Detect HubAudio.kicad_pro from folder pick, not only File.name.

Safari/macOS often puts .kicad_pro on webkitRelativePath while name is
the stem. Match both, sniff JSON if the suffix is missing, and set
webkitdirectory on the input DOM so the folder picker actually recurses.
This commit is contained in:
2026-09-21 07:54:35 +02:00
parent bfb781f94f
commit fbf2fc87a6
8 changed files with 235 additions and 65 deletions
@@ -31,28 +31,45 @@ class KicadProject:
def sniff_kicad_pro(content: bytes, name: str = "") -> bool: def sniff_kicad_pro(content: bytes, name: str = "") -> bool:
if name.lower().endswith(".kicad_pro"): if ".kicad_pro" in name.lower().replace("\\", "/"):
return True return True
head = content.lstrip()[:64] head = content.lstrip()[:64]
if not head.startswith(b"{"): if not head.startswith(b"{"):
return False return False
sample = content[:8000] sample = content[:65536]
return b"top_level_sheets" in sample or b'"meta"' in sample and b"kicad" in sample.lower() return b"top_level_sheets" in sample or b".kicad_pro" in sample
def find_kicad_pro(folder: Path) -> Path: def find_kicad_pro(folder: Path, preferred_stem: str | None = None) -> Path:
hits = sorted(p for p in folder.glob("*.kicad_pro") if p.is_file()) 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: if not hits:
raise ValueError( raise ValueError(
"In questa cartella non c'è un progetto KiCad (.kicad_pro)." "In questa cartella non c'è un progetto KiCad (.kicad_pro)."
) )
if len(hits) == 1: stem = (preferred_stem or folder.name).strip().lower()
return hits[0] named = [
named = [p for p in hits if p.stem.lower() == folder.name.lower()] p for p in hits
if len(named) == 1: if p.stem.lower() == stem or p.name.lower() == f"{stem}.kicad_pro"
return named[0] ]
names = ", ".join(p.name for p in hits) pool = named if named else hits
raise ValueError(f"Più file .kicad_pro nella cartella ({names}).") 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]: def _top_level_sch_names(pro: Path) -> list[str]:
@@ -11,7 +11,7 @@ import zipfile
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path 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_BUNDLE_BYTES = 30 * 1024 * 1024
_MAX_ZIP_MEMBERS = 400 _MAX_ZIP_MEMBERS = 400
@@ -29,7 +29,11 @@ class NetlistUpload:
def sniff_netlist_kind(content: bytes, name: str = "") -> str: 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" return "kicad_pro"
if content[:2] == b"PK": if content[:2] == b"PK":
return "zip" return "zip"
@@ -47,10 +51,6 @@ def sniff_netlist_kind(content: bytes, name: str = "") -> str:
return "kicad_xml" return "kicad_xml"
if "*PADS-PCB*" in head.upper() or head.lstrip().startswith("*PART*"): if "*PADS-PCB*" in head.upper() or head.lstrip().startswith("*PART*"):
return "pads" return "pads"
from backend.periscopex.kicad_project import sniff_kicad_pro
if sniff_kicad_pro(content, name):
return "kicad_pro"
return "unknown" return "unknown"
@@ -122,6 +122,8 @@ def _write_named(name: str, data: bytes, dest: Path) -> None:
if kind == "zip": if kind == "zip":
_extract_zip(data, dest) _extract_zip(data, dest)
return return
if kind == "kicad_pro" and not rel.lower().endswith(".kicad_pro"):
rel = f"{rel}.kicad_pro"
out = dest / Path(rel).name out = dest / Path(rel).name
# Keep a single subdirectory when the client sent webkitRelativePath. # Keep a single subdirectory when the client sent webkitRelativePath.
if "/" in rel: if "/" in rel:
@@ -2,6 +2,13 @@
What's new in Periscope. 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) ## 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. 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.
@@ -75,6 +75,11 @@ import type {
Project, Project,
} from "@/lib/types"; } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import {
filesBesideKicadPro,
looksLikeKicadPro,
looksLikeKicadSch,
} from "@/lib/kicad-project-files";
/* Spreadsheet ingest — used only to guess columns and classify refs. */ /* 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() : ""; 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[]): { function sortIncomingFiles(incoming: File[]): {
netlist: File[]; netlist: File[];
pcb: File[]; pcb: File[];
@@ -913,7 +888,7 @@ export function CreateProjectDialog({
setPcbFromBundle(true); setPcbFromBundle(true);
setPcbFile(null); setPcbFile(null);
setBomFile(null); setBomFile(null);
const kept = filesBesideKicadPro(incoming); const kept = filesBesideKicadPro(incoming, name);
setNetlistFiles(kept); setNetlistFiles(kept);
setNetlistNetCount(null); setNetlistNetCount(null);
setNetlistPreview(null); setNetlistPreview(null);
@@ -922,20 +897,20 @@ export function CreateProjectDialog({
setSelectedSubdesignIds(null); setSelectedSubdesignIds(null);
setNetlistUploadedEarly(false); setNetlistUploadedEarly(false);
setNetlistIsEdif(false); setNetlistIsEdif(false);
if (!kept.some((f) => f.name.toLowerCase().endsWith(".kicad_pro"))) { if (!kept.some((f) => looksLikeKicadPro(f))) {
setNetlistError( setNetlistError(
"In questa cartella non c'è un progetto KiCad (.kicad_pro).", "In questa cartella non c'è un progetto KiCad (.kicad_pro).",
); );
return; return;
} }
if (!kept.some((f) => f.name.toLowerCase().endsWith(".kicad_sch"))) { if (!kept.some((f) => looksLikeKicadSch(f))) {
setNetlistError( setNetlistError(
"Manca lo schematico nella stessa cartella del .kicad_pro.", "Manca lo schematico nella stessa cartella del .kicad_pro.",
); );
return; return;
} }
setNetlistError(null); setNetlistError(null);
}, []); }, [name]);
const closeAndClear = useCallback(() => { const closeAndClear = useCallback(() => {
setOpen(false); setOpen(false);
@@ -1623,8 +1598,7 @@ export function CreateProjectDialog({
}, [netlistFile, netlistFiles, existingProjectId, name, onBomFiles]); }, [netlistFile, netlistFiles, existingProjectId, name, onBomFiles]);
const wantsEarlyKicadUpload = Boolean( const wantsEarlyKicadUpload = Boolean(
netlistFile && netlistFile && netlistFiles.some((f) => looksLikeKicadPro(f)),
netlistFiles.some((f) => f.name.toLowerCase().endsWith(".kicad_pro")),
); );
// ---- LCSC per-row passive resolve ---- // ---- LCSC per-row passive resolve ----
// //
@@ -1741,8 +1715,8 @@ export function CreateProjectDialog({
const stepReady = (): boolean => { const stepReady = (): boolean => {
if (step === "details") { if (step === "details") {
const kicadReady = const kicadReady =
netlistFiles.some((f) => f.name.toLowerCase().endsWith(".kicad_pro")) && netlistFiles.some((f) => looksLikeKicadPro(f)) &&
netlistFiles.some((f) => f.name.toLowerCase().endsWith(".kicad_sch")); netlistFiles.some((f) => looksLikeKicadSch(f));
return !!(name.trim() && kicadReady && !netlistError); return !!(name.trim() && kicadReady && !netlistError);
} }
if (step === "columns") return !!(refCol && mpnCol); if (step === "columns") return !!(refCol && mpnCol);
@@ -167,7 +167,7 @@ export function FileUploadZone({
}) })
) : directory ? ( ) : directory ? (
<span> <span>
{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`} ?? `${files.length} file`}
</span> </span>
) : ( ) : (
@@ -179,11 +179,19 @@ export function FileUploadZone({
type="file" type="file"
accept={directory ? undefined : accept} accept={directory ? undefined : accept}
multiple={multiple || directory} multiple={multiple || directory}
{...(directory
? ({ webkitdirectory: "", directory: "" } as Record<string, string>)
: {})}
className="hidden" className="hidden"
onChange={onPick} 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");
}
}}
/> />
</label> </label>
); );
+11 -5
View File
@@ -269,11 +269,17 @@ export async function uploadPcb(
export async function uploadNetlist(projectId: string, files: File | File[]): Promise<UploadNetlistResult> { export async function uploadNetlist(projectId: string, files: File | File[]): Promise<UploadNetlistResult> {
const list = Array.isArray(files) ? files : [files]; const list = Array.isArray(files) ? files : [files];
const form = new FormData(); const form = new FormData();
const rels = list.map((f) => { const rels = list.map((f) => {
const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath; const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath;
return rel && rel.length > 0 ? rel : f.name; const raw = rel && rel.length > 0 ? rel : f.name;
}); return raw.replaceAll("\\", "/");
for (const f of list) form.append("files", f, f.name); });
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)); form.append("paths", JSON.stringify(rels));
const res = await send(`/api/projects/${projectId}/upload/netlist`, { method: "POST", body: form }); const res = await send(`/api/projects/${projectId}/upload/netlist`, { method: "POST", body: form });
const data = await jsonOrThrow<Record<string, unknown>>(res, "Failed to upload netlist"); const data = await jsonOrThrow<Record<string, unknown>>(res, "Failed to upload netlist");
@@ -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<T extends NamedUpload>(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<T extends NamedUpload>(
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);
});
}
+53
View File
@@ -268,3 +268,56 @@ def test_api_folder_upload_no_csv(tmp_path: Path):
assert body["bom_saved"] is True assert body["bom_saved"] is True
assert "U13" not in str(body) assert "U13" not in str(body)
assert body["parts"] >= 2 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