app
Adom Library
Public Made by Adomby adom
adom-lbr — the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad ⇄ Altium ⇄ EAGLE/Fusion) through one canonical adom-lbr JSON — symbol + footprint + 3D + pin↔pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.
← Commit history
Library Manager v2 (2.5.0): Local/Mine/Org/Public tabs, standard ~/project/library, infinite-scroll browse + server search (scales past the 300-cap / 100k), per-part symbol/footprint/3D completeness pips + complete-only filter, source-aware local rendering
4 files changed
+426−183
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.4.0"+version = "2.5.0" edition = "2021" description = "adom-lbr generator / EDA translator: the canonical adom-lbr JSON is the hub, with native converters to Altium (.SchLib/.PcbLib/.IntLib), EAGLE/Fusion (.lbr), and KiCad. Altium output is written natively — no Altium needed at conversion time."
VERSION+1−1@@ -1 +1 @@-2.4.0+2.5.0
src/manage.html+117−111@@ -8,40 +8,45 @@ :root{--bg:#0d1117;--panel:#171a21;--edge:#222b38;--ink:#e6edf3;--mut:#8b949e;--acc:#00b8b1;--dot:#00e6dc} *{box-sizing:border-box} body{margin:0;font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--ink);height:100vh;display:flex;flex-direction:column}- /* Canonical Adom app-shell header — matches adom-symbol / adom-footprint (adom-ui-design). */+ /* Canonical Adom app-shell header (matches adom-symbol / adom-footprint). */ #appbar{display:flex;align-items:center;gap:10px;padding:12px 18px;border-bottom:1px solid var(--edge);background:var(--panel);flex:0 0 auto} #appbar .dot{width:10px;height:10px;border-radius:50%;background:var(--dot);box-shadow:0 0 12px var(--dot)} #appbar .brand{font-weight:700} #appbar .brand span{color:var(--mut);font-weight:400}- #appbar .src{margin-left:auto;display:inline-flex;align-items:center;gap:6px;font:12px ui-monospace,monospace;color:var(--mut);background:#0d0f14;border:1px solid var(--edge);border-radius:7px;padding:4px 9px;cursor:help}- #appbar .src b{color:var(--acc);font-weight:600}+ #appbar .legend{margin-left:auto;display:inline-flex;align-items:center;gap:10px;font-size:11px;color:var(--mut)}+ #appbar .legend .pip{width:9px;height:9px} #shell{flex:1;display:flex;min-height:0}- #list{width:300px;min-width:300px;border-right:1px solid var(--edge);background:var(--panel);display:flex;flex-direction:column}- #list h1{font-size:15px;margin:0;padding:14px 16px;border-bottom:1px solid var(--edge);letter-spacing:.02em}- #list h1 small{color:var(--mut);font-weight:400}- #q{margin:10px;padding:8px 10px;background:#0d0f14;border:1px solid var(--edge);border-radius:8px;color:var(--ink)}+ /* Sidebar: tabs (source) → search + complete toggle → scrolling list. */+ #list{width:340px;min-width:340px;border-right:1px solid var(--edge);background:var(--panel);display:flex;flex-direction:column;min-height:0}+ .tabs{display:flex;gap:2px;padding:8px 8px 0;flex-wrap:wrap}+ .tab{font-size:12px;padding:6px 10px;border-radius:7px 7px 0 0;color:var(--mut);cursor:pointer;border:1px solid transparent;border-bottom:none;white-space:nowrap;max-width:130px;overflow:hidden;text-overflow:ellipsis}+ .tab:hover{color:var(--ink)}+ .tab.sel{color:var(--ink);background:var(--bg);border-color:var(--edge)}+ .srcline{font-size:11px;color:var(--mut);padding:4px 14px;border-bottom:1px solid var(--edge)}+ .ctl{display:flex;align-items:center;gap:8px;padding:8px 10px;border-bottom:1px solid var(--edge)}+ #q{flex:1;padding:8px 10px;background:#0d0f14;border:1px solid var(--edge);border-radius:8px;color:var(--ink)}+ .only{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--mut);cursor:pointer;user-select:none;white-space:nowrap}+ .only input{margin:0} #rows{overflow:auto;flex:1}- .row{padding:10px 16px;border-bottom:1px solid var(--edge);cursor:pointer}+ .row{display:flex;align-items:center;gap:8px;padding:9px 14px;border-bottom:1px solid var(--edge);cursor:pointer} .row:hover{background:#1e222b}- .row.sel{background:#123331;border-left:3px solid var(--acc);padding-left:13px}- .row .mpn{font-weight:600}- .row .sub{color:var(--mut);font-size:12px}+ .row.sel{background:#123331;border-left:3px solid var(--acc);padding-left:11px}+ .row .meta{flex:1;min-width:0}+ .row .mpn{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}+ .row .sub{color:var(--mut);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}+ .pips{display:flex;gap:3px;flex:0 0 auto;cursor:help}+ .pip{width:9px;height:9px;border-radius:50%;border:1.5px solid var(--mut);opacity:.4}+ .pip.on{background:var(--acc);border-color:var(--acc);opacity:1}+ .endmsg{color:var(--mut);text-align:center;font-size:12px;padding:12px}+ /* Viewer */ #view{flex:1;overflow:auto;padding:18px} #hint{color:var(--mut);text-align:center;margin-top:20vh} .cols{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px} .card{background:var(--panel);border:1px solid var(--edge);border-radius:12px;overflow:hidden;display:flex;flex-direction:column} .card h3{margin:0;padding:9px 12px;font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);border-bottom:1px solid var(--edge)} .card h3 small{text-transform:none;letter-spacing:0;color:#5b6675;font-weight:400}- .card iframe.embed{width:100%;height:440px;border:0;background:#161b22;display:block}- .card .body{aspect-ratio:1;width:100%;display:flex;align-items:center;justify-content:center;padding:10px;background:#161b22;overflow:hidden;cursor:grab;touch-action:none;position:relative}- .card .body svg{max-width:100%;max-height:100%;will-change:transform}- .card .hint2{position:absolute;bottom:6px;right:8px;font-size:10px;color:#b9c0cc;background:#0d0f14aa;border-radius:5px;padding:1px 6px;pointer-events:none}- .lfilter{display:flex;flex-wrap:wrap;gap:6px;padding:8px 10px;border-bottom:1px solid var(--edge)}- .chip{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--ink);background:#0d0f14;border:1px solid var(--edge);border-radius:6px;padding:3px 7px;cursor:pointer;user-select:none}- .chip input{margin:0;cursor:pointer}- .chip .sw{width:9px;height:9px;border-radius:2px;display:inline-block}- .chip:has(input:not(:checked)){opacity:.4;text-decoration:line-through}- model-viewer{width:100%;aspect-ratio:1;height:auto;background:#161b22}+ .card iframe.embed{width:100%;height:460px;border:0;background:#161b22;display:block}+ .card .body{aspect-ratio:1;width:100%;display:flex;align-items:center;justify-content:center;background:#161b22;color:#8b93a3} h2.title{margin:0 0 14px} h2.title small{color:var(--mut);font-weight:400;font-size:14px} table{width:100%;border-collapse:collapse;margin-top:16px;background:var(--panel);border:1px solid var(--edge);border-radius:12px;overflow:hidden}@@ -52,120 +57,121 @@ .badge{font:12px ui-monospace,monospace;background:#0d0f14;border:1px solid var(--edge);border-radius:6px;padding:2px 7px} .stacktitle{margin:22px 0 6px;font-size:13px;text-transform:uppercase;letter-spacing:.05em;color:var(--mut)} .err{color:#f87171;padding:16px}- .card .body [data-num]{cursor:pointer}- .pinhi .pinvis{stroke:var(--acc) !important;vector-effect:non-scaling-stroke;stroke-width:3}- rect.pad.pinhi{stroke:var(--acc);vector-effect:non-scaling-stroke;stroke-width:3}- #tip{position:fixed;z-index:9999;background:#0d1117;border:1px solid var(--acc);color:var(--ink);font:12px ui-monospace,monospace;padding:3px 8px;border-radius:6px;pointer-events:none;display:none;white-space:nowrap}- #tip b{color:var(--acc)} </style> </head> <body> <header id="appbar"> <span class="dot"></span> <span class="brand">adom-lbr <span>· Library Manager</span></span>- <span class="src" id="src" title="Live from the Adom Wiki. The component list and every symbol / footprint / 3D (.glb) file is fetched from wiki.adom.inc on demand — nothing is stored in this container. SVGs render on the fly per click.">◍ <b>wiki.adom.inc</b></span>+ <span class="legend"><span class="pips"><span class="pip on"></span><span class="pip on"></span><span class="pip on"></span></span>= symbol · footprint · 3D present</span> </header> <div id="shell"> <aside id="list">- <h1>Components <small id="count"></small></h1>- <input id="q" placeholder="filter components…">+ <div class="tabs" id="tabs"></div>+ <div class="srcline" id="srcline"></div>+ <div class="ctl">+ <input id="q" placeholder="filter / search…">+ <label class="only" title="Only show parts that have symbol + footprint + 3D"><input type="checkbox" id="only">complete</label>+ </div> <div id="rows"></div> </aside>- <main id="view"><div id="hint">← pick a component from the list to open its viewer</div></main>+ <main id="view"><div id="hint">← pick a component to open its viewer</div></main> </div>- <script> const COLORS={Silkscreen:'#d8b4fe',Courtyard:'#4ade80','Fabrication / Assembly':'#f472b6',Copper:'#f59e0b',Other:'#94a3b8'};-let all=[];-async function loadList(){- document.getElementById('rows').innerHTML='<div class="row" style="color:#8b93a3;cursor:default">loading components from wiki…</div>';- try{- const r=await fetch('api/components').then(r=>r.json());- all=r.components||[];- document.getElementById('count').textContent=all.length?all.length:'';- document.getElementById('src').innerHTML='◍ <b>wiki.adom.inc</b> · '+all.length+' parts';- render();- }catch(e){- document.getElementById('rows').innerHTML='<div class="row" style="color:#f87171;cursor:default">failed to load list — retry</div>';- }+const LIMIT=50;+let tabs=[], curTab=null, offset=0, loading=false, done=false, seen=new Set();+const rowsEl=document.getElementById('rows');++function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}+function cssq(s){return String(s).replace(/["\\]/g,'\\$&');}++async function loadTabs(){+ let r; try{ r=await fetch('api/tabs').then(x=>x.json()); }catch(e){ document.getElementById('tabs').innerHTML='<div class="endmsg">failed to load</div>'; return; }+ tabs=r.tabs||[];+ const tb=document.getElementById('tabs'); tb.innerHTML='';+ tabs.forEach(t=>{+ const d=document.createElement('div'); d.className='tab'; d.textContent=t.label; d.title=t.label;+ d.onclick=()=>selectTab(t.key); tb.appendChild(d); t._el=d;+ });+ selectTab((tabs[0]&&tabs[0].key)||'public');+}+function srcText(t){+ if(t.source==='local') return 'local container · ~/project/library';+ if(t.key==='public') return 'wiki.adom.inc · public · scroll to browse, type to search';+ if(t.key==='mine') return 'wiki.adom.inc · authored by you';+ return 'wiki.adom.inc · org: '+t.label;+}+function selectTab(key){+ curTab=key; offset=0; done=false; loading=false; seen=new Set();+ tabs.forEach(t=>t._el&&t._el.classList.toggle('sel',t.key===key));+ const t=tabs.find(x=>x.key===key);+ document.getElementById('srcline').textContent=t?srcText(t):'';+ rowsEl.innerHTML=''; loadMore();+}++async function loadMore(){+ if(loading||done||!curTab) return; loading=true;+ const q=document.getElementById('q').value.trim();+ const only=document.getElementById('only').checked?1:0;+ const url=`api/list?tab=${encodeURIComponent(curTab)}&offset=${offset}&limit=${LIMIT}&q=${encodeURIComponent(q)}&complete=${only}`;+ let r; try{ r=await fetch(url).then(x=>x.json()); }catch(e){ loading=false; return; }+ const items=r.items||[];+ const fresh=items.filter(it=>!seen.has(it.token)); fresh.forEach(it=>seen.add(it.token));+ fresh.forEach(addRow);+ offset = (typeof r.next==='number') ? r.next : offset+items.length;+ done = !r.more;+ loading=false;+ fillPips(fresh);+ if(done){+ if(!rowsEl.querySelector('.row')) rowsEl.insertAdjacentHTML('beforeend','<div class="endmsg">no components</div>');+ else rowsEl.insertAdjacentHTML('beforeend','<div class="endmsg">— end —</div>');+ } else if(rowsEl.scrollHeight<=rowsEl.clientHeight+40){ loadMore(); } // fill the viewport }-function render(){- const q=document.getElementById('q').value.toLowerCase();- const rows=document.getElementById('rows');- rows.innerHTML='';- all.filter(c=>!q||c.slug.includes(q)||(c.title||'').toLowerCase().includes(q)).forEach(c=>{- const d=document.createElement('div');d.className='row';d.dataset.slug=c.slug;- d.innerHTML=`<div class="mpn">${c.title||c.slug}</div><div class="sub">${c.slug}</div>`;- d.onclick=()=>openPart(c.slug,d);- rows.appendChild(d);+function addRow(it){+ const d=document.createElement('div'); d.className='row'; d.dataset.token=it.token;+ d.innerHTML=`<div class="meta"><div class="mpn">${esc(it.title)}</div><div class="sub">${esc(it.id)}</div></div>`+ +`<div class="pips" data-token="${esc(it.token)}" title="symbol · footprint · 3D"><div class="pip" data-k="s"></div><div class="pip" data-k="f"></div><div class="pip" data-k="d"></div></div>`;+ d.onclick=()=>openPart(it,d);+ rowsEl.appendChild(d);+}+async function fillPips(items){+ if(!items.length) return;+ const ids=items.map(i=>i.token).join(',');+ let r; try{ r=await fetch('api/complete?ids='+encodeURIComponent(ids)).then(x=>x.json()); }catch(e){ return; }+ const c=r.complete||{};+ items.forEach(it=>{+ const box=rowsEl.querySelector(`.pips[data-token="${cssq(it.token)}"]`); if(!box) return;+ const f=c[it.token]||{};+ ['s','f','d'].forEach(k=>{const p=box.querySelector(`.pip[data-k="${k}"]`); if(p)p.classList.toggle('on',!!f[k]);}); }); }-async function openPart(slug,el){+rowsEl.addEventListener('scroll',()=>{+ if(rowsEl.scrollTop+rowsEl.clientHeight>=rowsEl.scrollHeight-120) loadMore();+});+let qto=null;+document.getElementById('q').oninput=()=>{ clearTimeout(qto); qto=setTimeout(()=>selectTab(curTab),250); };+document.getElementById('only').onchange=()=>selectTab(curTab);++async function openPart(it,el){ document.querySelectorAll('.row').forEach(r=>r.classList.remove('sel')); if(el)el.classList.add('sel');- const v=document.getElementById('view');- v.innerHTML='<div id="hint">loading '+slug+'…</div>';- const p=await fetch('api/part/'+slug).then(r=>r.json());- if(p.status!=='ok'){v.innerHTML='<div class="err">'+(p.error||'failed')+'</div>';return;}- const glb=p.glb?`<iframe class="embed" src="viewer3d/${slug}" loading="lazy" title="3D model"></iframe>`:'<div class="body" style="color:#888;background:#0d0f14">no 3D model</div>';- let rows=(p.layers||[]).map(l=>`<tr>- <td><span class="swatch" style="background:${COLORS[l.role]||'#888'}"></span>${l.role}<span style="color:#8b93a3"> ×${l.count}</span></td>- <td><span class="badge">${l.kicad}</span></td>- <td><span class="badge">${l.altium}</span></td></tr>`).join('');+ const v=document.getElementById('view'); const tok=it.token;+ v.innerHTML='<div id="hint">loading '+esc(it.id)+'…</div>';+ let p; try{ p=await fetch('api/part/'+tok).then(x=>x.json()); }catch(e){ v.innerHTML='<div class="err">failed to load</div>'; return; }+ const glb=(p&&p.glb)?`<iframe class="embed" src="viewer3d/${tok}" loading="lazy" title="3D"></iframe>`:'<div class="body">no 3D model</div>';+ const layers=(p&&p.layers)||[];+ const rows=layers.map(l=>`<tr><td><span class="swatch" style="background:${COLORS[l.role]||'#888'}"></span>${l.role}<span style="color:#8b93a3"> ×${l.count}</span></td><td><span class="badge">${l.kicad}</span></td><td><span class="badge">${l.altium}</span></td></tr>`).join(''); v.innerHTML=`- <h2 class="title">${p.mpn} <small>· ${p.pins} pins · ${p.pads} pads</small></h2>+ <h2 class="title">${esc((p&&p.mpn)||it.title)} <small>· ${(p&&p.pins)||0} pins · ${(p&&p.pads)||0} pads</small></h2> <div class="cols">- <div class="card"><h3>Schematic Symbol <small>· via adom-symbol</small></h3><iframe class="embed" src="embed/symbol/${slug}" loading="lazy" title="symbol viewer"></iframe></div>- <div class="card"><h3>Footprint <small>· via adom-footprint</small></h3><iframe class="embed" src="embed/footprint/${slug}" loading="lazy" title="footprint viewer"></iframe></div>+ <div class="card"><h3>Schematic Symbol <small>· via adom-symbol</small></h3><iframe class="embed" src="embed/symbol/${tok}" loading="lazy" title="symbol"></iframe></div>+ <div class="card"><h3>Footprint <small>· via adom-footprint</small></h3><iframe class="embed" src="embed/footprint/${tok}" loading="lazy" title="footprint"></iframe></div> <div class="card"><h3>3D Model</h3>${glb}</div> </div>- <div class="stacktitle">Footprint layer stackup — what each element becomes per EDA tool</div>- <table><thead><tr><th>Element (neutral role)</th><th>KiCad layer</th><th>Altium layer</th></tr></thead>- <tbody>${rows}</tbody></table>`;- // Symbol + footprint are now canonical interactive iframes (adom-symbol /- // adom-footprint embed) with their OWN pan/zoom + layer/pin panels — nothing- // to wire here. The 3D column keeps its own viewer.-}--// Hover a pin (symbol) or pad (footprint) → highlight it + show its number and-// name/description in a tooltip. Pins/pads carry data-num + data-name.-function setupHighlight(v){- let tip=document.getElementById('tip');- if(!tip){tip=document.createElement('div');tip.id='tip';document.body.appendChild(tip);}- const clear=box=>box.querySelectorAll('.pinhi').forEach(x=>x.classList.remove('pinhi'));- v.querySelectorAll('.card .body').forEach(box=>{- box.addEventListener('pointermove',e=>{- const hit=e.target.closest('[data-num]');- clear(box);- if(hit){- hit.classList.add('pinhi');- const num=hit.dataset.num, nm=hit.dataset.name;- const kind=hit.closest('#fpbody')?'Pad':'Pin';- tip.innerHTML=(nm&&nm!=='~')?`<b>${kind} ${num}</b> · ${nm}`:`<b>${kind} ${num}</b>`;- tip.style.display='block';- tip.style.left=(e.clientX+14)+'px';- tip.style.top=(e.clientY+14)+'px';- } else { tip.style.display='none'; }- });- box.addEventListener('pointerleave',()=>{clear(box);tip.style.display='none';});- });-}--// Drag-to-pan + scroll-to-zoom on an SVG viewer box (pointer capture so a drag-// that leaves the box keeps tracking). Re-assigning on* handlers avoids stacking.-function makePannable(box){- const svg=box.querySelector('svg');- if(!svg) return;- let tx=0,ty=0,scale=1,sx=0,sy=0,drag=false;- const apply=()=>{svg.style.transform=`translate(${tx}px,${ty}px) scale(${scale})`;};- box.onpointerdown=e=>{drag=true;sx=e.clientX-tx;sy=e.clientY-ty;box.setPointerCapture(e.pointerId);box.style.cursor='grabbing';};- box.onpointermove=e=>{if(drag){tx=e.clientX-sx;ty=e.clientY-sy;apply();}};- box.onpointerup=box.onpointercancel=()=>{drag=false;box.style.cursor='grab';};- box.onwheel=e=>{e.preventDefault();const f=e.deltaY<0?1.12:0.89;scale=Math.max(0.25,Math.min(12,scale*f));apply();};- box.ondblclick=()=>{tx=ty=0;scale=1;apply();};+ ${rows?`<div class="stacktitle">Footprint layer stackup — what each element becomes per EDA tool</div><table><thead><tr><th>Element (neutral role)</th><th>KiCad layer</th><th>Altium layer</th></tr></thead><tbody>${rows}</tbody></table>`:''}`; }-document.getElementById('q').oninput=render;-loadList();+loadTabs(); </script> </body> </html>
src/manage.rs+307−70@@ -49,45 +49,307 @@ pub fn run(port: u16) -> Result<()> { } fn route(method: &Method, url: &str) -> Response<Cursor<Vec<u8>>> {- let path = url.split('?').next().unwrap_or(url);+ let (path, query) = url.split_once('?').unwrap_or((url, ""));+ let q = |key: &str| -> Option<String> {+ query.split('&').find_map(|kv| {+ let (k, v) = kv.split_once('=')?;+ if k == key { Some(urldecode(v)) } else { None }+ })+ }; match (method, path) { (Method::Get, "/") => html(INDEX_HTML),- (Method::Get, "/api/components") => json_resp(&cached_list()),+ (Method::Get, "/api/tabs") => json_resp(&tabs_config().to_string()),+ (Method::Get, "/api/list") => {+ let tab = q("tab").unwrap_or_else(|| "public".into());+ let offset = q("offset").and_then(|s| s.parse().ok()).unwrap_or(0usize);+ let limit = q("limit").and_then(|s| s.parse().ok()).unwrap_or(50usize).min(100);+ let search = q("q").unwrap_or_default();+ let complete = q("complete").as_deref() == Some("1");+ json_resp(&list_for_tab(&tab, offset, limit, &search, complete).to_string())+ }+ (Method::Get, "/api/complete") => json_resp(&complete_batch(&q("ids").unwrap_or_default()).to_string()), (Method::Get, p) if p.starts_with("/api/part/") => {- let slug = &p["/api/part/".len()..];- json_resp(&get_part(slug).to_string())+ let (source, id) = parse_token(&p["/api/part/".len()..]);+ json_resp(&get_part(&source, &id).to_string()) } (Method::Get, p) if p.starts_with("/glb/") => {- let slug = &p["/glb/".len()..];- match wiki_bytes(slug, &format!("{}.glb", up(slug))) {+ let (source, id) = parse_token(&p["/glb/".len()..]);+ match fetch_component_bytes(&source, &id, "glb") { Some(b) => Response::from_data(b).with_header(hdr("model/gltf-binary")), None => Response::from_string("no glb").with_status_code(404), } }- // Canonical interactive viewers — the symbol/footprint columns render via- // the AUTHORITATIVE tools (adom-symbol / adom-footprint `embed`) so the- // Manager faithfully shows what the real pipeline produces, not a- // home-grown SVG that could quietly diverge from ground truth. (Method::Get, p) if p.starts_with("/embed/symbol/") => {- embed_view(&p["/embed/symbol/".len()..], "kicad_sym", "adom-symbol")+ let (source, id) = parse_token(&p["/embed/symbol/".len()..]);+ embed_view(&source, &id, "sym", "adom-symbol") } (Method::Get, p) if p.starts_with("/embed/footprint/") => {- embed_view(&p["/embed/footprint/".len()..], "kicad_mod", "adom-footprint")+ let (source, id) = parse_token(&p["/embed/footprint/".len()..]);+ embed_view(&source, &id, "fp", "adom-footprint") }- // 3D column: the CANONICAL Adom Babylon viewer (vendored), not Google- // model-viewer. Its interactive page is iframed per component and loads- // the proxied GLB via the viewer's own SceneLoader (PBR + HDRI + ViewCube). (Method::Get, "/js/adom-3d-viewer.min.js") => Response::from_data(VIEWER_JS.to_vec()).with_header(hdr("application/javascript")), (Method::Get, "/js/environmentSpecular.env") => Response::from_data(VIEWER_ENV.to_vec()).with_header(hdr("application/octet-stream")),- (Method::Get, p) if p.starts_with("/viewer3d/") => {- viewer3d_page(&p["/viewer3d/".len()..])- }+ (Method::Get, p) if p.starts_with("/viewer3d/") => viewer3d_page(&p["/viewer3d/".len()..]), _ => Response::from_string("not found").with_status_code(404), } } +fn urldecode(s: &str) -> String {+ let s = s.replace('+', " ");+ let b = s.as_bytes();+ let mut out = String::new();+ let mut i = 0;+ while i < b.len() {+ if b[i] == b'%' && i + 2 < b.len() {+ if let Ok(byte) = u8::from_str_radix(&s[i + 1..i + 3], 16) { out.push(byte as char); i += 3; continue; }+ }+ out.push(b[i] as char); i += 1;+ }+ out+}+fn urlenc(s: &str) -> String {+ s.bytes().map(|b| match b {+ b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(),+ _ => format!("%{:02X}", b),+ }).collect()+}++/// A component is `(source, id)`. Encoded in URLs as `source:id` (one path+/// segment; slugs/MPNs contain no ':' or '/'). A bare token defaults to wiki.+fn parse_token(tok: &str) -> (String, String) {+ let t = tok.trim_end_matches('/');+ match t.split_once(':') {+ Some((sc, id)) => (sc.to_string(), id.to_string()),+ None => ("wiki".to_string(), t.to_string()),+ }+}++// ── Identity (for the Mine / Org tabs) ──+static WHOAMI: Mutex<Option<(String, Vec<String>)>> = Mutex::new(None);+fn whoami() -> (String, Vec<String>) {+ if let Some(v) = WHOAMI.lock().unwrap().as_ref() { return v.clone(); }+ let mut res = (String::new(), Vec::new());+ if let Ok(out) = Command::new("adom-wiki").args(["whoami", "--json"]).output() {+ if let Ok(v) = serde_json::from_slice::<Value>(&out.stdout) {+ let name = v.pointer("/data/user/displayName").and_then(|x| x.as_str()).unwrap_or("").to_string();+ let orgs = v.pointer("/data/orgs").and_then(|x| x.as_array())+ .map(|a| a.iter().filter_map(|o| o.get("name").and_then(|n| n.as_str()).map(String::from)).collect())+ .unwrap_or_default();+ res = (name, orgs);+ }+ }+ *WHOAMI.lock().unwrap() = Some(res.clone());+ res+}++// ── Local library: the standard $HOME/project/library (fallback chip-fetcher) ──+fn library_root() -> Option<std::path::PathBuf> {+ let home = std::env::var("HOME").unwrap_or_else(|_| "/home/adom".into());+ for c in [std::env::var("ADOM_LIBRARY").ok(),+ Some(format!("{home}/project/library")),+ Some(format!("{home}/project/chip-fetcher/library"))].into_iter().flatten() {+ let pb = std::path::PathBuf::from(&c);+ if pb.is_dir() && !local_scan(&pb).is_empty() { return Some(pb); }+ }+ None+}+fn local_scan(root: &std::path::Path) -> Vec<String> {+ let mut out = Vec::new();+ if let Ok(rd) = std::fs::read_dir(root) {+ for e in rd.flatten() {+ let pb = e.path();+ if pb.is_dir() {+ let mpn = e.file_name().to_string_lossy().to_string();+ if pb.join(format!("{mpn}.kicad_sym")).exists() || pb.join(format!("{mpn}.kicad_mod")).exists() {+ out.push(mpn);+ }+ }+ }+ }+ out.sort();+ out+}+fn local_components() -> Vec<String> { library_root().map(|r| local_scan(&r)).unwrap_or_default() }++// ── Per-component files = completeness signal AND the exact filename to fetch ──+#[derive(Clone, Default)]+struct CompFiles { sym: Option<String>, fp: Option<String>, glb: Option<String> }+static FILES_CACHE: Mutex<Option<std::collections::HashMap<String, CompFiles>>> = Mutex::new(None);++fn component_files(source: &str, id: &str) -> CompFiles {+ let key = format!("{source}:{id}");+ if let Some(m) = FILES_CACHE.lock().unwrap().as_ref() { if let Some(c) = m.get(&key) { return c.clone(); } }+ let cf = if source == "local" {+ let root = library_root();+ let pick = |ext: &str| root.as_ref().and_then(|r| {+ let name = format!("{id}.{ext}");+ if r.join(id).join(&name).exists() { Some(name) } else { None }+ });+ CompFiles { sym: pick("kicad_sym"), fp: pick("kicad_mod"), glb: pick("glb") }+ } else {+ let mut cf = CompFiles::default();+ if let Some(body) = curl(&format!("{WIKI}/api/v1/pages/{id}/files")) {+ if let Ok(v) = serde_json::from_slice::<Value>(&body) {+ if let Some(files) = v.get("files").and_then(|f| f.as_array()) {+ for f in files {+ let n = f.get("name").and_then(|n| n.as_str()).unwrap_or("");+ let nl = n.to_lowercase();+ if nl.contains(".altium.") || nl.contains(".fusion.") { continue; }+ if nl.ends_with(".kicad_sym") && cf.sym.is_none() { cf.sym = Some(n.to_string()); }+ else if nl.ends_with(".kicad_mod") && cf.fp.is_none() { cf.fp = Some(n.to_string()); }+ else if nl.ends_with(".glb") && cf.glb.is_none() { cf.glb = Some(n.to_string()); }+ }+ }+ }+ }+ cf+ };+ FILES_CACHE.lock().unwrap().get_or_insert_with(std::collections::HashMap::new).insert(key, cf.clone());+ cf+}+fn fetch_component_text(source: &str, id: &str, which: &str) -> Option<String> {+ let cf = component_files(source, id);+ let name = match which { "sym" => cf.sym, "fp" => cf.fp, _ => cf.glb }?;+ if source == "local" { library_root().and_then(|r| std::fs::read_to_string(r.join(id).join(&name)).ok()) }+ else { wiki_text(id, &name) }+}+fn fetch_component_bytes(source: &str, id: &str, which: &str) -> Option<Vec<u8>> {+ let cf = component_files(source, id);+ let name = match which { "sym" => cf.sym, "fp" => cf.fp, _ => cf.glb }?;+ if source == "local" { library_root().and_then(|r| std::fs::read(r.join(id).join(&name)).ok()) }+ else { wiki_bytes(id, &name) }+}+fn complete_batch(ids: &str) -> Value {+ let mut out = serde_json::Map::new();+ for tok in ids.split(',').filter(|s| !s.is_empty()) {+ let (source, id) = parse_token(tok);+ let cf = component_files(&source, &id);+ out.insert(tok.to_string(), json!({"s": cf.sym.is_some(), "f": cf.fp.is_some(), "d": cf.glb.is_some()}));+ }+ json!({ "status": "ok", "complete": out })+}++// ── Tabs + listing ──+fn tabs_config() -> Value {+ let (name, orgs) = whoami();+ let mut tabs = vec![json!({"key":"local","label":"Local","source":"local"})];+ if !name.is_empty() { tabs.push(json!({"key":"mine","label":name,"source":"wiki"})); }+ for org in &orgs { tabs.push(json!({"key":format!("org:{org}"),"label":org,"source":"wiki"})); }+ tabs.push(json!({"key":"public","label":"Public","source":"wiki"}));+ json!({ "status": "ok", "tabs": tabs })+}++static PAGES_CACHE: Mutex<Option<Vec<Value>>> = Mutex::new(None);+fn wiki_pages_all() -> Vec<Value> {+ if let Some(v) = PAGES_CACHE.lock().unwrap().as_ref() { return v.clone(); }+ let mut all: Vec<Value> = Vec::new();+ let mut offset = 0usize;+ loop {+ let body = match curl(&format!("{WIKI}/api/v1/pages?type=component&limit=200&offset={offset}")) { Some(b) => b, None => break };+ let v: Value = match serde_json::from_slice(&body) { Ok(v) => v, Err(_) => break };+ let pages = v.get("pages").and_then(|p| p.as_array()).cloned().unwrap_or_default();+ let n = pages.len();+ all.extend(pages);+ offset += n;+ let total = v.get("total").and_then(|t| t.as_u64()).unwrap_or(0) as usize;+ if n == 0 || all.len() >= total || all.len() >= 5000 { break; }+ }+ *PAGES_CACHE.lock().unwrap() = Some(all.clone());+ all+}+fn page_item(pg: &Value) -> Value {+ let slug = pg.get("slug").and_then(|s| s.as_str()).unwrap_or("");+ let title = pg.get("title").and_then(|s| s.as_str()).unwrap_or(slug);+ json!({"source":"wiki","id":slug,"token":format!("wiki:{slug}"),"title":title})+}+fn matches(pg: &Value, q: &str) -> bool {+ if q.is_empty() { return true; }+ let s = pg.get("slug").and_then(|s| s.as_str()).unwrap_or("").to_lowercase();+ let t = pg.get("title").and_then(|s| s.as_str()).unwrap_or("").to_lowercase();+ s.contains(q) || t.contains(q)+}++/// List a tab. Local/Mine/Org are bounded (filter the cached set, paginate here).+/// Public browse is server-paged (`/pages` offset); Public search is server+/// relevance (`/search`, top-N). `complete` drops parts missing sym/fp/3d.+/// Total count of a bounded tab (local/mine/org) BEFORE pagination — so the+/// scroll `more` flag is right even when the complete-filter shrinks a page.+fn next_total(tab: &str, ql: &str, name: &str) -> usize {+ if tab == "local" {+ local_components().into_iter().filter(|m| ql.is_empty() || m.to_lowercase().contains(ql)).count()+ } else if tab == "mine" {+ wiki_pages_all().iter().filter(|pg| pg.get("author_name").and_then(|a| a.as_str()) == Some(name) && matches(pg, ql)).count()+ } else if let Some(org) = tab.strip_prefix("org:") {+ wiki_pages_all().iter().filter(|pg| pg.get("org_name").and_then(|a| a.as_str()) == Some(org) && matches(pg, ql)).count()+ } else { 0 }+}++fn list_for_tab(tab: &str, offset: usize, limit: usize, search: &str, complete: bool) -> Value {+ let (name, _) = whoami();+ let ql = search.to_lowercase();+ let (mut items, mut more): (Vec<Value>, bool) = if tab == "local" {+ let all: Vec<Value> = local_components().into_iter()+ .filter(|m| ql.is_empty() || m.to_lowercase().contains(&ql))+ .map(|m| json!({"source":"local","id":m,"token":format!("local:{m}"),"title":m}))+ .collect();+ let total = all.len();+ (all.into_iter().skip(offset).take(limit).collect(), offset + limit < total)+ } else if tab == "mine" {+ let all: Vec<Value> = wiki_pages_all().iter()+ .filter(|pg| pg.get("author_name").and_then(|a| a.as_str()) == Some(name.as_str()) && matches(pg, &ql))+ .map(page_item).collect();+ let total = all.len();+ (all.into_iter().skip(offset).take(limit).collect(), offset + limit < total)+ } else if let Some(org) = tab.strip_prefix("org:") {+ let all: Vec<Value> = wiki_pages_all().iter()+ .filter(|pg| pg.get("org_name").and_then(|a| a.as_str()) == Some(org) && matches(pg, &ql))+ .map(page_item).collect();+ let total = all.len();+ (all.into_iter().skip(offset).take(limit).collect(), offset + limit < total)+ } else if !search.is_empty() {+ // Public SEARCH — server relevance, top-N (no offset paging).+ let mut v = Vec::new();+ if let Some(b) = curl(&format!("{WIKI}/api/v1/search?q={}&type=component&limit={}", urlenc(search), limit.max(50))) {+ if let Ok(j) = serde_json::from_slice::<Value>(&b) {+ if let Some(rs) = j.get("results").and_then(|r| r.as_array()) { for r in rs { v.push(page_item(r)); } }+ }+ }+ (v, false)+ } else {+ // Public BROWSE — server-paged.+ let mut v = Vec::new();+ if let Some(b) = curl(&format!("{WIKI}/api/v1/pages?type=component&limit={limit}&offset={offset}")) {+ if let Ok(j) = serde_json::from_slice::<Value>(&b) {+ if let Some(pgs) = j.get("pages").and_then(|p| p.as_array()) { for pg in pgs { v.push(page_item(pg)); } }+ }+ }+ let got = v.len();+ (v, got == limit)+ };++ // Next offset to request = how many SLOTS this page consumed (before the+ // complete-filter drops any), so scroll pagination stays correct.+ let raw = items.len();+ let next = if tab == "public" && search.is_empty() { offset + raw }+ else if tab == "public" { offset }+ else { offset + limit };+ if complete {+ items.retain(|it| {+ let sc = it.get("source").and_then(|x| x.as_str()).unwrap_or("");+ let id = it.get("id").and_then(|x| x.as_str()).unwrap_or("");+ let cf = component_files(sc, id);+ cf.sym.is_some() && cf.fp.is_some() && cf.glb.is_some()+ });+ }+ let _ = more;+ let more = if tab == "public" && search.is_empty() { raw == limit }+ else if tab == "public" { false }+ else { offset + limit < next_total(tab, &ql, &name) };+ json!({ "status": "ok", "items": items, "more": more, "next": next })+}+ /// The canonical Adom Babylon viewer bundle + its HDRI, vendored so the 3D /// column is self-contained (no CDN, no external host — the strict-CSP-safe way). const VIEWER_JS: &[u8] = include_bytes!("adom-3d-viewer.min.js");@@ -171,88 +433,63 @@ fn list_components() -> Value { /// authoritative tool's `embed` (`adom-symbol` / `adom-footprint`) on the wiki /// source file. Self-contained HTML, iframed by the Manager — no external assets, /// no home-grown SVG. Requires the tool to be installed (declared as a dep).-fn embed_view(slug: &str, ext: &str, tool: &str) -> Response<Cursor<Vec<u8>>> {+fn embed_view(source: &str, id: &str, which: &str, tool: &str) -> Response<Cursor<Vec<u8>>> { let page = |msg: &str, code: u16| { Response::from_string(format!( "<body style='margin:0;background:#0d1117;color:#8b949e;font:13px -apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh'><div>{msg}</div></body>" )).with_header(hdr("text/html; charset=utf-8")).with_status_code(code) };- let mpn = up(slug);- let src = match wiki_text(slug, &format!("{mpn}.{ext}")) {+ let text = match fetch_component_text(source, id, which) { Some(t) if !t.trim_start().starts_with('<') && !t.contains("\"error\"") => t,- _ => return page(&format!("no {ext} on the wiki for {mpn}"), 200),+ _ => return page(&format!("no {} for {id}", if which == "sym" { "symbol" } else { "footprint" }), 200), };- let base = std::env::temp_dir().join(format!("adomlbr-mgr-{slug}"));+ let ext = if which == "sym" { "kicad_sym" } else { "kicad_mod" };+ let base = std::env::temp_dir().join(format!("adomlbr-mgr-{source}-{id}")); let infile = base.with_extension(ext); let outfile = base.with_extension("embed.html");- if std::fs::write(&infile, src.as_bytes()).is_err() {- return page("temp write failed", 500);- }+ if std::fs::write(&infile, text.as_bytes()).is_err() { return page("temp write failed", 500); } let ok = Command::new(tool)- .args(["embed", "--file", infile.to_str().unwrap_or(""),- "--out", outfile.to_str().unwrap_or("")])+ .args(["embed", "--file", infile.to_str().unwrap_or(""), "--out", outfile.to_str().unwrap_or("")]) .output().map(|o| o.status.success()).unwrap_or(false);- if !ok {- return page(&format!("{tool} embed unavailable — is {tool} installed?"), 200);- }+ if !ok { return page(&format!("{tool} embed unavailable — is {tool} installed?"), 200); } match std::fs::read(&outfile) { Ok(b) => Response::from_data(b).with_header(hdr("text/html; charset=utf-8")), Err(_) => page("embed read failed", 500), } } -/// Fetch a component and build its adom-lbr part FRESH on every call (nothing is-/// cached), then render symbol/footprint SVG on the fly. The canonical adom-lbr-/// JSON is the primary source; if the page has none, we import the KiCad files.-fn get_part(slug: &str) -> Value {- let mpn = up(slug);- let (part, src): (AdomLbrPart, &str) = match load_part(slug, &mpn) {+/// Build a component's part (source-aware) for the layer-stackup + counts.+fn get_part(source: &str, id: &str) -> Value {+ let (part, src) = match load_part(source, id) { Ok(v) => v,- Err(e) => {- return json!({"status":"error","error":e,- "hints":["the page needs an adom-lbr JSON, or <MPN>.kicad_sym + <MPN>.kicad_mod"]})- }+ Err(e) => return json!({"status":"error","error":e}), };- let has_glb = wiki_bytes(slug, &format!("{mpn}.glb")).is_some();+ let cf = component_files(source, id); json!({ "status": "ok",- "slug": slug,+ "id": id, "mpn": part.mpn,- "source": src, // "adom-lbr json" or "kicad (generated)"- "symbol_svg": symbol_svg(&part.symbol), // rendered on the fly, every click- "footprint_svg": footprint_svg(&part.footprint, &pin_names(&part.symbol)),+ "source_kind": src, "layers": layer_stackup(&part.footprint),- "glb": has_glb,+ "glb": cf.glb.is_some(), "pins": part.symbol.pins.len(), "pads": part.footprint.pads.len(), }) } -/// Load a part, preferring the canonical adom-lbr JSON on the page; else generate-/// it from the KiCad files. Returns the part + a label of where it came from.-fn load_part(slug: &str, mpn: &str) -> Result<(AdomLbrPart, &'static str), String> {- // 1) canonical adom-lbr JSON (try the common filenames)- for name in [- format!("{mpn}.adom-lbr.json"),- format!("{mpn}.adomlbr.json"),- "adom-lbr.json".to_string(),- format!("{mpn}.json"),- ] {- if let Some(txt) = wiki_text(slug, &name) {- if let Ok(p) = serde_json::from_str::<AdomLbrPart>(&txt) {- return Ok((p, "adom-lbr json"));- }+fn load_part(source: &str, id: &str) -> Result<(AdomLbrPart, &'static str), String> {+ let stem = if source == "local" { id.to_string() } else { up(id) };+ for name in [format!("{stem}.adom-lbr.json"), format!("{stem}.json"), "adom-lbr.json".to_string()] {+ let txt = if source == "local" {+ library_root().and_then(|r| std::fs::read_to_string(r.join(id).join(&name)).ok())+ } else { wiki_text(id, &name) };+ if let Some(txt) = txt {+ if let Ok(p) = serde_json::from_str::<AdomLbrPart>(&txt) { return Ok((p, "adom-lbr json")); } } }- // 2) fall back to the KiCad source files- match (- wiki_text(slug, &format!("{mpn}.kicad_sym")),- wiki_text(slug, &format!("{mpn}.kicad_mod")),- ) {- (Some(s), Some(m)) => altium_codec::import_kicad(&s, &m, mpn)- .map(|p| (p, "kicad (generated)"))- .map_err(|e| e.to_string()),- _ => Err(format!("no adom-lbr JSON or KiCad files on {slug}")),+ match (fetch_component_text(source, id, "sym"), fetch_component_text(source, id, "fp")) {+ (Some(sy), Some(m)) => altium_codec::import_kicad(&sy, &m, id).map(|p| (p, "kicad (generated)")).map_err(|e| e.to_string()),+ _ => Err(format!("no adom-lbr JSON or KiCad files for {id}")), } }