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:
@@ -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]:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -167,7 +167,7 @@ export function FileUploadZone({
|
||||
})
|
||||
) : directory ? (
|
||||
<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`}
|
||||
</span>
|
||||
) : (
|
||||
@@ -179,11 +179,19 @@ export function FileUploadZone({
|
||||
type="file"
|
||||
accept={directory ? undefined : accept}
|
||||
multiple={multiple || directory}
|
||||
{...(directory
|
||||
? ({ webkitdirectory: "", directory: "" } as Record<string, string>)
|
||||
: {})}
|
||||
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");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
|
||||
@@ -271,9 +271,15 @@ export async function uploadNetlist(projectId: string, files: File | File[]): Pr
|
||||
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;
|
||||
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));
|
||||
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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user