← 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

John Lauer ·8d3b806838 ·3mo ago ·parent 04e52e9
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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}")),     } }