Bläddra i källkod

Fix camera port diagnostic for A1/P1 printers (#1799)

maziggy 2 månader sedan
förälder
incheckning
8e99b0c86d
48 ändrade filer med 5012 tillägg och 151 borttagningar
  1. 4 0
      CHANGELOG.md
  2. 1 1
      README.md
  3. 43 0
      backend/app/api/routes/inventory.py
  4. 111 5
      backend/app/api/routes/library.py
  5. 300 0
      backend/app/api/routes/library_tags.py
  6. 28 0
      backend/app/api/routes/system.py
  7. 34 0
      backend/app/core/database.py
  8. 102 0
      backend/app/core/local_config.py
  9. 2 0
      backend/app/main.py
  10. 48 0
      backend/app/models/library.py
  11. 9 5
      backend/app/models/notification_template.py
  12. 78 0
      backend/app/schemas/library.py
  13. 71 8
      backend/app/services/notification_service.py
  14. 26 4
      backend/app/services/printer_diagnostic.py
  15. 133 0
      backend/tests/integration/test_library_api.py
  16. 288 0
      backend/tests/integration/test_library_tags_api.py
  17. 188 0
      backend/tests/integration/test_spool_by_tag_lookup.py
  18. 130 0
      backend/tests/integration/test_system_api.py
  19. 196 0
      backend/tests/unit/services/test_notification_service.py
  20. 23 1
      backend/tests/unit/services/test_printer_diagnostic.py
  21. 148 0
      backend/tests/unit/test_local_config.py
  22. 4 0
      backend/tests/unit/test_route_auth_coverage.py
  23. 146 0
      backend/tests/unit/test_user_print_template_rename_migration.py
  24. 1166 98
      frontend/package-lock.json
  25. 2 0
      frontend/package.json
  26. 107 0
      frontend/src/__tests__/components/BulkTagsPickerModal.test.tsx
  27. 24 0
      frontend/src/__tests__/components/ConnectionDiagnosticModal.test.tsx
  28. 58 0
      frontend/src/__tests__/components/FolderReadmePanel.test.tsx
  29. 97 0
      frontend/src/__tests__/components/LibraryTagsModal.test.tsx
  30. 67 0
      frontend/src/api/client.ts
  31. 260 0
      frontend/src/components/BulkTagsPickerModal.tsx
  32. 8 2
      frontend/src/components/ConnectionDiagnostic.tsx
  33. 101 0
      frontend/src/components/FolderReadmePanel.tsx
  34. 283 0
      frontend/src/components/LibraryTagsModal.tsx
  35. 36 1
      frontend/src/i18n/index.ts
  36. 45 2
      frontend/src/i18n/locales/de.ts
  37. 45 2
      frontend/src/i18n/locales/en.ts
  38. 45 2
      frontend/src/i18n/locales/es.ts
  39. 45 2
      frontend/src/i18n/locales/fr.ts
  40. 45 2
      frontend/src/i18n/locales/it.ts
  41. 45 2
      frontend/src/i18n/locales/ja.ts
  42. 45 2
      frontend/src/i18n/locales/ko.ts
  43. 45 2
      frontend/src/i18n/locales/pt-BR.ts
  44. 45 2
      frontend/src/i18n/locales/tr.ts
  45. 45 2
      frontend/src/i18n/locales/zh-CN.ts
  46. 45 2
      frontend/src/i18n/locales/zh-TW.ts
  47. 186 4
      frontend/src/pages/FileManagerPage.tsx
  48. 9 0
      frontend/src/utils/libraryTagsQuery.ts

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 4 - 0
CHANGELOG.md


+ 1 - 1
README.md

@@ -157,7 +157,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 - Skip objects during print
 - AMS slot RFID re-read
 - **AMS slot Load / Unload from the printer card** — Hover any AMS slot or external spool, click the menu button, and load that tray or unload the currently-loaded one without going to the touchscreen; supports dual-extruder H2D (Ext-L / Ext-R drive their own nozzle)
-- **AMS Filament Backup status + control** — Mirrors BambuStudio's per-printer "AMS Filament Backup" auto-switch (when a spool runs out, the printer rolls over to a same-material spool in another slot). A small badge in the Filaments section header on each printer card shows the live state (blue circular-arrow icon = ON, dim = OFF, "?" = A1 family with no `cfg` field yet); click to toggle. State syncs in real time whether you toggled from Bambuddy, BambuStudio, or the printer's touchscreen. Bambuddy's **Prefer Lowest Remaining Filament** sort now respects this setting — when Backup is OFF the dispatcher skips the prefer-lowest sort entirely so it won't reach for a near-empty spool the printer can't roll off of.
+- **AMS Filament Backup status + control with pair view** — Mirrors BambuStudio's per-printer "AMS Filament Backup" auto-switch (when a spool runs out, the printer rolls over to a same-preset, same-colour spool in another slot). A small badge in the Filaments section header on each printer card shows the live state (blue circular-arrow icon = ON, dim = OFF, "?" = A1 family with no `cfg` field yet); click to open the AMS Filament Backup modal — a BambuStudio Auto Refill-style ring graphic per backup pair, with the filament colour as the ring fill and member slot labels (e.g. `A·1`, `B·3`) on contrast-aware pills around the band. Dual-extruder printers (H2D / H2C / X2D) carry an `R` / `L` badge per ring because the firmware can't cross extruders. State syncs in real time whether you toggled from Bambuddy, BambuStudio, or the printer's touchscreen. Bambuddy's "insufficient filament" check is **backup-aware**: when Backup is ON, the deficit check pools remaining grams across same-`(preset, colour)` spools on the printer, so the warning doesn't fire spuriously when the firmware will swap to a peer mid-print (#1762). Bambuddy's **Prefer Lowest Remaining Filament** sort also respects the toggle — when Backup is OFF the dispatcher skips the prefer-lowest sort entirely so it won't reach for a near-empty spool the printer can't roll off of.
 - AMS slot configuration (model-filtered presets, K profiles, color picker, pre-population for configured slots)
 - AMS info card (hover for serial number, firmware version) with custom friendly names that persist across printers
 - **AMS remote drying** — Start, monitor, and stop drying sessions for AMS 2 Pro and AMS-HT directly from the Printers page with filament-based temperature/duration presets, optional spool rotation; automatic PSU detection and HMS power error reporting

+ 43 - 0
backend/app/api/routes/inventory.py

@@ -1181,6 +1181,49 @@ async def import_spools_csv(
     )
 
 
+@router.get("/spools/by-tag", response_model=SpoolResponse)
+async def get_spool_by_tag(
+    tray_uuid: str | None = None,
+    tag_uid: str | None = None,
+    include_archived: bool = False,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequireAnyPermissionIfAuthEnabled(Permission.INVENTORY_READ, Permission.INVENTORY_UPDATE),
+):
+    """Find a single spool by its NFC ``tray_uuid`` and/or ``tag_uid``.
+
+    Lets NFC inventory integrations dedupe a scan without listing the whole
+    inventory. ``tray_uuid`` is the primary identifier (it matches the value the
+    AMS reports over MQTT), so it is tried first; ``tag_uid`` is the fallback.
+    At least one identifier must be supplied. Returns 404 when nothing matches.
+
+    Accepts ``inventory:read`` OR ``inventory:update`` so a Manage-Inventory API
+    key (which has ``inventory:update`` via ``can_manage_inventory``) can read a
+    spool back without widening the global ``INVENTORY_READ`` scope mapping (#1663).
+    """
+    normalized_tray_uuid = normalize_tray_uuid(tray_uuid) or None
+    normalized_tag_uid = normalize_tag_uid(tag_uid) or None
+
+    if not normalized_tray_uuid and not normalized_tag_uid:
+        raise HTTPException(400, "Provide tray_uuid and/or tag_uid")
+
+    base_query = select(Spool).options(selectinload(Spool.k_profiles))
+    if not include_archived:
+        base_query = base_query.where(Spool.archived_at.is_(None))
+
+    for column, value in (
+        (Spool.tray_uuid, normalized_tray_uuid),
+        (Spool.tag_uid, normalized_tag_uid),
+    ):
+        if not value:
+            continue
+        result = await db.execute(base_query.where(func.upper(column) == value).order_by(Spool.id))
+        spool = result.scalars().first()
+        if spool:
+            return spool
+
+    raise HTTPException(404, "Spool not found")
+
+
 @router.get("/spools/{spool_id}", response_model=SpoolResponse)
 async def get_spool(
     spool_id: int,

+ 111 - 5
backend/app/api/routes/library.py

@@ -16,7 +16,7 @@ from pathlib import Path
 
 from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
 from fastapi.responses import FileResponse as FastAPIFileResponse
-from sqlalchemy import func, select
+from sqlalchemy import distinct, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
@@ -31,7 +31,7 @@ from backend.app.core.database import async_session, get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.tasks import spawn_background_task
 from backend.app.models.archive import PrintArchive
-from backend.app.models.library import LibraryFile, LibraryFolder
+from backend.app.models.library import LibraryFile, LibraryFileTag, LibraryFolder
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.project import Project
 from backend.app.models.user import User
@@ -54,9 +54,11 @@ from backend.app.schemas.library import (
     FileUpdate,
     FileUploadResponse,
     FolderCreate,
+    FolderReadmeResponse,
     FolderResponse,
     FolderTreeItem,
     FolderUpdate,
+    TagSummary,
     ZipExtractError,
     ZipExtractResponse,
     ZipExtractResult,
@@ -1035,6 +1037,72 @@ async def get_folder(
     )
 
 
+_README_BYTES_CAP = 512 * 1024  # 512 KiB — model descriptions don't need more
+_README_PREFERRED_STEMS = ("readme", "description")
+
+
+@router.get("/folders/{folder_id}/readme", response_model=FolderReadmeResponse)
+async def get_folder_readme(
+    folder_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
+):
+    """Return the first markdown description file for a folder (#1268).
+
+    Picks ``README.md`` / ``readme.md`` / ``description.md`` first (any case),
+    otherwise the alphabetically-first ``*.md`` in the folder. 404 when no
+    markdown file is present so the FE can hide the side panel.
+    """
+    user, can_read_all = auth_result
+
+    folder_row = await db.execute(select(LibraryFolder.id).where(LibraryFolder.id == folder_id))
+    if folder_row.scalar_one_or_none() is None:
+        raise HTTPException(status_code=404, detail="Folder not found")
+
+    query = LibraryFile.active().where(
+        LibraryFile.folder_id == folder_id,
+        func.lower(LibraryFile.filename).like("%.md"),
+    )
+    if user is not None and not can_read_all:
+        query = query.where(LibraryFile.created_by_id == user.id)
+    result = await db.execute(query)
+    candidates = result.scalars().all()
+    if not candidates:
+        raise HTTPException(status_code=404, detail="No markdown description in folder")
+
+    def sort_key(f: LibraryFile) -> tuple[int, str]:
+        stem = os.path.splitext(f.filename.lower())[0]
+        try:
+            return (_README_PREFERRED_STEMS.index(stem), f.filename.lower())
+        except ValueError:
+            return (len(_README_PREFERRED_STEMS), f.filename.lower())
+
+    pick = sorted(candidates, key=sort_key)[0]
+
+    abs_path = to_absolute_path(pick.file_path)
+    if not abs_path or not abs_path.exists():
+        raise HTTPException(status_code=404, detail="Markdown file missing on disk")
+
+    try:
+        raw = abs_path.read_bytes()
+    except OSError as e:
+        logger.warning("Folder readme read failed for %s: %s", abs_path, e)
+        raise HTTPException(status_code=500, detail="Could not read markdown file") from None
+
+    truncated = len(raw) > _README_BYTES_CAP
+    if truncated:
+        raw = raw[:_README_BYTES_CAP]
+    # `errors="replace"` so a single bad byte never blanks the panel.
+    content = raw.decode("utf-8", errors="replace")
+
+    return FolderReadmeResponse(filename=pick.filename, content=content, truncated=truncated)
+
+
 @router.put("/folders/{folder_id}", response_model=FolderResponse)
 async def update_folder(
     folder_id: int,
@@ -1722,6 +1790,8 @@ async def list_files(
     include_root: bool = True,
     internal_only: bool = False,
     external_only: bool = False,
+    recursive: bool = False,
+    tag_ids: list[int] = Query(default_factory=list),
     db: AsyncSession = Depends(get_db),
     auth_result: tuple[User | None, bool] = Depends(
         require_ownership_permission(
@@ -1743,6 +1813,16 @@ async def list_files(
         external_only: Restrict the result to files under external folders
                        (`is_external=True`) — the symmetric combined view for users with
                        multiple linked external sources (#1621).
+        recursive: When combined with ``folder_id``, also include files in every
+                   descendant subfolder (#1268). Implemented via a recursive CTE
+                   that walks ``library_folders.parent_id``. Default off so
+                   existing callers (folder browsing, etc.) keep their narrow
+                   single-folder semantics.
+        tag_ids: Restrict the listing to files carrying ALL of these tags
+                 (AND semantics, #1268). When non-empty the folder filter is
+                 intentionally bypassed — tags are cross-cutting and the user
+                 wants "every file with this tag" regardless of where it lives.
+                 ``recursive`` becomes irrelevant in that case.
     """
     if internal_only and external_only:
         raise HTTPException(
@@ -1751,11 +1831,36 @@ async def list_files(
         )
 
     user, can_read_all = auth_result
-    query = LibraryFile.active().options(selectinload(LibraryFile.created_by))
+    query = LibraryFile.active().options(
+        selectinload(LibraryFile.created_by),
+        selectinload(LibraryFile.tags),
+    )
     if user is not None and not can_read_all:
         query = query.where(LibraryFile.created_by_id == user.id)
 
-    if folder_id is not None:
+    if tag_ids:
+        # Cross-cutting filter — every requested tag must be present on the
+        # file. JOIN + GROUP BY + HAVING COUNT(DISTINCT) is portable across
+        # SQLite and Postgres without dialect tricks. We deliberately skip
+        # the folder / project / include_root scoping below so the result
+        # is the global "all files carrying these tags".
+        unique_tag_ids = list(dict.fromkeys(tag_ids))
+        query = (
+            query.join(LibraryFileTag, LibraryFileTag.file_id == LibraryFile.id)
+            .where(LibraryFileTag.tag_id.in_(unique_tag_ids))
+            .group_by(LibraryFile.id)
+            .having(func.count(distinct(LibraryFileTag.tag_id)) == len(unique_tag_ids))
+        )
+    elif folder_id is not None and recursive:
+        # Walk the subtree starting at folder_id and collect every descendant
+        # id. Recursive CTE works on both SQLite (>=3.8.3, shipped 2014) and
+        # Postgres without dialect branching.
+        roots = (
+            select(LibraryFolder.id).where(LibraryFolder.id == folder_id).cte(name="folder_descendants", recursive=True)
+        )
+        descendants = roots.union_all(select(LibraryFolder.id).join(roots, LibraryFolder.parent_id == roots.c.id))
+        query = query.where(LibraryFile.folder_id.in_(select(descendants.c.id)))
+    elif folder_id is not None:
         query = query.where(LibraryFile.folder_id == folder_id)
     elif project_id is not None:
         # Single join instead of one query per folder (avoids N+1 pattern)
@@ -1771,7 +1876,7 @@ async def list_files(
 
     query = query.order_by(LibraryFile.filename)
     result = await db.execute(query)
-    files = result.scalars().all()
+    files = result.scalars().unique().all() if tag_ids else result.scalars().all()
 
     # Get duplicate counts
     hash_counts = {}
@@ -1819,6 +1924,7 @@ async def list_files(
                 print_time_seconds=print_time,
                 filament_used_grams=filament_grams,
                 sliced_for_model=sliced_for_model,
+                tags=[TagSummary(id=t.id, name=t.name) for t in f.tags],
             )
         )
 

+ 300 - 0
backend/app/api/routes/library_tags.py

@@ -0,0 +1,300 @@
+"""Library tag catalog + per-file assignment endpoints (#1268).
+
+Tags are global cross-cutting labels for library files — one catalog per
+install, no per-user partitioning. Designed as the orthogonal complement to
+folders: folders express hierarchy, tags express attributes ("toy",
+"kid-safe", "petg-only"). The reporter (#1268) and at least one upvoter
+asked for them; the design decisions were locked with @maziggy:
+
+* tags apply to files only (folders already express hierarchy)
+* the tag filter on the file list intentionally IGNORES the selected folder
+  so "show me every toy regardless of where it lives" works (multi-tag = AND)
+* bulk-tagging from the multi-select toolbar ships in v1
+* no auto-tags from 3MF metadata; user-authored only
+* no color, no icon — label-only chips
+
+Permission model:
+
+* **Catalog mutations** (POST / PATCH / DELETE on ``/library/tags``) require
+  :attr:`Permission.LIBRARY_UPDATE_ALL` because the catalog is global —
+  ownership-aware update isn't meaningful for a row no user owns.
+* **Bulk assignment** is gated by the existing
+  :attr:`Permission.LIBRARY_UPDATE_ALL` / :attr:`Permission.LIBRARY_UPDATE_OWN`
+  pair so a ``*_OWN`` user can only re-tag files they created.
+* **GET** is gated by :attr:`Permission.LIBRARY_READ_ALL` /
+  :attr:`Permission.LIBRARY_READ_OWN` — ``*_OWN`` callers see every catalog
+  row (it's just labels), but ``file_count`` is filtered to their own files.
+"""
+
+from __future__ import annotations
+
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import delete, distinct, func, select
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import require_ownership_permission, require_permission_if_auth_enabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.library import LibraryFile, LibraryFileTag, LibraryTag
+from backend.app.models.user import User
+from backend.app.schemas.library import (
+    TagBulkAssignRequest,
+    TagBulkAssignResponse,
+    TagCreate,
+    TagResponse,
+    TagUpdate,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/library/tags", tags=["library-tags"])
+
+
+def _name_key(name: str) -> str:
+    """Case-insensitive uniqueness key — LOWER(TRIM(name)).
+
+    Mirrors the same convention used by Locations (#1505) so the catalog
+    can't end up with "Toys" + "toys" + " TOYS " as separate rows. Empty
+    string after stripping is rejected by Pydantic min_length, so this
+    helper trusts its input.
+    """
+    return name.strip().lower()
+
+
+@router.get("", response_model=list[TagResponse])
+@router.get("/", response_model=list[TagResponse])
+async def list_tags(
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
+) -> list[TagResponse]:
+    """List every tag in the catalog with the count of files using it.
+
+    Catalog rows are global, so a ``read_own`` caller still sees every tag
+    name — that's just the chip set the rest of the UI offers. But the
+    ``file_count`` projection is filtered to their own files so the number
+    matches what they'd see when they filter the listing by that tag.
+    """
+    user, can_read_all = auth_result
+
+    # Count distinct file_ids per tag via the association table joined back
+    # to LibraryFile so soft-deleted (trashed) files don't inflate the chip
+    # counts shown in the management modal.
+    file_filter = LibraryFile.deleted_at.is_(None)
+    if user is not None and not can_read_all:
+        file_filter = file_filter & (LibraryFile.created_by_id == user.id)
+
+    count_subq = (
+        select(
+            LibraryFileTag.tag_id.label("tag_id"),
+            func.count(distinct(LibraryFile.id)).label("file_count"),
+        )
+        .join(LibraryFile, LibraryFile.id == LibraryFileTag.file_id)
+        .where(file_filter)
+        .group_by(LibraryFileTag.tag_id)
+        .subquery()
+    )
+
+    query = (
+        select(LibraryTag, func.coalesce(count_subq.c.file_count, 0))
+        .outerjoin(count_subq, count_subq.c.tag_id == LibraryTag.id)
+        .order_by(func.lower(LibraryTag.name))
+    )
+    rows = (await db.execute(query)).all()
+    return [
+        TagResponse(
+            id=t.id,
+            name=t.name,
+            file_count=int(count),
+            created_at=t.created_at,
+            updated_at=t.updated_at,
+        )
+        for t, count in rows
+    ]
+
+
+@router.post("", response_model=TagResponse, status_code=201)
+@router.post("/", response_model=TagResponse, status_code=201)
+async def create_tag(
+    payload: TagCreate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
+) -> TagResponse:
+    """Create a tag. Case-insensitive dup → 409."""
+    key = _name_key(payload.name)
+    tag = LibraryTag(name=payload.name.strip(), name_key=key)
+    db.add(tag)
+    try:
+        await db.commit()
+    except IntegrityError:
+        # Race condition or actual dup — re-fetch the existing row so the
+        # caller can recover by reading the id from the 409 detail string
+        # if they want to. The body is consistent regardless of cause.
+        await db.rollback()
+        raise HTTPException(status_code=409, detail="Tag with this name already exists") from None
+    await db.refresh(tag)
+    return TagResponse(id=tag.id, name=tag.name, file_count=0, created_at=tag.created_at, updated_at=tag.updated_at)
+
+
+@router.patch("/{tag_id}", response_model=TagResponse)
+async def update_tag(
+    tag_id: int,
+    payload: TagUpdate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
+) -> TagResponse:
+    """Rename a tag. Case-insensitive dup → 409 (own-name no-op is allowed)."""
+    tag = (await db.execute(select(LibraryTag).where(LibraryTag.id == tag_id))).scalar_one_or_none()
+    if tag is None:
+        raise HTTPException(status_code=404, detail="Tag not found")
+
+    new_key = _name_key(payload.name)
+    if new_key != tag.name_key:
+        # Pre-check so the user gets a clean 409 instead of an IntegrityError
+        # that we'd then have to translate. The post-commit IntegrityError
+        # branch still catches the concurrent-create race.
+        existing = (await db.execute(select(LibraryTag).where(LibraryTag.name_key == new_key))).scalar_one_or_none()
+        if existing is not None and existing.id != tag.id:
+            raise HTTPException(status_code=409, detail="Tag with this name already exists")
+    tag.name = payload.name.strip()
+    tag.name_key = new_key
+    try:
+        await db.commit()
+    except IntegrityError:
+        await db.rollback()
+        raise HTTPException(status_code=409, detail="Tag with this name already exists") from None
+    await db.refresh(tag)
+
+    # Re-count files for the projection so the caller's modal shows the
+    # right number after the rename.
+    file_count = (
+        await db.execute(select(func.count(LibraryFileTag.file_id)).where(LibraryFileTag.tag_id == tag.id))
+    ).scalar_one()
+    return TagResponse(
+        id=tag.id,
+        name=tag.name,
+        file_count=int(file_count or 0),
+        created_at=tag.created_at,
+        updated_at=tag.updated_at,
+    )
+
+
+@router.delete("/{tag_id}", status_code=204)
+async def delete_tag(
+    tag_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
+) -> None:
+    """Delete a tag. Association rows ON DELETE CASCADE — files are untouched."""
+    tag = (await db.execute(select(LibraryTag).where(LibraryTag.id == tag_id))).scalar_one_or_none()
+    if tag is None:
+        raise HTTPException(status_code=404, detail="Tag not found")
+    await db.delete(tag)
+    await db.commit()
+
+
+@router.post("/bulk-assign", response_model=TagBulkAssignResponse)
+async def bulk_assign(
+    payload: TagBulkAssignRequest,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_UPDATE_ALL,
+            Permission.LIBRARY_UPDATE_OWN,
+        )
+    ),
+) -> TagBulkAssignResponse:
+    """Add / remove / replace tag assignments across multiple files.
+
+    Implemented as set-style operations against the association table —
+    cheaper than re-doing the M2M list per file and idempotent on retries.
+    A caller without ``*_UPDATE_ALL`` can only modify files they created
+    (per the existing ownership pair); silently-skipped files are
+    excluded from the response counts so the UI can detect partial
+    application.
+    """
+    user, can_update_all = auth_result
+
+    # Resolve the file scope FIRST — anything not visible to the caller is
+    # quietly dropped, so a malicious or buggy client can't tag files it
+    # doesn't own. This is the same posture as bulk-delete in
+    # library_trash.py.
+    file_q = select(LibraryFile.id).where(
+        LibraryFile.id.in_(payload.file_ids),
+        LibraryFile.deleted_at.is_(None),
+    )
+    if user is not None and not can_update_all:
+        file_q = file_q.where(LibraryFile.created_by_id == user.id)
+    file_ids = list((await db.execute(file_q)).scalars().all())
+    if not file_ids:
+        return TagBulkAssignResponse(files_updated=0, associations_added=0, associations_removed=0)
+
+    # Validate tag ids exist. Unknown tag_ids are silently dropped from
+    # the operation rather than raising — matches the bulk-trash shape
+    # and keeps a partial-success result usable.
+    tag_ids: list[int] = []
+    if payload.tag_ids:
+        tag_ids = list(
+            (await db.execute(select(LibraryTag.id).where(LibraryTag.id.in_(payload.tag_ids)))).scalars().all()
+        )
+
+    added = 0
+    removed = 0
+
+    if payload.action == "add":
+        if not tag_ids:
+            return TagBulkAssignResponse(files_updated=0, associations_added=0, associations_removed=0)
+        # Insert (file_id, tag_id) for every pair that doesn't already exist.
+        # We could use INSERT ... ON CONFLICT DO NOTHING for Postgres + SQLite
+        # 3.24+ but the explicit pre-check keeps the SQLAlchemy core dialect
+        # neutral and lets us count what actually got added.
+        existing = set(
+            (
+                await db.execute(
+                    select(LibraryFileTag.file_id, LibraryFileTag.tag_id).where(
+                        LibraryFileTag.file_id.in_(file_ids),
+                        LibraryFileTag.tag_id.in_(tag_ids),
+                    )
+                )
+            ).all()
+        )
+        to_insert = [
+            {"file_id": fid, "tag_id": tid} for fid in file_ids for tid in tag_ids if (fid, tid) not in existing
+        ]
+        if to_insert:
+            await db.execute(LibraryFileTag.__table__.insert(), to_insert)
+            added = len(to_insert)
+    elif payload.action == "remove":
+        if not tag_ids:
+            return TagBulkAssignResponse(files_updated=0, associations_added=0, associations_removed=0)
+        result = await db.execute(
+            delete(LibraryFileTag).where(
+                LibraryFileTag.file_id.in_(file_ids),
+                LibraryFileTag.tag_id.in_(tag_ids),
+            )
+        )
+        removed = int(result.rowcount or 0)
+    elif payload.action == "replace":
+        # Strip everything currently on these files, then INSERT the new set.
+        del_result = await db.execute(delete(LibraryFileTag).where(LibraryFileTag.file_id.in_(file_ids)))
+        removed = int(del_result.rowcount or 0)
+        if tag_ids:
+            await db.execute(
+                LibraryFileTag.__table__.insert(),
+                [{"file_id": fid, "tag_id": tid} for fid in file_ids for tid in tag_ids],
+            )
+            added = len(file_ids) * len(tag_ids)
+
+    await db.commit()
+    return TagBulkAssignResponse(
+        files_updated=len(file_ids),
+        associations_added=added,
+        associations_removed=removed,
+    )

+ 28 - 0
backend/app/api/routes/system.py

@@ -16,6 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import APP_VERSION, settings
 from backend.app.core.database import get_db
+from backend.app.core.local_config import read_local_toml, read_ntp_gate
 from backend.app.core.permissions import Permission
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
@@ -603,3 +604,30 @@ async def get_system_health(
     """
     sensitive_strings = await collect_sensitive_strings(db)
     return await asyncio.to_thread(scan_logs, sensitive_strings=sensitive_strings)
+
+
+@router.get("/appliance")
+async def get_appliance_defaults():
+    """Expose appliance-set state for the SPA's bootstrap surface.
+
+    Two file sources, both optional and silently degraded when absent:
+
+    - ``/etc/bambuddy/local.toml`` — hostname / timezone / locale the
+      firstboot wizard collected.
+    - ``/run/bambuddy/time-synced`` — chrony NTP gate state. The RPi 5 has
+      no battery-backed RTC, so on a fresh boot the clock is wrong until
+      ntp-gate.sh writes "ok" (or "warning" if 3-minute timeout elapsed).
+      A warning state means JWT expiries and TLS validity windows may be
+      misaligned; the UI should surface this.
+
+    No auth required — the frontend bootstrap reads this BEFORE auth might
+    be set up, and the contents are user-set defaults plus a public sync
+    flag (no secrets).
+    """
+    config = read_local_toml()
+    return {
+        "hostname": config.get("hostname"),
+        "timezone": config.get("timezone"),
+        "locale": config.get("locale"),
+        "time_synced": read_ntp_gate(),
+    }

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

@@ -3079,6 +3079,40 @@ async def run_migrations(conn):
             "ALTER TABLE notification_providers ADD COLUMN on_ai_failure_detection BOOLEAN DEFAULT false",
         )
 
+    # Migration: Disambiguate the four ``user_print_*`` notification template
+    # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
+    await _migrate_rename_user_print_template_names(conn)
+
+
+_USER_PRINT_TEMPLATE_RENAMES: tuple[tuple[str, str, str], ...] = (
+    ("user_print_start", "User Print Started", "User Print Started Email"),
+    ("user_print_complete", "User Print Completed", "User Print Completed Email"),
+    ("user_print_failed", "User Print Failed", "User Print Failed Email"),
+    ("user_print_stopped", "User Print Stopped", "User Print Stopped Email"),
+)
+
+
+async def _migrate_rename_user_print_template_names(conn) -> None:
+    """Append " Email" to the four ``user_print_*`` notification template names (#1792).
+
+    The provider-level "Print Completed" and the per-user "User Print Completed"
+    rows were visually indistinguishable in the Message Templates list because
+    the seed name lacked the suffix that the EVENT_NAMES display map in
+    routes/notification_templates.py already uses ("User Print Completed Email").
+
+    Renames only rows where ``name`` is still the old default — admins who
+    renamed the template themselves keep their custom name. Standard SQL
+    UPDATE works on both SQLite and Postgres.
+    """
+    from sqlalchemy import text
+
+    async with conn.begin_nested():
+        for event_type, old_name, new_name in _USER_PRINT_TEMPLATE_RENAMES:
+            await conn.execute(
+                text("UPDATE notification_templates SET name = :new WHERE event_type = :et AND name = :old"),
+                {"new": new_name, "et": event_type, "old": old_name},
+            )
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

+ 102 - 0
backend/app/core/local_config.py

@@ -0,0 +1,102 @@
+"""
+Small readers for appliance-set state files.
+
+Two distinct surfaces, same shape (defensive, silent on missing files,
+side-effect-free):
+
+- ``read_local_toml`` reads ``/etc/bambuddy/local.toml`` (the file the
+  appliance setup wizard writes during firstboot with the user's hostname,
+  timezone, and locale).
+- ``read_ntp_gate`` reads ``/run/bambuddy/time-synced`` (the appliance's
+  ntp-gate.sh signals time-sync state here once chrony reports sync, or
+  when the 3-minute timeout elapses with a "warning" marker).
+
+Universal across install shapes:
+
+- On the Bambuddy Appliance: both files exist by the time bambuddy.service
+  starts; we surface their values to the frontend.
+- On Docker / manual installs: both files are absent; we degrade silently.
+
+These readers are read-only and side-effect-free. They do NOT call
+hostnamectl / timedatectl / chronyc — system-state changes are the
+appliance's firstboot.sh responsibility (root, runs before this process
+exists). Here we just expose state so the frontend can render accordingly.
+"""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from typing import Literal, TypedDict
+
+import tomllib
+
+log = logging.getLogger(__name__)
+
+DEFAULT_PATH = Path("/etc/bambuddy/local.toml")
+DEFAULT_NTP_GATE_PATH = Path("/run/bambuddy/time-synced")
+
+# Three states: synced ("ok"), gated-and-timed-out ("warning"), or unknown (None).
+TimeSyncState = Literal["ok", "warning"] | None
+
+
+class LocalConfig(TypedDict, total=False):
+    hostname: str
+    timezone: str
+    locale: str
+
+
+def read_local_toml(path: Path = DEFAULT_PATH) -> LocalConfig:
+    """Read the appliance local.toml. Missing / invalid file returns empty dict.
+
+    Only the keys actually present in the file are returned — the caller checks
+    `if "locale" in config:` rather than relying on defaults. Non-string values
+    are dropped with a warning to keep this defensive on a hand-edited file.
+    """
+    if not path.is_file():
+        return {}
+    try:
+        with path.open("rb") as f:
+            data = tomllib.load(f)
+    except (OSError, tomllib.TOMLDecodeError) as exc:
+        log.warning("local.toml at %s could not be parsed: %s", path, exc)
+        return {}
+
+    result: LocalConfig = {}
+    for key in ("hostname", "timezone", "locale"):
+        value = data.get(key)
+        if value is None:
+            continue
+        if not isinstance(value, str):
+            log.warning("local.toml: %r is %s, expected str — ignoring", key, type(value).__name__)
+            continue
+        result[key] = value  # type: ignore[literal-required]
+    return result
+
+
+def read_ntp_gate(path: Path = DEFAULT_NTP_GATE_PATH) -> TimeSyncState:
+    """Read the appliance NTP gate file. Returns "ok", "warning", or None.
+
+    Wire contract with bambuddy-appliance/firstboot/ntp-gate.sh:
+      - File absent: gate hasn't been evaluated yet, or this isn't an appliance
+        install. Caller should treat as "unknown / don't gate."
+      - File content starts with "ok": chrony reported sync within 3 minutes.
+      - File content starts with "warning": 3-minute timeout elapsed without
+        sync. The user has already waited and the wizard proceeded with a
+        degraded clock — auth tokens may have incorrect expiry, TLS certs may
+        fail validation. UI should surface this.
+      - Anything else: defensive fall-through to None.
+    """
+    try:
+        body = path.read_text(errors="replace").strip()
+    except FileNotFoundError:
+        return None
+    except OSError as exc:
+        log.warning("ntp-gate file at %s could not be read: %s", path, exc)
+        return None
+
+    if body.startswith("ok"):
+        return "ok"
+    if body.startswith("warning"):
+        return "warning"
+    return None

+ 2 - 0
backend/app/main.py

@@ -37,6 +37,7 @@ from backend.app.api.routes import (
     kprofiles,
     labels,
     library,
+    library_tags,
     library_trash,
     local_backup,
     local_presets,
@@ -6614,6 +6615,7 @@ app.include_router(camera.router, prefix=app_settings.api_prefix)
 app.include_router(external_links.router, prefix=app_settings.api_prefix)
 app.include_router(projects.router, prefix=app_settings.api_prefix)
 app.include_router(library.router, prefix=app_settings.api_prefix)
+app.include_router(library_tags.router, prefix=app_settings.api_prefix)
 app.include_router(library_trash.router, prefix=app_settings.api_prefix)
 app.include_router(slice_jobs.router, prefix=app_settings.api_prefix)
 app.include_router(slicer_presets.router, prefix=app_settings.api_prefix)

+ 48 - 0
backend/app/models/library.py

@@ -106,6 +106,13 @@ class LibraryFile(Base):
     folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files")
     project: Mapped["Project | None"] = relationship()
     created_by: Mapped["User | None"] = relationship()
+    # Tags (#1268). M2M via library_file_tags. Loaded explicitly via
+    # ``selectinload`` in list_files so each row in the listing carries its
+    # chip set without N+1 fetches.
+    tags: Mapped[list["LibraryTag"]] = relationship(
+        secondary="library_file_tags",
+        back_populates="files",
+    )
 
     @classmethod
     def active(cls) -> "Select[tuple[LibraryFile]]":
@@ -119,6 +126,47 @@ class LibraryFile(Base):
         return select(cls).where(cls.deleted_at.is_(None))
 
 
+class LibraryTag(Base):
+    """User-authored cross-cutting label for library files (#1268).
+
+    Folders express hierarchy; tags express orthogonal attributes ("toy",
+    "kid-safe", "petg-only"). Catalog is global (one tag set per install)
+    — the multi-user "private tags" case is not in v1 scope. ``name_key``
+    is ``LOWER(TRIM(name))`` so "Toys" / "toys" / "  TOYS  " all collide
+    on the UNIQUE index and the route returns 409 instead of silently
+    creating a duplicate.
+    """
+
+    __tablename__ = "library_tags"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(64), nullable=False)
+    name_key: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+
+    files: Mapped[list["LibraryFile"]] = relationship(
+        secondary="library_file_tags",
+        back_populates="tags",
+    )
+
+
+class LibraryFileTag(Base):
+    """Association between library files and tags (#1268).
+
+    Composite PK so the same (file, tag) pair can't be inserted twice. Both
+    sides ON DELETE CASCADE: deleting a tag drops every association row,
+    deleting a file drops its tag links, and the catalog row survives so
+    other files keep their chip.
+    """
+
+    __tablename__ = "library_file_tags"
+
+    file_id: Mapped[int] = mapped_column(ForeignKey("library_files.id", ondelete="CASCADE"), primary_key=True)
+    tag_id: Mapped[int] = mapped_column(ForeignKey("library_tags.id", ondelete="CASCADE"), primary_key=True)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+
 from backend.app.models.archive import PrintArchive  # noqa: E402, F811
 from backend.app.models.project import Project  # noqa: E402, F811
 from backend.app.models.user import User  # noqa: E402, F811

+ 9 - 5
backend/app/models/notification_template.py

@@ -195,28 +195,32 @@ DEFAULT_TEMPLATES = [
         "title_template": "Stock Break Risk: {material}",
         "body_template": "{material} ({brand}) will run out before replenishment arrives.\nStock: {stock_g}g | Rate: {rate_g_day}g/day | Lead time: {lead_time_days}d\nOnly {days_left}d of stock remaining — order immediately.",
     },
-    # User email notification templates (sent to the print job owner)
+    # User email notification templates (sent to the print job owner).
+    # Names include " Email" so they aren't confused with the provider-level
+    # `print_*` templates above, which share the same body shape but are
+    # broadcast to admin-configured providers (ntfy/pushover/telegram/discord/
+    # etc.) rather than mailed to a specific user.
     {
         "event_type": "user_print_start",
-        "name": "User Print Started",
+        "name": "User Print Started Email",
         "title_template": "Your Print Has Started",
         "body_template": "Hello {username},\n\nYour print job has started on {printer}.\n\nFile: {filename}\n\nYou will be notified when it completes.",
     },
     {
         "event_type": "user_print_complete",
-        "name": "User Print Completed",
+        "name": "User Print Completed Email",
         "title_template": "Your Print Is Complete",
         "body_template": "Hello {username},\n\nYour print job has completed on {printer}.\n\nFile: {filename}",
     },
     {
         "event_type": "user_print_failed",
-        "name": "User Print Failed",
+        "name": "User Print Failed Email",
         "title_template": "Your Print Has Failed",
         "body_template": "Hello {username},\n\nYour print job has failed on {printer}.\n\nFile: {filename}",
     },
     {
         "event_type": "user_print_stopped",
-        "name": "User Print Stopped",
+        "name": "User Print Stopped Email",
         "title_template": "Your Print Has Been Stopped",
         "body_template": "Hello {username},\n\nYour print job was stopped on {printer}.\n\nFile: {filename}",
     },

+ 78 - 0
backend/app/schemas/library.py

@@ -63,6 +63,19 @@ class FolderResponse(BaseModel):
         from_attributes = True
 
 
+class FolderReadmeResponse(BaseModel):
+    """Markdown sidebar payload for a folder (#1268).
+
+    ``filename`` is the on-disk name (so the UI can show "README.md") and
+    ``content`` is the raw markdown — the FE renders it. ``truncated`` is
+    True when the source file was clipped at the size cap.
+    """
+
+    filename: str
+    content: str
+    truncated: bool
+
+
 class FolderTreeItem(BaseModel):
     """Schema for folder tree item (includes children)."""
 
@@ -166,6 +179,16 @@ class FileResponse(BaseModel):
         from_attributes = True
 
 
+class TagSummary(BaseModel):
+    """Compact tag projection — embedded in file listings (#1268)."""
+
+    id: int
+    name: str
+
+    class Config:
+        from_attributes = True
+
+
 class FileListResponse(BaseModel):
     """Schema for file list item (lighter than full response)."""
 
@@ -189,10 +212,65 @@ class FileListResponse(BaseModel):
     filament_used_grams: float | None = None
     sliced_for_model: str | None = None
 
+    # Tags assigned to this file (#1268). Empty list when the file has none —
+    # never null, so the FE can iterate without a guard.
+    tags: list[TagSummary] = []
+
+    class Config:
+        from_attributes = True
+
+
+# ============ Tag Schemas (#1268) ============
+
+
+class TagResponse(BaseModel):
+    """Tag with the count of files currently using it."""
+
+    id: int
+    name: str
+    file_count: int
+    created_at: datetime
+    updated_at: datetime
+
     class Config:
         from_attributes = True
 
 
+class TagCreate(BaseModel):
+    """Create a new tag (catalog row)."""
+
+    name: str = Field(..., min_length=1, max_length=64)
+
+
+class TagUpdate(BaseModel):
+    """Rename a tag. ``name`` is required — there's nothing else to update."""
+
+    name: str = Field(..., min_length=1, max_length=64)
+
+
+class TagBulkAssignRequest(BaseModel):
+    """Bulk tag assignment payload.
+
+    ``action='add'``      → append tags to every listed file (idempotent on dup).
+    ``action='remove'``   → strip the listed tags from every listed file.
+    ``action='replace'``  → REPLACE the tag set on every listed file with the
+                            exact set in ``tag_ids`` (omitting tag_ids clears
+                            them all).
+    """
+
+    file_ids: list[int] = Field(..., min_length=1)
+    tag_ids: list[int] = Field(default_factory=list)
+    action: str = Field("add", pattern="^(add|remove|replace)$")
+
+
+class TagBulkAssignResponse(BaseModel):
+    """Result of a bulk-assign call."""
+
+    files_updated: int
+    associations_added: int
+    associations_removed: int
+
+
 class FileMoveRequest(BaseModel):
     """Schema for moving files to a folder."""
 

+ 71 - 8
backend/app/services/notification_service.py

@@ -1,11 +1,13 @@
 """Notification service for sending push notifications via various providers."""
 
 import asyncio
+import html
 import json
 import logging
 import re
 import smtplib
 from datetime import datetime, timedelta, timezone
+from email.mime.image import MIMEImage
 from email.mime.multipart import MIMEMultipart
 from email.mime.text import MIMEText
 from typing import Any
@@ -410,8 +412,27 @@ class NotificationService:
         else:
             return False, f"HTTP {response.status_code}: {response.text[:200]}"
 
-    async def _send_email(self, config: dict, subject: str, body: str) -> tuple[bool, str]:
-        """Send notification via email (SMTP)."""
+    async def _send_email(
+        self,
+        config: dict,
+        subject: str,
+        body: str,
+        image_data: bytes | None = None,
+        finish_photo_url: str | None = None,
+    ) -> tuple[bool, str]:
+        """Send notification via email (SMTP).
+
+        Inline finish-photo embed is opt-in via the template: when the rendered
+        ``body`` contains the substituted ``{finish_photo_url}`` value AND the
+        finish-photo bytes are present, the message is built as
+        ``multipart/related`` wrapping a ``multipart/alternative`` (plain + HTML)
+        plus an inline ``MIMEImage`` with ``Content-ID: <bambuddy-finish-photo>``.
+        The HTML part replaces the URL with ``<img src="cid:...">``; the plain-
+        text part keeps the URL as a clickable link. When the template doesn't
+        reference ``{finish_photo_url}`` (or image bytes aren't available), the
+        original single-part text shape is used — no attachment, no surprise
+        inline image (#1792).
+        """
         smtp_server = config.get("smtp_server", "").strip()
         smtp_port = int(config.get("smtp_port", 587))
         username = config.get("username", "").strip()
@@ -429,12 +450,48 @@ class NotificationService:
         if auth_enabled and not all([username, password]):
             return False, "Username and password are required when authentication is enabled"
 
+        # Template-driven: only inline-embed when the user's template explicitly
+        # referenced {finish_photo_url} (so the URL appears in the rendered body)
+        # AND the photo bytes are available. Falls back to text-only otherwise.
+        inline_photo = bool(image_data and finish_photo_url and finish_photo_url in body)
+
         try:
-            msg = MIMEMultipart()
-            msg["From"] = from_email
-            msg["To"] = to_email
-            msg["Subject"] = f"[Bambuddy] {subject}"
-            msg.attach(MIMEText(body, "plain"))
+            if inline_photo:
+                # multipart/related → (multipart/alternative → text, html) + inline image
+                msg = MIMEMultipart("related")
+                msg["From"] = from_email
+                msg["To"] = to_email
+                msg["Subject"] = f"[Bambuddy] {subject}"
+
+                alt = MIMEMultipart("alternative")
+                alt.attach(MIMEText(body, "plain"))
+                # Build HTML body: escape the rendered body, then swap the
+                # escaped URL substring for an inline <img> referencing the
+                # MIMEImage we attach below. Done AFTER escape so the cid: URL
+                # we inject isn't re-escaped.
+                escaped_body = html.escape(body).replace("\n", "<br>\n")
+                escaped_url = html.escape(finish_photo_url)
+                img_tag = (
+                    '<img src="cid:bambuddy-finish-photo" '
+                    'alt="Printer camera snapshot" '
+                    'style="max-width:100%;height:auto;border:1px solid #ddd;border-radius:4px;">'
+                )
+                html_body = f"<html><body><p>{escaped_body.replace(escaped_url, img_tag)}</p></body></html>"
+                alt.attach(MIMEText(html_body, "html"))
+                msg.attach(alt)
+
+                img = MIMEImage(image_data, _subtype="jpeg")
+                # Angle-bracketed Content-ID per RFC 2392, referenced from HTML
+                # without the brackets via ``cid:bambuddy-finish-photo``.
+                img.add_header("Content-ID", "<bambuddy-finish-photo>")
+                img.add_header("Content-Disposition", "inline", filename="finish-photo.jpg")
+                msg.attach(img)
+            else:
+                msg = MIMEMultipart()
+                msg["From"] = from_email
+                msg["To"] = to_email
+                msg["Subject"] = f"[Bambuddy] {subject}"
+                msg.attach(MIMEText(body, "plain"))
 
             if security == "ssl":
                 # Direct SSL connection (typically port 465)
@@ -682,7 +739,13 @@ class NotificationService:
             elif provider.provider_type == "telegram":
                 return await self._send_telegram(config, f"*{title}*\n{message}", image_data=image_data)
             elif provider.provider_type == "email":
-                return await self._send_email(config, title, message)
+                # finish_photo_url is pulled from the rendered template variables
+                # so _send_email can detect whether the template referenced the
+                # URL and inline-embed the photo only in that case.
+                finish_photo_url = (variables or {}).get("finish_photo_url")
+                return await self._send_email(
+                    config, title, message, image_data=image_data, finish_photo_url=finish_photo_url
+                )
             elif provider.provider_type == "discord":
                 return await self._send_discord(config, title, message, image_data=image_data)
             elif provider.provider_type == "webhook":

+ 26 - 4
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.camera import get_camera_port
 from backend.app.services.discovery import is_running_in_docker
 from backend.app.services.printer_manager import printer_manager
 from backend.app.utils.printer_models import has_external_storage
@@ -26,6 +27,7 @@ logger = logging.getLogger(__name__)
 PORT_MQTT = 8883  # MQTT over TLS — control + status. Connection-critical.
 PORT_FTPS = 990  # FTPS — file upload; required to send prints.
 PORT_RTSPS = 322  # RTSPS — camera stream; optional.
+PORT_CHAMBER_IMAGE = 6000  # Chamber image protocol — A1/P1 camera stream; optional.
 
 _PORT_PROBE_TIMEOUT = 3.0
 
@@ -54,6 +56,19 @@ async def _check_port(ip: str, port: int, timeout: float = _PORT_PROBE_TIMEOUT)
         return False
 
 
+def _camera_port_for_printer(printer: Printer | None) -> tuple[int, str]:
+    """Return the model-specific camera diagnostic port and display protocol."""
+    if not printer:
+        return PORT_RTSPS, "RTSPS"
+    model = getattr(printer, "model", None)
+    if not model:
+        return PORT_RTSPS, "RTSPS"
+    camera_port = get_camera_port(model)
+    if camera_port == PORT_CHAMBER_IMAGE:
+        return camera_port, "Chamber Image"
+    return camera_port, "RTSPS"
+
+
 def _detect_docker_network_mode() -> str:
     """Detect Docker network mode.
 
@@ -118,15 +133,22 @@ async def run_connection_diagnostic(
     checks: list[DiagnosticCheck] = []
 
     # --- Port reachability (probed in parallel) ---
-    mqtt_ok, ftps_ok, rtsps_ok = await asyncio.gather(
+    camera_port, camera_protocol = _camera_port_for_printer(printer)
+    mqtt_ok, ftps_ok, camera_ok = await asyncio.gather(
         _check_port(ip_address, PORT_MQTT),
         _check_port(ip_address, PORT_FTPS),
-        _check_port(ip_address, PORT_RTSPS),
+        _check_port(ip_address, camera_port),
     )
-    # MQTT is connection-critical; FTPS/RTSPS only degrade printing/camera.
+    # MQTT is connection-critical; FTPS/camera only degrade printing/camera.
     checks.append(DiagnosticCheck(id="port_mqtt", status="pass" if mqtt_ok else "fail"))
     checks.append(DiagnosticCheck(id="port_ftps", status="pass" if ftps_ok else "warn"))
-    checks.append(DiagnosticCheck(id="port_rtsps", status="pass" if rtsps_ok else "warn"))
+    checks.append(
+        DiagnosticCheck(
+            id="port_rtsps",
+            status="pass" if camera_ok else "warn",
+            params={"port": camera_port, "protocol": camera_protocol},
+        )
+    )
 
     # --- Docker network mode ---
     network_mode: str | None = None

+ 133 - 0
backend/tests/integration/test_library_api.py

@@ -487,6 +487,139 @@ class TestLibraryFilesAPI:
         assert test_file["created_by_id"] == user.id
         assert test_file["created_by_username"] == "testuploader"
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_files_recursive_includes_subfolders(
+        self, async_client: AsyncClient, folder_factory, file_factory
+    ):
+        """#1268: ?recursive=true with folder_id must include every descendant.
+
+        Tree:
+            toys/             ← f_toys, direct file "robot_top.3mf"
+              cars/           ← child of toys, file "robot_car.3mf"
+                race/         ← grandchild, file "robot_race.3mf"
+            other/            ← unrelated, file "robot_other.3mf" (must NOT appear)
+        """
+        toys = await folder_factory(name="toys")
+        cars = await folder_factory(name="cars", parent_id=toys.id)
+        race = await folder_factory(name="race", parent_id=cars.id)
+        other = await folder_factory(name="other")
+
+        top = await file_factory(folder_id=toys.id, filename="robot_top.3mf")
+        mid = await file_factory(folder_id=cars.id, filename="robot_car.3mf")
+        deep = await file_factory(folder_id=race.id, filename="robot_race.3mf")
+        await file_factory(folder_id=other.id, filename="robot_other.3mf")
+
+        # Non-recursive: only the file directly under toys.
+        r = await async_client.get(f"/api/v1/library/files?folder_id={toys.id}")
+        assert r.status_code == 200
+        assert {f["id"] for f in r.json()} == {top.id}
+
+        # Recursive: toys + cars + race files, but NOT other/.
+        r = await async_client.get(f"/api/v1/library/files?folder_id={toys.id}&recursive=true")
+        assert r.status_code == 200
+        assert {f["id"] for f in r.json()} == {top.id, mid.id, deep.id}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_files_recursive_without_folder_id_is_noop(
+        self, async_client: AsyncClient, folder_factory, file_factory
+    ):
+        """recursive=true is meaningful only with folder_id — without it the
+        existing include_root branch handles scoping. Just confirming the new
+        param doesn't shadow that path."""
+        folder = await folder_factory()
+        f_in = await file_factory(folder_id=folder.id)
+        f_root = await file_factory()
+
+        r = await async_client.get("/api/v1/library/files?include_root=false&recursive=true")
+        assert r.status_code == 200
+        assert {f["id"] for f in r.json()} == {f_in.id, f_root.id}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_folder_readme_returns_first_markdown(
+        self, async_client: AsyncClient, folder_factory, file_factory
+    ):
+        """#1268: /folders/{id}/readme reads on-disk content of the first .md."""
+        folder = await folder_factory()
+        with tempfile.NamedTemporaryFile(suffix=".md", delete=False, mode="w", encoding="utf-8") as f:
+            f.write("# Robot\n\nA cute little robot.")
+            md_path = f.name
+        try:
+            await file_factory(
+                folder_id=folder.id,
+                filename="README.md",
+                file_path=md_path,
+                file_type="md",
+                file_size=Path(md_path).stat().st_size,
+            )
+            r = await async_client.get(f"/api/v1/library/folders/{folder.id}/readme")
+            assert r.status_code == 200
+            body = r.json()
+            assert body["filename"] == "README.md"
+            assert body["content"] == "# Robot\n\nA cute little robot."
+            assert body["truncated"] is False
+        finally:
+            import os
+
+            os.unlink(md_path)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_folder_readme_prefers_readme_over_other_md(
+        self, async_client: AsyncClient, folder_factory, file_factory
+    ):
+        """When the folder has multiple .md files, README.md / description.md
+        wins regardless of insertion order or filename case."""
+        folder = await folder_factory()
+        with tempfile.NamedTemporaryFile(suffix=".md", delete=False, mode="w", encoding="utf-8") as f:
+            f.write("notes notes notes")
+            notes_path = f.name
+        with tempfile.NamedTemporaryFile(suffix=".md", delete=False, mode="w", encoding="utf-8") as f:
+            f.write("the real one")
+            readme_path = f.name
+        try:
+            # notes.md inserted FIRST — naive ordering would pick this one.
+            await file_factory(
+                folder_id=folder.id,
+                filename="notes.md",
+                file_path=notes_path,
+                file_type="md",
+            )
+            await file_factory(
+                folder_id=folder.id,
+                filename="readme.md",  # lowercase to confirm case-insensitive match
+                file_path=readme_path,
+                file_type="md",
+            )
+            r = await async_client.get(f"/api/v1/library/folders/{folder.id}/readme")
+            assert r.status_code == 200
+            assert r.json()["filename"] == "readme.md"
+            assert r.json()["content"] == "the real one"
+        finally:
+            import os
+
+            os.unlink(notes_path)
+            os.unlink(readme_path)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_folder_readme_404_when_no_markdown(
+        self, async_client: AsyncClient, folder_factory, file_factory
+    ):
+        """No .md in the folder → 404 so the FE can hide the side panel."""
+        folder = await folder_factory()
+        await file_factory(folder_id=folder.id, filename="model.3mf", file_type="3mf")
+        r = await async_client.get(f"/api/v1/library/folders/{folder.id}/readme")
+        assert r.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_folder_readme_404_when_folder_missing(self, async_client: AsyncClient):
+        r = await async_client.get("/api/v1/library/folders/999999/readme")
+        assert r.status_code == 404
+
 
 class TestLibraryAddToQueueAPI:
     """Integration tests for /api/v1/library/files/add-to-queue endpoint."""

+ 288 - 0
backend/tests/integration/test_library_tags_api.py

@@ -0,0 +1,288 @@
+"""Integration tests for the library tag catalog + bulk-assign (#1268)."""
+
+import pytest
+from httpx import AsyncClient
+
+
+@pytest.fixture
+async def folder_factory(db_session):
+    """Minimal folder factory shared across the tests in this module."""
+    _counter = [0]
+
+    async def _create_folder(**kwargs):
+        from backend.app.models.library import LibraryFolder
+
+        _counter[0] += 1
+        defaults = {"name": f"Folder {_counter[0]}"}
+        defaults.update(kwargs)
+        f = LibraryFolder(**defaults)
+        db_session.add(f)
+        await db_session.commit()
+        await db_session.refresh(f)
+        return f
+
+    return _create_folder
+
+
+@pytest.fixture
+async def file_factory(db_session):
+    """Minimal file factory shared across the tests in this module."""
+    _counter = [0]
+
+    async def _create_file(**kwargs):
+        from backend.app.models.library import LibraryFile
+
+        _counter[0] += 1
+        defaults = {
+            "filename": f"file_{_counter[0]}.3mf",
+            "file_path": f"/test/file_{_counter[0]}.3mf",
+            "file_size": 100,
+            "file_type": "3mf",
+        }
+        defaults.update(kwargs)
+        f = LibraryFile(**defaults)
+        db_session.add(f)
+        await db_session.commit()
+        await db_session.refresh(f)
+        return f
+
+    return _create_file
+
+
+class TestLibraryTagCRUD:
+    """Catalog CRUD: create / list / rename / delete."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_tag_and_list(self, async_client: AsyncClient):
+        r = await async_client.post("/api/v1/library/tags", json={"name": "toy"})
+        assert r.status_code == 201
+        body = r.json()
+        assert body["name"] == "toy"
+        assert body["file_count"] == 0
+
+        r = await async_client.get("/api/v1/library/tags")
+        assert r.status_code == 200
+        names = [t["name"] for t in r.json()]
+        assert "toy" in names
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_tag_strips_whitespace(self, async_client: AsyncClient):
+        r = await async_client.post("/api/v1/library/tags", json={"name": "  kid-safe  "})
+        assert r.status_code == 201
+        assert r.json()["name"] == "kid-safe"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_duplicate_case_insensitive_409(self, async_client: AsyncClient):
+        """'Toys' / 'toys' / 'TOYS  ' all collide on name_key."""
+        r1 = await async_client.post("/api/v1/library/tags", json={"name": "Toys"})
+        assert r1.status_code == 201
+        for dup in ("toys", "TOYS", "  ToYs  "):
+            r = await async_client.post("/api/v1/library/tags", json={"name": dup})
+            assert r.status_code == 409, dup
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rename_tag(self, async_client: AsyncClient):
+        r = await async_client.post("/api/v1/library/tags", json={"name": "kidsafe"})
+        tag_id = r.json()["id"]
+        r = await async_client.patch(f"/api/v1/library/tags/{tag_id}", json={"name": "kid-safe"})
+        assert r.status_code == 200
+        assert r.json()["name"] == "kid-safe"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rename_collision_409(self, async_client: AsyncClient):
+        a = (await async_client.post("/api/v1/library/tags", json={"name": "a"})).json()
+        b = (await async_client.post("/api/v1/library/tags", json={"name": "b"})).json()
+        # Renaming b → A (case-insensitive collision with a) must fail.
+        r = await async_client.patch(f"/api/v1/library/tags/{b['id']}", json={"name": "A"})
+        assert r.status_code == 409
+        # Renaming a row to its own current name (round-trip with the same key)
+        # must NOT 409 — the pre-check excludes the tag itself.
+        r = await async_client.patch(f"/api/v1/library/tags/{a['id']}", json={"name": "a"})
+        assert r.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_tag_cascades_associations_keeps_file(self, async_client: AsyncClient, file_factory):
+        f = await file_factory()
+        tag = (await async_client.post("/api/v1/library/tags", json={"name": "x"})).json()
+        await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [f.id], "tag_ids": [tag["id"]], "action": "add"},
+        )
+        r = await async_client.delete(f"/api/v1/library/tags/{tag['id']}")
+        assert r.status_code == 204
+
+        # Tag list no longer contains it.
+        names = [t["name"] for t in (await async_client.get("/api/v1/library/tags")).json()]
+        assert "x" not in names
+        # File still listed (CASCADE only dropped the association row).
+        r = await async_client.get(
+            f"/api/v1/library/files?folder_id={f.folder_id}" if f.folder_id else "/api/v1/library/files"
+        )
+        assert any(item["id"] == f.id for item in r.json())
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_unknown_tag_404(self, async_client: AsyncClient):
+        r = await async_client.delete("/api/v1/library/tags/999999")
+        assert r.status_code == 404
+
+
+class TestLibraryTagBulkAssign:
+    """Bulk-assign: add / remove / replace + per-action assertions."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_is_idempotent(self, async_client: AsyncClient, file_factory):
+        f = await file_factory()
+        t = (await async_client.post("/api/v1/library/tags", json={"name": "t"})).json()
+        payload = {"file_ids": [f.id], "tag_ids": [t["id"]], "action": "add"}
+        r1 = await async_client.post("/api/v1/library/tags/bulk-assign", json=payload)
+        assert r1.status_code == 200
+        assert r1.json()["associations_added"] == 1
+        r2 = await async_client.post("/api/v1/library/tags/bulk-assign", json=payload)
+        # Second call adds 0 — pair already exists; route remains 200 not 409.
+        assert r2.status_code == 200
+        assert r2.json()["associations_added"] == 0
+        # And the file_count for the tag is still exactly 1.
+        tags = (await async_client.get("/api/v1/library/tags")).json()
+        assert next(x["file_count"] for x in tags if x["id"] == t["id"]) == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_remove_drops_only_listed_tags(self, async_client: AsyncClient, file_factory):
+        f = await file_factory()
+        a = (await async_client.post("/api/v1/library/tags", json={"name": "a"})).json()
+        b = (await async_client.post("/api/v1/library/tags", json={"name": "b"})).json()
+        await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [f.id], "tag_ids": [a["id"], b["id"]], "action": "add"},
+        )
+        # Remove only `a`. `b` should still be on the file.
+        r = await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [f.id], "tag_ids": [a["id"]], "action": "remove"},
+        )
+        assert r.status_code == 200
+        assert r.json()["associations_removed"] == 1
+        # Tag-filter listing by `b` still returns the file.
+        r = await async_client.get(f"/api/v1/library/files?tag_ids={b['id']}")
+        assert {x["id"] for x in r.json()} == {f.id}
+        r = await async_client.get(f"/api/v1/library/files?tag_ids={a['id']}")
+        assert {x["id"] for x in r.json()} == set()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_replace_with_empty_tag_set_clears(self, async_client: AsyncClient, file_factory):
+        f = await file_factory()
+        a = (await async_client.post("/api/v1/library/tags", json={"name": "a"})).json()
+        await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [f.id], "tag_ids": [a["id"]], "action": "add"},
+        )
+        # Replace with [] → file ends up with no tags.
+        r = await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [f.id], "tag_ids": [], "action": "replace"},
+        )
+        assert r.status_code == 200
+        assert r.json()["associations_removed"] == 1
+        # File listing shows empty tags array.
+        r = await async_client.get("/api/v1/library/files?include_root=false")
+        item = next(x for x in r.json() if x["id"] == f.id)
+        assert item["tags"] == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unknown_file_ids_silently_skipped(self, async_client: AsyncClient, file_factory):
+        """Unknown / inaccessible file ids must not 404 the whole call — the
+        caller may be racing a delete or have a stale selection. Counts reflect
+        what actually happened."""
+        f = await file_factory()
+        t = (await async_client.post("/api/v1/library/tags", json={"name": "t"})).json()
+        r = await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [f.id, 999999], "tag_ids": [t["id"]], "action": "add"},
+        )
+        assert r.status_code == 200
+        body = r.json()
+        assert body["files_updated"] == 1
+        assert body["associations_added"] == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_invalid_action_422(self, async_client: AsyncClient, file_factory):
+        f = await file_factory()
+        r = await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [f.id], "tag_ids": [], "action": "nuke"},
+        )
+        assert r.status_code == 422
+
+
+class TestLibraryTagFilter:
+    """list_files?tag_ids=… — AND semantics + folder bypass."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_and_semantics(self, async_client: AsyncClient, file_factory):
+        a_only = await file_factory(filename="a_only.3mf")
+        b_only = await file_factory(filename="b_only.3mf")
+        ab = await file_factory(filename="ab.3mf")
+        a = (await async_client.post("/api/v1/library/tags", json={"name": "A"})).json()
+        b = (await async_client.post("/api/v1/library/tags", json={"name": "B"})).json()
+        await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [a_only.id, ab.id], "tag_ids": [a["id"]], "action": "add"},
+        )
+        await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [b_only.id, ab.id], "tag_ids": [b["id"]], "action": "add"},
+        )
+        # Filter by A alone → a_only + ab
+        r = await async_client.get(f"/api/v1/library/files?tag_ids={a['id']}")
+        assert {x["id"] for x in r.json()} == {a_only.id, ab.id}
+        # Filter by A AND B → only ab
+        r = await async_client.get(f"/api/v1/library/files?tag_ids={a['id']}&tag_ids={b['id']}")
+        assert {x["id"] for x in r.json()} == {ab.id}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tag_filter_ignores_folder_id(self, async_client: AsyncClient, folder_factory, file_factory):
+        """Tag filter is cross-cutting — passing folder_id must NOT narrow the
+        result. Confirms decision #2 from the design discussion."""
+        folder_a = await folder_factory(name="A")
+        folder_b = await folder_factory(name="B")
+        in_a = await file_factory(folder_id=folder_a.id, filename="in_a.3mf")
+        in_b = await file_factory(folder_id=folder_b.id, filename="in_b.3mf")
+        tag = (await async_client.post("/api/v1/library/tags", json={"name": "x"})).json()
+        await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={
+                "file_ids": [in_a.id, in_b.id],
+                "tag_ids": [tag["id"]],
+                "action": "add",
+            },
+        )
+        # Pass folder_id=folder_a alongside tag_ids — file from folder_b must
+        # STILL appear because the tag filter overrides folder scoping.
+        r = await async_client.get(f"/api/v1/library/files?folder_id={folder_a.id}&tag_ids={tag['id']}")
+        assert {x["id"] for x in r.json()} == {in_a.id, in_b.id}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_file_listing_includes_tags_array(self, async_client: AsyncClient, file_factory):
+        f = await file_factory()
+        tag = (await async_client.post("/api/v1/library/tags", json={"name": "petg"})).json()
+        await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [f.id], "tag_ids": [tag["id"]], "action": "add"},
+        )
+        r = await async_client.get("/api/v1/library/files?include_root=false")
+        item = next(x for x in r.json() if x["id"] == f.id)
+        assert item["tags"] == [{"id": tag["id"], "name": "petg"}]

+ 188 - 0
backend/tests/integration/test_spool_by_tag_lookup.py

@@ -0,0 +1,188 @@
+"""By-tag spool lookup endpoint (#1663).
+
+``GET /api/v1/inventory/spools/by-tag`` lets NFC inventory integrations
+dedupe a scan by ``tray_uuid``/``tag_uid`` without listing the whole
+inventory, and is readable with either a read-status or a manage-inventory
+API-key scope.
+"""
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import generate_api_key
+from backend.app.models.api_key import APIKey
+from backend.app.models.settings import Settings
+from backend.app.models.spool import Spool
+
+TRAY_UUID = "AABBCCDDEEFF0011AABBCCDDEEFF0011"
+TAG_UID = "04A1B2C3"
+
+
+@pytest.fixture
+async def spool_factory(db_session: AsyncSession):
+    """Create a Spool with sensible defaults."""
+
+    async def _create(**kwargs):
+        defaults = {
+            "material": "PLA",
+            "subtype": "Basic",
+            "brand": "Bambu",
+            "color_name": "Red",
+            "rgba": "FF0000FF",
+            "label_weight": 1000,
+            "weight_used": 0,
+            "weight_used_baseline": 0,
+            "weight_locked": False,
+        }
+        defaults.update(kwargs)
+        spool = Spool(**defaults)
+        db_session.add(spool)
+        await db_session.commit()
+        await db_session.refresh(spool)
+        return spool
+
+    return _create
+
+
+class TestSpoolByTagLookup:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_lookup_by_tray_uuid(self, async_client: AsyncClient, spool_factory):
+        spool = await spool_factory(tray_uuid=TRAY_UUID)
+        resp = await async_client.get(f"/api/v1/inventory/spools/by-tag?tray_uuid={TRAY_UUID}")
+        assert resp.status_code == 200
+        assert resp.json()["id"] == spool.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_lookup_by_tag_uid(self, async_client: AsyncClient, spool_factory):
+        spool = await spool_factory(tag_uid=TAG_UID)
+        resp = await async_client.get(f"/api/v1/inventory/spools/by-tag?tag_uid={TAG_UID}")
+        assert resp.status_code == 200
+        assert resp.json()["id"] == spool.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_lookup_normalizes_input(self, async_client: AsyncClient, spool_factory):
+        """Lowercase / separator-laden input still matches the stored hex."""
+        spool = await spool_factory(tray_uuid=TRAY_UUID)
+        messy = "aa:bb:cc:dd:ee:ff:00:11:aa:bb:cc:dd:ee:ff:00:11"
+        resp = await async_client.get(f"/api/v1/inventory/spools/by-tag?tray_uuid={messy}")
+        assert resp.status_code == 200
+        assert resp.json()["id"] == spool.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tray_uuid_preferred_over_tag_uid(self, async_client: AsyncClient, spool_factory):
+        """When both identifiers are given, tray_uuid wins (it's the AMS key)."""
+        by_uuid = await spool_factory(tray_uuid=TRAY_UUID)
+        await spool_factory(tag_uid=TAG_UID)
+        resp = await async_client.get(f"/api/v1/inventory/spools/by-tag?tray_uuid={TRAY_UUID}&tag_uid={TAG_UID}")
+        assert resp.status_code == 200
+        assert resp.json()["id"] == by_uuid.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tray_uuid_miss_falls_through_to_tag_uid(self, async_client: AsyncClient, spool_factory):
+        """tray_uuid is tried first, but a miss must fall through to tag_uid, not 404."""
+        by_tag = await spool_factory(tag_uid=TAG_UID)
+        unknown_tray = "FF" * 16
+        resp = await async_client.get(f"/api/v1/inventory/spools/by-tag?tray_uuid={unknown_tray}&tag_uid={TAG_UID}")
+        assert resp.status_code == 200
+        assert resp.json()["id"] == by_tag.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_no_identifier_is_400(self, async_client: AsyncClient):
+        resp = await async_client.get("/api/v1/inventory/spools/by-tag")
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_non_hex_identifier_is_400(self, async_client: AsyncClient):
+        """A value with no hex characters normalizes to empty → treated as absent."""
+        resp = await async_client.get("/api/v1/inventory/spools/by-tag?tray_uuid=zzz")
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_no_match_is_404(self, async_client: AsyncClient, spool_factory):
+        await spool_factory(tray_uuid=TRAY_UUID)
+        resp = await async_client.get("/api/v1/inventory/spools/by-tag?tray_uuid=" + "FF" * 16)
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_archived_excluded_by_default(self, async_client: AsyncClient, spool_factory):
+        from datetime import datetime, timezone
+
+        await spool_factory(tray_uuid=TRAY_UUID, archived_at=datetime.now(timezone.utc))
+        resp = await async_client.get(f"/api/v1/inventory/spools/by-tag?tray_uuid={TRAY_UUID}")
+        assert resp.status_code == 404
+
+        resp = await async_client.get(f"/api/v1/inventory/spools/by-tag?tray_uuid={TRAY_UUID}&include_archived=true")
+        assert resp.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_does_not_collide_with_spool_id_route(self, async_client: AsyncClient, spool_factory):
+        """'by-tag' must route to the lookup handler, not /spools/{spool_id}."""
+        await spool_factory(tray_uuid=TRAY_UUID)
+        resp = await async_client.get("/api/v1/inventory/spools/by-tag?tray_uuid=" + TRAY_UUID)
+        assert resp.status_code == 200
+
+
+async def _make_api_key(db_session: AsyncSession, **scopes) -> str:
+    full_key, key_hash, key_prefix = generate_api_key()
+    flags = {
+        "can_queue": False,
+        "can_control_printer": False,
+        "can_read_status": False,
+        "can_manage_inventory": False,
+    }
+    flags.update(scopes)
+    db_session.add(APIKey(name="test-key", key_hash=key_hash, key_prefix=key_prefix, enabled=True, **flags))
+    db_session.add(Settings(key="auth_enabled", value="true"))
+    await db_session.commit()
+    return full_key
+
+
+class TestSpoolByTagApiKeyScope:
+    """The endpoint is the core ask of #1663: a Manage-Inventory key (which can
+    already create/update spools) must be able to read them back."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_manage_inventory_key_can_read(self, async_client: AsyncClient, db_session, spool_factory):
+        spool = await spool_factory(tray_uuid=TRAY_UUID)
+        key = await _make_api_key(db_session, can_manage_inventory=True)
+        resp = await async_client.get(
+            f"/api/v1/inventory/spools/by-tag?tray_uuid={TRAY_UUID}",
+            headers={"X-API-Key": key},
+        )
+        assert resp.status_code == 200
+        assert resp.json()["id"] == spool.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_read_status_key_can_read(self, async_client: AsyncClient, db_session, spool_factory):
+        spool = await spool_factory(tray_uuid=TRAY_UUID)
+        key = await _make_api_key(db_session, can_read_status=True)
+        resp = await async_client.get(
+            f"/api/v1/inventory/spools/by-tag?tray_uuid={TRAY_UUID}",
+            headers={"X-API-Key": key},
+        )
+        assert resp.status_code == 200
+        assert resp.json()["id"] == spool.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_key_without_inventory_scope_is_denied(self, async_client: AsyncClient, db_session, spool_factory):
+        await spool_factory(tray_uuid=TRAY_UUID)
+        key = await _make_api_key(db_session, can_control_printer=True)
+        resp = await async_client.get(
+            f"/api/v1/inventory/spools/by-tag?tray_uuid={TRAY_UUID}",
+            headers={"X-API-Key": key},
+        )
+        assert resp.status_code == 403

+ 130 - 0
backend/tests/integration/test_system_api.py

@@ -473,3 +473,133 @@ class TestSystemHealthAPI:
         ids = [f["signature_id"] for f in result["findings"]]
         assert "ftp-auth-rejected" in ids
         assert result["summary"]["layer8"] >= 1
+
+
+class TestSystemApplianceAPI:
+    """Integration tests for GET /api/v1/system/appliance (appliance locale defaults)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_appliance_endpoint_returns_nulls_when_no_local_toml(
+        self, async_client: AsyncClient, tmp_path, monkeypatch
+    ):
+        """Non-appliance install: file is absent, every field is null."""
+        from backend.app.api.routes import system as system_routes
+
+        absent = tmp_path / "nope.toml"
+        monkeypatch.setattr(
+            system_routes,
+            "read_local_toml",
+            lambda: __import__("backend.app.core.local_config", fromlist=["read_local_toml"]).read_local_toml(absent),
+        )
+
+        response = await async_client.get("/api/v1/system/appliance")
+
+        assert response.status_code == 200
+        body = response.json()
+        assert body == {"hostname": None, "timezone": None, "locale": None, "time_synced": None}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_appliance_endpoint_returns_wizard_values(self, async_client: AsyncClient, tmp_path, monkeypatch):
+        """Appliance install: wizard's local.toml values surface verbatim."""
+        from backend.app.api.routes import system as system_routes
+        from backend.app.core import local_config
+
+        toml = tmp_path / "local.toml"
+        toml.write_text('hostname = "workshop-pi"\ntimezone = "Europe/Berlin"\nlocale = "de"\n')
+        monkeypatch.setattr(system_routes, "read_local_toml", lambda: local_config.read_local_toml(toml))
+
+        response = await async_client.get("/api/v1/system/appliance")
+
+        assert response.status_code == 200
+        body = response.json()
+        assert body["hostname"] == "workshop-pi"
+        assert body["timezone"] == "Europe/Berlin"
+        assert body["locale"] == "de"
+        # time_synced state is host-dependent in this test; just assert the field exists.
+        assert "time_synced" in body
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_appliance_endpoint_partial(self, async_client: AsyncClient, tmp_path, monkeypatch):
+        """Only locale set: hostname + timezone surface as null."""
+        from backend.app.api.routes import system as system_routes
+        from backend.app.core import local_config
+
+        toml = tmp_path / "local.toml"
+        toml.write_text('locale = "ja"\n')
+        monkeypatch.setattr(system_routes, "read_local_toml", lambda: local_config.read_local_toml(toml))
+
+        response = await async_client.get("/api/v1/system/appliance")
+
+        assert response.status_code == 200
+        body = response.json()
+        assert body["locale"] == "ja"
+        assert body["hostname"] is None
+        assert body["timezone"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_appliance_endpoint_requires_no_auth(self, async_client: AsyncClient):
+        """The frontend i18n bootstrap reads this before auth might be set up.
+
+        The endpoint must respond 200 even when auth is enabled and the caller
+        is unauthenticated — its contents are non-secret (user-set defaults).
+        """
+        response = await async_client.get("/api/v1/system/appliance")
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_appliance_endpoint_time_synced_ok(self, async_client: AsyncClient, tmp_path, monkeypatch):
+        """NTP gate written by ntp-gate.sh with 'ok' surfaces as time_synced='ok'."""
+        from backend.app.api.routes import system as system_routes
+        from backend.app.core import local_config
+
+        gate = tmp_path / "time-synced"
+        gate.write_text("ok\n")
+        monkeypatch.setattr(system_routes, "read_ntp_gate", lambda: local_config.read_ntp_gate(gate))
+
+        response = await async_client.get("/api/v1/system/appliance")
+        assert response.status_code == 200
+        assert response.json()["time_synced"] == "ok"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_appliance_endpoint_time_synced_warning(
+        self,
+        async_client: AsyncClient,
+        tmp_path,
+        monkeypatch,
+    ):
+        """3-minute NTP timeout marker surfaces as time_synced='warning'."""
+        from backend.app.api.routes import system as system_routes
+        from backend.app.core import local_config
+
+        gate = tmp_path / "time-synced"
+        gate.write_text("warning: ntp sync timed out\n")
+        monkeypatch.setattr(system_routes, "read_ntp_gate", lambda: local_config.read_ntp_gate(gate))
+
+        response = await async_client.get("/api/v1/system/appliance")
+        assert response.status_code == 200
+        assert response.json()["time_synced"] == "warning"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_appliance_endpoint_time_synced_absent(
+        self,
+        async_client: AsyncClient,
+        tmp_path,
+        monkeypatch,
+    ):
+        """Non-appliance install: no gate file -> time_synced is null."""
+        from backend.app.api.routes import system as system_routes
+        from backend.app.core import local_config
+
+        absent = tmp_path / "no-gate-here"
+        monkeypatch.setattr(system_routes, "read_ntp_gate", lambda: local_config.read_ntp_gate(absent))
+
+        response = await async_client.get("/api/v1/system/appliance")
+        assert response.status_code == 200
+        assert response.json()["time_synced"] is None

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

@@ -2346,3 +2346,199 @@ class TestNtfyOutbound:
 
         assert ok is False
         assert "Cloudflare" in detail
+
+
+class TestEmailProvider:
+    """Tests for SMTP email provider, including #1792 finish-photo inline embed.
+
+    Embed is opt-in via the template: only when the user's template referenced
+    ``{finish_photo_url}`` (so the URL appears in the rendered body) AND the
+    photo bytes are available does ``_send_email`` build the multipart/related
+    shape. Otherwise it stays single-part text — no surprise inline image.
+    """
+
+    PHOTO_URL = "https://printer.local/api/v1/archives/42/photos/finish.jpg"
+
+    @pytest.fixture
+    def service(self):
+        return NotificationService()
+
+    @pytest.fixture
+    def smtp_config(self):
+        return {
+            "smtp_server": "smtp.example.com",
+            "smtp_port": "587",
+            "username": "alice",
+            "password": "secret",
+            "from_email": "bambuddy@example.com",
+            "to_email": "alice@example.com",
+            "security": "starttls",
+            "auth_enabled": "true",
+        }
+
+    @staticmethod
+    def _fake_smtp_class(captured: dict):
+        class FakeSMTP:
+            def __init__(self, host, port):
+                captured["host"] = host
+                captured["port"] = port
+
+            def starttls(self):
+                captured["starttls"] = True
+
+            def login(self, u, p):
+                captured["login"] = (u, p)
+
+            def sendmail(self, frm, to, body):
+                captured["from"] = frm
+                captured["to"] = to
+                captured["raw"] = body
+
+            def quit(self):
+                captured["quit"] = True
+
+        return FakeSMTP
+
+    @pytest.mark.asyncio
+    async def test_email_without_image_or_url_stays_text_only(self, service, smtp_config):
+        """No image_data and no URL in body → original single-part text shape."""
+        captured: dict = {}
+        with patch("backend.app.services.notification_service.smtplib.SMTP", self._fake_smtp_class(captured)):
+            ok, _ = await service._send_email(smtp_config, "Print Failed", "Reason: unknown")
+
+        assert ok is True
+        assert "image/jpeg" not in captured["raw"]
+        assert "multipart/related" not in captured["raw"]
+        assert "cid:bambuddy-finish-photo" not in captured["raw"]
+        assert "Reason: unknown" in captured["raw"]
+
+    @pytest.mark.asyncio
+    async def test_email_image_without_template_reference_stays_text_only(self, service, smtp_config):
+        """image_data present but template didn't include {finish_photo_url} → no embed.
+
+        Pins the template-driven contract: a user whose body is just
+        "Print failed. Reason: unknown" does NOT get a surprise inline image
+        stapled to the bottom, even though the photo bytes are available
+        upstream from the archive.
+        """
+        captured: dict = {}
+        with patch("backend.app.services.notification_service.smtplib.SMTP", self._fake_smtp_class(captured)):
+            ok, _ = await service._send_email(
+                smtp_config,
+                "Print Failed",
+                "Reason: unknown",
+                image_data=b"\xff\xd8\xff\xe0jpeg",
+                finish_photo_url=self.PHOTO_URL,
+            )
+
+        assert ok is True
+        raw = captured["raw"]
+        assert "image/jpeg" not in raw
+        assert "multipart/related" not in raw
+        assert "cid:bambuddy-finish-photo" not in raw
+
+    @pytest.mark.asyncio
+    async def test_email_inlines_when_template_uses_finish_photo_url(self, service, smtp_config):
+        """URL in body + image_data present → multipart/related + cid embed; HTML swaps URL for <img>."""
+        captured: dict = {}
+        body = f"Print failed. Reason: unknown\n\nSnapshot: {self.PHOTO_URL}"
+
+        with patch("backend.app.services.notification_service.smtplib.SMTP", self._fake_smtp_class(captured)):
+            ok, _ = await service._send_email(
+                smtp_config,
+                "Print Failed",
+                body,
+                image_data=b"\xff\xd8\xff\xe0fake-jpeg-bytes",
+                finish_photo_url=self.PHOTO_URL,
+            )
+
+        assert ok is True
+        raw = captured["raw"]
+        # multipart/related shape with both alt parts and an image part
+        assert "multipart/related" in raw
+        assert "multipart/alternative" in raw
+        assert "text/plain" in raw
+        assert "text/html" in raw
+        assert "image/jpeg" in raw
+        # HTML references the exact cid the Content-ID header registers
+        assert "Content-ID: <bambuddy-finish-photo>" in raw
+        assert 'src="cid:bambuddy-finish-photo"' in raw
+        # Inline disposition so renders embedded, not as download attachment
+        assert 'Content-Disposition: inline; filename="finish-photo.jpg"' in raw
+        # Plain-text body keeps the URL so non-HTML clients still get a clickable link
+        assert self.PHOTO_URL in raw
+
+    @pytest.mark.asyncio
+    async def test_email_image_data_without_url_arg_stays_text_only(self, service, smtp_config):
+        """image_data passed but finish_photo_url=None → defence-in-depth, no embed.
+
+        Even if a future caller forgets to thread the URL through but does pass
+        the bytes, the conservative default is no embed (avoids attaching an
+        unreferenced image to an unrelated event type).
+        """
+        captured: dict = {}
+        with patch("backend.app.services.notification_service.smtplib.SMTP", self._fake_smtp_class(captured)):
+            ok, _ = await service._send_email(
+                smtp_config,
+                "Print Failed",
+                f"Snapshot: {self.PHOTO_URL}",
+                image_data=b"\xff\xd8\xff\xe0jpeg",
+                finish_photo_url=None,
+            )
+
+        assert ok is True
+        assert "image/jpeg" not in captured["raw"]
+        assert "multipart/related" not in captured["raw"]
+
+    @pytest.mark.asyncio
+    async def test_email_html_body_escapes_user_content(self, service, smtp_config):
+        """Template-rendered body must not be injected raw into the HTML part."""
+        captured: dict = {}
+        body = f"Filename: <script>alert(1)</script>\nLine 2\nSnapshot: {self.PHOTO_URL}"
+
+        with patch("backend.app.services.notification_service.smtplib.SMTP", self._fake_smtp_class(captured)):
+            ok, _ = await service._send_email(
+                smtp_config,
+                "Print Failed",
+                body,
+                image_data=b"\xff\xd8\xff\xe0jpeg",
+                finish_photo_url=self.PHOTO_URL,
+            )
+
+        assert ok is True
+        raw = captured["raw"]
+        # Raw HTML must NOT round-trip into the HTML part — verify escaped form is present.
+        assert "&lt;script&gt;alert(1)&lt;/script&gt;" in raw
+        # Newlines in the body become <br> in HTML
+        assert "Line 2" in raw
+        assert "<br>" in raw
+
+    @pytest.mark.asyncio
+    async def test_email_html_swaps_url_for_img_tag(self, service, smtp_config):
+        """In the HTML part, the URL substring is replaced with the <img cid:...> tag.
+
+        Plain text keeps the URL; HTML clients see the inline image where the
+        URL was. The URL must NOT appear inside an <a href> wrapping the image
+        — we replace the URL outright with the img tag (renderers don't need
+        the URL twice in the HTML part when the image is already inline).
+        """
+        captured: dict = {}
+        body = f"See: {self.PHOTO_URL} for the snapshot."
+
+        with patch("backend.app.services.notification_service.smtplib.SMTP", self._fake_smtp_class(captured)):
+            ok, _ = await service._send_email(
+                smtp_config,
+                "Print Failed",
+                body,
+                image_data=b"\xff\xd8\xff\xe0jpeg",
+                finish_photo_url=self.PHOTO_URL,
+            )
+
+        assert ok is True
+        raw = captured["raw"]
+        # The <img> tag appears in the HTML part
+        assert 'src="cid:bambuddy-finish-photo"' in raw
+        # The escaped URL is the marker we replaced — the HTML part should not
+        # contain BOTH the escaped URL AND the cid img (we swapped, not duplicated).
+        # The plain-text part still has the URL; check it's there at least once.
+        assert self.PHOTO_URL in raw

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

@@ -21,7 +21,7 @@ def _statuses(result):
 
 def _port_probe(overrides=None):
     """Sync side_effect for _check_port. Defaults: every port reachable."""
-    reachable = {8883: True, 990: True, 322: True}
+    reachable = {8883: True, 990: True, 322: True, 6000: True}
     reachable.update(overrides or {})
 
     def _probe(ip, port, timeout=3.0):
@@ -142,6 +142,28 @@ class TestExistingPrinter:
         assert s["port_ftps"] == "warn"
         assert s["port_rtsps"] == "warn"
 
+    async def test_a1_mini_uses_chamber_image_camera_port(self):
+        # A1/P1-family printers use the chamber-image camera protocol on 6000,
+        # not RTSPS on 322. A closed 322 must not create a false camera warning.
+        with _Env(ports=_port_probe({322: False, 6000: True}), state=_state()):
+            result = await run_connection_diagnostic(
+                "192.168.1.50",
+                printer=_printer(model="A1 Mini"),
+            )
+        assert _statuses(result)["port_rtsps"] == "pass"
+        camera_check = next(c for c in result.checks if c.id == "port_rtsps")
+        assert camera_check.params == {"port": 6000, "protocol": "Chamber Image"}
+
+    async def test_rtsp_models_still_probe_rtsps_port(self):
+        with _Env(ports=_port_probe({322: False, 6000: True}), state=_state()):
+            result = await run_connection_diagnostic(
+                "192.168.1.50",
+                printer=_printer(model="X1C"),
+            )
+        assert _statuses(result)["port_rtsps"] == "warn"
+        camera_check = next(c for c in result.checks if c.id == "port_rtsps")
+        assert camera_check.params == {"port": 322, "protocol": "RTSPS"}
+
     async def test_developer_mode_off_is_a_problem(self):
         with _Env(state=_state(connected=True, developer_mode=False)):
             result = await run_connection_diagnostic("192.168.1.50", printer=_printer())

+ 148 - 0
backend/tests/unit/test_local_config.py

@@ -0,0 +1,148 @@
+"""
+Tests for backend.app.core.local_config — the reader for
+/etc/bambuddy/local.toml that the appliance setup wizard writes.
+
+Defensive on bad input: every failure mode returns an empty dict
+(never raises), so a malformed file never blocks startup.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from backend.app.core.local_config import read_local_toml, read_ntp_gate
+
+
+def test_missing_file_returns_empty(tmp_path: Path):
+    assert read_local_toml(tmp_path / "nope.toml") == {}
+
+
+def test_empty_file_returns_empty(tmp_path: Path):
+    path = tmp_path / "local.toml"
+    path.write_text("")
+    assert read_local_toml(path) == {}
+
+
+def test_comment_only_file_returns_empty(tmp_path: Path):
+    path = tmp_path / "local.toml"
+    path.write_text("# Written by bambuddy-wizard during firstboot.\n")
+    assert read_local_toml(path) == {}
+
+
+def test_full_config_parses(tmp_path: Path):
+    path = tmp_path / "local.toml"
+    path.write_text(
+        "# Written by bambuddy-wizard during firstboot.\n"
+        'hostname = "workshop-pi"\n'
+        'timezone = "Europe/Berlin"\n'
+        'locale = "de"\n'
+    )
+    result = read_local_toml(path)
+    assert result == {
+        "hostname": "workshop-pi",
+        "timezone": "Europe/Berlin",
+        "locale": "de",
+    }
+
+
+def test_partial_config_only_returns_present_keys(tmp_path: Path):
+    path = tmp_path / "local.toml"
+    path.write_text('locale = "ja"\n')
+    result = read_local_toml(path)
+    assert result == {"locale": "ja"}
+    assert "hostname" not in result
+    assert "timezone" not in result
+
+
+def test_invalid_toml_returns_empty(tmp_path: Path, caplog: pytest.LogCaptureFixture):
+    path = tmp_path / "local.toml"
+    path.write_text("not = valid = toml = at all\n")
+    result = read_local_toml(path)
+    assert result == {}
+    assert any("could not be parsed" in r.message for r in caplog.records)
+
+
+def test_non_string_value_is_dropped(tmp_path: Path, caplog: pytest.LogCaptureFixture):
+    path = tmp_path / "local.toml"
+    path.write_text(
+        "hostname = 42\n"  # not a string
+        'locale = "de"\n'
+    )
+    result = read_local_toml(path)
+    assert result == {"locale": "de"}
+    assert any("expected str" in r.message for r in caplog.records)
+
+
+def test_unknown_keys_are_ignored(tmp_path: Path):
+    """A hand-edited config with extra keys must not leak them to the response."""
+    path = tmp_path / "local.toml"
+    path.write_text('locale = "de"\nunknown_key = "value"\nadmin_password = "should not surface"\n')
+    result = read_local_toml(path)
+    assert set(result.keys()) <= {"hostname", "timezone", "locale"}
+    assert "admin_password" not in result
+
+
+def test_escaped_characters_round_trip(tmp_path: Path):
+    """The wizard escapes backslash and quote when writing; the reader parses them back."""
+    path = tmp_path / "local.toml"
+    path.write_text('hostname = "with\\"quote"\n')
+    result = read_local_toml(path)
+    assert result == {"hostname": 'with"quote'}
+
+
+# ---------------------------------------------------------------------------
+# read_ntp_gate
+# ---------------------------------------------------------------------------
+
+
+def test_ntp_gate_missing_returns_none(tmp_path: Path):
+    assert read_ntp_gate(tmp_path / "absent") is None
+
+
+def test_ntp_gate_ok(tmp_path: Path):
+    path = tmp_path / "time-synced"
+    path.write_text("ok\n")
+    assert read_ntp_gate(path) == "ok"
+
+
+def test_ntp_gate_warning(tmp_path: Path):
+    path = tmp_path / "time-synced"
+    path.write_text("warning: ntp sync timed out\n")
+    assert read_ntp_gate(path) == "warning"
+
+
+def test_ntp_gate_warning_no_suffix(tmp_path: Path):
+    """Just 'warning' on its own is also accepted."""
+    path = tmp_path / "time-synced"
+    path.write_text("warning\n")
+    assert read_ntp_gate(path) == "warning"
+
+
+def test_ntp_gate_empty_returns_none(tmp_path: Path):
+    """Empty / surprise content is treated as unknown rather than misclassified."""
+    path = tmp_path / "time-synced"
+    path.write_text("")
+    assert read_ntp_gate(path) is None
+
+
+def test_ntp_gate_unknown_marker_returns_none(tmp_path: Path):
+    path = tmp_path / "time-synced"
+    path.write_text("synced via remote NTP\n")  # neither 'ok' nor 'warning'
+    assert read_ntp_gate(path) is None
+
+
+def test_ntp_gate_strips_whitespace(tmp_path: Path):
+    """Leading whitespace shouldn't trick a startswith check."""
+    path = tmp_path / "time-synced"
+    path.write_text("   ok\n")
+    assert read_ntp_gate(path) == "ok"
+
+
+def test_ntp_gate_binary_garbage_returns_none(tmp_path: Path, caplog: pytest.LogCaptureFixture):
+    """Defensive read mode survives non-utf8 content without crashing."""
+    path = tmp_path / "time-synced"
+    path.write_bytes(b"\xff\xfe\x00\x01ok\n")
+    # errors="replace" maps the bytes through but the prefix is no longer 'ok'.
+    assert read_ntp_gate(path) is None

+ 4 - 0
backend/tests/unit/test_route_auth_coverage.py

@@ -93,6 +93,10 @@ _PUBLIC_ROUTES: frozenset[tuple[str, str]] = frozenset(
         # UI bootstrap — defaults for sidebar order and ui-preferences are public defaults that ship with the app.
         ("GET", "/api/v1/settings/default-sidebar-order"),
         ("GET", "/api/v1/settings/ui-preferences"),
+        # Appliance locale defaults — read by the i18n bootstrap BEFORE auth might be set up.
+        # Contents are user-set hostname/timezone/locale from the firstboot wizard (no secrets);
+        # the file is absent on non-appliance installs, in which case every field is null.
+        ("GET", "/api/v1/system/appliance"),
         # Slicer printer-models — static catalog, no user data.
         ("GET", "/api/v1/slicer/printer-models"),
         # Current Bambuddy version — public info (already visible in HTTP response headers + Docker tags).

+ 146 - 0
backend/tests/unit/test_user_print_template_rename_migration.py

@@ -0,0 +1,146 @@
+"""Regression test for the user_print_* notification template rename migration (#1792).
+
+The four ``user_print_*`` notification templates seeded with names like
+"User Print Completed" looked indistinguishable from the provider-level
+"Print Completed" template in the Message Templates list (the EVENT_NAMES
+display map in routes/notification_templates.py already used the disambiguated
+"User Print Completed Email" label, but the seed wrote the short name to the
+DB, so the UI rendered the ambiguous one).
+
+The migration appends " Email" to those four template names IF AND ONLY IF
+the row still has the old default name — admins who renamed the template
+themselves keep their custom name. This test verifies both branches.
+"""
+
+from __future__ import annotations
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from backend.app.core.database import _migrate_rename_user_print_template_names
+
+
+@pytest.fixture
+async def engine():
+    """In-memory SQLite with just the notification_templates table.
+
+    The migration is a single UPDATE on one table, so the fixture only needs
+    that table — avoids the brittleness of registering every model in the
+    project just to satisfy run_migrations's broader DDL surface.
+    """
+    from backend.app.models.notification_template import NotificationTemplate
+
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(NotificationTemplate.__table__.create)
+    try:
+        yield engine
+    finally:
+        await engine.dispose()
+
+
+_OLD_DEFAULTS = {
+    "user_print_start": "User Print Started",
+    "user_print_complete": "User Print Completed",
+    "user_print_failed": "User Print Failed",
+    "user_print_stopped": "User Print Stopped",
+}
+_NEW_DEFAULTS = {
+    "user_print_start": "User Print Started Email",
+    "user_print_complete": "User Print Completed Email",
+    "user_print_failed": "User Print Failed Email",
+    "user_print_stopped": "User Print Stopped Email",
+}
+
+
+async def _insert_template(conn, event_type: str, name: str) -> None:
+    await conn.execute(
+        text(
+            "INSERT INTO notification_templates "
+            "(event_type, name, title_template, body_template, is_default) "
+            "VALUES (:et, :n, 't', 'b', 1)"
+        ),
+        {"et": event_type, "n": name},
+    )
+
+
+async def _name_for(conn, event_type: str) -> str:
+    return (
+        await conn.execute(
+            text("SELECT name FROM notification_templates WHERE event_type = :et"),
+            {"et": event_type},
+        )
+    ).scalar_one()
+
+
+async def test_migration_renames_default_named_user_print_rows(engine):
+    """Rows with the old default name get the new disambiguated name."""
+    async with engine.begin() as conn:
+        for event_type, old_name in _OLD_DEFAULTS.items():
+            await _insert_template(conn, event_type, old_name)
+
+    async with engine.begin() as conn:
+        await _migrate_rename_user_print_template_names(conn)
+
+    async with engine.begin() as conn:
+        for event_type, new_name in _NEW_DEFAULTS.items():
+            assert await _name_for(conn, event_type) == new_name
+
+
+async def test_migration_preserves_user_edited_names(engine):
+    """An admin who renamed a template keeps their custom name across the migration."""
+    async with engine.begin() as conn:
+        await _insert_template(conn, "user_print_complete", "My Custom Renamed Template")
+        await _insert_template(conn, "user_print_failed", "User Print Failed")  # still default
+
+    async with engine.begin() as conn:
+        await _migrate_rename_user_print_template_names(conn)
+
+    async with engine.begin() as conn:
+        # Custom name preserved
+        assert await _name_for(conn, "user_print_complete") == "My Custom Renamed Template"
+        # Default name renamed
+        assert await _name_for(conn, "user_print_failed") == "User Print Failed Email"
+
+
+async def test_migration_does_not_touch_provider_templates(engine):
+    """The non-user provider templates with similar names must not be renamed."""
+    async with engine.begin() as conn:
+        await _insert_template(conn, "print_complete", "Print Completed")
+        await _insert_template(conn, "print_failed", "Print Failed")
+
+    async with engine.begin() as conn:
+        await _migrate_rename_user_print_template_names(conn)
+
+    async with engine.begin() as conn:
+        assert await _name_for(conn, "print_complete") == "Print Completed"
+        assert await _name_for(conn, "print_failed") == "Print Failed"
+
+
+async def test_migration_is_idempotent(engine):
+    """Running the migration twice must not double-suffix already-renamed rows."""
+    async with engine.begin() as conn:
+        for event_type, old_name in _OLD_DEFAULTS.items():
+            await _insert_template(conn, event_type, old_name)
+
+    async with engine.begin() as conn:
+        await _migrate_rename_user_print_template_names(conn)
+    async with engine.begin() as conn:
+        await _migrate_rename_user_print_template_names(conn)
+
+    async with engine.begin() as conn:
+        for event_type, new_name in _NEW_DEFAULTS.items():
+            current = await _name_for(conn, event_type)
+            assert current == new_name
+            assert "Email Email" not in current
+
+
+async def test_migration_handles_empty_table(engine):
+    """Migration on an empty table must be a safe no-op (fresh install path)."""
+    async with engine.begin() as conn:
+        await _migrate_rename_user_print_template_names(conn)
+
+    async with engine.begin() as conn:
+        count = (await conn.execute(text("SELECT COUNT(*) FROM notification_templates"))).scalar_one()
+        assert count == 0

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 1166 - 98
frontend/package-lock.json


+ 2 - 0
frontend/package.json

@@ -39,9 +39,11 @@
     "react": "^19.2.0",
     "react-dom": "^19.2.0",
     "react-i18next": "^16.3.5",
+    "react-markdown": "^9.1.0",
     "react-router-dom": "^7.16.0",
     "react-simple-keyboard": "^3.8.164",
     "recharts": "^3.5.1",
+    "remark-gfm": "^4.0.1",
     "three": "^0.181.2"
   },
   "overrides": {

+ 107 - 0
frontend/src/__tests__/components/BulkTagsPickerModal.test.tsx

@@ -0,0 +1,107 @@
+/**
+ * Tests for BulkTagsPickerModal (#1268).
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router-dom';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { BulkTagsPickerModal } from '../../components/BulkTagsPickerModal';
+import { api } from '../../api/client';
+
+const mockShowToast = vi.fn();
+const mockOnClose = vi.fn();
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getLibraryTags: vi.fn(),
+    createLibraryTag: vi.fn(),
+    bulkAssignLibraryTags: vi.fn(),
+  },
+}));
+
+vi.mock('../../contexts/ToastContext', () => ({
+  useToast: () => ({ showToast: mockShowToast }),
+}));
+
+const tags = [
+  { id: 1, name: 'toy', file_count: 2, created_at: '2026-01-01', updated_at: '2026-01-01' },
+  { id: 2, name: 'petg', file_count: 7, created_at: '2026-01-01', updated_at: '2026-01-01' },
+];
+
+function renderModal(fileIds: number[] = [10, 11, 12]) {
+  const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+  return render(
+    <QueryClientProvider client={client}>
+      <MemoryRouter>
+        <BulkTagsPickerModal open fileIds={fileIds} onClose={mockOnClose} />
+      </MemoryRouter>
+    </QueryClientProvider>,
+  );
+}
+
+describe('BulkTagsPickerModal', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    (api.getLibraryTags as ReturnType<typeof vi.fn>).mockResolvedValue(tags);
+  });
+
+  it('lists existing tags from the catalog', async () => {
+    renderModal();
+    expect(await screen.findByText('toy')).toBeInTheDocument();
+    expect(screen.getByText('petg')).toBeInTheDocument();
+  });
+
+  it('checking a tag and clicking Add applies it via bulkAssignLibraryTags', async () => {
+    (api.bulkAssignLibraryTags as ReturnType<typeof vi.fn>).mockResolvedValue({
+      files_updated: 3,
+      associations_added: 3,
+      associations_removed: 0,
+    });
+    const user = userEvent.setup();
+    renderModal([10, 11, 12]);
+    await screen.findByText('toy');
+
+    const toyCheckbox = screen
+      .getAllByRole('checkbox')
+      .find((el) => el.parentElement?.textContent?.includes('toy'));
+    expect(toyCheckbox).toBeDefined();
+    await user.click(toyCheckbox!);
+
+    await user.click(screen.getByRole('button', { name: /Add tags/i }));
+    await waitFor(() => {
+      expect(api.bulkAssignLibraryTags).toHaveBeenCalledWith([10, 11, 12], [1], 'add');
+    });
+  });
+
+  it('switching to Remove changes the apply action', async () => {
+    (api.bulkAssignLibraryTags as ReturnType<typeof vi.fn>).mockResolvedValue({
+      files_updated: 3,
+      associations_added: 0,
+      associations_removed: 3,
+    });
+    const user = userEvent.setup();
+    renderModal([10, 11, 12]);
+    await screen.findByText('toy');
+
+    // Pick the Remove radio.
+    await user.click(screen.getByRole('radio', { name: /Remove from selected files/i }));
+
+    const petgCheckbox = screen
+      .getAllByRole('checkbox')
+      .find((el) => el.parentElement?.textContent?.includes('petg'));
+    await user.click(petgCheckbox!);
+
+    await user.click(screen.getByRole('button', { name: /Remove tags/i }));
+    await waitFor(() => {
+      expect(api.bulkAssignLibraryTags).toHaveBeenCalledWith([10, 11, 12], [2], 'remove');
+    });
+  });
+
+  it('apply is disabled when no tag is selected', async () => {
+    renderModal();
+    await screen.findByText('toy');
+    expect(screen.getByRole('button', { name: /Add tags/i })).toBeDisabled();
+  });
+});

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

@@ -98,4 +98,28 @@ describe('ConnectionDiagnosticModal', () => {
 
     spy.mockRestore();
   });
+
+  it('renders model-specific camera port diagnostics', async () => {
+    const spy = vi.spyOn(api, 'diagnosePrinter').mockResolvedValue({
+      ...PROBLEM_RESULT,
+      overall: 'warnings',
+      checks: [
+        { id: 'port_mqtt', status: 'pass', params: {} },
+        { id: 'port_ftps', status: 'pass', params: {} },
+        {
+          id: 'port_rtsps',
+          status: 'warn',
+          params: { protocol: 'Chamber Image', port: 6000 },
+        },
+      ],
+    });
+
+    renderModal({ printerId: 1, printerName: 'Test A1 Mini', onClose: vi.fn() });
+
+    await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
+    expect(await screen.findByText(/Camera port \(Chamber Image 6000\)/i)).toBeInTheDocument();
+    expect(screen.getByText(/Port 6000 is unreachable/i)).toBeInTheDocument();
+
+    spy.mockRestore();
+  });
 });

+ 58 - 0
frontend/src/__tests__/components/FolderReadmePanel.test.tsx

@@ -0,0 +1,58 @@
+/**
+ * Tests for FolderReadmePanel (#1268).
+ */
+
+import { describe, it, expect } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { FolderReadmePanel } from '../../components/FolderReadmePanel';
+import { server } from '../mocks/server';
+
+describe('FolderReadmePanel', () => {
+  it('renders nothing when the folder has no markdown (404)', async () => {
+    server.use(
+      http.get('/api/v1/library/folders/:id/readme', () =>
+        HttpResponse.json({ detail: 'No markdown' }, { status: 404 }),
+      ),
+    );
+    render(<FolderReadmePanel folderId={1} />);
+    // Wait briefly so the query has time to resolve, then confirm no panel
+    // chrome leaked into the DOM (the test render util mounts toast/provider
+    // wrappers, so we can't assert `container.firstChild === null`).
+    await waitFor(() => {
+      expect(screen.queryByText('Truncated')).not.toBeInTheDocument();
+      expect(document.querySelector('button[type="button"] svg.lucide-file-text')).toBeNull();
+    });
+  });
+
+  it('renders markdown content and the filename when present', async () => {
+    server.use(
+      http.get('/api/v1/library/folders/:id/readme', () =>
+        HttpResponse.json({
+          filename: 'README.md',
+          content: '# Robot model\n\nA cute robot.',
+          truncated: false,
+        }),
+      ),
+    );
+    render(<FolderReadmePanel folderId={42} />);
+    expect(await screen.findByText('README.md')).toBeInTheDocument();
+    expect(await screen.findByRole('heading', { name: 'Robot model' })).toBeInTheDocument();
+    expect(screen.getByText('A cute robot.')).toBeInTheDocument();
+  });
+
+  it('shows a Truncated chip when the API flags the content as clipped', async () => {
+    server.use(
+      http.get('/api/v1/library/folders/:id/readme', () =>
+        HttpResponse.json({
+          filename: 'description.md',
+          content: 'very long content',
+          truncated: true,
+        }),
+      ),
+    );
+    render(<FolderReadmePanel folderId={7} />);
+    expect(await screen.findByText('Truncated')).toBeInTheDocument();
+  });
+});

+ 97 - 0
frontend/src/__tests__/components/LibraryTagsModal.test.tsx

@@ -0,0 +1,97 @@
+/**
+ * Tests for LibraryTagsModal (#1268).
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router-dom';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { LibraryTagsModal } from '../../components/LibraryTagsModal';
+import { api } from '../../api/client';
+
+const mockShowToast = vi.fn();
+const mockOnClose = vi.fn();
+const mockOnPick = vi.fn();
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getLibraryTags: vi.fn(),
+    createLibraryTag: vi.fn(),
+    updateLibraryTag: vi.fn(),
+    deleteLibraryTag: vi.fn(),
+  },
+}));
+
+vi.mock('../../contexts/ToastContext', () => ({
+  useToast: () => ({ showToast: mockShowToast }),
+}));
+
+const tags = [
+  { id: 1, name: 'toy', file_count: 4, created_at: '2026-01-01', updated_at: '2026-01-01' },
+  { id: 2, name: 'kid-safe', file_count: 0, created_at: '2026-01-01', updated_at: '2026-01-01' },
+];
+
+function renderModal() {
+  const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+  return render(
+    <QueryClientProvider client={client}>
+      <MemoryRouter>
+        <LibraryTagsModal open onClose={mockOnClose} onPickTag={mockOnPick} />
+      </MemoryRouter>
+    </QueryClientProvider>,
+  );
+}
+
+describe('LibraryTagsModal', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    (api.getLibraryTags as ReturnType<typeof vi.fn>).mockResolvedValue(tags);
+  });
+
+  it('renders the catalog with file counts', async () => {
+    renderModal();
+    expect(await screen.findByText('toy')).toBeInTheDocument();
+    expect(screen.getByText('kid-safe')).toBeInTheDocument();
+    expect(screen.getByText('4')).toBeInTheDocument();
+  });
+
+  it('opens the editor and calls createLibraryTag on save', async () => {
+    (api.createLibraryTag as ReturnType<typeof vi.fn>).mockResolvedValue({
+      id: 3,
+      name: 'new',
+      file_count: 0,
+      created_at: '2026-01-01',
+      updated_at: '2026-01-01',
+    });
+    const user = userEvent.setup();
+    renderModal();
+    await screen.findByText('toy');
+    // Header has both "New tag" and "Manage tag catalog" — click the one
+    // that opens the editor (the button with Plus icon).
+    await user.click(screen.getByRole('button', { name: /New tag/i }));
+    const input = await screen.findByLabelText(/Name/i);
+    await user.type(input, 'new');
+    await user.click(screen.getByRole('button', { name: /Save/i }));
+    await waitFor(() => {
+      expect(api.createLibraryTag).toHaveBeenCalledWith('new');
+    });
+  });
+
+  it('clicking a row invokes onPickTag and closes the modal', async () => {
+    const user = userEvent.setup();
+    renderModal();
+    await user.click(await screen.findByText('toy'));
+    expect(mockOnPick).toHaveBeenCalledWith(1);
+    expect(mockOnClose).toHaveBeenCalled();
+  });
+
+  it('confirm dialog warns when deleting an in-use tag', async () => {
+    const user = userEvent.setup();
+    renderModal();
+    await screen.findByText('toy');
+    await user.click(screen.getByLabelText('Delete toy'));
+    // In-use message — substring match keeps the test robust to whitespace.
+    expect(await screen.findByText(/{{count}}|on 4|4 file/i)).toBeInTheDocument();
+  });
+});

+ 67 - 0
frontend/src/api/client.ts

@@ -5787,6 +5787,8 @@ export const api = {
     includeRoot = true,
     projectId?: number,
     scope?: 'internal' | 'external',
+    recursive = false,
+    tagIds: number[] = [],
   ) => {
     const params = new URLSearchParams();
     if (folderId !== undefined && folderId !== null) {
@@ -5798,8 +5800,48 @@ export const api = {
     params.set('include_root', String(includeRoot));
     if (scope === 'internal') params.set('internal_only', 'true');
     else if (scope === 'external') params.set('external_only', 'true');
+    // recursive=true expands the folder_id filter to include every descendant
+    // folder (#1268). Only meaningful when folder_id is set; ignored server-side
+    // otherwise. Off by default so non-search callers keep folder-scoped behavior.
+    if (recursive) params.set('recursive', 'true');
+    // Tag filter (#1268). Repeated ?tag_ids=N&tag_ids=M form for AND semantics
+    // — backend joins the association table and HAVING COUNT(DISTINCT) matches
+    // the array length. Tag filter intentionally bypasses folder scoping
+    // server-side (cross-cutting design decision).
+    for (const tagId of tagIds) {
+      params.append('tag_ids', String(tagId));
+    }
     return request<LibraryFileListItem[]>(`/library/files?${params}`);
   },
+  getLibraryFolderReadme: (folderId: number) =>
+    request<{ filename: string; content: string; truncated: boolean }>(
+      `/library/folders/${folderId}/readme`,
+    ),
+
+  // ============ Library tag catalog (#1268) ============
+  getLibraryTags: () =>
+    request<LibraryTag[]>('/library/tags'),
+  createLibraryTag: (name: string) =>
+    request<LibraryTag>('/library/tags', {
+      method: 'POST',
+      body: JSON.stringify({ name }),
+    }),
+  updateLibraryTag: (id: number, name: string) =>
+    request<LibraryTag>(`/library/tags/${id}`, {
+      method: 'PATCH',
+      body: JSON.stringify({ name }),
+    }),
+  deleteLibraryTag: (id: number) =>
+    request<void>(`/library/tags/${id}`, { method: 'DELETE' }),
+  bulkAssignLibraryTags: (
+    fileIds: number[],
+    tagIds: number[],
+    action: 'add' | 'remove' | 'replace',
+  ) =>
+    request<LibraryTagBulkAssignResult>('/library/tags/bulk-assign', {
+      method: 'POST',
+      body: JSON.stringify({ file_ids: fileIds, tag_ids: tagIds, action }),
+    }),
   getLibraryFile: (id: number) => request<LibraryFile>(`/library/files/${id}`),
   uploadLibraryFile: async (
     file: File,
@@ -6402,6 +6444,11 @@ export interface LibraryFile {
   sliced_for_model: string | null;
 }
 
+export interface LibraryTagSummary {
+  id: number;
+  name: string;
+}
+
 export interface LibraryFileListItem {
   id: number;
   folder_id: number | null;
@@ -6420,6 +6467,26 @@ export interface LibraryFileListItem {
   print_time_seconds: number | null;
   filament_used_grams: number | null;
   sliced_for_model: string | null;
+  // Tags assigned to this file (#1268). The backend always emits an empty
+  // array when a file has no tags, but the field is typed optional so any
+  // legacy code path (or mock) that constructs a LibraryFileListItem without
+  // it doesn't crash the renderer. Read sites use `file.tags ?? []`.
+  tags?: LibraryTagSummary[];
+}
+
+// Library tag catalog (#1268)
+export interface LibraryTag {
+  id: number;
+  name: string;
+  file_count: number;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface LibraryTagBulkAssignResult {
+  files_updated: number;
+  associations_added: number;
+  associations_removed: number;
 }
 
 export interface LibraryFileUpdate {

+ 260 - 0
frontend/src/components/BulkTagsPickerModal.tsx

@@ -0,0 +1,260 @@
+import { useState, useEffect, useMemo } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Tag, Loader2, Plus, X } from 'lucide-react';
+
+import { api, type LibraryTag } from '../api/client';
+import { Button } from './Button';
+import { useToast } from '../contexts/ToastContext';
+import { libraryTagsQueryKey } from '../utils/libraryTagsQuery';
+
+interface BulkTagsPickerModalProps {
+  open: boolean;
+  fileIds: number[];
+  onClose: () => void;
+}
+
+type Action = 'add' | 'remove';
+
+/**
+ * Multi-file tag application modal (#1268). Opens from the File Manager's
+ * multi-select toolbar. Checkbox-list of catalog tags + inline "create new" so
+ * the user doesn't have to leave the flow to add a tag they forgot to make.
+ *
+ * Replace mode is omitted from the UI — it's a destructive op that the user
+ * would rarely want for arbitrary multi-selections. The API still exposes it
+ * for callers that need it (e.g. a future bulk-edit screen).
+ */
+export function BulkTagsPickerModal({ open, fileIds, onClose }: BulkTagsPickerModalProps) {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+
+  const [action, setAction] = useState<Action>('add');
+  const [selected, setSelected] = useState<Set<number>>(new Set());
+  const [filter, setFilter] = useState('');
+  const [newTagName, setNewTagName] = useState('');
+
+  // Reset state on close so re-opening the modal doesn't keep stale selection.
+  useEffect(() => {
+    if (!open) {
+      setAction('add');
+      setSelected(new Set());
+      setFilter('');
+      setNewTagName('');
+    }
+  }, [open]);
+
+  const { data: tags = [], isLoading } = useQuery({
+    queryKey: libraryTagsQueryKey,
+    queryFn: api.getLibraryTags,
+    enabled: open,
+  });
+
+  const filteredTags = useMemo<LibraryTag[]>(() => {
+    const q = filter.trim().toLowerCase();
+    if (!q) return tags;
+    return tags.filter((t) => t.name.toLowerCase().includes(q));
+  }, [tags, filter]);
+
+  const toggleTag = (id: number) => {
+    setSelected((prev) => {
+      const next = new Set(prev);
+      if (next.has(id)) {
+        next.delete(id);
+      } else {
+        next.add(id);
+      }
+      return next;
+    });
+  };
+
+  const createTagMutation = useMutation({
+    mutationFn: (name: string) => api.createLibraryTag(name),
+    onSuccess: (tag) => {
+      setSelected((prev) => new Set(prev).add(tag.id));
+      setNewTagName('');
+      queryClient.invalidateQueries({ queryKey: libraryTagsQueryKey });
+    },
+    onError: (err: Error) => {
+      showToast(err.message || t('fileManager.tags.saveFailed'), 'error');
+    },
+  });
+
+  const applyMutation = useMutation({
+    mutationFn: () =>
+      api.bulkAssignLibraryTags(fileIds, Array.from(selected), action),
+    onSuccess: (result) => {
+      showToast(
+        action === 'add'
+          ? t('fileManager.tags.applyAddSuccess', {
+              count: result.associations_added,
+              files: result.files_updated,
+            })
+          : t('fileManager.tags.applyRemoveSuccess', {
+              count: result.associations_removed,
+              files: result.files_updated,
+            }),
+        'success',
+      );
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      queryClient.invalidateQueries({ queryKey: libraryTagsQueryKey });
+      onClose();
+    },
+    onError: (err: Error) => {
+      showToast(err.message || t('fileManager.tags.applyFailed'), 'error');
+    },
+  });
+
+  useEffect(() => {
+    if (!open) return;
+    const onKey = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && !applyMutation.isPending && !createTagMutation.isPending) {
+        onClose();
+      }
+    };
+    document.addEventListener('keydown', onKey);
+    return () => document.removeEventListener('keydown', onKey);
+  }, [open, onClose, applyMutation.isPending, createTagMutation.isPending]);
+
+  if (!open) return null;
+
+  const createDisabled =
+    !newTagName.trim() ||
+    createTagMutation.isPending ||
+    tags.some((tg) => tg.name.toLowerCase() === newTagName.trim().toLowerCase());
+
+  const titleId = 'bulk-tags-picker-title';
+
+  return (
+    <div className="fixed inset-0 z-[60] flex items-center justify-center">
+      <div className="absolute inset-0 bg-black/60" onClick={() => !applyMutation.isPending && onClose()} />
+      <div
+        className="relative w-full max-w-md mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] flex flex-col"
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby={titleId}
+      >
+        <div className="flex items-center justify-between gap-4 px-5 py-4 border-b border-bambu-dark-tertiary">
+          <h3 id={titleId} className="text-base font-semibold text-white flex items-center gap-2">
+            <Tag className="w-4 h-4 text-bambu-green" />
+            {t('fileManager.tags.bulkTitle', { count: fileIds.length })}
+          </h3>
+          <button
+            type="button"
+            className="p-1.5 text-bambu-gray hover:text-white rounded"
+            onClick={onClose}
+            aria-label={t('common.close')}
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        <div className="px-5 py-3 border-b border-bambu-dark-tertiary flex gap-4 text-sm">
+          <label className="flex items-center gap-2 cursor-pointer">
+            <input
+              type="radio"
+              name="bulk-action"
+              checked={action === 'add'}
+              onChange={() => setAction('add')}
+              className="accent-bambu-green"
+            />
+            <span className="text-white">{t('fileManager.tags.actionAdd')}</span>
+          </label>
+          <label className="flex items-center gap-2 cursor-pointer">
+            <input
+              type="radio"
+              name="bulk-action"
+              checked={action === 'remove'}
+              onChange={() => setAction('remove')}
+              className="accent-bambu-green"
+            />
+            <span className="text-white">{t('fileManager.tags.actionRemove')}</span>
+          </label>
+        </div>
+
+        <div className="px-5 py-3 border-b border-bambu-dark-tertiary">
+          <input
+            type="text"
+            value={filter}
+            onChange={(e) => setFilter(e.target.value)}
+            placeholder={t('fileManager.tags.searchPlaceholder')}
+            className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+          />
+        </div>
+
+        <div className="overflow-y-auto flex-1 min-h-[8rem] max-h-[24rem]">
+          {isLoading ? (
+            <div className="flex items-center justify-center py-12 text-bambu-gray">
+              <Loader2 className="w-5 h-5 animate-spin mr-2" />
+              {t('common.loading')}
+            </div>
+          ) : filteredTags.length === 0 ? (
+            <div className="py-12 text-center text-bambu-gray text-sm">
+              {tags.length === 0 ? t('fileManager.tags.empty') : t('fileManager.tags.noMatches')}
+            </div>
+          ) : (
+            <ul className="divide-y divide-bambu-dark-tertiary/40">
+              {filteredTags.map((tg) => (
+                <li key={tg.id}>
+                  <label className="flex items-center gap-3 px-5 py-2 hover:bg-bambu-dark-tertiary/30 cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={selected.has(tg.id)}
+                      onChange={() => toggleTag(tg.id)}
+                      className="accent-bambu-green"
+                    />
+                    <span className="text-sm text-white truncate flex-1">{tg.name}</span>
+                    <span className="text-xs text-bambu-gray">{tg.file_count}</span>
+                  </label>
+                </li>
+              ))}
+            </ul>
+          )}
+        </div>
+
+        {action === 'add' && (
+          <div className="px-5 py-3 border-t border-bambu-dark-tertiary flex gap-2">
+            <input
+              type="text"
+              value={newTagName}
+              onChange={(e) => setNewTagName(e.target.value)}
+              placeholder={t('fileManager.tags.createPlaceholder')}
+              maxLength={64}
+              className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+              onKeyDown={(e) => {
+                if (e.key === 'Enter' && !createDisabled) {
+                  e.preventDefault();
+                  createTagMutation.mutate(newTagName.trim());
+                }
+              }}
+            />
+            <Button
+              type="button"
+              variant="secondary"
+              onClick={() => createTagMutation.mutate(newTagName.trim())}
+              disabled={createDisabled}
+            >
+              {createTagMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
+              {t('fileManager.tags.createButton')}
+            </Button>
+          </div>
+        )}
+
+        <div className="px-5 py-4 border-t border-bambu-dark-tertiary flex justify-end gap-2">
+          <Button type="button" variant="secondary" onClick={onClose} disabled={applyMutation.isPending}>
+            {t('common.cancel')}
+          </Button>
+          <Button
+            type="button"
+            onClick={() => applyMutation.mutate()}
+            disabled={selected.size === 0 || applyMutation.isPending || fileIds.length === 0}
+          >
+            {applyMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
+            {action === 'add' ? t('fileManager.tags.applyAdd') : t('fileManager.tags.applyRemove')}
+          </Button>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 8 - 2
frontend/src/components/ConnectionDiagnostic.tsx

@@ -40,8 +40,12 @@ export function DiagnosticChecklist({ result }: { result: PrinterDiagnosticResul
         : 'bg-red-500/10 border-red-500/30 text-red-300';
 
   const renderCheck = (check: DiagnosticCheck) => {
+    const params =
+      check.id === 'port_rtsps'
+        ? { protocol: 'RTSPS', port: 322, ...check.params }
+        : check.params;
     const detail = t(`diagnostic.check.${check.id}.${check.status}`, {
-      ...check.params,
+      ...params,
       defaultValue: '',
     });
     return (
@@ -55,7 +59,9 @@ export function DiagnosticChecklist({ result }: { result: PrinterDiagnosticResul
           <StatusIcon status={check.status} />
         </div>
         <div className="flex-1 min-w-0">
-          <div className="text-sm text-white">{t(`diagnostic.check.${check.id}.title`)}</div>
+          <div className="text-sm text-white">
+            {t(`diagnostic.check.${check.id}.title`, params)}
+          </div>
           {detail && <div className="text-xs text-bambu-gray mt-0.5">{detail}</div>}
         </div>
       </li>

+ 101 - 0
frontend/src/components/FolderReadmePanel.tsx

@@ -0,0 +1,101 @@
+import { useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { ChevronDown, ChevronUp, FileText } from 'lucide-react';
+import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+
+import { api } from '../api/client';
+
+interface FolderReadmePanelProps {
+  folderId: number;
+}
+
+/**
+ * Side panel that renders a `.md` file from the selected folder (#1268).
+ * Hidden when the folder has no markdown file. Disables raw HTML and links
+ * stay text-only — same posture as the print-archive note panel.
+ */
+export function FolderReadmePanel({ folderId }: FolderReadmePanelProps) {
+  const { t } = useTranslation();
+  const [collapsed, setCollapsed] = useState(false);
+
+  const { data, isLoading, error } = useQuery({
+    queryKey: ['folder-readme', folderId],
+    queryFn: () => api.getLibraryFolderReadme(folderId),
+    retry: false,
+    staleTime: 30_000,
+  });
+
+  if (isLoading || error || !data) return null;
+
+  return (
+    <div className="mb-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg overflow-hidden">
+      <button
+        type="button"
+        onClick={() => setCollapsed((v) => !v)}
+        className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left hover:bg-bambu-dark/40 transition-colors"
+      >
+        <div className="flex items-center gap-2 min-w-0">
+          <FileText className="w-4 h-4 text-bambu-green flex-shrink-0" />
+          <span className="text-sm font-medium text-white truncate" title={data.filename}>
+            {data.filename}
+          </span>
+          {data.truncated && (
+            <span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400 flex-shrink-0">
+              {t('fileManager.readme.truncated')}
+            </span>
+          )}
+        </div>
+        {collapsed ? (
+          <ChevronDown className="w-4 h-4 text-bambu-gray flex-shrink-0" />
+        ) : (
+          <ChevronUp className="w-4 h-4 text-bambu-gray flex-shrink-0" />
+        )}
+      </button>
+      {!collapsed && (
+        <div className="px-4 py-3 border-t border-bambu-dark-tertiary max-h-96 overflow-y-auto text-sm text-bambu-gray-light leading-relaxed space-y-2">
+          <ReactMarkdown
+            remarkPlugins={[remarkGfm]}
+            components={{
+              h1: ({ children }) => <h1 className="text-lg font-semibold text-white mt-2 mb-1">{children}</h1>,
+              h2: ({ children }) => <h2 className="text-base font-semibold text-white mt-2 mb-1">{children}</h2>,
+              h3: ({ children }) => <h3 className="text-sm font-semibold text-white mt-2 mb-1">{children}</h3>,
+              p: ({ children }) => <p className="my-1">{children}</p>,
+              ul: ({ children }) => <ul className="list-disc list-inside space-y-0.5 ml-2">{children}</ul>,
+              ol: ({ children }) => <ol className="list-decimal list-inside space-y-0.5 ml-2">{children}</ol>,
+              li: ({ children }) => <li>{children}</li>,
+              code: ({ children, ...props }) => {
+                const inline = !(props as { className?: string }).className;
+                return inline ? (
+                  <code className="px-1 py-0.5 bg-bambu-dark rounded text-xs font-mono text-bambu-green">{children}</code>
+                ) : (
+                  <code className="block p-2 bg-bambu-dark rounded text-xs font-mono text-bambu-gray-light overflow-x-auto">{children}</code>
+                );
+              },
+              pre: ({ children }) => <pre className="my-2">{children}</pre>,
+              blockquote: ({ children }) => (
+                <blockquote className="border-l-2 border-bambu-dark-tertiary pl-3 text-bambu-gray italic">{children}</blockquote>
+              ),
+              a: ({ children, href }) => (
+                <a href={href} target="_blank" rel="noopener noreferrer" className="text-bambu-green hover:underline">
+                  {children}
+                </a>
+              ),
+              table: ({ children }) => (
+                <div className="overflow-x-auto">
+                  <table className="min-w-full text-xs border-collapse">{children}</table>
+                </div>
+              ),
+              th: ({ children }) => <th className="border border-bambu-dark-tertiary px-2 py-1 text-left font-semibold text-white">{children}</th>,
+              td: ({ children }) => <td className="border border-bambu-dark-tertiary px-2 py-1">{children}</td>,
+              hr: () => <hr className="border-bambu-dark-tertiary my-2" />,
+            }}
+          >
+            {data.content}
+          </ReactMarkdown>
+        </div>
+      )}
+    </div>
+  );
+}

+ 283 - 0
frontend/src/components/LibraryTagsModal.tsx

@@ -0,0 +1,283 @@
+import { useState, useEffect, useCallback } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Tag, Plus, Loader2, Pencil, Trash2, X } from 'lucide-react';
+
+import { api, type LibraryTag } from '../api/client';
+import { Button } from './Button';
+import { ConfirmModal } from './ConfirmModal';
+import { useToast } from '../contexts/ToastContext';
+import { libraryTagsQueryKey } from '../utils/libraryTagsQuery';
+
+interface LibraryTagsModalProps {
+  open: boolean;
+  onClose: () => void;
+  /** Optional callback when the user clicks a row to pick a tag for filtering. */
+  onPickTag?: (tagId: number) => void;
+}
+
+/**
+ * Catalog CRUD for #1268 library tags. Same shape as LocationsModal but tags
+ * are deletable while in use — the backend's ON DELETE CASCADE drops the
+ * association rows, files keep their identity. The confirm dialog warns the
+ * user when file_count > 0 so accidental deletion of a heavily-used tag isn't
+ * silent.
+ */
+export function LibraryTagsModal({ open, onClose, onPickTag }: LibraryTagsModalProps) {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+
+  const [editorOpen, setEditorOpen] = useState(false);
+  const [editing, setEditing] = useState<LibraryTag | null>(null);
+  const [name, setName] = useState('');
+  const [deleteTarget, setDeleteTarget] = useState<LibraryTag | null>(null);
+
+  const { data: tags = [], isLoading } = useQuery({
+    queryKey: libraryTagsQueryKey,
+    queryFn: api.getLibraryTags,
+    enabled: open,
+  });
+
+  const invalidate = () => {
+    queryClient.invalidateQueries({ queryKey: libraryTagsQueryKey });
+    // File listings carry the tags array — bump those too so chips refresh
+    // immediately after a rename/delete.
+    queryClient.invalidateQueries({ queryKey: ['library-files'] });
+  };
+
+  const saveMutation = useMutation({
+    mutationFn: async () => {
+      const trimmed = name.trim();
+      if (!trimmed) throw new Error(t('fileManager.tags.nameRequired'));
+      if (editing) {
+        return api.updateLibraryTag(editing.id, trimmed);
+      }
+      return api.createLibraryTag(trimmed);
+    },
+    onSuccess: () => {
+      showToast(t(editing ? 'fileManager.tags.updated' : 'fileManager.tags.created'), 'success');
+      setEditorOpen(false);
+      setEditing(null);
+      setName('');
+      invalidate();
+    },
+    onError: (err: Error) => {
+      showToast(err.message || t('fileManager.tags.saveFailed'), 'error');
+    },
+  });
+
+  const deleteMutation = useMutation({
+    mutationFn: (id: number) => api.deleteLibraryTag(id),
+    onSuccess: () => {
+      showToast(t('fileManager.tags.deleted'), 'success');
+      setDeleteTarget(null);
+      invalidate();
+    },
+    onError: (err: Error) => {
+      showToast(err.message || t('fileManager.tags.deleteFailed'), 'error');
+    },
+  });
+
+  const openCreate = () => {
+    setEditing(null);
+    setName('');
+    setEditorOpen(true);
+  };
+
+  const openEdit = (tag: LibraryTag) => {
+    setEditing(tag);
+    setName(tag.name);
+    setEditorOpen(true);
+  };
+
+  const closeEditor = useCallback(() => {
+    if (saveMutation.isPending) return;
+    setEditorOpen(false);
+    setEditing(null);
+    setName('');
+  }, [saveMutation.isPending]);
+
+  useEffect(() => {
+    if (!open) return;
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key !== 'Escape') return;
+      if (saveMutation.isPending || deleteMutation.isPending) return;
+      if (editorOpen) {
+        closeEditor();
+      } else if (!deleteTarget) {
+        onClose();
+      }
+    };
+    document.addEventListener('keydown', handleKeyDown);
+    return () => document.removeEventListener('keydown', handleKeyDown);
+  }, [open, editorOpen, deleteTarget, saveMutation.isPending, deleteMutation.isPending, closeEditor, onClose]);
+
+  const handleSave = (e: React.FormEvent) => {
+    e.preventDefault();
+    saveMutation.mutate();
+  };
+
+  if (!open) return null;
+
+  const modalTitleId = 'library-tags-modal-title';
+  const editorTitleId = 'library-tag-editor-title';
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center">
+      <div
+        className="absolute inset-0 bg-black/60"
+        onClick={() => {
+          if (saveMutation.isPending || deleteMutation.isPending) return;
+          onClose();
+        }}
+      />
+      <div
+        className="relative w-full max-w-4xl mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] flex flex-col"
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby={modalTitleId}
+      >
+        <div className="flex items-center justify-between gap-4 px-6 py-4 border-b border-bambu-dark-tertiary">
+          <div className="min-w-0 flex-1">
+            <h2 id={modalTitleId} className="text-lg font-semibold text-white flex items-center gap-2">
+              <Tag className="w-5 h-5 text-bambu-green" />
+              {t('fileManager.tags.title')}
+            </h2>
+            <p className="text-bambu-gray text-sm mt-0.5">{t('fileManager.tags.subtitle')}</p>
+          </div>
+          <div className="flex items-center gap-2">
+            <Button onClick={openCreate}>
+              <Plus className="w-4 h-4" />
+              {t('fileManager.tags.add')}
+            </Button>
+            <button
+              type="button"
+              className="p-1.5 text-bambu-gray hover:text-white rounded"
+              onClick={onClose}
+              aria-label={t('common.close')}
+            >
+              <X className="w-5 h-5" />
+            </button>
+          </div>
+        </div>
+
+        <div className="overflow-y-auto">
+          {isLoading ? (
+            <div className="flex items-center justify-center py-16 text-bambu-gray">
+              <Loader2 className="w-6 h-6 animate-spin mr-2" />
+              {t('common.loading')}
+            </div>
+          ) : tags.length === 0 ? (
+            <div className="py-16 text-center text-bambu-gray">{t('fileManager.tags.empty')}</div>
+          ) : (
+            <table className="w-full text-sm">
+              <thead>
+                <tr className="border-b border-bambu-dark-tertiary text-left text-bambu-gray">
+                  <th className="px-4 py-3 font-medium">{t('fileManager.tags.name')}</th>
+                  <th className="px-4 py-3 font-medium text-right">{t('fileManager.tags.fileCount')}</th>
+                  <th className="px-4 py-3 font-medium text-right w-32">{t('common.actions')}</th>
+                </tr>
+              </thead>
+              <tbody>
+                {tags.map((tag) => (
+                  <tr
+                    key={tag.id}
+                    className={`border-b border-bambu-dark-tertiary/60 hover:bg-bambu-dark-tertiary/30 ${onPickTag ? 'cursor-pointer' : ''}`}
+                    onClick={() => {
+                      if (onPickTag) {
+                        onPickTag(tag.id);
+                        onClose();
+                      }
+                    }}
+                  >
+                    <td className="px-4 py-3 text-white font-medium">{tag.name}</td>
+                    <td className="px-4 py-3 text-right text-bambu-gray">{tag.file_count}</td>
+                    <td className="px-4 py-3 text-right" onClick={(e) => e.stopPropagation()}>
+                      <div className="flex items-center justify-end gap-1">
+                        <button
+                          type="button"
+                          className="p-1.5 text-bambu-gray hover:text-bambu-green rounded"
+                          onClick={() => openEdit(tag)}
+                          title={t('common.edit')}
+                          aria-label={t('fileManager.tags.editAria', { name: tag.name })}
+                        >
+                          <Pencil className="w-4 h-4" />
+                        </button>
+                        <button
+                          type="button"
+                          className="p-1.5 text-bambu-gray hover:text-red-400 rounded"
+                          onClick={() => setDeleteTarget(tag)}
+                          title={t('common.delete')}
+                          aria-label={t('fileManager.tags.deleteAria', { name: tag.name })}
+                        >
+                          <Trash2 className="w-4 h-4" />
+                        </button>
+                      </div>
+                    </td>
+                  </tr>
+                ))}
+              </tbody>
+            </table>
+          )}
+        </div>
+      </div>
+
+      {editorOpen && (
+        <div className="fixed inset-0 z-[60] flex items-center justify-center">
+          <div className="absolute inset-0 bg-black/60" onClick={closeEditor} />
+          <div
+            className="relative w-full max-w-md mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl p-6 shadow-2xl"
+            role="dialog"
+            aria-modal="true"
+            aria-labelledby={editorTitleId}
+          >
+            <h3 id={editorTitleId} className="text-lg font-semibold text-white mb-4">
+              {editing ? t('fileManager.tags.edit') : t('fileManager.tags.add')}
+            </h3>
+            <form onSubmit={handleSave}>
+              <label className="block text-sm font-medium text-bambu-gray mb-1" htmlFor="library-tag-name">
+                {t('fileManager.tags.name')}
+              </label>
+              <input
+                id="library-tag-name"
+                type="text"
+                maxLength={64}
+                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green mb-4"
+                placeholder={t('fileManager.tags.createPlaceholder')}
+                value={name}
+                onChange={(e) => setName(e.target.value)}
+                autoFocus
+              />
+              <div className="flex justify-end gap-2">
+                <Button type="button" variant="secondary" onClick={closeEditor}>
+                  {t('common.cancel')}
+                </Button>
+                <Button type="submit" disabled={saveMutation.isPending || !name.trim()}>
+                  {saveMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
+                  {t('common.save')}
+                </Button>
+              </div>
+            </form>
+          </div>
+        </div>
+      )}
+
+      {deleteTarget && (
+        <ConfirmModal
+          title={t('fileManager.tags.confirmDelete', { name: deleteTarget.name })}
+          message={
+            deleteTarget.file_count > 0
+              ? t('fileManager.tags.confirmDeleteInUseMessage', { count: deleteTarget.file_count })
+              : t('fileManager.tags.confirmDeleteMessage')
+          }
+          confirmText={t('common.delete')}
+          variant="danger"
+          isLoading={deleteMutation.isPending}
+          onConfirm={() => deleteMutation.mutate(deleteTarget.id)}
+          onCancel={() => setDeleteTarget(null)}
+        />
+      )}
+    </div>
+  );
+}

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

@@ -29,13 +29,16 @@ const resources = {
   tr: { translation: tr },
 };
 
+const SUPPORTED_LNGS = ['en', 'de', 'es', 'fr', 'ja', 'it', 'ko', 'pt-BR', 'tr', 'zh-CN', 'zh-TW'];
+const APPLIANCE_CONSUMED_KEY = 'bambuddy_appliance_locale_consumed';
+
 i18n
   .use(LanguageDetector)
   .use(initReactI18next)
   .init({
     resources,
     fallbackLng: 'en',
-    supportedLngs: ['en', 'de', 'es', 'fr', 'ja', 'it', 'ko', 'pt-BR', 'tr', 'zh-CN', 'zh-TW'],
+    supportedLngs: SUPPORTED_LNGS,
 
     detection: {
       // Order of detection methods
@@ -55,6 +58,38 @@ i18n
     },
   });
 
+/**
+ * Bambuddy Appliance hook: on the first SPA load after the firstboot wizard
+ * runs, /api/v1/system/appliance returns the locale the user picked. We
+ * apply it once (gated by a localStorage flag) and stop. On non-appliance
+ * installs the endpoint either 404s or returns nulls — silent no-op.
+ *
+ * This runs AFTER i18n.init so the LanguageDetector has already populated a
+ * default; we override that default exactly once for fresh appliances. The
+ * appliance is then "consumed" and the language picker is the only way to
+ * change locale going forward (the wizard ran once; future intent comes from
+ * the running UI).
+ */
+function applyApplianceLocale() {
+  if (typeof window === 'undefined' || !window.localStorage) return;
+  if (window.localStorage.getItem(APPLIANCE_CONSUMED_KEY)) return;
+
+  fetch('/api/v1/system/appliance')
+    .then((r) => (r.ok ? r.json() : null))
+    .then((data) => {
+      if (!data || typeof data.locale !== 'string') return;
+      if (!SUPPORTED_LNGS.includes(data.locale)) return;
+      i18n.changeLanguage(data.locale);
+      window.localStorage.setItem(APPLIANCE_CONSUMED_KEY, '1');
+    })
+    .catch(() => {
+      // Endpoint absent or unreachable — non-appliance install or dev environment.
+      // Leave the detector's choice in place.
+    });
+}
+
+applyApplianceLocale();
+
 export default i18n;
 
 // Helper to get available languages

+ 45 - 2
frontend/src/i18n/locales/de.ts

@@ -3340,6 +3340,49 @@ export default {
     folderSortByActivity: 'Nach letzter Aktivität',
     dragToResizeTooltip: 'Ziehen zum Ändern der Größe, Doppelklick zum Zurücksetzen',
     searchFiles: 'Dateien suchen...',
+    searchSubfoldersHint: 'Inklusive Unterordner',
+    readme: {
+      truncated: 'Gekürzt',
+    },
+    tags: {
+      title: 'Tags',
+      subtitle: 'Dateien mit Labels versehen — Spielzeug, kindersicher, nur PETG, was immer du willst.',
+      manage: 'Tags',
+      manageTitle: 'Tag-Katalog verwalten',
+      add: 'Neuer Tag',
+      edit: 'Tag umbenennen',
+      name: 'Name',
+      fileCount: 'Dateien',
+      empty: 'Noch keine Tags. Erstelle einen, um Dateien zu kennzeichnen.',
+      noMatches: 'Keine passenden Tags.',
+      createPlaceholder: 'z. B. Spielzeug, kindersicher, petg',
+      createButton: 'Erstellen',
+      nameRequired: 'Name ist erforderlich.',
+      searchPlaceholder: 'Tags filtern...',
+      created: 'Tag erstellt.',
+      updated: 'Tag umbenannt.',
+      deleted: 'Tag entfernt.',
+      saveFailed: 'Tag konnte nicht gespeichert werden.',
+      deleteFailed: 'Tag konnte nicht entfernt werden.',
+      applyFailed: 'Tags konnten nicht angewendet werden.',
+      applyAdd: 'Tags hinzufügen',
+      applyRemove: 'Tags entfernen',
+      applyAddSuccess: '{{count}} Tag(s) zu {{files}} Datei(en) hinzugefügt.',
+      applyRemoveSuccess: '{{count}} Tag(s) von {{files}} Datei(en) entfernt.',
+      actionAdd: 'Zu ausgewählten Dateien hinzufügen',
+      actionRemove: 'Von ausgewählten Dateien entfernen',
+      tagAction: 'Tag',
+      bulkTitle: '{{count}} ausgewählte Datei(en) taggen',
+      bulkTooltip: 'Tags für alle ausgewählten Dateien hinzufügen oder entfernen.',
+      noPermission: 'Du hast keine Berechtigung, Dateien zu taggen.',
+      filterLabel: 'Filtern nach:',
+      clearAll: 'Alle entfernen',
+      confirmDelete: 'Tag "{{name}}" löschen?',
+      confirmDeleteMessage: 'Entfernt den Tag aus dem Katalog. Dateien behalten ihre übrigen Tags.',
+      confirmDeleteInUseMessage: 'Dieser Tag ist auf {{count}} Datei(en). Beim Löschen verschwindet er von allen; die Dateien selbst bleiben unverändert.',
+      editAria: '{{name}} bearbeiten',
+      deleteAria: '{{name}} löschen',
+    },
     allTypes: 'Alle Typen',
     prints: 'Drucke',
     ascending: 'Aufsteigend',
@@ -5842,9 +5885,9 @@ export default {
         skip: 'Nicht geprüft — eine aktive MQTT-Verbindung ist erforderlich. Bei älteren Slicern, in denen diese Einstellung nur im Slicer existiert, meldet sie der Drucker nicht — diese Prüfung besteht auch dann, wenn die Option deaktiviert ist. Prüfen Sie Installationsschritt 4 in diesem Fall manuell.',
       },
       port_rtsps: {
-        title: 'Kameraport (RTSPS 322)',
+        title: 'Kameraport ({{protocol}} {{port}})',
         pass: 'Erreichbar — der Kamerastream funktioniert.',
-        warn: 'Port 322 ist nicht erreichbar. Die Live-Kameraansicht funktioniert nicht. Dies betrifft das Drucken nicht.',
+        warn: 'Port {{port}} ist nicht erreichbar. Die Live-Kameraansicht funktioniert nicht. Dies betrifft das Drucken nicht.',
       },
       network_mode: {
         title: 'Docker-Netzwerkmodus',

+ 45 - 2
frontend/src/i18n/locales/en.ts

@@ -3355,6 +3355,49 @@ export default {
     folderSortByActivity: 'By recent activity',
     dragToResizeTooltip: 'Drag to resize, double-click to reset',
     searchFiles: 'Search files...',
+    searchSubfoldersHint: 'Including subfolders',
+    readme: {
+      truncated: 'Truncated',
+    },
+    tags: {
+      title: 'Tags',
+      subtitle: 'Label files for cross-cutting filtering — toys, kid-safe, PETG-only, anything.',
+      manage: 'Tags',
+      manageTitle: 'Manage tag catalog',
+      add: 'New tag',
+      edit: 'Rename tag',
+      name: 'Name',
+      fileCount: 'Files',
+      empty: 'No tags yet. Create one to start labelling files.',
+      noMatches: 'No matching tags.',
+      createPlaceholder: 'e.g. toys, kid-safe, petg',
+      createButton: 'Create',
+      nameRequired: 'Name is required.',
+      searchPlaceholder: 'Filter tags...',
+      created: 'Tag created.',
+      updated: 'Tag renamed.',
+      deleted: 'Tag removed.',
+      saveFailed: 'Could not save tag.',
+      deleteFailed: 'Could not remove tag.',
+      applyFailed: 'Could not apply tags.',
+      applyAdd: 'Add tags',
+      applyRemove: 'Remove tags',
+      applyAddSuccess: 'Added {{count}} tag(s) across {{files}} file(s).',
+      applyRemoveSuccess: 'Removed {{count}} tag(s) across {{files}} file(s).',
+      actionAdd: 'Add to selected files',
+      actionRemove: 'Remove from selected files',
+      tagAction: 'Tag',
+      bulkTitle: 'Tag {{count}} selected file(s)',
+      bulkTooltip: 'Add or remove tags on every selected file.',
+      noPermission: 'You do not have permission to tag files.',
+      filterLabel: 'Filtering by:',
+      clearAll: 'Clear all',
+      confirmDelete: 'Delete tag "{{name}}"?',
+      confirmDeleteMessage: 'This removes the tag from the catalog. Files keep their other tags.',
+      confirmDeleteInUseMessage: 'This tag is on {{count}} file(s). Deleting removes the chip from all of them; files themselves are untouched.',
+      editAria: 'Edit {{name}}',
+      deleteAria: 'Delete {{name}}',
+    },
     allTypes: 'All types',
     prints: 'Prints',
     ascending: 'Ascending',
@@ -5867,9 +5910,9 @@ export default {
         skip: 'Not checked — needs a live MQTT connection. On older slicers where this setting lives only in the slicer the printer never reports it, so this check will pass even when the option is off — verify install step 4 manually.',
       },
       port_rtsps: {
-        title: 'Camera port (RTSPS 322)',
+        title: 'Camera port ({{protocol}} {{port}})',
         pass: 'Reachable — the camera stream will work.',
-        warn: 'Port 322 is unreachable. The live camera view will not work. This does not affect printing.',
+        warn: 'Port {{port}} is unreachable. The live camera view will not work. This does not affect printing.',
       },
       network_mode: {
         title: 'Docker network mode',

+ 45 - 2
frontend/src/i18n/locales/es.ts

@@ -3343,6 +3343,49 @@ export default {
     folderSortByActivity: 'Por actividad reciente',
     dragToResizeTooltip: 'Arrastre para redimensionar, doble clic para restablecer',
     searchFiles: 'Buscar archivos...',
+    searchSubfoldersHint: 'Incluyendo subcarpetas',
+    readme: {
+      truncated: 'Truncado',
+    },
+    tags: {
+      title: 'Etiquetas',
+      subtitle: 'Etiqueta archivos para filtros transversales — juguetes, apto para niños, solo PETG, lo que necesites.',
+      manage: 'Etiquetas',
+      manageTitle: 'Gestionar el catálogo de etiquetas',
+      add: 'Nueva etiqueta',
+      edit: 'Renombrar etiqueta',
+      name: 'Nombre',
+      fileCount: 'Archivos',
+      empty: 'Aún no hay etiquetas. Crea una para empezar a etiquetar archivos.',
+      noMatches: 'No hay etiquetas que coincidan.',
+      createPlaceholder: 'p. ej. juguetes, apto-niños, petg',
+      createButton: 'Crear',
+      nameRequired: 'El nombre es obligatorio.',
+      searchPlaceholder: 'Filtrar etiquetas...',
+      created: 'Etiqueta creada.',
+      updated: 'Etiqueta renombrada.',
+      deleted: 'Etiqueta eliminada.',
+      saveFailed: 'No se pudo guardar la etiqueta.',
+      deleteFailed: 'No se pudo eliminar la etiqueta.',
+      applyFailed: 'No se pudieron aplicar las etiquetas.',
+      applyAdd: 'Añadir etiquetas',
+      applyRemove: 'Quitar etiquetas',
+      applyAddSuccess: 'Se añadieron {{count}} etiqueta(s) en {{files}} archivo(s).',
+      applyRemoveSuccess: 'Se quitaron {{count}} etiqueta(s) en {{files}} archivo(s).',
+      actionAdd: 'Añadir a los archivos seleccionados',
+      actionRemove: 'Quitar de los archivos seleccionados',
+      tagAction: 'Etiquetar',
+      bulkTitle: 'Etiquetar {{count}} archivo(s) seleccionado(s)',
+      bulkTooltip: 'Añadir o quitar etiquetas en cada archivo seleccionado.',
+      noPermission: 'No tienes permiso para etiquetar archivos.',
+      filterLabel: 'Filtrando por:',
+      clearAll: 'Limpiar todo',
+      confirmDelete: '¿Eliminar la etiqueta "{{name}}"?',
+      confirmDeleteMessage: 'Esto elimina la etiqueta del catálogo. Los archivos conservan sus otras etiquetas.',
+      confirmDeleteInUseMessage: 'Esta etiqueta está en {{count}} archivo(s). Al eliminarla desaparece de todos; los archivos en sí no se tocan.',
+      editAria: 'Editar {{name}}',
+      deleteAria: 'Eliminar {{name}}',
+    },
     allTypes: 'Todos los tipos',
     prints: 'Impresiones',
     ascending: 'Ascendente',
@@ -5851,9 +5894,9 @@ export default {
         skip: 'No comprobado — se necesita una conexión MQTT activa. En slicers más antiguos donde este ajuste solo existe en el slicer, la impresora no lo reporta, así que esta comprobación pasa aunque la opción esté desactivada — verifique el paso 4 de la instalación manualmente.',
       },
       port_rtsps: {
-        title: 'Puerto de la cámara (RTSPS 322)',
+        title: 'Puerto de la cámara ({{protocol}} {{port}})',
         pass: 'Accesible — la transmisión de la cámara funcionará.',
-        warn: 'El puerto 322 no es accesible. La vista de la cámara en directo no funcionará. Esto no afecta a la impresión.',
+        warn: 'El puerto {{port}} no es accesible. La vista de la cámara en directo no funcionará. Esto no afecta a la impresión.',
       },
       network_mode: {
         title: 'Modo de red de Docker',

+ 45 - 2
frontend/src/i18n/locales/fr.ts

@@ -3329,6 +3329,49 @@ export default {
     folderSortByActivity: 'Par activité récente',
     dragToResizeTooltip: 'Glisser pour redimensionner, double-clic reset',
     searchFiles: 'Chercher fichiers...',
+    searchSubfoldersHint: 'Sous-dossiers inclus',
+    readme: {
+      truncated: 'Tronqué',
+    },
+    tags: {
+      title: 'Étiquettes',
+      subtitle: 'Étiquetez les fichiers pour des filtres transversaux — jouets, adapté aux enfants, PETG uniquement, etc.',
+      manage: 'Étiquettes',
+      manageTitle: 'Gérer le catalogue d\'étiquettes',
+      add: 'Nouvelle étiquette',
+      edit: 'Renommer l\'étiquette',
+      name: 'Nom',
+      fileCount: 'Fichiers',
+      empty: 'Aucune étiquette pour le moment. Créez-en une pour commencer.',
+      noMatches: 'Aucune étiquette correspondante.',
+      createPlaceholder: 'ex. jouets, kid-safe, petg',
+      createButton: 'Créer',
+      nameRequired: 'Le nom est requis.',
+      searchPlaceholder: 'Filtrer les étiquettes...',
+      created: 'Étiquette créée.',
+      updated: 'Étiquette renommée.',
+      deleted: 'Étiquette supprimée.',
+      saveFailed: 'Impossible d\'enregistrer l\'étiquette.',
+      deleteFailed: 'Impossible de supprimer l\'étiquette.',
+      applyFailed: 'Impossible d\'appliquer les étiquettes.',
+      applyAdd: 'Ajouter des étiquettes',
+      applyRemove: 'Retirer des étiquettes',
+      applyAddSuccess: '{{count}} étiquette(s) ajoutée(s) sur {{files}} fichier(s).',
+      applyRemoveSuccess: '{{count}} étiquette(s) retirée(s) sur {{files}} fichier(s).',
+      actionAdd: 'Ajouter aux fichiers sélectionnés',
+      actionRemove: 'Retirer des fichiers sélectionnés',
+      tagAction: 'Étiqueter',
+      bulkTitle: 'Étiqueter {{count}} fichier(s) sélectionné(s)',
+      bulkTooltip: 'Ajouter ou retirer des étiquettes sur chaque fichier sélectionné.',
+      noPermission: 'Vous n\'avez pas la permission d\'étiqueter les fichiers.',
+      filterLabel: 'Filtré par :',
+      clearAll: 'Tout effacer',
+      confirmDelete: 'Supprimer l\'étiquette « {{name}} » ?',
+      confirmDeleteMessage: 'Cela supprime l\'étiquette du catalogue. Les fichiers conservent leurs autres étiquettes.',
+      confirmDeleteInUseMessage: 'Cette étiquette est sur {{count}} fichier(s). La suppression la retire de tous ; les fichiers eux-mêmes ne sont pas touchés.',
+      editAria: 'Modifier {{name}}',
+      deleteAria: 'Supprimer {{name}}',
+    },
     allTypes: 'Tous types',
     prints: 'Impressions',
     ascending: 'Croissant',
@@ -5832,9 +5875,9 @@ export default {
         skip: 'Non vérifié — une connexion MQTT active est requise. Sur les slicers plus anciens où ce paramètre n\'existe que dans le slicer, l\'imprimante ne le signale pas, donc cette vérification passe même si l\'option est désactivée — vérifiez l\'étape 4 de l\'installation manuellement.',
       },
       port_rtsps: {
-        title: 'Port caméra (RTSPS 322)',
+        title: 'Port caméra ({{protocol}} {{port}})',
         pass: 'Accessible — le flux de la caméra fonctionnera.',
-        warn: 'Le port 322 est inaccessible. La vue caméra en direct ne fonctionnera pas. Cela n\'affecte pas l\'impression.',
+        warn: 'Le port {{port}} est inaccessible. La vue caméra en direct ne fonctionnera pas. Cela n\'affecte pas l\'impression.',
       },
       network_mode: {
         title: 'Mode réseau Docker',

+ 45 - 2
frontend/src/i18n/locales/it.ts

@@ -3328,6 +3328,49 @@ export default {
     folderSortByActivity: 'Per attività recente',
     dragToResizeTooltip: 'Trascina per ridimensionare, doppio clic per reset',
     searchFiles: 'Cerca file...',
+    searchSubfoldersHint: 'Sottocartelle incluse',
+    readme: {
+      truncated: 'Troncato',
+    },
+    tags: {
+      title: 'Etichette',
+      subtitle: 'Etichetta i file per filtri trasversali — giocattoli, sicuri per bambini, solo PETG, qualunque cosa serva.',
+      manage: 'Etichette',
+      manageTitle: 'Gestisci il catalogo delle etichette',
+      add: 'Nuova etichetta',
+      edit: 'Rinomina etichetta',
+      name: 'Nome',
+      fileCount: 'File',
+      empty: 'Nessuna etichetta. Creane una per iniziare.',
+      noMatches: 'Nessuna etichetta corrispondente.',
+      createPlaceholder: 'es. giocattoli, sicuro-bimbi, petg',
+      createButton: 'Crea',
+      nameRequired: 'Il nome è obbligatorio.',
+      searchPlaceholder: 'Filtra etichette...',
+      created: 'Etichetta creata.',
+      updated: 'Etichetta rinominata.',
+      deleted: 'Etichetta rimossa.',
+      saveFailed: 'Impossibile salvare l\'etichetta.',
+      deleteFailed: 'Impossibile rimuovere l\'etichetta.',
+      applyFailed: 'Impossibile applicare le etichette.',
+      applyAdd: 'Aggiungi etichette',
+      applyRemove: 'Rimuovi etichette',
+      applyAddSuccess: 'Aggiunte {{count}} etichetta/e su {{files}} file.',
+      applyRemoveSuccess: 'Rimosse {{count}} etichetta/e da {{files}} file.',
+      actionAdd: 'Aggiungi ai file selezionati',
+      actionRemove: 'Rimuovi dai file selezionati',
+      tagAction: 'Etichetta',
+      bulkTitle: 'Etichetta {{count}} file selezionato/i',
+      bulkTooltip: 'Aggiungi o rimuovi etichette su ogni file selezionato.',
+      noPermission: 'Non hai il permesso per etichettare i file.',
+      filterLabel: 'Filtraggio per:',
+      clearAll: 'Pulisci tutto',
+      confirmDelete: 'Eliminare l\'etichetta "{{name}}"?',
+      confirmDeleteMessage: 'Rimuove l\'etichetta dal catalogo. I file mantengono le altre etichette.',
+      confirmDeleteInUseMessage: 'Questa etichetta è su {{count}} file. L\'eliminazione la rimuove da tutti; i file non vengono toccati.',
+      editAria: 'Modifica {{name}}',
+      deleteAria: 'Elimina {{name}}',
+    },
     allTypes: 'Tutti i tipi',
     prints: 'Stampe',
     ascending: 'Crescente',
@@ -5831,9 +5874,9 @@ export default {
         skip: 'Non verificato — è necessaria una connessione MQTT attiva. Negli slicer più vecchi dove questa impostazione esiste solo nello slicer, la stampante non la segnala, quindi questo controllo passa anche se l\'opzione è disattivata — verifica manualmente il passo 4 dell\'installazione.',
       },
       port_rtsps: {
-        title: 'Porta fotocamera (RTSPS 322)',
+        title: 'Porta fotocamera ({{protocol}} {{port}})',
         pass: 'Raggiungibile — lo streaming della fotocamera funzionerà.',
-        warn: 'La porta 322 non è raggiungibile. La visualizzazione live della fotocamera non funzionerà. Questo non influisce sulla stampa.',
+        warn: 'La porta {{port}} non è raggiungibile. La visualizzazione live della fotocamera non funzionerà. Questo non influisce sulla stampa.',
       },
       network_mode: {
         title: 'Modalità di rete Docker',

+ 45 - 2
frontend/src/i18n/locales/ja.ts

@@ -3340,6 +3340,49 @@ export default {
     folderSortByActivity: '最終更新順',
     dragToResizeTooltip: 'ドラッグしてリサイズ、ダブルクリックでリセット',
     searchFiles: 'ファイルを検索...',
+    searchSubfoldersHint: 'サブフォルダーを含む',
+    readme: {
+      truncated: '切り詰め',
+    },
+    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: '{{files}}個のファイルに{{count}}個のタグを追加しました。',
+      applyRemoveSuccess: '{{files}}個のファイルから{{count}}個のタグを削除しました。',
+      actionAdd: '選択したファイルに追加',
+      actionRemove: '選択したファイルから削除',
+      tagAction: 'タグ付け',
+      bulkTitle: '選択した{{count}}個のファイルにタグ付け',
+      bulkTooltip: '選択したすべてのファイルでタグを追加・削除します。',
+      noPermission: 'ファイルにタグを付ける権限がありません。',
+      filterLabel: '絞り込み:',
+      clearAll: 'すべてクリア',
+      confirmDelete: 'タグ「{{name}}」を削除しますか?',
+      confirmDeleteMessage: 'カタログからタグを削除します。ファイル自体や他のタグはそのまま残ります。',
+      confirmDeleteInUseMessage: 'このタグは{{count}}個のファイルに付いています。削除するとすべてのファイルからチップが消えますが、ファイル自体はそのままです。',
+      editAria: '{{name}}を編集',
+      deleteAria: '{{name}}を削除',
+    },
     allTypes: 'すべての種類',
     prints: '印刷回数',
     ascending: '昇順',
@@ -5843,9 +5886,9 @@ export default {
         skip: '未確認 — アクティブなMQTT接続が必要です。古いスライサーでこの設定がスライサー側のみに存在する場合、プリンターはそれを報告しないため、オプションが無効でもこのチェックは通過します — インストール手順4を手動で確認してください。',
       },
       port_rtsps: {
-        title: 'カメラポート (RTSPS 322)',
+        title: 'カメラポート ({{protocol}} {{port}})',
         pass: '到達可能 — カメラストリームは機能します。',
-        warn: 'ポート322に到達できません。ライブカメラ表示は機能しません。これは印刷には影響しません。',
+        warn: 'ポート{{port}}に到達できません。ライブカメラ表示は機能しません。これは印刷には影響しません。',
       },
       network_mode: {
         title: 'Dockerネットワークモード',

+ 45 - 2
frontend/src/i18n/locales/ko.ts

@@ -3153,6 +3153,49 @@ export default {
     folderSortByActivity: '최근 활동순',
     dragToResizeTooltip: '드래그하여 크기 조정, 더블클릭하여 초기화',
     searchFiles: '파일 검색...',
+    searchSubfoldersHint: '하위 폴더 포함',
+    readme: {
+      truncated: '잘림',
+    },
+    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: '{{files}}개 파일에 태그 {{count}}개를 추가했습니다.',
+      applyRemoveSuccess: '{{files}}개 파일에서 태그 {{count}}개를 제거했습니다.',
+      actionAdd: '선택한 파일에 추가',
+      actionRemove: '선택한 파일에서 제거',
+      tagAction: '태그',
+      bulkTitle: '선택한 {{count}}개 파일에 태그 지정',
+      bulkTooltip: '선택한 모든 파일에 태그를 추가하거나 제거합니다.',
+      noPermission: '파일에 태그를 붙일 권한이 없습니다.',
+      filterLabel: '필터:',
+      clearAll: '모두 지우기',
+      confirmDelete: '태그 "{{name}}"을(를) 삭제하시겠습니까?',
+      confirmDeleteMessage: '카탈로그에서 태그를 제거합니다. 파일과 다른 태그는 그대로 유지됩니다.',
+      confirmDeleteInUseMessage: '이 태그는 {{count}}개 파일에 있습니다. 삭제하면 모든 파일에서 칩이 사라지지만 파일 자체는 변경되지 않습니다.',
+      editAria: '{{name}} 편집',
+      deleteAria: '{{name}} 삭제',
+    },
     allTypes: '모든 유형',
     prints: '인쇄물',
     ascending: '오름차순',
@@ -5891,9 +5934,9 @@ export default {
         skip: '확인되지 않음 — 활성 MQTT 연결이 필요합니다. 이 설정이 슬라이서에만 존재하는 이전 슬라이서에서는 프린터가 보고하지 않으므로, 옵션이 꺼져 있어도 이 검사는 통과합니다 — 설치 단계 4를 수동으로 확인하세요.'
       },
       port_rtsps: {
-        title: '카메라 포트 (RTSPS 322)',
+        title: '카메라 포트 ({{protocol}} {{port}})',
         pass: '연결 가능 — 카메라 스트림이 작동합니다.',
-        warn: '포트 322에 연결할 수 없습니다. 라이브 카메라 보기가 작동하지 않습니다. 인쇄에는 영향을 주지 않습니다.'
+        warn: '포트 {{port}}에 연결할 수 없습니다. 라이브 카메라 보기가 작동하지 않습니다. 인쇄에는 영향을 주지 않습니다.'
       },
       network_mode: {
         title: 'Docker 네트워크 모드',

+ 45 - 2
frontend/src/i18n/locales/pt-BR.ts

@@ -3328,6 +3328,49 @@ export default {
     folderSortByActivity: 'Por atividade recente',
     dragToResizeTooltip: 'Arraste para redimensionar, clique duas vezes para redefinir',
     searchFiles: 'Pesquisar arquivos...',
+    searchSubfoldersHint: 'Incluindo subpastas',
+    readme: {
+      truncated: 'Truncado',
+    },
+    tags: {
+      title: 'Tags',
+      subtitle: 'Rotule arquivos para filtros transversais — brinquedos, seguro para crianças, somente PETG, o que precisar.',
+      manage: 'Tags',
+      manageTitle: 'Gerenciar catálogo de tags',
+      add: 'Nova tag',
+      edit: 'Renomear tag',
+      name: 'Nome',
+      fileCount: 'Arquivos',
+      empty: 'Ainda não há tags. Crie uma para começar a rotular arquivos.',
+      noMatches: 'Nenhuma tag correspondente.',
+      createPlaceholder: 'ex.: brinquedos, infantil, petg',
+      createButton: 'Criar',
+      nameRequired: 'O nome é obrigatório.',
+      searchPlaceholder: 'Filtrar tags...',
+      created: 'Tag criada.',
+      updated: 'Tag renomeada.',
+      deleted: 'Tag removida.',
+      saveFailed: 'Não foi possível salvar a tag.',
+      deleteFailed: 'Não foi possível remover a tag.',
+      applyFailed: 'Não foi possível aplicar as tags.',
+      applyAdd: 'Adicionar tags',
+      applyRemove: 'Remover tags',
+      applyAddSuccess: '{{count}} tag(s) adicionada(s) em {{files}} arquivo(s).',
+      applyRemoveSuccess: '{{count}} tag(s) removida(s) em {{files}} arquivo(s).',
+      actionAdd: 'Adicionar aos arquivos selecionados',
+      actionRemove: 'Remover dos arquivos selecionados',
+      tagAction: 'Marcar',
+      bulkTitle: 'Marcar {{count}} arquivo(s) selecionado(s)',
+      bulkTooltip: 'Adicionar ou remover tags em cada arquivo selecionado.',
+      noPermission: 'Você não tem permissão para marcar arquivos.',
+      filterLabel: 'Filtrando por:',
+      clearAll: 'Limpar tudo',
+      confirmDelete: 'Excluir a tag "{{name}}"?',
+      confirmDeleteMessage: 'Isto remove a tag do catálogo. Os arquivos mantêm as outras tags.',
+      confirmDeleteInUseMessage: 'Esta tag está em {{count}} arquivo(s). Excluí-la remove o chip de todos; os arquivos em si ficam intactos.',
+      editAria: 'Editar {{name}}',
+      deleteAria: 'Excluir {{name}}',
+    },
     allTypes: 'Todos os tipos',
     prints: 'Impressões',
     ascending: 'Crescente',
@@ -5831,9 +5874,9 @@ export default {
         skip: 'Não verificado — é necessária uma conexão MQTT ativa. Em fatiadores mais antigos onde essa configuração existe apenas no fatiador, a impressora não a reporta, então esta verificação passa mesmo com a opção desligada — verifique o passo 4 da instalação manualmente.',
       },
       port_rtsps: {
-        title: 'Porta da câmera (RTSPS 322)',
+        title: 'Porta da câmera ({{protocol}} {{port}})',
         pass: 'Acessível — o streaming da câmera funcionará.',
-        warn: 'A porta 322 está inacessível. A visualização ao vivo da câmera não funcionará. Isso não afeta a impressão.',
+        warn: 'A porta {{port}} está inacessível. A visualização ao vivo da câmera não funcionará. Isso não afeta a impressão.',
       },
       network_mode: {
         title: 'Modo de rede Docker',

+ 45 - 2
frontend/src/i18n/locales/tr.ts

@@ -3335,6 +3335,49 @@ export default {
     folderSortByActivity: 'Son etkinliğe göre',
     dragToResizeTooltip: 'Yeniden boyutlandırmak için sürükleyin, sıfırlamak için çift tıklayın',
     searchFiles: 'Dosyalarda ara...',
+    searchSubfoldersHint: 'Alt klasörler dahil',
+    readme: {
+      truncated: 'Kısaltıldı',
+    },
+    tags: {
+      title: 'Etiketler',
+      subtitle: 'Çapraz filtreleme için dosyaları etiketleyin — oyuncak, çocuklar için güvenli, sadece PETG, ne isterseniz.',
+      manage: 'Etiketler',
+      manageTitle: 'Etiket kataloğunu yönet',
+      add: 'Yeni etiket',
+      edit: 'Etiketi yeniden adlandır',
+      name: 'Ad',
+      fileCount: 'Dosya',
+      empty: 'Henüz etiket yok. Bir tane oluşturup dosyaları etiketlemeye başlayın.',
+      noMatches: 'Eşleşen etiket yok.',
+      createPlaceholder: 'örn. oyuncak, çocuk-güvenli, petg',
+      createButton: 'Oluştur',
+      nameRequired: 'Ad gerekli.',
+      searchPlaceholder: 'Etiketleri filtrele...',
+      created: 'Etiket oluşturuldu.',
+      updated: 'Etiket yeniden adlandırıldı.',
+      deleted: 'Etiket kaldırıldı.',
+      saveFailed: 'Etiket kaydedilemedi.',
+      deleteFailed: 'Etiket kaldırılamadı.',
+      applyFailed: 'Etiketler uygulanamadı.',
+      applyAdd: 'Etiket ekle',
+      applyRemove: 'Etiket kaldır',
+      applyAddSuccess: '{{files}} dosyaya {{count}} etiket eklendi.',
+      applyRemoveSuccess: '{{files}} dosyadan {{count}} etiket kaldırıldı.',
+      actionAdd: 'Seçili dosyalara ekle',
+      actionRemove: 'Seçili dosyalardan kaldır',
+      tagAction: 'Etiketle',
+      bulkTitle: 'Seçili {{count}} dosyayı etiketle',
+      bulkTooltip: 'Seçili her dosyaya etiket ekleyin veya kaldırın.',
+      noPermission: 'Dosyaları etiketleme izniniz yok.',
+      filterLabel: 'Filtre:',
+      clearAll: 'Tümünü temizle',
+      confirmDelete: '"{{name}}" etiketi silinsin mi?',
+      confirmDeleteMessage: 'Bu, etiketi katalogdan kaldırır. Dosyalar diğer etiketlerini korur.',
+      confirmDeleteInUseMessage: 'Bu etiket {{count}} dosyada bulunuyor. Silmek hepsinden çıkarır; dosyaların kendisine dokunulmaz.',
+      editAria: '{{name}} düzenle',
+      deleteAria: '{{name}} sil',
+    },
     allTypes: 'Tüm türler',
     prints: 'Baskılar',
     ascending: 'Artan',
@@ -5781,9 +5824,9 @@ export default {
         skip: 'Kontrol edilmedi — etkin bir MQTT bağlantısı gerekli. Bu ayarın yalnızca dilimleyicide bulunduğu eski dilimleyicilerde yazıcı bunu bildirmez, bu nedenle seçenek kapalı olsa bile bu kontrol geçer — kurulum adımı 4\'ü manuel olarak doğrulayın.',
       },
       port_rtsps: {
-        title: 'Kamera portu (RTSPS 322)',
+        title: 'Kamera portu ({{protocol}} {{port}})',
         pass: 'Erişilebilir — kamera akışı çalışacak.',
-        warn: 'Port 322 erişilemez. Canlı kamera görünümü çalışmayacak. Bu, baskıyı etkilemez.',
+        warn: 'Port {{port}} erişilemez. Canlı kamera görünümü çalışmayacak. Bu, baskıyı etkilemez.',
       },
       network_mode: {
         title: 'Docker ağ modu',

+ 45 - 2
frontend/src/i18n/locales/zh-CN.ts

@@ -3328,6 +3328,49 @@ export default {
     folderSortByActivity: '按最近活动',
     dragToResizeTooltip: '拖动调整大小,双击重置',
     searchFiles: '搜索文件...',
+    searchSubfoldersHint: '包含子文件夹',
+    readme: {
+      truncated: '已截断',
+    },
+    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: '已为 {{files}} 个文件添加 {{count}} 个标签。',
+      applyRemoveSuccess: '已从 {{files}} 个文件移除 {{count}} 个标签。',
+      actionAdd: '添加到所选文件',
+      actionRemove: '从所选文件移除',
+      tagAction: '标签',
+      bulkTitle: '为所选的 {{count}} 个文件加标签',
+      bulkTooltip: '在每个所选文件上添加或移除标签。',
+      noPermission: '您没有为文件加标签的权限。',
+      filterLabel: '筛选条件:',
+      clearAll: '全部清除',
+      confirmDelete: '删除标签 "{{name}}"?',
+      confirmDeleteMessage: '此操作会从目录中删除该标签。文件保留其它标签。',
+      confirmDeleteInUseMessage: '此标签存在于 {{count}} 个文件上。删除后所有文件上的标签都会消失,但文件本身不会动。',
+      editAria: '编辑 {{name}}',
+      deleteAria: '删除 {{name}}',
+    },
     allTypes: '所有类型',
     prints: '打印',
     ascending: '升序',
@@ -5830,9 +5873,9 @@ export default {
         skip: '未检查 — 需要有效的 MQTT 连接。在该设置仅存在于切片机中的较旧切片机上,打印机不会报告此设置,因此即使选项已关闭,此检查也会通过 — 请手动验证安装步骤 4。',
       },
       port_rtsps: {
-        title: '摄像头端口(RTSPS 322)',
+        title: '摄像头端口({{protocol}} {{port}})',
         pass: '可达 — 摄像头视频流将正常工作。',
-        warn: '端口 322 不可达。实时摄像头视图将无法工作。这不影响打印。',
+        warn: '端口 {{port}} 不可达。实时摄像头视图将无法工作。这不影响打印。',
       },
       network_mode: {
         title: 'Docker 网络模式',

+ 45 - 2
frontend/src/i18n/locales/zh-TW.ts

@@ -3328,6 +3328,49 @@ export default {
     folderSortByActivity: '依最近活動',
     dragToResizeTooltip: '拖曳調整大小,雙擊重設',
     searchFiles: '搜尋檔案...',
+    searchSubfoldersHint: '包含子資料夾',
+    readme: {
+      truncated: '已截斷',
+    },
+    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: '已為 {{files}} 個檔案新增 {{count}} 個標籤。',
+      applyRemoveSuccess: '已從 {{files}} 個檔案移除 {{count}} 個標籤。',
+      actionAdd: '加到所選檔案',
+      actionRemove: '從所選檔案移除',
+      tagAction: '標籤',
+      bulkTitle: '為所選的 {{count}} 個檔案加標籤',
+      bulkTooltip: '在每個所選檔案上新增或移除標籤。',
+      noPermission: '您沒有為檔案加標籤的權限。',
+      filterLabel: '篩選條件:',
+      clearAll: '全部清除',
+      confirmDelete: '刪除標籤 "{{name}}"?',
+      confirmDeleteMessage: '此動作會從目錄中刪除該標籤。檔案保留其他標籤。',
+      confirmDeleteInUseMessage: '此標籤存在於 {{count}} 個檔案上。刪除後所有檔案上的標籤都會消失,但檔案本身不會動。',
+      editAria: '編輯 {{name}}',
+      deleteAria: '刪除 {{name}}',
+    },
     allTypes: '所有類型',
     prints: '列印',
     ascending: '升序',
@@ -5830,9 +5873,9 @@ export default {
         skip: '未檢查 — 需要有效的 MQTT 連線。在該設定僅存在於切片機中的較舊切片機上,印表機不會回報此設定,因此即使選項已關閉,此檢查也會通過 — 請手動驗證安裝步驟 4。',
       },
       port_rtsps: {
-        title: '攝影機連接埠(RTSPS 322)',
+        title: '攝影機連接埠({{protocol}} {{port}})',
         pass: '可達 — 攝影機串流將正常運作。',
-        warn: '連接埠 322 無法連線。即時攝影機檢視將無法運作。這不影響列印。',
+        warn: '連接埠 {{port}} 無法連線。即時攝影機檢視將無法運作。這不影響列印。',
       },
       network_mode: {
         title: 'Docker 網路模式',

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

@@ -41,6 +41,7 @@ import {
   RefreshCw,
   Lock,
   FolderSymlink,
+  Tag as TagIcon,
 } from 'lucide-react';
 import { api } from '../api/client';
 import type {
@@ -58,7 +59,10 @@ import { ConfirmModal } from '../components/ConfirmModal';
 import { PrintModal } from '../components/PrintModal';
 import { ModelViewerModal } from '../components/ModelViewerModal';
 import { SliceModal } from '../components/SliceModal';
+import { BulkTagsPickerModal } from '../components/BulkTagsPickerModal';
 import { FileUploadModal } from '../components/FileUploadModal';
+import { FolderReadmePanel } from '../components/FolderReadmePanel';
+import { LibraryTagsModal } from '../components/LibraryTagsModal';
 import { PurgeOldFilesModal } from '../components/PurgeOldFilesModal';
 import { useToast } from '../contexts/ToastContext';
 import { useIsMobile } from '../hooks/useIsMobile';
@@ -731,6 +735,7 @@ interface FileCardProps {
   onPreview3d?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
   onGenerateThumbnail?: (file: LibraryFileListItem) => void;
+  onTagClick?: (tagId: number) => void;
   thumbnailVersion?: number;
   hasPermission: (permission: Permission) => boolean;
   canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
@@ -738,7 +743,7 @@ interface FileCardProps {
   t: TFunction;
 }
 
-function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onSlice, useSlicerApi, onPreview3d, onRename, onGenerateThumbnail, thumbnailVersion, hasPermission, canModify, authEnabled, t }: FileCardProps) {
+function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onSlice, useSlicerApi, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, t }: FileCardProps) {
   const [showActions, setShowActions] = useState(false);
 
   return (
@@ -805,6 +810,22 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
             {file.created_by_username}
           </div>
         )}
+        {(file.tags?.length ?? 0) > 0 && (
+          <div className="mt-2 flex flex-wrap gap-1" onClick={(e) => e.stopPropagation()}>
+            {file.tags!.map((tg) => (
+              <button
+                key={tg.id}
+                type="button"
+                onClick={() => onTagClick?.(tg.id)}
+                className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[10px] bg-bambu-green/10 text-bambu-green hover:bg-bambu-green/20 transition-colors max-w-full"
+                title={tg.name}
+              >
+                <TagIcon className="w-2.5 h-2.5 flex-shrink-0" />
+                <span className="truncate">{tg.name}</span>
+              </button>
+            ))}
+          </div>
+        )}
       </div>
 
       {/* Actions - always visible on mobile, hover on desktop */}
@@ -962,6 +983,12 @@ export function FileManagerPage() {
   const [showUploadModal, setShowUploadModal] = useState(false);
   const [droppedFiles, setDroppedFiles] = useState<File[]>([]);
   const [showPurgeModal, setShowPurgeModal] = useState(false);
+  // Tag UI state (#1268). selectedTagIds is the AND-style filter applied to
+  // the listing; setting it bypasses folder scoping on the server so
+  // "every toy" works regardless of which folder is currently selected.
+  const [showTagsModal, setShowTagsModal] = useState(false);
+  const [showBulkTagsModal, setShowBulkTagsModal] = useState(false);
+  const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
   const [linkFolder, setLinkFolder] = useState<LibraryFolderTree | null>(null);
   const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | null>(null);
   const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
@@ -1124,8 +1151,49 @@ export function FileManagerPage() {
     staleTime: 30_000,
   });
 
+  // #1268: when a folder is selected and the user has typed a search query,
+  // ask the server to expand the result to every descendant folder so the
+  // client-side filter can match files in subfolders too. Without this the
+  // listing is just the immediate children and "robot.3mf" two levels deep
+  // is invisible from the parent. Only kicks in for folder-scoped views —
+  // root and the internal/external pseudo-nodes already return the union.
+  const searchExpandsSubfolders = selectedFolderId !== null && searchQuery.trim().length > 0;
+  // The tag filter overrides folder scoping server-side (#1268 design call),
+  // so the FE query key includes it as a peer of folder/topLevelView. Sorted
+  // so the cache hits regardless of the order tags were toggled.
+  const tagFilterKey = useMemo(() => [...selectedTagIds].sort((a, b) => a - b), [selectedTagIds]);
+  // Tag catalog — needed to resolve names for the active-filter chip bar.
+  // Cheap query, shared with LibraryTagsModal / BulkTagsPickerModal via the
+  // same queryKey so they all invalidate together on tag CRUD.
+  const { data: tagCatalog = [] } = useQuery({
+    queryKey: ['library-tags'],
+    queryFn: api.getLibraryTags,
+  });
+  const tagsById = useMemo(() => {
+    const map = new Map<number, string>();
+    for (const t of tagCatalog) map.set(t.id, t.name);
+    return map;
+  }, [tagCatalog]);
+  // Prune the active filter when a tag is removed from the catalog so the
+  // listing never stalls on a phantom id. Skipped while the catalog query is
+  // still settling (empty array on first paint) — otherwise the user's filter
+  // gets cleared the moment the page mounts.
+  useEffect(() => {
+    if (tagCatalog.length === 0) return;
+    setSelectedTagIds((prev) => {
+      const next = prev.filter((id) => tagsById.has(id));
+      return next.length === prev.length ? prev : next;
+    });
+  }, [tagCatalog.length, tagsById]);
+
+  const toggleTagFilter = useCallback((tagId: number) => {
+    setSelectedTagIds((prev) =>
+      prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId],
+    );
+  }, []);
+
   const { data: files, isLoading: filesLoading } = useQuery({
-    queryKey: ['library-files', selectedFolderId, topLevelView],
+    queryKey: ['library-files', selectedFolderId, topLevelView, searchExpandsSubfolders, tagFilterKey],
     // When a specific folder is selected we list its contents directly; when
     // no folder is selected the topLevelView pseudo-node decides whether the
     // server scopes the result to internal-managed-storage files or to the
@@ -1137,6 +1205,8 @@ export function FileManagerPage() {
         false,
         undefined,
         selectedFolderId === null ? topLevelView : undefined,
+        searchExpandsSubfolders,
+        tagFilterKey,
       ),
   });
 
@@ -1586,6 +1656,14 @@ export function FileManagerPage() {
             <FolderPlus className="w-4 h-4 mr-2" />
             {t('fileManager.newFolder')}
           </Button>
+          <Button
+            variant="secondary"
+            onClick={() => setShowTagsModal(true)}
+            title={t('fileManager.tags.manageTitle')}
+          >
+            <TagIcon className="w-4 h-4 mr-2" />
+            {t('fileManager.tags.manage')}
+          </Button>
           {hasPermission('library:purge') && (
             <Button
               variant="secondary"
@@ -1858,6 +1936,50 @@ export function FileManagerPage() {
 
         {/* Files area */}
         <div className="flex-1 flex flex-col min-w-0 min-h-0">
+          {/* Markdown description panel (#1268) — auto-hides if the folder
+              has no README/description.md so non-users pay no UI cost. */}
+          {selectedFolderId !== null && <FolderReadmePanel folderId={selectedFolderId} />}
+          {/* Tag filter rail (#1268). Lists every catalog tag as a togglable
+              chip — active chips are filled green and show an X, inactive
+              chips are outlined and toggle ON when clicked. Clicking an active
+              chip removes it from the filter. Hidden entirely when the
+              catalog is empty so brand-new installs don't see a stray rail. */}
+          {tagCatalog.length > 0 && (
+            <div className="mb-3 flex flex-wrap items-center gap-2 p-2 sm:p-3 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary">
+              <span className="text-xs text-bambu-gray font-medium shrink-0">
+                {t('fileManager.tags.filterLabel')}
+              </span>
+              {tagCatalog.map((tg) => {
+                const active = selectedTagIds.includes(tg.id);
+                return (
+                  <button
+                    key={tg.id}
+                    type="button"
+                    onClick={() => toggleTagFilter(tg.id)}
+                    className={
+                      active
+                        ? 'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-bambu-green/20 text-bambu-green border border-bambu-green/40 hover:bg-bambu-green/30 transition-colors'
+                        : 'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-bambu-dark text-bambu-gray border border-bambu-dark-tertiary hover:text-white hover:border-bambu-green/40 transition-colors'
+                    }
+                    title={tg.name}
+                  >
+                    <TagIcon className="w-3 h-3" />
+                    <span>{tg.name}</span>
+                    {active && <X className="w-3 h-3" />}
+                  </button>
+                );
+              })}
+              {selectedTagIds.length > 0 && (
+                <button
+                  type="button"
+                  onClick={() => setSelectedTagIds([])}
+                  className="ml-auto text-xs text-bambu-gray hover:text-white shrink-0"
+                >
+                  {t('fileManager.tags.clearAll')}
+                </button>
+              )}
+            </div>
+          )}
           {/* External folder info bar */}
           {selectedFolder?.is_external && (
             <div className="flex items-center gap-3 mb-4 p-3 bg-purple-500/10 border border-purple-500/30 rounded-lg">
@@ -1905,6 +2027,14 @@ export function FileManagerPage() {
                   onChange={(e) => setSearchQuery(e.target.value)}
                   className="w-full pl-9 pr-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
                 />
+                {searchExpandsSubfolders && (
+                  <span
+                    className="absolute -bottom-4 left-0 text-[10px] text-bambu-gray whitespace-nowrap"
+                    title={t('fileManager.searchSubfoldersHint')}
+                  >
+                    {t('fileManager.searchSubfoldersHint')}
+                  </span>
+                )}
               </div>
 
               {/* Type filter */}
@@ -2063,6 +2193,16 @@ export function FileManagerPage() {
                       <MoveRight className="w-4 h-4 sm:mr-1" />
                       <span className="hidden sm:inline">{t('common.move')}</span>
                     </Button>
+                    <Button
+                      variant="secondary"
+                      size="sm"
+                      onClick={() => setShowBulkTagsModal(true)}
+                      disabled={!hasAnyPermission('library:update_own', 'library:update_all')}
+                      title={!hasAnyPermission('library:update_own', 'library:update_all') ? t('fileManager.tags.noPermission') : t('fileManager.tags.bulkTooltip')}
+                    >
+                      <TagIcon className="w-4 h-4 sm:mr-1" />
+                      <span className="hidden sm:inline">{t('fileManager.tags.tagAction')}</span>
+                    </Button>
                     <Button
                       variant="danger"
                       size="sm"
@@ -2175,6 +2315,7 @@ export function FileManagerPage() {
                     }}
                     onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
                     onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
+                    onTagClick={toggleTagFilter}
                     thumbnailVersion={thumbnailVersions[file.id]}
                     hasPermission={hasPermission}
                     canModify={canModify}
@@ -2197,20 +2338,21 @@ export function FileManagerPage() {
                     grids that compute `min-content` independently — the header's empty
                     trailing div resolved to 0px, leaving body columns shifted left of
                     their headers. Fixed width keeps header and body in lockstep. */}
-                <div className={`hidden sm:grid ${authEnabled ? 'grid-cols-[auto_1fr_120px_100px_100px_100px_220px]' : 'grid-cols-[auto_1fr_100px_100px_100px_220px]'} gap-4 px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary text-xs text-bambu-gray font-medium`}>
+                <div className={`hidden sm:grid ${authEnabled ? 'grid-cols-[auto_1fr_120px_100px_100px_100px_minmax(0,200px)_220px]' : 'grid-cols-[auto_1fr_100px_100px_100px_minmax(0,200px)_220px]'} gap-4 px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary text-xs text-bambu-gray font-medium`}>
                   <div className="w-6" />
                   <div>{t('common.name')}</div>
                   {authEnabled && <div>{t('fileManager.uploadedBy', { defaultValue: 'Uploaded By' })}</div>}
                   <div>{t('common.type')}</div>
                   <div>{t('fileManager.size')}</div>
                   <div>{t('fileManager.prints')}</div>
+                  <div>{t('fileManager.tags.title')}</div>
                   <div />
                 </div>
                 {/* List rows */}
                 {filteredAndSortedFiles.map((file) => (
                   <div
                     key={file.id}
-                    className={`grid ${authEnabled ? 'grid-cols-[auto_1fr_120px_100px_100px_100px_220px]' : 'grid-cols-[auto_1fr_100px_100px_100px_220px]'} gap-4 px-4 py-3 items-center border-b border-bambu-dark-tertiary last:border-b-0 cursor-pointer hover:bg-bambu-dark/50 transition-colors ${
+                    className={`grid ${authEnabled ? 'grid-cols-[auto_1fr_120px_100px_100px_100px_minmax(0,200px)_220px]' : 'grid-cols-[auto_1fr_100px_100px_100px_minmax(0,200px)_220px]'} gap-4 px-4 py-3 items-center border-b border-bambu-dark-tertiary last:border-b-0 cursor-pointer hover:bg-bambu-dark/50 transition-colors ${
                       selectedFiles.includes(file.id) ? 'bg-bambu-green/10' : ''
                     }`}
                     onClick={() => handleFileSelect(file.id)}
@@ -2284,6 +2426,30 @@ export function FileManagerPage() {
                     <div className="text-sm text-bambu-gray">{formatFileSize(file.file_size)}</div>
                     {/* Prints */}
                     <div className="text-sm text-bambu-gray">{file.print_count > 0 ? `${file.print_count}x` : '-'}</div>
+                    {/* Tags (#1268) — clickable chips push into the active
+                        filter; minmax(0,200px) on the column lets the cell
+                        shrink/wrap on narrow viewports without pushing the
+                        Actions cell off-screen. */}
+                    <div className="min-w-0" onClick={(e) => e.stopPropagation()}>
+                      {!file.tags || file.tags.length === 0 ? (
+                        <span className="text-xs text-bambu-gray/50">-</span>
+                      ) : (
+                        <div className="flex flex-wrap gap-1">
+                          {file.tags.map((tg) => (
+                            <button
+                              key={tg.id}
+                              type="button"
+                              onClick={() => toggleTagFilter(tg.id)}
+                              className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[10px] bg-bambu-green/10 text-bambu-green hover:bg-bambu-green/20 transition-colors max-w-full"
+                              title={tg.name}
+                            >
+                              <TagIcon className="w-2.5 h-2.5 flex-shrink-0" />
+                              <span className="truncate">{tg.name}</span>
+                            </button>
+                          ))}
+                        </div>
+                      )}
+                    </div>
                     {/* Actions */}
                     <div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
                       {isSlicedFilename(file.filename) && (
@@ -2460,6 +2626,22 @@ export function FileManagerPage() {
         <PurgeOldFilesModal onClose={() => setShowPurgeModal(false)} />
       )}
 
+      <LibraryTagsModal
+        open={showTagsModal}
+        onClose={() => setShowTagsModal(false)}
+        onPickTag={(tagId) => {
+          if (!selectedTagIds.includes(tagId)) {
+            setSelectedTagIds((prev) => [...prev, tagId]);
+          }
+        }}
+      />
+
+      <BulkTagsPickerModal
+        open={showBulkTagsModal}
+        fileIds={selectedFiles}
+        onClose={() => setShowBulkTagsModal(false)}
+      />
+
       {linkFolder && (
         <LinkFolderModal
           folder={linkFolder}

+ 9 - 0
frontend/src/utils/libraryTagsQuery.ts

@@ -0,0 +1,9 @@
+/**
+ * Shared React Query key for the library-tag catalog (#1268).
+ *
+ * Lives in its own module so the consumers — LibraryTagsModal,
+ * BulkTagsPickerModal, FileManagerPage — can invalidate together without
+ * importing component files from each other (which breaks Vite Fast Refresh
+ * when a single file exports both a constant and a component).
+ */
+export const libraryTagsQueryKey = ['library-tags'] as const;

Vissa filer visades inte eftersom för många filer har ändrats