← Commit history

v0.3.0: machine-aware Add flow (real libraries/folders, browse dialog, per-component library dropdowns showing where each part is), cross-library dedup (MPN/value+package/symbol/base-part), stored render assets, project-only library diff + delete-removed, universal OS/identity

barrett-land ·a7e3fa1105 ·3mo ago ·parent a383222
4 files changed +754−74
skills/publish-board/SKILL.md+36−7
@@ -40,11 +40,23 @@ python3 skills/publish-board/kicad_board_bundle.py <project-dir> It reports the render files, `board_name`, the 3D model refs (`models_std` vs `models_custom`), and the `components` (ref, value, mpn, slug). -### 3. Bundle the custom 3D models  → `<Board>.3dshapes/`+### 3. Bundle the custom 3D models + normalize the refs `models_std` (`${KICAD*_3DMODEL_DIR}/…`) resolve on the render service — leave-them. For each `models_custom` (absolute path), pull the `.step`/`.wrl` from the-user's machine into `<Board>.3dshapes/<basename>` (basename must match the PCB-ref — that's how the Manager's GLB render rewrites `${KIPRJMOD}/3d/<basename>`).+them. For each `models_custom` ref, pull the `.step`/`.wrl` from the user's+machine into `<Board>.3dshapes/<basename>`. Then **normalize the PCB's model refs+to project-relative paths** so the published source is portable:+```+python3 skills/publish-board/kicad_board_bundle.py <project-dir> --normalize+```+This rewrites every custom `(model "…")` ref to `<Board>.3dshapes/<basename>`+(forward slashes; KiCad resolves them next to the `.kicad_pcb`, and the Manager's+GLB render bundles them by basename), leaving `${KICAD*_3DMODEL_DIR}` refs and+every `offset`/`scale`/`rotate` untouched.++**NEVER publish a `.kicad_pcb` with machine-specific ABSOLUTE model paths**+(`/home/<user>/…`, `C:\Users\<user>\…`). They resolve nowhere on the render+service or on anyone else's machine (and not even on the author's, if they wrote+them for a different OS). Run `--normalize` and confirm `grep -E '\(model "(/|[A-Za-z]:\\\\)' <pcb>` is empty before pushing.  ### 4. Map components → wiki slugs (MPN → slug) Slug = MPN lowercased, non-alphanumeric → `-`. **The helper falls back to the@@ -72,7 +84,19 @@ aren't on the wiki — they won't appear in the component list until published.   NOT), then `repo push`: `.kicad_pro`, `.kicad_pcb`, all `.kicad_sch`, the   `<Board>.3dshapes/`. Set visibility open. -### 7. Verify+### 7. Regenerate the stored render assets — REQUIRED on every publish/update+After the board files are published, regenerate the pre-rendered assets so the+Manager loads the board instantly (no live render) and the wiki 3D preview is fresh:+```+adom-project-manager --render-board <board-slug>+```+This renders the 3D GLB + PCB SVG + per-sheet schematic SVGs and pushes them to the+page's `render/` folder (with a `manifest.json`). The Manager serves `render/`+directly when present and only falls back to live rendering when it's absent.+**Stale `render/` = the board shows its OLD 3D/PCB/schematic after an edit**, so+never skip this after changing `.kicad_pcb`/`.kicad_sch`/models.++### 8. Verify Open the Manager; confirm the board appears (Personal/Organization by owner) and its 3D + schematic + PCB render, and the component list matches the board. @@ -87,7 +111,12 @@ its 3D + schematic + PCB render, and the component list matches the board. - **Hierarchical schematics**: upload the `.kicad_pro` + EVERY `.kicad_sch`. The   Manager renders the whole project so the root's sheet-symbol boxes resolve; a   single sheet alone renders wrong.-- **3D models**: only ABSOLUTE-path `(model …)` refs need bundling into-  `.3dshapes/`; `${KICAD*_3DMODEL_DIR}/…` are already on the render service.+- **3D models**: bundle every non-`${KICAD*_3DMODEL_DIR}` `(model …)` into+  `<Board>.3dshapes/` and **normalize** the refs to `<Board>.3dshapes/<basename>`+  (step 3). Machine-specific absolute paths are the #1 cause of "board renders as+  a bare PCB / the render service errors" — and they break even on the author's+  own box if written for a different OS. The Manager bundles by basename so+  relative, Unix-absolute, and Windows-absolute all *render*, but only relative+  paths are portable *source*, so always normalize before publishing. - **Every page needs a hero**; components additionally need a 3D file — even the   library. Generate/reuse one to pass the (hard-enforced) publish lint.
skills/publish-board/kicad_board_bundle.py+59−17
@@ -1,14 +1,23 @@ #!/usr/bin/env python3-"""Analyze a KiCad project for publishing to the wiki (see publish-board SKILL).+"""Analyze (and optionally normalize) a KiCad project for wiki publishing.  Given a project directory (already pulled from the user's machine), report the-render files, the 3D-model refs (standard vs custom-absolute — the custom ones-must be bundled into <Board>.3dshapes/), and the component list with MPN → slug-so the library's dependencies can be filled.+render files, the 3D-model refs (standard vs custom — the custom ones must be+bundled into <Board>.3dshapes/), and the component list with MPN -> slug so the+library's dependencies can be filled. -usage: kicad_board_bundle.py <project-dir>+With --normalize, rewrite every CUSTOM (model "...") ref in the .kicad_pcb to a+project-relative <Board>.3dshapes/<basename> path. This is REQUIRED before+publishing: machine-specific absolute paths (/home/<user>/…, C:\\Users\\<user>\\…)+resolve nowhere on the render service or on anyone else's machine. Relative paths+with forward slashes work on every OS and KiCad resolves them next to the .pcb.+Only the path string is changed; each model's offset/scale/rotate is untouched,+and ${KICAD*_3DMODEL_DIR} standard refs are left alone.++usage: kicad_board_bundle.py <project-dir> [--normalize] prints JSON: { pro, pcb, schematics[], sym, pretty[], board_name,-               models_std[], models_custom[], components[{ref,value,mpn,slug}] }+               models_std[], models_custom[], components[{ref,value,mpn,slug}],+               normalized? } """ import sys, os, re, json, glob @@ -17,7 +26,32 @@ def slugify(s):     return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")  -def main(d):+def basename(path):+    return re.split(r"[\\/]", path)[-1]+++def normalize_pcb(pcb_path, shapes_dir):+    """Rewrite custom (model "...") refs to <shapes_dir>/<basename>. Returns the+    count of refs actually changed. ${...} standard refs are left untouched."""+    txt = open(pcb_path, encoding="utf-8", errors="ignore").read()+    changed = [0]++    def repl(m):+        path = m.group(1)+        if path.startswith("${"):+            return m.group(0)+        newp = f"{shapes_dir}/{basename(path)}"+        if newp != path:+            changed[0] += 1+        return f'(model "{newp}"'++    out = re.sub(r'\(model\s+"([^"]+)"', repl, txt)+    if changed[0]:+        open(pcb_path, "w", encoding="utf-8").write(out)+    return changed[0]+++def main(d, normalize=False):     g = lambda pat: sorted(glob.glob(os.path.join(d, "**", pat), recursive=True))     pro = (g("*.kicad_pro") or [None])[0]     pcb = (g("*.kicad_pcb") or [None])[0]@@ -26,13 +60,24 @@ def main(d):     pretty = sorted(p for p in g("*.pretty") if os.path.isdir(p))     board_name = os.path.splitext(os.path.basename(pro or pcb or "board"))[0] +    result = {+        "pro": pro, "pcb": pcb, "schematics": schs, "sym": sym, "pretty": pretty,+        "board_name": board_name,+    }++    # normalize BEFORE reporting so the reported refs reflect the fixed file+    if normalize and pcb:+        result["normalized"] = normalize_pcb(pcb, f"{board_name}.3dshapes")+     # 3D model refs from the PCB: ${KICAD*_3DMODEL_DIR}/... resolve on the render-    # service; absolute paths must be bundled into <Board>.3dshapes/ by basename.+    # service; everything else is custom and must be bundled into <Board>.3dshapes/.     models_std, models_custom = set(), set()     if pcb:         txt = open(pcb, encoding="utf-8", errors="ignore").read()         for m in re.findall(r'\(model\s+"([^"]+)"', txt):             (models_std if m.startswith("${") else models_custom).add(m)+    result["models_std"] = sorted(models_std)+    result["models_custom"] = sorted(models_custom)      # components: each schematic symbol instance's Reference + an MPN-like property     mpn_keys = ("MPN", "Manufacturer Part Number", "Mfr Part #", "Mfr. Part #",@@ -60,16 +105,13 @@ def main(d):                 "mpn": mpn,                 "slug": slugify(mpn) if mpn else None,             }+    result["components"] = list(comps.values()) -    print(json.dumps({-        "pro": pro, "pcb": pcb, "schematics": schs, "sym": sym, "pretty": pretty,-        "board_name": board_name,-        "models_std": sorted(models_std), "models_custom": sorted(models_custom),-        "components": list(comps.values()),-    }, indent=2))+    print(json.dumps(result, indent=2))   if __name__ == "__main__":-    if len(sys.argv) < 2:-        sys.exit("usage: kicad_board_bundle.py <project-dir>")-    main(sys.argv[1])+    args = [a for a in sys.argv[1:] if not a.startswith("--")]+    if not args:+        sys.exit("usage: kicad_board_bundle.py <project-dir> [--normalize]")+    main(args[0], normalize="--normalize" in sys.argv)
src/main.rs+531−19
@@ -20,7 +20,8 @@ use std::sync::Mutex; use tiny_http::{Header, Method, Response, Server};  fn main() {-    // Minimal arg parse: `--port <n>` / `--port=<n>` (default 8785).+    // `--render-board <slug>`: regenerate + upload the page's render/ assets, then+    // exit (used by the publish flow). Otherwise serve on `--port <n>` (default 8785).     let mut port = 8785u16;     let mut args = std::env::args().skip(1);     while let Some(a) = args.next() {@@ -30,6 +31,9 @@ fn main() {             }         } else if let Some(v) = a.strip_prefix("--port=") {             port = v.parse().unwrap_or(port);+        } else if a == "--render-board" {+            let slug = args.next().unwrap_or_default();+            std::process::exit(if !slug.is_empty() && render_and_upload(&slug) { 0 } else { 1 });         }     }     if let Err(e) = run(port) {@@ -38,6 +42,51 @@ fn main() {     } } +/// Render a board's GLB + PCB SVG + schematic SVGs live and publish them to the+/// page's `render/` folder (with a manifest), so the Manager can serve them+/// instantly. Run on every board publish/update to keep the assets fresh.+fn render_and_upload(slug: &str) -> bool {+    eprintln!("rendering {slug} …");+    let (sheets, pcb_svg, glb) = render_board_media(slug);+    if glb.is_none() && pcb_svg.is_none() && sheets.is_empty() {+        eprintln!("nothing rendered (not a KiCad board?)");+        return false;+    }+    let work = format!("{SCRATCH}/render-{}", slug.replace(['/', ' '], "_"));+    let rdir = format!("{work}/render");+    let _ = std::fs::remove_dir_all(&work);+    if std::fs::create_dir_all(&rdir).is_err() {+        return false;+    }+    if let Some(g) = &glb {+        let _ = std::fs::write(format!("{rdir}/board.glb"), g);+    }+    if let Some(s) = &pcb_svg {+        let _ = std::fs::write(format!("{rdir}/pcb.svg"), s);+    }+    for (i, (_, svg)) in sheets.iter().enumerate() {+        let _ = std::fs::write(format!("{rdir}/sheet-{i:03}.svg"), svg);+    }+    let manifest = json!({+        "v": 1,+        "glb": glb.is_some(),+        "pcb": pcb_svg.is_some(),+        "sheets": sheets.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>(),+    });+    let _ = std::fs::write(format!("{rdir}/manifest.json"), serde_json::to_string_pretty(&manifest).unwrap());+    // repo push the render/ folder (bare slug works for org-owned pages too)+    let bare = slug.rsplit('/').next().unwrap_or(slug);+    let ok = Command::new("adom-wiki")+        .args(["repo", "push", bare, "--files", "render", "-m",+            "regenerate board render assets (3D GLB + PCB/schematic SVGs)"])+        .current_dir(&work)+        .status()+        .map(|s| s.success())+        .unwrap_or(false);+    eprintln!("{}", if ok { "uploaded render/ assets" } else { "upload FAILED" });+    ok+}+ const WIKI: &str = "https://wiki.adom.inc"; // Hosted headless KiCad 10 (service-kicad) — renders board SVG/GLB server-side, // so board views need no local kicad-cli and no desktop bridge.@@ -129,6 +178,25 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> {         (Method::Post, "/api/apply") => json_resp(&start_apply(body).to_string()),         (Method::Get, "/api/apply-progress") => json_resp(&get_progress().to_string()),         (Method::Get, "/api/env") => json_resp(&api_env().to_string()),+        (Method::Get, "/api/libraries") => json_resp(&user_libraries().to_string()),+        (Method::Get, "/api/pick-folder") => json_resp(&pick_folder().to_string()),+        (Method::Get, p) if p.starts_with("/api/project-diff") => {+            let param = |k: &str| url.split_once(&format!("{k}=")).map(|(_, v)| urldecode(v.split('&').next().unwrap_or(v))).unwrap_or_default();+            json_resp(&project_diff(&param("board"), &param("dest")).to_string())+        }+        (Method::Get, p) if p.starts_with("/api/in-library") => {+            let slugs: Vec<String> = url.split_once("?slugs=")+                .map(|(_, q)| urldecode(q.split('&').next().unwrap_or(q)))+                .unwrap_or_default()+                .split(',').filter(|s| !s.is_empty()).map(String::from).collect();+            let matched: std::collections::HashMap<String, Value> = components_in_libs(&slugs)+                .into_iter().map(|(s, by, lib)| (s, json!({"by": by, "lib": lib}))).collect();+            json_resp(&json!(matched).to_string())+        }+        (Method::Get, p) if p.starts_with("/api/suggest-path") => {+            let prefix = url.split_once("?p=").map(|(_, q)| urldecode(q.split('&').next().unwrap_or(q))).unwrap_or_default();+            json_resp(&suggest_path(&prefix).to_string())+        }         (Method::Get, "/") => html(INDEX_HTML),         (Method::Get, "/api/components") => {             // ?refresh=1 busts the server-side cache so newly-pushed components appear@@ -152,20 +220,31 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> {         (Method::Get, p) if p.starts_with("/board-svg/") => {             // forms: /board-svg/<slug>/pcb  or  /board-svg/<slug>/sch/<idx>             let segs: Vec<&str> = p["/board-svg/".len()..].splitn(3, '/').collect();-            let svg = cached_board(segs.first().copied().unwrap_or("")).and_then(|(sheets, pcb, _)| {-                match segs.get(1).copied() {+            let slug = segs.first().copied().unwrap_or("");+            // live in-memory render first; else a stored render/ asset from the page+            let svg = cached_board(slug)+                .and_then(|(sheets, pcb, _)| match segs.get(1).copied() {                     Some("pcb") => pcb,                     Some("sch") => segs.get(2).and_then(|i| i.parse::<usize>().ok()).and_then(|i| sheets.get(i).map(|(_, s)| s.clone())),                     _ => None,-                }-            });+                })+                .or_else(|| {+                    let file = match segs.get(1).copied() {+                        Some("pcb") => Some("render/pcb.svg".to_string()),+                        Some("sch") => segs.get(2).and_then(|i| i.parse::<usize>().ok()).map(|i| format!("render/sheet-{i:03}.svg")),+                        _ => None,+                    };+                    file.and_then(|f| wiki_bytes(slug, &f)).and_then(|b| String::from_utf8(b).ok())+                });             match svg {                 Some(s) => Response::from_string(s).with_header(hdr("image/svg+xml")),                 None => Response::from_string("not rendered").with_status_code(404),             }         }         (Method::Get, p) if p.starts_with("/board-glb/") => {-            match cached_board(&p["/board-glb/".len()..]).and_then(|(_, _, g)| g) {+            let slug = &p["/board-glb/".len()..];+            let glb = cached_board(slug).and_then(|(_, _, g)| g).or_else(|| wiki_bytes(slug, "render/board.glb"));+            match glb {                 Some(g) => Response::from_data(g).with_header(hdr("model/gltf-binary")),                 None => Response::from_string("not rendered").with_status_code(404),             }@@ -687,6 +766,14 @@ fn kicad_dir(sub: &str) -> String {     home_path(&["Documents", "KiCad", sub]) } +/// Everything before the last path separator (the containing directory).+fn parent_dir(path: &str) -> String {+    match path.rfind(['/', '\\']) {+        Some(i) => path[..i].to_string(),+        None => ".".to_string(),+    }+}+ /// GET /api/env — the user's machine OS + home + OS-appropriate default delivery /// folders, so the UI's "Deliver project to" picker isn't hard-coded to Linux. fn api_env() -> Value {@@ -704,8 +791,141 @@ fn api_env() -> Value {     }) } +/// The KiCad config dir on the user's machine (holds the global lib tables).+fn kicad_config_base() -> String {+    let (os, home) = machine();+    match os.as_str() {+        "macos" => format!("{home}/Library/Preferences/kicad"),+        "windows" => format!("{home}\\AppData\\Roaming\\kicad"),+        _ => format!("{home}/.config/kicad"),+    }+}++/// True for a KiCad-bundled standard library (as opposed to the user's own) —+/// identified by a `${KICAD*_…_DIR}` variable or an install-dir path.+fn is_stock_kicad_lib(uri: &str) -> bool {+    let u = uri.to_lowercase();+    uri.starts_with("${KICAD")+        || u.contains("/usr/share/kicad")+        || u.contains("/usr/lib/kicad")+        || u.contains("program files\\kicad")+        || u.contains("program files/kicad")+        || u.contains("/applications/kicad")+}++/// Parse `(lib (name "X") … (uri "Y"))` entries out of a KiCad lib-table.+fn parse_lib_table(text: &str) -> Vec<Value> {+    text.split("(lib ")+        .skip(1)+        .filter_map(|chunk| {+            let name = chunk.find("(name \"").and_then(|i| chunk[i + 7..].split('"').next())?;+            let uri = chunk.find("(uri \"").and_then(|i| chunk[i + 6..].split('"').next())?;+            if name.is_empty() {+                return None;+            }+            Some(json!({ "name": name, "uri": uri }))+        })+        .collect()+}++/// GET /api/libraries — the user's REAL KiCad symbol + footprint libraries (name+/// + on-disk path) read from their global sym-lib-table / fp-lib-table via the+/// bridge, so placement suggests the user's own libraries — never hard-coded ones.+fn user_libraries() -> Value {+    let (os, _) = machine();+    let base = kicad_config_base();+    let read = |table: &str| -> Vec<Value> {+        let cmd = if os == "windows" {+            format!("for /d %d in (\"{base}\\*\") do @type \"%d\\{table}\" 2>nul")+        } else {+            format!("cat {base}/*/{table} 2>/dev/null")+        };+        // merge across KiCad versions, first occurrence of each name wins+        let mut seen = std::collections::HashSet::new();+        ad_run(&cmd)+            .map(|t| parse_lib_table(&t))+            .unwrap_or_default()+            .into_iter()+            .filter(|l| {+                let n = l.get("name").and_then(|x| x.as_str()).unwrap_or("").to_string();+                let uri = l.get("uri").and_then(|x| x.as_str()).unwrap_or("");+                // keep only the user's PERSONAL libraries: drop KiCad's bundled+                // standard libs and the built-in default-table pointer.+                n != "KiCad" && !is_stock_kicad_lib(uri) && seen.insert(n)+            })+            .collect()+    };+    json!({ "symLibs": read("sym-lib-table"), "fpLibs": read("fp-lib-table") })+}++/// Minimal percent-decoding for query values (`%20`, `+`, `%2F`, …).+fn urldecode(s: &str) -> String {+    let b = s.as_bytes();+    let mut out = Vec::with_capacity(b.len());+    let mut i = 0;+    while i < b.len() {+        if b[i] == b'%' && i + 3 <= b.len() {+            if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {+                out.push(v);+                i += 3;+                continue;+            }+        }+        out.push(if b[i] == b'+' { b' ' } else { b[i] });+        i += 1;+    }+    String::from_utf8_lossy(&out).into_owned()+}++/// GET /api/pick-folder — pop a NATIVE folder-picker dialog on the user's machine+/// (via the bridge) and return the chosen directory. Blocks until the user picks+/// or cancels (empty path = cancelled / no dialog available).+fn pick_folder() -> Value {+    let (os, _) = machine();+    let cmd = match os.as_str() {+        "windows" => "powershell -NoProfile -STA -Command \"Add-Type -AssemblyName System.Windows.Forms; $f=New-Object System.Windows.Forms.FolderBrowserDialog; if($f.ShowDialog() -eq 'OK'){[Console]::Out.Write($f.SelectedPath)}\"".to_string(),+        "macos" => "osascript -e 'POSIX path of (choose folder)' 2>/dev/null".to_string(),+        _ => "zenity --file-selection --directory 2>/dev/null".to_string(),+    };+    let path = ad_run(&cmd).unwrap_or_default().trim().to_string();+    json!({ "path": path, "cancelled": path.is_empty() })+}++/// GET /api/suggest-path?p=<prefix> — existing directories on the user's machine+/// whose path starts with the typed prefix (for the delivery autosuggest).+fn suggest_path(prefix: &str) -> Value {+    let (os, _) = machine();+    let clean: String = prefix.chars().filter(|c| !"\"'`;|&\n\r$".contains(*c)).collect();+    let cmd = if os == "windows" {+        format!("dir /b /ad \"{clean}*\" 2>nul")+    } else {+        format!("for d in {clean}*/ ; do [ -d \"$d\" ] && echo \"$d\" ; done 2>/dev/null")+    };+    let dirs: Vec<String> = ad_run(&cmd)+        .map(|t| t.lines().map(|l| l.trim_end_matches(['/', '\\']).to_string()).filter(|l| !l.is_empty()).take(14).collect())+        .unwrap_or_default();+    json!({ "dirs": dirs })+}+ const PYTHON_SELFCONTAIN: &str = include_str!("selfcontain.py"); +/// The `.kicad_mod` filenames present in a directory on the user's machine.+fn list_kicad_mods(dir: &str) -> Vec<String> {+    let (os, _) = machine();+    let out = if os == "windows" {+        ad_run(&format!("dir /b \"{}\\*.kicad_mod\" 2>nul", dir.replace('"', "")))+    } else {+        ad_run(&format!("ls {}/*.kicad_mod 2>/dev/null", sh_quote(dir)))+    };+    out.map(|o| {+        o.lines()+            .filter_map(|l| l.rsplit(['/', '\\']).next().map(|s| s.trim().to_string()))+            .filter(|s| s.ends_with(".kicad_mod"))+            .collect()+    })+    .unwrap_or_default()+}+ /// Fetch every KiCad project file on a board's wiki page (`.kicad_pro`, /// `.kicad_pcb`, root + hierarchical `.kicad_sch`). When `project_only`, make the /// project self-contained: extract the embedded symbols/footprints into a project@@ -764,8 +984,16 @@ fn fetch_project_files(board: &str, dest: &str, project_only: bool) -> (String,                 }                 if !pretty.is_empty() {                     let pdir = format!("{dest}/{libnick}.pretty");+                    // if the project library already exists, update it as a DIFF: send+                    // the current footprints (add/replace), then delete any .kicad_mod+                    // that's no longer part of the board.+                    let keep: std::collections::HashSet<String> = pretty.iter().filter_map(|p| p.rsplit(['/', '\\']).next().map(String::from)).collect();+                    let stale: Vec<String> = list_kicad_mods(&pdir).into_iter().filter(|f| !keep.contains(f)).collect();                     ad_shell(&format!("mkdir -p {}", sh_quote(&pdir)));                     ad_send(&pretty, &pdir);+                    for f in stale {+                        ad_shell(&format!("rm -f {}", sh_quote(&format!("{pdir}/{f}"))));+                    }                 }                 got = root.iter().chain(pretty.iter()).filter_map(|p| p.rsplit('/').next().map(String::from)).collect();                 return (dest, got);@@ -945,7 +1173,11 @@ fn render_board_glb(slug: &str) -> Option<Vec<u8>> {     let pcb_name = files.iter().find(|n| n.ends_with(".kicad_pcb"))?;     let shapes_dir = files.iter().find(|n| n.ends_with(".3dshapes")).cloned();     let pcb = String::from_utf8(wiki_bytes(slug, pcb_name)?).ok()?;-    // rewrite absolute (model "/…/<name>") refs to ${KIPRJMOD}/3d/<name>; collect names+    // Normalize every custom (model "…") ref to ${KIPRJMOD}/3d/<name> and collect+    // the names. Custom = anything NOT a ${KICAD*_3DMODEL_DIR} standard ref: a+    // machine-absolute Unix path (/home/…), a Windows path (C:\Users\…), OR a+    // project-relative path (<Board>.3dshapes/…). Handling all three means the 3D+    // renders regardless of how the source .kicad_pcb wrote its paths.     let mut models: std::collections::HashSet<String> = std::collections::HashSet::new();     let mut out = String::with_capacity(pcb.len());     let mut rest = pcb.as_str();@@ -955,12 +1187,13 @@ fn render_board_glb(slug: &str) -> Option<Vec<u8>> {         match after.find('"') {             Some(end) => {                 let path = &after[..end];-                if path.starts_with('/') {-                    let base = path.rsplit('/').next().unwrap_or(path);+                if path.starts_with("${") {+                    // standard KiCad 3D model dir — the render service already has it+                    out.push_str(path);+                } else {+                    let base = path.rsplit(['/', '\\']).next().unwrap_or(path);                     models.insert(base.to_string());                     out.push_str(&format!("${{KIPRJMOD}}/3d/{base}"));-                } else {-                    out.push_str(path);                 }                 rest = &after[end..];             }@@ -1007,10 +1240,28 @@ fn render_board_media(slug: &str) -> BoardMedia {     (sheets, pcb_svg, glb) } -/// {rendering, has_3d, has_sch, has_pcb}. Rendering runs in a background thread-/// (a board GLB can take ~a minute) so the single-threaded server stays-/// responsive; the UI polls until `rendering` is false.+/// The pre-rendered assets published to a board's `render/` folder, if any+/// (manifest = which assets exist + the ordered sheet names). When present the+/// Manager serves them directly — no service-kicad, no wait — instead of+/// rendering live. Regenerated on every board publish (`--render-board`).+fn stored_render(slug: &str) -> Option<Value> {+    wiki_bytes(slug, "render/manifest.json").and_then(|b| serde_json::from_slice::<Value>(&b).ok())+}++/// {rendering, has_3d, has_sch, has_pcb}. With pre-rendered assets it returns+/// immediately; otherwise rendering runs in a background thread (a board GLB can+/// take ~a minute) so the single-threaded server stays responsive and the UI+/// polls until `rendering` is false. fn board_media(slug: &str) -> Value {+    // fast path: assets pre-rendered to the page's render/ folder at publish time+    if let Some(m) = stored_render(slug) {+        let sheets: Vec<String> = m.get("sheets").and_then(|s| s.as_array())+            .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())+            .unwrap_or_default();+        return json!({"rendering": false, "is_kicad": true, "stored": true, "sheets": sheets,+            "has_3d": m.get("glb").and_then(|b| b.as_bool()).unwrap_or(false),+            "has_pcb": m.get("pcb").and_then(|b| b.as_bool()).unwrap_or(false)});+    }     if let Some((sheets, pcb, glb)) = cached_board(slug) {         let names: Vec<String> = sheets.into_iter().map(|(n, _)| n).collect();         return json!({"rendering": false, "is_kicad": true, "sheets": names, "has_3d": glb.is_some(), "has_pcb": pcb.is_some()});@@ -1190,6 +1441,244 @@ fn start_apply(body: &str) -> Value {     json!({"status":"started","total":total}) } +/// Every symbol name already present anywhere in the user's symbol libraries,+/// mapped to the library that has it — so an existing symbol is never re-added.+/// Best-effort (Unix grep / Windows Select-String); empty on failure.+fn existing_symbol_index(files: &[String]) -> std::collections::HashMap<String, String> {+    let mut idx = std::collections::HashMap::new();+    let files: Vec<&String> = files.iter().filter(|f| !f.is_empty()).collect();+    if files.is_empty() {+        return idx;+    }+    let (os, _) = machine();+    let out = if os == "windows" {+        let list = files.iter().map(|f| format!("'{}'", f.replace('\'', ""))).collect::<Vec<_>>().join(",");+        ad_run(&format!("powershell -NoProfile -Command \"Select-String -Path {list} -Pattern '\\(symbol \\\"([^\\\"]+)\\\"' | ForEach-Object {{ $_.Path + [char]9 + $_.Matches.Groups[1].Value }}\""))+    } else {+        let list = files.iter().map(|f| sh_quote(f)).collect::<Vec<_>>().join(" ");+        ad_run(&format!("grep -HoE '\\(symbol \"[^\"]+\"' {list} 2>/dev/null"))+    };+    if let Some(out) = out {+        for line in out.lines() {+            let (file, name) = if os == "windows" {+                match line.rsplit_once('\t') { Some((f, n)) => (f, n.trim().to_string()), None => continue }+            } else {+                match line.split_once(":(symbol \"") { Some((f, r)) => (f, r.trim_end_matches('"').to_string()), None => continue }+            };+            if !name.is_empty() {+                let lib = file.rsplit(['/', '\\']).next().unwrap_or(file).trim_end_matches(".kicad_sym").to_string();+                idx.entry(name).or_insert(lib);+            }+        }+    }+    idx+}++/// Which of the given MPNs appear (case-insensitively) in the user's symbol+/// libraries → a map from the found MPN (lowercased) to the library it's in.+/// Catches parts whose library symbol embeds the manufacturer part number.+fn mpns_present_in_libs(files: &[String], mpns: &[String]) -> std::collections::HashMap<String, String> {+    let files: Vec<&String> = files.iter().filter(|f| !f.is_empty()).collect();+    let pats: Vec<String> = mpns.iter()+        .map(|m| m.chars().filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_').collect::<String>())+        .filter(|s| s.len() >= 4)+        .collect();+    if files.is_empty() || pats.is_empty() {+        return Default::default();+    }+    let (os, _) = machine();+    let pat = pats.join("|");+    let out = if os == "windows" {+        let list = files.iter().map(|f| format!("'{}'", f.replace('\'', ""))).collect::<Vec<_>>().join(",");+        ad_run(&format!("powershell -NoProfile -Command \"Select-String -Path {list} -Pattern '(?i)({pat})' -AllMatches | ForEach-Object {{ $_.Path + [char]9 + $_.Matches.Value }}\""))+    } else {+        let list = files.iter().map(|f| sh_quote(f)).collect::<Vec<_>>().join(" ");+        ad_run(&format!("grep -HioE '({pat})' {list} 2>/dev/null"))+    };+    // parse "<file>:<match>" (unix, split on the LAST ':') / "<path>\t<match>" (windows)+    let mut map = std::collections::HashMap::new();+    if let Some(out) = out {+        for line in out.lines() {+            let split = if os == "windows" { line.rsplit_once('\t') } else { line.rsplit_once(':') };+            if let Some((file, m)) = split {+                let m = m.trim().to_lowercase();+                if m.is_empty() {+                    continue;+                }+                let lib = file.rsplit(['/', '\\']).next().unwrap_or(file).trim_end_matches(".kicad_sym").to_string();+                map.entry(m).or_insert(lib);+            }+        }+    }+    map+}++/// Normalize a value/name for fuzzy compare: lowercase, µ→u, alphanumerics only.+fn norm_val(s: &str) -> String {+    s.to_lowercase().replace(['µ', 'μ'], "u").chars().filter(|c| c.is_ascii_alphanumeric()).collect()+}++/// The imperial size code in a package string (0402/0603/0805/1206/…), or "".+fn pkg_size(package: &str) -> String {+    let b = package.as_bytes();+    let mut i = 0;+    while i + 4 <= b.len() {+        if b[i..i + 4].iter().all(|c| c.is_ascii_digit())+            && (i == 0 || !b[i - 1].is_ascii_digit())+            && (i + 4 == b.len() || !b[i + 4].is_ascii_digit())+        {+            return package[i..i + 4].to_string();+        }+        i += 1;+    }+    String::new()+}++/// Derive a (normalized-value, package) signature from a component's symbol name+/// and footprint name — the wiki symbols encode it (`C_10uF_0402`, footprint+/// `C_0402_1005Metric`) even when the part JSON's value/package fields are blank.+/// None when there's no package code (e.g. an IC) — those match by MPN only.+fn value_package_sig(sym_name: &str, fp_name: &str) -> Option<(String, String)> {+    let pkg = {+        let p = pkg_size(fp_name);+        if p.is_empty() { pkg_size(sym_name) } else { p }+    };+    if pkg.is_empty() {+        return None;+    }+    // the value token: the first name segment with a digit that isn't the package+    let val = sym_name+        .split(|c: char| !c.is_ascii_alphanumeric() && c != '.')+        .find(|t| t.chars().any(|c| c.is_ascii_digit()) && pkg_size(t).is_empty())?;+    let v = norm_val(val);+    if v.len() >= 2 { Some((v, pkg)) } else { None }+}++/// Which of the given component slugs are ALREADY in the user's libraries, each+/// with the reason ("mpn" | "symbol" | "value+package"). Matches by MPN (grep, or+/// a library symbol name that PREFIXES the MPN — e.g. "BMA580" for "BMA580-561000",+/// which covers stub components with no symbol/footprint on the wiki), else exact+/// symbol name, else value + package. Reused by the delivery and the live preview.+fn components_in_libs(slugs: &[String]) -> Vec<(String, String, String)> {+    let libs = user_libraries();+    let sym_files: Vec<String> = libs.get("symLibs").and_then(|a| a.as_array())+        .map(|a| a.iter().filter_map(|l| l.get("uri").and_then(|u| u.as_str()).map(String::from)).collect())+        .unwrap_or_default();+    if sym_files.is_empty() || slugs.is_empty() {+        return vec![];+    }+    let mpn_map = mpns_present_in_libs(&sym_files, &slugs.iter().map(|s| up(s)).collect::<Vec<_>>());+    // normalized symbol name -> the library it lives in+    let norm_index: std::collections::HashMap<String, String> =+        existing_symbol_index(&sym_files).into_iter().map(|(n, l)| (norm_val(&n), l)).collect();+    let mut out = vec![];+    for slug in slugs {+        let nmpn = norm_val(&up(slug));+        if let Some(lib) = mpn_map.get(&up(slug).to_lowercase()) {+            out.push((slug.clone(), "mpn".to_string(), lib.clone()));+            continue;+        }+        if let Some((_, lib)) = norm_index.iter().find(|(n, _)| n.len() >= 5 && nmpn.starts_with(n.as_str())) {+            out.push((slug.clone(), "mpn".to_string(), lib.clone()));+            continue;+        }+        if let Ok((part, _)) = load_part(slug, &up(slug)) {+            let sn = norm_val(&part.symbol.name);+            if sn.len() >= 3 {+                if let Some(lib) = norm_index.get(&sn) {+                    out.push((slug.clone(), "symbol".to_string(), lib.clone()));+                    continue;+                }+            }+            if let Some((v, p)) = value_package_sig(&part.symbol.name, &part.footprint.name) {+                if let Some((_, lib)) = norm_index.iter().find(|(n, _)| n.contains(&v) && n.contains(&p)) {+                    out.push((slug.clone(), "value+package".to_string(), lib.clone()));+                }+            }+        }+    }+    out+}++/// Diff a board's components against its self-contained project library at `dest`+/// on the user's machine. Returns which components are being ADDED (not yet in the+/// project lib), KEPT (already there), and which library symbols would be REMOVED+/// (in the lib, no longer on the board). `exists` = the project lib was found.+fn project_diff(board: &str, dest: &str) -> Value {+    let libnick = board.replace(['/', ' '], "_");+    let s = sep();+    let mut lib_path = format!("{}{s}{libnick}.kicad_sym", dest.trim_end_matches(['/', '\\']));+    // if it's not at the given destination, search the user's Documents for it+    // (the default path guess can be wrong — e.g. "KiCad" vs "KiCAD")+    if ad_file_exists(&lib_path) != Some(true) {+        let (os, home) = machine();+        let search = if os == "windows" {+            format!("dir /b /s \"{home}\\Documents\\{libnick}.kicad_sym\" 2>nul")+        } else {+            format!("find {home}/Documents -maxdepth 6 -name '{libnick}.kicad_sym' 2>/dev/null | head -1")+        };+        if let Some(found) = ad_run(&search).filter(|f| !f.trim().is_empty()) {+            lib_path = found.lines().next().unwrap_or("").trim().to_string();+        }+    }+    // top-level symbol names already in the project lib (skip _N_N unit sub-symbols)+    let existing: Vec<String> = ad_pull(&lib_path)+        .and_then(|b| String::from_utf8(b).ok())+        .map(|t| {+            let mut names = vec![];+            let mut idx = 0;+            while let Some(p) = t[idx..].find("(symbol \"") {+                let start = idx + p + 9;+                match t[start..].find('"') {+                    Some(e) => {+                        let raw = &t[start..start + e];+                        let unit = { let tail: Vec<&str> = raw.rsplitn(3, '_').collect(); tail.len() == 3 && tail[0].chars().all(|c| c.is_ascii_digit()) && tail[1].chars().all(|c| c.is_ascii_digit()) && !tail[0].is_empty() && !tail[1].is_empty() };+                        if !unit {+                            names.push(raw.to_string());+                        }+                        idx = start + e;+                    }+                    None => break,+                }+            }+            names+        })+        .unwrap_or_default();+    let exists = !existing.is_empty() || ad_file_exists(&lib_path) == Some(true);+    // The project library names its symbols the way the board's schematic does+    // (generic "C"/"R"/"BMA580"), and neither carries MPNs — so compare the library+    // against the board's actual SCHEMATIC symbol set, not the components' names.+    let sch_syms = board_schematic_symbols(board);+    let slugs: Vec<String> = board_deps(board).get("deps").and_then(|d| d.as_array())+        .map(|a| a.iter().filter_map(|x| x.get("slug").and_then(|s| s.as_str()).map(String::from)).collect())+        .unwrap_or_default();+    // library symbols no longer used anywhere on the board → flagged for deletion+    let remove: Vec<String> = existing.iter().filter(|n| !sch_syms.contains(&norm_val(n))).cloned().collect();+    // the project library is built from THIS board: if it already exists every+    // component is already in it (kept); otherwise every component is created (added).+    let (add, keep) = if exists { (Vec::<String>::new(), slugs) } else { (slugs, Vec::<String>::new()) };+    json!({ "exists": exists, "add": add, "keep": keep, "remove": remove })+}++/// The set of symbol names (normalized) the board's schematic actually uses — the+/// name after the `lib_id "Lib:Name"` colon, across every `.kicad_sch` on the page.+fn board_schematic_symbols(board: &str) -> std::collections::HashSet<String> {+    let mut syms = std::collections::HashSet::new();+    for name in list_page_files(board).iter().filter(|n| n.ends_with(".kicad_sch")) {+        if let Some(txt) = wiki_text(board, name) {+            for chunk in txt.split("(lib_id \"").skip(1) {+                if let Some(id) = chunk.split('"').next() {+                    let n = id.rsplit(':').next().unwrap_or(id);+                    if !n.is_empty() {+                        syms.insert(norm_val(n));+                    }+                }+            }+        }+    }+    syms+}+ /// The actual work — converts + merges + delivers, updating PROGRESS at each step. fn run_apply(items: Vec<(String, String, String)>, board: Option<String>, dest: String, project_only: bool) {     let _ = std::fs::create_dir_all(SCRATCH);@@ -1200,6 +1689,27 @@ fn run_apply(items: Vec<(String, String, String)>, board: Option<String>, dest:         by_symlib.entry(sym.clone()).or_default().push(slug.clone());         by_fplib.entry(fp.clone()).or_default().push(slug.clone());     }+    // resolve each library NAME to its real on-disk path from the user's lib tables;+    // a name not in their tables (a brand-new library) falls back to the KiCad dir.+    let libs = user_libraries();+    let path_map = |key: &str| -> std::collections::HashMap<String, String> {+        libs.get(key).and_then(|a| a.as_array()).map(|a| {+            a.iter().filter_map(|l| Some((l.get("name")?.as_str()?.to_string(), l.get("uri")?.as_str()?.to_string()))).collect()+        }).unwrap_or_default()+    };+    let sym_paths = path_map("symLibs");+    let fp_paths = path_map("fpLibs");+    // for "integrate into my libraries": index every symbol/footprint already in+    // the user's libraries so an existing one is skipped instead of duplicated.+    // (Project-only mode builds a fresh isolated library — nothing to dedup.)+    // which components are ALREADY in the user's libraries: match by MPN (which+    // some library symbols embed), else by value + package. Skip those entirely+    // (both symbol and footprint) so nothing gets duplicated.+    // No blanket "already in a library" skip: the target library is pre-filled in+    // the UI with wherever the part already lives, so merge_symbols (per-target+    // dedup) keeps it out when unchanged and adds it when the user picks a+    // different library. Changing the dropdown → a new symbol/footprint there.+    let skipped: Vec<Value> = vec![];     let total = items.len();     let mut done_n = 0usize;     let mut added: Vec<Value> = vec![];@@ -1221,7 +1731,7 @@ fn run_apply(items: Vec<(String, String, String)>, board: Option<String>, dest:             }         }         prog(format!("Merging {} symbol(s) into {symlib}", blocks.len()), done_n);-        let remote = format!("{}{}{symlib}.kicad_sym", kicad_dir("Symbols"), sep());+        let remote = sym_paths.get(symlib).cloned().unwrap_or_else(|| format!("{}{}{symlib}.kicad_sym", kicad_dir("Symbols"), sep()));         let existing = ad_pull(&remote).map(|b| String::from_utf8_lossy(&b).to_string());         // NEVER overwrite a library we failed to read. ad_pull returns None for         // both "absent" and "pull failed" — only proceed with a fresh library@@ -1253,12 +1763,14 @@ fn run_apply(items: Vec<(String, String, String)>, board: Option<String>, dest:                 continue;             }         }-        let local = format!("{SCRATCH}/{symlib}.kicad_sym");+        // write back to the library's real file (send its basename to its dir)+        let base = remote.rsplit(['/', '\\']).next().unwrap_or("lib.kicad_sym").to_string();+        let local = format!("{SCRATCH}/{base}");         if let Err(e) = std::fs::write(&local, &merged) {             errors.push(format!("failed to stage {symlib}.kicad_sym locally: {e}"));             continue;         }-        if ad_send(&[local], &kicad_dir("Symbols")) {+        if ad_send(&[local], &parent_dir(&remote)) {             for (name, _) in &blocks {                 added.push(json!({"symbol": name, "symLib": symlib}));             }@@ -1269,7 +1781,7 @@ fn run_apply(items: Vec<(String, String, String)>, board: Option<String>, dest:      for (fplib, slugs) in &by_fplib {         prog(format!("Delivering footprints → {fplib}.pretty"), total);-        let dest = format!("{}{}{fplib}.pretty", kicad_dir("Footprints"), sep());+        let dest = fp_paths.get(fplib).cloned().unwrap_or_else(|| format!("{}{}{fplib}.pretty", kicad_dir("Footprints"), sep()));         ad_shell(&format!("mkdir -p {}", sh_quote(&dest)));         let mut files = vec![];         for slug in slugs {@@ -1296,7 +1808,7 @@ fn run_apply(items: Vec<(String, String, String)>, board: Option<String>, dest:         None => json!(null),     }; -    let result = json!({"status":"ok","count":added.len(),"added":added,"errors":errors,"project":project});+    let result = json!({"status":"ok","count":added.len(),"added":added,"skipped":skipped,"errors":errors,"project":project});     set_progress(json!({"stage":"Done","current":total,"total":total,"done":true,"result":result})); } 
src/manage.html+128−31
@@ -55,7 +55,27 @@   .modal .note{background:#12151c;border:1px solid var(--edge);border-radius:8px;padding:10px 12px;font-size:12px;color:var(--mut);margin:6px 0 4px}   #modal-dest{margin:0 0 16px}   #modal-dest label{display:block;font-size:11px;color:var(--mut);margin-bottom:6px;text-transform:uppercase;letter-spacing:.05em}-  #modal-dest .destrow{display:flex;gap:8px}+  #modal-dest .destrow{display:flex;gap:8px;align-items:center}+  #modal-dest .browsebtn{flex:2;text-align:left;background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:8px;padding:8px 10px;font:12px ui-monospace,monospace;cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}+  #modal-dest .browsebtn:hover{border-color:var(--acc)}+  #modal-dest #destname{flex:1}+  #modal-dest .destsep{color:var(--mut);font:14px ui-monospace,monospace;flex:0 0 auto}+  .libsel{background:#262c36;color:var(--ink);border:1px solid var(--edge);border-radius:6px;padding:3px 6px;font-size:12px;max-width:100%}+  .libsel:focus{outline:none;border-color:var(--acc)}+  .libsel option{background:#262c36;color:var(--ink)}+  .inlib td{color:#4ade80}+  .inlib .lib{color:#4ade80}+  .inlibtag{color:#4ade80;font-size:10px;font-weight:600;letter-spacing:.03em;white-space:nowrap}+  .rmrow td{color:#f87171}+  .rmtag{color:#f87171;font-size:10px;font-weight:600;letter-spacing:.03em;white-space:nowrap}+  .lib.addcell{color:#4ade80}+  .lib.keepcell{color:var(--ink)}+  .lib.rmcell{color:#f87171}+  #modal-status{font-size:12px;color:#c9d1d9;padding:7px 10px;border-radius:6px;background:#161b22;border:1px solid var(--edge);margin:2px 0 12px;position:relative;overflow:hidden}+  #modal-status:empty{display:none}+  #modal-status.done{color:#4ade80;border-color:rgba(74,222,128,.35)}+  #modal-status.loading::after{content:'';position:absolute;left:-30%;bottom:0;height:2px;width:30%;background:var(--acc);animation:barslide 1.1s ease-in-out infinite}+  @keyframes barslide{to{left:100%}}   #modal-dest select,#modal-dest input{background:#0d0f14;border:1px solid var(--edge);color:var(--ink);border-radius:8px;padding:8px 10px}   #modal-dest select{min-width:200px}   #modal-dest input{flex:1;font:12px ui-monospace,monospace}@@ -144,6 +164,7 @@   <aside id="list">     <h1>Adom <small>Project Manager</small><button id="refresh" title="refresh list (fetch newly-pushed boards)">↻</button></h1>     <div id="crumb"></div>+    <div id="tabbar" class="tabs"></div>     <input id="q" placeholder="filter boards…">     <div id="toolbar" style="display:none">       <label class="selall"><input type="checkbox" id="selall"> <span id="selcount">select all</span></label>@@ -161,6 +182,7 @@   </main>   <div id="modal" class="modal-overlay" style="display:none"><div class="modal">     <h2 id="modal-title"></h2><div class="msub" id="modal-sub"></div>+    <div id="modal-status" class="libstatus"></div>     <div class="opts" id="modal-opts"></div>     <div id="modal-dest"></div>     <div id="modal-preview"></div>@@ -175,6 +197,9 @@ const COLORS={Silkscreen:'#d8b4fe',Courtyard:'#4ade80','Fabrication / Assembly': let all=[], bySlug={}, boardSlug=null, curDeps=[], me='', pubQuery='', orgs=[], boardTab='mine'; // the user's machine env (OS + home + default delivery folders), from /api/env let env={os:'linux',home:'',sep:'/',dest_folders:[]};+// the user's REAL KiCad libraries (name+uri), from /api/libraries; curPlan = the+// components + their chosen target libraries for the open Add modal+let userLibs={symLibs:[],fpLibs:[]}, curPlan=[]; function destFolders(){ return (env.dest_folders&&env.dest_folders.length)?env.dest_folders:[(env.home||'~')+env.sep+'Documents'+env.sep+'KiCad'+env.sep+'Projects']; } const isBoard=c=>{const t=(c.tags||[]).map(x=>(''+x).toLowerCase());return t.includes('board')||t.includes('molecule');}; const qval=()=>document.getElementById('q').value.toLowerCase();@@ -186,10 +211,14 @@ function makeRow(slug,title,sub,onclick,tag,sel){   d.onclick=(e)=>{ if(e.target.classList.contains('selbox')){toggleSel(slug,e.target.checked);e.stopPropagation();return;} onclick(slug,d); };   return d; }-// component-type → the user's existing KiCad symbol library (fallback JLC_Misc_BLT)-const TYPE_TO_SYMLIB={'Capacitors':'JLC_Capacitors_BLT','Resistors':'JLC_Resistors_BLT','MOSFETs':'JLC_FETs_BLT','Transistors':'JLC_FETs_BLT','Inductors':'JLC_Inductors_BLT','LEDs':'JLC_LEDs_BLT','Diodes':'JLC_Diodes_BLT','Connectors':'JLC_Connectors_BLT','Microcontrollers':'JLC_MCUs_BLT','Power ICs':'JLC_Power_BLT','Crystals & Oscillators':'JLC_Oscillators_BLT','ICs':'JLC_Misc_BLT','Other':'JLC_Misc_BLT'};+// keywords per component type, used to pre-select the closest of the USER's own+// libraries (discovered from their machine) — never hard-coded personal names.+const TYPE_KEYWORDS={'Capacitors':['cap'],'Resistors':['res'],'MOSFETs':['fet','mos'],'Transistors':['fet','transistor','bjt'],'Inductors':['induct','coil','choke'],'LEDs':['led'],'Diodes':['diode'],'Connectors':['conn'],'Microcontrollers':['mcu','micro'],'Power ICs':['power','regulat','pmic'],'Crystals & Oscillators':['crystal','osc','xtal'],'ICs':['ic','misc'],'Other':['misc']}; const selected=new Set();-function symLibFor(c){return TYPE_TO_SYMLIB[typeOf(c)]||'JLC_Misc_BLT';}+// best guess among the user's discovered libraries for a component type+function bestLib(libs,type){const kws=TYPE_KEYWORDS[type]||[];for(const kw of kws){const m=libs.find(l=>l.name.toLowerCase().includes(kw));if(m)return m.name;}return libs[0]?libs[0].name:'';}+function symLibFor(c){return bestLib(userLibs.symLibs,typeOf(c));}+function fpLibFor(c){return bestLib(userLibs.fpLibs,typeOf(c));} function toggleSel(slug,on){on?selected.add(slug):selected.delete(slug);updateToolbar();} function updateToolbar(){   const n=selected.size;@@ -204,25 +233,33 @@ async function loadList(fresh){     const r=await fetch('api/components'+(fresh?'?refresh=1':'')).then(r=>r.json());     all=r.components||[]; me=r.me||''; orgs=r.orgs||[]; bySlug={}; all.forEach(c=>bySlug[c.slug]=c);     fetch('api/env').then(r=>r.json()).then(e=>{ if(e&&e.os) env=e; }).catch(()=>{});+    fetch('api/libraries').then(r=>r.json()).then(l=>{ if(l&&l.symLibs) userLibs=l; }).catch(()=>{});     render();   }catch(e){     rowsEl().innerHTML='<div class="row" style="color:#f87171;cursor:default">failed to load — retry</div>';   }finally{ if(btn)btn.classList.remove('spin'); } } function render(){ boardSlug?renderDeps():renderBoards(); }+// The Personal/Organization/Public nav lives in a persistent bar (not inside the+// scrolling list), so it's always available — even while a board is open. Clicking+// a tab leaves the current project and returns you to that board list.+function renderTabs(){+  const boards=all.filter(isBoard);+  const mineAll=boards.filter(c=>c.owner&&c.owner===me&&!c.org).length;+  const orgAll=boards.filter(c=>c.org&&orgs.includes(c.org)).length;+  const defs=[['mine',`Personal · ${mineAll}`],['org',`Organization · ${orgAll}`],['public','Public']];+  const bar=document.getElementById('tabbar');+  bar.innerHTML=defs.map(([k,label])=>`<button class="tab${boardTab===k?' active':''}" data-tab="${k}">${label}</button>`).join('');+  bar.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{ boardTab=t.dataset.tab; boardSlug?backToBoards():renderBoards(); });+} function renderBoards(){   document.getElementById('toolbar').style.display='none';selected.clear();   document.getElementById('crumb').innerHTML='<span class="cactive">Projects</span>';   const q=qval(),el=rowsEl();el.innerHTML='';+  renderTabs();   const boards=all.filter(isBoard);   const mineAll=boards.filter(c=>c.owner&&c.owner===me&&!c.org);   const orgAll=boards.filter(c=>c.org&&orgs.includes(c.org));-  // tabs: My projects (personal, no org) · Org (in my orgs) · Public (search)-  const tabbar=document.createElement('div');tabbar.className='tabs';-  const defs=[['mine',`Personal · ${mineAll.length}`],['org',`Organization · ${orgAll.length}`],['public','Public']];-  tabbar.innerHTML=defs.map(([k,label])=>`<button class="tab${boardTab===k?' active':''}" data-tab="${k}">${label}</button>`).join('');-  el.appendChild(tabbar);-  tabbar.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{boardTab=t.dataset.tab;renderBoards();});   const qbox=document.getElementById('q');qbox.style.display=boardTab==='public'?'none':'';qbox.placeholder='search projects…';   if(boardTab==='public'){renderPublic(boards,el);return;}   const list=(boardTab==='mine'?mineAll:orgAll).filter(c=>!q||c.slug.includes(q)||(c.title||'').toLowerCase().includes(q));@@ -409,6 +446,7 @@ function typeOf(c){   return 'Other'; } function renderDeps(){+  renderTabs();   document.getElementById('q').placeholder='filter components…';   document.getElementById('toolbar').style.display='flex';   const q=qval(),el=rowsEl();el.innerHTML='';@@ -517,13 +555,53 @@ function makePannable(box){ } // ---- selection controls + add-to-library / add-project modal ---- document.getElementById('selall').onchange=(e)=>{const on=e.target.checked;curDeps.forEach(s=>on?selected.add(s):selected.delete(s));renderDeps();};-function previewTable(plan){-  return `<table><thead><tr><th>Component</th><th>Type</th><th>Symbol → library</th><th>Footprint → library</th></tr></thead><tbody>${-    plan.map(p=>`<tr><td>${p.title}</td><td>${p.type}</td><td><span class="lib">${p.symLib}.kicad_sym</span></td><td><span class="lib">${p.fpLib}.pretty</span></td></tr>`).join('')}</tbody></table>`;+// a <select> of the user's libraries; if the current value isn't among them+// (e.g. a project-only lib) it's added so nothing is silently dropped.+function libSelect(libs,sel,i,k){+  const extra=(sel&&!libs.some(l=>l.name===sel))?`<option value="${sel}" selected>${sel}</option>`:'';+  return `<select class="libsel" data-i="${i}" data-k="${k}">${extra}${libs.map(l=>`<option value="${l.name}"${l.name===sel?' selected':''}>${l.name}</option>`).join('')}</select>`;+}+function previewTable(plan,editable){+  // project-only: green = being added, white/keep = already in the project library+  const st=p=>!projChecked?'':((projDiff.add&&projDiff.add.includes(p.slug))?'addcell':((projDiff.keep&&projDiff.keep.includes(p.slug))?'keepcell':''));+  const cell=(p,i)=>editable+    ? `<td>${libSelect(userLibs.symLibs,p.symLib,i,'symLib')}</td><td>${libSelect(userLibs.fpLibs,p.fpLib,i,'fpLib')}</td>`+    : `<td><span class="lib ${st(p)}">${p.symLib}.kicad_sym</span></td><td><span class="lib ${st(p)}">${p.fpLib}.pretty</span></td>`;+  let rows=plan.map((p,i)=>{const inl=editable&&libChecked&&inLib[p.slug];return `<tr class="${inl?'inlib':''}"><td>${p.title}${inl?` <span class="inlibtag">✓ ${inl.lib||'in your library'}</span>`:''}</td><td>${p.type}</td>${cell(p,i)}</tr>`;}).join('');+  // project-only: symbols in the library no longer on the board — flagged for deletion (red)+  if(!editable && projChecked && projDiff.remove && projDiff.remove.length){+    const lib=(plan[0]&&plan[0].symLib)||'library';+    rows+=projDiff.remove.map(n=>`<tr class="rmrow"><td>${n} <span class="rmtag">flagged for deletion</span></td><td>—</td><td colspan="2"><span class="lib rmcell">remove from ${lib}.kicad_sym</span></td></tr>`).join('');+  }+  return `<table><thead><tr><th>Component</th><th>Type</th><th>Symbol → library</th><th>Footprint → library</th></tr></thead><tbody>${rows}</tbody></table>`; }+// parts already in the user's libraries (slug → {by, lib}), from /api/in-library+let inLib={}, inLibLoading=false, libChecked=false;+function setLibStatus(){ const el=document.getElementById('modal-status'); if(!el)return;+  if(inLibLoading){ el.className='loading'; el.textContent='Searching your libraries…'; }+  else if(libChecked){ const n=Object.keys(inLib).length; el.className='done'; el.textContent='✓ Done — '+(n?n+' already in your libraries':'none already present'); }+  else { el.className=''; el.textContent=''; } }+function checkInLib(slugs,cb){ inLibLoading=true; libChecked=false; setLibStatus(); if(cb)cb();+  fetch('api/in-library?slugs='+encodeURIComponent(slugs.join(','))).then(r=>r.json())+    .then(m=>{ inLib=m||{}; curPlan.forEach(p=>{const h=inLib[p.slug]; if(h&&h.lib)p.symLib=h.lib;}); inLibLoading=false; libChecked=true; setLibStatus(); if(cb)cb(); })+    .catch(()=>{ inLibLoading=false; libChecked=true; setLibStatus(); if(cb)cb(); }); }+// project-only mode: diff the board against the project library on disk (add/keep/remove)+let projDiff={}, projLoading=false, projChecked=false;+function setProjStatus(){ const el=document.getElementById('modal-status'); if(!el)return;+  if(projLoading){ el.className='loading'; el.textContent='Checking your project library…'; }+  else if(projChecked){ const a=(projDiff.add||[]).length, r=(projDiff.remove||[]).length;+    el.className='done'; el.textContent = projDiff.exists ? `✓ Existing project library found — ${a} to add, ${r} to remove` : `No project library on disk yet — ${a} will be created`; }+  else { el.className=''; el.textContent=''; } }+function checkProjectDiff(board,dest,cb){ if(!board||!dest){projDiff={};projChecked=false;setProjStatus();if(cb)cb();return;}+  projLoading=true; projChecked=false; setProjStatus(); if(cb)cb();+  fetch('api/project-diff?board='+encodeURIComponent(board)+'&dest='+encodeURIComponent(dest))+    .then(r=>r.json()).then(d=>{projDiff=d||{};projLoading=false;projChecked=true;setProjStatus();if(cb)cb();})+    .catch(()=>{projLoading=false;projChecked=true;setProjStatus();if(cb)cb();}); }+// wire the per-component library dropdowns to update the live plan+function wireLibSels(){document.querySelectorAll('#modal-preview .libsel').forEach(s=>s.onchange=()=>{if(curPlan[+s.dataset.i])curPlan[+s.dataset.i][s.dataset.k]=s.value;});} function buildPlan(slugs,projectLib,projName){   return slugs.map(s=>{const c=bySlug[s]||{slug:s,title:s};const t=typeOf(c);-    return {slug:s,title:c.title||s,type:t,symLib:projectLib?projName:symLibFor(c),fpLib:projectLib?projName:'adom_lbr'};});+    return {slug:s,title:c.title||s,type:t,symLib:projectLib?projName:symLibFor(c),fpLib:projectLib?projName:fpLibFor(c)};}); } let modalGo=null; const showModal=()=>document.getElementById('modal').style.display='flex';@@ -534,16 +612,18 @@ function selOpt(sel,attr,cb){document.querySelectorAll('#modal-opts .opt').forEa // recommended destination parents for delivered projects (editable in the box) function openAddLib(){   const slugs=[...selected]; if(!slugs.length)return;-  const plan=buildPlan(slugs,false);+  curPlan=buildPlan(slugs,false);   document.getElementById('modal-dest').innerHTML='';   document.getElementById('modal-title').textContent='Add to Library';-  document.getElementById('modal-sub').textContent=`${slugs.length} component(s) → your existing KiCad libraries`;+  document.getElementById('modal-sub').textContent=`${slugs.length} component(s) → pick which of your libraries each goes into`;   document.getElementById('modal-opts').innerHTML=`<div class="opt sel" data-eda="kicad"><input type="radio" name="eda" checked> KiCad</div><div class="opt" data-eda="altium"><input type="radio" name="eda"> Altium (soon)</div>`;   const go=document.getElementById('modal-go');-  selOpt('eda','eda',v=>{document.getElementById('modal-preview').innerHTML=v==='altium'?'<div class="note">Altium add-to-library isn\'t wired up yet — pick KiCad for now.</div>':previewTable(plan);go.disabled=v==='altium';});-  document.getElementById('modal-preview').innerHTML=previewTable(plan);+  inLib={};+  const drawP=()=>{document.getElementById('modal-preview').innerHTML=previewTable(curPlan,true);wireLibSels();};+  selOpt('eda','eda',v=>{if(v==='altium'){document.getElementById('modal-preview').innerHTML='<div class="note">Altium add-to-library isn\'t wired up yet — pick KiCad for now.</div>';go.disabled=true;}else{drawP();go.disabled=false;}});+  drawP(); checkInLib(curPlan.map(p=>p.slug),drawP);   go.disabled=false;go.textContent='Yes, add it';-  modalGo=()=>doApply(plan,'kicad',false);+  modalGo=()=>doApply(curPlan,'kicad',false);   showModal(); } function openAddProj(){@@ -553,20 +633,35 @@ function openAddProj(){   document.getElementById('modal-title').textContent='Add Project to my Computer';   document.getElementById('modal-sub').textContent=`schematic + ${slugs.length} component(s) — symbols & footprints resolved so it opens clean`;   document.getElementById('modal-opts').innerHTML=`<div class="opt sel" data-mode="personal"><input type="radio" name="mode" checked> Integrate into my libraries</div><div class="opt" data-mode="project"><input type="radio" name="mode"> Project-only library</div>`;-  // editable destination + recommended-folder presets-  const homeDisp=env.home||'~';-  document.getElementById('modal-dest').innerHTML=`<label>Deliver project to</label><div class="destrow"><select id="destsel">${destFolders().map(f=>`<option value="${f}">${f.replace(homeDisp,'~')}</option>`).join('')}</select><input id="destpath" spellcheck="false"></div>`;-  const destpath=document.getElementById('destpath'), destsel=document.getElementById('destsel');-  destpath.value=destFolders()[0]+env.sep+boardSlug;-  destsel.onchange=()=>{destpath.value=destsel.value+env.sep+boardSlug;};+  // deliver-to: two boxes — an existing parent DIRECTORY (with path autosuggest)+  // and the project folder NAME — joined by the OS separator into the full path.+  // deliver-to: a "Browse files" button pops a native folder dialog on the user's+  // machine (via the bridge); once picked it shows the path. Then / + project name.+  document.getElementById('modal-dest').innerHTML=`<label>Deliver project to</label><div class="destrow"><button type="button" id="destbrowse" class="browsebtn" title="choose a folder on your computer">Browse files</button><span class="destsep">${env.sep}</span><input id="destname" spellcheck="false" autocomplete="off" placeholder="project folder name"></div>`;+  const destname=document.getElementById('destname'); destname.value=boardSlug;+  let chosenDir=''; const browse=document.getElementById('destbrowse');+  const destPath=()=>chosenDir?chosenDir.replace(/[\/\\]+$/,'')+env.sep+destname.value.trim():'';+  // project directory to diff against: the browsed folder, else the default Projects dir, + the name+  const projPath=()=>((chosenDir||destFolders()[0]||'').replace(/[\/\\]+$/,''))+env.sep+(destname.value.trim()||boardSlug);   const draw=()=>{-    const plan=buildPlan(slugs,mode==='project',projName);-    document.getElementById('modal-preview').innerHTML=`<div class="note">${mode==='project'?'A self-contained <b>'+projName+'</b> symbol + footprint library is created for this project — your personal libraries stay untouched.':'Symbols merge into your existing per-type libraries; footprints go to <b>adom_lbr.pretty</b>. The schematic library references get registered so nothing shows as broken.'}</div>`+previewTable(plan);-    modalGo=()=>doApply(plan,'kicad',mode==='project',boardSlug,document.getElementById('destpath').value);+    const editable=mode==='personal';+    curPlan=buildPlan(slugs,mode==='project',projName);+    if(editable)curPlan.forEach(p=>{const h=inLib[p.slug]; if(h&&h.lib)p.symLib=h.lib;});+    document.getElementById('modal-preview').innerHTML=`<div class="note">${mode==='project'?'A self-contained <b>'+projName+'</b> project library is created/updated — <span style="color:#4ade80">green</span> = added, white = already in the library, <span style="color:#f87171">red</span> = removed.':'Pick which of your libraries each symbol + footprint goes into. Parts already in your libraries are marked; keep the library to skip, or change it to add a copy there.'}</div>`+previewTable(curPlan,editable);+    if(editable)wireLibSels();+    modalGo=()=>doApply(curPlan,'kicad',mode==='project',boardSlug,destPath());   };-  selOpt('mode','mode',v=>{mode=v;draw();});+  // run the check for the current mode (drives its status bar + coloring)+  const runCheck=()=>{ if(mode==='project'){ inLib={}; checkProjectDiff(boardSlug,projPath(),draw); } else { projDiff={}; checkInLib(slugs,draw); } };+  browse.onclick=async()=>{ const old=browse.dataset.path||'Browse files'; browse.textContent='Opening…'; browse.disabled=true;+    try{ const r=await fetch('api/pick-folder').then(r=>r.json());+      if(r.path){ chosenDir=r.path; browse.dataset.path=r.path; browse.textContent=r.path; browse.title=r.path; if(mode==='project')runCheck(); }+      else browse.textContent=old;+    }catch(e){ browse.textContent=old; }+    browse.disabled=false; };+  selOpt('mode','mode',v=>{mode=v;runCheck();});   document.getElementById('modal-go').disabled=false;document.getElementById('modal-go').textContent='Yes, add the project';-  draw();showModal();+  inLib={}; projDiff={}; draw(); runCheck(); showModal(); } async function doApply(plan,eda,project,board,dest){   const go=document.getElementById('modal-go');go.disabled=true;go.textContent='Starting…';@@ -587,10 +682,12 @@ async function doApply(plan,eda,project,board,dest){       const r=p.result||{}, pf=(r.project&&r.project.files||[]);       go.textContent=`✓ Added ${r.count||0}`;       const folder=(r.project&&r.project.folder||'').replace(env.home||'~','~');+      const sk=(r.skipped||[]);       bar.innerHTML=`<b>✓ Done.</b> Merged ${r.count||0} symbol(s) into your libraries.`++        (sk.length?`<br><span style="color:#8b93a3">Skipped ${sk.length} already in your libraries (e.g. ${sk.slice(0,2).map(s=>s.component+' · '+s.by).join(', ')})</span>`:'')+         (pf.length?`<br>Delivered ${pf.length} project file(s) → <span class="lib">${folder}</span>`:'')+         ((r.errors&&r.errors.length)?`<br><span style="color:#f87171">${r.errors.length} issue(s): ${r.errors.slice(0,2).join('; ')}</span>`:'');-      setTimeout(hideModal,pf.length||(r.errors&&r.errors.length)?3600:1600);+      setTimeout(hideModal,pf.length||sk.length||(r.errors&&r.errors.length)?4200:1600);     }   },350); }