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 <cursoragent@cursor.com>
This commit is contained in:
@@ -25,6 +25,7 @@ class NetlistUpload:
|
|||||||
work_dir: Path
|
work_dir: Path
|
||||||
pcb: Path | None
|
pcb: Path | None
|
||||||
extra_sch: list[Path]
|
extra_sch: list[Path]
|
||||||
|
bom: Path | None = None
|
||||||
|
|
||||||
|
|
||||||
def sniff_netlist_kind(content: bytes) -> str:
|
def sniff_netlist_kind(content: bytes) -> str:
|
||||||
@@ -68,6 +69,8 @@ _KEEP_SUFFIX = {
|
|||||||
".edf",
|
".edf",
|
||||||
".asc",
|
".asc",
|
||||||
".net",
|
".net",
|
||||||
|
".csv",
|
||||||
|
".xlsx",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -128,6 +131,18 @@ def find_kicad_pcb(work: Path) -> Path | None:
|
|||||||
return hits[0] if hits else 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]:
|
def _sheetfiles_of(path: Path) -> list[str]:
|
||||||
from backend.pinscopex.parsers_kicad import _parse_sexp, _sheetfiles, _tag
|
from backend.pinscopex.parsers_kicad import _parse_sexp, _sheetfiles, _tag
|
||||||
|
|
||||||
@@ -222,11 +237,14 @@ def materialize_netlist_upload(
|
|||||||
|
|
||||||
root = _pick_root(dest)
|
root = _pick_root(dest)
|
||||||
pcb = find_kicad_pcb(dest)
|
pcb = find_kicad_pcb(dest)
|
||||||
|
bom = find_bom(dest)
|
||||||
extras = [
|
extras = [
|
||||||
p for p in work_sch_files(dest)
|
p for p in work_sch_files(dest)
|
||||||
if p.resolve() != root.resolve()
|
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]:
|
def work_sch_files(dest: Path) -> list[Path]:
|
||||||
|
|||||||
@@ -18,6 +18,32 @@ from backend.services import projects as proj_svc
|
|||||||
router = APIRouter(tags=["projects"])
|
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 ---
|
# --- Library check ---
|
||||||
|
|
||||||
|
|
||||||
@@ -515,6 +541,9 @@ async def upload_netlist(
|
|||||||
raise HTTPException(400, "No netlist file uploaded")
|
raise HTTPException(400, "No netlist file uploaded")
|
||||||
|
|
||||||
sub_designs: list[dict] = []
|
sub_designs: list[dict] = []
|
||||||
|
bom_saved = False
|
||||||
|
pcb_saved = False
|
||||||
|
sheets = 1
|
||||||
try:
|
try:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
parsed = materialize_netlist_upload(blobs, Path(tmp) / "work")
|
parsed = materialize_netlist_upload(blobs, Path(tmp) / "work")
|
||||||
@@ -530,10 +559,24 @@ async def upload_netlist(
|
|||||||
proj_svc.save_companion_sheets(
|
proj_svc.save_companion_sheets(
|
||||||
storage, user_id, project_id, parsed.root, parsed.extra_sch,
|
storage, user_id, project_id, parsed.root, parsed.extra_sch,
|
||||||
)
|
)
|
||||||
|
sheets = 1 + len(parsed.extra_sch)
|
||||||
else:
|
else:
|
||||||
proj_svc.clear_companion_sheets(storage, user_id, project_id)
|
proj_svc.clear_companion_sheets(storage, user_id, project_id)
|
||||||
if parsed.pcb is not None:
|
if parsed.pcb is not None:
|
||||||
proj_svc.save_pcb(storage, user_id, project_id, parsed.pcb.read_bytes())
|
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:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -549,6 +592,9 @@ async def upload_netlist(
|
|||||||
"format": fmt,
|
"format": fmt,
|
||||||
"sub_designs": sub_designs,
|
"sub_designs": sub_designs,
|
||||||
"designator_pins": designator_pins,
|
"designator_pins": designator_pins,
|
||||||
|
"pcb_saved": pcb_saved,
|
||||||
|
"bom_saved": bom_saved,
|
||||||
|
"sheets": sheets,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
What's new in Pinscope.
|
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
|
## 2.26.2 — 2026-09-11 — Three file boxes
|
||||||
|
|
||||||
New project: BOM, schematic, and PCB on the first screen. PCB is optional.
|
New project: BOM, schematic, and PCB on the first screen. PCB is optional.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "2.26.2",
|
"version": "2.26.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"sync-version": "node scripts/sync-version.mjs",
|
"sync-version": "node scripts/sync-version.mjs",
|
||||||
|
|||||||
@@ -590,6 +590,8 @@ export function CreateProjectDialog({
|
|||||||
const [netlistFiles, setNetlistFiles] = useState<File[]>([]);
|
const [netlistFiles, setNetlistFiles] = useState<File[]>([]);
|
||||||
const netlistFile = netlistFiles[0] ?? null;
|
const netlistFile = netlistFiles[0] ?? null;
|
||||||
const [pcbFile, setPcbFile] = useState<File | null>(null);
|
const [pcbFile, setPcbFile] = useState<File | null>(null);
|
||||||
|
const [bomFromBundle, setBomFromBundle] = useState(false);
|
||||||
|
const [pcbFromBundle, setPcbFromBundle] = useState(false);
|
||||||
const [netlistNetCount, setNetlistNetCount] = useState<number | null>(null);
|
const [netlistNetCount, setNetlistNetCount] = useState<number | null>(null);
|
||||||
const [netlistError, setNetlistError] = useState<string | null>(null);
|
const [netlistError, setNetlistError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -922,8 +924,15 @@ export function CreateProjectDialog({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleNetlistChange = useCallback((incoming: File[]) => {
|
const handleNetlistChange = useCallback((incoming: File[]) => {
|
||||||
const { netlist, pcb } = splitProjectFiles(incoming);
|
const { netlist, pcb, bom } = splitProjectFiles(incoming);
|
||||||
if (pcb[0]) setPcbFile(pcb[0]);
|
if (pcb[0]) {
|
||||||
|
setPcbFile(pcb[0]);
|
||||||
|
setPcbFromBundle(false);
|
||||||
|
}
|
||||||
|
if (bom[0]) {
|
||||||
|
handleBomChange([bom[0]]);
|
||||||
|
setBomFromBundle(false);
|
||||||
|
}
|
||||||
const files = netlist;
|
const files = netlist;
|
||||||
const file = files[0] || null;
|
const file = files[0] || null;
|
||||||
setNetlistFiles(files);
|
setNetlistFiles(files);
|
||||||
@@ -969,7 +978,7 @@ export function CreateProjectDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, []);
|
}, [handleBomChange]);
|
||||||
|
|
||||||
const resetAndClose = useCallback(() => {
|
const resetAndClose = useCallback(() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -978,6 +987,8 @@ export function CreateProjectDialog({
|
|||||||
setBomFile(null);
|
setBomFile(null);
|
||||||
setNetlistFiles([]);
|
setNetlistFiles([]);
|
||||||
setPcbFile(null);
|
setPcbFile(null);
|
||||||
|
setBomFromBundle(false);
|
||||||
|
setPcbFromBundle(false);
|
||||||
setNetlistNetCount(null);
|
setNetlistNetCount(null);
|
||||||
setNetlistError(null);
|
setNetlistError(null);
|
||||||
setCsvData(null);
|
setCsvData(null);
|
||||||
@@ -1584,6 +1595,8 @@ export function CreateProjectDialog({
|
|||||||
const runEarlyNetlistUpload = useCallback(async (): Promise<{
|
const runEarlyNetlistUpload = useCallback(async (): Promise<{
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
subDesigns: EdifSubDesign[];
|
subDesigns: EdifSubDesign[];
|
||||||
|
bomSaved?: boolean;
|
||||||
|
pcbSaved?: boolean;
|
||||||
}> => {
|
}> => {
|
||||||
if (!netlistFile) return { ok: false, subDesigns: [] };
|
if (!netlistFile) return { ok: false, subDesigns: [] };
|
||||||
setEarlyUploading(true);
|
setEarlyUploading(true);
|
||||||
@@ -1602,12 +1615,23 @@ export function CreateProjectDialog({
|
|||||||
if (result.designator_pins.length > 0) {
|
if (result.designator_pins.length > 0) {
|
||||||
setNetlistPreview(result.designator_pins);
|
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);
|
setNetlistUploadedEarly(true);
|
||||||
setInitialNetlistFiles(netlistFiles);
|
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) {
|
} catch (e) {
|
||||||
// Only delete the project if we created it just now — leave LCSC's
|
|
||||||
// earlier draft alone.
|
|
||||||
if (createdProjectIdHere) {
|
if (createdProjectIdHere) {
|
||||||
try {
|
try {
|
||||||
await deleteProject(createdProjectIdHere);
|
await deleteProject(createdProjectIdHere);
|
||||||
@@ -1623,8 +1647,14 @@ export function CreateProjectDialog({
|
|||||||
} finally {
|
} finally {
|
||||||
setEarlyUploading(false);
|
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 ----
|
// ---- LCSC per-row passive resolve ----
|
||||||
//
|
//
|
||||||
// Walks the lcscPassives list in batches of LCSC_RESOLVE_CONCURRENCY,
|
// Walks the lcscPassives list in batches of LCSC_RESOLVE_CONCURRENCY,
|
||||||
@@ -1738,7 +1768,13 @@ export function CreateProjectDialog({
|
|||||||
}, [step, lcscResolving, lcscPassives.length, hasIcs, hasSimple, hasPassives]);
|
}, [step, lcscResolving, lcscPassives.length, hasIcs, hasSimple, hasPassives]);
|
||||||
|
|
||||||
const canAdvance = (): boolean => {
|
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 === "columns") return !!(refCol && mpnCol);
|
||||||
if (step === "subdesigns")
|
if (step === "subdesigns")
|
||||||
return !!(selectedSubdesignIds && selectedSubdesignIds.size > 0);
|
return !!(selectedSubdesignIds && selectedSubdesignIds.size > 0);
|
||||||
@@ -1772,12 +1808,18 @@ export function CreateProjectDialog({
|
|||||||
// the server) and when already done.
|
// the server) and when already done.
|
||||||
if (
|
if (
|
||||||
!rerunProject &&
|
!rerunProject &&
|
||||||
netlistIsEdif &&
|
|
||||||
!netlistUploadedEarly &&
|
!netlistUploadedEarly &&
|
||||||
netlistFile
|
netlistFile &&
|
||||||
|
(netlistIsEdif || needsEarlyKiCadUpload)
|
||||||
) {
|
) {
|
||||||
const res = await runEarlyNetlistUpload();
|
const res = await runEarlyNetlistUpload();
|
||||||
if (!res.ok) return;
|
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");
|
setStep("columns");
|
||||||
}
|
}
|
||||||
@@ -1917,21 +1959,39 @@ export function CreateProjectDialog({
|
|||||||
// projects, the project already owns prior artifacts and should be
|
// projects, the project already owns prior artifacts and should be
|
||||||
// preserved on failure.
|
// preserved on failure.
|
||||||
try {
|
try {
|
||||||
if (!projectAlreadyExists || bomFile !== initialBomFile) {
|
if (
|
||||||
|
bomFile &&
|
||||||
|
(!projectAlreadyExists || bomFile !== initialBomFile) &&
|
||||||
|
!bomFromBundle
|
||||||
|
) {
|
||||||
setProgress("Uploading BOM...");
|
setProgress("Uploading BOM...");
|
||||||
await uploadBom(
|
await uploadBom(
|
||||||
project.id,
|
project.id,
|
||||||
bomFile!,
|
bomFile,
|
||||||
refCol || undefined,
|
refCol || undefined,
|
||||||
mpnCol || 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) {
|
if (!projectAlreadyExists || netlistFiles !== initialNetlistFiles) {
|
||||||
setProgress("Uploading netlist...");
|
if (!netlistUploadedEarly) {
|
||||||
await uploadNetlist(project.id, netlistFiles);
|
setProgress("Uploading netlist...");
|
||||||
|
await uploadNetlist(project.id, netlistFiles);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (pcbFile) {
|
if (pcbFile && !pcbFromBundle) {
|
||||||
setProgress("Uploading PCB...");
|
setProgress("Uploading PCB...");
|
||||||
await uploadPcb(project.id, pcbFile);
|
await uploadPcb(project.id, pcbFile);
|
||||||
}
|
}
|
||||||
@@ -2118,15 +2178,20 @@ export function CreateProjectDialog({
|
|||||||
label="BOM"
|
label="BOM"
|
||||||
accept=".csv,.xlsx"
|
accept=".csv,.xlsx"
|
||||||
files={bomFile ? [bomFile] : []}
|
files={bomFile ? [bomFile] : []}
|
||||||
onFilesChange={handleBomChange}
|
onFilesChange={(files) => {
|
||||||
|
setBomFromBundle(false);
|
||||||
|
handleBomChange(files);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-muted-foreground leading-tight px-1">
|
<p className="text-[11px] text-muted-foreground leading-tight px-1">
|
||||||
.csv or .xlsx
|
{bomFromBundle
|
||||||
|
? "Taken from the KiCad zip"
|
||||||
|
: ".csv / .xlsx — or inside the KiCad zip"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<FileUploadZone
|
<FileUploadZone
|
||||||
label="Schematic"
|
label="KiCad / netlist"
|
||||||
accept=".asc,.net,.NET,.txt,.edn,.edif,.edf,.xml,.kicad_sch,.kicad_net,.zip"
|
accept=".asc,.net,.NET,.txt,.edn,.edif,.edf,.xml,.kicad_sch,.kicad_net,.zip"
|
||||||
multiple
|
multiple
|
||||||
files={netlistFiles}
|
files={netlistFiles}
|
||||||
@@ -2143,7 +2208,7 @@ export function CreateProjectDialog({
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[11px] text-muted-foreground leading-tight px-1">
|
<p className="text-[11px] text-muted-foreground leading-tight px-1">
|
||||||
Netlist or .kicad_sch
|
Zip del progetto, o tutti i .kicad_sch
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2153,16 +2218,21 @@ export function CreateProjectDialog({
|
|||||||
accept=".kicad_pcb"
|
accept=".kicad_pcb"
|
||||||
files={pcbFile ? [pcbFile] : []}
|
files={pcbFile ? [pcbFile] : []}
|
||||||
onFilesChange={(files) => {
|
onFilesChange={(files) => {
|
||||||
const picked = splitProjectFiles(files).pcb[0] ?? files[0] ?? null;
|
setPcbFromBundle(false);
|
||||||
|
const picked =
|
||||||
|
splitProjectFiles(files).pcb[0] ?? files[0] ?? null;
|
||||||
setPcbFile(
|
setPcbFile(
|
||||||
picked?.name.toLowerCase().endsWith(".kicad_pcb")
|
picked?.name.toLowerCase().endsWith(".kicad_pcb")
|
||||||
? picked
|
? picked
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
preloaded={
|
||||||
|
pcbFromBundle && !pcbFile ? ["from project zip"] : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-muted-foreground leading-tight px-1">
|
<p className="text-[11px] text-muted-foreground leading-tight px-1">
|
||||||
Optional .kicad_pcb
|
Optional — or inside the zip
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -295,6 +295,9 @@ export interface UploadNetlistResult {
|
|||||||
// browser-side PADS parser produces. Empty list for PADS uploads (the
|
// browser-side PADS parser produces. Empty list for PADS uploads (the
|
||||||
// browser parses those locally).
|
// browser parses those locally).
|
||||||
designator_pins: NetlistPreviewDesignator[];
|
designator_pins: NetlistPreviewDesignator[];
|
||||||
|
pcb_saved?: boolean;
|
||||||
|
bom_saved?: boolean;
|
||||||
|
sheets?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadPcb(
|
export async function uploadPcb(
|
||||||
@@ -344,6 +347,9 @@ export async function uploadNetlist(
|
|||||||
sub_designs: (data.sub_designs as EdifSubDesign[] | undefined) ?? [],
|
sub_designs: (data.sub_designs as EdifSubDesign[] | undefined) ?? [],
|
||||||
designator_pins:
|
designator_pins:
|
||||||
(data.designator_pins as NetlistPreviewDesignator[] | undefined) ?? [],
|
(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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// AUTO-GENERATED by scripts/sync-version.mjs from content/changelog.md.
|
// 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.
|
// 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";
|
export const APP_VERSION_DATE = "2026-09-11";
|
||||||
|
|||||||
@@ -193,6 +193,67 @@ def test_api_accepts_multiple_sch_files(tmp_path: Path):
|
|||||||
assert resp.json()["parts"] == 2
|
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):
|
def test_kicad_pcb_bytes_are_not_parsed_as_pads(tmp_path: Path):
|
||||||
with pytest.raises(ValueError, match="board"):
|
with pytest.raises(ValueError, match="board"):
|
||||||
|
|||||||
Reference in New Issue
Block a user