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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008
#!/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 = 6; // tuned from the 138-part stress test: fast without overloading adompkg
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 };
}
// --- Generic-part resolution. Wiki component pages are keyed by MPN ONLY. For resistors the
// resistance is encoded INSIDE the MPN (e.g. "...1001..." = 100×10^1 = 1kΩ) and NEVER appears as
// human-readable "1k" in the page title/desc — so a description search ("1k 0402 resistor") can't
// text-match a resistor page (FTS just returns whatever mentions "0402"). We must decode the value
// to its EIA code(s) and match those against candidate MPNs, then verify package via page metadata.
// (Capacitors usually DO carry "100nF" in the title so they resolve by text; this is the fallback.)
function ohmsFromToken(val) {
const m = String(val || '').toLowerCase().replace(/ohm|Ω|ω/g, '').match(/^(\d*\.?\d+)([rkmg]?)$/);
if (!m) return null;
const mult = { '': 1, r: 1, k: 1e3, m: 1e6, g: 1e9 }[m[2]];
const n = parseFloat(m[1]);
return (isFinite(n) && mult != null) ? n * mult : null;
}
// EIA value codes for a resistance, most-specific (4-digit/1%) first: 1000Ω -> ["1001", "102"]
function eiaResistorCodes(ohms) {
if (!(ohms > 0)) return [];
const out = [];
for (const sig of [3, 2]) { // 4-digit (3 sig figs) then 3-digit (2 sig figs)
let exp = Math.floor(Math.log10(ohms)) - (sig - 1);
let mant = Math.round(ohms / Math.pow(10, exp));
if (mant >= Math.pow(10, sig)) { mant = Math.round(mant / 10); exp++; }
if (exp >= 0 && mant >= Math.pow(10, sig - 1)) out.push(String(mant) + String(exp));
}
return out;
}
// 3-digit pF code for a capacitance string ("100nf" -> "104", "4.7uf" -> "475", "10pf" -> "100")
function pfCapCode(val) {
const m = String(val || '').toLowerCase().match(/^(\d*\.?\d+)(p|n|u|µ)?f?$/);
if (!m) return null;
const pf = parseFloat(m[1]) * ({ p: 1, n: 1e3, u: 1e6, 'µ': 1e6, '': 1 }[m[2] || '']);
if (!(pf > 0)) return null;
const exp = Math.max(0, Math.floor(Math.log10(pf)) - 1);
return String(Math.round(pf / Math.pow(10, exp))) + String(exp);
}
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. Wiki pages are keyed by MPN only, so a descriptive request
// ("1k 0402 resistor", "100nF 0402 capacitor") must be resolved to a real MPN page.
if (!exact) {
const tok = passiveTokens(mpn);
const isR = /resistor/i.test(mpn), isC = /capacitor|mlcc/i.test(mpn);
const descriptive = (tok.pkg && tok.val) || isR || isC || /inductor|\bled\b|ferrite|diode/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);
// (a) text match — works for caps/LEDs whose title carries the human value ("100nF MLCC …")
exact = comps.find(r => isPassive(r) && (!tok.val || hay(r).includes(tok.val)) && (!tok.pkg || hay(r).includes(tok.pkg))) || null;
// (b) MPN-code match — resistors (and caps) encode the value IN the MPN, not the page text.
// FTS on the description often misses the family pages entirely, so search by
// type+package, match the decoded EIA value code in the MPN, then VERIFY package via meta.
if (!exact && tok.pkg && tok.val && (isR || isC)) {
const typeWord = isR ? 'resistor' : 'capacitor';
const codes = isR ? eiaResistorCodes(ohmsFromToken(tok.val)) : [pfCapCode(tok.val)].filter(Boolean);
if (codes.length) {
const seen = new Set(comps.map(r => r.slug));
const extra = (await adompkgSearch(`${typeWord} ${tok.pkg}`)).filter(r => r.type === 'component' && !seen.has(r.slug));
const pool = comps.concat(extra);
const idOf = r => (r.slug + ' ' + r.title).toLowerCase().replace(/[^a-z0-9]/g, '');
const matchCat = r => new RegExp(typeWord, 'i').test(r.title + ' ' + r.desc);
const cands = [];
for (const code of codes) for (const r of pool) // 4-digit (specific) candidates first
if (matchCat(r) && idOf(r).includes(code) && !cands.includes(r)) cands.push(r);
for (const r of cands.slice(0, 8)) { // confirm the package matches the page
const pm = await wikiJSON(`/api/v1/pages/${r.slug}`);
const pkg = pm && pm.metadata && String(pm.metadata.package || '').toLowerCase();
if (pkg === tok.pkg) { exact = r; break; }
}
}
}
}
}
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, opts = {}) {
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}"; }`);
if (!opts.skipRegister) sh.push(regSnippet('${CFG}sym-lib-table', name, pth)); // temp dumps don't touch lib tables
}
if (!opts.skipRegister) 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
// Defensive repair: some wiki/uploaded .kicad_mod files have pad layers written comma-separated and
// unquoted (`(layers F.Cu,F.Paste,F.Mask)`), which KiCad reads as an SMD *aperture* (no copper).
// Requote to valid KiCad syntax on copy so footprints always land as proper SMD pads.
function normalizeFpLayers(buf) {
try {
const txt = buf.toString('utf8');
if (!/\(layers [^"()]*,/.test(txt)) return buf; // only touch the broken comma form
const fixed = txt.replace(/\(layers ([^"()]+)\)/g, (m, g) =>
'(layers ' + g.split(/[,\s]+/).filter(Boolean).map(t => `"${t.replace(/"/g, '')}"`).join(' ') + ')');
return Buffer.from(fixed, 'utf8');
} catch { return buf; }
}
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';
let buf, fname;
if (d.source === 'kicad-default') {
// normal mode: the stock footprint is already in KiCad, nothing to copy.
// temp mode (d.stockPath set): copy the stock .kicad_mod from the desktop into the temp .pretty.
if (!d.stockPath || !d.fpName) return 'stock (skipped)';
const r = await adomDesktopJSON('read_file', { path: `${d.stockPath}/${d.fpName}.kicad_mod`, encoding: 'base64' });
if (!(r.j && r.j.success && r.j.content)) return 'stock (skipped)';
buf = Buffer.from(r.j.content, 'base64'); fname = `${d.fpName}.kicad_mod`;
} else {
const got = await getFileContent(c, 'footprint'); if (!got) return 'no source file';
buf = normalizeFpLayers(got.buf); fname = ((c.names && c.names.footprint) || c.mpn) + '.kicad_mod';
}
const tmp = path.join(tmpdir, fname); fs.writeFileSync(tmp, 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';
}
// redirect a component's destinations under a temp root (mirrors Symbols/Footprints/3D), for a
// dry-run export that doesn't touch the user's real libraries or lib tables.
function tempDest(c, tempRoot) {
const d = c.dest || {}; const base = p => (p || '').split('/').pop();
const t = (kd, sub) => d[kd] && d[kd].path ? { ...d[kd], path: `${tempRoot}/${sub}/${base(d[kd].path)}`, exists: false } : (d[kd] || null);
const out = { symbol: t('symbol', 'Symbols'), footprint: t('footprint', 'Footprints'), model3d: t('model3d', '3D') };
if (out.footprint && d.footprint && d.footprint.source === 'kicad-default') out.footprint.stockPath = d.footprint.path; // copy stock fp from here
return out;
}
// build the renamed/stripped/propped symbol block for one component.
// opts.nickPrefix is prepended to the footprint-library nickname in the Footprint property so it
// matches how the footprint lib is registered (e.g. "TEMP_" for a temp export).
async function buildSymbolBlock(c, opts = {}) {
const got = await getFileContent(c, 'symbol'); if (!got) return { err: 'no source file' };
const block0 = extractSymbolBlock(got.buf.toString('utf8')); if (!block0) return { err: '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);
if (c.description) block = setSymProp(block, 'Description', c.description);
const fpd = c.dest && c.dest.footprint;
if (fpd) {
const nick = (opts.nickPrefix || '') + 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);
return { block, newN };
}
// merge ALL symbols destined for the same .kicad_sym in ONE read→merge→write (avoids the
// per-component read-modify-write race that left each lib with only the last symbol)
async function fileSymbolsBatched(components, tmpdir, opts = {}) {
const byLib = new Map();
for (const c of components) {
const d = c.dest && c.dest.symbol; if (!d || !d.path || !hasAnyCad(c)) continue;
if (!byLib.has(d.path)) byLib.set(d.path, { name: d.name, comps: [] });
byLib.get(d.path).comps.push(c);
}
const countSyms = t => (t.match(/\(property "Reference"/g) || []).length;
for (const [pth, grp] of byLib) {
const nick = baseNoExt(grp.name);
DB.send.current = `symbols → ${nick}`; save();
// Read the existing library. SAFETY: only fall back to an empty template when the file is
// genuinely ABSENT. If the read fails but the file EXISTS, refuse to write — never clobber a
// user library we couldn't read. (This is the bug that once truncated real JLC libs to 1 symbol.)
const r = await adomDesktopJSON('read_file', { path: pth, encoding: 'utf8' });
let text;
if (r.j && r.j.success && typeof r.j.content === 'string') {
text = r.j.content;
} else {
const ex = await runDesktopShell(`[ -f ${shq(pth)} ] && echo EXISTS || echo MISSING`, 20);
if (/EXISTS/.test(ex)) { DB.send.errors.push(`symbols ${nick}: SKIPPED — couldn't read existing library; refusing to overwrite (nothing lost)`); continue; }
text = '(kicad_symbol_lib (version 20211014) (generator chip-fetcher-lite)\n)\n';
}
const before = countSyms(text);
let added = 0;
for (const c of grp.comps) {
try {
const b = await buildSymbolBlock(c, opts);
if (b.err) { DB.send.errors.push(`${c.mpn} symbol: ${b.err}`); continue; }
const m = mergeSymbol(text, b.block, b.newN); text = m.text; if (!m.skipped) added++;
} catch (e) { DB.send.errors.push(`${c.mpn} symbol: ${e.message || e}`); }
}
// SAFETY: never write a library that has FEWER symbols than it already had.
const after = countSyms(text);
if (after < before) { DB.send.errors.push(`symbols ${nick}: SKIPPED — write would drop ${before}→${after} symbols; refusing (no data lost)`); continue; }
await runDesktopShell(`cp ${shq(pth)} ${shq(pth + '.bak')} 2>/dev/null || true`, 20);
const tmp = path.join(tmpdir, nick + '.kicad_sym'); fs.writeFileSync(tmp, text);
const sr = await sendFiles([tmp], dirOf(pth));
if (sr.j && sr.j.success === false) DB.send.errors.push(`symbols ${nick}: send failed`);
DB.send.current = `symbols → ${nick} (${added})`; save();
}
}
async function fileToLibrary(components, opts = {}) {
const temp = !!opts.tempRoot;
DB.send = { status: 'running', total: components.length, done: 0, current: temp ? `exporting to ${opts.tempRoot}…` : 'creating libraries…', errors: [], startedAt: new Date().toISOString(), tempRoot: opts.tempRoot || null };
save();
const orig = new Map();
if (temp) for (const c of components) { orig.set(c.id, c.dest); c.dest = tempDest(c, opts.tempRoot); }
try { await createMissingLibraries(components, { skipRegister: temp }); } catch (e) { DB.send.errors.push('create libs: ' + (e.message || e)); }
const tmpdir = path.join(DATA_DIR, 'send'); fs.mkdirSync(tmpdir, { recursive: true });
// 1) footprints + 3D per component (independent files)
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]]) {
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}`); }
}
if (!temp) for (const kd of ['footprint', 'model3d']) if (c.dest && c.dest[kd]) c.dest[kd].exists = true;
DB.send.done++; save();
}
// 2) symbols batched per library (one read→merge-all→write).
// In temp mode the footprint libs are registered as TEMP_<nick>, so the symbols' Footprint
// property must use that prefix to resolve.
await fileSymbolsBatched(components, tmpdir, { nickPrefix: temp ? 'TEMP_' : '' });
if (!temp) for (const c of components) if (c.dest && c.dest.symbol) c.dest.symbol.exists = true;
if (temp) for (const c of components) c.dest = orig.get(c.id); // restore real dests; temp send is non-destructive
DB.send.status = 'done';
DB.send.current = (temp ? `Exported ${DB.send.done} to temp` : `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.
// {temp:true} (or an explicit tempRoot) exports to a temp folder instead of the real libraries.
if (p === '/api/send-to-library' && req.method === 'POST') {
const { ids, edas, temp, tempRoot } = 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' });
let root = tempRoot || null;
if (!root && temp) { // derive a temp root next to the user's KiCad dir
const sd = DB.probe && DB.probe.libraries && DB.probe.libraries.kicad && DB.probe.libraries.kicad.symbolsDir;
root = sd ? dirOf(dirOf(sd)) + '/chip-fetcher-lite-temp' : null;
if (!root) return sendJSON(res, 400, { error: 'probe libraries first so a temp root can be derived, or pass tempRoot' });
}
fileToLibrary(comps, root ? { tempRoot: root } : {}).catch(e => { DB.send.status = 'error'; DB.send.current = String(e && e.message || e); save(); });
return sendJSON(res, 200, { status: 'running', total: comps.length, tempRoot: root });
}
// ---- 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}`);
});