diff --git a/periscope/src/backend/periscopex/kicad_project.py b/periscope/src/backend/periscopex/kicad_project.py index 8833162..5abc62c 100644 --- a/periscope/src/backend/periscopex/kicad_project.py +++ b/periscope/src/backend/periscopex/kicad_project.py @@ -1,8 +1,9 @@ """Load a KiCad project from the folder that contains ``*.kicad_pro``. A lone ``.kicad_pro`` file is a manifest (JSON). Schematic bytes live in -sibling ``.kicad_sch`` files in that same directory. Hierarchical -``Sheetfile`` children are followed only when they stay inside that folder. +sibling ``.kicad_sch`` files in that same directory, plus hierarchical +``Sheetfile`` children (including a subfolder path). ``.history``, ``.git``, +and backups are never walked. """ from __future__ import annotations @@ -13,11 +14,23 @@ import json from dataclasses import dataclass, field from pathlib import Path -_KEEP_SUFFIX = { - ".kicad_sch", - ".kicad_pcb", - ".kicad_pro", -} +def skip_kicad_walk_dir(name: str) -> bool: + n = name.rstrip("/").lower() + if not n or n.startswith("."): + return True + return n.endswith("-backups") or n.endswith(".pretty") or n in { + "3dmodels", + "__macosx", + "node_modules", + } + + +def path_has_skipped_dir(path: Path, folder: Path) -> bool: + try: + rel = path.relative_to(folder) + except ValueError: + return True + return any(skip_kicad_walk_dir(part) for part in rel.parts[:-1]) @dataclass @@ -43,7 +56,7 @@ def sniff_kicad_pro(content: bytes, name: str = "") -> bool: 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: + if not path.is_file() or path_has_skipped_dir(path, folder): continue try: depth = len(path.relative_to(folder).parts) @@ -119,6 +132,15 @@ def collect_hierarchical_sheets(root_sch: Path, folder: Path) -> list[Path]: ordered.append(path) for rel in _sheetfiles_of(path): child = path.parent / rel.replace("\\", "/") + if ".." in Path(rel.replace("\\", "/")).parts: + continue + try: + resolved = child.resolve() + rel_parts = resolved.relative_to(folder).parts + except ValueError: + continue + if any(skip_kicad_walk_dir(part) for part in rel_parts[:-1]): + continue queue.append(child) return ordered @@ -159,13 +181,18 @@ def copy_kicad_siblings(pro: Path, dest: Path) -> KicadProject: loaded = load_kicad_project(pro.parent) dest.mkdir(parents=True, exist_ok=True) mapping: dict[Path, Path] = {} + folder = loaded.folder.resolve() for src in [loaded.pro, *loaded.sheets]: - out = dest / src.name + rel = src.resolve().relative_to(folder) + out = dest / rel + out.parent.mkdir(parents=True, exist_ok=True) out.write_bytes(src.read_bytes()) mapping[src.resolve()] = out pcb = None if loaded.pcb is not None: - pcb = dest / loaded.pcb.name + pcb_rel = loaded.pcb.resolve().relative_to(folder) + pcb = dest / pcb_rel + pcb.parent.mkdir(parents=True, exist_ok=True) pcb.write_bytes(loaded.pcb.read_bytes()) root = mapping[loaded.root_sch.resolve()] sheets = [mapping[p.resolve()] for p in loaded.sheets] diff --git a/periscope/src/backend/periscopex/netlist_bundle.py b/periscope/src/backend/periscopex/netlist_bundle.py index bb903e4..e0e16ea 100644 --- a/periscope/src/backend/periscopex/netlist_bundle.py +++ b/periscope/src/backend/periscopex/netlist_bundle.py @@ -81,7 +81,11 @@ _KEEP_SUFFIX = { def _keep_zip_member(rel: str) -> bool: + from backend.periscopex.kicad_project import skip_kicad_walk_dir + parts = Path(rel).parts + if any(skip_kicad_walk_dir(p) for p in parts[:-1]): + return False if any( p.endswith("-backups") or p.endswith(".pretty") or p.lower() in {"3dmodels", "__macosx"} for p in parts @@ -162,9 +166,11 @@ def _sheetfiles_of(path: Path) -> list[str]: def _project_folder(work: Path) -> Path | None: + from backend.periscopex.kicad_project import path_has_skipped_dir + pros = [ p for p in work.rglob("*.kicad_pro") - if p.is_file() and "-backups" not in p.parts + if p.is_file() and not path_has_skipped_dir(p, work) ] if not pros: return None @@ -303,4 +309,9 @@ def materialize_netlist_upload( def work_sch_files(dest: Path) -> list[Path]: - return sorted(p for p in dest.rglob("*.kicad_sch") if p.is_file()) + from backend.periscopex.kicad_project import path_has_skipped_dir + + return sorted( + p for p in dest.rglob("*.kicad_sch") + if p.is_file() and not path_has_skipped_dir(p, dest) + ) diff --git a/periscope/src/backend/periscopex/parsers_kicad.py b/periscope/src/backend/periscopex/parsers_kicad.py index f53fb8a..0119776 100644 --- a/periscope/src/backend/periscopex/parsers_kicad.py +++ b/periscope/src/backend/periscopex/parsers_kicad.py @@ -364,11 +364,23 @@ def _sheetfiles(tree: Any) -> list[str]: out: list[str] = [] for sheet in _kids(tree, "sheet"): for p in _kids(sheet, "property"): - if len(p) >= 3 and str(p[1]) == "Sheetfile": + if len(p) >= 3 and str(p[1]) in ("Sheetfile", "Sheet file"): rel = str(p[2]).strip() if rel: out.append(rel) - return out + for p in _kids(sheet, "file"): + if len(p) >= 2: + rel = str(p[1]).strip() + if rel: + out.append(rel) + for item in sheet: + if not isinstance(item, list) or not item: + continue + if str(item[0]) == "file" and len(item) >= 2: + rel = str(item[1]).strip() + if rel: + out.append(rel) + return list(dict.fromkeys(out)) def _parse_kicad_sch_sheet(tree: Any) -> _SchSheet: diff --git a/periscope/src/frontend/content/changelog.md b/periscope/src/frontend/content/changelog.md index c51df68..7dad447 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.10 — 2026-09-21 — Hierarchical sheets, skip .history + +Apri progetto still skips `.history` / `.git` / backups so the folder pick does not hang. It now keeps **every** linked schematic: all `*.kicad_sch` next to the `.kicad_pro`, plus `Sheetfile` / `(sheet` / `file=` children in a subfolder. Not only `HubAudio.kicad_sch`. + +- [Fixed] USB / Codec / POWER (and other hierarchical modules) are ingested with the root sheet. +- [Fixed] Nested sheet paths are followed; `.history` copies are ignored. + ## 2.60.9 — 2026-09-21 — Apri progetto does not walk forever After folder permission, 2.60.8 kept `Next` disabled because `showDirectoryPicker` then recursed `dirHandle.entries()` / `getFile()` through the whole tree (HubAudio `.history` is ~2000 files). We only read the project directory: `.kicad_pro`, `.kicad_sch`, `.kicad_pcb`. Timeout shows an error instead of a spinner forever. Next enables when a `.kicad_pro` is in. 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 f348fe9..108dc35 100644 --- a/periscope/src/frontend/src/components/upload/file-upload-zone.tsx +++ b/periscope/src/frontend/src/components/upload/file-upload-zone.tsx @@ -3,7 +3,14 @@ import { useCallback, useState, type ChangeEvent, type DragEvent, type MouseEvent } from "react"; import { FileCheck, Loader2, Upload } from "lucide-react"; import { cn } from "@/lib/utils"; -import { isKicadProjectFileName, skipWalkDirName } from "@/lib/kicad-project-files"; +import { + isKicadProjectFileName, + joinSheetRel, + sheetfilesFromSchText, + skipRel, + skipWalkDirName, + uploadRel, +} from "@/lib/kicad-project-files"; export type FileUploadZoneProps = { label: string; @@ -16,7 +23,7 @@ export type FileUploadZoneProps = { onPickState?: (state: { busy: boolean; error: string | null }) => void; }; -const WALK_MS = 8_000; +const WALK_MS = 12_000; const WALK_TIMEOUT_IT = "La cartella è troppo grande o il browser non finisce di leggerla. Scegli solo la cartella del .kicad_pro, non il repo intero."; @@ -73,26 +80,100 @@ function fileFromEntry(entry: FileSystemFileEntry): Promise { return new Promise((resolve, reject) => entry.file(resolve, reject)); } -/** Project folder only: kicad_pro / sch / pcb. Do not recurse (HubAudio .history is ~2000 files). */ -async function flattenEntry(root: FileSystemEntry): Promise { - const out: File[] = []; - if (root.isFile) { - if (!isKicadProjectFileName(root.name)) return out; - const file = await fileFromEntry(root as FileSystemFileEntry); - out.push(stampRelativePath(file, file.name)); - return out; +function getDirectoryEntry(dir: FileSystemDirectoryEntry, name: string): Promise { + return new Promise((resolve) => { + dir.getDirectory( + name, + {}, + (entry) => resolve(entry as FileSystemDirectoryEntry), + () => resolve(null), + ); + }); +} + +function getFileEntry(dir: FileSystemDirectoryEntry, name: string): Promise { + return new Promise((resolve) => { + dir.getFile( + name, + {}, + (entry) => resolve(entry as FileSystemFileEntry), + () => resolve(null), + ); + }); +} + +async function fileAtDirectoryEntry( + root: FileSystemDirectoryEntry, + relFromRoot: string, +): Promise { + const parts = relFromRoot.replaceAll("\\", "/").split("/").filter(Boolean); + if (parts.length === 0) return null; + let dir: FileSystemDirectoryEntry = root; + for (const part of parts.slice(0, -1)) { + if (skipWalkDirName(part)) return null; + const next = await getDirectoryEntry(dir, part); + if (!next) return null; + dir = next; } - if (!root.isDirectory) return out; - if (skipWalkDirName(root.name)) return out; - const kids = await readAllEntries((root as FileSystemDirectoryEntry).createReader()); - const nestedPrefix = root.name + "/"; + const base = parts[parts.length - 1]; + if (!isKicadProjectFileName(base)) return null; + const fileEntry = await getFileEntry(dir, base); + if (!fileEntry) return null; + return fileFromEntry(fileEntry); +} + +async function followLinkedSheets( + byRel: Map, + fetchRel: (relFromHandle: string) => Promise, + handlePrefix: string, +): Promise { + const queue = [...byRel.entries()] + .filter(([, file]) => uploadRel(file).toLowerCase().includes(".kicad_sch")) + .map(([rel]) => rel); + const seen = new Set(queue); + while (queue.length > 0) { + const schRel = queue.shift()!; + const file = byRel.get(schRel); + if (!file) continue; + const text = await file.text(); + for (const sheetfile of sheetfilesFromSchText(text)) { + const joined = joinSheetRel(schRel, sheetfile); + if (!joined || seen.has(joined) || skipRel(joined)) continue; + seen.add(joined); + const inner = handlePrefix && joined.startsWith(handlePrefix) + ? joined.slice(handlePrefix.length) + : joined; + const child = byRel.get(joined) ?? (await fetchRel(inner)); + if (!child) continue; + const stamped = stampRelativePath(child, joined); + byRel.set(joined, stamped); + queue.push(joined); + } + } +} + +/** Project dir kicad files + Sheetfile children. Never list .history / .git. */ +async function flattenEntry(root: FileSystemEntry): Promise { + const byRel = new Map(); + if (root.isFile) { + if (!isKicadProjectFileName(root.name)) return []; + const file = await fileFromEntry(root as FileSystemFileEntry); + byRel.set(root.name, stampRelativePath(file, file.name)); + return [...byRel.values()]; + } + if (!root.isDirectory) return []; + if (skipWalkDirName(root.name)) return []; + const dir = root as FileSystemDirectoryEntry; + const prefix = `${root.name}/`; + const kids = await readAllEntries(dir.createReader()); for (const child of kids) { if (child.isDirectory) continue; if (!isKicadProjectFileName(child.name)) continue; const file = await fileFromEntry(child as FileSystemFileEntry); - out.push(stampRelativePath(file, nestedPrefix + child.name)); + byRel.set(prefix + child.name, stampRelativePath(file, prefix + child.name)); } - return out; + await followLinkedSheets(byRel, (inner) => fileAtDirectoryEntry(dir, inner), prefix); + return [...byRel.values()]; } async function filesFromDropEvent(event: DragEvent): Promise { @@ -122,18 +203,43 @@ function mergeSelection(current: File[], incoming: File[], multiple: boolean, di type DirHandle = { name: string; entries: () => AsyncIterable<[string, { kind: string; getFile?: () => Promise } & DirHandle]>; + getFileHandle?: (name: string) => Promise<{ getFile: () => Promise }>; + getDirectoryHandle?: (name: string) => Promise; }; +async function fileAtDirHandle(root: DirHandle, relFromRoot: string): Promise { + const parts = relFromRoot.replaceAll("\\", "/").split("/").filter(Boolean); + if (parts.length === 0) return null; + let dir: DirHandle = root; + for (const part of parts.slice(0, -1)) { + if (skipWalkDirName(part) || !dir.getDirectoryHandle) return null; + try { + dir = await dir.getDirectoryHandle(part); + } catch { + return null; + } + } + const base = parts[parts.length - 1]; + if (!isKicadProjectFileName(base) || !dir.getFileHandle) return null; + try { + const fh = await dir.getFileHandle(base); + return fh.getFile(); + } catch { + return null; + } +} + async function filesFromDirectoryHandle(handle: DirHandle, prefix = ""): Promise { - const out: File[] = []; + const byRel = new Map(); for await (const [name, child] of handle.entries()) { if (child.kind === "directory") continue; if (!isKicadProjectFileName(name)) continue; if (!child.getFile) continue; const file = await child.getFile(); - out.push(stampRelativePath(file, prefix + name)); + byRel.set(prefix + name, stampRelativePath(file, prefix + name)); } - return out; + await followLinkedSheets(byRel, (inner) => fileAtDirHandle(handle, inner), prefix); + return [...byRel.values()]; } function pickFolderViaHiddenInput(): Promise { @@ -146,7 +252,11 @@ function pickFolderViaHiddenInput(): Promise { input.style.display = "none"; const finish = (files: File[]) => { input.remove(); - resolve(files.filter((f) => isKicadProjectFileName(relativePath(f)))); + resolve( + files.filter( + (f) => isKicadProjectFileName(relativePath(f)) && !skipRel(relativePath(f)), + ), + ); }; input.addEventListener("change", () => finish(Array.from(input.files ?? []))); input.addEventListener("cancel", () => finish([])); diff --git a/periscope/src/frontend/src/lib/kicad-project-files.ts b/periscope/src/frontend/src/lib/kicad-project-files.ts index d6269f2..19f1f5f 100644 --- a/periscope/src/frontend/src/lib/kicad-project-files.ts +++ b/periscope/src/frontend/src/lib/kicad-project-files.ts @@ -56,6 +56,35 @@ export function skipRel(rel: string): boolean { return low.includes("-backups/") || low.includes("/__macosx/") || low.includes(".pretty/"); } +/** Linked hierarchical sheets: Sheetfile, (file "…"), or file="…". */ +export function sheetfilesFromSchText(text: string): string[] { + const found: string[] = []; + const patterns = [ + /\(\s*property\s+"Sheetfile"\s+"([^"]+)"/gi, + /\(\s*sheetfile\s+"([^"]+)"/gi, + /\(\s*file\s+"([^"]+\.kicad_sch)"/gi, + /\bfile\s*=\s*"([^"]+\.kicad_sch)"/gi, + ]; + for (const re of patterns) { + re.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + const rel = (m[1] || "").replaceAll("\\", "/").trim(); + if (rel) found.push(rel); + } + } + return [...new Set(found)]; +} + +export function joinSheetRel(schRel: string, sheetfile: string): string | null { + const file = sheetfile.replaceAll("\\", "/").trim(); + if (!file || file.startsWith("/") || file.split("/").includes("..")) return null; + const parent = parentDir(schRel.replaceAll("\\", "/")); + const joined = parent ? `${parent}/${file}` : file; + if (skipRel(joined)) return null; + return joined.replace(/\/+/g, "/"); +} + /** Step 1 Next: project name + a .kicad_pro already in the pick (not waiting on the whole tree). */ export function detailsStepReady(projectName: string, files: NamedUpload[]): boolean { return Boolean(projectName.trim() && files.some((f) => looksLikeKicadPro(f))); @@ -74,6 +103,12 @@ function sameFolder(parent: string, proDir: string): boolean { return false; } +function underProjectDir(parent: string, proDir: string): boolean { + if (sameFolder(parent, proDir)) return true; + if (!proDir) return parent.split("/").filter(Boolean).every((p) => !skipWalkDirName(p)); + return parent === proDir || parent.startsWith(`${proDir}/`); +} + function isKicadKeep(file: NamedUpload): boolean { const base = uploadBase(file).toLowerCase(); return ( @@ -116,7 +151,7 @@ export function filesBesideKicadPro( return incoming.filter((f) => { const rel = uploadRel(f); if (skipRel(rel)) return false; - if (!sameFolder(parentDir(rel), dir)) return false; + if (!underProjectDir(parentDir(rel), dir)) return false; return isKicadKeep(f); }); } diff --git a/tests/test_kicad_project.py b/tests/test_kicad_project.py index 325238e..6180955 100644 --- a/tests/test_kicad_project.py +++ b/tests/test_kicad_project.py @@ -406,3 +406,213 @@ def test_real_hubaudio_recursive_tree_is_huge_project_dir_is_small(): assert any(p.name.endswith(".kicad_pcb") for p in project_dir) assert all(".history" not in str(p) for p in project_dir) assert len(project_dir) < 40 + sch_names = {p.name for p in project_dir if p.suffix.lower() == ".kicad_sch"} + assert "HubAudio.kicad_sch" in sch_names + assert "USB.kicad_sch" in sch_names + assert "Codec.kicad_sch" in sch_names + assert "POWER.kicad_sch" in sch_names + assert len(sch_names) > 1 + + +def _sheet_block(name: str, file: str) -> str: + return f""" + (sheet + (at 50 0) + (size 20 20) + (property "Sheetname" "{name}" (at 50 0 0) (effects (font (size 1.27 1.27)))) + (property "Sheetfile" "{file}" (at 50 0 0) (effects (font (size 1.27 1.27)))) + ) +""" + + +def _write_hier_modules(folder: Path) -> None: + """Root + USB/Codec/POWER siblings + sheets/nested.kicad_sch; junk in .history.""" + folder.mkdir(parents=True, exist_ok=True) + nested_dir = folder / "sheets" + nested_dir.mkdir() + history = folder / ".history" + history.mkdir() + (history / "HubAudio.kicad_sch").write_text(_sch(_ic("U99", "FAKE", "FAKE"))) + (history / "evil.kicad_pro").write_text(_pro("HubAudio.kicad_sch")) + usb = _sch( + _ic("U2", "CH340E", "CH340E"), + """ + (global_label "GND" (at 0 3.81 0) (uuid "dddddddd-dddd-dddd-dddd-dddddddddddd")) +""", + ) + codec = _sch( + _ic("U9", "TPD2E007DCKR", "TPD2E007DCKR"), + """ + (global_label "GND" (at 0 3.81 0) (uuid "cccccccccccccccccccccccccccccccccccc")) +""", + ) + power = _sch( + _ic("U1", "SPX3819M5-L-3-3", "SPX3819M5-L-3-3"), + """ + (global_label "GND" (at 0 3.81 0) (uuid "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee")) +""", + ) + nested = _sch( + _ic("U4", "PCA9534ARGTR", "PCA9534ARGTR"), + """ + (global_label "GND" (at 0 3.81 0) (uuid "ffffffffffffffffffffffffffffffffffffffff")) +""", + ) + root = _sch( + _resistor("R1", "10k"), + """ + (global_label "GND" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")) +""" + + _sheet_block("USB", "USB.kicad_sch") + + _sheet_block("Codec", "Codec.kicad_sch") + + _sheet_block("POWER", "POWER.kicad_sch") + + _sheet_block("Nested", "sheets/nested.kicad_sch"), + ) + (folder / "HubAudio.kicad_pro").write_text(_pro("HubAudio.kicad_sch")) + (folder / "HubAudio.kicad_sch").write_text(root) + (folder / "USB.kicad_sch").write_text(usb) + (folder / "Codec.kicad_sch").write_text(codec) + (folder / "POWER.kicad_sch").write_text(power) + (nested_dir / "nested.kicad_sch").write_text(nested) + (folder / "HubAudio.kicad_pcb").write_text( + '(kicad_pcb (version 20240108) (generator pcbnew)\n (net 0 "")\n)\n' + ) + + +def _sheetfiles_from_text(text: str) -> list[str]: + found: list[str] = [] + for pat in ( + r'\(\s*property\s+"Sheetfile"\s+"([^"]+)"', + r'\(\s*sheetfile\s+"([^"]+)"', + r'\(\s*file\s+"([^"]+\.kicad_sch)"', + r'file\s*=\s*"([^"]+\.kicad_sch)"', + ): + found.extend(re.findall(pat, text, flags=re.I)) + return list(dict.fromkeys(found)) + + +def _join_sheet_rel(sch_rel: str, sheetfile: str) -> str | None: + file = sheetfile.replace("\\", "/").strip() + if not file or file.startswith("/") or ".." in file.split("/"): + return None + parent = sch_rel.replace("\\", "/").rsplit("/", 1)[0] if "/" in sch_rel.replace("\\", "/") else "" + joined = f"{parent}/{file}" if parent else file + if _skip_rel(joined): + return None + return joined + + +def _files_beside(incoming: list[dict[str, str]]) -> list[dict[str, str]]: + """Same keep rule as frontend filesBesideKicadPro (all sch under project dir).""" + hits = [ + f + for f in incoming + if _looks_like_kicad_pro(f["name"], f.get("webkitRelativePath", "")) + and not _skip_rel(f.get("webkitRelativePath") or f["name"]) + ] + if not hits: + return [] + pro_rel = (hits[0].get("webkitRelativePath") or hits[0]["name"]).replace("\\", "/") + pro_dir = pro_rel.rsplit("/", 1)[0] if "/" in pro_rel else "" + kept: list[dict[str, str]] = [] + for f in incoming: + rel = (f.get("webkitRelativePath") or f["name"]).replace("\\", "/") + if _skip_rel(rel): + continue + parent = rel.rsplit("/", 1)[0] if "/" in rel else "" + under = parent == pro_dir or ( + bool(pro_dir) and (parent == pro_dir or parent.startswith(pro_dir + "/")) + ) or (not parent and not pro_dir) + if not under: + continue + base = rel.rsplit("/", 1)[-1].lower() + if not ( + ".kicad_pro" in rel.lower() + or base.endswith((".kicad_sch", ".kicad_pcb")) + ): + continue + kept.append(f) + return kept + + +def test_sheetfiles_from_root_text_usb_codec_power(): + text = ( + _sheet_block("USB", "USB.kicad_sch") + + _sheet_block("Codec", "Codec.kicad_sch") + + _sheet_block("POWER", "POWER.kicad_sch") + + '(file "sheets/nested.kicad_sch")' + + 'file="also.kicad_sch"' + ) + names = _sheetfiles_from_text(text) + assert "USB.kicad_sch" in names + assert "Codec.kicad_sch" in names + assert "POWER.kicad_sch" in names + assert "sheets/nested.kicad_sch" in names + assert "also.kicad_sch" in names + assert _join_sheet_rel("HubAudio/HubAudio.kicad_sch", "USB.kicad_sch") == "HubAudio/USB.kicad_sch" + assert _join_sheet_rel("HubAudio/HubAudio.kicad_sch", "sheets/nested.kicad_sch") == "HubAudio/sheets/nested.kicad_sch" + assert _join_sheet_rel("HubAudio/HubAudio.kicad_sch", ".history/x.kicad_sch") is None + assert _join_sheet_rel("HubAudio/HubAudio.kicad_sch", "../escape.kicad_sch") is None + + +def test_keep_every_sibling_sch_not_only_root(): + incoming = [ + {"name": "HubAudio.kicad_pro", "webkitRelativePath": "HubAudio/HubAudio.kicad_pro"}, + {"name": "HubAudio.kicad_sch", "webkitRelativePath": "HubAudio/HubAudio.kicad_sch"}, + {"name": "USB.kicad_sch", "webkitRelativePath": "HubAudio/USB.kicad_sch"}, + {"name": "Codec.kicad_sch", "webkitRelativePath": "HubAudio/Codec.kicad_sch"}, + {"name": "POWER.kicad_sch", "webkitRelativePath": "HubAudio/POWER.kicad_sch"}, + {"name": "nested.kicad_sch", "webkitRelativePath": "HubAudio/sheets/nested.kicad_sch"}, + {"name": "HubAudio.kicad_pcb", "webkitRelativePath": "HubAudio/HubAudio.kicad_pcb"}, + {"name": "HubAudio.kicad_sch", "webkitRelativePath": "HubAudio/.history/HubAudio.kicad_sch"}, + ] + kept = _files_beside(incoming) + names = { (f.get("webkitRelativePath") or f["name"]).rsplit("/", 1)[-1] for f in kept } + rels = { f.get("webkitRelativePath") or f["name"] for f in kept } + assert "HubAudio.kicad_sch" in names + assert "USB.kicad_sch" in names + assert "Codec.kicad_sch" in names + assert "POWER.kicad_sch" in names + assert "nested.kicad_sch" in names + assert names != {"HubAudio.kicad_sch"} + assert "HubAudio/.history/HubAudio.kicad_sch" not in rels + + +def test_hier_modules_and_nested_sheet_history_ignored(tmp_path: Path): + src = tmp_path / "HubAudio" + _write_hier_modules(src) + loaded = load_kicad_project(src) + names = {p.name for p in loaded.sheets} + assert names == { + "HubAudio.kicad_sch", + "USB.kicad_sch", + "Codec.kicad_sch", + "POWER.kicad_sch", + "nested.kicad_sch", + } + assert all(".history" not in p.parts for p in loaded.sheets) + assert loaded.pro.name == "HubAudio.kicad_pro" + parts, _nets, fmt = parse_netlist_any(loaded.root_sch) + assert fmt == "kicad_sch" + assert "U2" in parts and "U9" in parts and "U1" in parts and "U4" in parts + assert "U99" not in parts + files = [ + (str(p.relative_to(tmp_path)), p.read_bytes()) + for p in src.rglob("*") + if p.is_file() and p.suffix.lower() in {".kicad_pro", ".kicad_sch", ".kicad_pcb"} + and ".history" not in p.parts + ] + parsed = materialize_netlist_upload(files, tmp_path / "work") + extra = {p.name for p in parsed.extra_sch} + assert extra == {"USB.kicad_sch", "Codec.kicad_sch", "POWER.kicad_sch", "nested.kicad_sch"} + copied = {p.name for p in (tmp_path / "work").rglob("*.kicad_sch")} + assert "nested.kicad_sch" in copied + assert not list((tmp_path / "work").rglob(".history/**/*")) + + +def test_history_kicad_pro_is_not_the_project(tmp_path: Path): + src = tmp_path / "HubAudio" + _write_hier_modules(src) + loaded = load_kicad_project(src) + assert ".history" not in loaded.pro.parts + assert loaded.pro.parent == src.resolve() \ No newline at end of file