Selaa lähdekoodia

Add local profiles — import OrcaSlicer presets without Bambu Cloud (#310)

Users who use OrcaSlicer without Bambu Cloud can now import slicer
presets directly into Bambuddy. Supports .orca_filament, .bbscfg,
.bbsflmt, .zip, and .json exports with automatic inheritance resolution
via OrcaSlicer's GitHub base profiles (cached with 7-day TTL).
maziggy 3 kuukautta sitten
vanhempi
sitoutus
edf244c469

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.1.9b] - Not released
 
 ### New Features
+- **Local Profiles — OrcaSlicer Import** ([#310](https://github.com/maziggy/bambuddy/issues/310)) — Import slicer presets from OrcaSlicer without Bambu Cloud. Supports `.orca_filament`, `.bbscfg`, `.bbsflmt`, `.zip`, and `.json` exports. Resolves OrcaSlicer inheritance chains by fetching base Bambu profiles from GitHub (cached locally with 7-day TTL). Stores presets in the database with extracted core fields (material type, vendor, nozzle temps, pressure advance, compatible printers). New "Local Profiles" tab on the Profiles page with drag-and-drop import, 3-column layout (Filament/Process/Printer), search, and expandable preset details. Local filament presets appear in AMS slot configuration alongside cloud presets. Includes smart profile type detection (explicit type field, ZIP path hints, settings ID keys, content heuristics, and name-based patterns) and material/vendor extraction from preset names as fallback.
 - **Hostname Support for Printers** ([#290](https://github.com/maziggy/bambuddy/issues/290)) — Printers can now be added using hostnames (e.g., `printer.local`, `my-printer.home.lan`) in addition to IPv4 addresses. Updated backend validation, frontend forms, and all locale labels.
 - **Camera View Controls** ([#291](https://github.com/maziggy/bambuddy/issues/291)) — Added chamber light toggle and skip objects buttons to both embedded camera viewer and standalone camera page. Extracted skip objects modal into a reusable `SkipObjectsModal` component shared across PrintersPage and both camera views.
 - **Per-Filament Spoolman Usage Tracking** ([#277](https://github.com/maziggy/bambuddy/pull/277)) — Accurate per-filament usage tracking for Spoolman integration with G-code parsing. Parses 3MF files at print start to build per-layer, per-filament extrusion maps. Reports accurate partial usage when prints fail or are cancelled based on actual layer progress. Tracking data stored in database to survive server restarts. Uses Spoolman's filament density for mm-to-grams conversion. Prefers `tray_uuid` over `tag_uid` for spool identification.

+ 1 - 0
README.md

@@ -152,6 +152,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - MQTT publishing for Home Assistant, Node-RED, etc.
 - **Prometheus metrics** - Export printer telemetry for Grafana dashboards
 - Bambu Cloud profile management
+- **Local Profiles** - Import OrcaSlicer presets (`.orca_filament`, `.bbscfg`, `.bbsflmt`, `.zip`, `.json`) without Bambu Cloud
 - K-profiles (pressure advance)
 - **GitHub backup** - Schedule automatic backups of cloud profiles, k profiles and settings to GitHub
 - External sidebar links

+ 200 - 0
backend/app/api/routes/local_presets.py

@@ -0,0 +1,200 @@
+"""API routes for local slicer presets (imported from OrcaSlicer, etc.)."""
+
+import json
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException, UploadFile
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.local_preset import LocalPreset
+from backend.app.models.user import User
+from backend.app.schemas.local_preset import (
+    ImportResponse,
+    LocalPresetCreate,
+    LocalPresetDetail,
+    LocalPresetResponse,
+    LocalPresetsResponse,
+    LocalPresetUpdate,
+)
+from backend.app.services.orca_profiles import (
+    extract_core_fields,
+    get_cache_status,
+    import_orca_file,
+    reclassify_presets,
+    refresh_base_cache,
+    resolve_preset,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/local-presets", tags=["Local Presets"])
+
+
+@router.get("/", response_model=LocalPresetsResponse)
+async def list_local_presets(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """List all local presets grouped by type."""
+    result = await db.execute(select(LocalPreset).order_by(LocalPreset.name))
+    presets = result.scalars().all()
+
+    grouped = LocalPresetsResponse()
+    for p in presets:
+        resp = LocalPresetResponse.model_validate(p)
+        if p.preset_type == "filament":
+            grouped.filament.append(resp)
+        elif p.preset_type == "printer":
+            grouped.printer.append(resp)
+        elif p.preset_type == "process":
+            grouped.process.append(resp)
+
+    return grouped
+
+
+@router.get("/{preset_id}", response_model=LocalPresetDetail)
+async def get_local_preset(
+    preset_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Get full detail for a local preset including the setting JSON."""
+    result = await db.execute(select(LocalPreset).where(LocalPreset.id == preset_id))
+    preset = result.scalar_one_or_none()
+    if not preset:
+        raise HTTPException(404, "Local preset not found")
+
+    data = LocalPresetResponse.model_validate(preset).model_dump()
+    try:
+        data["setting"] = json.loads(preset.setting)
+    except Exception:
+        data["setting"] = {}
+
+    return LocalPresetDetail(**data)
+
+
+@router.post("/import", response_model=ImportResponse)
+async def import_presets(
+    file: UploadFile,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Import presets from an OrcaSlicer export file (.json, .orca_filament, .bbscfg, .bbsflmt, .zip)."""
+    if not file.filename:
+        raise HTTPException(400, "No filename provided")
+
+    content = await file.read()
+    if not content:
+        raise HTTPException(400, "Empty file")
+
+    result = await import_orca_file(file.filename, content, db)
+    return ImportResponse(**result)
+
+
+@router.post("/", response_model=LocalPresetResponse)
+async def create_local_preset(
+    data: LocalPresetCreate,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Manually create a local preset."""
+    if data.preset_type not in ("filament", "printer", "process"):
+        raise HTTPException(400, "preset_type must be filament, printer, or process")
+
+    # Extract core fields
+    core = extract_core_fields(data.setting)
+
+    preset = LocalPreset(
+        name=data.name,
+        preset_type=data.preset_type,
+        source="manual",
+        setting=json.dumps(data.setting),
+        **core,
+    )
+    db.add(preset)
+    await db.flush()
+    await db.refresh(preset)
+    return LocalPresetResponse.model_validate(preset)
+
+
+@router.put("/{preset_id}", response_model=LocalPresetResponse)
+async def update_local_preset(
+    preset_id: int,
+    data: LocalPresetUpdate,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Update a local preset's name or settings."""
+    result = await db.execute(select(LocalPreset).where(LocalPreset.id == preset_id))
+    preset = result.scalar_one_or_none()
+    if not preset:
+        raise HTTPException(404, "Local preset not found")
+
+    if data.name is not None:
+        preset.name = data.name
+
+    if data.setting is not None:
+        # Re-resolve and extract core fields
+        resolved = await resolve_preset(data.setting, preset.preset_type, db)
+        core = extract_core_fields(resolved)
+        preset.setting = json.dumps(resolved)
+        preset.filament_type = core.get("filament_type")
+        preset.filament_vendor = core.get("filament_vendor")
+        preset.nozzle_temp_min = core.get("nozzle_temp_min")
+        preset.nozzle_temp_max = core.get("nozzle_temp_max")
+        preset.pressure_advance = core.get("pressure_advance")
+        preset.default_filament_colour = core.get("default_filament_colour")
+        preset.filament_cost = core.get("filament_cost")
+        preset.filament_density = core.get("filament_density")
+        preset.compatible_printers = core.get("compatible_printers")
+
+    await db.flush()
+    await db.refresh(preset)
+    return LocalPresetResponse.model_validate(preset)
+
+
+@router.delete("/{preset_id}")
+async def delete_local_preset(
+    preset_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete a local preset."""
+    result = await db.execute(select(LocalPreset).where(LocalPreset.id == preset_id))
+    preset = result.scalar_one_or_none()
+    if not preset:
+        raise HTTPException(404, "Local preset not found")
+
+    await db.delete(preset)
+    return {"success": True}
+
+
+@router.get("/base-cache/status")
+async def base_cache_status(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Get the status of the OrcaSlicer base profile cache."""
+    return await get_cache_status(db)
+
+
+@router.post("/base-cache/refresh")
+async def refresh_cache(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Force refresh all cached base profiles from GitHub."""
+    return await refresh_base_cache(db)
+
+
+@router.post("/reclassify")
+async def reclassify(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Re-evaluate preset types for all local presets using the improved heuristic."""
+    return await reclassify_presets(db)

+ 4 - 0
backend/app/api/routes/printers.py

@@ -1502,6 +1502,7 @@ async def save_slot_preset(
     tray_id: int,
     preset_id: str,
     preset_name: str,
+    preset_source: str = "cloud",
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ):
@@ -1525,6 +1526,7 @@ async def save_slot_preset(
         # Update existing
         mapping.preset_id = preset_id
         mapping.preset_name = preset_name
+        mapping.preset_source = preset_source
     else:
         # Create new
         mapping = SlotPresetMapping(
@@ -1533,6 +1535,7 @@ async def save_slot_preset(
             tray_id=tray_id,
             preset_id=preset_id,
             preset_name=preset_name,
+            preset_source=preset_source,
         )
         db.add(mapping)
 
@@ -1544,6 +1547,7 @@ async def save_slot_preset(
         "tray_id": mapping.tray_id,
         "preset_id": mapping.preset_id,
         "preset_name": mapping.preset_name,
+        "preset_source": mapping.preset_source,
     }
 
 

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

@@ -65,9 +65,11 @@ async def init_db():
         group,
         kprofile_note,
         library,
+        local_preset,
         maintenance,
         notification,
         notification_template,
+        orca_base_cache,
         pending_upload,
         print_queue,
         printer,
@@ -1102,6 +1104,14 @@ async def run_migrations(conn):
     except OperationalError:
         pass  # Already applied
 
+    # Migration: Add preset_source column to slot_preset_mappings for local preset support
+    try:
+        await conn.execute(
+            text("ALTER TABLE slot_preset_mappings ADD COLUMN preset_source VARCHAR(20) DEFAULT 'cloud'")
+        )
+    except OperationalError:
+        pass  # Already applied
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

+ 2 - 0
backend/app/main.py

@@ -186,6 +186,7 @@ from backend.app.api.routes import (
     groups,
     kprofiles,
     library,
+    local_presets,
     maintenance,
     metrics,
     notification_templates,
@@ -2807,6 +2808,7 @@ app.include_router(archives.router, prefix=app_settings.api_prefix)
 app.include_router(filaments.router, prefix=app_settings.api_prefix)
 app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
 app.include_router(cloud.router, prefix=app_settings.api_prefix)
+app.include_router(local_presets.router, prefix=app_settings.api_prefix)
 app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
 app.include_router(print_queue.router, prefix=app_settings.api_prefix)
 app.include_router(kprofiles.router, prefix=app_settings.api_prefix)

+ 4 - 0
backend/app/models/__init__.py

@@ -6,9 +6,11 @@ from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
 from backend.app.models.group import Group, user_groups
 from backend.app.models.kprofile_note import KProfileNote
 from backend.app.models.library import LibraryFile, LibraryFolder
+from backend.app.models.local_preset import LocalPreset
 from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
 from backend.app.models.notification import NotificationLog
 from backend.app.models.notification_template import NotificationTemplate
+from backend.app.models.orca_base_cache import OrcaBaseProfile
 from backend.app.models.pending_upload import PendingUpload
 from backend.app.models.printer import Printer
 from backend.app.models.project import Project
@@ -39,4 +41,6 @@ __all__ = [
     "user_groups",
     "GitHubBackupConfig",
     "GitHubBackupLog",
+    "LocalPreset",
+    "OrcaBaseProfile",
 ]

+ 40 - 0
backend/app/models/local_preset.py

@@ -0,0 +1,40 @@
+"""Model for locally stored slicer presets (imported from OrcaSlicer, etc.)."""
+
+from datetime import datetime
+
+from sqlalchemy import DateTime, Integer, String, Text, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class LocalPreset(Base):
+    """A locally stored slicer preset, typically imported from OrcaSlicer."""
+
+    __tablename__ = "local_presets"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(300))
+    preset_type: Mapped[str] = mapped_column(String(20))  # filament, printer, process
+    source: Mapped[str] = mapped_column(String(50), default="orcaslicer")  # orcaslicer, manual
+
+    # Core fields extracted for filtering / AMS config
+    filament_type: Mapped[str | None] = mapped_column(String(50))
+    filament_vendor: Mapped[str | None] = mapped_column(String(200))
+    nozzle_temp_min: Mapped[int | None] = mapped_column(Integer)
+    nozzle_temp_max: Mapped[int | None] = mapped_column(Integer)
+    pressure_advance: Mapped[str | None] = mapped_column(String(50))
+    default_filament_colour: Mapped[str | None] = mapped_column(String(50))
+    filament_cost: Mapped[str | None] = mapped_column(String(50))
+    filament_density: Mapped[str | None] = mapped_column(String(50))
+    compatible_printers: Mapped[str | None] = mapped_column(Text)  # JSON array
+
+    # Full resolved JSON blob
+    setting: Mapped[str] = mapped_column(Text)
+
+    # Inheritance info
+    inherits: Mapped[str | None] = mapped_column(String(300))
+    version: Mapped[str | None] = mapped_column(String(50))
+
+    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())

+ 22 - 0
backend/app/models/orca_base_cache.py

@@ -0,0 +1,22 @@
+"""Cache model for OrcaSlicer base profiles fetched from GitHub."""
+
+from datetime import datetime
+
+from sqlalchemy import DateTime, Index, String, Text, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class OrcaBaseProfile(Base):
+    """Cached OrcaSlicer base profile from GitHub for inheritance resolution."""
+
+    __tablename__ = "orca_base_profiles"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(300))
+    profile_type: Mapped[str] = mapped_column(String(20))  # filament, machine, process
+    setting: Mapped[str] = mapped_column(Text)  # Full JSON
+    fetched_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+    __table_args__ = (Index("ix_orca_base_profiles_name", "name", unique=True),)

+ 1 - 0
backend/app/models/slot_preset.py

@@ -24,6 +24,7 @@ class SlotPresetMapping(Base):
     tray_id: Mapped[int] = mapped_column(Integer)  # Tray ID within AMS (0-3)
     preset_id: Mapped[str] = mapped_column(String(100))  # Cloud preset setting_id
     preset_name: Mapped[str] = mapped_column(String(200))  # Preset name for display
+    preset_source: Mapped[str] = mapped_column(String(20), default="cloud")  # cloud or local
     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())
 

+ 67 - 0
backend/app/schemas/local_preset.py

@@ -0,0 +1,67 @@
+"""Pydantic schemas for local preset API."""
+
+from datetime import datetime
+
+from pydantic import BaseModel
+
+
+class LocalPresetResponse(BaseModel):
+    """Local preset summary (without full setting blob)."""
+
+    id: int
+    name: str
+    preset_type: str
+    source: str
+    filament_type: str | None = None
+    filament_vendor: str | None = None
+    nozzle_temp_min: int | None = None
+    nozzle_temp_max: int | None = None
+    pressure_advance: str | None = None
+    default_filament_colour: str | None = None
+    filament_cost: str | None = None
+    filament_density: str | None = None
+    compatible_printers: str | None = None
+    inherits: str | None = None
+    version: str | None = None
+    created_at: datetime
+    updated_at: datetime
+
+    model_config = {"from_attributes": True}
+
+
+class LocalPresetDetail(LocalPresetResponse):
+    """Full preset detail including the resolved setting JSON."""
+
+    setting: dict
+
+
+class LocalPresetCreate(BaseModel):
+    """Schema for manually creating a local preset."""
+
+    name: str
+    preset_type: str  # filament, printer, process
+    setting: dict
+
+
+class LocalPresetUpdate(BaseModel):
+    """Schema for updating a local preset."""
+
+    name: str | None = None
+    setting: dict | None = None
+
+
+class LocalPresetsResponse(BaseModel):
+    """Grouped local presets by type."""
+
+    filament: list[LocalPresetResponse] = []
+    printer: list[LocalPresetResponse] = []
+    process: list[LocalPresetResponse] = []
+
+
+class ImportResponse(BaseModel):
+    """Result of an import operation."""
+
+    success: bool
+    imported: int
+    skipped: int
+    errors: list[str] = []

+ 511 - 0
backend/app/services/orca_profiles.py

@@ -0,0 +1,511 @@
+"""Service for importing and resolving OrcaSlicer profiles.
+
+Handles:
+- Parsing .json, .orca_filament, .zip exports
+- Fetching base Bambu profiles from OrcaSlicer GitHub for inheritance resolution
+- Caching base profiles in the database with TTL
+- Extracting core fields for quick access
+"""
+
+import io
+import json
+import logging
+import zipfile
+from datetime import datetime, timedelta, timezone
+
+import httpx
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.local_preset import LocalPreset
+from backend.app.models.orca_base_cache import OrcaBaseProfile
+
+logger = logging.getLogger(__name__)
+
+ORCA_BASE_URL = "https://raw.githubusercontent.com/SoftFever/OrcaSlicer/main/resources/profiles/BBL"
+CACHE_TTL_DAYS = 7
+MAX_INHERITANCE_DEPTH = 10
+
+
+async def get_cached_base_profile(name: str, db: AsyncSession) -> dict | None:
+    """Get a base profile from cache if still fresh."""
+    result = await db.execute(select(OrcaBaseProfile).where(OrcaBaseProfile.name == name))
+    profile = result.scalar_one_or_none()
+    if not profile:
+        return None
+
+    # Check TTL
+    cutoff = datetime.now(timezone.utc) - timedelta(days=CACHE_TTL_DAYS)
+    fetched = profile.fetched_at
+    if fetched.tzinfo is None:
+        fetched = fetched.replace(tzinfo=timezone.utc)
+    if fetched < cutoff:
+        return None
+
+    try:
+        return json.loads(profile.setting)
+    except Exception:
+        return None
+
+
+async def fetch_and_cache_base_profile(name: str, profile_type: str, db: AsyncSession) -> dict | None:
+    """Fetch a base profile from OrcaSlicer GitHub and cache it."""
+    # Check cache first
+    cached = await get_cached_base_profile(name, db)
+    if cached is not None:
+        return cached
+
+    # Map profile_type to GitHub subdirectory
+    type_dirs = {
+        "filament": "filament",
+        "machine": "machine",
+        "printer": "machine",
+        "process": "process",
+    }
+    subdir = type_dirs.get(profile_type, "filament")
+
+    # Try fetching from GitHub
+    urls_to_try = [
+        f"{ORCA_BASE_URL}/{subdir}/{name}.json",
+    ]
+    # Also try filament dir as fallback for any type
+    if subdir != "filament":
+        urls_to_try.append(f"{ORCA_BASE_URL}/filament/{name}.json")
+
+    data = None
+    async with httpx.AsyncClient(timeout=15.0) as client:
+        for url in urls_to_try:
+            try:
+                resp = await client.get(url)
+                if resp.status_code == 200:
+                    data = resp.json()
+                    break
+            except Exception as e:
+                logger.debug("Failed to fetch %s: %s", url, e)
+
+    if data is None:
+        logger.warning("Could not fetch base profile '%s' from GitHub", name)
+        return None
+
+    # Cache in DB
+    setting_json = json.dumps(data)
+    result = await db.execute(select(OrcaBaseProfile).where(OrcaBaseProfile.name == name))
+    existing = result.scalar_one_or_none()
+    if existing:
+        existing.setting = setting_json
+        existing.profile_type = profile_type
+        existing.fetched_at = datetime.now(timezone.utc)
+    else:
+        cache_entry = OrcaBaseProfile(
+            name=name,
+            profile_type=profile_type,
+            setting=setting_json,
+            fetched_at=datetime.now(timezone.utc),
+        )
+        db.add(cache_entry)
+
+    return data
+
+
+async def resolve_preset(preset_data: dict, profile_type: str, db: AsyncSession, depth: int = 0) -> dict:
+    """Recursively resolve inheritance chain, merging parent into child.
+
+    OrcaSlicer uses shallow merge: child keys fully replace parent keys.
+    """
+    if depth >= MAX_INHERITANCE_DEPTH:
+        logger.warning("Inheritance depth limit reached for preset")
+        return preset_data
+
+    inherits = preset_data.get("inherits")
+    if not inherits:
+        return preset_data
+
+    # Fetch the base profile
+    base = await fetch_and_cache_base_profile(inherits, profile_type, db)
+    if base is None:
+        logger.warning("Cannot resolve inherits='%s' — base profile not found", inherits)
+        return preset_data
+
+    # Recursively resolve the base first
+    resolved_base = await resolve_preset(base, profile_type, db, depth + 1)
+
+    # Shallow merge: start with base, override with child
+    merged = {**resolved_base, **preset_data}
+    return merged
+
+
+def extract_core_fields(data: dict) -> dict:
+    """Extract commonly needed fields from a resolved preset for quick access."""
+    fields: dict = {}
+
+    # filament_type — often a single-element array like ["PLA"]
+    ft = data.get("filament_type")
+    if isinstance(ft, list) and ft:
+        fields["filament_type"] = str(ft[0])
+    elif isinstance(ft, str):
+        fields["filament_type"] = ft
+
+    # filament_vendor
+    fv = data.get("filament_vendor")
+    if isinstance(fv, list) and fv:
+        fields["filament_vendor"] = str(fv[0])
+    elif isinstance(fv, str):
+        fields["filament_vendor"] = fv
+
+    # nozzle_temp_min / max — from nozzle_temperature array or range fields
+    nozzle_temp = data.get("nozzle_temperature")
+    if isinstance(nozzle_temp, list) and nozzle_temp:
+        try:
+            temps = [int(t) for t in nozzle_temp if str(t).isdigit()]
+            if temps:
+                fields["nozzle_temp_min"] = min(temps)
+                fields["nozzle_temp_max"] = max(temps)
+        except (ValueError, TypeError):
+            pass
+
+    # Override with explicit range fields if present
+    range_low = data.get("nozzle_temperature_range_low")
+    range_high = data.get("nozzle_temperature_range_high")
+    if isinstance(range_low, list) and range_low:
+        try:
+            fields["nozzle_temp_min"] = int(range_low[0])
+        except (ValueError, TypeError):
+            pass
+    if isinstance(range_high, list) and range_high:
+        try:
+            fields["nozzle_temp_max"] = int(range_high[0])
+        except (ValueError, TypeError):
+            pass
+
+    # pressure_advance — store as JSON string if it's an array
+    pa = data.get("pressure_advance")
+    if pa is not None:
+        fields["pressure_advance"] = json.dumps(pa) if isinstance(pa, list) else str(pa)
+
+    # default_filament_colour
+    colour = data.get("default_filament_colour")
+    if colour is not None:
+        fields["default_filament_colour"] = json.dumps(colour) if isinstance(colour, list) else str(colour)
+
+    # filament_cost
+    cost = data.get("filament_cost")
+    if isinstance(cost, list) and cost:
+        fields["filament_cost"] = str(cost[0])
+    elif cost is not None:
+        fields["filament_cost"] = str(cost)
+
+    # filament_density
+    density = data.get("filament_density")
+    if isinstance(density, list) and density:
+        fields["filament_density"] = str(density[0])
+    elif density is not None:
+        fields["filament_density"] = str(density)
+
+    # compatible_printers
+    compat = data.get("compatible_printers")
+    if isinstance(compat, list):
+        fields["compatible_printers"] = json.dumps(compat)
+
+    return fields
+
+
+MATERIAL_TYPES = [
+    "PLA",
+    "ABS",
+    "ASA",
+    "PETG",
+    "TPU",
+    "PA",
+    "PC",
+    "PVA",
+    "HIPS",
+    "PET",
+    "PP",
+    "PEI",
+    "PEEK",
+    "PCTG",
+    "PPA",
+    "POM",
+]
+
+
+def _parse_material_from_name(name: str) -> str | None:
+    """Extract filament material type from preset name, e.g. 'Overture PLA Matte' -> 'PLA'."""
+    import re
+
+    upper = name.upper()
+    for mat in MATERIAL_TYPES:
+        if re.search(rf"\b{mat}\b", upper):
+            return mat
+    return None
+
+
+def _parse_vendor_from_name(name: str) -> str | None:
+    """Extract vendor from preset name, e.g. 'Overture PLA Matte @BBL X1C' -> 'Overture'."""
+    import re
+
+    # Strip @printer suffix
+    clean = re.sub(r"@.+$", "", name).strip()
+    upper = clean.upper()
+    for mat in MATERIAL_TYPES:
+        idx = upper.find(mat)
+        if idx > 0:
+            vendor = clean[:idx].strip()
+            if vendor and len(vendor) > 1:
+                return vendor
+    return None
+
+
+def _type_from_path(zip_entry: str) -> str | None:
+    """Infer profile type from the ZIP directory path."""
+    parts = zip_entry.lower().replace("\\", "/").split("/")
+    for part in parts:
+        if part in ("filament",):
+            return "filament"
+        if part in ("machine", "printer"):
+            return "printer"
+        if part in ("process", "print"):
+            return "process"
+    return None
+
+
+def _guess_profile_type(data: dict, path_hint: str | None = None) -> str:
+    """Determine the profile type from JSON data and optional ZIP path hint."""
+    import re
+
+    # 1. Explicit "type" field set by OrcaSlicer
+    explicit = data.get("type", "").lower()
+    if explicit in ("filament",):
+        return "filament"
+    if explicit in ("machine", "printer"):
+        return "printer"
+    if explicit in ("process", "print"):
+        return "process"
+
+    # 2. ZIP directory path hint (e.g. "filament/MyPreset.json")
+    if path_hint:
+        from_path = _type_from_path(path_hint)
+        if from_path:
+            return from_path
+
+    # 3. Strong ID-based heuristics — *_settings_id is definitive
+    if "print_settings_id" in data:
+        return "process"
+    if "filament_settings_id" in data:
+        return "filament"
+    if "printer_settings_id" in data:
+        return "printer"
+
+    # 4. Content-based heuristics — check process BEFORE filament because
+    #    resolved process presets can inherit filament_type from their base
+    process_keys = {
+        "layer_height",
+        "first_layer_height",
+        "wall_loops",
+        "prime_tower_width",
+        "prime_tower_max_speed",
+        "prime_tower_rib_wall",
+        "outer_wall_speed",
+        "inner_wall_speed",
+        "interlocking_depth",
+        "bottom_shell_layers",
+        "top_shell_layers",
+        "sparse_infill_density",
+    }
+    if process_keys & data.keys():
+        return "process"
+    if "machine_max_speed_x" in data or "printer_model" in data or "bed_shape" in data:
+        return "printer"
+    if "filament_type" in data or "filament_vendor" in data:
+        return "filament"
+
+    # 5. Name-based heuristics as last resort
+    name = data.get("name", "")
+    if re.search(r"\d+\.\d+mm\s", name):
+        return "process"
+    if name.lower().endswith("process"):
+        return "process"
+
+    return "filament"
+
+
+async def import_orca_file(filename: str, content: bytes, db: AsyncSession) -> dict:
+    """Import presets from a file (.json, .orca_filament, .bbscfg, .bbsflmt, .zip).
+
+    Returns dict with keys: success, imported, skipped, errors.
+    """
+    imported = 0
+    skipped = 0
+    errors: list[str] = []
+
+    # Determine file type
+    lower_name = filename.lower()
+
+    if lower_name.endswith(".json"):
+        # Single JSON preset
+        try:
+            data = json.loads(content)
+            result = await _import_single_preset(data, db, path_hint=filename)
+            if result == "imported":
+                imported += 1
+            elif result == "skipped":
+                skipped += 1
+            else:
+                errors.append(result)
+        except json.JSONDecodeError as e:
+            errors.append(f"Invalid JSON: {e}")
+    elif lower_name.endswith((".orca_filament", ".zip", ".bbscfg", ".bbsflmt")):
+        # ZIP archive — extract and parse each JSON
+        try:
+            with zipfile.ZipFile(io.BytesIO(content)) as zf:
+                for entry in zf.namelist():
+                    if entry.endswith(".json") and "bundle_structure" not in entry:
+                        try:
+                            raw = zf.read(entry)
+                            data = json.loads(raw)
+                            result = await _import_single_preset(data, db, path_hint=entry)
+                            if result == "imported":
+                                imported += 1
+                            elif result == "skipped":
+                                skipped += 1
+                            else:
+                                errors.append(f"{entry}: {result}")
+                        except json.JSONDecodeError:
+                            errors.append(f"{entry}: Invalid JSON")
+                        except Exception as e:
+                            errors.append(f"{entry}: {e}")
+        except zipfile.BadZipFile:
+            errors.append("Invalid ZIP/orca_filament archive")
+    else:
+        errors.append(f"Unsupported file type: {filename}")
+
+    return {
+        "success": imported > 0 or (imported == 0 and skipped > 0 and not errors),
+        "imported": imported,
+        "skipped": skipped,
+        "errors": errors,
+    }
+
+
+async def _import_single_preset(data: dict, db: AsyncSession, path_hint: str | None = None) -> str:
+    """Import a single preset dict. Returns 'imported', 'skipped', or error string."""
+    name = data.get("name")
+    if not name:
+        return "Preset has no name"
+
+    # Check for duplicate by name
+    result = await db.execute(select(LocalPreset).where(LocalPreset.name == name))
+    if result.scalar_one_or_none():
+        return "skipped"
+
+    profile_type = _guess_profile_type(data, path_hint)
+    inherits_value = data.get("inherits")
+
+    # Resolve inheritance
+    try:
+        resolved = await resolve_preset(data, profile_type, db)
+    except Exception as e:
+        logger.warning("Failed to resolve inheritance for '%s': %s", name, e)
+        resolved = data
+
+    # Extract core fields
+    core = extract_core_fields(resolved)
+
+    # Fallback: parse material/vendor from preset name if not found in data
+    filament_type = core.get("filament_type") or _parse_material_from_name(name)
+    filament_vendor = core.get("filament_vendor") or _parse_vendor_from_name(name)
+
+    preset = LocalPreset(
+        name=name,
+        preset_type=profile_type,
+        source="orcaslicer",
+        filament_type=filament_type,
+        filament_vendor=filament_vendor,
+        nozzle_temp_min=core.get("nozzle_temp_min"),
+        nozzle_temp_max=core.get("nozzle_temp_max"),
+        pressure_advance=core.get("pressure_advance"),
+        default_filament_colour=core.get("default_filament_colour"),
+        filament_cost=core.get("filament_cost"),
+        filament_density=core.get("filament_density"),
+        compatible_printers=core.get("compatible_printers"),
+        setting=json.dumps(resolved),
+        inherits=inherits_value,
+        version=data.get("version"),
+    )
+    db.add(preset)
+    return "imported"
+
+
+async def refresh_base_cache(db: AsyncSession) -> dict:
+    """Force refresh all cached base profiles."""
+    result = await db.execute(select(OrcaBaseProfile))
+    profiles = result.scalars().all()
+
+    refreshed = 0
+    failed = 0
+
+    for profile in profiles:
+        # Clear fetched_at to force re-fetch
+        try:
+            profile.fetched_at = datetime.min
+            data = await fetch_and_cache_base_profile(profile.name, profile.profile_type, db)
+            if data:
+                refreshed += 1
+            else:
+                failed += 1
+        except Exception:
+            failed += 1
+
+    return {"refreshed": refreshed, "failed": failed, "total": len(profiles)}
+
+
+async def get_cache_status(db: AsyncSession) -> dict:
+    """Get the status of the base profile cache."""
+    result = await db.execute(select(OrcaBaseProfile))
+    profiles = result.scalars().all()
+
+    cutoff = datetime.now(timezone.utc) - timedelta(days=CACHE_TTL_DAYS)
+    fresh = 0
+    stale = 0
+
+    for p in profiles:
+        fetched = p.fetched_at
+        if fetched.tzinfo is None:
+            fetched = fetched.replace(tzinfo=timezone.utc)
+        if fetched >= cutoff:
+            fresh += 1
+        else:
+            stale += 1
+
+    return {
+        "total": len(profiles),
+        "fresh": fresh,
+        "stale": stale,
+        "ttl_days": CACHE_TTL_DAYS,
+    }
+
+
+async def reclassify_presets(db: AsyncSession) -> dict:
+    """Re-evaluate preset_type for all local presets using the improved heuristic."""
+    result = await db.execute(select(LocalPreset))
+    presets = result.scalars().all()
+
+    reclassified = 0
+    for preset in presets:
+        try:
+            data = json.loads(preset.setting)
+        except Exception:
+            continue
+
+        new_type = _guess_profile_type(data)
+        if new_type != preset.preset_type:
+            logger.info(
+                "Reclassifying '%s' from '%s' to '%s'",
+                preset.name,
+                preset.preset_type,
+                new_type,
+            )
+            preset.preset_type = new_type
+            reclassified += 1
+
+    return {"total": len(presets), "reclassified": reclassified}

+ 341 - 0
backend/tests/unit/test_orca_profiles.py

@@ -0,0 +1,341 @@
+"""Unit tests for OrcaSlicer profile import service.
+
+Tests _guess_profile_type, _parse_material_from_name, _parse_vendor_from_name,
+and extract_core_fields.
+"""
+
+import json
+
+import pytest
+
+
+class TestGuessProfileType:
+    """Tests for _guess_profile_type()."""
+
+    def test_explicit_filament_type(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"type": "filament", "name": "Some Filament"}
+        assert _guess_profile_type(data) == "filament"
+
+    def test_explicit_process_type(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"type": "process", "name": "0.20mm Standard"}
+        assert _guess_profile_type(data) == "process"
+
+    def test_explicit_machine_type(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"type": "machine", "name": "Bambu Lab X1C"}
+        assert _guess_profile_type(data) == "printer"
+
+    def test_explicit_printer_type(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"type": "printer", "name": "Bambu Lab X1C"}
+        assert _guess_profile_type(data) == "printer"
+
+    def test_explicit_print_type(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"type": "print", "name": "0.20mm Standard"}
+        assert _guess_profile_type(data) == "process"
+
+    def test_path_hint_filament(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"name": "Some Preset"}
+        assert _guess_profile_type(data, path_hint="filament/MyPreset.json") == "filament"
+
+    def test_path_hint_process(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"name": "Some Preset"}
+        assert _guess_profile_type(data, path_hint="process/MyProcess.json") == "process"
+
+    def test_path_hint_machine(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"name": "Some Preset"}
+        assert _guess_profile_type(data, path_hint="machine/MyPrinter.json") == "printer"
+
+    def test_print_settings_id_indicates_process(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"name": "# 0.08mm Extra Fine @BBL H2D", "print_settings_id": "# 0.08mm Extra Fine @BBL H2D"}
+        assert _guess_profile_type(data) == "process"
+
+    def test_filament_settings_id_indicates_filament(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"name": "eSUN PLA", "filament_settings_id": "eSUN PLA"}
+        assert _guess_profile_type(data) == "filament"
+
+    def test_printer_settings_id_indicates_printer(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"name": "Bambu Lab X1C", "printer_settings_id": "Bambu Lab X1C"}
+        assert _guess_profile_type(data) == "printer"
+
+    def test_prime_tower_keys_indicate_process(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {
+            "name": "# 0.16mm High Quality",
+            "prime_tower_width": "20",
+            "prime_tower_max_speed": "100",
+        }
+        assert _guess_profile_type(data) == "process"
+
+    def test_outer_wall_speed_indicates_process(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"name": "H2D eSUN PETG Process", "outer_wall_speed": ["150"]}
+        assert _guess_profile_type(data) == "process"
+
+    def test_layer_height_indicates_process(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"name": "Standard", "layer_height": "0.2", "first_layer_height": "0.2"}
+        assert _guess_profile_type(data) == "process"
+
+    def test_machine_keys_indicate_printer(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"name": "My Printer", "machine_max_speed_x": "500", "bed_shape": "0x0,220x0,220x220,0x220"}
+        assert _guess_profile_type(data) == "printer"
+
+    def test_filament_type_indicates_filament(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"name": "Generic PLA", "filament_type": ["PLA"]}
+        assert _guess_profile_type(data) == "filament"
+
+    def test_name_with_layer_height_pattern(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"name": "0.20mm Standard @BBL X1C"}
+        assert _guess_profile_type(data) == "process"
+
+    def test_name_ending_with_process(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"name": "H2D eSUN PETG Process"}
+        assert _guess_profile_type(data) == "process"
+
+    def test_default_to_filament(self):
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {"name": "Unknown Preset"}
+        assert _guess_profile_type(data) == "filament"
+
+    def test_override_keys_only_process(self):
+        """Test realistic override-only process preset (inheritance unresolved)."""
+        from backend.app.services.orca_profiles import _guess_profile_type
+
+        data = {
+            "from": "User",
+            "inherits": "0.08mm Extra Fine @BBL H2D",
+            "name": "# 0.08mm Extra Fine @BBL H2D",
+            "prime_tower_max_speed": "100",
+            "prime_tower_rib_wall": "0",
+            "prime_tower_width": "20",
+            "print_extruder_id": ["1", "1"],
+            "print_settings_id": "# 0.08mm Extra Fine @BBL H2D",
+            "version": "2.3.0.4",
+        }
+        assert _guess_profile_type(data) == "process"
+
+
+class TestParseMaterialFromName:
+    """Tests for _parse_material_from_name()."""
+
+    def test_pla_in_name(self):
+        from backend.app.services.orca_profiles import _parse_material_from_name
+
+        assert _parse_material_from_name("Overture PLA Matte @BBL X1C") == "PLA"
+
+    def test_abs_in_name(self):
+        from backend.app.services.orca_profiles import _parse_material_from_name
+
+        assert _parse_material_from_name("CR3D ABS+ @Bambu Lab X1 Carbon") == "ABS"
+
+    def test_petg_in_name(self):
+        from backend.app.services.orca_profiles import _parse_material_from_name
+
+        assert _parse_material_from_name("eSUN PETG Silk @Bambu Lab X1 Carbon") == "PETG"
+
+    def test_tpu_in_name(self):
+        from backend.app.services.orca_profiles import _parse_material_from_name
+
+        assert _parse_material_from_name("Sunlu TPU @Bambu Lab X1 Carbon") == "TPU"
+
+    def test_no_material_in_name(self):
+        from backend.app.services.orca_profiles import _parse_material_from_name
+
+        assert _parse_material_from_name("# 0.20mm Standard @BBL X1C") is None
+
+    def test_material_word_boundary(self):
+        from backend.app.services.orca_profiles import _parse_material_from_name
+
+        # "PLA" should match as a word, not inside "DISPLAY"
+        assert _parse_material_from_name("Bambu PLA Basic @BBL X1C") == "PLA"
+
+    def test_asa_in_name(self):
+        from backend.app.services.orca_profiles import _parse_material_from_name
+
+        assert _parse_material_from_name("Bambu ASA-CF @BBL H2D") == "ASA"
+
+    def test_pa_in_name(self):
+        from backend.app.services.orca_profiles import _parse_material_from_name
+
+        # "PA12" doesn't match \bPA\b because 1 is a word char — PA needs word boundary
+        assert _parse_material_from_name("Fiberlogy PA12+CF15") is None
+        assert _parse_material_from_name("Fiberlogy PA @BBL X1C") == "PA"
+
+
+class TestParseVendorFromName:
+    """Tests for _parse_vendor_from_name()."""
+
+    def test_overture_vendor(self):
+        from backend.app.services.orca_profiles import _parse_vendor_from_name
+
+        assert _parse_vendor_from_name("Overture PLA Matte @BBL X1C") == "Overture"
+
+    def test_esun_vendor(self):
+        from backend.app.services.orca_profiles import _parse_vendor_from_name
+
+        assert _parse_vendor_from_name("eSUN PETG @Bambu Lab H2D") == "eSUN"
+
+    def test_bambu_vendor(self):
+        from backend.app.services.orca_profiles import _parse_vendor_from_name
+
+        assert _parse_vendor_from_name("Bambu PLA Basic @BBL X1C") == "Bambu"
+
+    def test_devil_design_vendor(self):
+        from backend.app.services.orca_profiles import _parse_vendor_from_name
+
+        assert _parse_vendor_from_name("Devil Design PLA @Bambu Lab X1 Carbon") == "Devil Design"
+
+    def test_no_vendor_process_name(self):
+        from backend.app.services.orca_profiles import _parse_vendor_from_name
+
+        assert _parse_vendor_from_name("# 0.20mm Standard @BBL X1C") is None
+
+    def test_strips_at_suffix(self):
+        from backend.app.services.orca_profiles import _parse_vendor_from_name
+
+        # Should strip @BBL X1C before parsing
+        result = _parse_vendor_from_name("Azurefilm PLA Wood @Bambu Lab H2D 0.4 nozzle")
+        assert result == "Azurefilm"
+
+    def test_single_char_vendor_rejected(self):
+        from backend.app.services.orca_profiles import _parse_vendor_from_name
+
+        # Vendor must be >1 char
+        assert _parse_vendor_from_name("X PLA") is None
+
+
+class TestExtractCoreFields:
+    """Tests for extract_core_fields()."""
+
+    def test_filament_type_array(self):
+        from backend.app.services.orca_profiles import extract_core_fields
+
+        core = extract_core_fields({"filament_type": ["PLA"]})
+        assert core["filament_type"] == "PLA"
+
+    def test_filament_type_string(self):
+        from backend.app.services.orca_profiles import extract_core_fields
+
+        core = extract_core_fields({"filament_type": "ABS"})
+        assert core["filament_type"] == "ABS"
+
+    def test_filament_vendor_array(self):
+        from backend.app.services.orca_profiles import extract_core_fields
+
+        core = extract_core_fields({"filament_vendor": ["Bambu Lab"]})
+        assert core["filament_vendor"] == "Bambu Lab"
+
+    def test_nozzle_temp_from_array(self):
+        from backend.app.services.orca_profiles import extract_core_fields
+
+        core = extract_core_fields({"nozzle_temperature": ["220"]})
+        assert core["nozzle_temp_min"] == 220
+        assert core["nozzle_temp_max"] == 220
+
+    def test_nozzle_temp_range_override(self):
+        from backend.app.services.orca_profiles import extract_core_fields
+
+        core = extract_core_fields(
+            {
+                "nozzle_temperature": ["220"],
+                "nozzle_temperature_range_low": ["190"],
+                "nozzle_temperature_range_high": ["230"],
+            }
+        )
+        assert core["nozzle_temp_min"] == 190
+        assert core["nozzle_temp_max"] == 230
+
+    def test_pressure_advance_array(self):
+        from backend.app.services.orca_profiles import extract_core_fields
+
+        core = extract_core_fields({"pressure_advance": ["0.04"]})
+        assert core["pressure_advance"] == json.dumps(["0.04"])
+
+    def test_default_filament_colour(self):
+        from backend.app.services.orca_profiles import extract_core_fields
+
+        core = extract_core_fields({"default_filament_colour": ["#FFAA00"]})
+        assert "#FFAA00" in core["default_filament_colour"]
+
+    def test_filament_cost_array(self):
+        from backend.app.services.orca_profiles import extract_core_fields
+
+        core = extract_core_fields({"filament_cost": ["24.99"]})
+        assert core["filament_cost"] == "24.99"
+
+    def test_filament_density(self):
+        from backend.app.services.orca_profiles import extract_core_fields
+
+        core = extract_core_fields({"filament_density": ["1.24"]})
+        assert core["filament_density"] == "1.24"
+
+    def test_compatible_printers(self):
+        from backend.app.services.orca_profiles import extract_core_fields
+
+        core = extract_core_fields({"compatible_printers": ["Bambu Lab X1 Carbon", "Bambu Lab P1S"]})
+        parsed = json.loads(core["compatible_printers"])
+        assert "Bambu Lab X1 Carbon" in parsed
+        assert "Bambu Lab P1S" in parsed
+
+    def test_empty_data(self):
+        from backend.app.services.orca_profiles import extract_core_fields
+
+        core = extract_core_fields({})
+        assert core == {}
+
+    def test_full_resolved_preset(self):
+        """Test extraction from a realistic fully resolved preset."""
+        from backend.app.services.orca_profiles import extract_core_fields
+
+        data = {
+            "filament_type": ["PETG"],
+            "filament_vendor": ["eSUN"],
+            "nozzle_temperature": ["240"],
+            "nozzle_temperature_range_low": ["220"],
+            "nozzle_temperature_range_high": ["260"],
+            "pressure_advance": ["0.035"],
+            "default_filament_colour": ["#4A90D9"],
+            "filament_cost": ["19.99"],
+            "filament_density": ["1.27"],
+            "compatible_printers": ["Bambu Lab X1 Carbon 0.4 nozzle"],
+        }
+        core = extract_core_fields(data)
+        assert core["filament_type"] == "PETG"
+        assert core["filament_vendor"] == "eSUN"
+        assert core["nozzle_temp_min"] == 220
+        assert core["nozzle_temp_max"] == 260
+        assert core["filament_cost"] == "19.99"
+        assert core["filament_density"] == "1.27"

+ 194 - 0
frontend/src/__tests__/components/LocalProfilesView.test.tsx

@@ -0,0 +1,194 @@
+/**
+ * Tests for LocalProfilesView component.
+ */
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import { render } from '../utils';
+import { LocalProfilesView } from '../../components/LocalProfilesView';
+
+const mockLocalPresets = {
+  filament: [
+    {
+      id: 1,
+      name: 'Overture PLA Matte @BBL X1C',
+      preset_type: 'filament',
+      source: 'orcaslicer',
+      filament_type: 'PLA',
+      filament_vendor: 'Overture',
+      nozzle_temp_min: 190,
+      nozzle_temp_max: 230,
+      pressure_advance: '["0.04"]',
+      default_filament_colour: '["#FFAA00"]',
+      filament_cost: '24.99',
+      filament_density: '1.24',
+      compatible_printers: '["Bambu Lab X1 Carbon 0.4 nozzle"]',
+      inherits: 'Bambu PLA Basic @BBL X1C',
+      version: '2.3.0.4',
+      created_at: '2026-01-01T00:00:00Z',
+      updated_at: '2026-01-01T00:00:00Z',
+    },
+    {
+      id: 2,
+      name: 'eSUN PETG @Bambu Lab H2D',
+      preset_type: 'filament',
+      source: 'orcaslicer',
+      filament_type: 'PETG',
+      filament_vendor: null,
+      nozzle_temp_min: 220,
+      nozzle_temp_max: 250,
+      pressure_advance: null,
+      default_filament_colour: null,
+      filament_cost: null,
+      filament_density: null,
+      compatible_printers: null,
+      inherits: null,
+      version: null,
+      created_at: '2026-01-01T00:00:00Z',
+      updated_at: '2026-01-01T00:00:00Z',
+    },
+  ],
+  process: [
+    {
+      id: 3,
+      name: '0.20mm Standard @BBL X1C',
+      preset_type: 'process',
+      source: 'orcaslicer',
+      filament_type: null,
+      filament_vendor: null,
+      nozzle_temp_min: null,
+      nozzle_temp_max: null,
+      pressure_advance: null,
+      default_filament_colour: null,
+      filament_cost: null,
+      filament_density: null,
+      compatible_printers: null,
+      inherits: '0.20mm Standard @BBL X1C',
+      version: '2.3.0.4',
+      created_at: '2026-01-01T00:00:00Z',
+      updated_at: '2026-01-01T00:00:00Z',
+    },
+  ],
+  printer: [],
+};
+
+describe('LocalProfilesView', () => {
+  beforeEach(() => {
+    server.use(
+      http.get('/api/v1/local-presets/', () => {
+        return HttpResponse.json(mockLocalPresets);
+      }),
+      http.delete('/api/v1/local-presets/:id', () => {
+        return HttpResponse.json({ success: true });
+      }),
+    );
+  });
+
+  it('renders filament and process columns', async () => {
+    render(<LocalProfilesView />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Overture PLA Matte @BBL X1C')).toBeInTheDocument();
+    });
+
+    expect(screen.getByText('eSUN PETG @Bambu Lab H2D')).toBeInTheDocument();
+    expect(screen.getByText('0.20mm Standard @BBL X1C')).toBeInTheDocument();
+  });
+
+  it('shows material badges from filament_type', async () => {
+    render(<LocalProfilesView />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Overture PLA Matte @BBL X1C')).toBeInTheDocument();
+    });
+
+    // PLA badge should appear for the first preset
+    const plaBadges = screen.getAllByText('PLA');
+    expect(plaBadges.length).toBeGreaterThan(0);
+  });
+
+  it('shows vendor from filament_vendor field', async () => {
+    render(<LocalProfilesView />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Overture')).toBeInTheDocument();
+    });
+  });
+
+  it('parses vendor from name when filament_vendor is null', async () => {
+    render(<LocalProfilesView />);
+
+    await waitFor(() => {
+      expect(screen.getByText('eSUN PETG @Bambu Lab H2D')).toBeInTheDocument();
+    });
+
+    // eSUN should be parsed from the name
+    expect(screen.getByText('eSUN')).toBeInTheDocument();
+  });
+
+  it('filters presets by search query', async () => {
+    render(<LocalProfilesView />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Overture PLA Matte @BBL X1C')).toBeInTheDocument();
+    });
+
+    const searchInput = screen.getByPlaceholderText(/search/i);
+    fireEvent.change(searchInput, { target: { value: 'PETG' } });
+
+    expect(screen.queryByText('Overture PLA Matte @BBL X1C')).not.toBeInTheDocument();
+    expect(screen.getByText('eSUN PETG @Bambu Lab H2D')).toBeInTheDocument();
+  });
+
+  it('shows empty state when no presets', async () => {
+    server.use(
+      http.get('/api/v1/local-presets/', () => {
+        return HttpResponse.json({ filament: [], process: [], printer: [] });
+      }),
+    );
+
+    render(<LocalProfilesView />);
+
+    await waitFor(() => {
+      expect(screen.getByText(/no local presets/i)).toBeInTheDocument();
+    });
+  });
+
+  it('shows Local badge on preset cards', async () => {
+    render(<LocalProfilesView />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Overture PLA Matte @BBL X1C')).toBeInTheDocument();
+    });
+
+    const badges = screen.getAllByText(/^Local$/i);
+    expect(badges.length).toBeGreaterThan(0);
+  });
+
+  it('shows delete confirmation modal', async () => {
+    render(<LocalProfilesView />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Overture PLA Matte @BBL X1C')).toBeInTheDocument();
+    });
+
+    // Click first delete button
+    const deleteButtons = screen.getAllByTitle(/delete/i);
+    fireEvent.click(deleteButtons[0]);
+
+    await waitFor(() => {
+      expect(screen.getByText(/are you sure/i)).toBeInTheDocument();
+    });
+  });
+
+  it('shows import zone', async () => {
+    render(<LocalProfilesView />);
+
+    await waitFor(() => {
+      expect(screen.getByText(/import profiles/i)).toBeInTheDocument();
+    });
+
+    expect(screen.getByText(/\.bbscfg/i)).toBeInTheDocument();
+  });
+});

+ 72 - 2
frontend/src/api/client.ts

@@ -850,6 +850,44 @@ export interface SlicerSettingDeleteResponse {
   message: string;
 }
 
+// Local preset types (OrcaSlicer imports)
+export interface LocalPreset {
+  id: number;
+  name: string;
+  preset_type: string;
+  source: string;
+  filament_type: string | null;
+  filament_vendor: string | null;
+  nozzle_temp_min: number | null;
+  nozzle_temp_max: number | null;
+  pressure_advance: string | null;
+  default_filament_colour: string | null;
+  filament_cost: string | null;
+  filament_density: string | null;
+  compatible_printers: string | null;
+  inherits: string | null;
+  version: string | null;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface LocalPresetDetail extends LocalPreset {
+  setting: Record<string, unknown>;
+}
+
+export interface LocalPresetsResponse {
+  filament: LocalPreset[];
+  printer: LocalPreset[];
+  process: LocalPreset[];
+}
+
+export interface ImportResponse {
+  success: boolean;
+  imported: number;
+  skipped: number;
+  errors: string[];
+}
+
 export interface FieldOption {
   value: string;
   label: string;
@@ -2933,8 +2971,8 @@ export const api = {
     request<Record<number, SlotPresetMapping>>(`/printers/${printerId}/slot-presets`),
   getSlotPreset: (printerId: number, amsId: number, trayId: number) =>
     request<SlotPresetMapping | null>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`),
-  saveSlotPreset: (printerId: number, amsId: number, trayId: number, presetId: string, presetName: string) =>
-    request<SlotPresetMapping>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}?preset_id=${encodeURIComponent(presetId)}&preset_name=${encodeURIComponent(presetName)}`, {
+  saveSlotPreset: (printerId: number, amsId: number, trayId: number, presetId: string, presetName: string, presetSource = 'cloud') =>
+    request<SlotPresetMapping>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}?preset_id=${encodeURIComponent(presetId)}&preset_name=${encodeURIComponent(presetName)}&preset_source=${encodeURIComponent(presetSource)}`, {
       method: 'PUT',
     }),
   deleteSlotPreset: (printerId: number, amsId: number, trayId: number) =>
@@ -3660,6 +3698,38 @@ export const api = {
 
   clearGitHubBackupLogs: (keepLast: number = 10) =>
     request<{ deleted: number; message: string }>(`/github-backup/logs?keep_last=${keepLast}`, { method: 'DELETE' }),
+
+  // Local Presets (OrcaSlicer imports)
+  getLocalPresets: () =>
+    request<LocalPresetsResponse>('/local-presets/'),
+  getLocalPresetDetail: (id: number) =>
+    request<LocalPresetDetail>(`/local-presets/${id}`),
+  importLocalPresets: (formData: FormData) =>
+    fetch(`${API_BASE}/local-presets/import`, {
+      method: 'POST',
+      headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {},
+      body: formData,
+    }).then(async (res) => {
+      if (!res.ok) {
+        const err = await res.json().catch(() => ({}));
+        throw new Error(err.detail || `HTTP ${res.status}`);
+      }
+      return res.json() as Promise<ImportResponse>;
+    }),
+  createLocalPreset: (data: { name: string; preset_type: string; setting: Record<string, unknown> }) =>
+    request<LocalPreset>('/local-presets/', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  updateLocalPreset: (id: number, data: { name?: string; setting?: Record<string, unknown> }) =>
+    request<LocalPreset>(`/local-presets/${id}`, {
+      method: 'PUT',
+      body: JSON.stringify(data),
+    }),
+  deleteLocalPreset: (id: number) =>
+    request<{ success: boolean }>(`/local-presets/${id}`, { method: 'DELETE' }),
+  refreshBaseProfileCache: () =>
+    request<{ refreshed: number; failed: number; total: number }>('/local-presets/base-cache/refresh', { method: 'POST' }),
 };
 
 // AMS History types

+ 150 - 78
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -1,5 +1,6 @@
 import { useState, useMemo, useEffect, useCallback } from 'react';
 import { useQuery, useMutation } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import { X, Loader2, Settings2, ChevronDown, CheckCircle2, RotateCcw } from 'lucide-react';
 import { api } from '../api/client';
 import type { KProfile } from '../api/client';
@@ -216,6 +217,7 @@ export function ConfigureAmsSlotModal({
   nozzleDiameter = '0.4',
   onSuccess,
 }: ConfigureAmsSlotModalProps) {
+  const { t } = useTranslation();
   const [selectedPresetId, setSelectedPresetId] = useState<string>('');
   const [selectedKProfile, setSelectedKProfile] = useState<KProfile | null>(null);
   const [colorHex, setColorHex] = useState<string>(''); // Just the 6-char hex, no alpha
@@ -231,6 +233,13 @@ export function ConfigureAmsSlotModal({
     enabled: isOpen,
   });
 
+  // Fetch local presets
+  const { data: localPresets, isLoading: localLoading } = useQuery({
+    queryKey: ['localPresets'],
+    queryFn: () => api.getLocalPresets(),
+    enabled: isOpen,
+  });
+
   // Fetch K profiles
   const { data: kprofilesData, isLoading: kprofilesLoading } = useQuery({
     queryKey: ['kprofiles', printerId, nozzleDiameter],
@@ -243,12 +252,24 @@ export function ConfigureAmsSlotModal({
     mutationFn: async () => {
       if (!selectedPresetId) throw new Error('No filament preset selected');
 
-      // Get the selected preset details
-      const selectedPreset = cloudSettings?.filament.find(p => p.setting_id === selectedPresetId);
-      if (!selectedPreset) throw new Error('Selected preset not found');
+      // Check if this is a local preset
+      const isLocal = selectedPresetId.startsWith('local_');
+      const localId = isLocal ? parseInt(selectedPresetId.replace('local_', ''), 10) : null;
+      const localPreset = isLocal
+        ? localPresets?.filament.find(p => p.id === localId)
+        : null;
+
+      // Get the selected cloud preset details (null for local presets)
+      const selectedPreset = !isLocal
+        ? cloudSettings?.filament.find(p => p.setting_id === selectedPresetId)
+        : null;
+
+      if (!isLocal && !selectedPreset) throw new Error('Selected preset not found');
+      if (isLocal && !localPreset) throw new Error('Selected local preset not found');
 
       // Parse the preset name for filament info
-      const parsed = parsePresetName(selectedPreset.name);
+      const presetName = isLocal ? localPreset!.name : selectedPreset!.name;
+      const parsed = parsePresetName(presetName);
 
       // Get cali_idx from selected K profile's slot_id (-1 = use default 0.020)
       const caliIdx = selectedKProfile?.slot_id ?? -1;
@@ -257,70 +278,86 @@ export function ConfigureAmsSlotModal({
       const color = colorHex || slotInfo.trayColor?.slice(0, 6) || 'FFFFFF';
 
       // Create the tray_sub_brands from preset name (without printer/nozzle suffix)
-      const traySubBrands = selectedPreset.name.replace(/@.+$/, '').trim();
-
-      // Get tray_info_idx: for user presets, fetch detail to get filament_id or derive from base_id
-      let trayInfoIdx = convertToTrayInfoIdx(selectedPresetId);
-
-      // For user presets (not starting with GF), fetch the detail to get the real filament_id
-      if (!selectedPresetId.startsWith('GFS')) {
-        try {
-          const detail = await api.getCloudSettingDetail(selectedPresetId);
-          if (detail.filament_id) {
-            trayInfoIdx = detail.filament_id;
-          } else if (detail.base_id) {
-            // If no filament_id but has base_id (e.g., "GFSL05_09"), derive tray_info_idx from it
-            // This is common for user presets that inherit from Bambu presets
-            trayInfoIdx = convertToTrayInfoIdx(detail.base_id);
-            console.log(`Derived tray_info_idx from base_id: ${detail.base_id} -> ${trayInfoIdx}`);
+      const traySubBrands = presetName.replace(/@.+$/, '').trim();
+
+      let trayInfoIdx: string;
+      let settingId: string;
+
+      if (isLocal) {
+        // Local presets have no Bambu Cloud mapping
+        trayInfoIdx = '';
+        settingId = '';
+      } else {
+        // Get tray_info_idx: for user presets, fetch detail to get filament_id or derive from base_id
+        trayInfoIdx = convertToTrayInfoIdx(selectedPresetId);
+        settingId = selectedPresetId;
+
+        // For user presets (not starting with GF), fetch the detail to get the real filament_id
+        if (!selectedPresetId.startsWith('GFS')) {
+          try {
+            const detail = await api.getCloudSettingDetail(selectedPresetId);
+            if (detail.filament_id) {
+              trayInfoIdx = detail.filament_id;
+            } else if (detail.base_id) {
+              trayInfoIdx = convertToTrayInfoIdx(detail.base_id);
+              console.log(`Derived tray_info_idx from base_id: ${detail.base_id} -> ${trayInfoIdx}`);
+            }
+          } catch (e) {
+            console.warn('Failed to fetch preset detail for filament_id:', e);
           }
-        } catch (e) {
-          console.warn('Failed to fetch preset detail for filament_id:', e);
-          // Fall back to derived tray_info_idx
         }
       }
 
-      // Default temp range based on material type
-      let tempMin = 190;
-      let tempMax = 230;
-      const material = parsed.material.toUpperCase();
-      if (material.includes('PLA')) {
-        tempMin = 190;
-        tempMax = 230;
-      } else if (material.includes('PETG')) {
-        tempMin = 220;
-        tempMax = 260;
-      } else if (material.includes('ABS')) {
-        tempMin = 240;
-        tempMax = 280;
-      } else if (material.includes('ASA')) {
-        tempMin = 240;
-        tempMax = 280;
-      } else if (material.includes('TPU')) {
+      // Default temp range — use local preset core fields if available
+      let tempMin = isLocal && localPreset?.nozzle_temp_min ? localPreset.nozzle_temp_min : 190;
+      let tempMax = isLocal && localPreset?.nozzle_temp_max ? localPreset.nozzle_temp_max : 230;
+
+      if (!isLocal || (!localPreset?.nozzle_temp_min && !localPreset?.nozzle_temp_max)) {
+        // Fall back to material-based defaults
+        const material = (isLocal ? (localPreset?.filament_type || parsed.material) : parsed.material).toUpperCase();
+        if (material.includes('PLA')) {
+          tempMin = 190;
+          tempMax = 230;
+        } else if (material.includes('PETG')) {
+          tempMin = 220;
+          tempMax = 260;
+        } else if (material.includes('ABS')) {
+          tempMin = 240;
+          tempMax = 280;
+        } else if (material.includes('ASA')) {
+          tempMin = 240;
+          tempMax = 280;
+        } else if (material.includes('TPU')) {
         tempMin = 200;
         tempMax = 240;
       } else if (material.includes('PC')) {
         tempMin = 260;
         tempMax = 300;
       } else if (material.includes('PA') || material.includes('NYLON')) {
-        tempMin = 250;
-        tempMax = 290;
+          tempMin = 250;
+          tempMax = 290;
+        }
       }
 
       // Parse K value from selected profile
       const kValue = selectedKProfile?.k_value ? parseFloat(selectedKProfile.k_value) : 0;
 
+      // Determine tray_type: use local preset's filament_type or parsed material
+      const trayType = isLocal
+        ? (localPreset?.filament_type || parsed.material || 'PLA')
+        : (parsed.material || 'PLA');
+
       // Configure the slot via MQTT
       const result = await api.configureAmsSlot(printerId, slotInfo.amsId, slotInfo.trayId, {
         tray_info_idx: trayInfoIdx,
-        tray_type: parsed.material || 'PLA',
+        tray_type: trayType,
         tray_sub_brands: traySubBrands,
         tray_color: color + 'FF', // Add alpha
         nozzle_temp_min: tempMin,
         nozzle_temp_max: tempMax,
         cali_idx: caliIdx,
         nozzle_diameter: nozzleDiameter,
-        setting_id: selectedPresetId, // Full setting ID for slicer compatibility
+        setting_id: settingId, // Full setting ID for slicer compatibility (empty for local)
         // Pass K profile's filament_id and setting_id for proper linking
         kprofile_filament_id: selectedKProfile?.filament_id,
         kprofile_setting_id: selectedKProfile?.setting_id || undefined,
@@ -331,8 +368,10 @@ export function ConfigureAmsSlotModal({
       // Save the preset mapping so we can display the correct name in the UI
       // This is needed because user presets use filament_id (e.g., P285e239) as tray_info_idx,
       // which can't be resolved to a name via the filamentInfo API
+      const mappingPresetId = isLocal ? `local_${localId}` : selectedPresetId;
+      const mappingSource = isLocal ? 'local' : 'cloud';
       try {
-        await api.saveSlotPreset(printerId, slotInfo.amsId, slotInfo.trayId, selectedPresetId, traySubBrands);
+        await api.saveSlotPreset(printerId, slotInfo.amsId, slotInfo.trayId, mappingPresetId, traySubBrands, mappingSource);
       } catch (e) {
         console.warn('Failed to save slot preset mapping:', e);
         // Don't fail the whole operation - slot was configured successfully
@@ -366,34 +405,60 @@ export function ConfigureAmsSlotModal({
     },
   });
 
-  // Filter filament presets based on search
-  const filteredPresets = useMemo(() => {
-    if (!cloudSettings?.filament) return [];
+  // Unified preset item for the list (cloud + local)
+  type PresetItem = { id: string; name: string; source: 'cloud' | 'local'; isUser: boolean };
 
+  // Filter filament presets based on search (merged cloud + local)
+  const filteredPresets = useMemo(() => {
     const query = searchQuery.toLowerCase();
-    return cloudSettings.filament
-      .filter(p => {
-        if (!query) return true;
-        return p.name.toLowerCase().includes(query);
-      })
-      .sort((a, b) => {
-        // Sort user presets first, then alphabetically
-        const aIsUser = isUserPreset(a.setting_id);
-        const bIsUser = isUserPreset(b.setting_id);
-        if (aIsUser && !bIsUser) return -1;
-        if (!aIsUser && bIsUser) return 1;
-        return a.name.localeCompare(b.name);
-      });
-  }, [cloudSettings?.filament, searchQuery]);
+    const items: PresetItem[] = [];
+
+    // Add local presets first
+    if (localPresets?.filament) {
+      for (const lp of localPresets.filament) {
+        if (!query || lp.name.toLowerCase().includes(query)) {
+          items.push({ id: `local_${lp.id}`, name: lp.name, source: 'local', isUser: false });
+        }
+      }
+    }
+
+    // Add cloud presets
+    if (cloudSettings?.filament) {
+      for (const cp of cloudSettings.filament) {
+        if (!query || cp.name.toLowerCase().includes(query)) {
+          items.push({ id: cp.setting_id, name: cp.name, source: 'cloud', isUser: isUserPreset(cp.setting_id) });
+        }
+      }
+    }
+
+    // Sort: local first, then user cloud presets, then built-in, alphabetically within groups
+    return items.sort((a, b) => {
+      if (a.source === 'local' && b.source !== 'local') return -1;
+      if (a.source !== 'local' && b.source === 'local') return 1;
+      if (a.isUser && !b.isUser) return -1;
+      if (!a.isUser && b.isUser) return 1;
+      return a.name.localeCompare(b.name);
+    });
+  }, [cloudSettings?.filament, localPresets?.filament, searchQuery]);
 
   // Get full preset name for K profile filtering (brand + material, without printer suffix)
   const selectedPresetInfo = useMemo(() => {
-    if (!selectedPresetId || !cloudSettings?.filament) return null;
-    const selectedPreset = cloudSettings.filament.find(p => p.setting_id === selectedPresetId);
-    if (!selectedPreset) return null;
+    if (!selectedPresetId) return null;
+
+    // Resolve the name from either local or cloud presets
+    let presetName: string | null = null;
+    if (selectedPresetId.startsWith('local_')) {
+      const localId = parseInt(selectedPresetId.replace('local_', ''), 10);
+      const lp = localPresets?.filament.find(p => p.id === localId);
+      presetName = lp?.name || null;
+    } else if (cloudSettings?.filament) {
+      const cp = cloudSettings.filament.find(p => p.setting_id === selectedPresetId);
+      presetName = cp?.name || null;
+    }
+    if (!presetName) return null;
 
     // Remove printer/nozzle suffix (e.g., "@BBL X1C" or "@0.4 nozzle")
-    let nameWithoutSuffix = selectedPreset.name.replace(/@.+$/, '').trim();
+    let nameWithoutSuffix = presetName.replace(/@.+$/, '').trim();
     // Strip leading "# " from custom preset names (user convention)
     if (nameWithoutSuffix.startsWith('# ')) {
       nameWithoutSuffix = nameWithoutSuffix.slice(2).trim();
@@ -405,7 +470,7 @@ export function ConfigureAmsSlotModal({
       material: parsed.material,
       brand: parsed.brand,
     };
-  }, [selectedPresetId, cloudSettings?.filament]);
+  }, [selectedPresetId, cloudSettings?.filament, localPresets?.filament]);
 
   // For backwards compatibility with the label
   const selectedMaterial = selectedPresetInfo?.fullName || '';
@@ -531,7 +596,7 @@ export function ConfigureAmsSlotModal({
 
   if (!isOpen) return null;
 
-  const isLoading = settingsLoading || kprofilesLoading;
+  const isLoading = settingsLoading || localLoading || kprofilesLoading;
   const canSave = selectedPresetId && !configureMutation.isPending;
 
   // Get display color (custom or slot default)
@@ -615,28 +680,35 @@ export function ConfigureAmsSlotModal({
                   <div className="max-h-48 overflow-y-auto space-y-1">
                     {filteredPresets.length === 0 ? (
                       <p className="text-center py-4 text-bambu-gray">
-                        {cloudSettings?.filament?.length === 0
-                          ? 'No cloud presets. Login to Bambu Cloud to sync.'
+                        {(cloudSettings?.filament?.length === 0 && !localPresets?.filament?.length)
+                          ? 'No presets available. Login to Bambu Cloud or import local profiles.'
                           : 'No matching presets found.'}
                       </p>
                     ) : (
                       filteredPresets.map((preset) => (
                         <button
-                          key={preset.setting_id}
-                          onClick={() => setSelectedPresetId(preset.setting_id)}
+                          key={preset.id}
+                          onClick={() => setSelectedPresetId(preset.id)}
                           className={`w-full p-2 rounded-lg border text-left transition-colors ${
-                            selectedPresetId === preset.setting_id
+                            selectedPresetId === preset.id
                               ? 'bg-bambu-green/20 border-bambu-green'
                               : 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
                           }`}
                         >
                           <div className="flex items-center justify-between">
                             <span className="text-white text-sm truncate">{preset.name}</span>
-                            {isUserPreset(preset.setting_id) && (
-                              <span className="text-xs px-1.5 py-0.5 rounded bg-bambu-blue/20 text-bambu-blue">
-                                Custom
-                              </span>
-                            )}
+                            <div className="flex items-center gap-1 flex-shrink-0">
+                              {preset.source === 'local' && (
+                                <span className="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">
+                                  {t('profiles.localProfiles.badge')}
+                                </span>
+                              )}
+                              {preset.isUser && (
+                                <span className="text-xs px-1.5 py-0.5 rounded bg-bambu-blue/20 text-bambu-blue">
+                                  Custom
+                                </span>
+                              )}
+                            </div>
                           </div>
                         </button>
                       ))

+ 479 - 0
frontend/src/components/LocalProfilesView.tsx

@@ -0,0 +1,479 @@
+import { useState, useMemo, useCallback } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import {
+  Upload,
+  Loader2,
+  Search,
+  Trash2,
+  ChevronDown,
+  ChevronUp,
+  HardDrive,
+  Droplet,
+  Settings2,
+  Layers,
+  AlertCircle,
+} from 'lucide-react';
+import { api } from '../api/client';
+import type { LocalPreset } from '../api/client';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
+
+// Known material types for name-parsing fallback
+const MATERIAL_TYPES = ['PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'PC', 'PA', 'PVA', 'HIPS', 'PP', 'PET', 'NYLON'];
+
+const FILAMENT_TYPE_COLORS: Record<string, string> = {
+  PLA: 'E8E8E8', PETG: '4A90D9', ABS: 'E67E22', ASA: 'D35400',
+  TPU: '9B59B6', PC: 'BDC3C7', PA: '2ECC71', NYLON: '2ECC71',
+  PVA: 'F1C40F', HIPS: '95A5A6', PP: 'ECF0F1', PET: '3498DB',
+};
+
+// Extract material type from preset name as fallback
+function parseMaterialFromName(name: string): string | null {
+  const upper = name.toUpperCase();
+  for (const mat of MATERIAL_TYPES) {
+    if (new RegExp(`\\b${mat}\\b`).test(upper)) return mat;
+  }
+  return null;
+}
+
+// Extract vendor from preset name (text before the material type)
+function parseVendorFromName(name: string): string | null {
+  // Strip printer/nozzle suffix first (e.g. "@BBL X1C")
+  const clean = name.replace(/@.+$/, '').trim();
+  const upper = clean.toUpperCase();
+  for (const mat of MATERIAL_TYPES) {
+    const idx = upper.indexOf(mat);
+    if (idx > 0) {
+      const vendor = clean.slice(0, idx).trim();
+      // Skip if vendor looks like a generic prefix (e.g., "Generic", "Bambu")
+      if (vendor && vendor.length > 1) return vendor;
+    }
+  }
+  return null;
+}
+
+function PresetCard({
+  preset,
+  onDelete,
+  onExpand,
+  isExpanded,
+}: {
+  preset: LocalPreset;
+  onDelete: (id: number) => void;
+  onExpand: (id: number | null) => void;
+  isExpanded: boolean;
+}) {
+  const { t } = useTranslation();
+  const { hasPermission } = useAuth();
+
+  // Resolve material type: DB field → parse from name
+  const material = preset.filament_type || parseMaterialFromName(preset.name);
+
+  // Resolve vendor: DB field → parse from name
+  const vendor = preset.filament_vendor || parseVendorFromName(preset.name);
+
+  // Parse colour for swatch — try explicit colour, then fall back to material type
+  let colourHex: string | null = null;
+  let hasExplicitColour = false;
+  if (preset.default_filament_colour) {
+    try {
+      const parsed = JSON.parse(preset.default_filament_colour);
+      const raw = Array.isArray(parsed) ? parsed[0] : parsed;
+      if (typeof raw === 'string' && /^#?[0-9a-fA-F]{6,8}$/.test(raw.replace('#', ''))) {
+        colourHex = raw.replace('#', '').slice(0, 6);
+        hasExplicitColour = true;
+      }
+    } catch {
+      const raw = preset.default_filament_colour;
+      if (/^#?[0-9a-fA-F]{6,8}$/.test(raw.replace('#', ''))) {
+        colourHex = raw.replace('#', '').slice(0, 6);
+        hasExplicitColour = true;
+      }
+    }
+  }
+  if (!colourHex && material) {
+    colourHex = FILAMENT_TYPE_COLORS[material.toUpperCase()] || null;
+  }
+
+  return (
+    <Card className="bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-dark-tertiary/80 transition-colors">
+      <CardContent className="p-3">
+        <div className="flex items-start justify-between gap-2">
+          <div className="flex-1 min-w-0">
+            <div className="flex items-center gap-2 mb-1">
+              {/* 1) Color dot — always shown for filament presets, dimmed if no explicit colour */}
+              {preset.preset_type === 'filament' && (
+                <div
+                  className={`w-4 h-4 rounded-full border border-white/20 flex-shrink-0 ${
+                    !hasExplicitColour && !colourHex ? 'opacity-25' : !hasExplicitColour ? 'opacity-50' : ''
+                  }`}
+                  style={{ backgroundColor: colourHex ? `#${colourHex}` : '#666' }}
+                />
+              )}
+              <span className="text-sm font-medium text-white truncate">{preset.name}</span>
+            </div>
+
+            <div className="flex items-center gap-2 flex-wrap">
+              {/* 2) Material tag — fallback to name parsing */}
+              {material && (
+                <span className="text-xs px-1.5 py-0.5 rounded bg-bambu-green/20 text-bambu-green">
+                  {material}
+                </span>
+              )}
+              {/* 3) Vendor — fallback to name parsing */}
+              {vendor && (
+                <span className="text-xs text-bambu-gray">{vendor}</span>
+              )}
+              <span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">
+                {t('profiles.localProfiles.badge')}
+              </span>
+            </div>
+          </div>
+
+          <div className="flex items-center gap-1 flex-shrink-0">
+            {/* 4) Only delete, no edit */}
+            {hasPermission('settings:update') && (
+              <button
+                onClick={() => onDelete(preset.id)}
+                className="p-1 text-bambu-gray hover:text-red-400 transition-colors"
+                title={t('profiles.localProfiles.delete')}
+              >
+                <Trash2 className="w-3.5 h-3.5" />
+              </button>
+            )}
+            <button
+              onClick={() => onExpand(isExpanded ? null : preset.id)}
+              className="p-1 text-bambu-gray hover:text-white transition-colors"
+            >
+              {isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
+            </button>
+          </div>
+        </div>
+
+        {/* 5) Expanded detail — show meaningful fields, hide self-inherits */}
+        {isExpanded && (
+          <div className="mt-3 pt-3 border-t border-bambu-dark-tertiary text-xs space-y-1.5">
+            {material && (
+              <div className="flex justify-between">
+                <span className="text-bambu-gray">{t('profiles.localProfiles.filamentType')}</span>
+                <span className="text-white">{material}</span>
+              </div>
+            )}
+            {vendor && (
+              <div className="flex justify-between">
+                <span className="text-bambu-gray">{t('profiles.localProfiles.vendor')}</span>
+                <span className="text-white">{vendor}</span>
+              </div>
+            )}
+            {preset.nozzle_temp_min != null && preset.nozzle_temp_max != null && (
+              <div className="flex justify-between">
+                <span className="text-bambu-gray">{t('profiles.localProfiles.nozzleTemp')}</span>
+                <span className="text-white">{preset.nozzle_temp_min}–{preset.nozzle_temp_max}°C</span>
+              </div>
+            )}
+            {preset.filament_cost && (
+              <div className="flex justify-between">
+                <span className="text-bambu-gray">{t('profiles.localProfiles.cost')}</span>
+                <span className="text-white">{preset.filament_cost}</span>
+              </div>
+            )}
+            {preset.filament_density && (
+              <div className="flex justify-between">
+                <span className="text-bambu-gray">{t('profiles.localProfiles.density')}</span>
+                <span className="text-white">{preset.filament_density} g/cm³</span>
+              </div>
+            )}
+            {preset.pressure_advance && (
+              <div className="flex justify-between">
+                <span className="text-bambu-gray">{t('profiles.localProfiles.pressureAdvance')}</span>
+                <span className="text-white">{preset.pressure_advance}</span>
+              </div>
+            )}
+            {preset.compatible_printers && (
+              <div className="flex justify-between">
+                <span className="text-bambu-gray">{t('profiles.localProfiles.compatiblePrinters')}</span>
+                <span className="text-white truncate ml-2">
+                  {(() => { try { return JSON.parse(preset.compatible_printers).join(', '); } catch { return preset.compatible_printers; } })()}
+                </span>
+              </div>
+            )}
+            {/* Only show inherits if different from own name */}
+            {preset.inherits && preset.inherits !== preset.name && (
+              <div className="flex justify-between">
+                <span className="text-bambu-gray">{t('profiles.localProfiles.inheritsFrom')}</span>
+                <span className="text-white truncate ml-2">{preset.inherits}</span>
+              </div>
+            )}
+            <div className="flex justify-between">
+              <span className="text-bambu-gray">{t('profiles.localProfiles.source')}</span>
+              <span className="text-white capitalize">{preset.source}</span>
+            </div>
+          </div>
+        )}
+      </CardContent>
+    </Card>
+  );
+}
+
+export function LocalProfilesView() {
+  const { t } = useTranslation();
+  const { hasPermission } = useAuth();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const [searchQuery, setSearchQuery] = useState('');
+  const [expandedId, setExpandedId] = useState<number | null>(null);
+  const [isDragging, setIsDragging] = useState(false);
+  const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
+
+  const { data: presets, isLoading } = useQuery({
+    queryKey: ['localPresets'],
+    queryFn: () => api.getLocalPresets(),
+  });
+
+  const importMutation = useMutation({
+    mutationFn: async (files: FileList) => {
+      const results = [];
+      for (const file of Array.from(files)) {
+        const formData = new FormData();
+        formData.append('file', file);
+        results.push(await api.importLocalPresets(formData));
+      }
+      return results;
+    },
+    onSuccess: (results) => {
+      queryClient.invalidateQueries({ queryKey: ['localPresets'] });
+      let totalImported = 0;
+      let totalSkipped = 0;
+      let totalErrors = 0;
+      for (const r of results) {
+        totalImported += r.imported;
+        totalSkipped += r.skipped;
+        totalErrors += r.errors.length;
+      }
+
+      if (totalImported > 0) {
+        showToast(t('profiles.localProfiles.toast.importSuccess', { count: totalImported }));
+      }
+      if (totalSkipped > 0) {
+        showToast(t('profiles.localProfiles.toast.importSkipped', { count: totalSkipped }), 'warning');
+      }
+      if (totalErrors > 0) {
+        showToast(t('profiles.localProfiles.toast.importError', { count: totalErrors }), 'error');
+      }
+    },
+    onError: (err: Error) => {
+      showToast(err.message, 'error');
+    },
+  });
+
+  const deleteMutation = useMutation({
+    mutationFn: (id: number) => api.deleteLocalPreset(id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['localPresets'] });
+      setDeleteConfirm(null);
+      showToast(t('profiles.localProfiles.toast.deleted'));
+    },
+  });
+
+  const handleFiles = useCallback((files: FileList | null) => {
+    if (!files || files.length === 0) return;
+    importMutation.mutate(files);
+  }, [importMutation]);
+
+  const handleDrop = useCallback((e: React.DragEvent) => {
+    e.preventDefault();
+    setIsDragging(false);
+    handleFiles(e.dataTransfer.files);
+  }, [handleFiles]);
+
+  const filterPresets = useCallback((list: LocalPreset[]) => {
+    if (!searchQuery) return list;
+    const q = searchQuery.toLowerCase();
+    return list.filter(p =>
+      p.name.toLowerCase().includes(q) ||
+      p.filament_type?.toLowerCase().includes(q) ||
+      p.filament_vendor?.toLowerCase().includes(q)
+    );
+  }, [searchQuery]);
+
+  const filaments = useMemo(() => filterPresets(presets?.filament || []), [presets?.filament, filterPresets]);
+  const printers = useMemo(() => filterPresets(presets?.printer || []), [presets?.printer, filterPresets]);
+  const processes = useMemo(() => filterPresets(presets?.process || []), [presets?.process, filterPresets]);
+  const totalCount = filaments.length + printers.length + processes.length;
+
+  if (isLoading) {
+    return (
+      <div className="flex items-center justify-center py-16">
+        <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+      </div>
+    );
+  }
+
+  return (
+    <div className="space-y-6">
+      {/* Import Zone */}
+      {hasPermission('settings:update') && (
+        <div
+          onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
+          onDragLeave={() => setIsDragging(false)}
+          onDrop={handleDrop}
+          className={`relative border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
+            isDragging
+              ? 'border-bambu-green bg-bambu-green/10'
+              : 'border-bambu-dark-tertiary hover:border-bambu-gray'
+          }`}
+        >
+          <input
+            type="file"
+            accept=".json,.zip,.orca_filament,.bbscfg,.bbsflmt"
+            multiple
+            className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
+            onChange={(e) => handleFiles(e.target.files)}
+          />
+          {importMutation.isPending ? (
+            <div className="flex items-center justify-center gap-2">
+              <Loader2 className="w-5 h-5 text-bambu-green animate-spin" />
+              <span className="text-bambu-gray">{t('profiles.localProfiles.importing')}</span>
+            </div>
+          ) : (
+            <>
+              <Upload className="w-8 h-8 text-bambu-gray mx-auto mb-2" />
+              <p className="text-sm text-white font-medium">{t('profiles.localProfiles.import')}</p>
+              <p className="text-xs text-bambu-gray mt-1">{t('profiles.localProfiles.importDesc')}</p>
+            </>
+          )}
+        </div>
+      )}
+
+      {/* Search Bar */}
+      {totalCount > 0 && (
+        <div className="relative">
+          <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
+          <input
+            type="text"
+            value={searchQuery}
+            onChange={(e) => setSearchQuery(e.target.value)}
+            placeholder={t('profiles.localProfiles.search')}
+            className="w-full pl-9 pr-4 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+          />
+        </div>
+      )}
+
+      {/* No Presets */}
+      {totalCount === 0 && !isLoading && (
+        <div className="text-center py-12">
+          <HardDrive className="w-12 h-12 text-bambu-gray mx-auto mb-3 opacity-50" />
+          <p className="text-bambu-gray">{t('profiles.localProfiles.noPresets')}</p>
+          <p className="text-xs text-bambu-gray/60 mt-1">{t('profiles.localProfiles.importDesc')}</p>
+        </div>
+      )}
+
+      {/* 3-Column Preset Lists */}
+      {totalCount > 0 && (
+        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
+          {/* Filament Column */}
+          {filaments.length > 0 && (
+            <div>
+              <div className="flex items-center gap-2 mb-3">
+                <Droplet className="w-4 h-4 text-bambu-green" />
+                <h3 className="text-sm font-medium text-white">
+                  {t('profiles.localProfiles.filament')}
+                </h3>
+                <span className="text-xs text-bambu-gray">({filaments.length})</span>
+              </div>
+              <div className="space-y-2">
+                {filaments.map(p => (
+                  <PresetCard
+                    key={p.id}
+                    preset={p}
+                    onDelete={(id) => setDeleteConfirm(id)}
+                    onExpand={setExpandedId}
+                    isExpanded={expandedId === p.id}
+                  />
+                ))}
+              </div>
+            </div>
+          )}
+
+          {/* Process Column */}
+          {processes.length > 0 && (
+            <div>
+              <div className="flex items-center gap-2 mb-3">
+                <Layers className="w-4 h-4 text-blue-400" />
+                <h3 className="text-sm font-medium text-white">
+                  {t('profiles.localProfiles.process')}
+                </h3>
+                <span className="text-xs text-bambu-gray">({processes.length})</span>
+              </div>
+              <div className="space-y-2">
+                {processes.map(p => (
+                  <PresetCard
+                    key={p.id}
+                    preset={p}
+                    onDelete={(id) => setDeleteConfirm(id)}
+                    onExpand={setExpandedId}
+                    isExpanded={expandedId === p.id}
+                  />
+                ))}
+              </div>
+            </div>
+          )}
+
+          {/* Printer Column */}
+          {printers.length > 0 && (
+            <div>
+              <div className="flex items-center gap-2 mb-3">
+                <Settings2 className="w-4 h-4 text-orange-400" />
+                <h3 className="text-sm font-medium text-white">
+                  {t('profiles.localProfiles.printer')}
+                </h3>
+                <span className="text-xs text-bambu-gray">({printers.length})</span>
+              </div>
+              <div className="space-y-2">
+                {printers.map(p => (
+                  <PresetCard
+                    key={p.id}
+                    preset={p}
+                    onDelete={(id) => setDeleteConfirm(id)}
+                    onExpand={setExpandedId}
+                    isExpanded={expandedId === p.id}
+                  />
+                ))}
+              </div>
+            </div>
+          )}
+        </div>
+      )}
+
+      {/* Delete Confirmation Modal */}
+      {deleteConfirm !== null && (
+        <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
+          <div className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg p-6 max-w-sm mx-4">
+            <div className="flex items-center gap-2 mb-3">
+              <AlertCircle className="w-5 h-5 text-red-400" />
+              <h3 className="text-white font-medium">{t('profiles.localProfiles.deleteConfirmTitle')}</h3>
+            </div>
+            <p className="text-sm text-bambu-gray mb-4">{t('profiles.localProfiles.deleteConfirm')}</p>
+            <div className="flex justify-end gap-2">
+              <Button variant="secondary" size="sm" onClick={() => setDeleteConfirm(null)}>
+                {t('profiles.localProfiles.cancel')}
+              </Button>
+              <Button
+                variant="danger"
+                size="sm"
+                onClick={() => deleteMutation.mutate(deleteConfirm)}
+                disabled={deleteMutation.isPending}
+              >
+                {deleteMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
+                {t('profiles.localProfiles.delete')}
+              </Button>
+            </div>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

+ 35 - 0
frontend/src/i18n/locales/de.ts

@@ -1645,8 +1645,43 @@ export default {
     subtitle: 'Verwalten Sie Ihre Slicer-Voreinstellungen und Druckvorschub-Kalibrierungen',
     tabs: {
       cloud: 'Cloud-Profile',
+      local: 'Lokale Profile',
       kprofiles: 'K-Profile',
     },
+    localProfiles: {
+      title: 'Lokale Profile',
+      subtitle: 'Slicer-Voreinstellungen aus OrcaSlicer importieren und verwalten',
+      import: 'Profile importieren',
+      importDesc: '.bbscfg-, .bbsflmt-, .orca_filament-, .zip- oder .json-Dateien hier ablegen',
+      importing: 'Importiere...',
+      search: 'Lokale Voreinstellungen durchsuchen...',
+      noPresets: 'Noch keine lokalen Voreinstellungen',
+      badge: 'Lokal',
+      edit: 'Bearbeiten',
+      delete: 'Löschen',
+      cancel: 'Abbrechen',
+      deleteConfirmTitle: 'Voreinstellung löschen',
+      deleteConfirm: 'Möchten Sie diese Voreinstellung wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
+      source: 'Quelle',
+      inheritsFrom: 'Erbt von',
+      filamentType: 'Typ',
+      vendor: 'Hersteller',
+      compatiblePrinters: 'Drucker',
+      nozzleTemp: 'Düsentemperatur',
+      cost: 'Kosten',
+      density: 'Dichte',
+      pressureAdvance: 'Druckvorschub',
+      filament: 'Filament',
+      process: 'Prozess',
+      printer: 'Drucker',
+      toast: {
+        importSuccess: '{{count}} Voreinstellung(en) importiert',
+        importSkipped: '{{count}} Voreinstellung(en) übersprungen (Duplikate)',
+        importError: '{{count}} Fehler beim Import',
+        deleted: 'Voreinstellung gelöscht',
+        updated: 'Voreinstellung aktualisiert',
+      },
+    },
     connectedAs: 'Verbunden als',
     logout: 'Abmelden',
     noLogoutPermission: 'Sie haben keine Berechtigung zum Abmelden',

+ 35 - 0
frontend/src/i18n/locales/en.ts

@@ -1645,8 +1645,43 @@ export default {
     subtitle: 'Manage your slicer presets and pressure advance calibrations',
     tabs: {
       cloud: 'Cloud Profiles',
+      local: 'Local Profiles',
       kprofiles: 'K-Profiles',
     },
+    localProfiles: {
+      title: 'Local Profiles',
+      subtitle: 'Import and manage slicer presets from OrcaSlicer',
+      import: 'Import Profiles',
+      importDesc: 'Drop .bbscfg, .bbsflmt, .orca_filament, .zip, or .json files here',
+      importing: 'Importing...',
+      search: 'Search local presets...',
+      noPresets: 'No local presets yet',
+      badge: 'Local',
+      edit: 'Edit',
+      delete: 'Delete',
+      cancel: 'Cancel',
+      deleteConfirmTitle: 'Delete Preset',
+      deleteConfirm: 'Are you sure you want to delete this preset? This cannot be undone.',
+      source: 'Source',
+      inheritsFrom: 'Inherits',
+      filamentType: 'Type',
+      vendor: 'Vendor',
+      compatiblePrinters: 'Printers',
+      nozzleTemp: 'Nozzle Temp',
+      cost: 'Cost',
+      density: 'Density',
+      pressureAdvance: 'Pressure Advance',
+      filament: 'Filament',
+      process: 'Process',
+      printer: 'Printer',
+      toast: {
+        importSuccess: '{{count}} preset(s) imported',
+        importSkipped: '{{count}} preset(s) skipped (duplicates)',
+        importError: '{{count}} error(s) during import',
+        deleted: 'Preset deleted',
+        updated: 'Preset updated',
+      },
+    },
     connectedAs: 'Connected as',
     logout: 'Logout',
     noLogoutPermission: 'You do not have permission to logout',

+ 35 - 0
frontend/src/i18n/locales/it.ts

@@ -1645,8 +1645,43 @@ export default {
     subtitle: 'Gestisci preset slicer e calibrazioni pressure advance',
     tabs: {
       cloud: 'Profili cloud',
+      local: 'Profili locali',
       kprofiles: 'K-Profiles',
     },
+    localProfiles: {
+      title: 'Profili locali',
+      subtitle: 'Importa e gestisci preset slicer da OrcaSlicer',
+      import: 'Importa profili',
+      importDesc: 'Trascina file .bbscfg, .bbsflmt, .orca_filament, .zip o .json qui',
+      importing: 'Importazione...',
+      search: 'Cerca preset locali...',
+      noPresets: 'Nessun preset locale ancora',
+      badge: 'Locale',
+      edit: 'Modifica',
+      delete: 'Elimina',
+      cancel: 'Annulla',
+      deleteConfirmTitle: 'Elimina preset',
+      deleteConfirm: 'Sei sicuro di voler eliminare questo preset? Questa azione non può essere annullata.',
+      source: 'Fonte',
+      inheritsFrom: 'Eredita da',
+      filamentType: 'Tipo',
+      vendor: 'Produttore',
+      compatiblePrinters: 'Stampanti',
+      nozzleTemp: 'Temp. ugello',
+      cost: 'Costo',
+      density: 'Densità',
+      pressureAdvance: 'Pressure Advance',
+      filament: 'Filamento',
+      process: 'Processo',
+      printer: 'Stampante',
+      toast: {
+        importSuccess: '{{count}} preset importati',
+        importSkipped: '{{count}} preset saltati (duplicati)',
+        importError: '{{count}} errori durante l\'importazione',
+        deleted: 'Preset eliminato',
+        updated: 'Preset aggiornato',
+      },
+    },
     connectedAs: 'Connesso come',
     logout: 'Esci',
     noLogoutPermission: 'Non hai il permesso di disconnetterti',

+ 35 - 0
frontend/src/i18n/locales/ja.ts

@@ -1773,8 +1773,43 @@ export default {
     subtitle: 'スライサープリセットと圧力キャリブレーションの管理',
     tabs: {
       cloud: 'クラウドプロファイル',
+      local: 'ローカルプロファイル',
       kprofiles: 'Kプロファイル',
     },
+    localProfiles: {
+      title: 'ローカルプロファイル',
+      subtitle: 'OrcaSlicerからスライサープリセットをインポート・管理',
+      import: 'プロファイルをインポート',
+      importDesc: '.bbscfg、.bbsflmt、.orca_filament、.zip、.jsonファイルをここにドロップ',
+      importing: 'インポート中...',
+      search: 'ローカルプリセットを検索...',
+      noPresets: 'ローカルプリセットがまだありません',
+      badge: 'ローカル',
+      edit: '編集',
+      delete: '削除',
+      cancel: 'キャンセル',
+      deleteConfirmTitle: 'プリセットを削除',
+      deleteConfirm: 'このプリセットを削除してもよろしいですか?元に戻せません。',
+      source: 'ソース',
+      inheritsFrom: '継承元',
+      filamentType: 'タイプ',
+      vendor: 'メーカー',
+      compatiblePrinters: 'プリンター',
+      nozzleTemp: 'ノズル温度',
+      cost: 'コスト',
+      density: '密度',
+      pressureAdvance: 'プレッシャーアドバンス',
+      filament: 'フィラメント',
+      process: 'プロセス',
+      printer: 'プリンター',
+      toast: {
+        importSuccess: '{{count}}件のプリセットをインポートしました',
+        importSkipped: '{{count}}件のプリセットをスキップしました(重複)',
+        importError: 'インポート中に{{count}}件のエラーが発生しました',
+        deleted: 'プリセットを削除しました',
+        updated: 'プリセットを更新しました',
+      },
+    },
     noLogoutPermission: 'ログアウトする権限がありません',
     retry: 'リトライ',
   },

+ 17 - 1
frontend/src/pages/ProfilesPage.tsx

@@ -40,6 +40,7 @@ import {
   Equal,
   Minus as MinusIcon,
   Plus as PlusIcon,
+  HardDrive,
 } from 'lucide-react';
 import { api } from '../api/client';
 import { parseUTCDate } from '../utils/date';
@@ -49,9 +50,10 @@ import { Button } from '../components/Button';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { KProfilesView } from '../components/KProfilesView';
+import { LocalProfilesView } from '../components/LocalProfilesView';
 
 type TFunction = (key: string, options?: Record<string, unknown>) => string;
-type ProfileTab = 'cloud' | 'kprofiles';
+type ProfileTab = 'cloud' | 'local' | 'kprofiles';
 type LoginStep = 'email' | 'code' | 'token';
 type PresetType = 'all' | 'filament' | 'printer' | 'process';
 
@@ -2890,6 +2892,17 @@ export function ProfilesPage() {
           <Cloud className="w-4 h-4" />
           {t('profiles.tabs.cloud')}
         </button>
+        <button
+          onClick={() => setActiveTab('local')}
+          className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
+            activeTab === 'local'
+              ? 'text-bambu-green border-bambu-green'
+              : 'text-bambu-gray hover:text-white border-transparent'
+          }`}
+        >
+          <HardDrive className="w-4 h-4" />
+          {t('profiles.tabs.local')}
+        </button>
         <button
           onClick={() => setActiveTab('kprofiles')}
           className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
@@ -2953,6 +2966,9 @@ export function ProfilesPage() {
         </>
       )}
 
+      {/* Local Profiles Tab */}
+      {activeTab === 'local' && <LocalProfilesView />}
+
       {/* K-Profiles Tab */}
       {activeTab === 'kprofiles' && <KProfilesView />}
 

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/index-47EQ7Zpi.css


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/index-C468hjMw.js


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DzOw4aza.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-HwDBvXUG.css">
+    <script type="module" crossorigin src="/assets/index-C468hjMw.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-47EQ7Zpi.css">
   </head>
   <body>
     <div id="root"></div>

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä