Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions backend/alembic/versions/002_custom_projects.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Add custom projects support: is_custom column on projects, project_parts table, ldraw_part_index table.

Revision ID: 002_custom_projects
Revises: 001_initial
Create Date: 2026-02-23
"""
from typing import Sequence, Union
from alembic import op
from sqlalchemy import inspect
import sqlalchemy as sa


revision: str = "002_custom_projects"
down_revision: Union[str, None] = "001_initial"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def _column_exists(conn, table: str, column: str) -> bool:
cols = [c["name"] for c in inspect(conn).get_columns(table)]
return column in cols


def _tables_exist(conn):
return set(inspect(conn).get_table_names())


def upgrade() -> None:
conn = op.get_bind()
existing = _tables_exist(conn)

# Add is_custom column to projects if not present
if "projects" in existing and not _column_exists(conn, "projects", "is_custom"):
op.add_column("projects", sa.Column("is_custom", sa.Boolean(), nullable=False, server_default="0"))

if "project_parts" not in existing:
op.create_table(
"project_parts",
sa.Column("id", sa.String(), nullable=False),
sa.Column("project_id", sa.String(), nullable=False),
sa.Column("part_num", sa.String(), nullable=False),
sa.Column("quantity", sa.Integer(), nullable=False, server_default="1"),
sa.Column("color", sa.String(), nullable=True),
sa.Column("color_rgb", sa.String(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_project_parts_project_id"), "project_parts", ["project_id"], unique=False)

if "ldraw_part_index" not in existing:
op.create_table(
"ldraw_part_index",
sa.Column("part_num", sa.String(), nullable=False),
sa.Column("description", sa.String(), nullable=True),
sa.Column("indexed_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("part_num"),
)


def downgrade() -> None:
op.drop_table("ldraw_part_index")
op.drop_index(op.f("ix_project_parts_project_id"), table_name="project_parts")
op.drop_table("project_parts")
# SQLite doesn't support DROP COLUMN in older versions; skip for now
92 changes: 91 additions & 1 deletion backend/api/integrations/ldraw.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
import logging
import asyncio
import re
from datetime import datetime
from pathlib import Path
from typing import Optional
from typing import Optional, List, Dict
import aiohttp
import zipfile
import io
Expand All @@ -16,6 +17,95 @@
PARTS_UPDATE_PATTERN = re.compile(r"Parts Update\s+(\d{4}-\d{2})", re.IGNORECASE)


def _parse_dat_description(path: Path) -> Optional[str]:
"""Return the description from the first line of an LDraw .dat file.

LDraw .dat files start with a line like:
0 Brick 2 x 4
where '0' is the line type and the rest is the description.
"""
try:
with path.open("r", encoding="utf-8", errors="replace") as fh:
for raw in fh:
line = raw.strip()
if not line:
continue
# LDraw line type 0 = meta/comment; first such line is the description.
# Skip pure meta-keywords: comment markers (//) and file references (FILE).
_SKIP_PREFIXES = ("//", "FILE", "!LDRAW_ORG", "!LICENSE", "!CATEGORY", "!KEYWORDS", "!HISTORY")
parts = line.split(None, 1)
if parts and parts[0] == "0" and len(parts) > 1:
desc = parts[1].strip()
if desc and not any(desc.startswith(p) for p in _SKIP_PREFIXES):
return desc
break
except Exception:
pass
return None


def build_ldraw_part_index(db, parts_dir: Optional[Path] = None) -> int:
"""Scan the LDraw parts directory and populate the ldraw_part_index table.

Returns the number of parts indexed.
"""
from backend.database import LDrawPartIndex

if parts_dir is None:
parts_dir = settings.ldraw_library_path / "parts"

if not parts_dir.exists():
logger.warning(f"LDraw parts directory not found: {parts_dir}")
return 0

now = datetime.utcnow()
count = 0

for dat_file in sorted(parts_dir.glob("*.dat")):
part_num = dat_file.stem.lower()
description = _parse_dat_description(dat_file)
# Upsert: update description if part already indexed
existing = db.query(LDrawPartIndex).filter(LDrawPartIndex.part_num == part_num).first()
if existing:
existing.description = description
existing.indexed_at = now
else:
db.add(LDrawPartIndex(part_num=part_num, description=description, indexed_at=now))
count += 1
if count % 500 == 0:
db.flush()

db.commit()
logger.info(f"Indexed {count} LDraw parts")
return count


def search_ldraw_part_index(db, query: str, limit: int = 20) -> List[Dict]:
"""Search the ldraw_part_index table by part_num or description.

Returns a list of dicts with 'part_num' and 'description'.
"""
from backend.database import LDrawPartIndex
from sqlalchemy import or_

q = query.strip().lower()
if not q:
return []

rows = (
db.query(LDrawPartIndex)
.filter(
or_(
LDrawPartIndex.part_num.ilike(f"%{q}%"),
LDrawPartIndex.description.ilike(f"%{q}%"),
)
)
.limit(limit)
.all()
)
return [{"part_num": r.part_num, "description": r.description} for r in rows]


class LDrawManager:
"""Manages LDraw parts library and conversions."""

Expand Down
Loading