Browse Source

Add project import/export with ZIP file support

Features:
- Export single project as ZIP containing project.json and all files
  from linked library folders
- Export all projects as JSON (metadata only) for bulk backup
- Import from ZIP (creates folders and extracts files) or JSON
- New /api/v1/projects/import/file endpoint for file uploads
- Frontend buttons on Projects page and Project Detail page
- BOM items are now fully editable (not just checkbox toggle)
maziggy 4 months ago
parent
commit
30537cfa54

+ 9 - 0
CHANGELOG.md

@@ -5,6 +5,15 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.1.6] - 2026-01-24
 ## [0.1.6] - 2026-01-24
 
 
 ### New Features
 ### New Features
+- **Project Import/Export** - Export and import projects with full file support (Issue #152):
+  - Export single project as ZIP (includes project settings, BOM, and all files from linked library folders)
+  - Export all projects as JSON for metadata-only backup
+  - Import from ZIP (with files) or JSON (metadata only)
+  - Linked folders and files are automatically created on import
+  - Useful for sharing complete project bundles or migrating between instances
+- **BOM Item Editing** - Bill of Materials items are now fully editable:
+  - Edit name, quantity, price, URL, and remarks after creation
+  - Pencil icon on each BOM item to enter edit mode
 - **External Link for Archives** - Add custom external links to archives for non-MakerWorld sources (Issue #151):
 - **External Link for Archives** - Add custom external links to archives for non-MakerWorld sources (Issue #151):
   - Link archives to Printables, Thingiverse, or any other URL
   - Link archives to Printables, Thingiverse, or any other URL
   - Globe button opens external link when set, falls back to auto-detected MakerWorld URL
   - Globe button opens external link when set, falls back to auto-detected MakerWorld URL

+ 1 - 0
README.md

@@ -100,6 +100,7 @@
 - Auto-detect parts count from 3MF files
 - Auto-detect parts count from 3MF files
 - Color-coded project badges
 - Color-coded project badges
 - Bulk assign archives via multi-select toolbar
 - Bulk assign archives via multi-select toolbar
+- Import/Export projects as ZIP (includes files) or JSON
 
 
 </td>
 </td>
 <td width="50%" valign="top">
 <td width="50%" valign="top">

+ 396 - 1
backend/app/api/routes/projects.py

@@ -1,18 +1,23 @@
+import io
+import json
 import logging
 import logging
 import os
 import os
 import uuid
 import uuid
+import zipfile
 from datetime import datetime
 from datetime import datetime
 from pathlib import Path
 from pathlib import Path
 
 
 from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
 from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
-from fastapi.responses import FileResponse
+from fastapi.responses import FileResponse, StreamingResponse
 from sqlalchemy import case, func, select
 from sqlalchemy import case, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
+from backend.app.api.routes.library import get_library_dir
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
+from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.project import Project
 from backend.app.models.project import Project
 from backend.app.models.project_bom import ProjectBOMItem
 from backend.app.models.project_bom import ProjectBOMItem
@@ -25,6 +30,7 @@ from backend.app.schemas.project import (
     BOMItemUpdate,
     BOMItemUpdate,
     ProjectChildPreview,
     ProjectChildPreview,
     ProjectCreate,
     ProjectCreate,
+    ProjectImport,
     ProjectListResponse,
     ProjectListResponse,
     ProjectResponse,
     ProjectResponse,
     ProjectStats,
     ProjectStats,
@@ -1322,3 +1328,392 @@ async def get_project_timeline(
     events.sort(key=lambda e: e.timestamp, reverse=True)
     events.sort(key=lambda e: e.timestamp, reverse=True)
 
 
     return events[:limit]
     return events[:limit]
+
+
+# ============ Phase 10: Import/Export Endpoints ============
+
+
+@router.get("/{project_id}/export")
+async def export_project(
+    project_id: int,
+    format: str = "zip",  # "zip" (with files) or "json" (metadata only)
+    db: AsyncSession = Depends(get_db),
+):
+    """Export a project. Use format=zip (default) for full export with files, or format=json for metadata only."""
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    project = result.scalar_one_or_none()
+
+    if not project:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    # Get BOM items
+    bom_result = await db.execute(
+        select(ProjectBOMItem).where(ProjectBOMItem.project_id == project_id).order_by(ProjectBOMItem.sort_order)
+    )
+    bom_items = bom_result.scalars().all()
+
+    bom_export = [
+        {
+            "name": item.name,
+            "quantity_needed": item.quantity_needed,
+            "quantity_acquired": item.quantity_acquired,
+            "unit_price": item.unit_price,
+            "sourcing_url": item.sourcing_url,
+            "stl_filename": item.stl_filename,
+            "remarks": item.remarks,
+        }
+        for item in bom_items
+    ]
+
+    # Get linked folders and their files
+    folders_result = await db.execute(
+        select(LibraryFolder).where(LibraryFolder.project_id == project_id).order_by(LibraryFolder.name)
+    )
+    linked_folders = folders_result.scalars().all()
+
+    folders_export = []
+    files_to_include = []  # (archive_path, zip_path)
+
+    for folder in linked_folders:
+        # Get files in this folder
+        files_result = await db.execute(
+            select(LibraryFile).where(LibraryFile.folder_id == folder.id).order_by(LibraryFile.filename)
+        )
+        files = files_result.scalars().all()
+
+        folder_files = []
+        for f in files:
+            folder_files.append(
+                {
+                    "filename": f.filename,
+                    "file_type": f.file_type,
+                    "notes": f.notes,
+                }
+            )
+            # Add file to include in ZIP
+            library_dir = get_library_dir()
+            file_path = library_dir / f.file_path
+            if file_path.exists():
+                zip_path = f"files/{folder.name}/{f.filename}"
+                files_to_include.append((file_path, zip_path))
+                # Also include thumbnail if exists
+                if f.thumbnail_path:
+                    thumb_path = library_dir / f.thumbnail_path
+                    if thumb_path.exists():
+                        thumb_zip_path = f"files/{folder.name}/.thumbnails/{f.filename}.png"
+                        files_to_include.append((thumb_path, thumb_zip_path))
+
+        folders_export.append(
+            {
+                "name": folder.name,
+                "files": folder_files,
+            }
+        )
+
+    # Build project JSON
+    project_data = {
+        "name": project.name,
+        "description": project.description,
+        "color": project.color,
+        "status": project.status,
+        "target_count": project.target_count,
+        "target_parts_count": project.target_parts_count,
+        "notes": project.notes,
+        "tags": project.tags,
+        "due_date": project.due_date.isoformat() if project.due_date else None,
+        "priority": project.priority,
+        "budget": project.budget,
+        "bom_items": bom_export,
+        "linked_folders": folders_export,
+    }
+
+    # Return JSON if requested (for bulk export)
+    if format == "json":
+        return project_data
+
+    # Create ZIP in memory
+    zip_buffer = io.BytesIO()
+    with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+        # Add project.json
+        zf.writestr("project.json", json.dumps(project_data, indent=2))
+
+        # Add files
+        for file_path, zip_path in files_to_include:
+            zf.write(file_path, zip_path)
+
+    zip_buffer.seek(0)
+
+    # Generate filename
+    safe_name = "".join(c if c.isalnum() or c in "-_ " else "_" for c in project.name)
+    filename = f"{safe_name}_{datetime.now().strftime('%Y-%m-%d')}.zip"
+
+    return StreamingResponse(
+        zip_buffer,
+        media_type="application/zip",
+        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+    )
+
+
+@router.post("/import", response_model=ProjectResponse)
+async def import_project(
+    data: ProjectImport,
+    db: AsyncSession = Depends(get_db),
+):
+    """Import a project with optional BOM items and linked folders."""
+    # Create the project
+    project = Project(
+        name=data.name,
+        description=data.description,
+        color=data.color,
+        status=data.status,
+        target_count=data.target_count,
+        target_parts_count=data.target_parts_count,
+        notes=data.notes,
+        tags=data.tags,
+        due_date=data.due_date,
+        priority=data.priority,
+        budget=data.budget,
+    )
+    db.add(project)
+    await db.flush()
+
+    # Create BOM items
+    for idx, bom_data in enumerate(data.bom_items):
+        bom_item = ProjectBOMItem(
+            project_id=project.id,
+            name=bom_data.name,
+            quantity_needed=bom_data.quantity_needed,
+            quantity_acquired=bom_data.quantity_acquired,
+            unit_price=bom_data.unit_price,
+            sourcing_url=bom_data.sourcing_url,
+            stl_filename=bom_data.stl_filename,
+            remarks=bom_data.remarks,
+            sort_order=idx,
+        )
+        db.add(bom_item)
+
+    # Create linked folders in library
+    for folder_data in data.linked_folders:
+        # Check if folder with this name already exists at root level
+        existing_result = await db.execute(
+            select(LibraryFolder).where(
+                LibraryFolder.name == folder_data.name,
+                LibraryFolder.parent_id.is_(None),
+            )
+        )
+        existing_folder = existing_result.scalar_one_or_none()
+
+        if existing_folder:
+            # Link existing folder to project
+            existing_folder.project_id = project.id
+        else:
+            # Create new folder linked to project
+            new_folder = LibraryFolder(
+                name=folder_data.name,
+                project_id=project.id,
+                is_external=False,
+                external_readonly=False,
+                external_show_hidden=False,
+            )
+            db.add(new_folder)
+
+    await db.flush()
+    await db.refresh(project)
+
+    stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
+
+    return ProjectResponse(
+        id=project.id,
+        name=project.name,
+        description=project.description,
+        color=project.color,
+        status=project.status,
+        target_count=project.target_count,
+        target_parts_count=project.target_parts_count,
+        notes=project.notes,
+        attachments=project.attachments,
+        tags=project.tags,
+        due_date=project.due_date,
+        priority=project.priority,
+        budget=project.budget,
+        is_template=project.is_template,
+        template_source_id=project.template_source_id,
+        parent_id=project.parent_id,
+        parent_name=None,
+        children=[],
+        created_at=project.created_at,
+        updated_at=project.updated_at,
+        stats=stats,
+    )
+
+
+@router.post("/import/file", response_model=ProjectResponse)
+async def import_project_file(
+    file: UploadFile = File(...),
+    db: AsyncSession = Depends(get_db),
+):
+    """Import a project from a ZIP or JSON file."""
+    if not file.filename:
+        raise HTTPException(status_code=400, detail="No filename provided")
+
+    # Determine file type
+    filename_lower = file.filename.lower()
+    content = await file.read()
+
+    if filename_lower.endswith(".zip"):
+        # Extract project.json from ZIP
+        try:
+            with zipfile.ZipFile(io.BytesIO(content)) as zf:
+                if "project.json" not in zf.namelist():
+                    raise HTTPException(status_code=400, detail="ZIP must contain project.json")
+                project_json = zf.read("project.json")
+                data = json.loads(project_json)
+
+                # Get list of files in the ZIP
+                zip_files = {name: zf.read(name) for name in zf.namelist() if name.startswith("files/")}
+        except zipfile.BadZipFile:
+            raise HTTPException(status_code=400, detail="Invalid ZIP file")
+    elif filename_lower.endswith(".json"):
+        try:
+            data = json.loads(content)
+            zip_files = {}
+        except json.JSONDecodeError:
+            raise HTTPException(status_code=400, detail="Invalid JSON file")
+    else:
+        raise HTTPException(status_code=400, detail="File must be .zip or .json")
+
+    # Create the project
+    project = Project(
+        name=data.get("name", "Imported Project"),
+        description=data.get("description"),
+        color=data.get("color"),
+        status=data.get("status", "active"),
+        target_count=data.get("target_count"),
+        target_parts_count=data.get("target_parts_count"),
+        notes=data.get("notes"),
+        tags=data.get("tags"),
+        due_date=datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None,
+        priority=data.get("priority", 0),
+        budget=data.get("budget"),
+    )
+    db.add(project)
+    await db.flush()
+
+    # Create BOM items
+    for idx, bom_data in enumerate(data.get("bom_items", [])):
+        bom_item = ProjectBOMItem(
+            project_id=project.id,
+            name=bom_data.get("name", "Unnamed"),
+            quantity_needed=bom_data.get("quantity_needed", 1),
+            quantity_acquired=bom_data.get("quantity_acquired", 0),
+            unit_price=bom_data.get("unit_price"),
+            sourcing_url=bom_data.get("sourcing_url"),
+            stl_filename=bom_data.get("stl_filename"),
+            remarks=bom_data.get("remarks"),
+            sort_order=idx,
+        )
+        db.add(bom_item)
+
+    # Create linked folders and files
+    library_dir = get_library_dir()
+    for folder_data in data.get("linked_folders", []):
+        folder_name = folder_data.get("name")
+        if not folder_name:
+            continue
+
+        # Check if folder exists
+        existing_result = await db.execute(
+            select(LibraryFolder).where(
+                LibraryFolder.name == folder_name,
+                LibraryFolder.parent_id.is_(None),
+            )
+        )
+        existing_folder = existing_result.scalar_one_or_none()
+
+        if existing_folder:
+            # Link existing folder to project
+            existing_folder.project_id = project.id
+            folder = existing_folder
+        else:
+            # Create new folder
+            folder = LibraryFolder(
+                name=folder_name,
+                project_id=project.id,
+                is_external=False,
+                external_readonly=False,
+                external_show_hidden=False,
+            )
+            db.add(folder)
+            await db.flush()
+
+            # Create folder on disk
+            folder_path = library_dir / folder_name
+            folder_path.mkdir(parents=True, exist_ok=True)
+
+        # Import files for this folder from ZIP
+        folder_prefix = f"files/{folder_name}/"
+        for zip_path, file_content in zip_files.items():
+            if not zip_path.startswith(folder_prefix):
+                continue
+            if "/.thumbnails/" in zip_path:
+                continue  # Skip thumbnails, we'll regenerate them
+
+            relative_path = zip_path[len(folder_prefix) :]
+            if not relative_path:
+                continue
+
+            # Write file to disk
+            file_disk_path = library_dir / folder_name / relative_path
+            file_disk_path.parent.mkdir(parents=True, exist_ok=True)
+            file_disk_path.write_bytes(file_content)
+
+            # Determine file type
+            ext = Path(relative_path).suffix.lower()
+            if ext in [".stl", ".3mf", ".obj"]:
+                file_type = "model"
+            elif ext in [".gcode"]:
+                file_type = "gcode"
+            elif ext in [".jpg", ".jpeg", ".png", ".gif", ".webp"]:
+                file_type = "image"
+            else:
+                file_type = "other"
+
+            # Create library file record
+            lib_file = LibraryFile(
+                folder_id=folder.id,
+                filename=relative_path,
+                file_path=f"{folder_name}/{relative_path}",
+                file_type=file_type,
+                file_size=len(file_content),
+                is_external=False,
+            )
+            db.add(lib_file)
+
+    await db.flush()
+    await db.refresh(project)
+
+    stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
+
+    return ProjectResponse(
+        id=project.id,
+        name=project.name,
+        description=project.description,
+        color=project.color,
+        status=project.status,
+        target_count=project.target_count,
+        target_parts_count=project.target_parts_count,
+        notes=project.notes,
+        attachments=project.attachments,
+        tags=project.tags,
+        due_date=project.due_date,
+        priority=project.priority,
+        budget=project.budget,
+        is_template=project.is_template,
+        template_source_id=project.template_source_id,
+        parent_id=project.parent_id,
+        parent_name=None,
+        children=[],
+        created_at=project.created_at,
+        updated_at=project.updated_at,
+        stats=stats,
+    )

+ 10 - 1
backend/app/models/library.py

@@ -2,7 +2,7 @@
 
 
 from datetime import datetime
 from datetime import datetime
 
 
-from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, Text, func
+from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, Text, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 
 from backend.app.core.database import Base
 from backend.app.core.database import Base
@@ -17,6 +17,12 @@ class LibraryFolder(Base):
     name: Mapped[str] = mapped_column(String(255))
     name: Mapped[str] = mapped_column(String(255))
     parent_id: Mapped[int | None] = mapped_column(ForeignKey("library_folders.id", ondelete="CASCADE"), nullable=True)
     parent_id: Mapped[int | None] = mapped_column(ForeignKey("library_folders.id", ondelete="CASCADE"), nullable=True)
 
 
+    # External folder flags (for folders that point to external paths)
+    is_external: Mapped[bool] = mapped_column(Boolean, default=False)
+    external_readonly: Mapped[bool] = mapped_column(Boolean, default=False)
+    external_show_hidden: Mapped[bool] = mapped_column(Boolean, default=False)
+    external_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
+
     # Link to project or archive
     # Link to project or archive
     project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
     project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
     archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True)
     archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True)
@@ -55,6 +61,9 @@ class LibraryFile(Base):
     folder_id: Mapped[int | None] = mapped_column(ForeignKey("library_folders.id", ondelete="CASCADE"), nullable=True)
     folder_id: Mapped[int | None] = mapped_column(ForeignKey("library_folders.id", ondelete="CASCADE"), nullable=True)
     project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
     project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
 
 
+    # External file flag
+    is_external: Mapped[bool] = mapped_column(Boolean, default=False)
+
     # File info
     # File info
     filename: Mapped[str] = mapped_column(String(255))  # Original filename
     filename: Mapped[str] = mapped_column(String(255))  # Original filename
     file_path: Mapped[str] = mapped_column(String(500))  # Storage path
     file_path: Mapped[str] = mapped_column(String(500))  # Storage path

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

@@ -205,3 +205,58 @@ class TimelineEvent(BaseModel):
     title: str
     title: str
     description: str | None = None
     description: str | None = None
     metadata: dict | None = None  # Additional event-specific data
     metadata: dict | None = None  # Additional event-specific data
+
+
+# Phase 10: Import/Export Schemas
+class BOMItemExport(BaseModel):
+    """Schema for exporting a BOM item."""
+
+    name: str
+    quantity_needed: int
+    quantity_acquired: int
+    unit_price: float | None
+    sourcing_url: str | None
+    stl_filename: str | None
+    remarks: str | None
+
+
+class LinkedFolderExport(BaseModel):
+    """Schema for exporting a linked library folder."""
+
+    name: str
+
+
+class ProjectExport(BaseModel):
+    """Schema for exporting a project."""
+
+    name: str
+    description: str | None
+    color: str | None
+    status: str
+    target_count: int | None
+    target_parts_count: int | None
+    notes: str | None
+    tags: str | None
+    due_date: datetime | None
+    priority: str
+    budget: float | None
+    bom_items: list[BOMItemExport] = []
+    linked_folders: list[LinkedFolderExport] = []
+
+
+class ProjectImport(BaseModel):
+    """Schema for importing a project."""
+
+    name: str
+    description: str | None = None
+    color: str | None = None
+    status: str = "active"
+    target_count: int | None = None
+    target_parts_count: int | None = None
+    notes: str | None = None
+    tags: str | None = None
+    due_date: datetime | None = None
+    priority: str = "normal"
+    budget: float | None = None
+    bom_items: list[BOMItemExport] = []
+    linked_folders: list[LinkedFolderExport] = []

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

@@ -297,3 +297,330 @@ class TestProjectArchivesAPI:
         # Project should have an archive count (may be 0)
         # Project should have an archive count (may be 0)
         data = response.json()
         data = response.json()
         assert "name" in data
         assert "name" in data
+
+
+class TestProjectExportImport:
+    """Tests for project export/import functionality."""
+
+    @pytest.fixture
+    async def project_factory(self, db_session):
+        """Factory to create test projects."""
+        _counter = [0]
+
+        async def _create_project(**kwargs):
+            from backend.app.models.project import Project
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "name": f"Export Test Project {counter}",
+                "description": "Test project for export",
+                "color": "#00FF00",
+            }
+            defaults.update(kwargs)
+
+            project = Project(**defaults)
+            db_session.add(project)
+            await db_session.commit()
+            await db_session.refresh(project)
+            return project
+
+        return _create_project
+
+    @pytest.fixture
+    async def bom_item_factory(self, db_session):
+        """Factory to create test BOM items."""
+
+        async def _create_bom_item(project_id: int, **kwargs):
+            from backend.app.models.project_bom import ProjectBOMItem
+
+            defaults = {
+                "project_id": project_id,
+                "name": "Test Part",
+                "quantity_needed": 1,
+                "quantity_acquired": 0,
+                "sort_order": 0,
+            }
+            defaults.update(kwargs)
+
+            item = ProjectBOMItem(**defaults)
+            db_session.add(item)
+            await db_session.commit()
+            await db_session.refresh(item)
+            return item
+
+        return _create_bom_item
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_export_project(self, async_client: AsyncClient, project_factory, bom_item_factory, db_session):
+        """Verify project export includes BOM items."""
+        project = await project_factory(
+            name="Export Me",
+            description="A test project",
+            target_count=10,
+            target_parts_count=50,
+            budget=100.0,
+        )
+
+        # Add BOM items
+        await bom_item_factory(project.id, name="M3x8 Screws", quantity_needed=20, unit_price=0.10)
+        await bom_item_factory(project.id, name="Heat Inserts", quantity_needed=10, unit_price=0.25)
+
+        # Test JSON format export
+        response = await async_client.get(f"/api/v1/projects/{project.id}/export?format=json")
+        assert response.status_code == 200
+
+        data = response.json()
+        assert data["name"] == "Export Me"
+        assert data["description"] == "A test project"
+        assert data["target_count"] == 10
+        assert data["target_parts_count"] == 50
+        assert data["budget"] == 100.0
+        assert len(data["bom_items"]) == 2
+
+        # Check BOM items
+        bom_names = [item["name"] for item in data["bom_items"]]
+        assert "M3x8 Screws" in bom_names
+        assert "Heat Inserts" in bom_names
+
+        # Test ZIP format export (default)
+        zip_response = await async_client.get(f"/api/v1/projects/{project.id}/export")
+        assert zip_response.status_code == 200
+        assert zip_response.headers["content-type"] == "application/zip"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_import_project(self, async_client: AsyncClient):
+        """Verify project can be imported with BOM items."""
+        import_data = {
+            "name": "Imported Project",
+            "description": "Imported from JSON",
+            "color": "#FF00FF",
+            "target_count": 5,
+            "target_parts_count": 25,
+            "budget": 50.0,
+            "bom_items": [
+                {
+                    "name": "PTFE Tubes",
+                    "quantity_needed": 4,
+                    "quantity_acquired": 0,
+                    "unit_price": 2.50,
+                    "sourcing_url": "https://example.com",
+                    "stl_filename": None,
+                    "remarks": "Need 4mm ID",
+                },
+            ],
+        }
+
+        response = await async_client.post("/api/v1/projects/import", json=import_data)
+        assert response.status_code == 200
+
+        data = response.json()
+        assert data["name"] == "Imported Project"
+        assert data["description"] == "Imported from JSON"
+        assert data["target_count"] == 5
+        assert data["target_parts_count"] == 25
+        assert data["budget"] == 50.0
+        assert data["id"] > 0  # Has a valid ID
+        # BOM stats should show 1 item imported
+        assert data["stats"]["bom_total_items"] == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_export_project_with_linked_folder(self, async_client: AsyncClient, project_factory, db_session):
+        """Verify project export includes linked folders."""
+        from backend.app.models.library import LibraryFolder
+
+        project = await project_factory(name="Project With Folder")
+
+        # Create a linked folder
+        folder = LibraryFolder(name="Project Files", project_id=project.id)
+        db_session.add(folder)
+        await db_session.commit()
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}/export?format=json")
+        assert response.status_code == 200
+
+        data = response.json()
+        assert data["name"] == "Project With Folder"
+        assert len(data["linked_folders"]) == 1
+        assert data["linked_folders"][0]["name"] == "Project Files"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_import_project_with_linked_folder(self, async_client: AsyncClient):
+        """Verify project import accepts linked folders data."""
+        import_data = {
+            "name": "Imported With Folders",
+            "linked_folders": [
+                {"name": "STL Files"},
+                {"name": "Documentation"},
+            ],
+        }
+
+        # Import should succeed with linked_folders
+        response = await async_client.post("/api/v1/projects/import", json=import_data)
+        assert response.status_code == 200
+        data = response.json()
+        assert data["name"] == "Imported With Folders"
+        assert data["id"] > 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_import_project_from_json_file(self, async_client: AsyncClient):
+        """Verify project can be imported from JSON file upload."""
+        import io
+        import json
+
+        project_data = {
+            "name": "File Uploaded Project",
+            "description": "Imported from JSON file",
+            "color": "#123456",
+        }
+
+        # Create a file-like object
+        file_content = json.dumps(project_data).encode()
+        files = {"file": ("project.json", io.BytesIO(file_content), "application/json")}
+
+        response = await async_client.post("/api/v1/projects/import/file", files=files)
+        assert response.status_code == 200
+        data = response.json()
+        assert data["name"] == "File Uploaded Project"
+        assert data["description"] == "Imported from JSON file"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_import_project_from_zip_file(self, async_client: AsyncClient):
+        """Verify project can be imported from ZIP file with files."""
+        import io
+        import json
+        import zipfile
+
+        project_data = {
+            "name": "ZIP Imported Project",
+            "description": "Imported from ZIP",
+            "linked_folders": [{"name": "TestFolder", "files": [{"filename": "test.txt"}]}],
+        }
+
+        # Create a ZIP file in memory
+        zip_buffer = io.BytesIO()
+        with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("project.json", json.dumps(project_data))
+            zf.writestr("files/TestFolder/test.txt", "Hello World")
+
+        zip_buffer.seek(0)
+        files = {"file": ("project.zip", zip_buffer, "application/zip")}
+
+        response = await async_client.post("/api/v1/projects/import/file", files=files)
+        assert response.status_code == 200
+        data = response.json()
+        assert data["name"] == "ZIP Imported Project"
+        assert data["description"] == "Imported from ZIP"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_export_zip_contains_files(self, async_client: AsyncClient, project_factory, db_session):
+        """Verify ZIP export contains actual files from linked folders."""
+        import io
+        import json
+        import zipfile
+        from pathlib import Path
+
+        from backend.app.api.routes.library import get_library_dir
+        from backend.app.models.library import LibraryFile, LibraryFolder
+
+        project = await project_factory(name="Project With Files")
+
+        # Create a linked folder with is_external fields
+        folder = LibraryFolder(
+            name="TestExportFolder",
+            project_id=project.id,
+            is_external=False,
+            external_readonly=False,
+            external_show_hidden=False,
+        )
+        db_session.add(folder)
+        await db_session.flush()
+
+        # Create a test file on disk
+        library_dir = get_library_dir()
+        folder_path = library_dir / "TestExportFolder"
+        folder_path.mkdir(parents=True, exist_ok=True)
+        test_file_path = folder_path / "test_export.txt"
+        test_file_path.write_text("Export test content")
+
+        # Create library file record
+        lib_file = LibraryFile(
+            folder_id=folder.id,
+            filename="test_export.txt",
+            file_path="TestExportFolder/test_export.txt",
+            file_type="other",
+            file_size=19,
+            is_external=False,
+        )
+        db_session.add(lib_file)
+        await db_session.commit()
+
+        # Export as ZIP
+        response = await async_client.get(f"/api/v1/projects/{project.id}/export")
+        assert response.status_code == 200
+        assert response.headers["content-type"] == "application/zip"
+
+        # Verify ZIP contents
+        zip_buffer = io.BytesIO(response.content)
+        with zipfile.ZipFile(zip_buffer, "r") as zf:
+            assert "project.json" in zf.namelist()
+            assert "files/TestExportFolder/test_export.txt" in zf.namelist()
+
+            # Verify file content
+            file_content = zf.read("files/TestExportFolder/test_export.txt").decode()
+            assert file_content == "Export test content"
+
+            # Verify project.json
+            project_data = json.loads(zf.read("project.json"))
+            assert project_data["name"] == "Project With Files"
+
+        # Cleanup
+        test_file_path.unlink(missing_ok=True)
+        folder_path.rmdir()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_import_invalid_file_type(self, async_client: AsyncClient):
+        """Verify import rejects invalid file types."""
+        import io
+
+        files = {"file": ("project.txt", io.BytesIO(b"invalid"), "text/plain")}
+        response = await async_client.post("/api/v1/projects/import/file", files=files)
+        assert response.status_code == 400
+        assert "must be .zip or .json" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_import_zip_missing_project_json(self, async_client: AsyncClient):
+        """Verify import rejects ZIP without project.json."""
+        import io
+        import zipfile
+
+        zip_buffer = io.BytesIO()
+        with zipfile.ZipFile(zip_buffer, "w") as zf:
+            zf.writestr("other.txt", "no project.json here")
+
+        zip_buffer.seek(0)
+        files = {"file": ("project.zip", zip_buffer, "application/zip")}
+        response = await async_client.post("/api/v1/projects/import/file", files=files)
+        assert response.status_code == 400
+        assert "project.json" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_import_invalid_json(self, async_client: AsyncClient):
+        """Verify import rejects invalid JSON content."""
+        import io
+
+        files = {"file": ("project.json", io.BytesIO(b"not valid json"), "application/json")}
+        response = await async_client.post("/api/v1/projects/import/file", files=files)
+        assert response.status_code == 400
+        assert "Invalid JSON" in response.json()["detail"]

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

@@ -530,6 +530,53 @@ export interface BOMItemUpdate {
   remarks?: string;
   remarks?: string;
 }
 }
 
 
+// Project Export/Import Types
+export interface BOMItemExport {
+  name: string;
+  quantity_needed: number;
+  quantity_acquired: number;
+  unit_price: number | null;
+  sourcing_url: string | null;
+  stl_filename: string | null;
+  remarks: string | null;
+}
+
+export interface LinkedFolderExport {
+  name: string;
+}
+
+export interface ProjectExport {
+  name: string;
+  description: string | null;
+  color: string | null;
+  status: string;
+  target_count: number | null;
+  target_parts_count: number | null;
+  notes: string | null;
+  tags: string | null;
+  due_date: string | null;
+  priority: string;
+  budget: number | null;
+  bom_items: BOMItemExport[];
+  linked_folders: LinkedFolderExport[];
+}
+
+export interface ProjectImport {
+  name: string;
+  description?: string;
+  color?: string;
+  status?: string;
+  target_count?: number;
+  target_parts_count?: number;
+  notes?: string;
+  tags?: string;
+  due_date?: string;
+  priority?: string;
+  budget?: number;
+  bom_items?: BOMItemExport[];
+  linked_folders?: LinkedFolderExport[];
+}
+
 // Timeline Types
 // Timeline Types
 export interface TimelineEvent {
 export interface TimelineEvent {
   event_type: string;
   event_type: string;
@@ -2683,6 +2730,15 @@ export const api = {
   getProjectTimeline: (projectId: number, limit = 50) =>
   getProjectTimeline: (projectId: number, limit = 50) =>
     request<TimelineEvent[]>(`/projects/${projectId}/timeline?limit=${limit}`),
     request<TimelineEvent[]>(`/projects/${projectId}/timeline?limit=${limit}`),
 
 
+  // Project Export/Import
+  exportProjectJson: (projectId: number) =>
+    request<ProjectExport>(`/projects/${projectId}/export?format=json`),
+  importProject: (data: ProjectImport) =>
+    request<Project>('/projects/import', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+
   // API Keys
   // API Keys
   getAPIKeys: () => request<APIKey[]>('/api-keys/'),
   getAPIKeys: () => request<APIKey[]>('/api-keys/'),
   createAPIKey: (data: APIKeyCreate) =>
   createAPIKey: (data: APIKeyCreate) =>

+ 202 - 66
frontend/src/pages/ProjectDetailPage.tsx

@@ -27,10 +27,12 @@ import {
   ExternalLink,
   ExternalLink,
   ShoppingCart,
   ShoppingCart,
   FolderOpen,
   FolderOpen,
+  Download,
+  Pencil,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { parseUTCDate, formatDateOnly, formatDateTime, type TimeFormat } from '../utils/date';
 import { parseUTCDate, formatDateOnly, formatDateTime, type TimeFormat } from '../utils/date';
-import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate } from '../api/client';
+import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate, BOMItemUpdate } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
@@ -276,6 +278,12 @@ export function ProjectDetailPage() {
   const [newBomRemarks, setNewBomRemarks] = useState('');
   const [newBomRemarks, setNewBomRemarks] = useState('');
   const [showBomForm, setShowBomForm] = useState(false);
   const [showBomForm, setShowBomForm] = useState(false);
   const [hideBomCompleted, setHideBomCompleted] = useState(false);
   const [hideBomCompleted, setHideBomCompleted] = useState(false);
+  const [editingBomItem, setEditingBomItem] = useState<BOMItem | null>(null);
+  const [editBomName, setEditBomName] = useState('');
+  const [editBomQty, setEditBomQty] = useState(1);
+  const [editBomPrice, setEditBomPrice] = useState('');
+  const [editBomUrl, setEditBomUrl] = useState('');
+  const [editBomRemarks, setEditBomRemarks] = useState('');
 
 
   // Confirm modal state
   // Confirm modal state
   const [confirmModal, setConfirmModal] = useState<{
   const [confirmModal, setConfirmModal] = useState<{
@@ -302,11 +310,12 @@ export function ProjectDetailPage() {
   });
   });
 
 
   const updateBomMutation = useMutation({
   const updateBomMutation = useMutation({
-    mutationFn: ({ itemId, data }: { itemId: number; data: { quantity_acquired?: number } }) =>
+    mutationFn: ({ itemId, data }: { itemId: number; data: BOMItemUpdate }) =>
       api.updateBOMItem(projectId, itemId, data),
       api.updateBOMItem(projectId, itemId, data),
     onSuccess: () => {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] });
       queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] });
       queryClient.invalidateQueries({ queryKey: ['project', projectId] });
       queryClient.invalidateQueries({ queryKey: ['project', projectId] });
+      setEditingBomItem(null);
     },
     },
     onError: (error: Error) => showToast(error.message, 'error'),
     onError: (error: Error) => showToast(error.message, 'error'),
   });
   });
@@ -353,6 +362,57 @@ export function ProjectDetailPage() {
     });
     });
   };
   };
 
 
+  const handleEditBomItem = (item: BOMItem) => {
+    setEditingBomItem(item);
+    setEditBomName(item.name);
+    setEditBomQty(item.quantity_needed);
+    setEditBomPrice(item.unit_price?.toString() || '');
+    setEditBomUrl(item.sourcing_url || '');
+    setEditBomRemarks(item.remarks || '');
+  };
+
+  const handleSaveBomEdit = (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!editingBomItem || !editBomName.trim()) return;
+    updateBomMutation.mutate({
+      itemId: editingBomItem.id,
+      data: {
+        name: editBomName.trim(),
+        quantity_needed: editBomQty,
+        unit_price: editBomPrice ? parseFloat(editBomPrice) : undefined,
+        sourcing_url: editBomUrl.trim() || undefined,
+        remarks: editBomRemarks.trim() || undefined,
+      },
+    });
+  };
+
+  const handleCancelBomEdit = () => {
+    setEditingBomItem(null);
+  };
+
+  const handleExportProject = async () => {
+    try {
+      // Fetch ZIP file directly
+      const response = await fetch(`/api/v1/projects/${projectId}/export`);
+      if (!response.ok) {
+        throw new Error('Export failed');
+      }
+      const blob = await response.blob();
+      const url = URL.createObjectURL(blob);
+      const a = document.createElement('a');
+      a.href = url;
+      // Get filename from Content-Disposition header or use default
+      const contentDisposition = response.headers.get('Content-Disposition');
+      const filenameMatch = contentDisposition?.match(/filename="(.+)"/);
+      a.download = filenameMatch?.[1] || `${project?.name || 'project'}_${new Date().toISOString().split('T')[0]}.zip`;
+      a.click();
+      URL.revokeObjectURL(url);
+      showToast('Project exported', 'success');
+    } catch (error) {
+      showToast((error as Error).message, 'error');
+    }
+  };
+
   // Template handlers
   // Template handlers
   const createTemplateMutation = useMutation({
   const createTemplateMutation = useMutation({
     mutationFn: () => api.createTemplateFromProject(projectId),
     mutationFn: () => api.createTemplateFromProject(projectId),
@@ -433,10 +493,16 @@ export function ProjectDetailPage() {
           </div>
           </div>
           <StatusBadge status={project.status} />
           <StatusBadge status={project.status} />
         </div>
         </div>
-        <Button onClick={() => setShowEditModal(true)}>
-          <Edit3 className="w-4 h-4 mr-2" />
-          Edit
-        </Button>
+        <div className="flex gap-2">
+          <Button variant="secondary" onClick={handleExportProject} title="Export project">
+            <Download className="w-4 h-4 mr-2" />
+            Export
+          </Button>
+          <Button onClick={() => setShowEditModal(true)}>
+            <Edit3 className="w-4 h-4 mr-2" />
+            Edit
+          </Button>
+        </div>
       </div>
       </div>
 
 
       {/* Progress bars (if targets set) */}
       {/* Progress bars (if targets set) */}
@@ -903,70 +969,140 @@ export function ProjectDetailPage() {
                     item.is_complete ? 'bg-status-ok/10' : 'bg-bambu-dark'
                     item.is_complete ? 'bg-status-ok/10' : 'bg-bambu-dark'
                   }`}
                   }`}
                 >
                 >
-                  <div className="flex items-start gap-3">
-                    <button
-                      onClick={() => handleToggleAcquired(item)}
-                      disabled={updateBomMutation.isPending}
-                      className={`w-5 h-5 mt-0.5 rounded border-2 flex items-center justify-center transition-colors flex-shrink-0 ${
-                        item.is_complete
-                          ? 'bg-status-ok border-status-ok text-white'
-                          : 'border-bambu-gray hover:border-bambu-green'
-                      }`}
-                    >
-                      {item.is_complete && <CheckCircle className="w-3 h-3" />}
-                    </button>
-                    <div className="flex-1 min-w-0">
-                      <div className="flex items-center justify-between gap-2">
-                        <div className="flex items-center gap-2 min-w-0">
-                          <p className={`text-sm font-medium ${item.is_complete ? 'text-bambu-gray line-through' : 'text-white'}`}>
-                            {item.name}
-                            <span className="text-bambu-gray font-normal ml-2">
-                              x{item.quantity_needed}
-                            </span>
-                          </p>
-                          {item.unit_price !== null && (
-                            <span className="text-xs text-bambu-green whitespace-nowrap">
-                              {currency}{(item.unit_price * item.quantity_needed).toFixed(2)}
-                            </span>
+                  {editingBomItem?.id === item.id ? (
+                    // Edit form for this BOM item
+                    <form onSubmit={handleSaveBomEdit} className="space-y-3">
+                      <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
+                        <input
+                          type="text"
+                          value={editBomName}
+                          onChange={(e) => setEditBomName(e.target.value)}
+                          className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+                          placeholder="Part name"
+                          autoFocus
+                        />
+                        <div className="flex gap-2">
+                          <input
+                            type="number"
+                            value={editBomQty}
+                            onChange={(e) => setEditBomQty(parseInt(e.target.value) || 1)}
+                            className="w-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-bambu-green"
+                            min="1"
+                            placeholder="Qty"
+                          />
+                          <input
+                            type="number"
+                            step="0.01"
+                            value={editBomPrice}
+                            onChange={(e) => setEditBomPrice(e.target.value)}
+                            className="flex-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+                            placeholder={`Price (${currency})`}
+                          />
+                        </div>
+                      </div>
+                      <input
+                        type="url"
+                        value={editBomUrl}
+                        onChange={(e) => setEditBomUrl(e.target.value)}
+                        className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+                        placeholder="Sourcing URL (optional)"
+                      />
+                      <input
+                        type="text"
+                        value={editBomRemarks}
+                        onChange={(e) => setEditBomRemarks(e.target.value)}
+                        className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+                        placeholder="Remarks (optional)"
+                      />
+                      <div className="flex justify-end gap-2">
+                        <Button type="button" variant="secondary" size="sm" onClick={handleCancelBomEdit}>
+                          Cancel
+                        </Button>
+                        <Button type="submit" size="sm" disabled={!editBomName.trim() || updateBomMutation.isPending}>
+                          {updateBomMutation.isPending ? (
+                            <Loader2 className="w-4 h-4 animate-spin" />
+                          ) : (
+                            'Save'
                           )}
                           )}
+                        </Button>
+                      </div>
+                    </form>
+                  ) : (
+                    // Display mode
+                    <div className="flex items-start gap-3">
+                      <button
+                        onClick={() => handleToggleAcquired(item)}
+                        disabled={updateBomMutation.isPending}
+                        className={`w-5 h-5 mt-0.5 rounded border-2 flex items-center justify-center transition-colors flex-shrink-0 ${
+                          item.is_complete
+                            ? 'bg-status-ok border-status-ok text-white'
+                            : 'border-bambu-gray hover:border-bambu-green'
+                        }`}
+                      >
+                        {item.is_complete && <CheckCircle className="w-3 h-3" />}
+                      </button>
+                      <div className="flex-1 min-w-0">
+                        <div className="flex items-center justify-between gap-2">
+                          <div className="flex items-center gap-2 min-w-0">
+                            <p className={`text-sm font-medium ${item.is_complete ? 'text-bambu-gray line-through' : 'text-white'}`}>
+                              {item.name}
+                              <span className="text-bambu-gray font-normal ml-2">
+                                x{item.quantity_needed}
+                              </span>
+                            </p>
+                            {item.unit_price !== null && (
+                              <span className="text-xs text-bambu-green whitespace-nowrap">
+                                {currency}{(item.unit_price * item.quantity_needed).toFixed(2)}
+                              </span>
+                            )}
+                          </div>
+                          <div className="flex items-center gap-1">
+                            <button
+                              onClick={() => handleEditBomItem(item)}
+                              className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors flex-shrink-0"
+                              title="Edit"
+                            >
+                              <Pencil className="w-4 h-4" />
+                            </button>
+                            <button
+                              onClick={() => handleDeleteBomItem(item.id, item.name)}
+                              className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400 transition-colors flex-shrink-0"
+                              title="Delete"
+                            >
+                              <Trash2 className="w-4 h-4" />
+                            </button>
+                          </div>
                         </div>
                         </div>
-                        <button
-                          onClick={() => handleDeleteBomItem(item.id, item.name)}
-                          className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400 transition-colors flex-shrink-0"
-                          title="Delete"
-                        >
-                          <Trash2 className="w-4 h-4" />
-                        </button>
+                        {/* Sourcing URL */}
+                        {item.sourcing_url && (
+                          <a
+                            href={item.sourcing_url}
+                            target="_blank"
+                            rel="noopener noreferrer"
+                            className="flex items-center gap-1 mt-1 text-xs text-blue-400 hover:text-blue-300 transition-colors"
+                            onClick={(e) => e.stopPropagation()}
+                          >
+                            <ExternalLink className="w-3 h-3 flex-shrink-0" />
+                            <span className="truncate">
+                              {(() => {
+                                try {
+                                  return new URL(item.sourcing_url).hostname.replace('www.', '');
+                                } catch {
+                                  return item.sourcing_url;
+                                }
+                              })()}
+                            </span>
+                          </a>
+                        )}
+                        {/* Remarks */}
+                        {item.remarks && (
+                          <p className="mt-1 text-xs text-bambu-gray/80 italic">
+                            {item.remarks}
+                          </p>
+                        )}
                       </div>
                       </div>
-                      {/* Sourcing URL */}
-                      {item.sourcing_url && (
-                        <a
-                          href={item.sourcing_url}
-                          target="_blank"
-                          rel="noopener noreferrer"
-                          className="flex items-center gap-1 mt-1 text-xs text-blue-400 hover:text-blue-300 transition-colors"
-                          onClick={(e) => e.stopPropagation()}
-                        >
-                          <ExternalLink className="w-3 h-3 flex-shrink-0" />
-                          <span className="truncate">
-                            {(() => {
-                              try {
-                                return new URL(item.sourcing_url).hostname.replace('www.', '');
-                              } catch {
-                                return item.sourcing_url;
-                              }
-                            })()}
-                          </span>
-                        </a>
-                      )}
-                      {/* Remarks */}
-                      {item.remarks && (
-                        <p className="mt-1 text-xs text-bambu-gray/80 italic">
-                          {item.remarks}
-                        </p>
-                      )}
                     </div>
                     </div>
-                  </div>
+                  )}
                 </div>
                 </div>
               ))}
               ))}
               {/* BOM Total */}
               {/* BOM Total */}

+ 115 - 6
frontend/src/pages/ProjectsPage.tsx

@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useState, useRef } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { useNavigate } from 'react-router-dom';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import {
 import {
@@ -16,9 +16,11 @@ import {
   AlertTriangle,
   AlertTriangle,
   ChevronRight,
   ChevronRight,
   MoreVertical,
   MoreVertical,
+  Download,
+  Upload,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
-import type { ProjectListItem, ProjectCreate, ProjectUpdate } from '../api/client';
+import type { ProjectListItem, ProjectCreate, ProjectUpdate, ProjectImport } from '../api/client';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
@@ -613,6 +615,94 @@ export function ProjectsPage() {
     },
     },
   });
   });
 
 
+  const importMutation = useMutation({
+    mutationFn: (data: ProjectImport) => api.importProject(data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['projects'] });
+      showToast('Project imported', 'success');
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  const fileInputRef = useRef<HTMLInputElement>(null);
+
+  const handleExportAll = async () => {
+    try {
+      // Export all projects as JSON (metadata only, no files)
+      const allProjects = await api.getProjects();
+      const exports = await Promise.all(
+        allProjects.map(async (p) => {
+          const exported = await api.exportProjectJson(p.id);
+          return exported;
+        })
+      );
+      const blob = new Blob([JSON.stringify(exports, null, 2)], { type: 'application/json' });
+      const url = URL.createObjectURL(blob);
+      const a = document.createElement('a');
+      a.href = url;
+      a.download = `bambuddy_projects_${new Date().toISOString().split('T')[0]}.json`;
+      a.click();
+      URL.revokeObjectURL(url);
+      showToast('Projects exported (metadata only)', 'success');
+    } catch (error) {
+      showToast((error as Error).message, 'error');
+    }
+  };
+
+  const handleImportClick = () => {
+    fileInputRef.current?.click();
+  };
+
+  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0];
+    if (!file) return;
+
+    try {
+      const filename = file.name.toLowerCase();
+
+      if (filename.endsWith('.zip')) {
+        // ZIP file: upload via file endpoint
+        const formData = new FormData();
+        formData.append('file', file);
+
+        const response = await fetch('/api/v1/projects/import/file', {
+          method: 'POST',
+          body: formData,
+        });
+
+        if (!response.ok) {
+          const errorData = await response.json();
+          throw new Error(errorData.detail || 'Import failed');
+        }
+
+        queryClient.invalidateQueries({ queryKey: ['projects'] });
+        showToast('Project imported', 'success');
+      } else {
+        // JSON file: parse and handle bulk or single import
+        const text = await file.text();
+        const data = JSON.parse(text);
+
+        // Handle both single project and array of projects
+        const projectsToImport = Array.isArray(data) ? data : [data];
+
+        for (const project of projectsToImport) {
+          await importMutation.mutateAsync(project);
+        }
+
+        if (projectsToImport.length > 1) {
+          showToast(`${projectsToImport.length} projects imported`, 'success');
+        }
+      }
+    } catch (error) {
+      showToast(`Import failed: ${(error as Error).message}`, 'error');
+    }
+
+    // Reset file input
+    e.target.value = '';
+  };
+
   const handleSave = (data: ProjectCreate | ProjectUpdate) => {
   const handleSave = (data: ProjectCreate | ProjectUpdate) => {
     if (editingProject) {
     if (editingProject) {
       updateMutation.mutate({ id: editingProject.id, data });
       updateMutation.mutate({ id: editingProject.id, data });
@@ -650,6 +740,15 @@ export function ProjectsPage() {
 
 
   return (
   return (
     <div className="p-4 md:p-8 space-y-8">
     <div className="p-4 md:p-8 space-y-8">
+      {/* Hidden file input for import */}
+      <input
+        ref={fileInputRef}
+        type="file"
+        accept=".json,.zip"
+        onChange={handleFileChange}
+        className="hidden"
+      />
+
       {/* Header */}
       {/* Header */}
       <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
       <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
         <div>
         <div>
@@ -663,10 +762,20 @@ export function ProjectsPage() {
             Organize and track your 3D printing projects
             Organize and track your 3D printing projects
           </p>
           </p>
         </div>
         </div>
-        <Button onClick={() => setShowModal(true)} className="sm:w-auto w-full">
-          <Plus className="w-4 h-4 mr-2" />
-          New Project
-        </Button>
+        <div className="flex gap-2">
+          <Button variant="secondary" onClick={handleImportClick} title="Import project">
+            <Upload className="w-4 h-4 mr-2" />
+            Import
+          </Button>
+          <Button variant="secondary" onClick={handleExportAll} title="Export all projects">
+            <Download className="w-4 h-4 mr-2" />
+            Export
+          </Button>
+          <Button onClick={() => setShowModal(true)} className="sm:w-auto w-full">
+            <Plus className="w-4 h-4 mr-2" />
+            New Project
+          </Button>
+        </div>
       </div>
       </div>
 
 
       {/* Filter tabs */}
       {/* Filter tabs */}

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


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BQzscOA-.js"></script>
+    <script type="module" crossorigin src="/assets/index-CTmc90ua.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BKSrBx0A.css">
     <link rel="stylesheet" crossorigin href="/assets/index-BKSrBx0A.css">
   </head>
   </head>
   <body>
   <body>

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