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