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.2.0: OS-aware delivery paths (bridge-detected home/OS via /api/env); robust whoami (no barrett-land fallback); universal (removed personal hardcoding)
2 files changed
+105−24
src/main.rs+96−19@@ -42,8 +42,6 @@ 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. const KICAD_SVC: &str = "https://kicad-rk5ue5pcfemi.adom.cloud";-// TODO: detect the signed-in wiki user rather than hard-coding this owner.-const MY_OWNER: &str = "barrett-land"; /// The wiki component list is slow (~8s) but stable — fetch it once, serve /// instantly after. First click blocks; the rest are snappy.@@ -59,23 +57,35 @@ fn cached_list() -> String { s } -/// The signed-in wiki identity (username + org names), from `adom-wiki whoami`,-/// cached. Falls back to MY_OWNER if the CLI isn't reachable.+/// Follow a dotted path (`a.b.c`) into a JSON value.+fn dig<'a>(v: &'a Value, path: &str) -> Option<&'a Value> {+ let mut cur = v;+ for k in path.split('.') {+ cur = cur.get(k)?;+ }+ Some(cur)+}++/// The signed-in wiki identity (username + org names) from `adom-wiki whoami`,+/// cached. Tolerant of JSON-shape differences across CLI versions; if it can't+/// be read it leaves the identity EMPTY rather than guessing, so the Personal and+/// Organization tabs simply show nothing instead of mislabeling every user. static WHOAMI: Mutex<Option<(String, Vec<String>)>> = Mutex::new(None); fn whoami() -> (String, Vec<String>) { if let Some(v) = WHOAMI.lock().unwrap().clone() { return v; }- let mut me = MY_OWNER.to_string();- let mut orgs = vec![];+ let mut me = String::new();+ let mut orgs: Vec<String> = vec![]; if let Ok(out) = Command::new("adom-wiki").args(["whoami", "--json"]).output() { if let Ok(v) = serde_json::from_slice::<Value>(&out.stdout) {- let data = v.get("data");- if let Some(u) = data.and_then(|d| d.get("user")).and_then(|u| u.get("username")).and_then(|s| s.as_str()) {- me = u.to_string();- }- if let Some(a) = data.and_then(|d| d.get("orgs")).and_then(|o| o.as_array()) {- orgs = a.iter().filter_map(|o| o.get("name").and_then(|s| s.as_str()).map(String::from)).collect();+ me = ["data.user.username", "data.user.name", "data.user.handle", "user.username", "data.username", "username"]+ .iter()+ .find_map(|p| dig(&v, p).and_then(|x| x.as_str()).filter(|s| !s.is_empty()))+ .unwrap_or_default()+ .to_string();+ if let Some(a) = dig(&v, "data.orgs").or_else(|| v.get("orgs")).and_then(|o| o.as_array()) {+ orgs = a.iter().filter_map(|o| o.get("name").and_then(|s| s.as_str()).or_else(|| o.as_str()).filter(|s| !s.is_empty()).map(String::from)).collect(); } } }@@ -118,6 +128,7 @@ fn route(method: &Method, url: &str, body: &str) -> Response<Cursor<Vec<u8>>> { match (method, path) { (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, "/") => html(INDEX_HTML), (Method::Get, "/api/components") => { // ?refresh=1 busts the server-side cache so newly-pushed components appear@@ -622,11 +633,77 @@ fn empty_svg(msg: &str) -> String { // --------------------------------------------------------------------------- // TODO: detect from the bridge rather than hard-coding this user's home.-const KICAD_SYM_DIR: &str = "/home/barrett-land/Documents/KiCAD/Symbols";-const KICAD_FP_DIR: &str = "/home/barrett-land/Documents/KiCAD/Footprints";-const KICAD_PROJ_DIR: &str = "/home/barrett-land/Documents/KiCAD/Projects"; const SCRATCH: &str = "/tmp/adom-lbr-apply"; +/// Run a command on the user's machine via the bridge, returning the command's+/// own stdout (not the adom-desktop JSON envelope). None if empty / bridge down.+fn ad_run(cmd: &str) -> Option<String> {+ let raw = ad_shell_out(cmd)?;+ let v: Value = serde_json::from_str(&raw).ok()?;+ v.get("stdout").and_then(|s| s.as_str()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty())+}++/// The user's machine `(os, home)`, detected via the bridge and cached. Files are+/// delivered to the user's REAL machine (Windows/macOS/Linux), not this Linux+/// container, so delivery paths must be built for it.+static MACHINE: Mutex<Option<(String, String)>> = Mutex::new(None);+fn machine() -> (String, String) {+ if let Some(v) = MACHINE.lock().unwrap().clone() {+ return v;+ }+ // a Unix shell expands `$HOME` to an absolute /path; a Windows cmd shell does not.+ let res = match ad_run("echo $HOME").filter(|h| h.starts_with('/')) {+ Some(home) => {+ let os = if ad_run("uname -s").map(|u| u.contains("Darwin")).unwrap_or(false) { "macos" } else { "linux" };+ (os.to_string(), home)+ }+ None => {+ let home = ad_run("echo %USERPROFILE%").filter(|h| h.contains(':')).unwrap_or_else(|| "C:\\Users\\Default".into());+ ("windows".to_string(), home)+ }+ };+ *MACHINE.lock().unwrap() = Some(res.clone());+ res+}++/// The path separator for the user's OS.+fn sep() -> &'static str {+ if machine().0 == "windows" { "\\" } else { "/" }+}++/// Join parts onto the user's home directory with the OS-appropriate separator.+fn home_path(parts: &[&str]) -> String {+ let mut p = machine().1.trim_end_matches(['/', '\\']).to_string();+ for x in parts {+ p.push_str(sep());+ p.push_str(x);+ }+ p+}++/// A KiCad library/project subdir on the user's machine (`sub` = Symbols /+/// Footprints / Projects).+fn kicad_dir(sub: &str) -> String {+ home_path(&["Documents", "KiCad", sub])+}++/// 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 {+ let (os, home) = machine();+ json!({+ "os": os,+ "home": home,+ "sep": sep(),+ "dest_folders": [+ kicad_dir("Projects"),+ home_path(&["Documents"]),+ home_path(&["Downloads"]),+ home_path(&["Desktop"]),+ ],+ })+}+ const PYTHON_SELFCONTAIN: &str = include_str!("selfcontain.py"); /// Fetch every KiCad project file on a board's wiki page (`.kicad_pro`,@@ -635,7 +712,7 @@ const PYTHON_SELFCONTAIN: &str = include_str!("selfcontain.py"); /// library, re-point every `lib_id`/footprint reference to it, and write the /// project-local sym/fp-lib-tables. Then deliver everything to `dest`. fn fetch_project_files(board: &str, dest: &str, project_only: bool) -> (String, Vec<String>) {- let dest = if dest.trim().is_empty() { format!("{KICAD_PROJ_DIR}/{board}") } else { dest.trim().to_string() };+ let dest = if dest.trim().is_empty() { format!("{}{}{board}", kicad_dir("Projects"), sep()) } else { dest.trim().to_string() }; let work = format!("{SCRATCH}/proj-{}", board.replace(['/', ' '], "_")); let _ = std::fs::remove_dir_all(&work); let _ = std::fs::create_dir_all(&work);@@ -1144,7 +1221,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!("{KICAD_SYM_DIR}/{symlib}.kicad_sym");+ let remote = 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@@ -1181,7 +1258,7 @@ fn run_apply(items: Vec<(String, String, String)>, board: Option<String>, dest: errors.push(format!("failed to stage {symlib}.kicad_sym locally: {e}")); continue; }- if ad_send(&[local], KICAD_SYM_DIR) {+ if ad_send(&[local], &kicad_dir("Symbols")) { for (name, _) in &blocks { added.push(json!({"symbol": name, "symLib": symlib})); }@@ -1192,7 +1269,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!("{KICAD_FP_DIR}/{fplib}.pretty");+ let dest = format!("{}{}{fplib}.pretty", kicad_dir("Footprints"), sep()); ad_shell(&format!("mkdir -p {}", sh_quote(&dest))); let mut files = vec![]; for slug in slugs {
src/manage.html+9−5@@ -173,6 +173,9 @@ const COLORS={Silkscreen:'#d8b4fe',Courtyard:'#4ade80','Fabrication / Assembly': // Board-centric navigation: Boards (tagged board/molecule) ▸ a board's component // dependencies ▸ the symbol/footprint/3D viewer. 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:[]};+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(); const rowsEl=()=>document.getElementById('rows');@@ -200,6 +203,7 @@ async function loadList(fresh){ try{ 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(()=>{}); render(); }catch(e){ rowsEl().innerHTML='<div class="row" style="color:#f87171;cursor:default">failed to load — retry</div>';@@ -528,7 +532,6 @@ document.getElementById('modal-cancel').onclick=hideModal; document.getElementById('modal-go').onclick=()=>{if(modalGo)modalGo();}; function selOpt(sel,attr,cb){document.querySelectorAll('#modal-opts .opt').forEach(o=>o.onclick=()=>{document.querySelectorAll('#modal-opts .opt').forEach(x=>x.classList.remove('sel'));o.classList.add('sel');o.querySelector('input').checked=true;cb(o.dataset[attr]);});} // recommended destination parents for delivered projects (editable in the box)-const DEST_FOLDERS=['/home/barrett-land/Documents/KiCAD/Projects','/home/barrett-land/Documents/Adom/Molecules/Other Molecules','/home/barrett-land/Documents/Adom/Electrical','/home/barrett-land/Downloads']; function openAddLib(){ const slugs=[...selected]; if(!slugs.length)return; const plan=buildPlan(slugs,false);@@ -551,10 +554,11 @@ function openAddProj(){ 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- document.getElementById('modal-dest').innerHTML=`<label>Deliver project to</label><div class="destrow"><select id="destsel">${DEST_FOLDERS.map(f=>`<option value="${f}">${f.replace('/home/barrett-land','~')}</option>`).join('')}</select><input id="destpath" spellcheck="false"></div>`;+ 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=DEST_FOLDERS[0]+'/'+boardSlug;- destsel.onchange=()=>{destpath.value=destsel.value+'/'+boardSlug;};+ destpath.value=destFolders()[0]+env.sep+boardSlug;+ destsel.onchange=()=>{destpath.value=destsel.value+env.sep+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);@@ -582,7 +586,7 @@ async function doApply(plan,eda,project,board,dest){ clearInterval(poll); const r=p.result||{}, pf=(r.project&&r.project.files||[]); go.textContent=`✓ Added ${r.count||0}`;- const folder=(r.project&&r.project.folder||'').replace('/home/barrett-land','~');+ const folder=(r.project&&r.project.folder||'').replace(env.home||'~','~'); bar.innerHTML=`<b>✓ Done.</b> Merged ${r.count||0} symbol(s) into your libraries.`+ (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>`:'');