app
Chip Fetcher Lite
Public Made by Adomby adom
Lightweight wiki-first component staging area. Add a component, check the Adom wiki for KiCad/Altium/Fusion files, upload what's missing, then file the symbol + footprint + 3D model straight into your EDA libraries — with library destinations matched per manufacturer and a clean 5-field symbol.
← Commit history
Publish 1.0.0
9 files changed
+1631
DEVELOPING.mdadded+108@@ -0,0 +1,108 @@+# Developing chip-fetcher-lite — architecture & hard-won lessons++Read this before extending the app. Everything here is a real lesson from the build; it will save+a fresh session hours.++## Architecture in one screen++- **`server.js`** — pure-Node `http` server (no native deps). Serves the SPA + a REST API.+ In-memory `DB` persisted to `data/data.json` (debounced). Uploaded files in `data/staging/<id>/`.+- **`public/index.html`** — the entire frontend: one vanilla-JS SPA, dark Adom theme. Polls+ `GET /api/state` every 2.5s and re-renders.+- **`cli.js`** — thin wrapper (`serve`, `add`, `list`, `app`).+- No build step. Start with `node server.js` (port 7821, `CFL_PORT` to override).++### Data model (per component)+`{ id, name(=MPN once resolved), mpn, slug, type, passive, manufacturer, package, subcategory,+ pinCount, description, jlc, jlcPart, datasheetUrl, selected, footprintLocked,+ wiki:{status, slug, owner, url, variants}, _wiki:{files[], has3d},+ eda:{kicad|altium|fusion: {symbol|footprint|model3d: {present, source}}},+ uploads:[{eda,kind,name,path,...}], dest:{symbol|footprint|model3d:{name,path,exists,source,fpName}},+ names:{symbol,footprint} }`++- `eda` is recomputed (`recomputeEda`) from `_wiki.files` + `uploads`. 3D files mark `model3d` for all EDAs.+- `dest` is computed by `computeDest`/`applyDest` from the probed libraries.++## The Adom wiki (what this app reads)++- **Search:** `adompkg search <q>` (FTS). Hyphens break FTS (`chip-fetcher` → "no such column").+- **Page meta:** `GET https://wiki.adom.inc/api/v1/pages/<slug>` → `.page` (brief, owner) + `.metadata`+ (mpn, manufacturer, package, subcategory, pin_count, jlcpcb_part, datasheet_path, has_symbol/footprint/3d_model).+- **File list:** `GET /api/v1/pages/<slug>/files`.+- **Download a file:** `GET /blob/component/<slug>/<filename>` (the `/raw/` and `/api/.../raw/` paths 404).+- Components are wiki **pages** (type `component`), NOT adompkg packages — `adompkg view` won't find them.+- Most "library"/passive component pages are **stubs** (README + page.json only, no CAD). Handle gracefully.++### Description → MPN resolution+Users type descriptions ("100nF 0402 capacitor"). `classify()` resolves them to the real MPN page by+matching value+package against the FTS top component result (`titleMpn()` parses the MPN from a title+like "0402B104J160CT — 100nF MLCC Capacitor"). The component column then shows the MPN.++## The desktop (THE most important section)++**Barrett's "laptop" is Linux**, `HOME=/home/barrett-land`. Don't assume Windows.+**Always reach his file system through the `adom-desktop` bridge — never the container FS.**++His KiCad user libraries:+- `~/Documents/KiCAD/Symbols/*.kicad_sym` (JLC_*_BLT, etc.)+- `~/Documents/KiCAD/Footprints/*.pretty` (manufacturer-named: `TI_BLT.pretty`, …)+- `~/Documents/KiCAD/3D/<Manufacturer>/` (manufacturer subfolders)+- KiCad stock libs at `/usr/share/kicad/footprints/<lib>.pretty` (Capacitor_SMD, Resistor_SMD, …).++### adom-desktop gotchas (these WILL bite you)+- `desktop_list_files` is **non-recursive** and does **NOT** expand `%VARS%`/`~`. Takes a JSON arg.+- `read_file` **does** expand `~`/`%VARS%`. No approval needed.+- `shell_execute` runs `sh -c` (Linux). Needs user approval (auto-approve was on for Barrett).+- **`send_files` IGNORES its `dest` arg on Linux** — it dumps everything to `~/Downloads`. So:+ land the file, then `mv` it into place with a `shell_execute`. (`sendFiles()` does this.)+- `write_file` works but base64 on the CLI arg has a size ceiling — prefer `send_files`+`mv` for real files.++## Destination logic (computeDest)++- **Symbol** → JLC category lib by type: `JLC_<Cat>_BLT.kicad_sym` (Capacitors/Resistors/Inductors/+ LEDs/Diodes/Oscillators/FETs/Connectors/Flash/Power/MCUs/Misc). MCUs matches MPN hints (stm32/nrf/…).+- **Footprint** → prefer the wiki footprint filed into the manufacturer `.pretty`; for passives NOT on+ the wiki, use the KiCad **stock** lib (Capacitor_SMD → `C_0402_1005Metric`, name LOCKED, "KiCad default" badge).+- **3D** → manufacturer subfolder (deepest), matched to `metadata.manufacturer`.+- **`matchByMfr`** matches a manufacturer to a lib/folder by **exact name-segment** (`TI_BLT`→`ti`); fuzzy+ substring only when BOTH sides ≥4 chars; aliases (`MFR_ALIASES`) expand ONLY on a full-name match; generic+ words (`MFR_STOP`: technology/corporation/…) are filtered. Without these, "corpora**ti**on"→TI and+ "in**st**ruments"→ST false-matched. Don't loosen it.++## Filing into KiCad (send-to-library)++- Footprint/3D: download → `sendFiles()` (land + mv).+- Symbol: `read_file` the dest lib → `extractSymbolBlock` (balanced-paren) → `renameSymbol` →+ `stripExtraProps` (keep ONLY Reference/Value/Footprint/Datasheet/Description) → set Description+ (comma-separated specs), Footprint (`<nick>:<fpName>`), Datasheet, LCSC(=jlcPart) → `mergeSymbol`+ REPLACES an existing symbol of that name (re-send updates) → `.bak` backup → `sendFiles` (overwrites).+- Stubs (no CAD) are skipped, not errored.+- Symbol descriptions: passives = value/size/power(or voltage/dielectric); ICs = manufacturer, package,+ pin count, subcategory — all comma-separated into the single Description field (NOT separate fields).++## Hard rules++- **Re-checking the wiki must NEVER delete user-uploaded files or user-edited fields.** `runCheck`+ preserves `uploads` and a manually-set `jlcPart` (`meta.jlcpcb_part || c.jlcPart || null`); edited names+ survive (only reset on a footprint lock-state change).+- **Dedup:** adding a duplicate name, or a description that resolves to an MPN already listed, is rejected.+- **Bounded wiki checks:** mass-add uses a 4-concurrent queue (`enqueueCheck`/`pumpChecks`) — never fire+ N `adompkg` processes at once (138-part stress test confirmed this is necessary).++## Local dev gotchas++- **`pkill -f "node server.js"` matches its OWN shell** (the start command contains that string) and+ kills itself before node starts. Kill by PID (`ps -eo pid,args | grep "[s]erver.js"`) or+ `fuser -k 7821/tcp`, in a SEPARATE command from the `node server.js` start.+- Start the server as a background task with `exec node server.js` (cwd = repo). Don't combine kill+start.+- API calls in the SPA are **relative to the page** (`new URL('api'+path, document.baseURI)`) so they work+ behind the Hydrogen proxy (`/proxy/<port>/`). Absolute `/api/...` hits the proxy origin and 404s.+- Fonts: Adom brand `@font-face` from `adom.inc/fonts/...` (verified 200). Never system fonts as primary.+- **Sticky top bar:** the header + controls + table column-header must stay pinned while rows scroll.+ Two traps: (1) `body{height:100%}` constrains sticky children to one viewport so they scroll away —+ use `body{min-height:100%}` instead; (2) the thead's sticky `top` must equal header+controls height,+ which CHANGES when the controls bar wraps — compute it in JS (`updateStickyOffsets` sets `--header-h`+ and `--stick-top`, called on render + resize), never hardcode pixels.+- The webview tab is created with `adom-cli hydrogen webview open-or-refresh --name "Chip Fetcher Lite"+ --url <proxyURL> --panel-id <pane>`; `--panel-id` is required the first time (find via+ `adom-cli hydrogen workspace tabs`). Reload picks up server-side file changes (server serves from disk).
SKILL.mdadded+59@@ -0,0 +1,59 @@+---+name: chip-fetcher-lite+description: >+ Lightweight wiki-first component staging area. Add a component (MPN or description), check the+ Adom wiki for KiCad/Altium/Fusion files, upload what's missing, then file the symbol + footprint+ + 3D model into the user's EDA libraries with per-manufacturer destinations and a clean 5-field+ symbol. A fast, low-context alternative to the full chip-fetcher. Trigger words — chip fetcher+ lite, stage a component, check the wiki for a part, do I have CAD for, add basic parts, file into+ kicad, send to library, upload component to kicad.+---++# chip-fetcher-lite++A low-context alternative to the full chip-fetcher. It does NOT scrape vendors, generate symbols,+or run concur. It reads the Adom wiki, stages files, and files them into the user's libraries.++## Running++```+node ~/.claude/skills/chip-fetcher-lite/server.js # or: chip-fetcher-lite serve+chip-fetcher-lite app # open as a Hydrogen webview+```++Server listens on `:7821` (`CFL_PORT` to override). Runtime data in `data/` (not shipped).++## What's automatic (no agent needed)++- **Wiki check** on add: `adompkg search` → classify exact/variant/none (MPN→slug normalize,+ passive detection, **description→MPN resolution** so "100nF 0402 capacitor" → `0402B104J160CT`).+ Bounded to 4 concurrent lookups so mass-adds don't spawn hundreds of processes.+- **File-availability boxes** per row (KiCad/Altium/Fusion + JLC) from the wiki page's files/metadata.+- **Library probe** (clicking *Probe libraries*): the SERVER shells `adom-desktop shell_execute`+ to scan the user's machine for their KiCad libraries (`~/Documents/KiCAD/{Symbols,Footprints,3D}`,+ etc.) and computes per-component destinations.+- **Send to library** (clicking *Send to library*): the SERVER downloads each component's CAD from+ `wiki.adom.inc/blob/component/<slug>/<file>` (or uses uploaded files) and files them onto the+ desktop via adom-desktop — footprint→`.pretty`, 3D→manufacturer folder, symbol→merged into the+ JLC category lib (trimmed to Reference/Value/Footprint/Datasheet/Description + LCSC). Creates and+ registers any missing libraries. Live progress in the top bar.++## The agent's role++Mostly: drive the UI for the user and answer "what's on the wiki." Useful things you can do:++- **Add parts in bulk** — POST `/api/components {name}` per part (dedup is automatic). Honour a+ concurrency-friendly pace; the server queues the checks.+- **Look up JLC/LCSC part numbers** the wiki doesn't have: `adom-parts-search search <mpn>` →+ the jlcpcb hit's `vendor_pn` (a `C…` number) → POST `/api/components/:id/jlc {jlcPart}`.+- **Open the webview** in the right pane (see DEVELOPING.md for the panel-id flow).++## Hard rules++- **Re-checking the wiki must NEVER delete user-uploaded files or user-edited fields** (JLC PN,+ edited names). The server preserves them; keep it that way.+- **Always reach the user's file system through adom-desktop**, never the container FS.+- Prefer what's on the wiki over the KiCad default library; use the KiCad **stock** footprint only+ for passives that aren't on the wiki.++See **DEVELOPING.md** for the full architecture + the gotchas that will bite a fresh session.
cli.jsadded+65@@ -0,0 +1,65 @@+#!/usr/bin/env node+'use strict';+/*+ * chip-fetcher-lite CLI — thin wrapper over the local server API.+ *+ * chip-fetcher-lite serve start the server (foreground)+ * chip-fetcher-lite add <name...> add a component (starts a wiki check)+ * chip-fetcher-lite list list components + status+ * chip-fetcher-lite jobs show pending agent jobs (probe / send)+ * chip-fetcher-lite app open the viewer as a Hydrogen webview+ */+const { spawn, execFileSync } = require('child_process');+const path = require('path');++const PORT = parseInt(process.env.CFL_PORT || process.env.PORT || '7821', 10);+const BASE = `http://localhost:${PORT}`;++async function api(p, opts) {+ const r = await fetch(BASE + p, opts);+ if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);+ return r.json();+}++async function main() {+ const [cmd, ...rest] = process.argv.slice(2);+ switch (cmd) {+ case 'serve': {+ const s = spawn(process.execPath, [path.join(__dirname, 'server.js')], { stdio: 'inherit', env: process.env });+ s.on('exit', c => process.exit(c || 0));+ break;+ }+ case 'add': {+ const name = rest.join(' ').trim();+ if (!name) { console.error('usage: add <name>'); process.exit(1); }+ const c = await api('/api/components', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) });+ console.log(`added ${c.name} (${c.id}) — checking wiki…`);+ break;+ }+ case 'list': {+ const s = await api('/api/state');+ for (const c of s.components) {+ const n = ['kicad', 'altium', 'fusion'].filter(e => c.eda[e].present);+ console.log(`${c.name.padEnd(22)} ${c.wiki.status.padEnd(9)} [${n.join(',') || '—'}] ${c.type}`);+ }+ break;+ }+ case 'jobs': {+ const s = await api('/api/jobs');+ console.log(JSON.stringify(s.jobs, null, 2));+ break;+ }+ case 'app': {+ const proxy = process.env.VSCODE_PROXY_URI;+ if (!proxy) { console.log(`open ${BASE} in a browser`); break; }+ const url = proxy.replace('{{port}}', String(PORT));+ try {+ execFileSync('adom-cli', ['hydrogen', 'webview', 'open-or-refresh', '--name', 'Chip Fetcher Lite', '--url', url], { stdio: 'inherit' });+ } catch (e) { console.log(`open this URL as a webview:\n ${url}`); }+ break;+ }+ default:+ console.log('chip-fetcher-lite <serve|add|list|jobs|app>');+ }+}+main().catch(e => { console.error(e.message); process.exit(1); });
install.shadded+16@@ -0,0 +1,16 @@+#!/bin/bash+set -e+PKG_DIR="$(cd "$(dirname "$0")" && pwd)"+SKILLS_DIR="$HOME/.claude/skills"+DEST="$SKILLS_DIR/chip-fetcher-lite"+mkdir -p "$DEST"+cp -r "$PKG_DIR/server.js" "$PKG_DIR/cli.js" "$PKG_DIR/public" "$PKG_DIR/SKILL.md" "$DEST/" 2>/dev/null || true+[ -f "$PKG_DIR/README.md" ] && cp "$PKG_DIR/README.md" "$DEST/README.md"+# expose the CLI+mkdir -p "$HOME/.local/bin"+cat > "$HOME/.local/bin/chip-fetcher-lite" <<EOF+#!/bin/bash+exec node "$DEST/cli.js" "\$@"+EOF+chmod +x "$HOME/.local/bin/chip-fetcher-lite"+echo "Installed chip-fetcher-lite"
package.jsonadded+27@@ -0,0 +1,27 @@+{+ "slug": "chip-fetcher-lite",+ "title": "Chip Fetcher Lite",+ "version": "1.0.0",+ "type": "app",+ "description": "Lightweight wiki-first component staging area. Add a component, check the Adom wiki for KiCad/Altium/Fusion files, upload what's missing, then file the symbol + footprint + 3D model straight into your EDA libraries — with library destinations matched per manufacturer and a clean 5-field symbol.",+ "tags": ["eda", "kicad", "altium", "fusion", "components", "cad", "library", "passives", "jlcpcb", "footprint", "symbol", "staging", "chip-fetcher"],+ "hero": { "path": "screenshots/hero.png" },+ "dependencies": {},+ "files": [+ "screenshots/hero.png",+ "server.js",+ "cli.js",+ "public/index.html",+ "SKILL.md",+ "README.md",+ "DEVELOPING.md",+ "install.sh",+ "uninstall.sh",+ "package.json"+ ],+ "scripts": {+ "start": "node server.js",+ "install": "./install.sh",+ "uninstall": "./uninstall.sh"+ }+}
public/index.htmladded+487@@ -0,0 +1,487 @@+<!DOCTYPE html>+<html lang="en">+<head>+<meta charset="utf-8" />+<meta name="viewport" content="width=device-width, initial-scale=1" />+<title>chip-fetcher-lite</title>+<style>+ /* Adom brand fonts — Familjen Grotesk (headlines), Satoshi (body), JetBrains Mono (code).+ Per the Adom brand style guide (wiki: brand). Never fall back to system fonts as the primary face. */+ @font-face{font-family:'Familjen Grotesk';src:url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2') format('woff2');font-weight:700;font-display:swap}+ @font-face{font-family:'Familjen Grotesk';src:url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2') format('woff2');font-weight:500;font-display:swap}+ @font-face{font-family:'Satoshi';src:url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2');font-weight:400;font-display:swap}+ @font-face{font-family:'Satoshi';src:url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2');font-weight:500;font-display:swap}+ @font-face{font-family:'JetBrains Mono';src:url('https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx3cwhsk.woff2') format('woff2');font-weight:400;font-display:swap}+ @font-face{font-family:'JetBrains Mono';src:url('https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxTcwhsk.woff2') format('woff2');font-weight:500;font-display:swap}+ :root{+ --bg:#0d1117; --surface:#161b22; --elevated:#1c2128; --overlay:#21262d;+ --border:#30363d; --border-muted:#21262d;+ --text:#e6edf3; --text-muted:#8b949e; --text-dim:#484f58;+ --accent:#00b8b0; --accent-bright:#00e6dc; --accent-dim:rgba(0,184,176,.12); --accent-border:rgba(0,184,176,.25);+ --green:#3fb950; --green-dim:rgba(63,185,80,.14);+ --yellow:#d29922; --yellow-dim:rgba(210,153,34,.14);+ --red:#f85149; --red-dim:rgba(248,81,73,.14);+ --purple:#8C6BF7; --row-hover:#1c2128;+ }+ *{box-sizing:border-box}+ html{height:100%}+ body{margin:0;min-height:100%}+ body{background:var(--bg);color:var(--text);font-family:'Satoshi',-apple-system,Segoe UI,Roboto,sans-serif;font-size:13px}+ .mono{font-family:'JetBrains Mono',ui-monospace,Menlo,Consolas,monospace}+ h1,h2,h3{font-family:'Familjen Grotesk',sans-serif;margin:0}+ button{font-family:inherit;cursor:pointer}++ /* header */+ .app-header{display:flex;align-items:center;gap:12px;height:44px;padding:0 14px;background:var(--surface);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:30}+ .brand{display:flex;align-items:center;gap:9px}+ .logo-chip{display:block;color:var(--accent-bright)}+ .app-name{font-family:'Familjen Grotesk',sans-serif;font-weight:600;font-size:15px;letter-spacing:.2px;color:var(--accent-bright)}+ .sep{color:var(--text-dim)}+ .app-subtitle{color:var(--text-muted);font-size:12px}+ .subject{margin-left:6px;color:var(--text-muted);font-size:12px}+ .subject b{color:var(--text);font-weight:600}+ .spacer{flex:1}+ .lib-status{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--text-muted);margin-right:6px}+ .lib-status .dot{width:8px;height:8px;border-radius:50%;background:var(--text-dim);flex:none}+ .lib-status.ok .dot{background:var(--green);box-shadow:0 0 6px var(--green)}+ .lib-status.warn .dot{background:var(--yellow)}+ .lib-status.none .dot{background:var(--red)}+ .send-status{display:inline-flex;align-items:center;gap:7px;font-size:12px;padding:4px 11px;border-radius:7px;margin-left:12px;font-weight:600;max-width:520px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}+ .send-status.running{background:var(--accent-dim);border:1px solid var(--accent-border);color:var(--text)}+ .send-status.done{background:var(--green-dim);border:1px solid var(--green);color:var(--text)}+ .send-status.err{background:var(--red-dim);border:1px solid var(--red);color:var(--text)}++ /* controls bar */+ .controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:10px 14px;background:var(--bg);border-bottom:1px solid var(--border-muted);position:sticky;top:var(--header-h,44px);z-index:20}+ .add-box{display:flex;align-items:center;gap:6px;flex:1;min-width:260px;max-width:520px}+ input[type=text]{background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:7px 10px;font-size:13px;width:100%}+ input[type=text]:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-dim)}+ .btn{background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:7px 12px;font-size:12.5px;font-weight:600;transition:.15s;white-space:nowrap}+ .btn:hover{border-color:var(--accent-border);background:var(--elevated)}+ .btn.primary{background:var(--accent);border-color:var(--accent);color:#04231f}+ .btn.primary:hover{background:var(--accent-bright)}+ .btn:disabled{opacity:.45;cursor:not-allowed}+ .pills{display:flex;gap:6px}+ .pill{background:transparent;border:1px solid var(--border);color:var(--text-muted);border-radius:999px;padding:5px 11px;font-size:12px}+ .pill.active{color:var(--text);border-color:var(--accent-border);background:var(--accent-dim)}+ .send-group{display:flex;align-items:center;gap:8px;padding:5px 9px;border:1px solid var(--border);border-radius:8px;background:var(--surface)}+ .send-group .lbl{color:var(--text-muted);font-size:11px;text-transform:uppercase;letter-spacing:.4px}+ .eda-checks{display:flex;gap:8px}+ .eda-checks label{display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-muted);cursor:pointer;user-select:none}+ .eda-checks input{accent-color:var(--accent)}++ /* table */+ .wrap{padding:0 0 60px}+ table{width:100%;border-collapse:collapse}+ thead th{position:sticky;top:var(--stick-top,96px);z-index:10;background:var(--surface);color:var(--text-muted);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.5px;text-align:left;padding:8px 12px;border-bottom:1px solid var(--border)}+ .cat-row td{background:var(--elevated);border-top:1px solid var(--border);border-bottom:1px solid var(--border-muted);padding:6px 12px}+ .cat-name{font-family:'Satoshi',sans-serif;font-weight:600;font-size:12px;letter-spacing:.6px;text-transform:uppercase;color:var(--accent-bright)}+ .cat-count{color:var(--text-dim);font-size:11px;margin-left:8px}+ tbody tr.item{border-bottom:1px solid var(--border-muted);cursor:pointer}+ tbody tr.item:hover{background:var(--row-hover)}+ tbody tr.item.sel{background:var(--accent-dim);box-shadow:inset 3px 0 0 var(--accent)}+ td{padding:8px 12px;vertical-align:middle}+ td.sel-col{width:34px;text-align:center}+ input[type=checkbox]{accent-color:var(--accent);width:15px;height:15px;cursor:pointer}+ .name{font-weight:600}+ .name .mpn{color:var(--text-muted);font-size:11px;font-weight:400}+ .cdesc{color:var(--text-muted);font-size:11px;font-weight:400;margin-top:2px;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}+ .dest{color:var(--text);font-size:12px}+ .dest.empty{color:var(--text-dim)}+ .dest .basename{cursor:default;border-bottom:1px dotted var(--text-dim)}+ .namecell{min-width:180px}+ .nameinput{background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:5px;padding:4px 7px;font-size:12px;width:100%;font-family:'JetBrains Mono',ui-monospace,monospace}+ .nameinput:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-dim)}+ .destsub{font-size:10.5px;color:var(--text-dim);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}+ .destsub .basename{border-bottom:1px dotted var(--text-dim);cursor:default;color:var(--text-muted)}+ .destsub .nodest{font-style:italic}++ /* EDA file-availability boxes — one per EDA, each colours on its own */+ .eda-boxes{display:flex;gap:6px}+ .checking-box{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);border-radius:8px;padding:5px 10px;font-size:11px;color:var(--text-dim)}+ .eda-box{position:relative;display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border:1px solid var(--border);border-radius:8px;background:var(--surface);font-size:11.5px;font-weight:600;cursor:default}+ .eda-box .tally{font-family:'JetBrains Mono',monospace;font-size:9.5px;opacity:.85}+ .eda-box.jlc{cursor:default}+ .newtag{font-size:9px;font-weight:700;letter-spacing:.3px;text-transform:uppercase;color:var(--accent-bright);border:1px solid var(--accent-border);background:var(--accent-dim);border-radius:4px;padding:0 4px;vertical-align:1px}+ .deftag{font-size:9px;font-weight:700;letter-spacing:.3px;text-transform:uppercase;color:var(--purple-light);border:1px solid rgba(140,107,247,.4);background:rgba(140,107,247,.14);border-radius:4px;padding:0 4px;vertical-align:1px}+ .nameinput.locked{opacity:.7;cursor:not-allowed;border-style:dashed}+ .eda-box.green{border-color:var(--green);background:var(--green-dim);color:var(--text)}+ .eda-box.yellow{border-color:var(--yellow);background:var(--yellow-dim);color:var(--text)}+ .eda-box.red{border-color:var(--red);background:var(--red-dim);color:var(--text-muted)}+ .eda-pop{position:absolute;top:calc(100% + 6px);left:0;z-index:40;display:none;flex-direction:column;gap:5px;background:var(--overlay);border:1px solid var(--border);border-radius:8px;padding:9px 11px;min-width:140px;box-shadow:0 12px 34px rgba(0,0,0,.55)}+ .eda-box:hover .eda-pop{display:flex}+ .eda-pop .pophdr{font-size:10px;letter-spacing:.5px;text-transform:uppercase;color:var(--text-dim);margin-bottom:1px}+ .eda-pop .pk{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-dim)}+ .eda-pop .pk.on{color:var(--text)}+ .eda-pop .cb{width:15px;height:15px;border:1px solid var(--text-dim);border-radius:4px;display:inline-flex;align-items:center;justify-content:center;font-size:11px;color:#04231f;flex:none}+ .eda-pop .pk.on .cb{background:var(--green);border-color:var(--green)}+ .eda-pop .pk .tag{margin-left:auto;font-size:9px;color:var(--text-dim);font-style:italic}+ .spin{width:12px;height:12px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;display:inline-block;animation:sp .7s linear infinite;vertical-align:-2px}+ @keyframes sp{to{transform:rotate(360deg)}}++ .row-actions{display:flex;gap:6px;justify-content:flex-end}+ .icon-btn{background:var(--surface);border:1px solid var(--border);color:var(--text-muted);border-radius:6px;width:30px;height:28px;display:inline-flex;align-items:center;justify-content:center;font-size:13px}+ .icon-btn:hover{border-color:var(--accent-border);color:var(--text)}+ .icon-btn.del:hover{border-color:var(--red);color:var(--red)}+ .icon-btn:disabled{opacity:.3;cursor:not-allowed}+ .icon-btn:disabled:hover{border-color:var(--border);color:var(--text-muted)}+ .badge3d{font-size:10px;color:var(--text-dim);border:1px solid var(--border);border-radius:5px;padding:2px 5px}+ .badge3d.on{color:var(--green);border-color:var(--green)}++ .empty-state{text-align:center;color:var(--text-muted);padding:80px 20px}+ .empty-state h2{font-size:18px;margin-bottom:8px;color:var(--text)}++ /* modal */+ .modal-bg{position:fixed;inset:0;background:rgba(1,4,9,.7);backdrop-filter:blur(6px);display:none;align-items:center;justify-content:center;z-index:50}+ .modal-bg.show{display:flex}+ .modal{background:var(--overlay);border:1px solid var(--border);border-radius:12px;padding:22px;width:440px;max-width:92vw;box-shadow:0 20px 60px rgba(0,0,0,.5)}+ .modal h2{font-size:17px;margin-bottom:6px}+ .modal p{color:var(--text-muted);margin:0 0 16px;line-height:1.5}+ .modal .eda-pick{display:flex;flex-direction:column;gap:10px;margin-bottom:18px}+ .modal .eda-pick label{display:flex;align-items:center;gap:9px;padding:10px 12px;border:1px solid var(--border);border-radius:8px;cursor:pointer;font-size:13px}+ .modal .eda-pick label:hover{border-color:var(--accent-border);background:var(--accent-dim)}+ .modal-actions{display:flex;gap:8px;justify-content:flex-end}++ /* toast */+ #toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:var(--elevated);border:1px solid var(--accent-border);color:var(--text);padding:10px 16px;border-radius:9px;font-size:12.5px;z-index:60;opacity:0;transition:.2s;pointer-events:none}+ #toast.show{opacity:1}+</style>+</head>+<body>+ <header class="app-header">+ <div class="brand">+ <svg class="logo-chip" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">+ <rect x="7" y="7" width="10" height="10" rx="1.5"/>+ <rect x="10.5" y="10.5" width="3" height="3" rx=".5"/>+ <line x1="10" y1="7" x2="10" y2="4"/><line x1="14" y1="7" x2="14" y2="4"/>+ <line x1="10" y1="17" x2="10" y2="20"/><line x1="14" y1="17" x2="14" y2="20"/>+ <line x1="7" y1="10" x2="4" y2="10"/><line x1="7" y1="14" x2="4" y2="14"/>+ <line x1="17" y1="10" x2="20" y2="10"/><line x1="17" y1="14" x2="20" y2="14"/>+ </svg>+ <span class="app-name">chip-fetcher-lite</span>+ </div>+ <span class="subject">· <b id="count">0</b> components</span>+ <span class="send-status" id="sendStatus" style="display:none"></span>+ <span class="spacer"></span>+ <span class="lib-status" id="libStatus"><span class="dot"></span><span class="txt">libraries not probed</span></span>+ <button class="btn" id="probeBtn" title="Probe your EDA libraries">◎ Probe libraries</button>+ </header>++ <div class="controls">+ <div class="add-box">+ <input type="text" id="addInput" placeholder="Add a component — MPN or name, then Enter" autocomplete="off" />+ <button class="btn primary" id="addBtn">+ Add</button>+ </div>+ <div class="pills" id="pills">+ <button class="pill active" data-f="all">All</button>+ <button class="pill" data-f="green">Complete</button>+ <button class="pill" data-f="yellow">Partial</button>+ <button class="pill" data-f="red">Missing</button>+ </div>+ <span class="spacer"></span>+ <button class="btn" id="selectAll">✓ Select all</button>+ <div class="send-group">+ <span class="lbl">send to</span>+ <div class="eda-checks" id="sendEdas">+ <label><input type="checkbox" value="kicad" checked> KiCad</label>+ <label><input type="checkbox" value="altium"> Altium</label>+ <label><input type="checkbox" value="fusion"> Fusion</label>+ </div>+ <button class="btn primary" id="sendBtn">↑ Send to library</button>+ </div>+ </div>++ <div class="wrap">+ <table>+ <thead><tr>+ <th class="sel-col"></th>+ <th>Component</th>+ <th>Symbol name → dest</th>+ <th>Footprint name → dest</th>+ <th>3D dest</th>+ <th id="edaHead">EDA Files Available</th>+ <th style="text-align:right">Actions</th>+ </tr></thead>+ <tbody id="tbody"></tbody>+ </table>+ <div class="empty-state" id="empty" style="display:none">+ <h2>No components yet</h2>+ <div>Add a component above. It'll be checked against the Adom wiki for KiCad, Altium & Fusion files.</div>+ </div>+ </div>++ <!-- probe modal -->+ <div class="modal-bg" id="probeModal">+ <div class="modal">+ <h2>Probe your EDA libraries?</h2>+ <p>To show where each component will be filed, I can scan your local libraries. Pick which EDA software you use:</p>+ <div class="eda-pick">+ <label><input type="checkbox" value="kicad" checked> <span>KiCad</span></label>+ <label><input type="checkbox" value="altium"> <span>Altium</span></label>+ <label><input type="checkbox" value="fusion"> <span>Fusion 360</span></label>+ </div>+ <div class="modal-actions">+ <button class="btn" id="probeSkip">Skip for now</button>+ <button class="btn primary" id="probeGo">⌖ Probe my libraries</button>+ </div>+ </div>+ </div>++ <input type="file" id="fileInput" style="display:none" />+ <div id="toast"></div>++<script>+const $ = s => document.querySelector(s);+let STATE = { components:[], probe:{edas:[]}, jobs:[] };+let FILTER = 'all';+let uploadTarget = null;++function toast(msg){ const t=$('#toast'); t.textContent=msg; t.classList.add('show'); clearTimeout(t._t); t._t=setTimeout(()=>t.classList.remove('show'),2600); }+// Resolve API paths relative to the page (works behind the Hydrogen proxy at /proxy/<port>/).+function apiUrl(path){ return new URL('api'+path, document.baseURI).href; }+async function api(path, opts){ const r = await fetch(apiUrl(path), opts); if(!r.ok && r.status!==409){ const e=await r.json().catch(()=>({})); throw Object.assign(new Error(e.error||r.statusText),{status:r.status,body:e}); } return r.status===409 ? Object.assign({_conflict:true}, await r.json()) : r.json(); }++// EDAs the user actually probed for; before any probe, show all three.+function activeEdas(){+ const e=(STATE.probe&&STATE.probe.edas)||[];+ const v=e.filter(x=>['kicad','altium','fusion'].includes(x));+ return v.length?v:['kicad','altium','fusion'];+}+const KINDS=[['symbol','Symbol'],['footprint','Footprint'],['model3d','3D']];+// each EDA box colours independently by how many of symbol/footprint/3d it has+function edaColor(c,eda){+ const e=c.eda[eda]||{}; const n=['symbol','footprint','model3d'].filter(k=>e[k]&&e[k].present).length;+ return n===3?'green':n>=1?'yellow':'red';+}+// component-level status (for the filter pills): green=all active boxes green, red=all red, else yellow+function compStatus(c){+ if(c.wiki.status==='checking') return 'checking';+ const cols=['kicad','altium','fusion'].map(e=>edaColor(c,e));+ if(cols.every(x=>x==='green')) return 'green';+ if(cols.every(x=>x==='red')) return 'red';+ return 'yellow';+}+// editable library name (defaults to MPN) + the destination it'll be filed into+function destBasename(d){+ if(!d||!d.name) return '<span class="nodest">no destination yet</span>';+ let tag='';+ if(d.source==='kicad-default') tag=' <span class="deftag">KiCad default</span>';+ else if(d.exists===false) tag=' <span class="newtag">new</span>';+ return `→ <span class="basename" title="${esc(d.path||d.name)}">${esc(d.name)}</span>${tag}`;+}+function nameDestCell(c, kind){+ const d=c.dest&&c.dest[kind];+ const nm=(c.names&&c.names[kind])!=null?c.names[kind]:c.mpn;+ const locked = kind==='footprint' && c.footprintLocked;+ const attrs = locked ? 'readonly class="nameinput locked" title="Standard KiCad footprint — name is fixed"' : `class="nameinput" title="Name this ${kind} will get in the library"`;+ return `<td class="namecell"><input ${attrs} data-name="${kind}" data-id="${c.id}" value="${esc(nm)}" spellcheck="false"><div class="destsub">${destBasename(d)}</div></td>`;+}+function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,m=>({'&':'&','<':'<','>':'>','"':'"'}[m])); }++const EDA_NAME={kicad:'KiCad',altium:'Altium',fusion:'Fusion'};+function edaBox(c,eda){+ const e=c.eda[eda]||{};+ const n=['symbol','footprint','model3d'].filter(k=>e[k]&&e[k].present).length;+ const rows=KINDS.map(([k,label])=>{+ const v=e[k]||{}; const on=v.present;+ const tag=on?`<span class="tag">${v.source==='upload'?'uploaded':'wiki'}</span>`:'';+ return `<div class="pk ${on?'on':''}"><span class="cb">${on?'✓':''}</span>${label}${tag}</div>`;+ }).join('');+ return `<div class="eda-box ${edaColor(c,eda)}" title="Hover for symbol / footprint / 3D status">${EDA_NAME[eda]} <span class="tally">${n}/3</span>`++ `<div class="eda-pop"><div class="pophdr">${EDA_NAME[eda]} files on wiki</div>${rows}</div></div>`;+}+function jlcBox(c){+ const on=!!c.jlc;+ const row = on+ ? `<div class="pk on"><span class="cb">✓</span>${esc(c.jlcPart||'available')}</div>`+ : `<div class="pk"><span class="cb"></span>Part number not on wiki</div>`;+ return `<div class="eda-box jlc ${on?'green':'red'}" data-jlcedit="${c.id}" style="cursor:pointer"><span style="pointer-events:none">JLC</span><div class="eda-pop"><div class="pophdr">JLCPCB part number — click to edit</div>${row}</div></div>`;+}+function statusCell(c){+ if(c.wiki.status==='checking') return `<div class="checking-box"><span class="spin"></span> checking wiki…</div>`;+ // always show all three EDA boxes + a JLC presence box; probe selection only affects the status text+ return `<div class="eda-boxes">${['kicad','altium','fusion'].map(e=>edaBox(c,e)).join('')}${jlcBox(c)}</div>`;+}++const EDA_LABEL={kicad:'KiCad',altium:'Altium',fusion:'Fusion'};+function updateLibStatus(){+ const el=$('#libStatus'); if(!el) return;+ const p=STATE.probe||{};+ const found=p.libraries?Object.keys(p.libraries):[];+ let cls,txt;+ if(p.probedAt){+ if(found.length){ cls='ok'; txt='libraries found: '+found.map(e=>EDA_LABEL[e]||e).join(', '); }+ else { cls='none'; txt='no libraries found'; }+ } else if(p.status==='requested'){ cls='warn'; txt='probing libraries…'; }+ else { cls='none'; txt='libraries not probed'; }+ el.className='lib-status '+cls;+ el.querySelector('.txt').textContent=txt;+}+function updateSendStatus(){+ const el=$('#sendStatus'); if(!el) return;+ const s=STATE.send||{};+ if(s.status==='running'){ el.style.display='inline-flex'; el.className='send-status running'; el.innerHTML=`<span class="spin"></span> Sending ${s.done||0}/${s.total||0} — ${esc(s.current||'')}`; }+ else if(s.status==='done'){ el.style.display='inline-flex'; el.className='send-status done'; el.textContent='✓ '+(s.current||'Sent to library'); }+ else if(s.status==='error'){ el.style.display='inline-flex'; el.className='send-status err'; el.textContent='Send error: '+(s.current||''); }+ else { el.style.display='none'; }+}+function updateStickyOffsets(){+ const h=$('.app-header'), c=$('.controls'); if(!h||!c) return;+ const hh=h.offsetHeight, ch=c.offsetHeight;+ document.documentElement.style.setProperty('--header-h', hh+'px');+ document.documentElement.style.setProperty('--stick-top', (hh+ch)+'px');+}+window.addEventListener('resize', updateStickyOffsets);+function render(){+ $('#count').textContent = STATE.components.length;+ updateLibStatus();+ updateSendStatus();+ updateStickyOffsets();+ const list = STATE.components.filter(c=>{+ if(FILTER==='all') return true;+ if(c.wiki.status==='checking') return false;+ return compStatus(c)===FILTER;+ });+ $('#empty').style.display = STATE.components.length? 'none':'block';+ // group by type+ const groups={};+ for(const c of list){ const t=c.type||'IC / Other'; (groups[t]=groups[t]||[]).push(c); }+ const tbody=$('#tbody'); tbody.innerHTML='';+ const types=Object.keys(groups).sort();+ for(const t of types){+ const cat=document.createElement('tr'); cat.className='cat-row';+ cat.innerHTML=`<td colspan="7"><span class="cat-name">${esc(t)}</span><span class="cat-count">${groups[t].length}</span></td>`;+ tbody.appendChild(cat);+ for(const c of groups[t]){+ const tr=document.createElement('tr'); tr.className='item'+(c.selected?' sel':''); tr.dataset.id=c.id;+ const d3 = c.dest&&c.dest.model3d;+ const has3d = c.wikiFiles && c.wikiFiles['3d'];+ tr.innerHTML =+ `<td class="sel-col"><input type="checkbox" ${c.selected?'checked':''} data-sel="${c.id}"></td>`++ `<td class="name">${esc(c.name)}${c.description?`<div class="cdesc" title="Symbol description">${esc(c.description)}</div>`:''}</td>`++ nameDestCell(c,'symbol')++ nameDestCell(c,'footprint')++ `<td class="dest ${d3&&d3.name?'':'empty'}">${d3&&d3.name?`<span class="basename" title="${esc(d3.path||d3.name)}">${esc(d3.name)}</span>${d3.exists===false?' <span class="newtag">new</span>':''}`:(has3d?'<span class="badge3d on">3D on wiki</span>':'—')}</td>`++ `<td>${statusCell(c)}</td>`++ `<td><div class="row-actions">`++ (c.wiki&&c.wiki.url+ ? `<button class="icon-btn" data-wiki="${esc(c.wiki.url)}" title="Open wiki page: ${esc(c.wiki.url)}">↗</button>`+ : `<button class="icon-btn" disabled title="No wiki page found">↗</button>`)++ `<button class="icon-btn" data-up="${c.id}" title="Upload a file">⬆</button>`++ `<button class="icon-btn" data-check="${c.id}" title="Re-check wiki">↻</button>`++ `<button class="icon-btn del" data-del="${c.id}" title="Remove">✕</button>`++ `</div></td>`;+ tbody.appendChild(tr);+ }+ }+}++// ---- events+$('#addBtn').onclick = addComponent;+$('#addInput').addEventListener('keydown', e=>{ if(e.key==='Enter') addComponent(); });+async function addComponent(){+ const inp=$('#addInput'); const name=inp.value.trim(); if(!name) return;+ inp.value='';+ try{ const c=await api('/components',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})});+ if(c._duplicate || STATE.components.some(x=>x.id===c.id)){ toast(`${c.name} is already in the list`); render(); return; }+ STATE.components.unshift(c); render(); toast(`Added ${name} — checking wiki…`);+ }catch(e){ toast('Add failed: '+e.message); }+}++$('#tbody').addEventListener('click', async e=>{+ const t=e.target;+ if(t.dataset.del){ if(!confirm('Remove this component and its staged files?'))return; await api(`/components/${t.dataset.del}`,{method:'DELETE'}); STATE.components=STATE.components.filter(x=>x.id!==t.dataset.del); render(); return; }+ if(t.dataset.check){ const id=t.dataset.check; const c=STATE.components.find(x=>x.id===id); if(c){c.wiki.status='checking';render();} const nc=await api(`/components/${id}/check`,{method:'POST'}); replaceComp(nc); render(); return; }+ if(t.dataset.up){ uploadTarget=t.dataset.up; $('#fileInput').value=''; $('#fileInput').click(); return; }+ if(t.dataset.wiki){ window.open(t.dataset.wiki,'_blank','noopener'); return; }+ const jb=t.closest('[data-jlcedit]');+ if(jb){ const id=jb.dataset.jlcedit; const c=STATE.components.find(x=>x.id===id); if(!c)return;+ const pn=prompt('JLCPCB / LCSC part number for '+c.name+':', c.jlcPart||'');+ if(pn===null) return;+ const nc=await api(`/components/${id}/jlc`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({jlcPart:pn.trim()})});+ replaceComp(nc); render(); toast(pn.trim()?('JLC PN set: '+pn.trim()):'JLC PN cleared'); return; }+ if(t.dataset.sel!==undefined){ return setSelected(t.dataset.sel, t.checked); } // checkbox+ // click anywhere else on the row toggles selection (but not on inputs/buttons/eda boxes)+ if(t.closest('input,button,.eda-box,.nameinput,.icon-btn,a')) return;+ const tr=t.closest('tr.item'); if(!tr) return;+ const c=STATE.components.find(x=>x.id===tr.dataset.id); if(!c) return;+ setSelected(tr.dataset.id, !c.selected);+});+async function setSelected(id, on){+ const c=STATE.components.find(x=>x.id===id); if(c) c.selected=!!on; render();+ try{ await api(`/components/${id}/select`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({on:!!on})}); }catch(e){}+}++// editable symbol/footprint library names+$('#tbody').addEventListener('change', async e=>{+ const t=e.target; if(t.dataset.name===undefined) return;+ const id=t.dataset.id, kind=t.dataset.name, val=t.value.trim();+ const c=STATE.components.find(x=>x.id===id); if(c){ c.names=c.names||{}; c.names[kind]=val; }+ try{ await api(`/components/${id}/names`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({[kind]:val})}); toast(`${kind[0].toUpperCase()+kind.slice(1)} name → ${val}`); }+ catch(err){ toast('Rename failed: '+err.message); }+});+$('#tbody').addEventListener('keydown', e=>{ if(e.target.classList.contains('nameinput') && e.key==='Enter') e.target.blur(); });++$('#fileInput').addEventListener('change', async e=>{+ const f=e.target.files[0]; if(!f||!uploadTarget) return;+ const id=uploadTarget; const buf=await f.arrayBuffer();+ const q=`?filename=${encodeURIComponent(f.name)}`;+ try{+ let res=await api(`/components/${id}/upload${q}`,{method:'POST',body:buf});+ if(res._conflict){+ if(!confirm(`A ${res.existing.eda} ${res.existing.kind} file (${res.existing.name}) is already staged. Replace it with ${f.name}?`)) return;+ res=await api(`/components/${id}/upload${q}&replace=1`,{method:'POST',body:buf});+ }+ replaceComp(res); render(); toast(`Uploaded ${f.name}`);+ }catch(err){ toast('Upload failed: '+err.message); }+});++$('#pills').addEventListener('click', e=>{ if(!e.target.dataset.f)return; FILTER=e.target.dataset.f; [...$('#pills').children].forEach(p=>p.classList.toggle('active',p.dataset.f===FILTER)); render(); });++let allOn=false;+$('#selectAll').onclick=async()=>{ allOn=!allOn; await api('/select-all',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({on:allOn})}); STATE.components.forEach(c=>c.selected=allOn); $('#selectAll').textContent=(allOn?'✕ Deselect all':'✓ Select all'); render(); };++$('#sendBtn').onclick=async()=>{+ const ids=STATE.components.filter(c=>c.selected).map(c=>c.id);+ if(!ids.length){ toast('Select components first'); return; }+ const edas=[...$('#sendEdas').querySelectorAll('input:checked')].map(i=>i.value);+ if(!edas.length){ toast('Pick at least one EDA target'); return; }+ try{+ await api('/send-to-library',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids,edas})});+ toast(`Sending ${ids.length} component(s) to ${edas.join(', ')}…`); poll();+ }catch(e){ toast('Send failed: '+e.message); }+};++// ---- probe+function showProbe(){ $('#probeModal').classList.add('show'); }+$('#probeBtn').onclick=showProbe;+$('#probeSkip').onclick=()=>$('#probeModal').classList.remove('show');+$('#probeGo').onclick=async()=>{+ const edas=[...$('#probeModal').querySelectorAll('.eda-pick input:checked')].map(i=>i.value);+ if(!edas.length){ toast('Pick at least one EDA'); return; }+ await api('/probe-request',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({edas})});+ // sync the send-to targets to what they use+ $('#sendEdas').querySelectorAll('input').forEach(i=>i.checked=edas.includes(i.value));+ $('#probeModal').classList.remove('show');+ toast('Probe requested — the agent will scan your '+edas.join(', ')+' libraries.');+};++function replaceComp(nc){ const i=STATE.components.findIndex(x=>x.id===nc.id); if(i>=0)STATE.components[i]=nc; }++// ---- poll+async function poll(){+ try{ const s=await api('/state'); STATE=s;+ // don't rebuild the table out from under an in-progress edit+ if(!document.activeElement || !document.activeElement.classList.contains('nameinput')) render();+ }catch(e){}+}+async function init(){+ await poll();+ // offer the probe on first launch if not done yet+ if(!STATE.probe || (!STATE.probe.probedAt && STATE.probe.status!=='requested')) showProbe();+ setInterval(poll, 2500);+}+init();+</script>+</body>+</html>
screenshots/hero.pngadded⋯ 1 unchanged line ⋯
server.jsadded+864@@ -0,0 +1,864 @@+#!/usr/bin/env node+'use strict';+/*+ * chip-fetcher-lite — backend+ *+ * Pure-Node http server (no native deps). Serves the SPA and a small REST API.+ * Storage is a single JSON file + a staging dir for uploaded files.+ *+ * GET /api/state -> { components, probe, jobs }+ * POST /api/components {name} -> add a row (status "checking")+ * POST /api/components/:id/check -> run wiki lookup + file detection+ * POST /api/components/:id/select {on} -> toggle row highlight+ * POST /api/select-all {on} -> toggle every row+ * POST /api/components/:id/upload?kind=&filename= (raw body) -> stage a file+ * DELETE /api/components/:id+ * POST /api/probe-request {edas} -> queue a library-probe job for the agent+ * POST /api/probe {edas, libraries} -> agent posts probe results+ * POST /api/send-to-library {ids, edas}-> queue a send job for the agent+ * GET /api/jobs -> pending agent jobs+ * POST /api/jobs/:id/complete {result} -> agent marks a job done+ */++const http = require('http');+const fs = require('fs');+const os = require('os');+const path = require('path');+const crypto = require('crypto');+const { execFile } = require('child_process');++const ROOT = __dirname;+const DATA_DIR = process.env.CFL_DATA_DIR || path.join(ROOT, 'data');+const DATA_FILE = path.join(DATA_DIR, 'data.json');+const STAGING_DIR = path.join(DATA_DIR, 'staging');+const PUBLIC_DIR = path.join(ROOT, 'public');+const PORT = parseInt(process.env.PORT || process.env.CFL_PORT || '7821', 10);+const WIKI = process.env.CFL_WIKI || 'https://wiki.adom.inc';++fs.mkdirSync(STAGING_DIR, { recursive: true });++// ---------------------------------------------------------------- data store+function load() {+ try { return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); }+ catch { return { components: [], probe: { edas: [], libraries: {}, probedAt: null }, jobs: [] }; }+}+let DB = load();+if (!DB.send) DB.send = { status: 'idle', total: 0, done: 0, current: '', errors: [] };+let saveTimer = null;+function save() {+ clearTimeout(saveTimer);+ saveTimer = setTimeout(() => {+ fs.mkdirSync(DATA_DIR, { recursive: true });+ fs.writeFileSync(DATA_FILE, JSON.stringify(DB, null, 2));+ }, 50);+}+function uid() { return crypto.randomBytes(8).toString('hex'); }+function find(id) { return DB.components.find(c => c.id === id); }++// bounded wiki-check queue so mass-adds don't spawn hundreds of `adompkg` processes at once+const CHECK_CONCURRENCY = 4;+let _checkQueue = [], _checkActive = 0;+function enqueueCheck(id) { _checkQueue.push(id); pumpChecks(); }+function pumpChecks() {+ while (_checkActive < CHECK_CONCURRENCY && _checkQueue.length) {+ const c = find(_checkQueue.shift()); if (!c) continue;+ _checkActive++;+ runCheck(c).catch(e => { c.wiki.status = 'error'; c.type = 'IC / Other'; c.error = String(e && e.message || e); })+ .finally(() => { _checkActive--; save(); pumpChecks(); });+ }+}++// ---------------------------------------------------------------- wiki logic+// (ported from chip-fetcher-wiki-flow / cf-wiki-lookup.py)+function norm(mpn) {+ return mpn.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');+}+const SUFFIX = /(tr|t6|t7|tb|tg|ar|ag|-?reel|-?cut|-?tape)$/i;+function stem(slug) { return (slug.replace(SUFFIX, '').replace(/^-+|-+$/g, '')) || slug; }+const PASSIVE_HINT = /\b(0201|0402|0603|0805|1206|1210|resistor|capacitor|mlcc|inductor|ferrite|led)\b/i;+function sharedPrefix(a, b) { let n = 0; const L = Math.min(a.length, b.length); while (n < L && a[n] === b[n]) n++; return n; }++// `adompkg search` -> [{slug,type,title,desc}]+function adompkgSearch(q) {+ return new Promise((resolve) => {+ execFile('adompkg', ['search', q], { timeout: 45000, maxBuffer: 4 << 20, env: process.env },+ (err, stdout) => {+ if (err && !stdout) return resolve([]);+ const rows = []; let cur = null;+ const ENTRY = /^(\S+)\s{2,}([A-Za-z][\w-]*)\s{2,}(.*)$/;+ for (const line of (stdout || '').split('\n')) {+ const m = ENTRY.exec(line);+ if (m) { cur = { slug: m[1], type: m[2], title: m[3].trim(), desc: '' }; rows.push(cur); }+ else if (line.trim() && cur && /^\s/.test(line)) cur.desc = (cur.desc + ' ' + line.trim()).trim();+ }+ resolve(rows);+ });+ });+}++// the MPN is the head of a result title, e.g. "0402B104J160CT — 100nF MLCC Capacitor" -> "0402B104J160CT"+function titleMpn(r) {+ const t = (r.title || '').trim();+ return (t.split(/\s+[—–-]\s+/)[0].trim()) || t.split(/\s+/)[0] || r.slug;+}+// pull a value (100nF, 10k, 4.7uF) + package (0402…) out of a descriptive passive string+function passiveTokens(s) {+ const pkg = (s.match(/\b(0201|0402|0603|0805|1206|1210|1812|2010|2512)\b/) || [])[0] || null;+ const vm = s.match(/(\d+(?:\.\d+)?)\s*([pnuµμmkKMG]?)\s*(f|h|r|ω|ohm|farad|henry)?\b/i);+ let val = null;+ if (vm && /[a-zµμω]/i.test((vm[2] || '') + (vm[3] || ''))) // require a unit/prefix so we don't grab package digits+ val = (vm[1] + (vm[2] || '') + (vm[3] || '')).toLowerCase().replace(/[µμ]/g, 'u');+ return { pkg, val };+}+async function classify(mpn) {+ const slug = norm(mpn);+ let rows = await adompkgSearch(mpn);+ const st = stem(slug);+ if (st !== slug) {+ const seen = new Set(rows.map(r => r.slug));+ const more = await adompkgSearch(st);+ rows = rows.concat(more.filter(r => !seen.has(r.slug)));+ }+ const comps = rows.filter(r => r.type === 'component');+ const passive = !!(PASSIVE_HINT.test(mpn) || comps.some(r => PASSIVE_HINT.test(r.desc)));++ // 1. exact: slug, the MPN parsed from a result title, or a title equal to the input+ let exact = comps.find(r => r.slug === slug || norm(titleMpn(r)) === slug || r.title.toLowerCase() === mpn.toLowerCase()) || null;++ // 2. description -> MPN: the user may type "100nF 0402 capacitor"; resolve to the real MPN+ // page (the FTS already ranks it #1) by matching value + package against a passive result.+ if (!exact) {+ const tok = passiveTokens(mpn);+ const descriptive = (tok.pkg && tok.val) || /capacitor|resistor|inductor|\bled\b|ferrite|diode|mlcc/i.test(mpn);+ if (descriptive) {+ const hay = r => (r.title + ' ' + r.desc).toLowerCase().replace(/\s+/g, '');+ const isPassive = r => /capacitor|resistor|inductor|\bled\b|ferrite|diode|mlcc|oscillator|crystal/i.test(r.title + ' ' + r.desc);+ exact = comps.find(r => isPassive(r) && (!tok.val || hay(r).includes(tok.val)) && (!tok.pkg || hay(r).includes(tok.pkg))) || null;+ }+ }++ let variants = [];+ if (!exact) {+ const thresh = Math.max(6, Math.floor(slug.length * 0.6));+ for (const r of comps) {+ const sh = sharedPrefix(r.slug, slug);+ if (r.slug !== slug && (sh >= thresh || stem(r.slug) === st)) variants.push({ ...r, shared: sh });+ }+ variants.sort((a, b) => b.shared - a.shared);+ variants = variants.slice(0, 6);+ }+ const status = exact ? 'exact' : (variants.length ? 'variant' : 'none');+ return { slug, status, passive, exact, variants, resolvedMpn: exact ? titleMpn(exact) : null };+}++async function wikiJSON(p) {+ try {+ const res = await fetch(WIKI + p, { signal: AbortSignal.timeout(20000) });+ if (!res.ok) return null;+ return await res.json();+ } catch { return null; }+}++// Map a filename to an EDA bucket + kind+const EXT = [+ [/\.kicad_sym$/i, 'kicad', 'symbol'],+ [/\.kicad_mod$/i, 'kicad', 'footprint'],+ [/\.kicad_pcb$/i, 'kicad', 'footprint'],+ [/\.pretty$/i, 'kicad', 'footprint'],+ [/\.schlib$/i, 'altium', 'symbol'],+ [/\.pcblib$/i, 'altium', 'footprint'],+ [/\.intlib$/i, 'altium', 'library'],+ [/\.lbr$/i, 'fusion', 'library'],+ [/\.f3[dz]$/i, 'fusion', 'library'],+ [/\.(step|stp|glb|stl|wrl)$/i, '3d', 'model3d'],+];+function classifyFile(name) {+ for (const [re, eda, kind] of EXT) if (re.test(name)) return { eda, kind };+ return null;+}++// derive a grouping "type" for a component+function deriveType(meta, passive, mpn) {+ if (meta && meta.category && meta.category !== 'library' && meta.category !== 'other')+ return meta.category;+ const s = (mpn + ' ' + (meta && meta.package || '')).toLowerCase();+ if (/resistor/.test(s)) return 'Resistor';+ if (/capacitor|mlcc/.test(s)) return 'Capacitor';+ if (/inductor|ferrite/.test(s)) return 'Inductor';+ if (/\bled\b/.test(s)) return 'LED';+ if (/crystal|oscillator|mhz|khz/.test(s)) return 'Crystal / Oscillator';+ if (passive) return 'Passive';+ if (meta && meta.category) return meta.category === 'library' ? 'IC / Other' : meta.category;+ return 'IC / Other';+}++// ---- build the symbol's Description field (key specs per component type)+const POWER_BY_SIZE = { '0201': '1/20W', '0402': '1/16W', '0603': '1/10W', '0805': '1/8W', '1206': '1/4W', '1210': '1/2W', '2010': '3/4W', '2512': '1W' };+function sizeOf(c) { return c.package || (`${c.name || ''} ${c.mpn || ''}`.match(/\b(0201|0402|0603|0805|1206|1210|1812|2010|2512)\b/) || [])[1] || null; }+function pick(re, txt) { const m = (txt || '').match(re); return m ? (m[1] || m[0]).replace(/\s+/g, '') : null; }+function buildSymbolDescription(c, brief) {+ const t = `${c.type || ''} ${c.subcategory || ''}`;+ const txt = [brief, c.name, c.mpn].filter(Boolean).join(' ');+ const size = sizeOf(c);+ const tol = pick(/[±+]\s*\d+(?:\.\d+)?\s*%/, txt);+ if (/capacitor|mlcc/i.test(t)) {+ const val = pick(/\d+(?:\.\d+)?\s*[pnuµμm]?F\b/i, txt);+ const volt = pick(/\d+(?:\.\d+)?\s*V\b/i, txt);+ const diel = pick(/\b(?:C0G|NP0|X7R|X5R|X7S|X6S|Y5V|Z5U)\b/i, txt);+ return [val, size, volt, diel, tol].filter(Boolean).join(', ');+ }+ if (/resistor/i.test(t)) {+ const val = pick(/\d+(?:\.\d+)?\s*[kKMRGΩ]\d*(?:\s*(?:ohm|Ω))?\b/i, txt) || pick(/\b\d+(?:\.\d+)?[kKMR]\b/, `${c.name} ${c.mpn}`);+ const pwr = pick(/\d+\/\d+\s*W\b|\d+(?:\.\d+)?\s*W\b/i, txt) || (size && POWER_BY_SIZE[size]) || null;+ return [val, size, pwr, tol].filter(Boolean).join(', ');+ }+ if (/inductor|ferrite|choke/i.test(t)) {+ const val = pick(/\d+(?:\.\d+)?\s*[pnuµμm]?H\b/i, txt);+ const cur = pick(/\d+(?:\.\d+)?\s*m?A\b/i, txt);+ return [val, size, cur].filter(Boolean).join(', ') || ['Inductor', size].filter(Boolean).join(', ');+ }+ if (/\bled\b/i.test(t)) {+ const color = pick(/\b(?:red|green|blue|white|yellow|amber|orange|RGB)\b/i, txt);+ const volt = pick(/\d+(?:\.\d+)?\s*V\b/i, txt);+ return [color, size, volt].filter(Boolean).join(', ') || ['LED', size].filter(Boolean).join(', ');+ }+ if (/diode|rectifier|zener|schottky/i.test(t)) {+ const volt = pick(/\d+(?:\.\d+)?\s*V\b/i, txt);+ const cur = pick(/\d+(?:\.\d+)?\s*m?A\b/i, txt);+ return ['Diode', size, volt, cur].filter(Boolean).join(', ');+ }+ // non-passives: comma-separated key specs+ const pkgShort = (c.package || '').split(/[ ,(]/)[0] || c.package;+ const parts = [c.manufacturer, pkgShort, c.pinCount ? `${c.pinCount}-pin` : null, c.subcategory].filter(Boolean);+ if (parts.length >= 2) return parts.join(', ');+ if (brief) return brief.split(/\s+[—–-]\s+/)[0].trim().slice(0, 120);+ return parts.join(', ') || c.type;+}++// ---- per-EDA, per-file-kind presence model -----------------------------------+// Each component tracks, for KiCad/Altium/Fusion independently, whether a+// symbol / footprint / 3D model is available (and whether it came from the wiki+// or a user upload). Each EDA box colours on its own: red=0, yellow=1-2, green=3.+const EDAS = ['kicad', 'altium', 'fusion'];+const KINDS = ['symbol', 'footprint', 'model3d'];+function blankKind() { return { present: false, source: null }; }+function blankEda() { return { symbol: blankKind(), footprint: blankKind(), model3d: blankKind() }; }+function freshEda() { return { kicad: blankEda(), altium: blankEda(), fusion: blankEda() }; }++// apply one classified file to the eda model (uploads override wiki)+function applyFile(c, eda, kind, source) {+ const set = (e, k) => { const cur = c.eda[e][k]; if (cur.source !== 'upload' || source === 'upload') c.eda[e][k] = { present: true, source }; };+ if (kind === 'library') { set(eda, 'symbol'); set(eda, 'footprint'); } // .IntLib / .lbr = symbol + footprint+ else if (kind === 'model3d') { for (const e of EDAS) set(e, 'model3d'); } // a STEP/GLB serves any EDA+ else if (EDAS.includes(eda)) set(eda, kind);+}++// rebuild c.eda from the last wiki check (c._wiki) + current uploads+function recomputeEda(c) {+ c.eda = freshEda();+ const w = c._wiki || { files: [], has3d: false };+ for (const name of w.files) { const hit = classifyFile(name); if (hit) applyFile(c, hit.eda, hit.kind, 'wiki'); }+ if (w.has3d) for (const e of EDAS) if (!c.eda[e].model3d.present) c.eda[e].model3d = { present: true, source: 'wiki' };+ for (const u of (c.uploads || [])) { const hit = classifyFile(u.name); if (hit) applyFile(c, hit.eda, hit.kind, 'upload'); }+ c.wikiFiles = { '3d': EDAS.some(e => c.eda[e].model3d.present && c.eda[e].model3d.source === 'wiki') };+}++// Run the full wiki check for one component and merge results into its record.+async function runCheck(c) {+ const cls = await classify(c.mpn);+ // a descriptive request ("100nF 0402 capacitor") resolves to a real MPN page — adopt the MPN+ if (cls.resolvedMpn && cls.resolvedMpn !== c.mpn) {+ // if that MPN is already in the list, this entry is a duplicate — drop it+ const other = DB.components.find(x => x.id !== c.id && (x.slug === norm(cls.resolvedMpn) || norm(x.mpn) === norm(cls.resolvedMpn)));+ if (other) { DB.components = DB.components.filter(x => x.id !== c.id); return c; }+ const old = c.mpn;+ c.mpn = cls.resolvedMpn; c.name = cls.resolvedMpn;+ if (c.names) {+ if (c.names.symbol === old) c.names.symbol = cls.resolvedMpn;+ if (c.names.footprint === old && !c.footprintLocked) c.names.footprint = cls.resolvedMpn;+ }+ }+ c.slug = cls.exact ? cls.exact.slug : norm(c.mpn);+ c.passive = cls.passive;+ c.wiki = { status: cls.status, slug: null, variants: cls.variants || [], checkedAt: new Date().toISOString() };++ let meta = null, brief = '';+ c._wiki = { files: [], has3d: false };+ const resolvedSlug = cls.exact ? cls.exact.slug : null;+ if (resolvedSlug) {+ c.wiki.slug = resolvedSlug;+ const pageMeta = await wikiJSON(`/api/v1/pages/${resolvedSlug}`);+ meta = pageMeta && pageMeta.metadata;+ brief = (pageMeta && pageMeta.page && pageMeta.page.brief) || '';+ c.wiki.owner = (pageMeta && pageMeta.page && pageMeta.page.owner) || null;+ c.wiki.url = c.wiki.owner ? `${WIKI}/${c.wiki.owner}/${resolvedSlug}` : `${WIKI}/${resolvedSlug}`;+ const filesResp = await wikiJSON(`/api/v1/pages/${resolvedSlug}/files`);+ const files = (filesResp && filesResp.files) || [];+ c._wiki.files = files.map(f => f.name);+ if (meta && meta.has_3d_model) c._wiki.has3d = true;+ }+ c.type = deriveType(meta, c.passive, c.mpn);+ c.manufacturer = (meta && meta.manufacturer) || c.manufacturer || null;+ c.package = (meta && meta.package) || c.package || null;+ c.subcategory = (meta && meta.subcategory) || null;+ c.pinCount = (meta && meta.pin_count) || null;+ c.jlcPart = (meta && meta.jlcpcb_part) || c.jlcPart || null; // keep a manually-set PN through re-checks+ c.jlc = !!c.jlcPart;+ const dsPath = meta && meta.datasheet_path;+ c.datasheetUrl = dsPath ? `${WIKI}/blob/component/${resolvedSlug}/${dsPath}` : (c.datasheetUrl || null);+ c.description = buildSymbolDescription(c, brief); // goes in the symbol's Description field+ recomputeEda(c);+ // once we know the manufacturer, refine the destination (esp. the 3D manufacturer folder)+ if (DB.probe && DB.probe.libraries && Object.keys(DB.probe.libraries).length) applyDest(c);+ return c;+}++// ---------------------------------------------------------------- library probe+// The user's libraries live on THEIR machine, reached via the adom-desktop bridge.+// The server shells to `adom-desktop` to read the user's KiCad/Altium/Fusion+// library locations, then fills each component's symbol/footprint/3D destination.+const HOME = os.homedir();+const EDA_EXTS = { kicad: /\.(kicad_sym|kicad_mod|pretty)$/i, altium: /\.(schlib|pcblib|intlib)$/i, fusion: /\.lbr$/i };++function nameDest(p) { if (!p) return null; const name = p.split(/[\\/]/).filter(Boolean).pop() || p; return { name, path: p }; }+const dirOf = p => (p || '').replace(/[\\/][^\\/]*$/, '');++// Run a discovery shell command on the user's machine via adom-desktop.+function runDesktopShell(command, timeoutSeconds = 90) {+ return new Promise((resolve) => {+ execFile('adom-desktop', ['shell_execute', JSON.stringify({ command, timeoutSeconds })],+ { timeout: (timeoutSeconds + 15) * 1000, maxBuffer: 32 << 20, env: process.env }, (err, stdout) => {+ let out = '';+ try { out = JSON.parse(stdout || '{}').stdout || ''; } catch { out = stdout || ''; }+ resolve(out);+ });+ });+}++// Probe the USER's machine (via adom-desktop) for their own EDA libraries.+// KiCad: enumerate the actual .kicad_sym FILES, .pretty FOLDERS, and the 3D+// manufacturer SUBFOLDERS — NOT the KiCad stock/default libraries.+async function probeUserLibraries(edas) {+ const sel = (edas && edas.length) ? edas : ['kicad', 'altium', 'fusion'];+ const cmd = [+ 'D="$HOME/Documents"',+ 'find "$D" -iname "*.kicad_sym" 2>/dev/null | grep -vi backup | head -200 | sed "s|^|KSYM=|"',+ 'find "$D" -type d -iname "*.pretty" 2>/dev/null | grep -vi backup | head -200 | sed "s|^|KFP=|"',+ 'for d in "$D/KiCAD/3D" "$D/KiCad/3D" "$D/KiCAD/3d" "$D/KiCad/3d"; do [ -d "$d" ] && echo "K3D=$d"; done',+ 'for d in "$D/KiCAD/3D" "$D/KiCad/3D"; do [ -d "$d" ] && find "$d" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | head -300 | sed "s|^|K3DSUB=|"; done',+ 'find "$D" -iname "*.lbr" 2>/dev/null | grep -vi backup | head -80 | sed "s|^|FUS=|"',+ 'find "$D" \\( -iname "*.SchLib" -o -iname "*.PcbLib" -o -iname "*.IntLib" \\) 2>/dev/null | head -80 | sed "s|^|ALT=|"',+ // KiCad STOCK/default footprint libraries (passives & basics) — OS-specific share paths+ 'for s in /usr/share/kicad /usr/local/share/kicad /snap/kicad/current/usr/share/kicad "/var/lib/flatpak/app/org.kicad.KiCad/current/active/files/share/kicad" "$HOME/.local/share/flatpak/app/org.kicad.KiCad/current/active/files/share/kicad" "/Applications/KiCad/KiCad.app/Contents/SharedSupport"; do for lib in Capacitor_SMD Resistor_SMD Inductor_SMD LED_SMD Capacitor_THT Resistor_THT; do dd="$s/footprints/$lib.pretty"; if [ -d "$dd" ]; then echo "KDEFLIB=$lib|$dd"; ls "$dd" 2>/dev/null | grep -i "\\.kicad_mod$" | head -500 | sed "s|^|KDEFFP=$lib/|"; fi; done; done',+ ].join('; ');+ const out = await runDesktopShell(cmd);+ const lines = out.split('\n').map(l => l.trim());+ const grab = pfx => lines.filter(l => l.startsWith(pfx)).map(l => l.slice(pfx.length)).filter(Boolean);+ const base = p => (p || '').split('/').pop();+ const ksym = grab('KSYM='), kfp = grab('KFP='), k3d = grab('K3D='), k3dsub = grab('K3DSUB='), fus = grab('FUS='), alt = grab('ALT=');+ const kdeflib = grab('KDEFLIB='), kdeffp = grab('KDEFFP=');+ const defaultFootprints = {};+ for (const e of kdeflib) { const i = e.indexOf('|'); if (i < 0) continue; const lib = e.slice(0, i), pth = e.slice(i + 1); if (lib && pth && !defaultFootprints[lib]) defaultFootprints[lib] = { path: pth, footprints: [] }; }+ for (const e of kdeffp) { const i = e.indexOf('/'); if (i < 0) continue; const lib = e.slice(0, i), fp = e.slice(i + 1).replace(/\.kicad_mod$/i, ''); if (defaultFootprints[lib]) defaultFootprints[lib].footprints.push(fp); }++ const libraries = {};+ if (sel.includes('kicad') && (ksym.length || kfp.length || Object.keys(defaultFootprints).length)) {+ libraries.kicad = {+ symbolsDir: dirOf(ksym[0] || ''),+ footprintsDir: kfp.length ? dirOf(kfp[0]) : '',+ models3dDir: k3d[0] || '',+ symbolFiles: ksym, // full paths to each .kicad_sym+ footprintDirs: kfp, // full paths to each .pretty+ mfrFolders: k3dsub.map(p => ({ name: base(p), path: p })), // 3D manufacturer subfolders+ defaultFootprints, // KiCad stock footprint libs (Capacitor_SMD, etc.)+ };+ }+ if (sel.includes('fusion') && fus.length) libraries.fusion = { root: dirOf(fus[0]), files: fus.map(base) };+ if (sel.includes('altium') && alt.length) libraries.altium = { root: dirOf(alt[0]), files: alt.map(base) };+ return { libraries };+}++// ---- destination picking: JLC-category symbol lib, manufacturer footprint lib, manufacturer 3D folder+const baseNoExt = p => (p || '').split('/').pop().replace(/\.(kicad_sym|pretty)$/i, '');+const nrm = s => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');+// manufacturer name -> tokens/abbreviations that may appear in folder/library names+const MFR_ALIASES = {+ 'texas instruments': ['ti'], 'stmicroelectronics': ['st', 'stm'], 'nordic semiconductor': ['nordic', 'nrf'],+ 'analog devices': ['adi', 'analog', 'maxim'], 'on semiconductor': ['onsemi', 'on'], 'onsemi': ['on'],+ 'nxp semiconductors': ['nxp'], 'microchip technology': ['microchip', 'atmel'], 'würth elektronik': ['wurth', 'würth'],+ 'wurth elektronik': ['wurth'], 'vishay': ['vishay'], 'murata': ['murata'], 'tdk': ['tdk'], 'bosch': ['bosch'], 'espressif': ['espressif', 'esp'],+};+const MFR_STOP = new Set(['technology', 'technologies', 'corporation', 'corp', 'semiconductor', 'semiconductors', 'electronics', 'electronic', 'inc', 'ltd', 'co', 'company', 'gmbh', 'components', 'component', 'international', 'industries', 'microelectronics', 'group', 'limited', 'systems']);+function mfrTokens(mfr) {+ if (!mfr) return [];+ const lower = mfr.toLowerCase();+ const toks = [nrm(mfr)];+ // expand aliases only when the FULL manufacturer name matches the alias key (no substring contamination)+ for (const [k, v] of Object.entries(MFR_ALIASES)) if (lower.includes(k)) toks.push(...v.map(nrm));+ toks.push(...lower.split(/[^a-z0-9]+/).filter(w => w.length >= 3 && !MFR_STOP.has(w)).map(nrm));+ return [...new Set(toks.filter(Boolean))];+}+// map a component to its JLC category-library name (their convention: JLC_<Cat>_BLT)+const JLC_CATEGORY = [+ [/capacitor|\bmlcc\b/i, 'Capacitors'], [/resistor/i, 'Resistors'], [/inductor|ferrite|\bchoke\b/i, 'Inductors'],+ [/\bled\b/i, 'LEDs'], [/diode|rectifier|zener|\btvs\b|schottky/i, 'Diodes'], [/crystal|oscillator|resonator/i, 'Oscillators'],+ [/\bfet\b|mosfet|transistor|\bbjt\b/i, 'FETs'], [/connector|header|socket|receptacle/i, 'Connectors'],+ [/\bflash\b|eeprom|\bsram\b|\bdram\b|\bram\b|memory/i, 'Flash'], [/regulator|\bldo\b|dc-dc|buck|boost|\bpmic\b|power management/i, 'Power'],+ [/microcontroller|\bmcu\b|\bsoc\b|processor|stm32|nrf\d|esp32|esp8266|atmega|attiny|rp2040|rp2350|\bpic\d|samd\d|msp430/i, 'MCUs'],+];+function jlcCategory(c) {+ const s = `${c.type || ''} ${c.subcategory || ''} ${c.mpn || ''}`;+ for (const [re, cat] of JLC_CATEGORY) if (re.test(s)) return cat;+ return 'Misc';+}+function matchByMfr(items, getName, mfr) { // items -> first whose name matches a manufacturer token+ const toks = mfrTokens(mfr);+ if (!toks.length) return null;+ for (const it of items) {+ const raw = getName(it) || '';+ const segs = raw.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).map(nrm); // e.g. "TI_BLT" -> ["ti","blt"]+ const full = nrm(raw);+ for (const t of toks) {+ if (segs.includes(t)) return it; // exact segment (handles TI_BLT -> "ti")+ if (t.length >= 4 && full.length >= 4 && (full.includes(t) || t.includes(full))) return it; // fuzzy: both sides long+ }+ }+ return null;+}+// imperial -> metric size code; passive type -> (symbol prefix, KiCad stock lib)+const IMP_TO_METRIC = { '0201': '0603', '0402': '1005', '0603': '1608', '0805': '2012', '1206': '3216', '1210': '3225', '1812': '4532', '2010': '5025', '2512': '6332' };+const PASSIVE_FP = [[/capacitor|\bmlcc\b/i, 'C', 'Capacitor_SMD'], [/resistor/i, 'R', 'Resistor_SMD'], [/inductor|ferrite/i, 'L', 'Inductor_SMD'], [/\bled\b/i, 'LED', 'LED_SMD']];+// If this is a passive with a known size, return its KiCad STOCK footprint (non-editable).+function defaultFootprint(c, defLibs) {+ if (!defLibs) return null;+ const s = `${c.type || ''} ${c.subcategory || ''} ${c.mpn || ''} ${c.name || ''}`;+ const hit = PASSIVE_FP.find(([re]) => re.test(s)); if (!hit) return null;+ const [, prefix, libName] = hit;+ const sizem = `${c.name || ''} ${c.mpn || ''} ${c.package || ''}`.match(/\b(0201|0402|0603|0805|1206|1210|1812|2010|2512)\b/);+ if (!sizem) return null;+ const imp = sizem[1], met = IMP_TO_METRIC[imp]; if (!met) return null;+ const lib = defLibs[libName]; if (!lib) return null;+ const want = `${prefix}_${imp}_${met}Metric`;+ const fpName = (lib.footprints || []).includes(want) ? want : (lib.footprints || []).find(n => n.startsWith(want));+ if (!fpName) return null;+ return { name: `${libName}.pretty`, path: lib.path, source: 'kicad-default', exists: true, fpName };+}+function computeDest(c) {+ const lib = DB.probe && DB.probe.libraries;+ const k = lib && lib.kicad;+ if (k) {+ // SYMBOL -> JLC category library (.kicad_sym); create if missing+ const cat = jlcCategory(c);+ const jlcBase = `JLC_${cat}_BLT`;+ const symHit = (k.symbolFiles || []).find(p => baseNoExt(p).toLowerCase() === jlcBase.toLowerCase())+ || (k.symbolFiles || []).find(p => /jlc/i.test(p) && nrm(p).includes(nrm(cat).replace(/s$/, '')));+ const symbol = symHit ? { ...nameDest(symHit), exists: true }+ : { name: `${jlcBase}.kicad_sym`, path: `${k.symbolsDir}/${jlcBase}.kicad_sym`, exists: false };++ // FOOTPRINT -> prefer a footprint that's ON THE WIKI (file into the manufacturer library);+ // only fall back to the KiCad STOCK library for passives that are NOT on the wiki.+ const mfr = c.manufacturer || 'Misc';+ const hasWikiFp = c.eda && c.eda.kicad && c.eda.kicad.footprint && c.eda.kicad.footprint.present && c.eda.kicad.footprint.source === 'wiki';+ let footprint = hasWikiFp ? null : defaultFootprint(c, k.defaultFootprints);+ if (!footprint) {+ const fpHit = matchByMfr(k.footprintDirs || [], baseNoExt, c.manufacturer);+ footprint = fpHit ? { ...nameDest(fpHit), exists: true, source: 'manufacturer' }+ : { name: `${mfr}.pretty`, path: `${k.footprintsDir}/${mfr}.pretty`, exists: false, source: 'manufacturer' };+ }++ // 3D -> manufacturer subfolder (deepest); create if missing+ let model3d = null;+ if (k.models3dDir) {+ const mfHit = matchByMfr(k.mfrFolders || [], it => it.name, c.manufacturer);+ model3d = mfHit ? { ...nameDest(mfHit.path), exists: true }+ : { name: mfr, path: `${k.models3dDir}/${mfr}`, exists: false };+ }+ return { symbol, footprint, model3d };+ }+ const r = (lib && lib.fusion && lib.fusion.root) || (lib && lib.altium && lib.altium.root);+ return r ? { symbol: { ...nameDest(r), exists: true }, footprint: { ...nameDest(r), exists: true }, model3d: { ...nameDest(r), exists: true } } : { symbol: null, footprint: null, model3d: null };+}+// compute + apply the destination, locking the footprint name for KiCad stock footprints+function applyDest(c) {+ const wasLocked = c.footprintLocked;+ const d = computeDest(c);+ c.dest = d;+ c.names = c.names || {};+ if (d.footprint && d.footprint.source === 'kicad-default' && d.footprint.fpName) {+ c.names.footprint = d.footprint.fpName; c.footprintLocked = true; // stock footprint -> fixed name+ } else {+ c.footprintLocked = false;+ if (wasLocked) c.names.footprint = c.mpn; // came off a stock lock -> default to MPN+ }+ return c;+}++// ---- create missing KiCad libraries on the user's machine (via adom-desktop)+const shq = s => "'" + String(s).replace(/'/g, "'\\''") + "'";+function regSnippet(table, name, uri) { // append a lib entry to a KiCad lib-table (idempotent, safe)+ const entry = ` (lib (name "${name}")(type "KiCad")(uri "${uri}")(options "")(descr ""))`;+ return `T="${table}"; [ -f "$T" ] && ! grep -qF '(name "${name}")' "$T" && { tmp=$(mktemp); head -n -1 "$T" > "$tmp"; printf '%s\\n)\\n' '${entry}' >> "$tmp"; mv "$tmp" "$T"; echo 'REG=${name}'; }`;+}+async function createMissingLibraries(components) {+ const symLibs = new Map(), fpLibs = new Map(), dirs = new Set();+ for (const c of components) {+ const d = c.dest || {};+ if (d.symbol && d.symbol.exists === false) symLibs.set(d.symbol.path, baseNoExt(d.symbol.path));+ if (d.footprint && d.footprint.exists === false) { fpLibs.set(d.footprint.path, baseNoExt(d.footprint.path)); dirs.add(d.footprint.path); }+ if (d.model3d && d.model3d.exists === false) dirs.add(d.model3d.path);+ }+ if (!symLibs.size && !fpLibs.size && !dirs.size) return { created: [] };+ const sh = ['CFG=$(ls -d "$HOME/.config/kicad"/*/ 2>/dev/null | head -1)'];+ for (const dir of dirs) sh.push(`mkdir -p ${shq(dir)} && echo "DIR=${dir}"`);+ for (const [pth, name] of symLibs) {+ sh.push(`[ -f ${shq(pth)} ] || { printf '(kicad_symbol_lib (version 20211014) (generator chip-fetcher-lite)\\n)\\n' > ${shq(pth)}; echo "SYM=${pth}"; }`);+ sh.push(regSnippet('${CFG}sym-lib-table', name, pth));+ }+ for (const [pth, name] of fpLibs) sh.push(regSnippet('${CFG}fp-lib-table', name, pth));+ const out = await runDesktopShell(sh.join('\n'), 90);+ return { created: out.split('\n').map(l => l.trim()).filter(l => /^(DIR|SYM|REG)=/.test(l)), raw: out };+}++// ---------------------------------------------------------------- file the actual CAD into KiCad+function adomDesktopJSON(verb, argsObj, timeout = 180000) {+ return new Promise((resolve) => {+ execFile('adom-desktop', [verb, JSON.stringify(argsObj)], { timeout, maxBuffer: 64 << 20, env: process.env }, (err, stdout) => {+ let j = null; try { j = JSON.parse(stdout || '{}'); } catch {}+ resolve({ err, j, stdout: stdout || '' });+ });+ });+}+async function wikiBlob(slug, filename) {+ try {+ const r = await fetch(`${WIKI}/blob/component/${slug}/${encodeURIComponent(filename)}`, { signal: AbortSignal.timeout(45000) });+ if (!r.ok) return null;+ return Buffer.from(await r.arrayBuffer());+ } catch { return null; }+}+const escapeRe = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');+// get a component's symbol/footprint/3D source bytes (uploaded file wins, else wiki download)+async function getFileContent(c, kind) {+ const up = (c.uploads || []).find(u => u.kind === kind || (kind !== 'model3d' && u.kind === 'library'));+ if (up) { try { return { buf: fs.readFileSync(up.path), srcName: up.name }; } catch {} }+ const slug = c.wiki && c.wiki.slug; if (!slug) return null;+ const exts = kind === 'symbol' ? ['.kicad_sym'] : kind === 'footprint' ? ['.kicad_mod'] : ['.step', '.stp', '.wrl', '.glb'];+ for (const e of exts) { // honour ext priority (e.g. STEP before GLB for KiCad)+ const fname = (c._wiki && c._wiki.files || []).find(f => f.toLowerCase().endsWith(e));+ if (fname) { const buf = await wikiBlob(slug, fname); if (buf) return { buf, srcName: fname }; }+ }+ return null;+}+// does this component have ANY CAD to file (uploads or wiki files)? stubs (README only) don't.+function hasAnyCad(c) {+ if ((c.uploads || []).length) return true;+ const files = (c._wiki && c._wiki.files) || [];+ return files.some(f => /\.(kicad_sym|kicad_mod|step|stp|wrl|glb|lbr)$/i.test(f));+}+// send_files ignores `dest` on Linux (lands in ~/Downloads), so land then move into place+async function sendFiles(filePaths, destDir) {+ const r = await adomDesktopJSON('send_files', { filePaths });+ const landed = (r.j && r.j.destinationPaths) || [];+ if (!landed.length) return { j: { success: false, error: (r.j && r.j.error) || 'send_files returned no paths' } };+ const mvs = landed.map(p => `mv -f ${shq(p)} ${shq(destDir)}/`).join('; ');+ const out = await runDesktopShell(`mkdir -p ${shq(destDir)}; ${mvs}; echo MOVED`, 60);+ return { j: { success: /MOVED/.test(out), landed, destDir } };+}++// --- KiCad .kicad_sym s-expression helpers (extract one symbol, rename, set props, merge)+function extractSymbolBlock(libText) {+ const i = libText.indexOf('(symbol "'); if (i < 0) return null;+ let depth = 0, j = i;+ for (; j < libText.length; j++) { const ch = libText[j]; if (ch === '(') depth++; else if (ch === ')') { depth--; if (depth === 0) { j++; break; } } }+ return libText.slice(i, j);+}+function symbolNameOf(block) { const m = block.match(/\(symbol\s+"([^"]+)"/); return m ? m[1] : null; }+function renameSymbol(block, oldN, newN) {+ return block+ .replace(new RegExp('\\(symbol\\s+"' + escapeRe(oldN) + '"'), `(symbol "${newN}"`)+ .replace(new RegExp('\\(symbol\\s+"' + escapeRe(oldN) + '_', 'g'), `(symbol "${newN}_`);+}+function setSymProp(block, name, value) {+ const v = String(value).replace(/"/g, '\\"');+ const re = new RegExp('\\(property\\s+"' + name + '"\\s+"(?:[^"\\\\]|\\\\.)*"');+ if (re.test(block)) return block.replace(re, `(property "${name}" "${v}"`);+ return block.replace(/(\(symbol\s+"[^"]+"\s*)/, `$1\n (property "${name}" "${v}" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))`);+}+// keep only standard symbol properties; the specs go in Description, not separate fields+function stripExtraProps(block) {+ const keep = new Set(['Reference', 'Value', 'Footprint', 'Datasheet', 'Description']);+ let out = '', i = 0;+ while (i < block.length) {+ if (block.startsWith('(property "', i)) {+ const nm = (block.slice(i).match(/^\(property\s+"([^"]+)"/) || [])[1] || '';+ let depth = 0, j = i;+ for (; j < block.length; j++) { const ch = block[j]; if (ch === '(') depth++; else if (ch === ')') { depth--; if (depth === 0) { j++; break; } } }+ if (keep.has(nm)) out += block.slice(i, j);+ else { while (j < block.length && /[ \t\r\n]/.test(block[j])) j++; } // drop block + trailing ws+ i = j;+ } else { out += block[i]; i++; }+ }+ return out;+}+// insert the symbol; if one with this name exists, REPLACE it (so re-sending updates it)+function mergeSymbol(destText, block, newN) {+ const startRe = new RegExp('\\(symbol\\s+"' + escapeRe(newN) + '"');+ const m = startRe.exec(destText);+ if (m) {+ let depth = 0, j = m.index;+ for (; j < destText.length; j++) { const ch = destText[j]; if (ch === '(') depth++; else if (ch === ')') { depth--; if (depth === 0) { j++; break; } } }+ destText = destText.slice(0, m.index) + destText.slice(j);+ }+ const last = destText.lastIndexOf(')');+ if (last < 0) return { text: destText, skipped: false, error: 'bad lib' };+ const body = ' ' + block.replace(/\n/g, '\n ') + '\n';+ return { text: destText.slice(0, last) + body + destText.slice(last), skipped: false };+}++async function fileFootprint(c, tmpdir) {+ const d = c.dest && c.dest.footprint; if (!d || !d.path) return 'no dest';+ if (d.source === 'kicad-default') return 'stock (skipped)';+ const got = await getFileContent(c, 'footprint'); if (!got) return 'no source file';+ const name = ((c.names && c.names.footprint) || c.mpn) + '.kicad_mod';+ const tmp = path.join(tmpdir, name); fs.writeFileSync(tmp, got.buf);+ const r = await sendFiles([tmp], d.path);+ return (r.j && r.j.success === false) ? ('send failed: ' + (r.j.error || '')) : 'ok';+}+async function file3D(c, tmpdir) {+ const d = c.dest && c.dest.model3d; if (!d || !d.path) return 'no dest';+ const got = await getFileContent(c, 'model3d'); if (!got) return 'no source file';+ const ext = (got.srcName.match(/\.[a-z0-9]+$/i) || ['.step'])[0];+ const name = ((c.names && c.names.symbol) || c.mpn) + ext;+ const tmp = path.join(tmpdir, name); fs.writeFileSync(tmp, got.buf);+ const r = await sendFiles([tmp], d.path);+ return (r.j && r.j.success === false) ? ('send failed: ' + (r.j.error || '')) : 'ok';+}+async function fileSymbol(c, tmpdir) {+ const d = c.dest && c.dest.symbol; if (!d || !d.path) return 'no dest';+ const got = await getFileContent(c, 'symbol'); if (!got) return 'no source file';+ const block0 = extractSymbolBlock(got.buf.toString('utf8')); if (!block0) return 'no symbol block';+ const oldN = symbolNameOf(block0) || c.mpn;+ const newN = (c.names && c.names.symbol) || c.mpn;+ let block = renameSymbol(block0, oldN, newN);+ block = stripExtraProps(block); // drop the wiki symbol's many custom spec fields+ if (c.description) block = setSymProp(block, 'Description', c.description);+ const fpd = c.dest.footprint;+ if (fpd) {+ const nick = baseNoExt(fpd.name);+ const fpName = (fpd.source === 'kicad-default' && fpd.fpName) ? fpd.fpName : ((c.names && c.names.footprint) || c.mpn);+ block = setSymProp(block, 'Footprint', `${nick}:${fpName}`);+ }+ if (c.datasheetUrl) block = setSymProp(block, 'Datasheet', c.datasheetUrl);+ if (c.jlcPart) block = setSymProp(block, 'LCSC', c.jlcPart); // LCSC/JLCPCB part number field+ const r = await adomDesktopJSON('read_file', { path: d.path, encoding: 'utf8' });+ const destText = (r.j && r.j.success && r.j.content) || '(kicad_symbol_lib (version 20211014) (generator chip-fetcher-lite)\n)\n';+ const { text, skipped } = mergeSymbol(destText, block, newN);+ if (skipped) return 'already present';+ await runDesktopShell(`cp ${shq(d.path)} ${shq(d.path + '.bak')} 2>/dev/null || true`, 20);+ const tmp = path.join(tmpdir, baseNoExt(d.name) + '.kicad_sym'); fs.writeFileSync(tmp, text);+ const sr = await sendFiles([tmp], dirOf(d.path));+ return (sr.j && sr.j.success === false) ? ('send failed: ' + (sr.j.error || '')) : 'ok';+}+async function fileToLibrary(components) {+ DB.send = { status: 'running', total: components.length, done: 0, current: 'creating libraries…', errors: [], startedAt: new Date().toISOString() };+ save();+ try { await createMissingLibraries(components); } catch (e) { DB.send.errors.push('create libs: ' + (e.message || e)); }+ const tmpdir = path.join(DATA_DIR, 'send'); fs.mkdirSync(tmpdir, { recursive: true });+ for (const c of components) {+ if (!hasAnyCad(c)) { DB.send.errors.push(`${c.mpn}: skipped — no CAD on the wiki`); DB.send.done++; save(); continue; }+ for (const [kind, fn] of [['footprint', fileFootprint], ['3D model', file3D], ['symbol', fileSymbol]]) {+ DB.send.current = `${c.mpn}: ${kind}`; save();+ try { const r = await fn(c, tmpdir); if (r && !/^(ok|stock|already)/.test(r)) DB.send.errors.push(`${c.mpn} ${kind}: ${r}`); }+ catch (e) { DB.send.errors.push(`${c.mpn} ${kind}: ${e.message || e}`); }+ }+ // mark destinations as now existing+ for (const kd of ['symbol', 'footprint', 'model3d']) if (c.dest && c.dest[kd]) c.dest[kd].exists = true;+ DB.send.done++; save();+ }+ DB.send.status = 'done'; DB.send.current = `Filed ${DB.send.done} component(s)` + (DB.send.errors.length ? ` (${DB.send.errors.length} issue(s))` : '');+ DB.send.finishedAt = new Date().toISOString(); save();+}++// ---------------------------------------------------------------- http helpers+function sendJSON(res, code, obj) {+ const b = Buffer.from(JSON.stringify(obj));+ res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': b.length });+ res.end(b);+}+function readBody(req, limit = 64 << 20) {+ return new Promise((resolve, reject) => {+ const chunks = []; let n = 0;+ req.on('data', d => { n += d.length; if (n > limit) { reject(new Error('too large')); req.destroy(); } else chunks.push(d); });+ req.on('end', () => resolve(Buffer.concat(chunks)));+ req.on('error', reject);+ });+}+async function readJSON(req) { const b = await readBody(req); return b.length ? JSON.parse(b.toString('utf8')) : {}; }++const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png' };+function serveStatic(res, urlPath) {+ let rel = urlPath === '/' ? '/index.html' : urlPath;+ const fp = path.join(PUBLIC_DIR, path.normalize(rel).replace(/^(\.\.[/\\])+/, ''));+ if (!fp.startsWith(PUBLIC_DIR) || !fs.existsSync(fp) || !fs.statSync(fp).isFile()) { res.writeHead(404); return res.end('not found'); }+ const body = fs.readFileSync(fp);+ res.writeHead(200, { 'Content-Type': MIME[path.extname(fp)] || 'application/octet-stream' });+ res.end(body);+}++// ---------------------------------------------------------------- routes+const server = http.createServer(async (req, res) => {+ const u = new URL(req.url, 'http://localhost');+ const p = u.pathname;+ try {+ if (!p.startsWith('/api/')) return serveStatic(res, p);++ // ---- state+ if (p === '/api/state' && req.method === 'GET') return sendJSON(res, 200, DB);++ // ---- add component+ if (p === '/api/components' && req.method === 'POST') {+ const { name } = await readJSON(req);+ if (!name || !name.trim()) return sendJSON(res, 400, { error: 'name required' });+ // don't add duplicates — match an existing component by normalized name or MPN+ const want = norm(name);+ const dup = DB.components.find(x => norm(x.name) === want || norm(x.mpn) === want);+ if (dup) return sendJSON(res, 200, { ...dup, _duplicate: true });+ const c = {+ id: uid(), name: name.trim(), mpn: name.trim(), slug: norm(name),+ type: 'Checking…', passive: false, selected: false,+ wiki: { status: 'checking', slug: null, variants: [], checkedAt: null },+ eda: freshEda(),+ _wiki: { files: [], has3d: false },+ wikiFiles: { '3d': false },+ uploads: [],+ dest: { symbol: null, footprint: null, model3d: null },+ names: { symbol: name.trim(), footprint: name.trim() }, // editable library names; default to the MPN+ manufacturer: null, subcategory: null, jlc: false, jlcPart: null, description: '',+ addedAt: new Date().toISOString(),+ };+ // if libraries are already probed, file new components into them by default (refined after wiki check)+ if (DB.probe && DB.probe.libraries && Object.keys(DB.probe.libraries).length) applyDest(c);+ DB.components.unshift(c); save();+ // kick off check in the background+ enqueueCheck(c.id);+ return sendJSON(res, 200, c);+ }++ // ---- per-component routes+ let m;+ if ((m = p.match(/^\/api\/components\/([0-9a-f]+)(\/[a-z-]+)?$/))) {+ const c = find(m[1]);+ if (!c) return sendJSON(res, 404, { error: 'not found' });+ const action = m[2];++ if (!action && req.method === 'DELETE') {+ DB.components = DB.components.filter(x => x.id !== c.id);+ fs.rmSync(path.join(STAGING_DIR, c.id), { recursive: true, force: true });+ save(); return sendJSON(res, 200, { ok: true });+ }+ if (action === '/check' && req.method === 'POST') {+ c.wiki.status = 'checking'; save();+ await runCheck(c); save(); return sendJSON(res, 200, c);+ }+ if (action === '/select' && req.method === 'POST') {+ const { on } = await readJSON(req); c.selected = !!on; save(); return sendJSON(res, 200, c);+ }+ if (action === '/jlc' && req.method === 'POST') {+ const { jlcPart } = await readJSON(req);+ c.jlcPart = (jlcPart || '').trim() || null;+ c.jlc = !!c.jlcPart;+ save(); return sendJSON(res, 200, c);+ }+ if (action === '/names' && req.method === 'POST') {+ const { symbol, footprint } = await readJSON(req);+ if (!c.names) c.names = {};+ if (symbol !== undefined) c.names.symbol = String(symbol).trim();+ if (footprint !== undefined && !c.footprintLocked) c.names.footprint = String(footprint).trim();+ save(); return sendJSON(res, 200, c);+ }+ if (action === '/upload' && req.method === 'POST') {+ const filename = (u.searchParams.get('filename') || 'file.bin').replace(/[/\\]/g, '_');+ const replace = u.searchParams.get('replace') === '1';+ const hit = classifyFile(filename);+ if (!hit) return sendJSON(res, 400, { error: `unrecognized file type: ${filename}` });+ const existing = c.uploads.find(x => x.eda === hit.eda && x.kind === hit.kind);+ if (existing && !replace) return sendJSON(res, 409, { error: 'exists', existing });+ const body = await readBody(req);+ const dir = path.join(STAGING_DIR, c.id); fs.mkdirSync(dir, { recursive: true });+ const fp = path.join(dir, filename); fs.writeFileSync(fp, body);+ if (existing) Object.assign(existing, { name: filename, path: fp, size: body.length, uploadedAt: new Date().toISOString() });+ else c.uploads.push({ eda: hit.eda, kind: hit.kind, name: filename, path: fp, size: body.length, uploadedAt: new Date().toISOString() });+ recomputeEda(c);+ save(); return sendJSON(res, 200, c);+ }+ }++ // ---- selection: all+ if (p === '/api/select-all' && req.method === 'POST') {+ const { on } = await readJSON(req);+ for (const c of DB.components) c.selected = !!on;+ save(); return sendJSON(res, 200, { ok: true });+ }++ // ---- probe: UI requests a probe -> scan the user's machine via adom-desktop+ if (p === '/api/probe-request' && req.method === 'POST') {+ const { edas } = await readJSON(req);+ const sel = (edas && edas.length) ? edas : ['kicad', 'altium', 'fusion'];+ DB.probe = { ...(DB.probe || {}), edas: sel, status: 'requested' }; save();+ // run asynchronously; the UI polls /api/state and shows "probing…" until done+ probeUserLibraries(sel).then(({ libraries }) => {+ DB.probe = { edas: sel, libraries, probedAt: new Date().toISOString(), status: 'done' };+ for (const c of DB.components) applyDest(c);+ save();+ }).catch(e => { DB.probe = { ...DB.probe, status: 'error', error: String(e && e.message || e) }; save(); });+ return sendJSON(res, 200, { status: 'requested', edas: sel });+ }+ // ---- probe: agent posts results+ if (p === '/api/probe' && req.method === 'POST') {+ const { edas, libraries, defaults, destinations } = await readJSON(req);+ DB.probe = {+ edas: edas || DB.probe.edas, libraries: libraries || {},+ defaults: defaults || (DB.probe && DB.probe.defaults) || null, // default symbol/footprint/3d destination+ probedAt: new Date().toISOString(), status: 'done',+ };+ // apply the default destination to every component that doesn't have a specific one+ if (defaults) for (const c of DB.components) {+ if (!c.dest || (!c.dest.symbol && !c.dest.footprint && !c.dest.model3d)) c.dest = JSON.parse(JSON.stringify(defaults));+ }+ // per-component overrides: { <componentId>: { symbol:{name,path}, footprint:{name,path}, model3d:{name,path} } }+ if (destinations) for (const [id, d] of Object.entries(destinations)) { const c = find(id); if (c) c.dest = d; }+ save(); return sendJSON(res, 200, DB.probe);+ }++ // ---- send to library: create any missing libraries on the user's machine, then file in+ if (p === '/api/send-to-library' && req.method === 'POST') {+ const { ids, edas } = await readJSON(req);+ const sel = (ids && ids.length) ? ids : DB.components.filter(c => c.selected).map(c => c.id);+ if (!sel.length) return sendJSON(res, 400, { error: 'no components selected' });+ const comps = sel.map(find).filter(Boolean);+ if (DB.send && DB.send.status === 'running') return sendJSON(res, 409, { error: 'a send is already in progress' });+ // create the libs, download the wiki/uploaded CAD, and file symbol+footprint+3D onto the desktop+ fileToLibrary(comps).catch(e => { DB.send.status = 'error'; DB.send.current = String(e && e.message || e); save(); });+ return sendJSON(res, 200, { status: 'running', total: comps.length });+ }++ // ---- agent job queue+ if (p === '/api/jobs' && req.method === 'GET')+ return sendJSON(res, 200, { jobs: DB.jobs.filter(j => j.status === 'pending') });+ if ((m = p.match(/^\/api\/jobs\/([0-9a-f]+)\/complete$/)) && req.method === 'POST') {+ const job = DB.jobs.find(j => j.id === m[1]);+ if (!job) return sendJSON(res, 404, { error: 'not found' });+ const { result } = await readJSON(req);+ job.status = 'done'; job.result = result || null; job.completedAt = new Date().toISOString();+ save(); return sendJSON(res, 200, job);+ }++ return sendJSON(res, 404, { error: 'no route' });+ } catch (e) {+ return sendJSON(res, 500, { error: String(e && e.message || e) });+ }+});++server.listen(PORT, () => {+ console.log(`chip-fetcher-lite listening on http://localhost:${PORT}`);+ console.log(` data: ${DATA_FILE}`);+});
uninstall.shadded+5@@ -0,0 +1,5 @@+#!/bin/bash+set -e+rm -rf "$HOME/.claude/skills/chip-fetcher-lite"+rm -f "$HOME/.local/bin/chip-fetcher-lite"+echo "Uninstalled chip-fetcher-lite"