diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..2038d89aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Claude +.claude/ +CLAUDE.md + +# macOS +.DS_Store +**/.DS_Store + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +venv/ +.venv/ +env/ +.env +*.egg-info/ +dist/ +build/ + +# Node +frontend/node_modules/ +npm-debug.log* + +# Database +*.db +*.db-journal + +# Archive files (user data) +archive/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Screenshots (development - root folder only) +/screenshots/ + +# Logs +*.log diff --git a/README.md b/README.md new file mode 100644 index 000000000..039ea9dab --- /dev/null +++ b/README.md @@ -0,0 +1,429 @@ +# Bambusy + +
+
+
+ A self-hosted print archive and management system for Bambu Lab 3D printers +
+ ++ Features • + Screenshots • + Installation • + Configuration • + Usage • + Contributing +
+ +--- + +## Features + +- **Multi-Printer Support** - Connect and monitor multiple Bambu Lab printers (X1, X1C, P1P, P1S, A1, A1 Mini) +- **Automatic Print Archiving** - Automatically saves 3MF files when prints complete +- **3D Model Preview** - Interactive Three.js viewer for archived prints +- **Real-time Monitoring** - Live printer status via WebSocket with print progress, temperatures, and more +- **Print Statistics Dashboard** - Customizable dashboard with drag-and-drop widgets + - Print success rates + - Filament usage trends + - Print activity calendar + - Cost tracking +- **Filament Cost Tracking** - Track costs per print with customizable filament database +- **Photo Attachments** - Attach photos to archived prints for documentation +- **Failure Analysis** - Document failed prints with notes and photos +- **Cloud Profiles Sync** - Access your Bambu Cloud slicer presets +- **File Manager** - Browse and manage files on your printer's SD card +- **Re-print** - Send archived prints back to any connected printer +- **Dark/Light Theme** - Easy on the eyes, day or night +- **Keyboard Shortcuts** - Quick navigation with keyboard shortcuts + +## Screenshots + + +
+
+
Customizable Dashboard with drag-and-drop widgets
+
+
+
Real-time printer monitoring
+
+
+
Print archive with grid and list views
+
+
+
Interactive 3D model preview
+
+ Made with ❤️ for the 3D printing community +
diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py new file mode 100644 index 000000000..a264767bb --- /dev/null +++ b/backend/app/api/routes/archives.py @@ -0,0 +1,890 @@ +from pathlib import Path +import zipfile +import io + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request +from fastapi.responses import FileResponse, Response +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func + +from backend.app.core.config import settings +from backend.app.core.database import get_db +from backend.app.models.archive import PrintArchive +from backend.app.schemas.archive import ArchiveResponse, ArchiveUpdate, ArchiveStats +from backend.app.services.archive import ArchiveService + + +router = APIRouter(prefix="/archives", tags=["archives"]) + + +@router.get("/", response_model=list[ArchiveResponse]) +async def list_archives( + printer_id: int | None = None, + limit: int = 50, + offset: int = 0, + db: AsyncSession = Depends(get_db), +): + """List archived prints.""" + service = ArchiveService(db) + return await service.list_archives( + printer_id=printer_id, + limit=limit, + offset=offset, + ) + + +@router.get("/stats", response_model=ArchiveStats) +async def get_archive_stats(db: AsyncSession = Depends(get_db)): + """Get statistics across all archives.""" + # Total counts + total_result = await db.execute(select(func.count(PrintArchive.id))) + total_prints = total_result.scalar() or 0 + + successful_result = await db.execute( + select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed") + ) + successful_prints = successful_result.scalar() or 0 + + failed_result = await db.execute( + select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed") + ) + failed_prints = failed_result.scalar() or 0 + + # Totals + time_result = await db.execute( + select(func.sum(PrintArchive.print_time_seconds)) + ) + total_time = (time_result.scalar() or 0) / 3600 # Convert to hours + + filament_result = await db.execute( + select(func.sum(PrintArchive.filament_used_grams)) + ) + total_filament = filament_result.scalar() or 0 + + cost_result = await db.execute( + select(func.sum(PrintArchive.cost)) + ) + total_cost = cost_result.scalar() or 0 + + # By filament type (split comma-separated values for multi-material prints) + filament_type_result = await db.execute( + select(PrintArchive.filament_type) + .where(PrintArchive.filament_type.isnot(None)) + ) + prints_by_filament: dict[str, int] = {} + for (filament_types,) in filament_type_result.all(): + # Split by comma and count each type + for ftype in filament_types.split(","): + ftype = ftype.strip() + if ftype: + prints_by_filament[ftype] = prints_by_filament.get(ftype, 0) + 1 + + # By printer + printer_result = await db.execute( + select(PrintArchive.printer_id, func.count(PrintArchive.id)) + .group_by(PrintArchive.printer_id) + ) + prints_by_printer = {str(k): v for k, v in printer_result.all()} + + return ArchiveStats( + total_prints=total_prints, + successful_prints=successful_prints, + failed_prints=failed_prints, + total_print_time_hours=round(total_time, 1), + total_filament_grams=round(total_filament, 1), + total_cost=round(total_cost, 2), + prints_by_filament_type=prints_by_filament, + prints_by_printer=prints_by_printer, + ) + + +@router.get("/{archive_id}", response_model=ArchiveResponse) +async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)): + """Get a specific archive.""" + service = ArchiveService(db) + archive = await service.get_archive(archive_id) + if not archive: + raise HTTPException(404, "Archive not found") + return archive + + +@router.patch("/{archive_id}", response_model=ArchiveResponse) +async def update_archive( + archive_id: int, + update_data: ArchiveUpdate, + db: AsyncSession = Depends(get_db), +): + """Update archive metadata (tags, notes, cost, is_favorite).""" + result = await db.execute( + select(PrintArchive).where(PrintArchive.id == archive_id) + ) + archive = result.scalar_one_or_none() + if not archive: + raise HTTPException(404, "Archive not found") + + for field, value in update_data.model_dump(exclude_unset=True).items(): + setattr(archive, field, value) + + await db.commit() + await db.refresh(archive) + return archive + + +@router.post("/{archive_id}/favorite", response_model=ArchiveResponse) +async def toggle_favorite( + archive_id: int, + db: AsyncSession = Depends(get_db), +): + """Toggle favorite status for an archive.""" + result = await db.execute( + select(PrintArchive).where(PrintArchive.id == archive_id) + ) + archive = result.scalar_one_or_none() + if not archive: + raise HTTPException(404, "Archive not found") + + archive.is_favorite = not archive.is_favorite + await db.commit() + await db.refresh(archive) + return archive + + +@router.post("/{archive_id}/rescan", response_model=ArchiveResponse) +async def rescan_archive(archive_id: int, db: AsyncSession = Depends(get_db)): + """Rescan the 3MF file and update metadata.""" + from backend.app.services.archive import ThreeMFParser + + result = await db.execute( + select(PrintArchive).where(PrintArchive.id == archive_id) + ) + archive = result.scalar_one_or_none() + if not archive: + raise HTTPException(404, "Archive not found") + + file_path = settings.base_dir / archive.file_path + if not file_path.exists(): + raise HTTPException(404, "Archive file not found") + + # Parse the 3MF file + parser = ThreeMFParser(file_path) + metadata = parser.parse() + + # Update fields from metadata + if metadata.get("filament_type"): + archive.filament_type = metadata["filament_type"] + if metadata.get("filament_color"): + archive.filament_color = metadata["filament_color"] + if metadata.get("print_time_seconds"): + archive.print_time_seconds = metadata["print_time_seconds"] + if metadata.get("filament_used_grams"): + archive.filament_used_grams = metadata["filament_used_grams"] + if metadata.get("layer_height"): + archive.layer_height = metadata["layer_height"] + if metadata.get("nozzle_diameter"): + archive.nozzle_diameter = metadata["nozzle_diameter"] + if metadata.get("bed_temperature"): + archive.bed_temperature = metadata["bed_temperature"] + if metadata.get("nozzle_temperature"): + archive.nozzle_temperature = metadata["nozzle_temperature"] + if metadata.get("makerworld_url"): + archive.makerworld_url = metadata["makerworld_url"] + if metadata.get("designer"): + archive.designer = metadata["designer"] + + await db.commit() + await db.refresh(archive) + return archive + + +@router.post("/rescan-all") +async def rescan_all_archives(db: AsyncSession = Depends(get_db)): + """Rescan all archives and update their metadata.""" + from backend.app.services.archive import ThreeMFParser + + result = await db.execute(select(PrintArchive)) + archives = list(result.scalars().all()) + + updated = 0 + errors = [] + + for archive in archives: + try: + file_path = settings.base_dir / archive.file_path + if not file_path.exists(): + errors.append({"id": archive.id, "error": "File not found"}) + continue + + parser = ThreeMFParser(file_path) + metadata = parser.parse() + + if metadata.get("filament_type"): + archive.filament_type = metadata["filament_type"] + if metadata.get("filament_color"): + archive.filament_color = metadata["filament_color"] + if metadata.get("print_time_seconds"): + archive.print_time_seconds = metadata["print_time_seconds"] + if metadata.get("filament_used_grams"): + archive.filament_used_grams = metadata["filament_used_grams"] + if metadata.get("layer_height"): + archive.layer_height = metadata["layer_height"] + if metadata.get("nozzle_diameter"): + archive.nozzle_diameter = metadata["nozzle_diameter"] + if metadata.get("makerworld_url"): + archive.makerworld_url = metadata["makerworld_url"] + if metadata.get("designer"): + archive.designer = metadata["designer"] + + updated += 1 + except Exception as e: + errors.append({"id": archive.id, "error": str(e)}) + + await db.commit() + return {"updated": updated, "errors": errors} + + +@router.delete("/{archive_id}") +async def delete_archive(archive_id: int, db: AsyncSession = Depends(get_db)): + """Delete an archive.""" + service = ArchiveService(db) + if not await service.delete_archive(archive_id): + raise HTTPException(404, "Archive not found") + return {"status": "deleted"} + + +@router.get("/{archive_id}/download") +async def download_archive( + archive_id: int, + inline: bool = False, + db: AsyncSession = Depends(get_db), +): + """Download the 3MF file.""" + service = ArchiveService(db) + archive = await service.get_archive(archive_id) + if not archive: + raise HTTPException(404, "Archive not found") + + file_path = settings.base_dir / archive.file_path + if not file_path.exists(): + raise HTTPException(404, "File not found") + + # Use inline disposition to let browser/OS handle file association + content_disposition = "inline" if inline else "attachment" + + return FileResponse( + path=file_path, + filename=archive.filename, + media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml", + content_disposition_type=content_disposition, + ) + + +@router.get("/{archive_id}/file/{filename}") +async def download_archive_with_filename( + archive_id: int, + filename: str, + db: AsyncSession = Depends(get_db), +): + """Download the 3MF file with filename in URL (for Bambu Studio protocol).""" + service = ArchiveService(db) + archive = await service.get_archive(archive_id) + if not archive: + raise HTTPException(404, "Archive not found") + + file_path = settings.base_dir / archive.file_path + if not file_path.exists(): + raise HTTPException(404, "File not found") + + return FileResponse( + path=file_path, + filename=archive.filename, + media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml", + ) + + +@router.get("/{archive_id}/thumbnail") +async def get_thumbnail(archive_id: int, db: AsyncSession = Depends(get_db)): + """Get the thumbnail image.""" + service = ArchiveService(db) + archive = await service.get_archive(archive_id) + if not archive or not archive.thumbnail_path: + raise HTTPException(404, "Thumbnail not found") + + thumb_path = settings.base_dir / archive.thumbnail_path + if not thumb_path.exists(): + raise HTTPException(404, "Thumbnail file not found") + + return FileResponse(path=thumb_path, media_type="image/png") + + +@router.get("/{archive_id}/timelapse") +async def get_timelapse(archive_id: int, db: AsyncSession = Depends(get_db)): + """Get the timelapse video.""" + service = ArchiveService(db) + archive = await service.get_archive(archive_id) + if not archive or not archive.timelapse_path: + raise HTTPException(404, "Timelapse not found") + + timelapse_path = settings.base_dir / archive.timelapse_path + if not timelapse_path.exists(): + raise HTTPException(404, "Timelapse file not found") + + return FileResponse( + path=timelapse_path, + media_type="video/mp4", + filename=f"{archive.print_name or 'timelapse'}.mp4", + ) + + +@router.post("/{archive_id}/timelapse/scan") +async def scan_timelapse( + archive_id: int, + db: AsyncSession = Depends(get_db), +): + """Scan printer for timelapse matching this archive and attach it.""" + from backend.app.models.printer import Printer + from backend.app.services.bambu_ftp import list_files_async, download_file_bytes_async + + service = ArchiveService(db) + archive = await service.get_archive(archive_id) + if not archive: + raise HTTPException(404, "Archive not found") + + if archive.timelapse_path: + return {"status": "exists", "message": "Timelapse already attached"} + + if not archive.printer_id: + raise HTTPException(400, "Archive has no associated printer") + + # Get printer + result = await db.execute(select(Printer).where(Printer.id == archive.printer_id)) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(404, "Printer not found") + + # Get base name from archive filename (without .3mf extension) + base_name = Path(archive.filename).stem + + # Scan timelapse directory on printer + try: + files = await list_files_async(printer.ip_address, printer.access_code, "/timelapse/video") + except Exception: + raise HTTPException(500, "Failed to connect to printer") + + # Look for matching timelapse + matching_file = None + mp4_files = [f for f in files if not f.get("is_directory") and f.get("name", "").endswith(".mp4")] + + # Strategy 1: Match by print name in filename + for f in mp4_files: + fname = f.get("name", "") + if base_name.lower() in fname.lower(): + matching_file = f + break + + # Strategy 2: Match by timestamp proximity + if not matching_file and (archive.started_at or archive.completed_at or archive.created_at): + import re + from datetime import datetime, timedelta + + archive_time = archive.started_at or archive.completed_at or archive.created_at + best_match = None + best_diff = timedelta(hours=24) # Max 24 hour difference + + for f in mp4_files: + fname = f.get("name", "") + # Parse timestamp from filename like "video_2025-11-24_03-17-40.mp4" + match = re.search(r'(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})', fname) + if match: + try: + file_time = datetime.strptime(match.group(1), "%Y-%m-%d_%H-%M-%S") + # Timelapse is usually created at print end, so compare to completed_at or created_at + compare_time = archive.completed_at or archive.created_at + if compare_time: + diff = abs(file_time - compare_time) + if diff < best_diff: + best_diff = diff + best_match = f + except ValueError: + continue + + if best_match and best_diff < timedelta(hours=2): # Within 2 hours + matching_file = best_match + + if not matching_file: + return {"status": "not_found", "message": "No matching timelapse found on printer"} + + # Download the timelapse + remote_path = f"/timelapse/video/{matching_file['name']}" + timelapse_data = await download_file_bytes_async( + printer.ip_address, printer.access_code, remote_path + ) + + if not timelapse_data: + raise HTTPException(500, "Failed to download timelapse") + + # Attach timelapse to archive + success = await service.attach_timelapse( + archive_id, timelapse_data, matching_file["name"] + ) + + if not success: + raise HTTPException(500, "Failed to attach timelapse") + + return { + "status": "attached", + "message": f"Timelapse '{matching_file['name']}' attached successfully", + "filename": matching_file["name"], + } + + +@router.post("/{archive_id}/timelapse/upload") +async def upload_timelapse( + archive_id: int, + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), +): + """Manually upload a timelapse video to an archive.""" + service = ArchiveService(db) + archive = await service.get_archive(archive_id) + if not archive: + raise HTTPException(404, "Archive not found") + + if not file.filename or not file.filename.endswith((".mp4", ".avi", ".mkv")): + raise HTTPException(400, "File must be a video file (.mp4, .avi, .mkv)") + + content = await file.read() + success = await service.attach_timelapse(archive_id, content, file.filename) + + if not success: + raise HTTPException(500, "Failed to attach timelapse") + + return {"status": "attached", "filename": file.filename} + + +# ============================================ +# Photo Endpoints +# ============================================ + +@router.post("/{archive_id}/photos") +async def upload_photo( + archive_id: int, + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), +): + """Upload a photo of the printed result.""" + result = await db.execute( + select(PrintArchive).where(PrintArchive.id == archive_id) + ) + archive = result.scalar_one_or_none() + if not archive: + raise HTTPException(404, "Archive not found") + + if not file.filename or not file.filename.lower().endswith((".jpg", ".jpeg", ".png", ".webp")): + raise HTTPException(400, "File must be an image (.jpg, .jpeg, .png, .webp)") + + # Get archive directory + file_path = settings.base_dir / archive.file_path + archive_dir = file_path.parent + photos_dir = archive_dir / "photos" + photos_dir.mkdir(exist_ok=True) + + # Generate unique filename + import uuid + ext = Path(file.filename).suffix.lower() + photo_filename = f"{uuid.uuid4().hex[:8]}{ext}" + photo_path = photos_dir / photo_filename + + # Save file + content = await file.read() + photo_path.write_bytes(content) + + # Update archive photos list (create new list to trigger SQLAlchemy change detection) + photos = list(archive.photos or []) + photos.append(photo_filename) + archive.photos = photos + + await db.commit() + await db.refresh(archive) + + return {"status": "uploaded", "filename": photo_filename, "photos": archive.photos} + + +@router.get("/{archive_id}/photos/{filename}") +async def get_photo( + archive_id: int, + filename: str, + db: AsyncSession = Depends(get_db), +): + """Get a specific photo.""" + result = await db.execute( + select(PrintArchive).where(PrintArchive.id == archive_id) + ) + archive = result.scalar_one_or_none() + if not archive: + raise HTTPException(404, "Archive not found") + + file_path = settings.base_dir / archive.file_path + photo_path = file_path.parent / "photos" / filename + + if not photo_path.exists(): + raise HTTPException(404, "Photo not found") + + # Determine media type + ext = Path(filename).suffix.lower() + media_types = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", + } + media_type = media_types.get(ext, "image/jpeg") + + return FileResponse(path=photo_path, media_type=media_type) + + +@router.delete("/{archive_id}/photos/{filename}") +async def delete_photo( + archive_id: int, + filename: str, + db: AsyncSession = Depends(get_db), +): + """Delete a photo.""" + result = await db.execute( + select(PrintArchive).where(PrintArchive.id == archive_id) + ) + archive = result.scalar_one_or_none() + if not archive: + raise HTTPException(404, "Archive not found") + + if not archive.photos or filename not in archive.photos: + raise HTTPException(404, "Photo not found") + + # Delete file + file_path = settings.base_dir / archive.file_path + photo_path = file_path.parent / "photos" / filename + if photo_path.exists(): + photo_path.unlink() + + # Update archive photos list + photos = [p for p in archive.photos if p != filename] + archive.photos = photos if photos else None + + await db.commit() + + return {"status": "deleted", "photos": archive.photos} + + +# ============================================ +# QR Code Endpoint +# ============================================ + +@router.get("/{archive_id}/qrcode") +async def get_qrcode( + archive_id: int, + request: Request, + size: int = 200, + db: AsyncSession = Depends(get_db), +): + """Generate a QR code that links to this archive.""" + import qrcode + from qrcode.image.styledpil import StyledPilImage + + result = await db.execute( + select(PrintArchive).where(PrintArchive.id == archive_id) + ) + archive = result.scalar_one_or_none() + if not archive: + raise HTTPException(404, "Archive not found") + + # Build URL to archive detail page + base_url = str(request.base_url).rstrip('/') + archive_url = f"{base_url}/archives?id={archive_id}" + + # Generate QR code + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_M, + box_size=10, + border=2, + ) + qr.add_data(archive_url) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + + # Resize if needed + if size != 200: + img = img.resize((size, size)) + + # Convert to bytes + buffer = io.BytesIO() + img.save(buffer, format="PNG") + buffer.seek(0) + + return Response( + content=buffer.getvalue(), + media_type="image/png", + headers={ + "Content-Disposition": f'inline; filename="qr_{archive.print_name or archive_id}.png"' + } + ) + + +@router.get("/{archive_id}/capabilities") +async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(get_db)): + """Check what viewing capabilities are available for this 3MF file.""" + import json + import re + + service = ArchiveService(db) + archive = await service.get_archive(archive_id) + if not archive: + raise HTTPException(404, "Archive not found") + + file_path = settings.base_dir / archive.file_path + if not file_path.exists(): + raise HTTPException(404, "File not found") + + has_model = False + has_gcode = False + build_volume = {"x": 256, "y": 256, "z": 256} # Default to X1/P1 size + + try: + with zipfile.ZipFile(file_path, 'r') as zf: + names = zf.namelist() + + # Check for G-code + has_gcode = any(n.startswith('Metadata/') and n.endswith('.gcode') for n in names) + + # Check for 3D model - need to look for actual mesh data + for name in names: + if name.endswith('.model'): + try: + content = zf.read(name).decode('utf-8') + # Check if this model file contains actual mesh vertices + if '+ {mode === 'add' ? 'Add' : 'Remove'} tags for {selectedIds.length} selected archive{selectedIds.length !== 1 ? 's' : ''} +
+ + {/* Mode toggle */} +Existing tags:
++ Tags to {mode === 'add' ? 'add' : 'remove'}: +
+No prints on this day
+ )} + > + ) : ( +Select a day to see prints
+{message}
+Hidden widgets (click to show):
+All widgets are hidden.
+ +Period Filament
+{(totalFilament / 1000).toFixed(2)}kg
+{totalFilament.toFixed(0)}g total
+Period Cost
+{currency}{totalCost.toFixed(2)}
+{filteredArchives.length} prints
+Avg per Print
++ {filteredArchives.length > 0 + ? (totalFilament / filteredArchives.length).toFixed(0) + : 0}g +
++ {currency}{filteredArchives.length > 0 ? (totalCost / filteredArchives.length).toFixed(2) : '0.00'} avg +
+{printerName}
+Loading G-code...
+G-code not available
++ This file hasn't been sliced yet. G-code preview is only available + after slicing the model in Bambu Studio or Orca Slicer. +
+{error}
+
+ Press
{error}
++ Photo {currentIndex + 1} of {photos.length} +
++ {archiveName} +
++ Scan to open this archive +
+ ++ Send {archiveName} to a printer +
+ + {/* Printer selection */} + {loadingPrinters ? ( ++ Drag & drop .3mf files here +
+or
+ + ++ {uploadResult.uploaded} uploaded + {uploadResult.failed > 0 && ( + <>, {uploadResult.failed} failed> + )} +
+{printerName}
+ + {/* Stats */} +Drop .3mf files here
+Release to upload
++ {filteredArchives?.length || 0} of {archives?.length || 0} prints +
++ {search ? 'No archives match your search' : 'No archives yet'} +
++ Archives are created automatically when prints complete +
+{archive.print_name || archive.filename}
+ {archive.timelapse_path && ( + +{setting.type} preset
+
+ {JSON.stringify(detail, null, 2)}
+
+ ) : (
+ + Failed to load preset details +
+ )} +No presets found
++ {status?.is_authenticated + ? `Connected as ${status.email}` + : 'Manage your Bambu Cloud slicer presets'} +
+Failed to load profiles
+ +{printName}
+ )} +{printer.model || 'Unknown Model'}
+Status
++ {status.state?.toLowerCase() || 'Idle'} +
+Printing
++ {status.subtask_name || status.current_print} +
+Nozzle
++ {Math.round(status.temperatures.nozzle || 0)}°C +
+Bed
++ {Math.round(status.temperatures.bed || 0)}°C +
+Chamber
++ {Math.round(status.temperatures.chamber || 0)}°C +
+{printer.ip_address}
+{printer.serial_number}
+Manage your Bambu Lab printers
+No printers configured yet
+ +Configure Bambusy
+Auto-archive prints
++ Automatically save 3MF files when prints complete +
+Save thumbnails
++ Extract and save preview images from 3MF files +
+Bambusy v0.1.0
++ Archive and manage your Bambu Lab 3MF files +
++ Connect to printers via LAN mode (developer mode required) +
+Total Prints
+{stats?.total_prints || 0}
+Print Time
+{stats?.total_print_time_hours.toFixed(1) || 0}h
+Filament Used
+{((stats?.total_filament_grams || 0) / 1000).toFixed(2)}kg
+Total Cost
+{currency} {stats?.total_cost.toFixed(2) || '0.00'}
+No filament data available
; + } + + // Sort by print count descending + const sortedEntries = Object.entries(stats.prints_by_filament_type).sort( + ([, a], [, b]) => b - a + ); + + return ( +No printer data available
; + } + + return ( ++ {printerMap.get(printerId) || `Printer ${printerId}`} +
+{count} prints
+No print data available
; + } + returnDrag widgets to rearrange. Click the eye icon to hide.
+"u")return null;try{return o.activeElement||o.body}catch{return o.body}}var ti=/[\n"\\]/g;function pn(o){return o.replace(ti,function(u){return"\\"+u.charCodeAt(0).toString(16)+" "})}function es(o,u,p,_,M,D,j,te){o.name="",j!=null&&typeof j!="function"&&typeof j!="symbol"&&typeof j!="boolean"?o.type=j:o.removeAttribute("type"),u!=null?j==="number"?(u===0&&o.value===""||o.value!=u)&&(o.value=""+qt(u)):o.value!==""+qt(u)&&(o.value=""+qt(u)):j!=="submit"&&j!=="reset"||o.removeAttribute("value"),u!=null?ji(o,j,qt(u)):p!=null?ji(o,j,qt(p)):_!=null&&o.removeAttribute("value"),M==null&&D!=null&&(o.defaultChecked=!!D),M!=null&&(o.checked=M&&typeof M!="function"&&typeof M!="symbol"),te!=null&&typeof te!="function"&&typeof te!="symbol"&&typeof te!="boolean"?o.name=""+qt(te):o.removeAttribute("name")}function gn(o,u,p,_,M,D,j,te){if(D!=null&&typeof D!="function"&&typeof D!="symbol"&&typeof D!="boolean"&&(o.type=D),u!=null||p!=null){if(!(D!=="submit"&&D!=="reset"||u!=null)){kt(o);return}p=p!=null?""+qt(p):"",u=u!=null?""+qt(u):p,te||u===o.value||(o.value=u),o.defaultValue=u}_=_??M,_=typeof _!="function"&&typeof _!="symbol"&&!!_,o.checked=te?o.checked:!!_,o.defaultChecked=!!_,j!=null&&typeof j!="function"&&typeof j!="symbol"&&typeof j!="boolean"&&(o.name=j),kt(o)}function ji(o,u,p){u==="number"&&Kn(o.ownerDocument)===o||o.defaultValue===""+p||(o.defaultValue=""+p)}function Ei(o,u,p,_){if(o=o.options,u){u={};for(var M=0;M
=uh),u3=" ",d3=!1;function f3(o,u){switch(o){case"keyup":return sV.indexOf(u.keyCode)!==-1;case"keydown":return u.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function h3(o){return o=o.detail,typeof o=="object"&&"data"in o?o.data:null}var ju=!1;function lV(o,u){switch(o){case"compositionend":return h3(u);case"keypress":return u.which!==32?null:(d3=!0,u3);case"textInput":return o=u.data,o===u3&&d3?null:o;default:return null}}function cV(o,u){if(ju)return o==="compositionend"||!Ab&&f3(o,u)?(o=r3(),Qm=_b=ll=null,ju=!1,o):null;switch(o){case"paste":return null;case"keypress":if(!(u.ctrlKey||u.altKey||u.metaKey)||u.ctrlKey&&u.altKey){if(u.char&&1 <\/script>",D=D.removeChild(D.firstChild);break;case"select":D=typeof _.is=="string"?j.createElement("select",{is:_.is}):j.createElement("select"),_.multiple?D.multiple=!0:_.size&&(D.size=_.size);break;default:D=typeof _.is=="string"?j.createElement(M,{is:_.is}):j.createElement(M)}}D[Wn]=u,D[ei]=_;e:for(j=u.child;j!==null;){if(j.tag===5||j.tag===6)D.appendChild(j.stateNode);else if(j.tag!==4&&j.tag!==27&&j.child!==null){j.child.return=j,j=j.child;continue}if(j===u)break e;for(;j.sibling===null;){if(j.return===null||j.return===u)break e;j=j.return}j.sibling.return=j.return,j=j.sibling}u.stateNode=D;e:switch(sr(D,M,_),M){case"button":case"input":case"select":case"textarea":_=!!_.autoFocus;break e;case"img":_=!0;break e;default:_=!1}_&&oo(u)}}return kn(u),N_(u,u.type,o===null?null:o.memoizedProps,u.pendingProps,p),null;case 6:if(o&&u.stateNode!=null)o.memoizedProps!==_&&oo(u);else{if(typeof _!="string"&&u.stateNode===null)throw Error(i(166));if(o=me.current,qu(u)){if(o=u.stateNode,p=u.memoizedProps,_=null,M=ir,M!==null)switch(M.tag){case 27:case 5:_=M.memoizedProps}o[Wn]=u,o=!!(o.nodeValue===p||_!==null&&_.suppressHydrationWarning===!0||tD(o.nodeValue,p)),o||dl(u,!0)}else o=Wg(o).createTextNode(_),o[Wn]=u,u.stateNode=o}return kn(u),null;case 31:if(p=u.memoizedState,o===null||o.memoizedState!==null){if(_=qu(u),p!==null){if(o===null){if(!_)throw Error(i(318));if(o=u.memoizedState,o=o!==null?o.dehydrated:null,!o)throw Error(i(557));o[Wn]=u}else Ec(),(u.flags&128)===0&&(u.memoizedState=null),u.flags|=4;kn(u),o=!1}else p=Hb(),o!==null&&o.memoizedState!==null&&(o.memoizedState.hydrationErrors=p),o=!0;if(!o)return u.flags&256?(sa(u),u):(sa(u),null);if((u.flags&128)!==0)throw Error(i(558))}return kn(u),null;case 13:if(_=u.memoizedState,o===null||o.memoizedState!==null&&o.memoizedState.dehydrated!==null){if(M=qu(u),_!==null&&_.dehydrated!==null){if(o===null){if(!M)throw Error(i(318));if(M=u.memoizedState,M=M!==null?M.dehydrated:null,!M)throw Error(i(317));M[Wn]=u}else Ec(),(u.flags&128)===0&&(u.memoizedState=null),u.flags|=4;kn(u),M=!1}else M=Hb(),o!==null&&o.memoizedState!==null&&(o.memoizedState.hydrationErrors=M),M=!0;if(!M)return u.flags&256?(sa(u),u):(sa(u),null)}return sa(u),(u.flags&128)!==0?(u.lanes=p,u):(p=_!==null,o=o!==null&&o.memoizedState!==null,p&&(_=u.child,M=null,_.alternate!==null&&_.alternate.memoizedState!==null&&_.alternate.memoizedState.cachePool!==null&&(M=_.alternate.memoizedState.cachePool.pool),D=null,_.memoizedState!==null&&_.memoizedState.cachePool!==null&&(D=_.memoizedState.cachePool.pool),D!==M&&(_.flags|=2048)),p!==o&&p&&(u.child.flags|=8192),Dg(u,u.updateQueue),kn(u),null);case 4:return he(),o===null&&eS(u.stateNode.containerInfo),kn(u),null;case 10:return io(u.type),kn(u),null;case 19:if(q(ni),_=u.memoizedState,_===null)return kn(u),null;if(M=(u.flags&128)!==0,D=_.rendering,D===null)if(M)Rh(_,!1);else{if(Qn!==0||o!==null&&(o.flags&128)!==0)for(o=u.child;o!==null;){if(D=xg(o),D!==null){for(u.flags|=128,Rh(_,!1),o=D.updateQueue,u.updateQueue=o,Dg(u,o),u.subtreeFlags=0,o=p,p=u.child;p!==null;)O3(p,o),p=p.sibling;return re(ni,ni.current&1|2),rn&&to(u,_.treeForkCount),u.child}o=o.sibling}_.tail!==null&&O()>Ug&&(u.flags|=128,M=!0,Rh(_,!1),u.lanes=4194304)}else{if(!M)if(o=xg(D),o!==null){if(u.flags|=128,M=!0,o=o.updateQueue,u.updateQueue=o,Dg(u,o),Rh(_,!0),_.tail===null&&_.tailMode==="hidden"&&!D.alternate&&!rn)return kn(u),null}else 2*O()-_.renderingStartTime>Ug&&p!==536870912&&(u.flags|=128,M=!0,Rh(_,!1),u.lanes=4194304);_.isBackwards?(D.sibling=u.child,u.child=D):(o=_.last,o!==null?o.sibling=D:u.child=D,_.last=D)}return _.tail!==null?(o=_.tail,_.rendering=o,_.tail=o.sibling,_.renderingStartTime=O(),o.sibling=null,p=ni.current,re(ni,M?p&1|2:p&1),rn&&to(u,_.treeForkCount),o):(kn(u),null);case 22:case 23:return sa(u),t_(),_=u.memoizedState!==null,o!==null?o.memoizedState!==null!==_&&(u.flags|=8192):_&&(u.flags|=8192),_?(p&536870912)!==0&&(u.flags&128)===0&&(kn(u),u.subtreeFlags&6&&(u.flags|=8192)):kn(u),p=u.updateQueue,p!==null&&Dg(u,p.retryQueue),p=null,o!==null&&o.memoizedState!==null&&o.memoizedState.cachePool!==null&&(p=o.memoizedState.cachePool.pool),_=null,u.memoizedState!==null&&u.memoizedState.cachePool!==null&&(_=u.memoizedState.cachePool.pool),_!==p&&(u.flags|=2048),o!==null&&q(Tc),null;case 24:return p=null,o!==null&&(p=o.memoizedState.cache),u.memoizedState.cache!==p&&(u.flags|=2048),io(ci),kn(u),null;case 25:return null;case 30:return null}throw Error(i(156,u.tag))}function zV(o,u){switch(Bb(u),u.tag){case 1:return o=u.flags,o&65536?(u.flags=o&-65537|128,u):null;case 3:return io(ci),he(),o=u.flags,(o&65536)!==0&&(o&128)===0?(u.flags=o&-65537|128,u):null;case 26:case 27:case 5:return Be(u),null;case 31:if(u.memoizedState!==null){if(sa(u),u.alternate===null)throw Error(i(340));Ec()}return o=u.flags,o&65536?(u.flags=o&-65537|128,u):null;case 13:if(sa(u),o=u.memoizedState,o!==null&&o.dehydrated!==null){if(u.alternate===null)throw Error(i(340));Ec()}return o=u.flags,o&65536?(u.flags=o&-65537|128,u):null;case 19:return q(ni),null;case 4:return he(),null;case 10:return io(u.type),null;case 22:case 23:return sa(u),t_(),o!==null&&q(Tc),o=u.flags,o&65536?(u.flags=o&-65537|128,u):null;case 24:return io(ci),null;case 25:return null;default:return null}}function sP(o,u){switch(Bb(u),u.tag){case 3:io(ci),he();break;case 26:case 27:case 5:Be(u);break;case 4:he();break;case 31:u.memoizedState!==null&&sa(u);break;case 13:sa(u);break;case 19:q(ni);break;case 10:io(u.type);break;case 22:case 23:sa(u),t_(),o!==null&&q(Tc);break;case 24:io(ci)}}function Ph(o,u){try{var p=u.updateQueue,_=p!==null?p.lastEffect:null;if(_!==null){var M=_.next;p=M;do{if((p.tag&o)===o){_=void 0;var D=p.create,j=p.inst;_=D(),j.destroy=_}p=p.next}while(p!==M)}}catch(te){xn(u,u.return,te)}}function xl(o,u,p){try{var _=u.updateQueue,M=_!==null?_.lastEffect:null;if(M!==null){var D=M.next;_=D;do{if((_.tag&o)===o){var j=_.inst,te=j.destroy;if(te!==void 0){j.destroy=void 0,M=u;var Se=p,ze=te;try{ze()}catch(Qe){xn(M,Se,Qe)}}}_=_.next}while(_!==D)}}catch(Qe){xn(u,u.return,Qe)}}function oP(o){var u=o.updateQueue;if(u!==null){var p=o.stateNode;try{Z3(u,p)}catch(_){xn(o,o.return,_)}}}function lP(o,u,p){p.props=Nc(o.type,o.memoizedProps),p.state=o.memoizedState;try{p.componentWillUnmount()}catch(_){xn(o,u,_)}}function Dh(o,u){try{var p=o.ref;if(p!==null){switch(o.tag){case 26:case 27:case 5:var _=o.stateNode;break;case 30:_=o.stateNode;break;default:_=o.stateNode}typeof p=="function"?o.refCleanup=p(_):p.current=_}}catch(M){xn(o,u,M)}}function Ts(o,u){var p=o.ref,_=o.refCleanup;if(p!==null)if(typeof _=="function")try{_()}catch(M){xn(o,u,M)}finally{o.refCleanup=null,o=o.alternate,o!=null&&(o.refCleanup=null)}else if(typeof p=="function")try{p(null)}catch(M){xn(o,u,M)}else p.current=null}function cP(o){var u=o.type,p=o.memoizedProps,_=o.stateNode;try{e:switch(u){case"button":case"input":case"select":case"textarea":p.autoFocus&&_.focus();break e;case"img":p.src?_.src=p.src:p.srcSet&&(_.srcset=p.srcSet)}}catch(M){xn(o,o.return,M)}}function O_(o,u,p){try{var _=o.stateNode;o9(_,o.type,p,u),_[ei]=u}catch(M){xn(o,o.return,M)}}function uP(o){return o.tag===5||o.tag===3||o.tag===26||o.tag===27&&Ml(o.type)||o.tag===4}function L_(o){e:for(;;){for(;o.sibling===null;){if(o.return===null||uP(o.return))return null;o=o.return}for(o.sibling.return=o.return,o=o.sibling;o.tag!==5&&o.tag!==6&&o.tag!==18;){if(o.tag===27&&Ml(o.type)||o.flags&2||o.child===null||o.tag===4)continue e;o.child.return=o,o=o.child}if(!(o.flags&2))return o.stateNode}}function I_(o,u,p){var _=o.tag;if(_===5||_===6)o=o.stateNode,u?(p.nodeType===9?p.body:p.nodeName==="HTML"?p.ownerDocument.body:p).insertBefore(o,u):(u=p.nodeType===9?p.body:p.nodeName==="HTML"?p.ownerDocument.body:p,u.appendChild(o),p=p._reactRootContainer,p!=null||u.onclick!==null||(u.onclick=Qs));else if(_!==4&&(_===27&&Ml(o.type)&&(p=o.stateNode,u=null),o=o.child,o!==null))for(I_(o,u,p),o=o.sibling;o!==null;)I_(o,u,p),o=o.sibling}function Ng(o,u,p){var _=o.tag;if(_===5||_===6)o=o.stateNode,u?p.insertBefore(o,u):p.appendChild(o);else if(_!==4&&(_===27&&Ml(o.type)&&(p=o.stateNode),o=o.child,o!==null))for(Ng(o,u,p),o=o.sibling;o!==null;)Ng(o,u,p),o=o.sibling}function dP(o){var u=o.stateNode,p=o.memoizedProps;try{for(var _=o.type,M=u.attributes;M.length;)u.removeAttributeNode(M[0]);sr(u,_,p),u[Wn]=o,u[ei]=p}catch(D){xn(o,o.return,D)}}var lo=!1,fi=!1,U_=!1,fP=typeof WeakSet=="function"?WeakSet:Set,Gi=null;function BV(o,u){if(o=o.containerInfo,iS=Jg,o=E3(o),Rb(o)){if("selectionStart"in o)var p={start:o.selectionStart,end:o.selectionEnd};else e:{p=(p=o.ownerDocument)&&p.defaultView||window;var _=p.getSelection&&p.getSelection();if(_&&_.rangeCount!==0){p=_.anchorNode;var M=_.anchorOffset,D=_.focusNode;_=_.focusOffset;try{p.nodeType,D.nodeType}catch{p=null;break e}var j=0,te=-1,Se=-1,ze=0,Qe=0,it=o,Ge=null;t:for(;;){for(var Ye;it!==p||M!==0&&it.nodeType!==3||(te=j+M),it!==D||_!==0&&it.nodeType!==3||(Se=j+_),it.nodeType===3&&(j+=it.nodeValue.length),(Ye=it.firstChild)!==null;)Ge=it,it=Ye;for(;;){if(it===o)break t;if(Ge===p&&++ze===M&&(te=j),Ge===D&&++Qe===_&&(Se=j),(Ye=it.nextSibling)!==null)break;it=Ge,Ge=it.parentNode}it=Ye}p=te===-1||Se===-1?null:{start:te,end:Se}}else p=null}p=p||{start:0,end:0}}else p=null;for(rS={focusedElem:o,selectionRange:p},Jg=!1,Gi=u;Gi!==null;)if(u=Gi,o=u.child,(u.subtreeFlags&1028)!==0&&o!==null)o.return=u,Gi=o;else for(;Gi!==null;){switch(u=Gi,D=u.alternate,o=u.flags,u.tag){case 0:if((o&4)!==0&&(o=u.updateQueue,o=o!==null?o.events:null,o!==null))for(p=0;p