From aebe8f6cc3b01166bc925031e7a61b01c83e35bc Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Fri, 11 Sep 2026 08:15:00 +0200 Subject: [PATCH] Accept a KiCad project zip as the upload unit for hierarchical sheets. Sheets, optional BOM, and PCB are extracted together; a storage round-trip test proves the pipeline can re-parse child Sheetfiles. Co-authored-by: Cursor --- backend/pinscopex/netlist_bundle.py | 20 +++- backend/routers/projects.py | 46 +++++++ frontend/content/changelog.md | 7 ++ frontend/package.json | 2 +- .../dashboard/create-project-dialog.tsx | 112 ++++++++++++++---- frontend/src/lib/api.ts | 6 + frontend/src/lib/version.ts | 2 +- tests/test_netlist_bundle.py | 61 ++++++++++ 8 files changed, 232 insertions(+), 24 deletions(-) diff --git a/backend/pinscopex/netlist_bundle.py b/backend/pinscopex/netlist_bundle.py index 3dd32ce..8066dd2 100644 --- a/backend/pinscopex/netlist_bundle.py +++ b/backend/pinscopex/netlist_bundle.py @@ -25,6 +25,7 @@ class NetlistUpload: work_dir: Path pcb: Path | None extra_sch: list[Path] + bom: Path | None = None def sniff_netlist_kind(content: bytes) -> str: @@ -68,6 +69,8 @@ _KEEP_SUFFIX = { ".edf", ".asc", ".net", + ".csv", + ".xlsx", } @@ -128,6 +131,18 @@ def find_kicad_pcb(work: Path) -> Path | None: return hits[0] if hits else None +def find_bom(work: Path) -> Path | None: + """Prefer a shallow BOM path (KiCad project root over nested copies).""" + hits = [ + p for p in work.rglob("*") + if p.is_file() and p.suffix.lower() in {".csv", ".xlsx"} + ] + if not hits: + return None + hits.sort(key=lambda p: (len(p.relative_to(work).parts), p.name.lower())) + return hits[0] + + def _sheetfiles_of(path: Path) -> list[str]: from backend.pinscopex.parsers_kicad import _parse_sexp, _sheetfiles, _tag @@ -222,11 +237,14 @@ def materialize_netlist_upload( root = _pick_root(dest) pcb = find_kicad_pcb(dest) + bom = find_bom(dest) extras = [ p for p in work_sch_files(dest) if p.resolve() != root.resolve() ] - return NetlistUpload(root=root, work_dir=dest, pcb=pcb, extra_sch=extras) + return NetlistUpload( + root=root, work_dir=dest, pcb=pcb, extra_sch=extras, bom=bom, + ) def work_sch_files(dest: Path) -> list[Path]: diff --git a/backend/routers/projects.py b/backend/routers/projects.py index 1a282c9..dfa5afb 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -18,6 +18,32 @@ from backend.services import projects as proj_svc router = APIRouter(tags=["projects"]) +def _bom_file_to_csv_bytes(path: Path) -> bytes | None: + """Normalize a BOM found inside a KiCad zip/folder to CSV bytes.""" + suffix = path.suffix.lower() + raw = path.read_bytes() + if suffix == ".csv": + return raw + if suffix == ".xlsx": + try: + import csv as csv_mod + import io + + import openpyxl + + wb = openpyxl.load_workbook(io.BytesIO(raw), read_only=True, data_only=True) + ws = wb.active + out = io.StringIO() + writer = csv_mod.writer(out) + for row in ws.iter_rows(values_only=True): + writer.writerow([("" if c is None else str(c)) for c in row]) + wb.close() + return out.getvalue().encode("utf-8") + except Exception: + return None + return None + + # --- Library check --- @@ -515,6 +541,9 @@ async def upload_netlist( raise HTTPException(400, "No netlist file uploaded") sub_designs: list[dict] = [] + bom_saved = False + pcb_saved = False + sheets = 1 try: with tempfile.TemporaryDirectory() as tmp: parsed = materialize_netlist_upload(blobs, Path(tmp) / "work") @@ -530,10 +559,24 @@ async def upload_netlist( proj_svc.save_companion_sheets( storage, user_id, project_id, parsed.root, parsed.extra_sch, ) + sheets = 1 + len(parsed.extra_sch) else: proj_svc.clear_companion_sheets(storage, user_id, project_id) if parsed.pcb is not None: proj_svc.save_pcb(storage, user_id, project_id, parsed.pcb.read_bytes()) + pcb_saved = True + if parsed.bom is not None: + bom_bytes = _bom_file_to_csv_bytes(parsed.bom) + if bom_bytes: + proj_svc.save_bom(storage, user_id, project_id, bom_bytes) + proj_svc.update_project( + storage, user_id, project_id, + bom_columns={ + "reference": "Reference", + "mpn": "Manufacturer Part Number", + }, + ) + bom_saved = True except HTTPException: raise except Exception as e: @@ -549,6 +592,9 @@ async def upload_netlist( "format": fmt, "sub_designs": sub_designs, "designator_pins": designator_pins, + "pcb_saved": pcb_saved, + "bom_saved": bom_saved, + "sheets": sheets, } diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index 08da673..6356f83 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,13 @@ What's new in Pinscope. +## 2.26.3 — 2026-09-11 — KiCad project zip upload + +One zip (or every `.kicad_sch`) covers hierarchical sheets. If the zip has `bom.csv` and `.kicad_pcb`, those are taken too. Pipeline re-reads companions from storage. + +- [New] Zip → sheets + optional BOM/PCB. Nested `Sheetfile` paths kept. +- [Test] Upload → workspace re-parse finds child sheet parts. + ## 2.26.2 — 2026-09-11 — Three file boxes New project: BOM, schematic, and PCB on the first screen. PCB is optional. diff --git a/frontend/package.json b/frontend/package.json index 57f9202..ba0d3f4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "2.26.2", + "version": "2.26.3", "private": true, "scripts": { "sync-version": "node scripts/sync-version.mjs", diff --git a/frontend/src/components/dashboard/create-project-dialog.tsx b/frontend/src/components/dashboard/create-project-dialog.tsx index ffa5ad2..1e03040 100644 --- a/frontend/src/components/dashboard/create-project-dialog.tsx +++ b/frontend/src/components/dashboard/create-project-dialog.tsx @@ -590,6 +590,8 @@ export function CreateProjectDialog({ const [netlistFiles, setNetlistFiles] = useState([]); const netlistFile = netlistFiles[0] ?? null; const [pcbFile, setPcbFile] = useState(null); + const [bomFromBundle, setBomFromBundle] = useState(false); + const [pcbFromBundle, setPcbFromBundle] = useState(false); const [netlistNetCount, setNetlistNetCount] = useState(null); const [netlistError, setNetlistError] = useState(null); @@ -922,8 +924,15 @@ export function CreateProjectDialog({ }, []); const handleNetlistChange = useCallback((incoming: File[]) => { - const { netlist, pcb } = splitProjectFiles(incoming); - if (pcb[0]) setPcbFile(pcb[0]); + const { netlist, pcb, bom } = splitProjectFiles(incoming); + if (pcb[0]) { + setPcbFile(pcb[0]); + setPcbFromBundle(false); + } + if (bom[0]) { + handleBomChange([bom[0]]); + setBomFromBundle(false); + } const files = netlist; const file = files[0] || null; setNetlistFiles(files); @@ -969,7 +978,7 @@ export function CreateProjectDialog({ ); } }); - }, []); + }, [handleBomChange]); const resetAndClose = useCallback(() => { setOpen(false); @@ -978,6 +987,8 @@ export function CreateProjectDialog({ setBomFile(null); setNetlistFiles([]); setPcbFile(null); + setBomFromBundle(false); + setPcbFromBundle(false); setNetlistNetCount(null); setNetlistError(null); setCsvData(null); @@ -1584,6 +1595,8 @@ export function CreateProjectDialog({ const runEarlyNetlistUpload = useCallback(async (): Promise<{ ok: boolean; subDesigns: EdifSubDesign[]; + bomSaved?: boolean; + pcbSaved?: boolean; }> => { if (!netlistFile) return { ok: false, subDesigns: [] }; setEarlyUploading(true); @@ -1602,12 +1615,23 @@ export function CreateProjectDialog({ if (result.designator_pins.length > 0) { setNetlistPreview(result.designator_pins); } + if (result.nets > 0) setNetlistNetCount(result.nets); + if (result.pcb_saved) setPcbFromBundle(true); + if (result.bom_saved) { + const bom = await downloadProjectBom(projectId); + handleBomChange([bom]); + setBomFromBundle(true); + setBomUploadedEarly(true); + } setNetlistUploadedEarly(true); setInitialNetlistFiles(netlistFiles); - return { ok: true, subDesigns: result.sub_designs }; + return { + ok: true, + subDesigns: result.sub_designs, + bomSaved: Boolean(result.bom_saved), + pcbSaved: Boolean(result.pcb_saved), + }; } catch (e) { - // Only delete the project if we created it just now — leave LCSC's - // earlier draft alone. if (createdProjectIdHere) { try { await deleteProject(createdProjectIdHere); @@ -1623,8 +1647,14 @@ export function CreateProjectDialog({ } finally { setEarlyUploading(false); } - }, [netlistFile, netlistFiles, existingProjectId, name]); + }, [netlistFile, netlistFiles, existingProjectId, name, handleBomChange]); + const needsEarlyKiCadUpload = Boolean( + netlistFile && + (netlistFile.name.toLowerCase().endsWith(".zip") || + netlistFiles.length > 1 || + netlistFiles.some((f) => f.name.toLowerCase().endsWith(".kicad_sch"))), + ); // ---- LCSC per-row passive resolve ---- // // Walks the lcscPassives list in batches of LCSC_RESOLVE_CONCURRENCY, @@ -1738,7 +1768,13 @@ export function CreateProjectDialog({ }, [step, lcscResolving, lcscPassives.length, hasIcs, hasSimple, hasPassives]); const canAdvance = (): boolean => { - if (step === "details") return !!(name.trim() && bomFile && netlistFile && !netlistError); + if (step === "details") { + const hasBomSlot = + Boolean(bomFile) || + bomFromBundle || + Boolean(netlistFile?.name.toLowerCase().endsWith(".zip")); + return !!(name.trim() && hasBomSlot && netlistFile && !netlistError); + } if (step === "columns") return !!(refCol && mpnCol); if (step === "subdesigns") return !!(selectedSubdesignIds && selectedSubdesignIds.size > 0); @@ -1772,12 +1808,18 @@ export function CreateProjectDialog({ // the server) and when already done. if ( !rerunProject && - netlistIsEdif && !netlistUploadedEarly && - netlistFile + netlistFile && + (netlistIsEdif || needsEarlyKiCadUpload) ) { const res = await runEarlyNetlistUpload(); if (!res.ok) return; + if (needsEarlyKiCadUpload && !bomFile && !res.bomSaved) { + setEarlyUploadError( + "No BOM in that zip. Add a .csv in the BOM box, or include bom.csv in the project zip.", + ); + return; + } } setStep("columns"); } @@ -1917,21 +1959,39 @@ export function CreateProjectDialog({ // projects, the project already owns prior artifacts and should be // preserved on failure. try { - if (!projectAlreadyExists || bomFile !== initialBomFile) { + if ( + bomFile && + (!projectAlreadyExists || bomFile !== initialBomFile) && + !bomFromBundle + ) { setProgress("Uploading BOM..."); await uploadBom( project.id, - bomFile!, + bomFile, refCol || undefined, mpnCol || undefined, ); + } else if (bomFromBundle && existingProjectId && refCol && mpnCol) { + // Columns may have been adjusted after the zip BOM was saved — + // re-upload so the pipeline uses the confirmed mapping. + if (bomFile) { + setProgress("Uploading BOM..."); + await uploadBom( + project.id, + bomFile, + refCol || undefined, + mpnCol || undefined, + ); + } } if (!projectAlreadyExists || netlistFiles !== initialNetlistFiles) { - setProgress("Uploading netlist..."); - await uploadNetlist(project.id, netlistFiles); + if (!netlistUploadedEarly) { + setProgress("Uploading netlist..."); + await uploadNetlist(project.id, netlistFiles); + } } - if (pcbFile) { + if (pcbFile && !pcbFromBundle) { setProgress("Uploading PCB..."); await uploadPcb(project.id, pcbFile); } @@ -2118,15 +2178,20 @@ export function CreateProjectDialog({ label="BOM" accept=".csv,.xlsx" files={bomFile ? [bomFile] : []} - onFilesChange={handleBomChange} + onFilesChange={(files) => { + setBomFromBundle(false); + handleBomChange(files); + }} />

- .csv or .xlsx + {bomFromBundle + ? "Taken from the KiCad zip" + : ".csv / .xlsx — or inside the KiCad zip"}

) : (

- Netlist or .kicad_sch + Zip del progetto, o tutti i .kicad_sch

)}
@@ -2153,16 +2218,21 @@ export function CreateProjectDialog({ accept=".kicad_pcb" files={pcbFile ? [pcbFile] : []} onFilesChange={(files) => { - const picked = splitProjectFiles(files).pcb[0] ?? files[0] ?? null; + setPcbFromBundle(false); + const picked = + splitProjectFiles(files).pcb[0] ?? files[0] ?? null; setPcbFile( picked?.name.toLowerCase().endsWith(".kicad_pcb") ? picked : null, ); }} + preloaded={ + pcbFromBundle && !pcbFile ? ["from project zip"] : undefined + } />

- Optional .kicad_pcb + Optional — or inside the zip

diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d8695b9..a9705f6 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -295,6 +295,9 @@ export interface UploadNetlistResult { // browser-side PADS parser produces. Empty list for PADS uploads (the // browser parses those locally). designator_pins: NetlistPreviewDesignator[]; + pcb_saved?: boolean; + bom_saved?: boolean; + sheets?: number; } export async function uploadPcb( @@ -344,6 +347,9 @@ export async function uploadNetlist( sub_designs: (data.sub_designs as EdifSubDesign[] | undefined) ?? [], designator_pins: (data.designator_pins as NetlistPreviewDesignator[] | undefined) ?? [], + pcb_saved: Boolean(data.pcb_saved), + bom_saved: Boolean(data.bom_saved), + sheets: typeof data.sheets === "number" ? data.sheets : undefined, }; } diff --git a/frontend/src/lib/version.ts b/frontend/src/lib/version.ts index 0b1e231..57f543c 100644 --- a/frontend/src/lib/version.ts +++ b/frontend/src/lib/version.ts @@ -1,4 +1,4 @@ // AUTO-GENERATED by scripts/sync-version.mjs from content/changelog.md. // Do not edit by hand — change the top "## X.Y.Z" heading in the changelog. -export const APP_VERSION = "2.26.2"; +export const APP_VERSION = "2.26.3"; export const APP_VERSION_DATE = "2026-09-11"; diff --git a/tests/test_netlist_bundle.py b/tests/test_netlist_bundle.py index 760dd9c..da89d01 100644 --- a/tests/test_netlist_bundle.py +++ b/tests/test_netlist_bundle.py @@ -193,6 +193,67 @@ def test_api_accepts_multiple_sch_files(tmp_path: Path): assert resp.json()["parts"] == 2 +def test_zip_pipeline_workspace_reparses_hierarchy(tmp_path: Path): + """Upload → storage → local workspace layout must still see child sheets.""" + from fastapi.testclient import TestClient + + from backend.main import app + from backend.pinscopex.parsers import parse_netlist_any + from backend.services.storage import LocalStorageBackend + + app.state.storage = LocalStorageBackend(tmp_path) + client = TestClient(app) + pid = client.post("/api/projects", json={"name": "pipe"}).json()["id"] + root, child = _root_with_child() + root_nested = root.replace( + 'Sheetfile" "child.kicad_sch"', + 'Sheetfile" "sheets/child.kicad_sch"', + ) + pcb = b'(kicad_pcb (version 20240108) (generator pcbnew)\n (net 0 "")\n)\n' + bom = ( + b"Reference,Value,Manufacturer Part Number\n" + b"R1,10k,RC0603FR-0710KL\n" + b"C1,100n,CL10B104KB8NNNC\n" + ) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("proj/root.kicad_sch", root_nested) + zf.writestr("proj/sheets/child.kicad_sch", child) + zf.writestr("proj/board.kicad_pcb", pcb) + zf.writestr("proj/bom.csv", bom) + + resp = client.post( + f"/api/projects/{pid}/upload/netlist", + files={"file": ("board.zip", buf.getvalue(), "application/zip")}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["parts"] == 2 + assert body["sheets"] == 2 + assert body["pcb_saved"] is True + assert body["bom_saved"] is True + + storage = client.app.state.storage + prefix = f"users/local/projects/{pid}/uploads/" + ws = tmp_path / "ws" / "uploads" + ws.mkdir(parents=True) + for key in storage.list_recursive(prefix): + rel = key[len(prefix):] + dest = ws / rel + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(storage.read_bytes(key)) + + parts, nets, fmt = parse_netlist_any(ws / "netlist.kicad_sch") + assert fmt == "kicad_sch" + assert "R1" in parts and "C1" in parts + assert ("R1", "1") in nets["GND"] and ("C1", "1") in nets["GND"] + assert (ws / "pcb.kicad_pcb").is_file() + assert (ws / "bom.csv").is_file() + meta = client.get(f"/api/projects/{pid}").json() + assert meta["has_pcb"] is True + assert meta["has_bom"] is True + assert meta["has_netlist"] is True + def test_kicad_pcb_bytes_are_not_parsed_as_pads(tmp_path: Path): with pytest.raises(ValueError, match="board"):