mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 23:21:58 +02:00
New Features:
- Print from File Manager with plate selection, AMS mapping, and print options. Closes #94 - Add to queue without creating archive (reduces clutter) - Queue displays library file name, thumbnail, and print time Fixes: Queue items from File Manager now reference library files directly instead of creating archives upfront. Archives are created automatically when prints start.
This commit is contained in:
+15
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
All notable changes to Bambuddy will be documented in this file.
|
||||
|
||||
## [0.1.6b11] - 2026-01-20
|
||||
## [Unreleased]
|
||||
|
||||
### New Features
|
||||
- **Unified Print Modal** - Consolidated three separate modals into one unified component:
|
||||
@@ -18,11 +18,25 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Enhanced Add-to-Queue** - Now includes plate selection and print options:
|
||||
- Configure all print settings upfront instead of editing afterward
|
||||
- Filament mapping with manual override capability
|
||||
- **Print from File Manager** - Full print configuration when printing from library files:
|
||||
- Plate selection for multi-plate 3MF files with thumbnails
|
||||
- Filament slot mapping with comparison to loaded filaments
|
||||
- All print options (bed levelling, flow calibration, etc.)
|
||||
- **Deferred archive creation** - Queue items from File Manager no longer create archives upfront:
|
||||
- Queue items store `library_file_id` directly
|
||||
- Archives are created automatically when prints start
|
||||
- Reduces clutter in Archives from unprinted queued files
|
||||
- Queue displays library file name, thumbnail, and print time
|
||||
|
||||
### Changed
|
||||
- **Edit Queue Item modal** - Single printer selection only (reassigns item, doesn't duplicate)
|
||||
- **Edit Queue Item button** - Changed from "Print to X Printers" to "Save"
|
||||
|
||||
### Fixed
|
||||
- **File Manager folder navigation** - Fixed bug where opening a folder would briefly show files then jump back to root:
|
||||
- Removed `selectedFolderId` from useEffect dependency array that was causing a reset loop
|
||||
- Folder navigation now works correctly without resetting
|
||||
- **Queue items with library files** - Fixed 500 errors when listing/updating queue items from File Manager
|
||||
|
||||
## [0.1.6b10] - 2026-01-20
|
||||
|
||||
|
||||
@@ -78,6 +78,14 @@
|
||||
- Auto power-on before print
|
||||
- Auto power-off after cooldown
|
||||
|
||||
### 📁 File Manager (Library)
|
||||
- Upload and organize sliced files (3MF, gcode)
|
||||
- Folder structure with drag-and-drop
|
||||
- Print directly to any printer with full options
|
||||
- Add to queue without creating archive upfront
|
||||
- Plate selection for multi-plate 3MF files
|
||||
- Duplicate detection via file hash
|
||||
|
||||
### 📁 Projects
|
||||
- Group related prints (e.g., "Voron Build")
|
||||
- Track plates (print jobs) and parts separately
|
||||
|
||||
@@ -30,6 +30,7 @@ from backend.app.schemas.library import (
|
||||
FileDuplicate,
|
||||
FileListResponse,
|
||||
FileMoveRequest,
|
||||
FilePrintRequest,
|
||||
FileResponse as FileResponseSchema,
|
||||
FileUpdate,
|
||||
FileUploadResponse,
|
||||
@@ -755,10 +756,7 @@ async def add_files_to_queue(
|
||||
"""Add library files to the print queue.
|
||||
|
||||
Only sliced files (.gcode or .gcode.3mf) can be added to the queue.
|
||||
For each file:
|
||||
1. Validates it's a sliced file
|
||||
2. Creates an archive from the library file
|
||||
3. Creates a queue item pointing to that archive
|
||||
The archive will be created automatically when the print starts.
|
||||
"""
|
||||
added: list[AddToQueueResult] = []
|
||||
errors: list[AddToQueueError] = []
|
||||
@@ -771,8 +769,6 @@ async def add_files_to_queue(
|
||||
pos_result = await db.execute(select(func.coalesce(func.max(PrintQueueItem.position), 0)))
|
||||
max_position = pos_result.scalar() or 0
|
||||
|
||||
archive_service = ArchiveService(db)
|
||||
|
||||
for file_id in request.file_ids:
|
||||
lib_file = files.get(file_id)
|
||||
|
||||
@@ -792,7 +788,7 @@ async def add_files_to_queue(
|
||||
continue
|
||||
|
||||
try:
|
||||
# Get the full file path
|
||||
# Verify file exists on disk
|
||||
file_path = Path(app_settings.base_dir) / lib_file.file_path
|
||||
|
||||
if not file_path.exists():
|
||||
@@ -801,23 +797,11 @@ async def add_files_to_queue(
|
||||
)
|
||||
continue
|
||||
|
||||
# Create archive from the library file
|
||||
archive = await archive_service.archive_print(
|
||||
printer_id=None, # Unassigned
|
||||
source_file=file_path,
|
||||
)
|
||||
|
||||
if not archive:
|
||||
errors.append(
|
||||
AddToQueueError(file_id=file_id, filename=lib_file.filename, error="Failed to create archive")
|
||||
)
|
||||
continue
|
||||
|
||||
# Create queue item
|
||||
# Create queue item referencing library file (archive created at print start)
|
||||
max_position += 1
|
||||
queue_item = PrintQueueItem(
|
||||
printer_id=None, # Unassigned
|
||||
archive_id=archive.id,
|
||||
library_file_id=file_id,
|
||||
position=max_position,
|
||||
status="pending",
|
||||
)
|
||||
@@ -830,7 +814,6 @@ async def add_files_to_queue(
|
||||
file_id=file_id,
|
||||
filename=lib_file.filename,
|
||||
queue_item_id=queue_item.id,
|
||||
archive_id=archive.id,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -843,6 +826,509 @@ async def add_files_to_queue(
|
||||
return AddToQueueResponse(added=added, errors=errors)
|
||||
|
||||
|
||||
@router.get("/files/{file_id}/plates")
|
||||
async def get_library_file_plates(
|
||||
file_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get available plates from a multi-plate 3MF library file.
|
||||
|
||||
Returns a list of plates with their index, name, thumbnail availability,
|
||||
and filament requirements. For single-plate exports, returns a single plate.
|
||||
"""
|
||||
import xml.etree.ElementTree as ET
|
||||
import zipfile
|
||||
|
||||
# Get the library file
|
||||
result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
|
||||
lib_file = result.scalar_one_or_none()
|
||||
|
||||
if not lib_file:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
file_path = Path(app_settings.base_dir) / lib_file.file_path
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found on disk")
|
||||
|
||||
# Only 3MF files have plates
|
||||
if not lib_file.filename.lower().endswith(".3mf"):
|
||||
return {"file_id": file_id, "filename": lib_file.filename, "plates": [], "is_multi_plate": False}
|
||||
|
||||
plates = []
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zf:
|
||||
namelist = zf.namelist()
|
||||
|
||||
# Find all plate gcode files to determine available plates
|
||||
gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
|
||||
|
||||
if not gcode_files:
|
||||
# No sliced plates found
|
||||
return {"file_id": file_id, "filename": lib_file.filename, "plates": [], "is_multi_plate": False}
|
||||
|
||||
# Extract plate indices from gcode filenames
|
||||
plate_indices = []
|
||||
for gf in gcode_files:
|
||||
try:
|
||||
plate_str = gf[15:-6] # Remove "Metadata/plate_" and ".gcode"
|
||||
plate_indices.append(int(plate_str))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
plate_indices.sort()
|
||||
|
||||
# Parse model_settings.config for plate names
|
||||
plate_names = {}
|
||||
if "Metadata/model_settings.config" in namelist:
|
||||
try:
|
||||
model_content = zf.read("Metadata/model_settings.config").decode()
|
||||
model_root = ET.fromstring(model_content)
|
||||
for plate_elem in model_root.findall(".//plate"):
|
||||
plater_id = None
|
||||
plater_name = None
|
||||
for meta in plate_elem.findall("metadata"):
|
||||
key = meta.get("key")
|
||||
value = meta.get("value")
|
||||
if key == "plater_id" and value:
|
||||
try:
|
||||
plater_id = int(value)
|
||||
except ValueError:
|
||||
pass
|
||||
elif key == "plater_name" and value:
|
||||
plater_name = value.strip()
|
||||
if plater_id is not None and plater_name:
|
||||
plate_names[plater_id] = plater_name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Parse slice_info.config for plate metadata
|
||||
plate_metadata = {}
|
||||
if "Metadata/slice_info.config" in namelist:
|
||||
content = zf.read("Metadata/slice_info.config").decode()
|
||||
root = ET.fromstring(content)
|
||||
|
||||
for plate_elem in root.findall(".//plate"):
|
||||
plate_info = {"filaments": [], "prediction": None, "weight": None, "name": None, "objects": []}
|
||||
|
||||
plate_index = None
|
||||
for meta in plate_elem.findall("metadata"):
|
||||
key = meta.get("key")
|
||||
value = meta.get("value")
|
||||
if key == "index" and value:
|
||||
try:
|
||||
plate_index = int(value)
|
||||
except ValueError:
|
||||
pass
|
||||
elif key == "prediction" and value:
|
||||
try:
|
||||
plate_info["prediction"] = int(value)
|
||||
except ValueError:
|
||||
pass
|
||||
elif key == "weight" and value:
|
||||
try:
|
||||
plate_info["weight"] = float(value)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Get filaments used in this plate
|
||||
for filament_elem in plate_elem.findall("filament"):
|
||||
filament_id = filament_elem.get("id")
|
||||
filament_type = filament_elem.get("type", "")
|
||||
filament_color = filament_elem.get("color", "")
|
||||
used_g = filament_elem.get("used_g", "0")
|
||||
used_m = filament_elem.get("used_m", "0")
|
||||
|
||||
try:
|
||||
used_grams = float(used_g)
|
||||
except (ValueError, TypeError):
|
||||
used_grams = 0
|
||||
|
||||
if used_grams > 0 and filament_id:
|
||||
plate_info["filaments"].append(
|
||||
{
|
||||
"slot_id": int(filament_id),
|
||||
"type": filament_type,
|
||||
"color": filament_color,
|
||||
"used_grams": round(used_grams, 1),
|
||||
"used_meters": float(used_m) if used_m else 0,
|
||||
}
|
||||
)
|
||||
|
||||
plate_info["filaments"].sort(key=lambda x: x["slot_id"])
|
||||
|
||||
# Collect object names
|
||||
for obj_elem in plate_elem.findall("object"):
|
||||
obj_name = obj_elem.get("name")
|
||||
if obj_name and obj_name not in plate_info["objects"]:
|
||||
plate_info["objects"].append(obj_name)
|
||||
|
||||
# Set plate name
|
||||
if plate_index is not None:
|
||||
custom_name = plate_names.get(plate_index)
|
||||
if custom_name:
|
||||
plate_info["name"] = custom_name
|
||||
elif plate_info["objects"]:
|
||||
plate_info["name"] = plate_info["objects"][0]
|
||||
plate_metadata[plate_index] = plate_info
|
||||
|
||||
# Build plate list
|
||||
for idx in plate_indices:
|
||||
meta = plate_metadata.get(idx, {})
|
||||
has_thumbnail = f"Metadata/plate_{idx}.png" in namelist
|
||||
|
||||
plates.append(
|
||||
{
|
||||
"index": idx,
|
||||
"name": meta.get("name"),
|
||||
"objects": meta.get("objects", []),
|
||||
"has_thumbnail": has_thumbnail,
|
||||
"thumbnail_url": f"/api/v1/library/files/{file_id}/plate-thumbnail/{idx}"
|
||||
if has_thumbnail
|
||||
else None,
|
||||
"print_time_seconds": meta.get("prediction"),
|
||||
"filament_used_grams": meta.get("weight"),
|
||||
"filaments": meta.get("filaments", []),
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse plates from library file {file_id}: {e}")
|
||||
|
||||
return {
|
||||
"file_id": file_id,
|
||||
"filename": lib_file.filename,
|
||||
"plates": plates,
|
||||
"is_multi_plate": len(plates) > 1,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/files/{file_id}/plate-thumbnail/{plate_index}")
|
||||
async def get_library_file_plate_thumbnail(
|
||||
file_id: int,
|
||||
plate_index: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get the thumbnail image for a specific plate from a library file."""
|
||||
import zipfile
|
||||
|
||||
from starlette.responses import Response
|
||||
|
||||
result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
|
||||
lib_file = result.scalar_one_or_none()
|
||||
|
||||
if not lib_file:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
file_path = Path(app_settings.base_dir) / lib_file.file_path
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found on disk")
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zf:
|
||||
thumb_path = f"Metadata/plate_{plate_index}.png"
|
||||
if thumb_path in zf.namelist():
|
||||
data = zf.read(thumb_path)
|
||||
return Response(content=data, media_type="image/png")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise HTTPException(status_code=404, detail=f"Thumbnail for plate {plate_index} not found")
|
||||
|
||||
|
||||
@router.get("/files/{file_id}/filament-requirements")
|
||||
async def get_library_file_filament_requirements(
|
||||
file_id: int,
|
||||
plate_id: int | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get filament requirements from a library file.
|
||||
|
||||
Parses the 3MF file to extract filament slot IDs, types, colors, and usage.
|
||||
This enables AMS slot assignment when printing from the file manager.
|
||||
|
||||
Args:
|
||||
file_id: The library file ID
|
||||
plate_id: Optional plate index to get filaments for a specific plate
|
||||
"""
|
||||
import xml.etree.ElementTree as ET
|
||||
import zipfile
|
||||
|
||||
# Get the library file
|
||||
result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
|
||||
lib_file = result.scalar_one_or_none()
|
||||
|
||||
if not lib_file:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
# Get the full file path
|
||||
file_path = Path(app_settings.base_dir) / lib_file.file_path
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found on disk")
|
||||
|
||||
# Only 3MF files have parseable filament info
|
||||
if not lib_file.filename.lower().endswith(".3mf"):
|
||||
return {"file_id": file_id, "filename": lib_file.filename, "plate_id": plate_id, "filaments": []}
|
||||
|
||||
filaments = []
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zf:
|
||||
# Parse slice_info.config for filament requirements
|
||||
if "Metadata/slice_info.config" in zf.namelist():
|
||||
content = zf.read("Metadata/slice_info.config").decode()
|
||||
root = ET.fromstring(content)
|
||||
|
||||
if plate_id is not None:
|
||||
# Find filaments for specific plate
|
||||
for plate_elem in root.findall(".//plate"):
|
||||
# Check if this is the requested plate
|
||||
plate_index = None
|
||||
for meta in plate_elem.findall("metadata"):
|
||||
if meta.get("key") == "index":
|
||||
try:
|
||||
plate_index = int(meta.get("value", ""))
|
||||
except ValueError:
|
||||
pass
|
||||
break
|
||||
|
||||
if plate_index == plate_id:
|
||||
# Extract filaments from this plate
|
||||
for filament_elem in plate_elem.findall("filament"):
|
||||
filament_id = filament_elem.get("id")
|
||||
filament_type = filament_elem.get("type", "")
|
||||
filament_color = filament_elem.get("color", "")
|
||||
used_g = filament_elem.get("used_g", "0")
|
||||
used_m = filament_elem.get("used_m", "0")
|
||||
|
||||
try:
|
||||
used_grams = float(used_g)
|
||||
except (ValueError, TypeError):
|
||||
used_grams = 0
|
||||
|
||||
if used_grams > 0 and filament_id:
|
||||
filaments.append(
|
||||
{
|
||||
"slot_id": int(filament_id),
|
||||
"type": filament_type,
|
||||
"color": filament_color,
|
||||
"used_grams": round(used_grams, 1),
|
||||
"used_meters": float(used_m) if used_m else 0,
|
||||
}
|
||||
)
|
||||
break
|
||||
else:
|
||||
# Extract all filaments with used_g > 0 (for single-plate or overview)
|
||||
for filament_elem in root.findall(".//filament"):
|
||||
filament_id = filament_elem.get("id")
|
||||
filament_type = filament_elem.get("type", "")
|
||||
filament_color = filament_elem.get("color", "")
|
||||
used_g = filament_elem.get("used_g", "0")
|
||||
used_m = filament_elem.get("used_m", "0")
|
||||
|
||||
try:
|
||||
used_grams = float(used_g)
|
||||
except (ValueError, TypeError):
|
||||
used_grams = 0
|
||||
|
||||
if used_grams > 0 and filament_id:
|
||||
filaments.append(
|
||||
{
|
||||
"slot_id": int(filament_id),
|
||||
"type": filament_type,
|
||||
"color": filament_color,
|
||||
"used_grams": round(used_grams, 1),
|
||||
"used_meters": float(used_m) if used_m else 0,
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by slot ID
|
||||
filaments.sort(key=lambda x: x["slot_id"])
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse filament requirements from library file {file_id}: {e}")
|
||||
|
||||
return {
|
||||
"file_id": file_id,
|
||||
"filename": lib_file.filename,
|
||||
"plate_id": plate_id,
|
||||
"filaments": filaments,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/files/{file_id}/print")
|
||||
async def print_library_file(
|
||||
file_id: int,
|
||||
printer_id: int,
|
||||
body: FilePrintRequest | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Print a library file directly.
|
||||
|
||||
This endpoint:
|
||||
1. Creates an archive from the library file
|
||||
2. Uploads the file to the printer
|
||||
3. Starts the print
|
||||
|
||||
Only sliced files (.gcode or .gcode.3mf) can be printed.
|
||||
"""
|
||||
import zipfile
|
||||
|
||||
from backend.app.main import register_expected_print
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.services.bambu_ftp import (
|
||||
delete_file_async,
|
||||
get_ftp_retry_settings,
|
||||
upload_file_async,
|
||||
with_ftp_retry,
|
||||
)
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
|
||||
# Use defaults if no body provided
|
||||
if body is None:
|
||||
body = FilePrintRequest()
|
||||
|
||||
# Get the library file
|
||||
result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
|
||||
lib_file = result.scalar_one_or_none()
|
||||
|
||||
if not lib_file:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
# Validate file is sliced
|
||||
if not is_sliced_file(lib_file.filename):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Not a sliced file. Only .gcode or .gcode.3mf files can be printed.",
|
||||
)
|
||||
|
||||
# Get the full file path
|
||||
file_path = Path(app_settings.base_dir) / lib_file.file_path
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found on disk")
|
||||
|
||||
# Get printer
|
||||
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
||||
printer = result.scalar_one_or_none()
|
||||
if not printer:
|
||||
raise HTTPException(status_code=404, detail="Printer not found")
|
||||
|
||||
# Check printer is connected
|
||||
if not printer_manager.is_connected(printer_id):
|
||||
raise HTTPException(status_code=400, detail="Printer is not connected")
|
||||
|
||||
# Create archive from the library file
|
||||
archive_service = ArchiveService(db)
|
||||
archive = await archive_service.archive_print(
|
||||
printer_id=printer_id,
|
||||
source_file=file_path,
|
||||
)
|
||||
|
||||
if not archive:
|
||||
raise HTTPException(status_code=500, detail="Failed to create archive")
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Prepare remote filename
|
||||
base_name = lib_file.filename
|
||||
if base_name.endswith(".gcode.3mf"):
|
||||
base_name = base_name[:-10]
|
||||
elif base_name.endswith(".3mf"):
|
||||
base_name = base_name[:-4]
|
||||
remote_filename = f"{base_name}.3mf"
|
||||
remote_path = f"/{remote_filename}"
|
||||
|
||||
# Get FTP retry settings
|
||||
ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
|
||||
|
||||
# Delete existing file if present (avoids 553 error)
|
||||
await delete_file_async(
|
||||
printer.ip_address,
|
||||
printer.access_code,
|
||||
remote_path,
|
||||
socket_timeout=ftp_timeout,
|
||||
printer_model=printer.model,
|
||||
)
|
||||
|
||||
# Upload file to printer
|
||||
if ftp_retry_enabled:
|
||||
uploaded = await with_ftp_retry(
|
||||
upload_file_async,
|
||||
printer.ip_address,
|
||||
printer.access_code,
|
||||
file_path,
|
||||
remote_path,
|
||||
socket_timeout=ftp_timeout,
|
||||
printer_model=printer.model,
|
||||
max_retries=ftp_retry_count,
|
||||
retry_delay=ftp_retry_delay,
|
||||
operation_name=f"Upload for print to {printer.name}",
|
||||
)
|
||||
else:
|
||||
uploaded = await upload_file_async(
|
||||
printer.ip_address,
|
||||
printer.access_code,
|
||||
file_path,
|
||||
remote_path,
|
||||
socket_timeout=ftp_timeout,
|
||||
printer_model=printer.model,
|
||||
)
|
||||
|
||||
if not uploaded:
|
||||
raise HTTPException(status_code=500, detail="Failed to upload file to printer")
|
||||
|
||||
# Register this as an expected print so we don't create a duplicate archive
|
||||
register_expected_print(printer_id, remote_filename, archive.id)
|
||||
|
||||
# Determine plate ID
|
||||
if body.plate_id is not None:
|
||||
plate_id = body.plate_id
|
||||
else:
|
||||
plate_id = 1
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zf:
|
||||
for name in zf.namelist():
|
||||
if name.startswith("Metadata/plate_") and name.endswith(".gcode"):
|
||||
plate_str = name[15:-6]
|
||||
plate_id = int(plate_str)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
f"Print library file {file_id}: archive_id={archive.id}, plate_id={plate_id}, "
|
||||
f"ams_mapping={body.ams_mapping}, bed_levelling={body.bed_levelling}"
|
||||
)
|
||||
|
||||
# Start the print
|
||||
started = printer_manager.start_print(
|
||||
printer_id,
|
||||
remote_filename,
|
||||
plate_id,
|
||||
ams_mapping=body.ams_mapping,
|
||||
timelapse=body.timelapse,
|
||||
bed_levelling=body.bed_levelling,
|
||||
flow_cali=body.flow_cali,
|
||||
vibration_cali=body.vibration_cali,
|
||||
layer_inspect=body.layer_inspect,
|
||||
use_ams=body.use_ams,
|
||||
)
|
||||
|
||||
if not started:
|
||||
raise HTTPException(status_code=500, detail="Failed to start print")
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"status": "printing",
|
||||
"printer_id": printer_id,
|
||||
"archive_id": archive.id,
|
||||
"filename": lib_file.filename,
|
||||
}
|
||||
|
||||
|
||||
# ============ File Detail Endpoints ============
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.orm import selectinload
|
||||
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.library import LibraryFile
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.schemas.print_queue import (
|
||||
@@ -26,7 +27,7 @@ router = APIRouter(prefix="/queue", tags=["queue"])
|
||||
|
||||
|
||||
def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
|
||||
"""Add nested archive/printer info to response."""
|
||||
"""Add nested archive/printer/library_file info to response."""
|
||||
# Parse ams_mapping from JSON string BEFORE model_validate
|
||||
ams_mapping_parsed = None
|
||||
if item.ams_mapping:
|
||||
@@ -40,6 +41,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
|
||||
"id": item.id,
|
||||
"printer_id": item.printer_id,
|
||||
"archive_id": item.archive_id,
|
||||
"library_file_id": item.library_file_id,
|
||||
"position": item.position,
|
||||
"scheduled_time": item.scheduled_time,
|
||||
"require_previous_success": item.require_previous_success,
|
||||
@@ -64,6 +66,16 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
|
||||
response.archive_name = item.archive.print_name or item.archive.filename
|
||||
response.archive_thumbnail = item.archive.thumbnail_path
|
||||
response.print_time_seconds = item.archive.print_time_seconds
|
||||
if item.library_file:
|
||||
response.library_file_name = (
|
||||
item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None
|
||||
)
|
||||
if not response.library_file_name:
|
||||
response.library_file_name = item.library_file.filename
|
||||
response.library_file_thumbnail = item.library_file.thumbnail_path
|
||||
# Get print time from library file metadata if no archive
|
||||
if not item.archive and item.library_file.file_metadata:
|
||||
response.print_time_seconds = item.library_file.file_metadata.get("print_time_seconds")
|
||||
if item.printer:
|
||||
response.printer_name = item.printer.name
|
||||
return response
|
||||
@@ -78,7 +90,11 @@ async def list_queue(
|
||||
"""List all queue items, optionally filtered by printer or status."""
|
||||
query = (
|
||||
select(PrintQueueItem)
|
||||
.options(selectinload(PrintQueueItem.archive), selectinload(PrintQueueItem.printer))
|
||||
.options(
|
||||
selectinload(PrintQueueItem.archive),
|
||||
selectinload(PrintQueueItem.printer),
|
||||
selectinload(PrintQueueItem.library_file),
|
||||
)
|
||||
.order_by(PrintQueueItem.printer_id.nulls_first(), PrintQueueItem.position)
|
||||
)
|
||||
|
||||
@@ -102,16 +118,27 @@ async def add_to_queue(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Add an item to the print queue."""
|
||||
# Validate that either archive_id or library_file_id is provided
|
||||
if not data.archive_id and not data.library_file_id:
|
||||
raise HTTPException(400, "Either archive_id or library_file_id must be provided")
|
||||
|
||||
# Validate printer exists (if assigned)
|
||||
if data.printer_id is not None:
|
||||
result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(400, "Printer not found")
|
||||
|
||||
# Validate archive exists
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(400, "Archive not found")
|
||||
# Validate archive exists (if provided)
|
||||
if data.archive_id:
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(400, "Archive not found")
|
||||
|
||||
# Validate library file exists (if provided)
|
||||
if data.library_file_id:
|
||||
result = await db.execute(select(LibraryFile).where(LibraryFile.id == data.library_file_id))
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(400, "Library file not found")
|
||||
|
||||
# Get next position for this printer (or for unassigned items)
|
||||
if data.printer_id is not None:
|
||||
@@ -132,6 +159,7 @@ async def add_to_queue(
|
||||
item = PrintQueueItem(
|
||||
printer_id=data.printer_id,
|
||||
archive_id=data.archive_id,
|
||||
library_file_id=data.library_file_id,
|
||||
scheduled_time=data.scheduled_time,
|
||||
require_previous_success=data.require_previous_success,
|
||||
auto_off_after=data.auto_off_after,
|
||||
@@ -152,9 +180,10 @@ async def add_to_queue(
|
||||
await db.refresh(item)
|
||||
|
||||
# Load relationships for response
|
||||
await db.refresh(item, ["archive", "printer"])
|
||||
await db.refresh(item, ["archive", "printer", "library_file"])
|
||||
|
||||
logger.info(f"Added archive {data.archive_id} to queue for printer {data.printer_id or 'unassigned'}")
|
||||
source_name = f"archive {data.archive_id}" if data.archive_id else f"library file {data.library_file_id}"
|
||||
logger.info(f"Added {source_name} to queue for printer {data.printer_id or 'unassigned'}")
|
||||
|
||||
# MQTT relay - publish queue job added
|
||||
try:
|
||||
@@ -177,7 +206,11 @@ async def get_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Get a specific queue item."""
|
||||
result = await db.execute(
|
||||
select(PrintQueueItem)
|
||||
.options(selectinload(PrintQueueItem.archive), selectinload(PrintQueueItem.printer))
|
||||
.options(
|
||||
selectinload(PrintQueueItem.archive),
|
||||
selectinload(PrintQueueItem.printer),
|
||||
selectinload(PrintQueueItem.library_file),
|
||||
)
|
||||
.where(PrintQueueItem.id == item_id)
|
||||
)
|
||||
item = result.scalar_one_or_none()
|
||||
@@ -217,7 +250,7 @@ async def update_queue_item(
|
||||
setattr(item, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(item, ["archive", "printer"])
|
||||
await db.refresh(item, ["archive", "printer", "library_file"])
|
||||
|
||||
logger.info(f"Updated queue item {item_id}")
|
||||
return _enrich_response(item)
|
||||
@@ -372,7 +405,7 @@ async def start_queue_item(
|
||||
# Clear manual_start flag so scheduler picks it up
|
||||
item.manual_start = False
|
||||
await db.commit()
|
||||
await db.refresh(item, ["archive", "printer"])
|
||||
await db.refresh(item, ["archive", "printer", "library_file"])
|
||||
|
||||
logger.info(f"Manually started queue item {item_id} (cleared manual_start flag)")
|
||||
return _enrich_response(item)
|
||||
|
||||
@@ -579,6 +579,68 @@ async def run_migrations(conn):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Migration: Add library_file_id column to print_queue and make archive_id nullable
|
||||
# This allows queue items to reference library files directly (archive created at print start)
|
||||
try:
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE print_queue ADD COLUMN library_file_id INTEGER REFERENCES library_files(id) ON DELETE CASCADE"
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check if archive_id needs to be made nullable (requires table recreation in SQLite)
|
||||
try:
|
||||
result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='print_queue'"))
|
||||
row = result.fetchone()
|
||||
if row and "archive_id INTEGER NOT NULL" in (row[0] or ""):
|
||||
# Need to migrate - archive_id is currently NOT NULL
|
||||
await conn.execute(
|
||||
text("""
|
||||
CREATE TABLE print_queue_new2 (
|
||||
id INTEGER PRIMARY KEY,
|
||||
printer_id INTEGER REFERENCES printers(id) ON DELETE CASCADE,
|
||||
archive_id INTEGER REFERENCES print_archives(id) ON DELETE CASCADE,
|
||||
library_file_id INTEGER REFERENCES library_files(id) ON DELETE CASCADE,
|
||||
project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
|
||||
position INTEGER DEFAULT 0,
|
||||
scheduled_time DATETIME,
|
||||
manual_start BOOLEAN DEFAULT 0,
|
||||
require_previous_success BOOLEAN DEFAULT 0,
|
||||
auto_off_after BOOLEAN DEFAULT 0,
|
||||
ams_mapping TEXT,
|
||||
plate_id INTEGER,
|
||||
bed_levelling BOOLEAN DEFAULT 1,
|
||||
flow_cali BOOLEAN DEFAULT 0,
|
||||
vibration_cali BOOLEAN DEFAULT 1,
|
||||
layer_inspect BOOLEAN DEFAULT 0,
|
||||
timelapse BOOLEAN DEFAULT 0,
|
||||
use_ams BOOLEAN DEFAULT 1,
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
started_at DATETIME,
|
||||
completed_at DATETIME,
|
||||
error_message TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
)
|
||||
await conn.execute(
|
||||
text("""
|
||||
INSERT INTO print_queue_new2
|
||||
SELECT id, printer_id, archive_id, NULL, project_id, position, scheduled_time,
|
||||
manual_start, require_previous_success, auto_off_after, ams_mapping, plate_id,
|
||||
COALESCE(bed_levelling, 1), COALESCE(flow_cali, 0), COALESCE(vibration_cali, 1),
|
||||
COALESCE(layer_inspect, 0), COALESCE(timelapse, 0), COALESCE(use_ams, 1),
|
||||
status, started_at, completed_at, error_message, created_at
|
||||
FROM print_queue
|
||||
""")
|
||||
)
|
||||
await conn.execute(text("DROP TABLE print_queue"))
|
||||
await conn.execute(text("ALTER TABLE print_queue_new2 RENAME TO print_queue"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def seed_notification_templates():
|
||||
"""Seed default notification templates if they don't exist."""
|
||||
|
||||
@@ -15,7 +15,11 @@ class PrintQueueItem(Base):
|
||||
|
||||
# Links
|
||||
printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"), nullable=True)
|
||||
archive_id: Mapped[int] = mapped_column(ForeignKey("print_archives.id", ondelete="CASCADE"))
|
||||
# Either archive_id OR library_file_id must be set (archive created at print start from library file)
|
||||
archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="CASCADE"), nullable=True)
|
||||
library_file_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("library_files.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
|
||||
|
||||
# Scheduling
|
||||
@@ -57,10 +61,12 @@ class PrintQueueItem(Base):
|
||||
|
||||
# Relationships
|
||||
printer: Mapped["Printer"] = relationship()
|
||||
archive: Mapped["PrintArchive"] = relationship()
|
||||
archive: Mapped["PrintArchive | None"] = relationship()
|
||||
library_file: Mapped["LibraryFile | None"] = relationship()
|
||||
project: Mapped["Project | None"] = relationship(back_populates="queue_items")
|
||||
|
||||
|
||||
from backend.app.models.archive import PrintArchive # noqa: E402
|
||||
from backend.app.models.library import LibraryFile # noqa: E402
|
||||
from backend.app.models.printer import Printer # noqa: E402
|
||||
from backend.app.models.project import Project # noqa: E402
|
||||
|
||||
@@ -159,9 +159,10 @@ class FileMoveRequest(BaseModel):
|
||||
|
||||
|
||||
class FilePrintRequest(BaseModel):
|
||||
"""Schema for printing a file from the library."""
|
||||
"""Schema for printing a file from the library.
|
||||
|
||||
printer_id: str # Printer serial number
|
||||
Note: printer_id is passed as a query parameter, not in the body.
|
||||
"""
|
||||
|
||||
# Print options (same as archive reprint)
|
||||
plate_id: int | None = None
|
||||
@@ -218,7 +219,6 @@ class AddToQueueResult(BaseModel):
|
||||
file_id: int
|
||||
filename: str
|
||||
queue_item_id: int
|
||||
archive_id: int
|
||||
|
||||
|
||||
class AddToQueueError(BaseModel):
|
||||
|
||||
@@ -17,7 +17,9 @@ UTCDatetime = Annotated[datetime | None, PlainSerializer(serialize_utc_datetime)
|
||||
|
||||
class PrintQueueItemCreate(BaseModel):
|
||||
printer_id: int | None = None # None = unassigned, user assigns later
|
||||
archive_id: int
|
||||
# Either archive_id OR library_file_id must be provided
|
||||
archive_id: int | None = None
|
||||
library_file_id: int | None = None
|
||||
scheduled_time: datetime | None = None # None = ASAP (next when idle)
|
||||
require_previous_success: bool = False
|
||||
auto_off_after: bool = False # Power off printer after print completes
|
||||
@@ -57,7 +59,8 @@ class PrintQueueItemUpdate(BaseModel):
|
||||
class PrintQueueItemResponse(BaseModel):
|
||||
id: int
|
||||
printer_id: int | None # None = unassigned
|
||||
archive_id: int
|
||||
archive_id: int | None # None if library_file_id is set (archive created at print start)
|
||||
library_file_id: int | None # For queue items from library files
|
||||
position: int
|
||||
scheduled_time: UTCDatetime
|
||||
require_previous_success: bool
|
||||
@@ -81,8 +84,10 @@ class PrintQueueItemResponse(BaseModel):
|
||||
# Nested info for UI (populated in route)
|
||||
archive_name: str | None = None
|
||||
archive_thumbnail: str | None = None
|
||||
library_file_name: str | None = None # Name of library file (if library_file_id is set)
|
||||
library_file_thumbnail: str | None = None # Thumbnail of library file
|
||||
printer_name: str | None = None
|
||||
print_time_seconds: int | None = None # Estimated print time from archive
|
||||
print_time_seconds: int | None = None # Estimated print time from archive or library file
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -540,3 +540,197 @@ class TestQueueCancelEndpoint:
|
||||
|
||||
response = await async_client.post(f"/api/v1/queue/{item.id}/cancel")
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
class TestQueueLibraryFileSupport:
|
||||
"""Tests for queue items with library_file_id (instead of archive_id)."""
|
||||
|
||||
@pytest.fixture
|
||||
async def printer_factory(self, db_session):
|
||||
"""Factory to create test printers."""
|
||||
_counter = [0]
|
||||
|
||||
async def _create_printer(**kwargs):
|
||||
from backend.app.models.printer import Printer
|
||||
|
||||
_counter[0] += 1
|
||||
counter = _counter[0]
|
||||
|
||||
defaults = {
|
||||
"name": f"Library Test Printer {counter}",
|
||||
"ip_address": f"192.168.1.{150 + counter}",
|
||||
"serial_number": f"TESTLIB{counter:04d}",
|
||||
"access_code": "12345678",
|
||||
"model": "X1C",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
|
||||
printer = Printer(**defaults)
|
||||
db_session.add(printer)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(printer)
|
||||
return printer
|
||||
|
||||
return _create_printer
|
||||
|
||||
@pytest.fixture
|
||||
async def library_file_factory(self, db_session):
|
||||
"""Factory to create test library files."""
|
||||
_counter = [0]
|
||||
|
||||
async def _create_library_file(**kwargs):
|
||||
from backend.app.models.library import LibraryFile
|
||||
|
||||
_counter[0] += 1
|
||||
counter = _counter[0]
|
||||
|
||||
defaults = {
|
||||
"filename": f"library_test_{counter}.3mf",
|
||||
"file_path": f"/test/library/library_test_{counter}.3mf",
|
||||
"file_size": 2048,
|
||||
"file_type": "3mf",
|
||||
"file_metadata": {"print_name": f"Library Print {counter}", "print_time_seconds": 3600},
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
|
||||
lib_file = LibraryFile(**defaults)
|
||||
db_session.add(lib_file)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(lib_file)
|
||||
return lib_file
|
||||
|
||||
return _create_library_file
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_add_to_queue_with_library_file(
|
||||
self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
|
||||
):
|
||||
"""Verify item can be added to queue using library_file_id instead of archive_id."""
|
||||
printer = await printer_factory()
|
||||
lib_file = await library_file_factory()
|
||||
|
||||
data = {
|
||||
"printer_id": printer.id,
|
||||
"library_file_id": lib_file.id,
|
||||
}
|
||||
response = await async_client.post("/api/v1/queue/", json=data)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["printer_id"] == printer.id
|
||||
assert result["library_file_id"] == lib_file.id
|
||||
assert result["archive_id"] is None
|
||||
assert result["status"] == "pending"
|
||||
assert result["library_file_name"] == "Library Print 1"
|
||||
assert result["print_time_seconds"] == 3600
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_add_to_queue_library_file_with_options(
|
||||
self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
|
||||
):
|
||||
"""Verify library file queue item can have all options set."""
|
||||
printer = await printer_factory()
|
||||
lib_file = await library_file_factory()
|
||||
|
||||
data = {
|
||||
"printer_id": printer.id,
|
||||
"library_file_id": lib_file.id,
|
||||
"ams_mapping": [1, 2, -1, -1],
|
||||
"plate_id": 2,
|
||||
"bed_levelling": False,
|
||||
"timelapse": True,
|
||||
"manual_start": True,
|
||||
}
|
||||
response = await async_client.post("/api/v1/queue/", json=data)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["library_file_id"] == lib_file.id
|
||||
assert result["ams_mapping"] == [1, 2, -1, -1]
|
||||
assert result["plate_id"] == 2
|
||||
assert result["bed_levelling"] is False
|
||||
assert result["timelapse"] is True
|
||||
assert result["manual_start"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_add_to_queue_requires_archive_or_library_file(
|
||||
self, async_client: AsyncClient, printer_factory, db_session
|
||||
):
|
||||
"""Verify 400 error when neither archive_id nor library_file_id provided."""
|
||||
printer = await printer_factory()
|
||||
|
||||
data = {
|
||||
"printer_id": printer.id,
|
||||
}
|
||||
response = await async_client.post("/api/v1/queue/", json=data)
|
||||
assert response.status_code == 400
|
||||
assert (
|
||||
"archive_id" in response.json()["detail"].lower() or "library_file_id" in response.json()["detail"].lower()
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_update_queue_item_with_library_file(
|
||||
self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
|
||||
):
|
||||
"""Verify queue item with library_file_id can be updated."""
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
|
||||
printer = await printer_factory()
|
||||
lib_file = await library_file_factory()
|
||||
|
||||
# Create queue item directly
|
||||
item = PrintQueueItem(
|
||||
printer_id=printer.id,
|
||||
library_file_id=lib_file.id,
|
||||
status="pending",
|
||||
position=1,
|
||||
)
|
||||
db_session.add(item)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(item)
|
||||
|
||||
# Update the item
|
||||
response = await async_client.patch(
|
||||
f"/api/v1/queue/{item.id}",
|
||||
json={"auto_off_after": True, "plate_id": 3},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["auto_off_after"] is True
|
||||
assert result["plate_id"] == 3
|
||||
assert result["library_file_id"] == lib_file.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_list_queue_includes_library_file_info(
|
||||
self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
|
||||
):
|
||||
"""Verify queue list includes library file metadata."""
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
|
||||
printer = await printer_factory()
|
||||
lib_file = await library_file_factory(
|
||||
file_metadata={"print_name": "Custom Print Name", "print_time_seconds": 7200}
|
||||
)
|
||||
|
||||
item = PrintQueueItem(
|
||||
printer_id=printer.id,
|
||||
library_file_id=lib_file.id,
|
||||
status="pending",
|
||||
position=1,
|
||||
)
|
||||
db_session.add(item)
|
||||
await db_session.commit()
|
||||
|
||||
response = await async_client.get("/api/v1/queue/")
|
||||
assert response.status_code == 200
|
||||
items = response.json()
|
||||
assert len(items) >= 1
|
||||
|
||||
# Find our item
|
||||
our_item = next((i for i in items if i["library_file_id"] == lib_file.id), None)
|
||||
assert our_item is not None
|
||||
assert our_item["library_file_name"] == "Custom Print Name"
|
||||
assert our_item["print_time_seconds"] == 7200
|
||||
|
||||
@@ -845,7 +845,9 @@ export interface DiscoveredTasmotaDevice {
|
||||
export interface PrintQueueItem {
|
||||
id: number;
|
||||
printer_id: number | null; // null = unassigned
|
||||
archive_id: number;
|
||||
// Either archive_id OR library_file_id must be set (archive created at print start)
|
||||
archive_id: number | null;
|
||||
library_file_id: number | null;
|
||||
position: number;
|
||||
scheduled_time: string | null;
|
||||
require_previous_success: boolean;
|
||||
@@ -867,13 +869,17 @@ export interface PrintQueueItem {
|
||||
created_at: string;
|
||||
archive_name?: string | null;
|
||||
archive_thumbnail?: string | null;
|
||||
library_file_name?: string | null;
|
||||
library_file_thumbnail?: string | null;
|
||||
printer_name?: string | null;
|
||||
print_time_seconds?: number | null; // Estimated print time from archive
|
||||
print_time_seconds?: number | null; // Estimated print time from archive or library file
|
||||
}
|
||||
|
||||
export interface PrintQueueItemCreate {
|
||||
printer_id?: number | null; // null = unassigned
|
||||
archive_id: number;
|
||||
// Either archive_id OR library_file_id must be provided
|
||||
archive_id?: number | null;
|
||||
library_file_id?: number | null;
|
||||
scheduled_time?: string | null;
|
||||
require_previous_success?: boolean;
|
||||
auto_off_after?: boolean;
|
||||
@@ -2594,6 +2600,61 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ file_ids: fileIds }),
|
||||
}),
|
||||
printLibraryFile: (
|
||||
fileId: number,
|
||||
printerId: number,
|
||||
options?: {
|
||||
plate_id?: number;
|
||||
ams_mapping?: number[];
|
||||
bed_levelling?: boolean;
|
||||
flow_cali?: boolean;
|
||||
vibration_cali?: boolean;
|
||||
layer_inspect?: boolean;
|
||||
timelapse?: boolean;
|
||||
use_ams?: boolean;
|
||||
}
|
||||
) =>
|
||||
request<{ status: string; printer_id: number; archive_id: number; filename: string }>(
|
||||
`/library/files/${fileId}/print?printer_id=${printerId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: options ? JSON.stringify(options) : undefined,
|
||||
}
|
||||
),
|
||||
getLibraryFilePlates: (fileId: number) =>
|
||||
request<{
|
||||
file_id: number;
|
||||
filename: string;
|
||||
plates: Array<{
|
||||
index: number;
|
||||
name: string | null;
|
||||
objects: string[];
|
||||
has_thumbnail: boolean;
|
||||
thumbnail_url: string | null;
|
||||
print_time_seconds: number | null;
|
||||
filament_used_grams: number | null;
|
||||
filaments: Array<{
|
||||
slot_id: number;
|
||||
type: string;
|
||||
color: string;
|
||||
used_grams: number;
|
||||
used_meters: number;
|
||||
}>;
|
||||
}>;
|
||||
is_multi_plate: boolean;
|
||||
}>(`/library/files/${fileId}/plates`),
|
||||
getLibraryFileFilamentRequirements: (fileId: number, plateId?: number) =>
|
||||
request<{
|
||||
file_id: number;
|
||||
filename: string;
|
||||
filaments: Array<{
|
||||
slot_id: number;
|
||||
type: string;
|
||||
color: string;
|
||||
used_grams: number;
|
||||
used_meters: number;
|
||||
}>;
|
||||
}>(`/library/files/${fileId}/filament-requirements${plateId !== undefined ? `?plate_id=${plateId}` : ''}`),
|
||||
};
|
||||
|
||||
// AMS History types
|
||||
|
||||
@@ -12,9 +12,7 @@ import type { FilamentMappingProps } from './types';
|
||||
*/
|
||||
export function FilamentMapping({
|
||||
printerId,
|
||||
archiveId,
|
||||
selectedPlate,
|
||||
isMultiPlate,
|
||||
filamentReqs,
|
||||
manualMappings,
|
||||
onManualMappingChange,
|
||||
}: FilamentMappingProps) {
|
||||
@@ -22,13 +20,6 @@ export function FilamentMapping({
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Fetch filament requirements from the archived 3MF (filtered by plate if selected)
|
||||
const { data: filamentReqs } = useQuery({
|
||||
queryKey: ['archive-filaments', archiveId, selectedPlate],
|
||||
queryFn: () => api.getArchiveFilamentRequirements(archiveId, selectedPlate ?? undefined),
|
||||
enabled: selectedPlate !== null || !isMultiPlate,
|
||||
});
|
||||
|
||||
// Fetch printer status
|
||||
const { data: printerStatus } = useQuery({
|
||||
queryKey: ['printer-status', printerId],
|
||||
|
||||
@@ -2,21 +2,19 @@ import { Printer as PrinterIcon, Loader2, AlertCircle, Check } from 'lucide-reac
|
||||
import type { PrinterSelectorProps } from './types';
|
||||
|
||||
/**
|
||||
* Printer selection component with multiple modes:
|
||||
* - Grid mode (default): Shows printers as selectable cards (single or multi-select)
|
||||
* - Dropdown mode: Shows printers in a select dropdown (used when allowUnassigned is true)
|
||||
* Printer selection component with grid-based UI.
|
||||
* Supports single or multi-select modes.
|
||||
*/
|
||||
export function PrinterSelector({
|
||||
printers,
|
||||
selectedPrinterId,
|
||||
selectedPrinterIds = [],
|
||||
onSelect,
|
||||
selectedPrinterIds,
|
||||
onMultiSelect,
|
||||
isLoading = false,
|
||||
allowUnassigned = false,
|
||||
allowMultiple = false,
|
||||
showInactive = false,
|
||||
}: PrinterSelectorProps) {
|
||||
const activePrinters = printers.filter((p) => p.is_active);
|
||||
// Filter printers based on showInactive flag
|
||||
const displayPrinters = showInactive ? printers : printers.filter((p) => p.is_active);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -26,53 +24,17 @@ export function PrinterSelector({
|
||||
);
|
||||
}
|
||||
|
||||
// Use dropdown mode for edit scenarios (allows unassigning printer)
|
||||
if (allowUnassigned) {
|
||||
if (displayPrinters.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Printer</label>
|
||||
{printers.length === 0 ? (
|
||||
<div className="flex items-center gap-2 text-red-400 text-sm">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
No printers configured
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
className={`w-full px-3 py-2 bg-bambu-dark border rounded-lg text-white focus:border-bambu-green focus:outline-none ${
|
||||
selectedPrinterId === null ? 'border-orange-400' : 'border-bambu-dark-tertiary'
|
||||
}`}
|
||||
value={selectedPrinterId ?? ''}
|
||||
onChange={(e) => onSelect(e.target.value ? Number(e.target.value) : null)}
|
||||
>
|
||||
<option value="">-- Select a printer --</option>
|
||||
{printers.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedPrinterId === null && (
|
||||
<p className="text-xs text-orange-400 mt-1 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
Assign a printer to enable printing
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-red-400 text-sm mb-4">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
No {showInactive ? '' : 'active '}printers available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Grid mode for reprint/add-to-queue (only active printers)
|
||||
if (activePrinters.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-bambu-gray">No active printers available</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handlePrinterClick = (printerId: number) => {
|
||||
if (allowMultiple && onMultiSelect) {
|
||||
if (allowMultiple) {
|
||||
// Multi-select mode: toggle printer in selection
|
||||
if (selectedPrinterIds.includes(printerId)) {
|
||||
onMultiSelect(selectedPrinterIds.filter((id) => id !== printerId));
|
||||
@@ -80,36 +42,27 @@ export function PrinterSelector({
|
||||
onMultiSelect([...selectedPrinterIds, printerId]);
|
||||
}
|
||||
} else {
|
||||
// Single-select mode
|
||||
onSelect(printerId);
|
||||
// Single-select mode: replace selection
|
||||
onMultiSelect([printerId]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (onMultiSelect) {
|
||||
onMultiSelect(activePrinters.map((p) => p.id));
|
||||
}
|
||||
onMultiSelect(displayPrinters.map((p) => p.id));
|
||||
};
|
||||
|
||||
const handleDeselectAll = () => {
|
||||
if (onMultiSelect) {
|
||||
onMultiSelect([]);
|
||||
}
|
||||
onMultiSelect([]);
|
||||
};
|
||||
|
||||
const isSelected = (printerId: number) => {
|
||||
if (allowMultiple) {
|
||||
return selectedPrinterIds.includes(printerId);
|
||||
}
|
||||
return selectedPrinterId === printerId;
|
||||
};
|
||||
const isSelected = (printerId: number) => selectedPrinterIds.includes(printerId);
|
||||
|
||||
const selectedCount = allowMultiple ? selectedPrinterIds.length : (selectedPrinterId ? 1 : 0);
|
||||
const selectedCount = selectedPrinterIds.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 mb-6">
|
||||
{/* Multi-select header */}
|
||||
{allowMultiple && activePrinters.length > 1 && (
|
||||
{allowMultiple && displayPrinters.length > 1 && (
|
||||
<div className="flex items-center justify-between text-xs text-bambu-gray mb-2">
|
||||
<span>
|
||||
{selectedCount === 0
|
||||
@@ -117,7 +70,7 @@ export function PrinterSelector({
|
||||
: `${selectedCount} printer${selectedCount !== 1 ? 's' : ''} selected`}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
{selectedCount < activePrinters.length && (
|
||||
{selectedCount < displayPrinters.length && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectAll}
|
||||
@@ -139,7 +92,7 @@ export function PrinterSelector({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activePrinters.map((printer) => (
|
||||
{displayPrinters.map((printer) => (
|
||||
<button
|
||||
key={printer.id}
|
||||
type="button"
|
||||
@@ -148,7 +101,7 @@ export function PrinterSelector({
|
||||
isSelected(printer.id)
|
||||
? 'border-bambu-green bg-bambu-green/10'
|
||||
: 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
|
||||
}`}
|
||||
} ${!printer.is_active ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<div
|
||||
className={`p-2 rounded-lg ${
|
||||
@@ -162,7 +115,10 @@ export function PrinterSelector({
|
||||
/>
|
||||
</div>
|
||||
<div className="text-left flex-1">
|
||||
<p className="text-white font-medium">{printer.name}</p>
|
||||
<p className="text-white font-medium">
|
||||
{printer.name}
|
||||
{!printer.is_active && <span className="text-bambu-gray text-xs ml-2">(inactive)</span>}
|
||||
</p>
|
||||
<p className="text-xs text-bambu-gray">
|
||||
{printer.model || 'Unknown model'} • {printer.ip_address}
|
||||
</p>
|
||||
@@ -181,6 +137,14 @@ export function PrinterSelector({
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Warning when no printer selected */}
|
||||
{selectedCount === 0 && (
|
||||
<p className="text-xs text-orange-400 mt-1 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
Select at least one printer
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,13 +23,17 @@ import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types';
|
||||
|
||||
/**
|
||||
* Unified PrintModal component that handles three modes:
|
||||
* - 'reprint': Immediate print from archive (supports multi-printer)
|
||||
* - 'add-to-queue': Schedule print to queue (supports multi-printer)
|
||||
* - 'edit-queue-item': Edit existing queue item (single printer only)
|
||||
* - 'reprint': Immediate print from archive or library file (supports multi-printer)
|
||||
* - 'add-to-queue': Schedule print to queue from archive or library file (supports multi-printer)
|
||||
* - 'edit-queue-item': Edit existing queue item (supports multi-printer)
|
||||
*
|
||||
* Both archiveId and libraryFileId are supported. Library files can be printed immediately
|
||||
* or added to queue (archive is created at print start time, not when queued).
|
||||
*/
|
||||
export function PrintModal({
|
||||
mode,
|
||||
archiveId,
|
||||
libraryFileId,
|
||||
archiveName,
|
||||
queueItem,
|
||||
onClose,
|
||||
@@ -38,16 +42,17 @@ export function PrintModal({
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
|
||||
// Single printer selection (for edit mode and backward compatibility)
|
||||
const [selectedPrinter, setSelectedPrinter] = useState<number | null>(() => {
|
||||
if (mode === 'edit-queue-item' && queueItem) {
|
||||
return queueItem.printer_id;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
// Determine if we're printing a library file
|
||||
const isLibraryFile = !!libraryFileId && !archiveId;
|
||||
|
||||
// Multiple printer selection (for reprint and add-to-queue modes)
|
||||
const [selectedPrinters, setSelectedPrinters] = useState<number[]>([]);
|
||||
// Multiple printer selection (used for all modes now)
|
||||
const [selectedPrinters, setSelectedPrinters] = useState<number[]>(() => {
|
||||
// Initialize with the queue item's printer if editing
|
||||
if (mode === 'edit-queue-item' && queueItem?.printer_id) {
|
||||
return [queueItem.printer_id];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const [selectedPlate, setSelectedPlate] = useState<number | null>(() => {
|
||||
if (mode === 'edit-queue-item' && queueItem) {
|
||||
@@ -109,20 +114,17 @@ export function PrintModal({
|
||||
});
|
||||
|
||||
// Track initial values for clearing mappings on change (edit mode only)
|
||||
const [initialPrinterId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.printer_id : null));
|
||||
const [initialPrinterIds] = useState(() => (mode === 'edit-queue-item' && queueItem?.printer_id ? [queueItem.printer_id] : []));
|
||||
const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null));
|
||||
|
||||
// Submission state for multi-printer
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitProgress, setSubmitProgress] = useState({ current: 0, total: 0 });
|
||||
|
||||
// Determine if we're in multi-printer mode
|
||||
const isMultiPrinterMode = mode !== 'edit-queue-item';
|
||||
const effectivePrinterCount = isMultiPrinterMode ? selectedPrinters.length : (selectedPrinter ? 1 : 0);
|
||||
// Printer counts and effective printer for filament mapping
|
||||
const effectivePrinterCount = selectedPrinters.length;
|
||||
// For filament mapping, use first selected printer (mapping applies to all)
|
||||
const effectivePrinterId = isMultiPrinterMode
|
||||
? (selectedPrinters.length > 0 ? selectedPrinters[0] : null)
|
||||
: selectedPrinter;
|
||||
const effectivePrinterId = selectedPrinters.length > 0 ? selectedPrinters[0] : null;
|
||||
|
||||
// Queries
|
||||
const { data: printers, isLoading: loadingPrinters } = useQuery({
|
||||
@@ -130,17 +132,45 @@ export function PrintModal({
|
||||
queryFn: api.getPrinters,
|
||||
});
|
||||
|
||||
const { data: platesData } = useQuery({
|
||||
// Fetch plates for archives
|
||||
const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
|
||||
queryKey: ['archive-plates', archiveId],
|
||||
queryFn: () => api.getArchivePlates(archiveId),
|
||||
queryFn: () => api.getArchivePlates(archiveId!),
|
||||
enabled: !!archiveId && !isLibraryFile,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: filamentReqs } = useQuery({
|
||||
queryKey: ['archive-filaments', archiveId, selectedPlate],
|
||||
queryFn: () => api.getArchiveFilamentRequirements(archiveId, selectedPlate ?? undefined),
|
||||
enabled: selectedPlate !== null || !platesData?.is_multi_plate,
|
||||
// Fetch plates for library files
|
||||
const { data: libraryPlatesData } = useQuery({
|
||||
queryKey: ['library-file-plates', libraryFileId],
|
||||
queryFn: () => api.getLibraryFilePlates(libraryFileId!),
|
||||
enabled: isLibraryFile && !!libraryFileId,
|
||||
});
|
||||
|
||||
// Combine plates data from either source
|
||||
const platesData = isLibraryFile ? libraryPlatesData : archivePlatesData;
|
||||
|
||||
// Fetch filament requirements for archives
|
||||
const { data: archiveFilamentReqs, isError: archiveFilamentReqsError } = useQuery({
|
||||
queryKey: ['archive-filaments', archiveId, selectedPlate],
|
||||
queryFn: () => api.getArchiveFilamentRequirements(archiveId!, selectedPlate ?? undefined),
|
||||
enabled: !!archiveId && !isLibraryFile && (selectedPlate !== null || !platesData?.is_multi_plate),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Fetch filament requirements for library files (with plate support)
|
||||
const { data: libraryFilamentReqs } = useQuery({
|
||||
queryKey: ['library-file-filaments', libraryFileId, selectedPlate],
|
||||
queryFn: () => api.getLibraryFileFilamentRequirements(libraryFileId!, selectedPlate ?? undefined),
|
||||
enabled: isLibraryFile && !!libraryFileId && (selectedPlate !== null || !platesData?.is_multi_plate),
|
||||
});
|
||||
|
||||
// Track if archive data couldn't be loaded (archive deleted or file missing)
|
||||
const archiveDataMissing = !isLibraryFile && (archivePlatesError || archiveFilamentReqsError);
|
||||
|
||||
// Combine filament requirements from either source
|
||||
const effectiveFilamentReqs = isLibraryFile ? libraryFilamentReqs : archiveFilamentReqs;
|
||||
|
||||
// Only fetch printer status when single printer selected (for filament mapping)
|
||||
const { data: printerStatus } = useQuery({
|
||||
queryKey: ['printer-status', effectivePrinterId],
|
||||
@@ -149,7 +179,7 @@ export function PrintModal({
|
||||
});
|
||||
|
||||
// Get AMS mapping from hook (only when single printer selected)
|
||||
const { amsMapping } = useFilamentMapping(filamentReqs, printerStatus, manualMappings);
|
||||
const { amsMapping } = useFilamentMapping(effectiveFilamentReqs, printerStatus, manualMappings);
|
||||
|
||||
// Auto-select first plate for single-plate files
|
||||
useEffect(() => {
|
||||
@@ -158,8 +188,9 @@ export function PrintModal({
|
||||
}
|
||||
}, [platesData, selectedPlate]);
|
||||
|
||||
// Auto-select first printer when only one available (non-multi mode)
|
||||
// Auto-select first printer when only one available
|
||||
useEffect(() => {
|
||||
// Skip auto-select for edit mode (already initialized from queueItem)
|
||||
if (mode === 'edit-queue-item') return;
|
||||
const activePrinters = printers?.filter(p => p.is_active) || [];
|
||||
if (activePrinters.length === 1 && selectedPrinters.length === 0) {
|
||||
@@ -170,13 +201,15 @@ export function PrintModal({
|
||||
// Clear manual mappings when printer or plate changes
|
||||
useEffect(() => {
|
||||
if (mode === 'edit-queue-item') {
|
||||
if (selectedPrinter !== initialPrinterId || selectedPlate !== initialPlateId) {
|
||||
// For edit mode, clear mappings if printer selection or plate changed from initial
|
||||
const printersChanged = JSON.stringify(selectedPrinters.sort()) !== JSON.stringify(initialPrinterIds.sort());
|
||||
if (printersChanged || selectedPlate !== initialPlateId) {
|
||||
setManualMappings({});
|
||||
}
|
||||
} else {
|
||||
setManualMappings({});
|
||||
}
|
||||
}, [mode, selectedPrinter, selectedPrinters, selectedPlate, initialPrinterId, initialPlateId]);
|
||||
}, [mode, selectedPrinters, selectedPlate, initialPrinterIds, initialPlateId]);
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
@@ -212,29 +245,7 @@ export function PrintModal({
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
|
||||
if (mode === 'edit-queue-item') {
|
||||
// Edit mode - single printer update
|
||||
const data: PrintQueueItemUpdate = {
|
||||
printer_id: selectedPrinter,
|
||||
require_previous_success: scheduleOptions.requirePreviousSuccess,
|
||||
auto_off_after: scheduleOptions.autoOffAfter,
|
||||
manual_start: scheduleOptions.scheduleType === 'manual',
|
||||
ams_mapping: amsMapping,
|
||||
plate_id: selectedPlate,
|
||||
...printOptions,
|
||||
};
|
||||
|
||||
if (scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime) {
|
||||
data.scheduled_time = new Date(scheduleOptions.scheduledTime).toISOString();
|
||||
} else {
|
||||
data.scheduled_time = null;
|
||||
}
|
||||
|
||||
updateQueueMutation.mutate(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Multi-printer modes (reprint or add-to-queue)
|
||||
// Validate printer selection
|
||||
if (selectedPrinters.length === 0) {
|
||||
showToast('Please select at least one printer', 'error');
|
||||
return;
|
||||
@@ -249,37 +260,60 @@ export function PrintModal({
|
||||
errors: [],
|
||||
};
|
||||
|
||||
// Common queue data for add-to-queue and edit modes
|
||||
const getQueueData = (printerId: number): PrintQueueItemCreate => ({
|
||||
printer_id: printerId,
|
||||
// Use library_file_id for library files, archive_id for archives
|
||||
archive_id: isLibraryFile ? undefined : archiveId,
|
||||
library_file_id: isLibraryFile ? libraryFileId : undefined,
|
||||
require_previous_success: scheduleOptions.requirePreviousSuccess,
|
||||
auto_off_after: scheduleOptions.autoOffAfter,
|
||||
manual_start: scheduleOptions.scheduleType === 'manual',
|
||||
ams_mapping: amsMapping,
|
||||
plate_id: selectedPlate,
|
||||
scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
|
||||
? new Date(scheduleOptions.scheduledTime).toISOString()
|
||||
: undefined,
|
||||
...printOptions,
|
||||
});
|
||||
|
||||
for (let i = 0; i < selectedPrinters.length; i++) {
|
||||
const printerId = selectedPrinters[i];
|
||||
setSubmitProgress({ current: i + 1, total: selectedPrinters.length });
|
||||
|
||||
try {
|
||||
// Use the same AMS mapping for all printers (configured via UI based on first printer)
|
||||
// This assumes all printers have the same filament configuration
|
||||
if (mode === 'reprint') {
|
||||
await api.reprintArchive(archiveId, printerId, {
|
||||
plate_id: selectedPlate ?? undefined,
|
||||
ams_mapping: amsMapping,
|
||||
...printOptions,
|
||||
});
|
||||
} else {
|
||||
// add-to-queue mode
|
||||
const data: PrintQueueItemCreate = {
|
||||
// Reprint mode - start print immediately
|
||||
if (isLibraryFile) {
|
||||
await api.printLibraryFile(libraryFileId!, printerId, {
|
||||
ams_mapping: amsMapping,
|
||||
...printOptions,
|
||||
});
|
||||
} else {
|
||||
await api.reprintArchive(archiveId!, printerId, {
|
||||
plate_id: selectedPlate ?? undefined,
|
||||
ams_mapping: amsMapping,
|
||||
...printOptions,
|
||||
});
|
||||
}
|
||||
} else if (mode === 'edit-queue-item' && i === 0) {
|
||||
// Edit mode - update the original queue item for the first printer
|
||||
const updateData: PrintQueueItemUpdate = {
|
||||
printer_id: printerId,
|
||||
archive_id: archiveId,
|
||||
require_previous_success: scheduleOptions.requirePreviousSuccess,
|
||||
auto_off_after: scheduleOptions.autoOffAfter,
|
||||
manual_start: scheduleOptions.scheduleType === 'manual',
|
||||
ams_mapping: amsMapping,
|
||||
plate_id: selectedPlate,
|
||||
scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
|
||||
? new Date(scheduleOptions.scheduledTime).toISOString()
|
||||
: null,
|
||||
...printOptions,
|
||||
};
|
||||
|
||||
if (scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime) {
|
||||
data.scheduled_time = new Date(scheduleOptions.scheduledTime).toISOString();
|
||||
}
|
||||
|
||||
await addToQueueMutation.mutateAsync(data);
|
||||
await updateQueueMutation.mutateAsync(updateData);
|
||||
} else {
|
||||
// Add-to-queue mode OR edit mode with additional printers
|
||||
await addToQueueMutation.mutateAsync(getQueueData(printerId));
|
||||
}
|
||||
results.success++;
|
||||
} catch (error) {
|
||||
@@ -293,9 +327,9 @@ export function PrintModal({
|
||||
|
||||
// Show result toast
|
||||
if (results.failed === 0) {
|
||||
const action = mode === 'reprint' ? 'sent to' : 'queued for';
|
||||
const action = mode === 'reprint' ? 'sent to' : (mode === 'edit-queue-item' ? 'updated/queued for' : 'queued for');
|
||||
if (results.success === 1) {
|
||||
showToast(`Print ${action} printer`);
|
||||
showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Print ${action} printer`);
|
||||
} else {
|
||||
showToast(`Print ${action} ${results.success} printers`);
|
||||
}
|
||||
@@ -315,27 +349,22 @@ export function PrintModal({
|
||||
const canSubmit = useMemo(() => {
|
||||
if (isPending) return false;
|
||||
|
||||
// For edit mode, printer can be null (unassigned)
|
||||
if (mode === 'edit-queue-item') {
|
||||
return (printers?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
// For reprint and add-to-queue, need at least one selected printer
|
||||
// Need at least one selected printer
|
||||
if (selectedPrinters.length === 0) return false;
|
||||
|
||||
// For multi-plate files, need a selected plate
|
||||
if (isMultiPlate && !selectedPlate) return false;
|
||||
// For multi-plate archive files, need a selected plate (library files skip this)
|
||||
if (!isLibraryFile && isMultiPlate && !selectedPlate) return false;
|
||||
|
||||
return true;
|
||||
}, [mode, selectedPrinters.length, isMultiPlate, selectedPlate, isPending, printers]);
|
||||
}, [selectedPrinters.length, isMultiPlate, selectedPlate, isPending, isLibraryFile]);
|
||||
|
||||
// Modal title and action button text based on mode
|
||||
const getModalConfig = () => {
|
||||
const printerCount = isMultiPrinterMode ? selectedPrinters.length : 1;
|
||||
const printerCount = selectedPrinters.length;
|
||||
|
||||
if (mode === 'reprint') {
|
||||
return {
|
||||
title: 'Re-print',
|
||||
title: isLibraryFile ? 'Print' : 'Re-print',
|
||||
icon: Printer,
|
||||
submitText: printerCount > 1 ? `Print to ${printerCount} Printers` : 'Print',
|
||||
submitIcon: Printer,
|
||||
@@ -355,12 +384,15 @@ export function PrintModal({
|
||||
: 'Adding...',
|
||||
};
|
||||
}
|
||||
// edit-queue-item mode
|
||||
return {
|
||||
title: 'Edit Queue Item',
|
||||
icon: Pencil,
|
||||
submitText: 'Save Changes',
|
||||
submitText: 'Save',
|
||||
submitIcon: Pencil,
|
||||
loadingText: 'Saving...',
|
||||
loadingText: submitProgress.total > 1
|
||||
? `Saving ${submitProgress.current}/${submitProgress.total}...`
|
||||
: 'Saving...',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -368,8 +400,13 @@ export function PrintModal({
|
||||
const TitleIcon = modalConfig.icon;
|
||||
const SubmitIcon = modalConfig.submitIcon;
|
||||
|
||||
// Show filament mapping only when single printer selected
|
||||
const showFilamentMapping = effectivePrinterId && (isMultiPlate ? selectedPlate !== null : true);
|
||||
// Show filament mapping when:
|
||||
// - Single printer selected
|
||||
// - For archives: plate is selected (for multi-plate) or not required (single-plate)
|
||||
// - For library files: always show (no plate selection)
|
||||
const showFilamentMapping = effectivePrinterId && (
|
||||
isLibraryFile || (isMultiPlate ? selectedPlate !== null : true)
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -401,8 +438,7 @@ export function PrintModal({
|
||||
<p className={`text-sm text-bambu-gray ${mode === 'reprint' ? 'mb-4' : ''}`}>
|
||||
{mode === 'reprint' ? (
|
||||
<>
|
||||
Send <span className="text-white">{archiveName}</span> to{' '}
|
||||
{isMultiPrinterMode ? 'printer(s)' : 'a printer'}
|
||||
Send <span className="text-white">{archiveName}</span> to printer(s)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -415,17 +451,15 @@ export function PrintModal({
|
||||
{/* Printer selection */}
|
||||
<PrinterSelector
|
||||
printers={printers || []}
|
||||
selectedPrinterId={selectedPrinter}
|
||||
selectedPrinterIds={selectedPrinters}
|
||||
onSelect={setSelectedPrinter}
|
||||
onMultiSelect={setSelectedPrinters}
|
||||
isLoading={loadingPrinters}
|
||||
allowUnassigned={mode === 'edit-queue-item'}
|
||||
allowMultiple={isMultiPrinterMode}
|
||||
allowMultiple={true}
|
||||
showInactive={mode === 'edit-queue-item'}
|
||||
/>
|
||||
|
||||
{/* Multi-printer filament mapping note */}
|
||||
{isMultiPrinterMode && selectedPrinters.length > 1 && (
|
||||
{selectedPrinters.length > 1 && (
|
||||
<div className="flex items-start gap-2 p-3 mb-2 bg-blue-500/10 border border-blue-500/30 rounded-lg text-sm">
|
||||
<AlertCircle className="w-4 h-4 text-blue-400 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-blue-400">
|
||||
@@ -442,13 +476,21 @@ export function PrintModal({
|
||||
onSelect={setSelectedPlate}
|
||||
/>
|
||||
|
||||
{/* Filament mapping - show when single printer selected and plate ready */}
|
||||
{showFilamentMapping && (
|
||||
{/* Warning when archive data couldn't be loaded */}
|
||||
{archiveDataMissing && (
|
||||
<div className="flex items-start gap-2 p-3 mb-2 bg-orange-500/10 border border-orange-500/30 rounded-lg text-sm">
|
||||
<AlertCircle className="w-4 h-4 text-orange-400 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-orange-400">
|
||||
Archive data unavailable. The source file may have been deleted. Filament mapping is disabled.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filament mapping - show when printer selected and filament requirements available */}
|
||||
{showFilamentMapping && !archiveDataMissing && (
|
||||
<FilamentMapping
|
||||
printerId={effectivePrinterId!}
|
||||
archiveId={archiveId}
|
||||
selectedPlate={selectedPlate}
|
||||
isMultiPlate={isMultiPlate}
|
||||
filamentReqs={effectiveFilamentReqs}
|
||||
manualMappings={manualMappings}
|
||||
onManualMappingChange={setManualMappings}
|
||||
/>
|
||||
|
||||
@@ -10,13 +10,19 @@ export type PrintModalMode = 'reprint' | 'add-to-queue' | 'edit-queue-item';
|
||||
|
||||
/**
|
||||
* Props for the unified PrintModal component.
|
||||
*
|
||||
* Either archiveId or libraryFileId must be provided.
|
||||
* - archiveId: For reprinting/queueing archives
|
||||
* - libraryFileId: For printing library files directly
|
||||
*/
|
||||
export interface PrintModalProps {
|
||||
/** Modal operation mode */
|
||||
mode: PrintModalMode;
|
||||
/** Archive ID to print */
|
||||
archiveId: number;
|
||||
/** Archive display name */
|
||||
/** Archive ID to print (mutually exclusive with libraryFileId) */
|
||||
archiveId?: number;
|
||||
/** Library file ID to print (mutually exclusive with archiveId) */
|
||||
libraryFileId?: number;
|
||||
/** Display name for the print */
|
||||
archiveName: string;
|
||||
/** Existing queue item (only for edit-queue-item mode) */
|
||||
queueItem?: PrintQueueItem;
|
||||
@@ -103,13 +109,12 @@ export interface PlatesResponse {
|
||||
*/
|
||||
export interface PrinterSelectorProps {
|
||||
printers: Printer[];
|
||||
selectedPrinterId: number | null;
|
||||
selectedPrinterIds?: number[];
|
||||
onSelect: (printerId: number | null) => void;
|
||||
onMultiSelect?: (printerIds: number[]) => void;
|
||||
selectedPrinterIds: number[];
|
||||
onMultiSelect: (printerIds: number[]) => void;
|
||||
isLoading?: boolean;
|
||||
allowUnassigned?: boolean;
|
||||
allowMultiple?: boolean;
|
||||
/** Show inactive printers (for edit mode where original assignment may be inactive) */
|
||||
showInactive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,14 +127,26 @@ export interface PlateSelectorProps {
|
||||
onSelect: (plateIndex: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filament requirement data structure.
|
||||
*/
|
||||
export interface FilamentReqsData {
|
||||
filaments: Array<{
|
||||
slot_id: number;
|
||||
type: string;
|
||||
color: string;
|
||||
used_grams: number;
|
||||
used_meters: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the FilamentMapping component.
|
||||
*/
|
||||
export interface FilamentMappingProps {
|
||||
printerId: number;
|
||||
archiveId: number;
|
||||
selectedPlate: number | null;
|
||||
isMultiPlate: boolean;
|
||||
/** Pre-fetched filament requirements data */
|
||||
filamentReqs: FilamentReqsData | undefined;
|
||||
manualMappings: Record<number, number>;
|
||||
onManualMappingChange: (mappings: Record<number, number>) => void;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ import type {
|
||||
} from '../api/client';
|
||||
import { Button } from '../components/Button';
|
||||
import { ConfirmModal } from '../components/ConfirmModal';
|
||||
import { PrintModal } from '../components/PrintModal';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
|
||||
@@ -695,9 +696,10 @@ interface FileCardProps {
|
||||
onDelete: (id: number) => void;
|
||||
onDownload: (id: number) => void;
|
||||
onAddToQueue?: (id: number) => void;
|
||||
onPrint?: (file: LibraryFileListItem) => void;
|
||||
}
|
||||
|
||||
function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQueue }: FileCardProps) {
|
||||
function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQueue, onPrint }: FileCardProps) {
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -771,12 +773,21 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
|
||||
<div className="absolute right-0 bottom-8 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[140px]">
|
||||
{onAddToQueue && isSlicedFilename(file.filename) && (
|
||||
{onPrint && isSlicedFilename(file.filename) && (
|
||||
<button
|
||||
className="w-full px-3 py-1.5 text-left text-sm text-bambu-green hover:bg-bambu-dark flex items-center gap-2"
|
||||
onClick={() => { onAddToQueue(file.id); setShowActions(false); }}
|
||||
onClick={() => { onPrint(file); setShowActions(false); }}
|
||||
>
|
||||
<Printer className="w-3.5 h-3.5" />
|
||||
Print
|
||||
</button>
|
||||
)}
|
||||
{onAddToQueue && isSlicedFilename(file.filename) && (
|
||||
<button
|
||||
className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
|
||||
onClick={() => { onAddToQueue(file.id); setShowActions(false); }}
|
||||
>
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
Add to Queue
|
||||
</button>
|
||||
)}
|
||||
@@ -828,6 +839,7 @@ export function FileManagerPage() {
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
const [linkFolder, setLinkFolder] = useState<LibraryFolderTree | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | null>(null);
|
||||
const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
|
||||
return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
|
||||
});
|
||||
@@ -1416,6 +1428,7 @@ export function FileManagerPage() {
|
||||
onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
|
||||
onDownload={handleDownload}
|
||||
onAddToQueue={(id) => addToQueueMutation.mutate([id])}
|
||||
onPrint={setPrintFile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1506,14 +1519,23 @@ export function FileManagerPage() {
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
{isSlicedFilename(file.filename) && (
|
||||
<button
|
||||
onClick={() => addToQueueMutation.mutate([file.id])}
|
||||
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green transition-colors"
|
||||
title="Add to Queue"
|
||||
disabled={addToQueueMutation.isPending}
|
||||
>
|
||||
<Printer className="w-4 h-4" />
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
onClick={() => setPrintFile(file)}
|
||||
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green transition-colors"
|
||||
title="Print"
|
||||
>
|
||||
<Printer className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => addToQueueMutation.mutate([file.id])}
|
||||
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
|
||||
title="Add to Queue"
|
||||
disabled={addToQueueMutation.isPending}
|
||||
>
|
||||
<Clock className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDownload(file.id)}
|
||||
@@ -1598,6 +1620,20 @@ export function FileManagerPage() {
|
||||
onCancel={() => setDeleteConfirm(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{printFile && (
|
||||
<PrintModal
|
||||
mode="reprint"
|
||||
libraryFileId={printFile.id}
|
||||
archiveName={printFile.print_name || printFile.filename}
|
||||
onClose={() => setPrintFile(null)}
|
||||
onSuccess={() => {
|
||||
setPrintFile(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['library-files'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['archives'] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -167,7 +167,13 @@ function SortableQueueItem({
|
||||
<div className="w-14 h-14 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
|
||||
{item.archive_thumbnail ? (
|
||||
<img
|
||||
src={api.getArchiveThumbnail(item.archive_id)}
|
||||
src={api.getArchiveThumbnail(item.archive_id!)}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : item.library_file_thumbnail ? (
|
||||
<img
|
||||
src={api.getLibraryFileThumbnailUrl(item.library_file_id!)}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
@@ -182,15 +188,25 @@ function SortableQueueItem({
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className="text-white font-medium truncate">
|
||||
{item.archive_name || `Archive #${item.archive_id}`}
|
||||
{item.archive_name || item.library_file_name || `File #${item.archive_id || item.library_file_id}`}
|
||||
</p>
|
||||
<Link
|
||||
to={`/archives?highlight=${item.archive_id}`}
|
||||
className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
|
||||
title="View archive"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
{item.archive_id ? (
|
||||
<Link
|
||||
to={`/archives?highlight=${item.archive_id}`}
|
||||
className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
|
||||
title="View archive"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
) : item.library_file_id ? (
|
||||
<Link
|
||||
to={`/library?highlight=${item.library_file_id}`}
|
||||
className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
|
||||
title="View in File Manager"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-sm text-bambu-gray">
|
||||
@@ -472,7 +488,9 @@ export function QueuePage() {
|
||||
return [...items].sort((a, b) => {
|
||||
let cmp: number;
|
||||
if (pendingSortBy === 'name') {
|
||||
cmp = (a.archive_name || '').localeCompare(b.archive_name || '');
|
||||
const aName = a.archive_name || a.library_file_name || '';
|
||||
const bName = b.archive_name || b.library_file_name || '';
|
||||
cmp = aName.localeCompare(bName);
|
||||
} else if (pendingSortBy === 'printer') {
|
||||
cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
|
||||
} else if (pendingSortBy === 'time') {
|
||||
@@ -490,7 +508,9 @@ export function QueuePage() {
|
||||
return [...items].sort((a, b) => {
|
||||
let cmp: number;
|
||||
if (historySortBy === 'name') {
|
||||
cmp = (a.archive_name || '').localeCompare(b.archive_name || '');
|
||||
const aName = a.archive_name || a.library_file_name || '';
|
||||
const bName = b.archive_name || b.library_file_name || '';
|
||||
cmp = aName.localeCompare(bName);
|
||||
} else if (historySortBy === 'printer') {
|
||||
cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
|
||||
} else {
|
||||
@@ -803,8 +823,9 @@ export function QueuePage() {
|
||||
{editItem && (
|
||||
<PrintModal
|
||||
mode="edit-queue-item"
|
||||
archiveId={editItem.archive_id}
|
||||
archiveName={editItem.archive_name || `Archive #${editItem.archive_id}`}
|
||||
archiveId={editItem.archive_id ?? undefined}
|
||||
libraryFileId={editItem.library_file_id ?? undefined}
|
||||
archiveName={editItem.archive_name || editItem.library_file_name || `File #${editItem.archive_id || editItem.library_file_id}`}
|
||||
queueItem={editItem}
|
||||
onClose={() => setEditItem(null)}
|
||||
/>
|
||||
@@ -814,8 +835,9 @@ export function QueuePage() {
|
||||
{requeueItem && (
|
||||
<PrintModal
|
||||
mode="add-to-queue"
|
||||
archiveId={requeueItem.archive_id}
|
||||
archiveName={requeueItem.archive_name || `Archive #${requeueItem.archive_id}`}
|
||||
archiveId={requeueItem.archive_id ?? undefined}
|
||||
libraryFileId={requeueItem.library_file_id ?? undefined}
|
||||
archiveName={requeueItem.archive_name || requeueItem.library_file_name || `File #${requeueItem.archive_id || requeueItem.library_file_id}`}
|
||||
onClose={() => setRequeueItem(null)}
|
||||
/>
|
||||
)}
|
||||
@@ -830,10 +852,10 @@ export function QueuePage() {
|
||||
}
|
||||
message={
|
||||
confirmAction.type === 'cancel'
|
||||
? `Are you sure you want to cancel "${confirmAction.item.archive_name || 'this print'}"?`
|
||||
? `Are you sure you want to cancel "${confirmAction.item.archive_name || confirmAction.item.library_file_name || 'this print'}"?`
|
||||
: confirmAction.type === 'stop'
|
||||
? `Are you sure you want to stop the current print "${confirmAction.item.archive_name || 'this print'}"? This will cancel the print job on the printer.`
|
||||
: `Are you sure you want to remove "${confirmAction.item.archive_name || 'this item'}" from the queue history?`
|
||||
? `Are you sure you want to stop the current print "${confirmAction.item.archive_name || confirmAction.item.library_file_name || 'this print'}"? This will cancel the print job on the printer.`
|
||||
: `Are you sure you want to remove "${confirmAction.item.archive_name || confirmAction.item.library_file_name || 'this item'}" from the queue history?`
|
||||
}
|
||||
confirmText={
|
||||
confirmAction.type === 'cancel' ? 'Cancel Print' :
|
||||
|
||||
@@ -52,12 +52,12 @@ def on_message(client, userdata, msg):
|
||||
|
||||
# Always log calibration messages with full detail
|
||||
if is_cali_msg:
|
||||
print(f"\n{'='*80}")
|
||||
print(f"\n{'=' * 80}")
|
||||
print(f"[{datetime.now().strftime('%H:%M:%S.%f')[:-3]}] *** CALIBRATION COMMAND: {command} ***")
|
||||
print(f"Topic: {msg.topic}")
|
||||
print("Full payload:")
|
||||
print(json.dumps(payload, indent=2))
|
||||
print(f"{'='*80}\n")
|
||||
print(f"{'=' * 80}\n")
|
||||
else:
|
||||
# For other messages, just show a brief summary
|
||||
if "print" in payload:
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -23,8 +23,8 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-DE9m9l_f.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-t4RhRNeD.css">
|
||||
<script type="module" crossorigin src="/assets/index-BJM8SBBV.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-5D76qqt8.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user