diff --git a/periscope/src/backend/periscopex/kicad_project.py b/periscope/src/backend/periscopex/kicad_project.py index fc5006e..8833162 100644 --- a/periscope/src/backend/periscopex/kicad_project.py +++ b/periscope/src/backend/periscopex/kicad_project.py @@ -31,28 +31,45 @@ class KicadProject: def sniff_kicad_pro(content: bytes, name: str = "") -> bool: - if name.lower().endswith(".kicad_pro"): + if ".kicad_pro" in name.lower().replace("\\", "/"): return True head = content.lstrip()[:64] if not head.startswith(b"{"): return False - sample = content[:8000] - return b"top_level_sheets" in sample or b'"meta"' in sample and b"kicad" in sample.lower() + sample = content[:65536] + return b"top_level_sheets" in sample or b".kicad_pro" in sample -def find_kicad_pro(folder: Path) -> Path: - hits = sorted(p for p in folder.glob("*.kicad_pro") if p.is_file()) +def find_kicad_pro(folder: Path, preferred_stem: str | None = None) -> Path: + hits: list[Path] = [] + for path in folder.rglob("*"): + if not path.is_file() or "-backups" in path.parts: + continue + try: + depth = len(path.relative_to(folder).parts) + except ValueError: + continue + if depth > 8: + continue + suffix = path.suffix.lower() + if suffix == ".kicad_pro": + hits.append(path) + continue + if suffix in {"", ".json"} and path.stat().st_size < 2_000_000: + if sniff_kicad_pro(path.read_bytes()[:65536], path.name): + hits.append(path) if not hits: raise ValueError( "In questa cartella non c'è un progetto KiCad (.kicad_pro)." ) - if len(hits) == 1: - return hits[0] - named = [p for p in hits if p.stem.lower() == folder.name.lower()] - if len(named) == 1: - return named[0] - names = ", ".join(p.name for p in hits) - raise ValueError(f"Più file .kicad_pro nella cartella ({names}).") + stem = (preferred_stem or folder.name).strip().lower() + named = [ + p for p in hits + if p.stem.lower() == stem or p.name.lower() == f"{stem}.kicad_pro" + ] + pool = named if named else hits + pool.sort(key=lambda p: (len(p.relative_to(folder).parts), p.name.lower())) + return pool[0] def _top_level_sch_names(pro: Path) -> list[str]: diff --git a/periscope/src/backend/periscopex/netlist_bundle.py b/periscope/src/backend/periscopex/netlist_bundle.py index f34d26e..bb903e4 100644 --- a/periscope/src/backend/periscopex/netlist_bundle.py +++ b/periscope/src/backend/periscopex/netlist_bundle.py @@ -11,7 +11,7 @@ import zipfile from dataclasses import dataclass from pathlib import Path -from backend.periscopex.parsers import detect_netlist_format +from backend.periscopex.parsers import detect_netlist_format MAX_BUNDLE_BYTES = 30 * 1024 * 1024 _MAX_ZIP_MEMBERS = 400 @@ -29,7 +29,11 @@ class NetlistUpload: def sniff_netlist_kind(content: bytes, name: str = "") -> str: - if name.lower().endswith(".kicad_pro"): + from backend.periscopex.kicad_project import sniff_kicad_pro + + if name.lower().endswith(".kicad_pro") or name.lower().replace("\\", "/").endswith(".kicad_pro"): + return "kicad_pro" + if sniff_kicad_pro(content, name): return "kicad_pro" if content[:2] == b"PK": return "zip" @@ -47,10 +51,6 @@ def sniff_netlist_kind(content: bytes, name: str = "") -> str: return "kicad_xml" if "*PADS-PCB*" in head.upper() or head.lstrip().startswith("*PART*"): return "pads" - from backend.periscopex.kicad_project import sniff_kicad_pro - - if sniff_kicad_pro(content, name): - return "kicad_pro" return "unknown" @@ -122,6 +122,8 @@ def _write_named(name: str, data: bytes, dest: Path) -> None: if kind == "zip": _extract_zip(data, dest) return + if kind == "kicad_pro" and not rel.lower().endswith(".kicad_pro"): + rel = f"{rel}.kicad_pro" out = dest / Path(rel).name # Keep a single subdirectory when the client sent webkitRelativePath. if "/" in rel: diff --git a/periscope/src/frontend/content/changelog.md b/periscope/src/frontend/content/changelog.md index a6e2540..5541cfe 100644 --- a/periscope/src/frontend/content/changelog.md +++ b/periscope/src/frontend/content/changelog.md @@ -2,6 +2,13 @@ What's new in Periscope. +## 2.60.7 — 2026-09-21 — Detect HubAudio.kicad_pro after folder pick + +Apri progetto was rejecting the folder that actually contains `HubAudio.kicad_pro`. Safari/macOS folder pick often puts the real name in `webkitRelativePath` while `File.name` is the stem without `.kicad_pro`, and React was not setting `webkitdirectory` on the input. We match name **or** relative path, sniff JSON if the suffix is missing, and set the folder attribute on the DOM. + +- [Fixed] Folder that contains `HubAudio.kicad_pro` is accepted. +- [Fixed] `webkitdirectory` set via DOM `setAttribute` so Safari/Chrome recurse. + ## 2.60.6 — 2026-09-21 — Apri progetto KiCad (cartella) Un controllo: **Apri progetto KiCad**. Si sceglie la cartella dove sta il `.kicad_pro`. Carichiamo root + fogli hierarchical `.kicad_sch` e il `.kicad_pcb` fratello. Niente netlist, niente CSV. MPN solo da proprietà schematico (PNM/MPN/Value). Uno `.asc` PADS vecchio nella stessa cartella è ignorato. diff --git a/periscope/src/frontend/src/components/dashboard/create-project-dialog.tsx b/periscope/src/frontend/src/components/dashboard/create-project-dialog.tsx index fe985e6..ecae69c 100644 --- a/periscope/src/frontend/src/components/dashboard/create-project-dialog.tsx +++ b/periscope/src/frontend/src/components/dashboard/create-project-dialog.tsx @@ -75,6 +75,11 @@ import type { Project, } from "@/lib/types"; import { cn } from "@/lib/utils"; +import { + filesBesideKicadPro, + looksLikeKicadPro, + looksLikeKicadSch, +} from "@/lib/kicad-project-files"; /* Spreadsheet ingest — used only to guess columns and classify refs. */ @@ -360,36 +365,6 @@ function extensionOf(name: string): string { return i >= 0 ? name.slice(i).toLowerCase() : ""; } -function relativeOf(file: File): string { - return ( - (file as File & { webkitRelativePath?: string }).webkitRelativePath || - file.name - ).replaceAll("\\", "/"); -} - -const KICAD_PROJECT_EXTS = new Set([".kicad_pro", ".kicad_sch", ".kicad_pcb"]); - -function filesBesideKicadPro(incoming: File[]): File[] { - const rows = incoming.map((f) => ({ f, rel: relativeOf(f) })); - const pro = rows.find( - (row) => - row.rel.toLowerCase().endsWith(".kicad_pro") && - !row.rel.includes("-backups/") && - !row.rel.toLowerCase().includes("/__macosx/"), - ); - if (!pro) return []; - const dir = pro.rel.includes("/") ? pro.rel.slice(0, pro.rel.lastIndexOf("/")) : ""; - return rows - .filter((row) => { - const parent = row.rel.includes("/") - ? row.rel.slice(0, row.rel.lastIndexOf("/")) - : ""; - if (parent !== dir) return false; - return KICAD_PROJECT_EXTS.has(extensionOf(row.f.name)); - }) - .map((row) => row.f); -} - function sortIncomingFiles(incoming: File[]): { netlist: File[]; pcb: File[]; @@ -913,7 +888,7 @@ export function CreateProjectDialog({ setPcbFromBundle(true); setPcbFile(null); setBomFile(null); - const kept = filesBesideKicadPro(incoming); + const kept = filesBesideKicadPro(incoming, name); setNetlistFiles(kept); setNetlistNetCount(null); setNetlistPreview(null); @@ -922,20 +897,20 @@ export function CreateProjectDialog({ setSelectedSubdesignIds(null); setNetlistUploadedEarly(false); setNetlistIsEdif(false); - if (!kept.some((f) => f.name.toLowerCase().endsWith(".kicad_pro"))) { + if (!kept.some((f) => looksLikeKicadPro(f))) { setNetlistError( "In questa cartella non c'è un progetto KiCad (.kicad_pro).", ); return; } - if (!kept.some((f) => f.name.toLowerCase().endsWith(".kicad_sch"))) { + if (!kept.some((f) => looksLikeKicadSch(f))) { setNetlistError( "Manca lo schematico nella stessa cartella del .kicad_pro.", ); return; } setNetlistError(null); - }, []); + }, [name]); const closeAndClear = useCallback(() => { setOpen(false); @@ -1623,8 +1598,7 @@ export function CreateProjectDialog({ }, [netlistFile, netlistFiles, existingProjectId, name, onBomFiles]); const wantsEarlyKicadUpload = Boolean( - netlistFile && - netlistFiles.some((f) => f.name.toLowerCase().endsWith(".kicad_pro")), + netlistFile && netlistFiles.some((f) => looksLikeKicadPro(f)), ); // ---- LCSC per-row passive resolve ---- // @@ -1741,8 +1715,8 @@ export function CreateProjectDialog({ const stepReady = (): boolean => { if (step === "details") { const kicadReady = - netlistFiles.some((f) => f.name.toLowerCase().endsWith(".kicad_pro")) && - netlistFiles.some((f) => f.name.toLowerCase().endsWith(".kicad_sch")); + netlistFiles.some((f) => looksLikeKicadPro(f)) && + netlistFiles.some((f) => looksLikeKicadSch(f)); return !!(name.trim() && kicadReady && !netlistError); } if (step === "columns") return !!(refCol && mpnCol); diff --git a/periscope/src/frontend/src/components/upload/file-upload-zone.tsx b/periscope/src/frontend/src/components/upload/file-upload-zone.tsx index bd45a41..0908f22 100644 --- a/periscope/src/frontend/src/components/upload/file-upload-zone.tsx +++ b/periscope/src/frontend/src/components/upload/file-upload-zone.tsx @@ -167,7 +167,7 @@ export function FileUploadZone({ }) ) : directory ? ( - {files.find((f) => f.name.toLowerCase().endsWith(".kicad_pro"))?.name + {files.find((f) => f.name.toLowerCase().includes("kicad_pro") || (f as File & { webkitRelativePath?: string }).webkitRelativePath?.toLowerCase().includes("kicad_pro"))?.name ?? `${files.length} file`} ) : ( @@ -179,11 +179,19 @@ export function FileUploadZone({ type="file" accept={directory ? undefined : accept} multiple={multiple || directory} - {...(directory - ? ({ webkitdirectory: "", directory: "" } as Record) - : {})} className="hidden" onChange={onPick} + ref={(el) => { + if (!el) return; + if (directory) { + el.setAttribute("webkitdirectory", ""); + el.setAttribute("directory", ""); + el.multiple = true; + } else { + el.removeAttribute("webkitdirectory"); + el.removeAttribute("directory"); + } + }} /> ); diff --git a/periscope/src/frontend/src/lib/api.ts b/periscope/src/frontend/src/lib/api.ts index 25b9fc0..ee9c9ec 100644 --- a/periscope/src/frontend/src/lib/api.ts +++ b/periscope/src/frontend/src/lib/api.ts @@ -269,11 +269,17 @@ export async function uploadPcb( export async function uploadNetlist(projectId: string, files: File | File[]): Promise { const list = Array.isArray(files) ? files : [files]; const form = new FormData(); - const rels = list.map((f) => { - const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath; - return rel && rel.length > 0 ? rel : f.name; - }); - for (const f of list) form.append("files", f, f.name); + const rels = list.map((f) => { + const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath; + const raw = rel && rel.length > 0 ? rel : f.name; + return raw.replaceAll("\\", "/"); + }); + for (let i = 0; i < list.length; i++) { + const f = list[i]; + const rel = rels[i]; + const base = rel.includes("/") ? rel.slice(rel.lastIndexOf("/") + 1) : rel; + form.append("files", f, base || f.name); + } form.append("paths", JSON.stringify(rels)); const res = await send(`/api/projects/${projectId}/upload/netlist`, { method: "POST", body: form }); const data = await jsonOrThrow>(res, "Failed to upload netlist"); diff --git a/periscope/src/frontend/src/lib/kicad-project-files.ts b/periscope/src/frontend/src/lib/kicad-project-files.ts new file mode 100644 index 0000000..4a7ce64 --- /dev/null +++ b/periscope/src/frontend/src/lib/kicad-project-files.ts @@ -0,0 +1,103 @@ +"""Pick KiCad project files from a folder picker FileList (Chrome + Safari). + +Safari/macOS often puts the real name in ``webkitRelativePath`` while +``File.name`` is the stem without ``.kicad_pro``. Matching only ``file.name`` +misses ``HubAudio.kicad_pro`` after Apri progetto. +""" + +export type NamedUpload = { + name: string; + webkitRelativePath?: string; +}; + +export function uploadRel(file: NamedUpload): string { + const rel = (file.webkitRelativePath || "").replaceAll("\\", "/").trim(); + if (rel) return rel.replace(/^\.\//, ""); + return file.name.replaceAll("\\", "/"); +} + +export function uploadBase(file: NamedUpload): string { + const rel = uploadRel(file); + const slash = rel.lastIndexOf("/"); + return slash >= 0 ? rel.slice(slash + 1) : rel; +} + +export function looksLikeKicadPro(file: NamedUpload): boolean { + const chunks = [file.name, file.webkitRelativePath || "", uploadBase(file), uploadRel(file)]; + return chunks.some((s) => s.toLowerCase().includes(".kicad_pro")); +} + +export function looksLikeKicadSch(file: NamedUpload): boolean { + const chunks = [file.name, file.webkitRelativePath || "", uploadBase(file), uploadRel(file)]; + return chunks.some((s) => s.toLowerCase().includes(".kicad_sch")); +} + +function parentDir(rel: string): string { + const slash = rel.lastIndexOf("/"); + return slash >= 0 ? rel.slice(0, slash) : ""; +} + +function skipRel(rel: string): boolean { + const low = rel.toLowerCase(); + return low.includes("-backups/") || low.includes("/__macosx/") || low.includes(".pretty/"); +} + +function depth(rel: string): number { + const p = parentDir(rel); + if (!p) return 0; + return p.split("/").filter(Boolean).length; +} + +function sameFolder(parent: string, proDir: string): boolean { + if (parent === proDir) return true; + if (!parent && proDir.split("/").length <= 1) return true; + if (!proDir && parent.split("/").length <= 1) return true; + return false; +} + +function isKicadKeep(file: NamedUpload): boolean { + const base = uploadBase(file).toLowerCase(); + return ( + looksLikeKicadPro(file) || + base.endsWith(".kicad_sch") || + base.endsWith(".kicad_pcb") || + looksLikeKicadSch(file) || + (file.name.toLowerCase().includes(".kicad_pcb") || + uploadRel(file).toLowerCase().includes(".kicad_pcb")) + ); +} + +export function pickKicadPro(incoming: T[], preferredStem?: string): T | null { + const hits = incoming.filter((f) => looksLikeKicadPro(f) && !skipRel(uploadRel(f))); + if (hits.length === 0) return null; + const stem = (preferredStem || "").trim().toLowerCase(); + const named = stem + ? hits.filter((f) => { + const base = uploadBase(f).toLowerCase(); + return base === `${stem}.kicad_pro` || base.startsWith(`${stem}.kicad_pro`); + }) + : []; + const pool = named.length > 0 ? named : hits; + pool.sort((a, b) => { + const da = depth(uploadRel(a)); + const db = depth(uploadRel(b)); + if (da !== db) return da - db; + return uploadBase(a).localeCompare(uploadBase(b)); + }); + return pool[0] ?? null; +} + +export function filesBesideKicadPro( + incoming: T[], + preferredStem?: string, +): T[] { + const pro = pickKicadPro(incoming, preferredStem); + if (!pro) return []; + const dir = parentDir(uploadRel(pro)); + return incoming.filter((f) => { + const rel = uploadRel(f); + if (skipRel(rel)) return false; + if (!sameFolder(parentDir(rel), dir)) return false; + return isKicadKeep(f); + }); +} diff --git a/tests/test_kicad_project.py b/tests/test_kicad_project.py index c79f532..52a0b9e 100644 --- a/tests/test_kicad_project.py +++ b/tests/test_kicad_project.py @@ -268,3 +268,56 @@ def test_api_folder_upload_no_csv(tmp_path: Path): assert body["bom_saved"] is True assert "U13" not in str(body) assert body["parts"] >= 2 + + +def _looks_like_kicad_pro(name: str, relative: str = "") -> bool: + """Same rule as frontend looksLikeKicadPro (name and/or webkitRelativePath).""" + return any(".kicad_pro" in s.lower() for s in (name, relative, Path(relative or name).name)) + + +def test_safari_stem_name_is_still_hubaudio_kicad_pro(): + """Live 2.60.6 checked only File.name.endsWith('.kicad_pro') and missed Safari.""" + assert not "HubAudio".lower().endswith(".kicad_pro") + assert _looks_like_kicad_pro("HubAudio", "HubAudio/HubAudio.kicad_pro") + assert _looks_like_kicad_pro("HubAudio.kicad_pro", "") + assert sniff_netlist_kind( + _pro("HubAudio.kicad_sch").encode(), "HubAudio", + ) == "kicad_pro" + + +def test_safari_stem_upload_writes_kicad_pro_suffix(tmp_path: Path): + src = tmp_path / "HubAudio" + _write_project(src) + pro = (src / "HubAudio.kicad_pro").read_bytes() + files = [ + ("HubAudio", pro), + ("HubAudio.kicad_sch", (src / "HubAudio.kicad_sch").read_bytes()), + ("Codec.kicad_sch", (src / "Codec.kicad_sch").read_bytes()), + ("HubAudio.kicad_pcb", (src / "HubAudio.kicad_pcb").read_bytes()), + ] + parsed = materialize_netlist_upload(files, tmp_path / "work") + assert parsed.root.name == "HubAudio.kicad_sch" + assert any(p.name == "HubAudio.kicad_pro" for p in (tmp_path / "work").rglob("*")) + parts, _nets, fmt = parse_netlist_any(parsed.root) + assert fmt == "kicad_sch" + assert "U9" in parts and "U13" not in parts + + +@pytest.mark.skipif(not (HUBAUDIO / "HubAudio.kicad_pro").is_file(), reason="HubAudio tree not on disk") +def test_real_hubaudio_kicad_pro_filename_and_folder_ingest(tmp_path: Path): + pro_path = HUBAUDIO / "HubAudio.kicad_pro" + assert pro_path.name == "HubAudio.kicad_pro" + data = pro_path.read_bytes() + assert sniff_kicad_pro(data, "HubAudio.kicad_pro") + assert sniff_netlist_kind(data, "HubAudio") == "kicad_pro" + files: list[tuple[str, bytes]] = [] + for path in HUBAUDIO.iterdir(): + if path.suffix.lower() in {".kicad_pro", ".kicad_sch", ".kicad_pcb"}: + files.append((f"HubAudio/{path.name}", path.read_bytes())) + assert any(name.endswith("HubAudio.kicad_pro") for name, _ in files) + parsed = materialize_netlist_upload(files, tmp_path / "work") + assert parsed.root.name == "HubAudio.kicad_sch" + assert parsed.pcb is not None + parts, _nets, fmt = parse_netlist_any(parsed.root) + assert fmt == "kicad_sch" + assert "U13" not in parts