mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
Added project page viewer and editor
This commit is contained in:
@@ -888,3 +888,89 @@ async def reprint_archive(
|
||||
"archive_id": archive_id,
|
||||
"filename": archive.filename,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Project Page API
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/{archive_id}/project-page")
|
||||
async def get_project_page(archive_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Get the project page data from the 3MF file."""
|
||||
from backend.app.services.archive import ProjectPageParser
|
||||
from backend.app.schemas.archive import ProjectPageResponse
|
||||
|
||||
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, "Archive file not found")
|
||||
|
||||
parser = ProjectPageParser(file_path)
|
||||
data = parser.parse(archive_id)
|
||||
|
||||
return ProjectPageResponse(**data)
|
||||
|
||||
|
||||
@router.patch("/{archive_id}/project-page")
|
||||
async def update_project_page(
|
||||
archive_id: int,
|
||||
update_data: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update project page metadata in the 3MF file."""
|
||||
from backend.app.services.archive import ProjectPageParser
|
||||
|
||||
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, "Archive file not found")
|
||||
|
||||
parser = ProjectPageParser(file_path)
|
||||
success = parser.update_metadata(update_data)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(500, "Failed to update project page")
|
||||
|
||||
# Return updated data
|
||||
data = parser.parse(archive_id)
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/{archive_id}/project-image/{image_path:path}")
|
||||
async def get_project_image(
|
||||
archive_id: int,
|
||||
image_path: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get an image from the 3MF project page."""
|
||||
from backend.app.services.archive import ProjectPageParser
|
||||
|
||||
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, "Archive file not found")
|
||||
|
||||
parser = ProjectPageParser(file_path)
|
||||
result = parser.get_image(image_path)
|
||||
|
||||
if not result:
|
||||
raise HTTPException(404, "Image not found in 3MF file")
|
||||
|
||||
image_data, content_type = result
|
||||
return Response(
|
||||
content=image_data,
|
||||
media_type=content_type,
|
||||
headers={"Cache-Control": "max-age=3600"},
|
||||
)
|
||||
|
||||
@@ -65,3 +65,52 @@ class ArchiveStats(BaseModel):
|
||||
total_cost: float
|
||||
prints_by_filament_type: dict
|
||||
prints_by_printer: dict
|
||||
|
||||
|
||||
class ProjectPageImage(BaseModel):
|
||||
"""Image embedded in 3MF project page."""
|
||||
name: str
|
||||
path: str # Path within 3MF
|
||||
url: str # API URL to fetch image
|
||||
|
||||
|
||||
class ProjectPageResponse(BaseModel):
|
||||
"""Project page data extracted from 3MF file."""
|
||||
# Model info
|
||||
title: str | None = None
|
||||
description: str | None = None # HTML content
|
||||
designer: str | None = None
|
||||
designer_user_id: str | None = None
|
||||
license: str | None = None
|
||||
copyright: str | None = None
|
||||
creation_date: str | None = None
|
||||
modification_date: str | None = None
|
||||
origin: str | None = None # "original" or "remix"
|
||||
|
||||
# Profile info
|
||||
profile_title: str | None = None
|
||||
profile_description: str | None = None
|
||||
profile_cover: str | None = None
|
||||
profile_user_id: str | None = None
|
||||
profile_user_name: str | None = None
|
||||
|
||||
# MakerWorld info
|
||||
design_model_id: str | None = None
|
||||
design_profile_id: str | None = None
|
||||
design_region: str | None = None
|
||||
|
||||
# Images
|
||||
model_pictures: list[ProjectPageImage] = []
|
||||
profile_pictures: list[ProjectPageImage] = []
|
||||
thumbnails: list[ProjectPageImage] = []
|
||||
|
||||
|
||||
class ProjectPageUpdate(BaseModel):
|
||||
"""Update project page data in 3MF file."""
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
designer: str | None = None
|
||||
license: str | None = None
|
||||
copyright: str | None = None
|
||||
profile_title: str | None = None
|
||||
profile_description: str | None = None
|
||||
|
||||
@@ -264,6 +264,206 @@ class ThreeMFParser:
|
||||
break
|
||||
|
||||
|
||||
class ProjectPageParser:
|
||||
"""Parser for extracting project page data from Bambu Lab 3MF files."""
|
||||
|
||||
def __init__(self, file_path: Path):
|
||||
self.file_path = file_path
|
||||
|
||||
def parse(self, archive_id: int) -> dict:
|
||||
"""Extract project page metadata and images from 3MF file."""
|
||||
import html
|
||||
import re
|
||||
|
||||
result = {
|
||||
"title": None,
|
||||
"description": None,
|
||||
"designer": None,
|
||||
"designer_user_id": None,
|
||||
"license": None,
|
||||
"copyright": None,
|
||||
"creation_date": None,
|
||||
"modification_date": None,
|
||||
"origin": None,
|
||||
"profile_title": None,
|
||||
"profile_description": None,
|
||||
"profile_cover": None,
|
||||
"profile_user_id": None,
|
||||
"profile_user_name": None,
|
||||
"design_model_id": None,
|
||||
"design_profile_id": None,
|
||||
"design_region": None,
|
||||
"model_pictures": [],
|
||||
"profile_pictures": [],
|
||||
"thumbnails": [],
|
||||
}
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(self.file_path, "r") as zf:
|
||||
# Parse 3D/3dmodel.model for metadata
|
||||
model_path = "3D/3dmodel.model"
|
||||
if model_path in zf.namelist():
|
||||
content = zf.read(model_path).decode("utf-8", errors="ignore")
|
||||
|
||||
# Extract metadata elements using regex
|
||||
# Format: <metadata name="Key">Value</metadata> or <metadata name="Key" />
|
||||
metadata_pattern = r'<metadata\s+name="([^"]+)"[^>]*>([^<]*)</metadata>'
|
||||
matches = re.findall(metadata_pattern, content)
|
||||
|
||||
field_mapping = {
|
||||
"Title": "title",
|
||||
"Description": "description",
|
||||
"Designer": "designer",
|
||||
"DesignerUserId": "designer_user_id",
|
||||
"License": "license",
|
||||
"Copyright": "copyright",
|
||||
"CreationDate": "creation_date",
|
||||
"ModificationDate": "modification_date",
|
||||
"Origin": "origin",
|
||||
"ProfileTitle": "profile_title",
|
||||
"ProfileDescription": "profile_description",
|
||||
"ProfileCover": "profile_cover",
|
||||
"ProfileUserId": "profile_user_id",
|
||||
"ProfileUserName": "profile_user_name",
|
||||
"DesignModelId": "design_model_id",
|
||||
"DesignProfileId": "design_profile_id",
|
||||
"DesignRegion": "design_region",
|
||||
}
|
||||
|
||||
for name, value in matches:
|
||||
if name in field_mapping:
|
||||
# Decode HTML entities multiple times (content is often triple-encoded)
|
||||
decoded = value.strip()
|
||||
prev = None
|
||||
while prev != decoded:
|
||||
prev = decoded
|
||||
decoded = html.unescape(decoded)
|
||||
# Normalize non-breaking spaces to regular spaces
|
||||
decoded = decoded.replace('\xa0', ' ')
|
||||
result[field_mapping[name]] = decoded if decoded else None
|
||||
|
||||
# List images in Auxiliaries folder
|
||||
from urllib.parse import quote
|
||||
for name in zf.namelist():
|
||||
if name.startswith("Auxiliaries/Model Pictures/"):
|
||||
filename = name.split("/")[-1]
|
||||
if filename:
|
||||
result["model_pictures"].append({
|
||||
"name": filename,
|
||||
"path": name,
|
||||
"url": f"/api/v1/archives/{archive_id}/project-image/{quote(name, safe='')}",
|
||||
})
|
||||
elif name.startswith("Auxiliaries/Profile Pictures/"):
|
||||
filename = name.split("/")[-1]
|
||||
if filename:
|
||||
result["profile_pictures"].append({
|
||||
"name": filename,
|
||||
"path": name,
|
||||
"url": f"/api/v1/archives/{archive_id}/project-image/{quote(name, safe='')}",
|
||||
})
|
||||
elif name.startswith("Auxiliaries/.thumbnails/"):
|
||||
filename = name.split("/")[-1]
|
||||
if filename:
|
||||
result["thumbnails"].append({
|
||||
"name": filename,
|
||||
"path": name,
|
||||
"url": f"/api/v1/archives/{archive_id}/project-image/{quote(name, safe='')}",
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
result["_error"] = str(e)
|
||||
|
||||
return result
|
||||
|
||||
def get_image(self, image_path: str) -> tuple[bytes, str] | None:
|
||||
"""Extract an image from the 3MF file.
|
||||
|
||||
Returns tuple of (image_data, content_type) or None if not found.
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(self.file_path, "r") as zf:
|
||||
if image_path in zf.namelist():
|
||||
data = zf.read(image_path)
|
||||
# Determine content type from extension
|
||||
ext = image_path.lower().split(".")[-1]
|
||||
content_types = {
|
||||
"png": "image/png",
|
||||
"jpg": "image/jpeg",
|
||||
"jpeg": "image/jpeg",
|
||||
"webp": "image/webp",
|
||||
"gif": "image/gif",
|
||||
}
|
||||
content_type = content_types.get(ext, "application/octet-stream")
|
||||
return (data, content_type)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def update_metadata(self, updates: dict) -> bool:
|
||||
"""Update project page metadata in the 3MF file.
|
||||
|
||||
Args:
|
||||
updates: Dict with fields to update (title, description, designer, etc.)
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise.
|
||||
"""
|
||||
import html
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
try:
|
||||
# Read the 3MF file
|
||||
with zipfile.ZipFile(self.file_path, "r") as zf_read:
|
||||
# Find and read the 3dmodel.model file
|
||||
model_path = "3D/3dmodel.model"
|
||||
if model_path not in zf_read.namelist():
|
||||
return False
|
||||
|
||||
content = zf_read.read(model_path).decode("utf-8")
|
||||
|
||||
# Update metadata fields
|
||||
field_mapping = {
|
||||
"title": "Title",
|
||||
"description": "Description",
|
||||
"designer": "Designer",
|
||||
"license": "License",
|
||||
"copyright": "Copyright",
|
||||
"profile_title": "ProfileTitle",
|
||||
"profile_description": "ProfileDescription",
|
||||
}
|
||||
|
||||
for field, xml_name in field_mapping.items():
|
||||
if field in updates and updates[field] is not None:
|
||||
new_value = html.escape(updates[field])
|
||||
# Replace existing metadata or we'd need to add it
|
||||
pattern = rf'(<metadata\s+name="{xml_name}"[^>]*>)[^<]*(</metadata>)'
|
||||
replacement = rf'\g<1>{new_value}\g<2>'
|
||||
content = re.sub(pattern, replacement, content)
|
||||
|
||||
# Write to a temporary file first
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf") as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
|
||||
# Create new zip with updated content
|
||||
with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf_write:
|
||||
for item in zf_read.namelist():
|
||||
if item == model_path:
|
||||
zf_write.writestr(item, content.encode("utf-8"))
|
||||
else:
|
||||
zf_write.writestr(item, zf_read.read(item))
|
||||
|
||||
# Replace original file with updated one
|
||||
shutil.move(tmp_path, self.file_path)
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
# Clean up temp file if it exists
|
||||
if "tmp_path" in locals() and tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
return False
|
||||
|
||||
|
||||
class ArchiveService:
|
||||
"""Service for archiving print jobs."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user