app
Adom Project Manager
Public Made by Adomby adom
Adom Project Manager — the central hub for navigating wiki boards, molecules and libraries into Hydrogen: browse your wiki boards/projects (Personal / Organization / Public), view symbol + footprint + 3D and the per-EDA footprint layer stackup, explore the board and schematic in INTERACTIVE cross-probing viewers (click a net, hover a part for stock and price), add components to your KiCad/Altium libraries, and deliver self-contained KiCad projects. Built on adom-lbr. (Formerly Adom Project Manag
← 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
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(¶m("board"), ¶m("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); }