app
KiCad Library Manager
Public Made by Adomby adom
Organize your KiCad symbol / footprint / 3D libraries fast — three priority views, inline editing, live previews, and one-click sync.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332
'use strict';
/*
* kicad-library-manager — organize your KiCad libraries fast.
*
* Pure-Node http server (no native deps). Serves a single-file SPA and a small REST API.
* The user's libraries live on THEIR machine; we reach the filesystem through the
* `adom-desktop` bridge (never the container FS), exactly like chip-fetcher-lite.
*
* Three views over one parsed model:
* - symbol : grouped by symbol library; columns symbol / ref / value / datasheet / mpn / description (editable)
* - footprint : grouped by .pretty; columns footprint / 3d-model / #symbols-using (expandable)
* - model3d : grouped by folder; the 3D model on the left, footprints that use it
*
* Plus: select + delete, an undo/redo TREE (snapshots), an always-kept initial backup with
* a revert button, and a Sync button that writes the app's state to disk (toggle again to
* stop & undo). Disk writes are guarded (.bak backups, never silently shrink a lib, deletions
* go to a reversible trash dir).
*
* REST API:
* GET /api/state -> sanitized view of the working model + history/sync flags
* POST /api/probe-request {edas} -> scan the user's machine via adom-desktop, parse, snapshot backup
* POST /api/symbol/:id/field {field,value} -> edit a symbol field (symbol mode only)
* POST /api/delete {mode,ids} -> delete selected entries (symbol/footprint/model)
* POST /api/undo /api/redo -> walk the undo tree
* POST /api/revert -> restore the working model to the initial backup
* POST /api/sync {} -> write working model to disk (or, if active, stop & undo)
*/
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.KLM_DATA_DIR || path.join(ROOT, 'data');
const DATA_FILE = path.join(DATA_DIR, 'data.json');
const PUBLIC_DIR = path.join(ROOT, 'public');
const STAGING_DIR = path.join(DATA_DIR, 'staging');
const PORT = parseInt(process.env.PORT || process.env.KLM_PORT || '7823', 10);
const HOME = os.homedir();
const WIKI = process.env.KLM_WIKI || 'https://wiki.adom.inc';
const JLCPCB_API = process.env.JLCPCB_API || process.env.KLM_JLCPCB_API || 'https://jlcpcb-wela51osctvp.adom.cloud';
const LCSC_CACHE = new Map();
// ----------------------------------------------------------------- persistence
function freshLibs() { return { symbolLibs: [], footprintLibs: [], models3d: [] }; }
function freshDB() {
return {
libs: freshLibs(),
backup: null, // deep snapshot of libs at probe time (persisted)
probe: { status: 'idle', probedAt: null, edas: ['kicad'], tablesFound: [], counts: {}, error: null },
sync: { active: false, status: 'idle', lastSyncedAt: null, log: [], error: null, trashed: [], pendingTrash: [], pendingUnregister: [], unregisteredTables: [] },
};
}
let DB = load();
// history is in-memory only (full-libs snapshots would bloat data.json); rebuilt on load.
let HIST = { nodes: {}, rootId: null, currentId: null };
function load() {
try {
const d = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
return Object.assign(freshDB(), d);
} catch { return freshDB(); }
}
let _saveT = null;
function save() {
clearTimeout(_saveT);
_saveT = setTimeout(() => {
try { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(DATA_FILE, JSON.stringify(DB)); }
catch (e) { console.error('save failed', e.message); }
}, 250);
}
const clone = o => JSON.parse(JSON.stringify(o));
const uid = () => crypto.randomBytes(6).toString('hex');
// ----------------------------------------------------------------- adom-desktop bridge
// Run a shell command on the USER's machine. Returns stdout (best-effort JSON-unwrapped).
function runDesktopShell(command, timeoutSeconds = 120) {
return new Promise((resolve) => {
execFile('adom-desktop', ['shell_execute', JSON.stringify({ command, timeoutSeconds })],
{ timeout: (timeoutSeconds + 20) * 1000, maxBuffer: 256 << 20, env: process.env }, (err, stdout) => {
let out = '';
try { out = JSON.parse(stdout || '{}').stdout || ''; } catch { out = stdout || ''; }
resolve(out);
});
});
}
function adomDesktopJSON(verb, argsObj, timeout = 180000) {
return new Promise((resolve) => {
execFile('adom-desktop', [verb, JSON.stringify(argsObj)], { timeout, maxBuffer: 256 << 20, env: process.env }, (err, stdout) => {
let j = null; try { j = JSON.parse(stdout || '{}'); } catch {}
resolve({ err, j, stdout: stdout || '' });
});
});
}
const shq = s => "'" + String(s).replace(/'/g, "'\\''") + "'";
const dirOf = p => (p || '').replace(/[\\/][^\\/]*$/, '');
const baseOf = p => (p || '').split(/[\\/]/).filter(Boolean).pop() || '';
function expandPath(p) {
if (!p) return p;
return p.replace(/^~(?=[\\/]|$)/, HOME).replace(/\$\{HOME\}/g, HOME).replace(/\$HOME/g, HOME)
.replace(/\$\{USERPROFILE\}/g, HOME);
}
// Is this lib-table URI / path a KiCad STOCK (default) library rather than a personal one?
function isStockUri(uri) {
if (!uri) return false;
return /\$\{KICAD\d*_(FOOTPRINT|SYMBOL|3DMODEL)_DIR\}|\$\{KISYS(MOD|3DMOD)\}/i.test(uri)
|| /[\\/]usr[\\/](local[\\/])?share[\\/]kicad/i.test(uri)
|| /Program Files[\\/]+KiCad/i.test(uri)
|| /KiCad\.app[\\/]+Contents[\\/]+SharedSupport/i.test(uri)
|| /[\\/]snap[\\/]kicad[\\/]/i.test(uri)
|| /flatpak[\s\S]*org\.kicad/i.test(uri);
}
// ----------------------------------------------------------------- KiCad S-expression parsing
function unesc(s) { return String(s == null ? '' : s).replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\\\/g, '\\'); }
function escStr(s) { return String(s == null ? '' : s).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); }
// Split a .kicad_sym library into [header, [topLevelSymbolBlocks]].
// Top-level symbols are those at depth 1 (directly inside (kicad_symbol_lib ...)); nested
// "units" (Name_1_1) live inside their parent block and must NOT be treated as separate symbols.
function parseSymbolLib(text) {
const symbols = [];
let depth = 0, i = 0, firstSymAt = -1;
while (i < text.length) {
const ch = text[i];
if (ch === '"') { i = skipString(text, i); continue; }
if (ch === '(') {
if (depth === 1 && text.startsWith('(symbol "', i)) {
if (firstSymAt < 0) firstSymAt = i;
const block = balanced(text, i);
symbols.push(block);
i += block.length; continue;
}
depth++; i++; continue;
}
if (ch === ')') { depth--; i++; continue; }
i++;
}
const header = firstSymAt >= 0 ? text.slice(0, firstSymAt).replace(/\s+$/, '') : text.replace(/\)\s*$/, '').replace(/\s+$/, '');
return { header, symbols };
}
function skipString(text, i) { // i points at opening quote; returns index after closing quote
i++;
while (i < text.length) { if (text[i] === '\\') { i += 2; continue; } if (text[i] === '"') { return i + 1; } i++; }
return i;
}
function balanced(text, start) { // start points at '('; returns the balanced substring incl. parens
let d = 0, i = start;
for (; i < text.length; i++) {
const c = text[i];
if (c === '"') { i = skipString(text, i) - 1; continue; }
if (c === '(') d++;
else if (c === ')') { d--; if (d === 0) { i++; break; } }
}
return text.slice(start, i);
}
function symName(block) { const m = block.match(/^\(symbol\s+"((?:[^"\\]|\\.)*)"/); return m ? unesc(m[1]) : ''; }
function symProps(block) {
const out = {}; const re = /\(property\s+"((?:[^"\\]|\\.)*)"\s+"((?:[^"\\]|\\.)*)"/g; let m;
while ((m = re.exec(block))) { const k = unesc(m[1]); if (!(k in out)) out[k] = unesc(m[2]); }
return out;
}
// Property names that hold the JLCPCB / LCSC part number ONLY (no generic manufacturer PNs), best first.
const MPN_KEYS = ['LCSC', 'LCSC Part', 'LCSC Part #', 'LCSC#', 'LCSC Part Number',
'JLCPCB', 'JLCPCB Part', 'JLCPCB Part #', 'JLCPCB Part Number', 'JLC', 'JLC Part'];
function mpnOf(props) {
for (const k of MPN_KEYS) if (props[k] && props[k].trim()) return { field: k, value: props[k].trim() };
// fallback: any property whose name mentions LCSC/JLC, or whose value is an LCSC code C12345
for (const [k, v] of Object.entries(props)) { const t = (v || '').trim(); if (!t) continue; if (/lcsc|jlc/i.test(k) || /^C\d{3,}$/.test(t)) return { field: k, value: t }; }
return { field: null, value: '' };
}
// Backfill per-field "changed" flags for symbols edited before field-tracking existed: diff each
// pending-edit symbol against its backup counterpart (matched by LCSC PN, else name).
const VIS_FIELDS = ['name', 'ref', 'footprint', 'datasheet', 'mpn', 'description'];
function backfillEditedFields() {
if (!DB.backup) return;
const bAll = DB.backup.symbolLibs.flatMap(l => l.symbols);
for (const lib of DB.libs.symbolLibs) for (const s of lib.symbols) {
if (!s.pendingEdit || s.editedFields || s.pendingAdd) continue;
const b = (s.mpn && bAll.find(x => (x.mpn || '').toUpperCase() === s.mpn.toUpperCase())) || bAll.find(x => x.name === s.name);
const ef = {}; if (b) for (const f of VIS_FIELDS) if ((s[f] || '') !== (b[f] || '')) ef[f] = true;
s.editedFields = ef;
}
}
// Re-derive the LCSC/JLC PN for every loaded symbol from its raw block (used after the rule changes).
function reDeriveMpn() {
for (const lib of DB.libs.symbolLibs) for (const s of lib.symbols) {
const m = mpnOf(symProps(s.raw)); s.mpn = m.value; s.mpnField = m.field || s.mpnField || 'LCSC';
}
}
function symbolFromBlock(libNick, libPath, block) {
const p = symProps(block);
const fp = (p.Footprint || '').trim();
const mpn = mpnOf(p);
const name = symName(block);
return {
id: libNick + '::' + name,
lib: libNick, libPath,
name,
ref: (p.Reference || '').trim(),
value: (p.Value || '').trim(),
footprint: fp,
fpNick: fp.includes(':') ? fp.split(':')[0] : '',
fpName: fp.includes(':') ? fp.split(':').slice(1).join(':') : fp,
datasheet: (p.Datasheet || '').trim(),
mpn: mpn.value,
mpnField: mpn.field,
description: (p.Description || '').trim(),
raw: block,
};
}
// Set a property's value in a raw symbol block; insert the property if absent.
function setSymProp(block, name, value) {
const v = escStr(value);
const re = new RegExp('(\\(property\\s+"' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '"\\s+")(?:[^"\\\\]|\\\\.)*(")');
if (re.test(block)) return block.replace(re, '$1' + v + '$2');
// insert right after the (symbol "name" line
return block.replace(/(\(symbol\s+"(?:[^"\\]|\\.)*"\s*\n)/, `$1 (property "${name}" "${v}" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))\n`);
}
function renameSymInBlock(block, oldN, newN) {
const o = oldN.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Rename sub-unit symbols ("Name_1_1") FIRST, then the exact top-level name. Doing the
// top-level first would let the `_` pattern re-match it when newN starts with oldN (e.g. the
// "_copy" case), corrupting the name to "Name_copy_copy".
return block
.replace(new RegExp('\\(symbol\\s+"' + o + '_', 'g'), `(symbol "${escStr(newN)}_`)
.replace(new RegExp('\\(symbol\\s+"' + o + '"'), `(symbol "${escStr(newN)}"`);
}
// ----------------------------------------------------------------- lib-table parsing
function parseLibTable(text) {
const libs = []; if (!text) return libs;
const re = /\(lib\b([\s\S]*?)\)\s*(?=\(lib\b|\)\s*$|$)/g; let m;
while ((m = re.exec(text))) {
const body = m[1];
const nm = body.match(/\(name\s+"?([^")\s]+)"?\)/);
const uriQ = body.match(/\(uri\s+"([^"]*)"\)/) || body.match(/\(uri\s+([^)\s]+)\)/);
if (nm && uriQ) libs.push({ name: nm[1], uri: uriQ[1] });
}
return libs;
}
// ----------------------------------------------------------------- probe
// One generous shell pass: read both global lib-tables, then enumerate & read the user's
// PERSONAL symbol libs (full text — we need it for editing) and footprint libs (just the
// 3D-model refs), plus standalone 3D-model files. Markers delimit each record.
function probeShell() {
return [
'set -e 2>/dev/null || true',
'CFG=$(ls -d "$HOME/.config/kicad"/*/ 2>/dev/null | sort | tail -1)',
'[ -z "$CFG" ] && CFG="$HOME/.config/kicad/"',
'for T in "$CFG/sym-lib-table" "$CFG/fp-lib-table" "$HOME/Library/Preferences/kicad"/*/sym-lib-table "$HOME/Library/Preferences/kicad"/*/fp-lib-table; do [ -f "$T" ] && { echo "@@TABLE $T"; cat "$T"; echo; echo "@@END"; }; done',
'SEARCH="$HOME/Documents $HOME/KiCad $HOME/kicad $HOME/Projects"',
'find $SEARCH -iname "*.kicad_sym" 2>/dev/null | grep -vi backup | grep -vi "\\.bak" | head -300 | while read f; do echo "@@SYM $f"; cat "$f"; echo; echo "@@END"; done',
'find $SEARCH -type d -iname "*.pretty" 2>/dev/null | grep -vi backup | head -300 | while read d; do echo "@@FPDIR $d"; for m in "$d"/*.kicad_mod; do [ -f "$m" ] || continue; echo "@@FP $(basename "$m")"; grep -h "(model" "$m" 2>/dev/null | head -6; done; echo "@@END"; done',
'for r in "$HOME/Documents/KiCAD/3D" "$HOME/Documents/KiCad/3D" "$HOME/Documents/KiCAD/3dmodels" "$HOME/Documents/KiCad/3dmodels" "$HOME/KiCad/3D" "$HOME/Documents/KiCAD/packages3d"; do [ -d "$r" ] && find "$r" -type f \\( -iname "*.step" -o -iname "*.stp" -o -iname "*.wrl" -o -iname "*.glb" -o -iname "*.stl" \\) 2>/dev/null | head -1000 | sed "s|^|@@MODEL |"; done',
].join('\n');
}
function parseProbeOutput(out) {
const lines = (out || '').split('\n');
const tables = [], symFiles = [], fpDirs = [], modelFiles = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line.startsWith('@@TABLE ')) {
const pth = line.slice(8).trim(); const buf = [];
i++; while (i < lines.length && lines[i] !== '@@END') buf.push(lines[i++]);
tables.push({ path: pth, text: buf.join('\n') }); i++;
} else if (line.startsWith('@@SYM ')) {
const pth = line.slice(6).trim(); const buf = [];
i++; while (i < lines.length && lines[i] !== '@@END') buf.push(lines[i++]);
symFiles.push({ path: pth, text: buf.join('\n') }); i++;
} else if (line.startsWith('@@FPDIR ')) {
const dir = line.slice(8).trim(); const fps = [];
i++; let cur = null;
while (i < lines.length && lines[i] !== '@@END') {
const l = lines[i];
if (l.startsWith('@@FP ')) { cur = { file: l.slice(5).trim(), models: [] }; fps.push(cur); }
else if (cur) { const mm = l.match(/\(model\s+"?([^"\)]+)"?/); if (mm) cur.models.push(mm[1].trim()); }
i++;
}
fpDirs.push({ path: dir, footprints: fps }); i++;
} else if (line.startsWith('@@MODEL ')) {
modelFiles.push(line.slice(8).trim()); i++;
} else i++;
}
return { tables, symFiles, fpDirs, modelFiles };
}
async function probe(edas) {
const out = await runDesktopShell(probeShell(), 180);
const { tables, symFiles, fpDirs, modelFiles } = parseProbeOutput(out);
// path/basename -> nickname, from the lib-tables (best source for the nick KiCad actually uses)
const symNickByPath = {}, symNickByBase = {}, fpNickByPath = {}, fpNickByBase = {};
const defaultFpLibs = new Set(), defaultSymLibs = new Set(); // nicks that point at KiCad stock libs
const tablesFound = [];
for (const t of tables) {
tablesFound.push(t.path);
const isFp = /fp-lib-table/.test(t.path);
for (const lib of parseLibTable(t.text)) {
const p = expandPath(lib.uri);
const stock = isStockUri(lib.uri);
if (isFp) { fpNickByPath[p] = lib.name; fpNickByBase[baseOf(p).replace(/\.pretty$/i, '')] = lib.name; if (stock) defaultFpLibs.add(lib.name); }
else { symNickByPath[p] = lib.name; symNickByBase[baseOf(p).replace(/\.kicad_sym$/i, '')] = lib.name; if (stock) defaultSymLibs.add(lib.name); }
}
}
// ---- symbol libraries
const symbolLibs = [];
for (const f of symFiles) {
const base = baseOf(f.path).replace(/\.kicad_sym$/i, '');
const nick = symNickByPath[f.path] || symNickByBase[base] || base;
const inTable = !!(symNickByPath[f.path] || symNickByBase[base]);
const { header, symbols } = parseSymbolLib(f.text);
symbolLibs.push({
nick, path: f.path, header, inTable, isDefault: defaultSymLibs.has(nick) || isStockUri(f.path),
symbols: symbols.map(b => symbolFromBlock(nick, f.path, b)),
});
}
// ---- footprint libraries
const footprintLibs = [];
for (const d of fpDirs) {
const base = baseOf(d.path).replace(/\.pretty$/i, '');
const nick = fpNickByPath[d.path] || fpNickByBase[base] || base;
const inTable = !!(fpNickByPath[d.path] || fpNickByBase[base]);
footprintLibs.push({
nick, path: d.path, inTable, isDefault: defaultFpLibs.has(nick) || isStockUri(d.path),
footprints: d.footprints.map(fp => {
const name = fp.file.replace(/\.kicad_mod$/i, '');
const models = (fp.models || []).map(expandPath);
return { id: nick + '::' + name, lib: nick, libPath: d.path, name, file: fp.file, model: models[0] || '', models };
}),
});
}
// ---- 3D models: union of standalone files found + models referenced by footprints
const modelMap = new Map(); // key -> {name, path, folder}
const addModel = (p) => {
if (!p) return; const ep = expandPath(p);
const key = baseOf(ep).toLowerCase();
if (!modelMap.has(key)) modelMap.set(key, { id: key, name: baseOf(ep), path: ep, folder: baseOf(dirOf(ep)) || '(root)' });
};
for (const mp of modelFiles) addModel(mp);
for (const lib of footprintLibs) for (const fp of lib.footprints) for (const m of fp.models) addModel(m);
const models3d = [...modelMap.values()];
const counts = {
symbolLibs: symbolLibs.length, symbols: symbolLibs.reduce((n, l) => n + l.symbols.length, 0),
footprintLibs: footprintLibs.length, footprints: footprintLibs.reduce((n, l) => n + l.footprints.length, 0),
models3d: models3d.length,
};
return { libs: { symbolLibs, footprintLibs, models3d, defaultFpLibs: [...defaultFpLibs] }, tablesFound, counts };
}
// ----------------------------------------------------------------- usage maps (footprint<->symbol, model<->footprint)
function recomputeUsage() {
const allSymbols = [];
for (const lib of DB.libs.symbolLibs) for (const s of lib.symbols) allSymbols.push(s);
// footprint -> symbols that reference it (match by footprint NAME, prefer matching nick)
for (const lib of DB.libs.footprintLibs) {
for (const fp of lib.footprints) {
const users = allSymbols.filter(s => {
if (!s.fpName) return false;
if (s.fpName.toLowerCase() !== fp.name.toLowerCase()) return false;
if (s.fpNick && lib.nick) return s.fpNick.toLowerCase() === lib.nick.toLowerCase();
return true;
});
fp.usedBy = users.map(s => ({ id: s.id, name: s.name, lib: s.lib, ref: s.ref, value: s.value }));
fp.useCount = users.length;
}
}
// model -> footprints that reference it (match by basename)
const fpByModel = {};
for (const lib of DB.libs.footprintLibs) for (const fp of lib.footprints) for (const m of fp.models) {
const key = baseOf(m).toLowerCase(); (fpByModel[key] = fpByModel[key] || []).push({ id: fp.id, name: fp.name, lib: fp.lib });
}
for (const md of DB.libs.models3d) {
const u = fpByModel[md.name.toLowerCase()] || [];
md.usedBy = u; md.useCount = u.length;
}
}
// ----------------------------------------------------------------- undo/redo tree
function snapNow() { return { libs: clone(DB.libs), trash: clone(DB.sync.pendingTrash || []), unreg: clone(DB.sync.pendingUnregister || []) }; }
function restoreSnap(node) { DB.libs = clone(node.snap.libs); DB.sync.pendingTrash = clone(node.snap.trash || []); DB.sync.pendingUnregister = clone(node.snap.unreg || []); }
function historyInit(label) {
const id = uid();
HIST = { nodes: {}, rootId: id, currentId: id };
HIST.nodes[id] = { id, parentId: null, children: [], label: label || 'initial', ts: Date.now(), snap: snapNow() };
}
function commit(label) {
if (!HIST.currentId) historyInit('initial');
const parent = HIST.nodes[HIST.currentId];
const id = uid();
HIST.nodes[id] = { id, parentId: parent.id, children: [], label, ts: Date.now(), snap: snapNow() };
parent.children.push(id);
HIST.currentId = id;
pruneHistory();
}
function pruneHistory() {
const max = 300;
const ids = Object.keys(HIST.nodes);
if (ids.length <= max) return;
// drop oldest leaf nodes that are not on the path to current
const onPath = new Set(); let n = HIST.currentId;
while (n) { onPath.add(n); n = HIST.nodes[n].parentId; }
const leaves = ids.filter(id => HIST.nodes[id].children.length === 0 && !onPath.has(id))
.sort((a, b) => HIST.nodes[a].ts - HIST.nodes[b].ts);
for (const id of leaves) {
if (Object.keys(HIST.nodes).length <= max) break;
const node = HIST.nodes[id]; const par = HIST.nodes[node.parentId];
if (par) par.children = par.children.filter(c => c !== id);
delete HIST.nodes[id];
}
}
function historyInfo() {
const cur = HIST.nodes[HIST.currentId];
const par = cur && cur.parentId ? HIST.nodes[cur.parentId] : null;
const childId = cur && cur.children.length ? cur.children[cur.children.length - 1] : null;
const child = childId ? HIST.nodes[childId] : null;
return {
canUndo: !!par, canRedo: !!child,
undoLabel: cur ? cur.label : null,
redoLabel: child ? child.label : null,
nodeCount: Object.keys(HIST.nodes).length,
currentLabel: cur ? cur.label : null,
};
}
function undo() {
const cur = HIST.nodes[HIST.currentId]; if (!cur || !cur.parentId) return false;
HIST.currentId = cur.parentId; restoreSnap(HIST.nodes[HIST.currentId]);
recomputeUsage(); save(); return true;
}
function redo() {
const cur = HIST.nodes[HIST.currentId]; if (!cur || !cur.children.length) return false;
const childId = cur.children[cur.children.length - 1];
HIST.currentId = childId; restoreSnap(HIST.nodes[childId]);
recomputeUsage(); save(); return true;
}
// ----------------------------------------------------------------- mutations
const SYM_FIELD_TO_PROP = { ref: 'Reference', value: 'Value', footprint: 'Footprint', datasheet: 'Datasheet', description: 'Description' };
function findSymbol(id) {
for (const lib of DB.libs.symbolLibs) { const s = lib.symbols.find(x => x.id === id); if (s) return { lib, s }; }
return null;
}
function editSymbolField(id, field, value) {
const hit = findSymbol(id); if (!hit) throw new Error('symbol not found');
const { lib, s } = hit;
value = String(value == null ? '' : value);
if (field === 'name') {
const oldN = s.name; const newN = value.trim(); if (!newN || newN === oldN) return s;
if (lib.symbols.some(x => x !== s && x.name === newN)) throw new Error('a symbol named "' + newN + '" already exists in ' + lib.nick);
s.raw = renameSymInBlock(s.raw, oldN, newN);
s.name = newN; s.id = lib.nick + '::' + newN;
} else if (field === 'mpn') {
const prop = s.mpnField || 'LCSC';
s.raw = setSymProp(s.raw, prop, value); s.mpn = value.trim(); s.mpnField = prop;
} else {
const prop = SYM_FIELD_TO_PROP[field]; if (!prop) throw new Error('unknown field: ' + field);
s.raw = setSymProp(s.raw, prop, value);
s[field] = value.trim();
if (field === 'footprint') { s.fpNick = value.includes(':') ? value.split(':')[0] : ''; s.fpName = value.includes(':') ? value.split(':').slice(1).join(':') : value.trim(); }
}
if (!s.pendingAdd) { s.pendingEdit = true; s.editedFields = s.editedFields || {}; s.editedFields[field] = true; } // track which fields changed
recomputeUsage();
commit('edit ' + field + ' · ' + s.name);
save(); return s;
}
// Delete = FLAG for deletion (row turns red), applied to disk on the next Sync. Toggles, so a second
// delete un-flags. An un-synced just-added item (pendingAdd/added) is dropped outright (never on disk).
const isAdded = it => !!(it.pendingAdd || it.added);
function deleteEntries(mode, ids) {
const set = new Set(ids); let n = 0;
if (mode === 'symbol') {
for (const lib of DB.libs.symbolLibs) {
const keep = [];
for (const s of lib.symbols) {
if (set.has(s.id)) { n++; if (isAdded(s)) continue; s.pendingDelete = !s.pendingDelete; }
keep.push(s);
}
lib.symbols = keep.filter(s => !(set.has(s.id) && isAdded(s)));
}
} else if (mode === 'footprint') {
for (const lib of DB.libs.footprintLibs) {
for (const fp of lib.footprints) if (set.has(fp.id)) { n++; if (!isAdded(fp)) fp.pendingDelete = !fp.pendingDelete; }
lib.footprints = lib.footprints.filter(fp => !(set.has(fp.id) && isAdded(fp)));
}
} else if (mode === 'model3d') {
for (const md of DB.libs.models3d) if (set.has(md.id)) { n++; if (!isAdded(md)) md.pendingDelete = !md.pendingDelete; }
DB.libs.models3d = DB.libs.models3d.filter(md => !(set.has(md.id) && isAdded(md)));
}
recomputeUsage();
commit('flag ' + n + ' ' + mode + (n === 1 ? '' : 's') + ' for delete');
save(); return n;
}
// Flag a whole library (or 3D folder group) for deletion — toggles every item + the lib itself.
function deleteLibrary(mode, nick) {
if (mode === 'symbol' || mode === 'footprint') {
const lib = (mode === 'symbol' ? findSymLib : findFpLib)(nick); if (!lib) throw new Error(mode + ' library not found: ' + nick);
lib.pendingDelete = !lib.pendingDelete;
const items = mode === 'symbol' ? lib.symbols : lib.footprints;
for (const it of items) it.pendingDelete = lib.pendingDelete;
} else if (mode === 'model3d') {
const inFolder = DB.libs.models3d.filter(md => (md.folder || '(root)') === nick);
const allFlagged = inFolder.length && inFolder.every(md => md.pendingDelete);
for (const md of inFolder) md.pendingDelete = !allFlagged;
} else throw new Error('bad mode: ' + mode);
recomputeUsage();
commit('flag library ' + nick + ' for delete');
save(); return 1;
}
// ----------------------------------------------------------------- add / duplicate
function findSymLib(nick) { return DB.libs.symbolLibs.find(l => l.nick === nick); }
function findFpLib(nick) { return DB.libs.footprintLibs.find(l => l.nick === nick); }
function uniqueName(taken, base) { let n = base, i = 2; const set = new Set(taken); while (set.has(n)) n = base + '_' + (i++); return n; }
function blankSymbolBlock(name) {
return `(symbol "${escStr(name)}" (in_bom yes) (on_board yes)\n` +
` (property "Reference" "U" (at 0 0 0) (effects (font (size 1.27 1.27))))\n` +
` (property "Value" "${escStr(name)}" (at 0 0 0) (effects (font (size 1.27 1.27))))\n` +
` (property "Footprint" "" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))\n` +
` (property "Datasheet" "" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))\n` +
` (property "Description" "" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))\n )`;
}
function blankFootprintMod(name) {
return `(footprint "${escStr(name)}" (version 20211014) (generator kicad-library-manager) (layer "F.Cu")\n` +
` (attr smd)\n` +
` (fp_text reference "REF**" (at 0 -2) (layer "F.SilkS") (effects (font (size 1 1) (thickness 0.15))))\n` +
` (fp_text value "${escStr(name)}" (at 0 2) (layer "F.Fab") (effects (font (size 1 1) (thickness 0.15))))\n)`;
}
function addBlankSymbol(libNick, name) {
const lib = findSymLib(libNick); if (!lib) throw new Error('symbol library not found: ' + libNick);
const nm = uniqueName(lib.symbols.map(s => s.name), (name || 'NewSymbol').trim());
const s = symbolFromBlock(lib.nick, lib.path, blankSymbolBlock(nm)); s.pendingAdd = true;
lib.symbols.push(s); recomputeUsage(); commit('add symbol · ' + nm); save(); return s;
}
// Bulk-create symbols from a single symbol-block template (e.g. KiCad's Device:R),
// renaming + setting properties per spec. Used for the "fill a library" workflows.
function bulkAddSymbols(libNick, template, specs, clear) {
const lib = findSymLib(libNick); if (!lib) throw new Error('symbol library not found: ' + libNick);
const baseName = symName(template) || 'R';
if (clear) lib.symbols = [];
let added = 0;
for (const sp of specs) {
if (!sp || !sp.name) continue;
if (lib.symbols.some(s => s.name === sp.name)) continue;
let b = renameSymInBlock(template, baseName, sp.name);
if (sp.reference) b = setSymProp(b, 'Reference', sp.reference);
if (sp.value != null) b = setSymProp(b, 'Value', sp.value);
if (sp.footprint != null) b = setSymProp(b, 'Footprint', sp.footprint);
if (sp.datasheet != null) b = setSymProp(b, 'Datasheet', sp.datasheet || '~');
if (sp.description != null) b = setSymProp(b, 'Description', sp.description);
if (sp.mpn) b = setSymProp(b, sp.mpnField || 'LCSC', sp.mpn);
const ns = symbolFromBlock(lib.nick, lib.path, b); ns.pendingAdd = true;
lib.symbols.push(ns);
added++;
}
recomputeUsage(); commit(`bulk add ${added} symbols → ${libNick}`); save();
return added;
}
function duplicateSymbol(id, name) {
const hit = findSymbol(id); if (!hit) throw new Error('symbol not found');
const { lib, s } = hit;
const nm = uniqueName(lib.symbols.map(x => x.name), (name && name.trim()) || s.name + '_copy');
let raw = renameSymInBlock(s.raw, s.name, nm);
const dup = symbolFromBlock(lib.nick, lib.path, raw); dup.pendingAdd = true;
const idx = lib.symbols.findIndex(x => x.id === s.id);
lib.symbols.splice(idx + 1, 0, dup);
recomputeUsage(); commit('duplicate · ' + s.name + ' → ' + nm); save(); return dup;
}
function addBlankFootprint(libNick, name) {
const lib = findFpLib(libNick); if (!lib) throw new Error('footprint library not found: ' + libNick);
const nm = uniqueName(lib.footprints.map(f => f.name), (name || 'NewFootprint').trim());
const fp = { id: lib.nick + '::' + nm, lib: lib.nick, libPath: lib.path, name: nm, file: nm + '.kicad_mod', model: '', models: [], rawMod: blankFootprintMod(nm), added: true };
lib.footprints.push(fp); recomputeUsage(); commit('add footprint · ' + nm); save(); return fp;
}
// ----------------------------------------------------------------- wiki-first add (chip-fetcher style)
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() }; rows.push(cur); }
}
resolve(rows);
});
});
}
async function wikiJSON(p) {
try { const r = await fetch(WIKI + p, { signal: AbortSignal.timeout(20000) }); if (!r.ok) return null; return await r.json(); } catch { return null; }
}
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 norm = s => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
// Fetch an MPN's CAD from the Adom wiki and insert it into the chosen libraries (app model;
// written to disk on Sync). Returns a summary of what was added.
async function wikiAdd(mpn, symLibNick, fpLibNick) {
const q = mpn.trim(); if (!q) throw new Error('MPN required');
const want = norm(q);
const results = await adompkgSearch(q.replace(/-/g, ' '));
// prefer a result whose slug or title head normalizes to the MPN, else a component result
let hit = results.find(r => norm(r.slug) === want) ||
results.find(r => norm((r.title || '').split(/[—\-\s]/)[0]) === want) ||
results.find(r => r.type === 'component') || results[0];
if (!hit) throw new Error('no wiki page found for ' + q);
const slug = hit.slug;
const meta = await wikiJSON(`/api/v1/pages/${slug}`);
const filesRes = await wikiJSON(`/api/v1/pages/${slug}/files`);
const files = (filesRes && (filesRes.files || filesRes)) || [];
const names = files.map(f => (typeof f === 'string' ? f : f.name)).filter(Boolean);
const added = { slug, symbol: null, footprint: null, model3d: null, wikiUrl: `${WIKI}/component/${slug}` };
// ---- symbol
const symFile = names.find(n => /\.kicad_sym$/i.test(n));
const fpFile = names.find(n => /\.kicad_mod$/i.test(n));
const modelFile = names.find(n => /\.step$/i.test(n)) || names.find(n => /\.(stp|wrl|glb|stl)$/i.test(n));
const symLib = symLibNick ? findSymLib(symLibNick) : DB.libs.symbolLibs[0];
const fpLib = fpLibNick ? findFpLib(fpLibNick) : DB.libs.footprintLibs[0];
let fpNameForLink = '';
if (fpFile && fpLib) {
const buf = await wikiBlob(slug, fpFile); if (buf) {
const nm = uniqueName(fpLib.footprints.map(f => f.name), fpFile.replace(/\.kicad_mod$/i, ''));
const text = buf.toString('utf8');
const mm = text.match(/\(model\s+"?([^"\)]+)"?/); const model = mm ? expandPath(mm[1].trim()) : '';
const fp = { id: fpLib.nick + '::' + nm, lib: fpLib.nick, libPath: fpLib.path, name: nm, file: nm + '.kicad_mod', model, models: model ? [model] : [], rawMod: text, added: true };
fpLib.footprints.push(fp); added.footprint = { lib: fpLib.nick, name: nm }; fpNameForLink = fpLib.nick + ':' + nm;
}
}
if (symFile && symLib) {
const buf = await wikiBlob(slug, symFile); if (buf) {
const parsed = parseSymbolLib(buf.toString('utf8'));
const block = parsed.symbols[0];
if (block) {
let b = block;
const nm0 = symName(b);
const nm = uniqueName(symLib.symbols.map(s => s.name), nm0 || q);
if (nm !== nm0) b = renameSymInBlock(b, nm0, nm);
if (fpNameForLink) b = setSymProp(b, 'Footprint', fpNameForLink);
if (meta && meta.metadata && meta.metadata.datasheet_path) b = setSymProp(b, 'Datasheet', `${WIKI}/${meta.metadata.datasheet_path}`.replace(/([^:])\/\//g, '$1/'));
const sym = symbolFromBlock(symLib.nick, symLib.path, b); sym.pendingAdd = true;
symLib.symbols.push(sym); added.symbol = { lib: symLib.nick, name: nm };
}
}
}
// ---- 3D model: stage the bytes now; Sync sends them to the 3D folder
if (modelFile) {
const buf = await wikiBlob(slug, modelFile);
if (buf) {
fs.mkdirSync(STAGING_DIR, { recursive: true });
const local = path.join(STAGING_DIR, slug + '_' + modelFile.replace(/[^\w.\-]/g, '_'));
fs.writeFileSync(local, buf);
const folder3d = kiRoot() + '/3D';
const target = folder3d + '/' + modelFile;
DB.libs.models3d.push({ id: modelFile.toLowerCase(), name: modelFile, path: target, folder: '3D', added: true, _local: local });
added.model3d = { name: modelFile };
}
}
recomputeUsage();
commit('wiki add · ' + q);
save();
return added;
}
// ----------------------------------------------------------------- disk sync
const TRASH_DIRNAME = '.klm-trash';
function kiRoot() {
const sl = DB.libs.symbolLibs[0] && DB.libs.symbolLibs[0].path;
const fl = DB.libs.footprintLibs[0] && DB.libs.footprintLibs[0].path;
const base = sl ? dirOf(sl) : fl ? dirOf(fl) : path.join(HOME, 'Documents');
return dirOf(base) || base;
}
function buildSymLibText(lib) {
const header = (lib.header && lib.header.trim()) ? lib.header.trim()
: '(kicad_symbol_lib (version 20211014) (generator kicad-library-manager)';
// symbols flagged for deletion are written out (i.e. removed) on this sync
return header + '\n' + lib.symbols.filter(s => !s.pendingDelete).map(s => ' ' + s.raw.trim()).join('\n') + '\n)\n';
}
// Write a single symbol library to disk, guarded. Returns a log line.
async function writeSymLib(lib) {
const text = buildSymLibText(lib);
// GUARD 1: never write a lib we couldn't read back (avoid clobbering on a flaky bridge).
const cur = await adomDesktopJSON('read_file', { path: lib.path, encoding: 'utf8' });
const exists = cur.j && cur.j.success !== false && typeof cur.j.content === 'string';
// GUARD 2: never shrink unexpectedly — only allow fewer symbols than disk if the user deleted them
// (we always go through deleteEntries, so a smaller count is intentional). We still keep a .bak.
// Land the new text via a temp file + mv, with a .bak backup.
const tmp = path.join(DATA_DIR, 'out'); fs.mkdirSync(tmp, { recursive: true });
const local = path.join(tmp, baseOf(lib.path)); fs.writeFileSync(local, text);
const sent = await sendFiles([local], dirOf(lib.path), baseOf(lib.path), { backup: true });
return `${sent.ok ? 'OK' : 'FAIL'} symbol ${lib.nick} (${lib.symbols.length} symbols)${sent.ok ? '' : ' — ' + sent.error}`;
}
// send a local file to a destination dir on the user's machine; send_files ignores `dest` on
// Linux (lands in ~/Downloads), so land then mv into place. Optionally .bak the target first.
async function sendFiles(localPaths, destDir, finalName, opts = {}) {
const r = await adomDesktopJSON('send_files', { filePaths: localPaths });
const landed = (r.j && r.j.destinationPaths) || [];
if (!landed.length) return { ok: false, error: (r.j && r.j.error) || 'send_files returned no paths' };
const target = destDir + '/' + (finalName || baseOf(landed[0]));
const bak = opts.backup ? `[ -f ${shq(target)} ] && cp -f ${shq(target)} ${shq(target + '.klm.bak')};` : '';
const out = await runDesktopShell(`mkdir -p ${shq(destDir)}; ${bak} mv -f ${shq(landed[0])} ${shq(target)}; echo MOVED`, 60);
return { ok: /MOVED/.test(out), target };
}
async function moveToTrash(items) {
if (!items.length) return [];
const trashRoot = kiRoot() + '/' + TRASH_DIRNAME;
const cmds = [`mkdir -p ${shq(trashRoot)}`]; const done = [];
for (const it of items) {
const dest = trashRoot + '/' + baseOf(it.path);
cmds.push(`[ -e ${shq(it.path)} ] && mv -f ${shq(it.path)} ${shq(dest)} && echo "TRASHED ${it.path}"`); // -e: handles files AND .pretty dirs
done.push({ ...it, trashed: dest });
}
await runDesktopShell(cmds.join('\n'), 90);
return done;
}
// Remove deleted libraries' entries from the KiCad lib-tables (sym-lib-table / fp-lib-table).
// KiCad writes one `(lib (name "X") …)` per line, so a fixed-string line filter is safe; we
// keep a .klm.bak of each table so "stop & undo all" can restore it.
async function unregisterLibs(items) {
const out = { removed: [], tables: [] };
if (!items || !items.length) return out;
const tables = (DB.probe && DB.probe.tablesFound) || [];
const symT = tables.find(t => /sym-lib-table/.test(t));
const fpT = tables.find(t => /fp-lib-table/.test(t));
const groups = { sym: [], fp: [] };
for (const it of items) if (groups[it.table]) groups[it.table].push(it.nick);
for (const [k, tp] of [['sym', symT], ['fp', fpT]]) {
const nicks = [...new Set(groups[k])]; if (!nicks.length || !tp) continue;
const pats = nicks.map(n => `-e ${shq('(name "' + n + '")')}`).join(' ');
const cmd = `T=${shq(tp)}; [ -f "$T" ] || exit 0; cp -f "$T" "$T.klm.bak"; grep -Fv ${pats} "$T" > "$T.klm.tmp" && mv "$T.klm.tmp" "$T" && echo OK`;
const res = await runDesktopShell(cmd, 60);
if (/OK/.test(res)) { out.removed.push(...nicks); out.tables.push(tp); }
}
return out;
}
// Remove lib-table entries whose path no longer exists (e.g. a library file the user deleted), so
// KiCad stops erroring on launch. Leaves KiCad stock/project entries (${KICAD*}, ${KIPRJMOD}) alone —
// those resolve via KiCad's own env. Runs on every sync. Backs up each table to .klm.bak.
let DESKTOP_HOME = null;
async function desktopHome() {
if (DESKTOP_HOME) return DESKTOP_HOME;
const o = await runDesktopShell('printf %s "$HOME"', 15);
DESKTOP_HOME = (o || '').trim() || HOME; return DESKTOP_HOME;
}
function expandDesktop(p, home) {
return (p || '').replace(/^~(?=[\\/]|$)/, home).replace(/\$\{HOME\}/g, home).replace(/\$HOME\b/g, home).replace(/\$\{USERPROFILE\}/g, home);
}
async function pruneDeadLibTableEntries() {
const tables = (DB.probe && DB.probe.tablesFound) || [];
const result = { removed: [], tables: [] };
if (!tables.length) return result;
const home = await desktopHome();
for (const tp of tables) {
const text = await runDesktopShell(`[ -f ${shq(tp)} ] && cat ${shq(tp)} || echo __KLM_NOFILE__`, 30);
if (!text || text.includes('__KLM_NOFILE__')) continue;
const checks = []; // concrete-path entries we can verify (skip ${KICAD*}/${KIPRJMOD}/${KISYS*})
for (const lib of parseLibTable(text)) {
if (/\$\{KICAD|\$\{KIPRJMOD\}|\$\{KISYS/i.test(lib.uri)) continue;
const pth = expandDesktop(lib.uri, home);
if (/\$\{/.test(pth)) continue; // unknown custom var — leave it alone
checks.push({ name: lib.name, path: pth });
}
if (!checks.length) continue;
const probe = checks.map(c => `[ -e ${shq(c.path)} ] || echo ${shq('MISS::' + c.name)}`).join('; ');
const out = await runDesktopShell(probe, 40);
const dead = [...new Set(out.split('\n').map(l => l.trim()).filter(l => l.startsWith('MISS::')).map(l => l.slice(6)))];
if (!dead.length) continue;
const pats = dead.map(n => `-e ${shq('(name "' + n + '")')}`).join(' ');
const cmd = `T=${shq(tp)}; cp -f "$T" "$T.klm.bak"; grep -Fv ${pats} "$T" > "$T.klm.tmp" && mv "$T.klm.tmp" "$T" && echo OK`;
const res = await runDesktopShell(cmd, 40);
if (/OK/.test(res)) { for (const n of dead) result.removed.push({ table: baseOf(tp), name: n }); result.tables.push(tp); }
}
return result;
}
async function restoreTrash() {
// move everything in the trash back to its sibling .pretty / model dir (best-effort: by basename mapping).
const trashRoot = kiRoot() + '/' + TRASH_DIRNAME;
const restored = [];
for (const it of (DB.sync.trashed || [])) {
await runDesktopShell(`[ -e ${shq(it.trashed)} ] && mkdir -p ${shq(dirOf(it.path))} && mv -f ${shq(it.trashed)} ${shq(it.path)} && echo "RESTORED ${it.path}"`, 60);
restored.push(it.path);
}
await runDesktopShell(`rmdir ${shq(trashRoot)} 2>/dev/null || true`, 30);
return restored;
}
// After a sync writes to disk: drop the items/libs that were flagged for deletion and clear the
// pending-add (green) flags on everything that was just written.
function pruneAfterSync() {
DB.libs.symbolLibs = DB.libs.symbolLibs.filter(l => !l.pendingDelete);
for (const l of DB.libs.symbolLibs) { l.symbols = l.symbols.filter(s => !s.pendingDelete); for (const s of l.symbols) { delete s.pendingAdd; delete s.pendingEdit; delete s.editedFields; } }
DB.libs.footprintLibs = DB.libs.footprintLibs.filter(l => !l.pendingDelete);
for (const l of DB.libs.footprintLibs) { l.footprints = l.footprints.filter(f => !f.pendingDelete); for (const f of l.footprints) f.added = false; }
DB.libs.models3d = DB.libs.models3d.filter(m => !m.pendingDelete); for (const m of DB.libs.models3d) m.added = false;
recomputeUsage();
}
async function runSync() {
if (DB.sync.status === 'running') return DB.sync; // already in progress
DB.sync = { ...DB.sync, active: true, status: 'running', log: [], error: null }; save();
const log = [];
try {
// 1) write every symbol library (edits, additions, and removing pendingDelete symbols). Skip libs
// flagged for deletion entirely — their file is trashed below.
for (const lib of DB.libs.symbolLibs) {
if (!lib.path || lib.pendingDelete) continue;
try { log.push(await writeSymLib(lib)); } catch (e) { log.push('FAIL symbol ' + lib.nick + ' — ' + e.message); }
}
// 2) write any newly-added footprints (.kicad_mod) into their .pretty (skip libs being deleted)
let newFp = 0;
for (const lib of DB.libs.footprintLibs) {
if (lib.pendingDelete) continue;
log.push(...await writeAddedFootprints(lib)); newFp += lib.footprints.filter(f => !f.added && f.rawMod).length;
}
// 3) send any staged 3D models (from wiki-add) into the 3D folder
let new3d = 0;
for (const md of DB.libs.models3d) {
if (!md.added || !md._local || md.pendingDelete) continue;
try { const sent = await sendFiles([md._local], dirOf(md.path), baseOf(md.path), { backup: true }); if (sent.ok) { md.added = false; new3d++; } else log.push('FAIL 3D ' + md.name + ' — ' + sent.error); }
catch (e) { log.push('FAIL 3D ' + md.name + ' — ' + e.message); }
}
if (new3d) log.push(`Wrote ${new3d} new 3D model(s)`);
// 4) apply deletions: move flagged libs/footprints/models to the reversible trash dir
const trash = [];
for (const lib of DB.libs.symbolLibs) if (lib.pendingDelete && lib.path) trash.push({ path: lib.path });
for (const lib of DB.libs.footprintLibs) {
if (lib.pendingDelete && lib.path) trash.push({ path: lib.path });
else for (const fp of lib.footprints) if (fp.pendingDelete) trash.push({ path: lib.path + '/' + fp.file });
}
for (const md of DB.libs.models3d) if (md.pendingDelete && md.path) trash.push({ path: md.path });
const trashed = await moveToTrash(trash);
if (trashed.length) log.push(`Moved ${trashed.length} deleted file(s) to ${TRASH_DIRNAME}/`);
// 5) unregister whole deleted libraries from the KiCad lib-tables
const unregItems = [];
for (const lib of DB.libs.symbolLibs) if (lib.pendingDelete) unregItems.push({ nick: lib.nick, table: 'sym' });
for (const lib of DB.libs.footprintLibs) if (lib.pendingDelete) unregItems.push({ nick: lib.nick, table: 'fp' });
const unreg = await unregisterLibs(unregItems);
if (unreg.removed.length) log.push(`Unregistered ${unreg.removed.length} library(ies) from the lib-table(s)`);
// 5b) clean any lib-table entries whose path no longer exists (prevents KiCad launch errors)
const dead = await pruneDeadLibTableEntries();
if (dead.removed.length) log.push(`Removed ${dead.removed.length} dead lib-table entry(ies): ${dead.removed.map(d => d.name).join(', ')}`);
// 6) prune the model: drop the now-deleted items, clear the pending-add flags
pruneAfterSync();
// one-shot: reset to idle so the button is immediately ready to sync again
DB.sync = { active: false, status: 'synced', lastSyncedAt: new Date().toISOString(), log, error: null, trashed, pendingTrash: [], pendingUnregister: [], unregisteredTables: unreg.tables };
save();
} catch (e) {
DB.sync = { ...DB.sync, active: true, status: 'error', error: String(e && e.message || e), log }; save();
}
return DB.sync;
}
// Stop syncing: undo all on-disk changes — restore each symbol lib from the initial backup and
// move trashed files back. The app's working model is left as-is (use Revert to also reset it).
async function stopSync() {
DB.sync.status = 'reverting'; save();
const log = [];
const backup = DB.backup;
if (backup) {
for (const lib of backup.symbolLibs) {
if (!lib.path) continue;
try {
const text = buildSymLibText(lib);
const tmp = path.join(DATA_DIR, 'out'); fs.mkdirSync(tmp, { recursive: true });
const local = path.join(tmp, baseOf(lib.path)); fs.writeFileSync(local, text);
const sent = await sendFiles([local], dirOf(lib.path), baseOf(lib.path), { backup: false });
log.push(`${sent.ok ? 'RESTORED' : 'FAIL'} symbol ${lib.nick}`);
} catch (e) { log.push('FAIL restore ' + lib.nick + ' — ' + e.message); }
}
}
const restored = await restoreTrash();
if (restored.length) log.push(`Restored ${restored.length} file(s) from ${TRASH_DIRNAME}/`);
// restore any lib-tables we edited (re-register the libraries we'd unregistered)
for (const tp of (DB.sync.unregisteredTables || [])) {
await runDesktopShell(`[ -f ${shq(tp + '.klm.bak')} ] && mv -f ${shq(tp + '.klm.bak')} ${shq(tp)} && echo RT`, 30);
log.push('Restored lib-table ' + baseOf(tp));
}
DB.sync = { active: false, status: 'idle', lastSyncedAt: DB.sync.lastSyncedAt, log, error: null, trashed: [], pendingTrash: DB.sync.pendingTrash || [], pendingUnregister: DB.sync.pendingUnregister || [], unregisteredTables: [] };
save();
return DB.sync;
}
// Write the newly-added footprints of one footprint lib to disk. Returns log lines.
async function writeAddedFootprints(lib) {
const log = []; let n = 0;
for (const fp of lib.footprints) {
if (!fp.added || !fp.rawMod) continue;
try {
const tmp = path.join(DATA_DIR, 'out'); fs.mkdirSync(tmp, { recursive: true });
const local = path.join(tmp, fp.file); fs.writeFileSync(local, fp.rawMod);
const sent = await sendFiles([local], lib.path, fp.file, { backup: true });
if (sent.ok) { fp.added = false; n++; } else log.push('FAIL footprint ' + fp.name + ' — ' + sent.error);
} catch (e) { log.push('FAIL footprint ' + fp.name + ' — ' + e.message); }
}
if (n) log.push(`wrote ${n} new footprint(s)`);
return log;
}
// Sync exactly ONE library (symbol lib / footprint .pretty / 3D folder) to disk. A targeted,
// one-shot write — it does NOT flip the global sync.active toggle (that's "Sync all").
async function syncOneLibrary(mode, nick) {
const log = []; const addTrash = async items => { const tr = await moveToTrash(items); DB.sync.trashed = (DB.sync.trashed || []).concat(tr); return tr; };
if (mode === 'symbol') {
const lib = findSymLib(nick); if (!lib) throw new Error('symbol library not found: ' + nick);
if (lib.pendingDelete) {
if (lib.path) await addTrash([{ path: lib.path }]);
await unregisterLibs([{ nick: lib.nick, table: 'sym' }]);
DB.libs.symbolLibs = DB.libs.symbolLibs.filter(l => l !== lib);
log.push('deleted symbol library ' + nick);
} else {
log.push(await writeSymLib(lib));
lib.symbols = lib.symbols.filter(s => !s.pendingDelete); for (const s of lib.symbols) { delete s.pendingAdd; delete s.pendingEdit; delete s.editedFields; }
}
} else if (mode === 'footprint') {
const lib = findFpLib(nick); if (!lib) throw new Error('footprint library not found: ' + nick);
if (lib.pendingDelete) {
if (lib.path) await addTrash([{ path: lib.path }]);
await unregisterLibs([{ nick: lib.nick, table: 'fp' }]);
DB.libs.footprintLibs = DB.libs.footprintLibs.filter(l => l !== lib);
log.push('deleted footprint library ' + nick);
} else {
log.push(...await writeAddedFootprints(lib));
const del = lib.footprints.filter(f => f.pendingDelete).map(f => ({ path: lib.path + '/' + f.file }));
if (del.length) { const tr = await addTrash(del); log.push(`trashed ${tr.length} footprint(s)`); }
lib.footprints = lib.footprints.filter(f => !f.pendingDelete); for (const f of lib.footprints) f.added = false;
}
} else if (mode === 'model3d') {
let n = 0; const del = [];
for (const md of DB.libs.models3d) {
if ((md.folder || '(root)') !== nick) continue;
if (md.added && md._local) { const sent = await sendFiles([md._local], dirOf(md.path), baseOf(md.path), { backup: true }); if (sent.ok) { md.added = false; n++; } }
if (md.pendingDelete && md.path) del.push({ path: md.path });
}
if (del.length) await addTrash(del);
DB.libs.models3d = DB.libs.models3d.filter(md => !((md.folder || '(root)') === nick && md.pendingDelete));
log.push(`wrote ${n} model(s), removed ${del.length}`);
} else throw new Error('bad mode: ' + mode);
// every push also cleans dead lib-table entries (paths that no longer exist)
const dead = await pruneDeadLibTableEntries();
if (dead.removed.length) log.push(`Removed ${dead.removed.length} dead lib-table entry(ies): ${dead.removed.map(d => d.name).join(', ')}`);
recomputeUsage();
DB.sync.lastSyncedAt = new Date().toISOString(); DB.sync.log = log; save();
return log;
}
// ----------------------------------------------------------------- preview rendering (kicad-cli on the desktop)
const PREVIEW_CACHE = new Map();
function previewLocalDir() { const d = path.join(DATA_DIR, 'preview'); fs.mkdirSync(d, { recursive: true }); return d; }
async function landFile(localPath) {
const r = await adomDesktopJSON('send_files', { filePaths: [localPath] });
const landed = (r.j && r.j.destinationPaths) || [];
return landed[0] || null;
}
// KiCad symbol SVGs are drawn for a LIGHT background (black text on a light-yellow body, dark-red
// outlines, an opaque page-fill rect). Remap the whole theme palette to one clean dark-friendly
// scheme so every symbol looks consistent: light-gray body/graphics, teal pins, muted field text,
// and transparent fills (so our gridded canvas shows through). Unknown colours pass through.
const SYM_STROKE_MAP = { '000000': 'c9d1d9', '840000': 'c9d1d9', 'a90000': '8b949e', '006464': '2bd9d0', 'ff8c8c': 'c9d1d9' };
const SYM_FILL_MAP = { '000000': 'none', 'ffffc2': 'none', 'ffffff': 'none', '840000': 'c9d1d9', 'a90000': '8b949e', '006464': '2bd9d0' };
function themeSymbolSvg(svg) {
svg = svg.replace(/stroke:#([0-9a-fA-F]{6})/g, (m, h) => { const v = SYM_STROKE_MAP[h.toLowerCase()]; return 'stroke:' + (v ? '#' + v : '#' + h); });
svg = svg.replace(/fill:#([0-9a-fA-F]{6})/g, (m, h) => { const v = SYM_FILL_MAP[h.toLowerCase()]; return 'fill:' + (v === 'none' ? 'none' : v ? '#' + v : '#' + h); });
return svg;
}
async function renderSymbolSvg(name, libText) {
const key = 'sym:' + crypto.createHash('sha1').update(name + '\n' + libText).digest('hex');
if (PREVIEW_CACHE.has(key)) return PREVIEW_CACHE.get(key);
const safe = (name.replace(/[^\w.\-]/g, '_') || 'sym');
const local = path.join(previewLocalDir(), safe + '.kicad_sym'); fs.writeFileSync(local, libText);
const landed = await landFile(local); if (!landed) throw new Error('failed to send symbol lib to desktop');
const cmd = `set -e; D="$HOME/.klm-preview"; mkdir -p "$D/lib" "$D/symout"; rm -f "$D/symout/"*.svg; mv -f ${shq(landed)} "$D/lib/${safe}.kicad_sym"; kicad-cli sym export svg --symbol ${shq(name)} -o "$D/symout" "$D/lib/${safe}.kicad_sym" >/dev/null 2>&1 || true; cat "$D/symout/"*.svg 2>/dev/null`;
const svg = await runDesktopShell(cmd, 60);
const out = /<svg/i.test(svg) ? themeSymbolSvg(svg) : '';
PREVIEW_CACHE.set(key, out); return out;
}
async function renderFootprintSvg(prettyPath, fpName, rawMod) {
const key = 'fp:' + crypto.createHash('sha1').update((prettyPath || '') + '|' + fpName + '|' + (rawMod || '')).digest('hex');
if (PREVIEW_CACHE.has(key)) return PREVIEW_CACHE.get(key);
const safe = (fpName.replace(/[^\w.\-]/g, '_') || 'fp');
// Plot bottom→top: courtyard (lowest) → fab → silkscreen → paste/mask → copper pads (on top).
// kicad-cli draws layers in the order given, so this puts courtyard under silk and pads above the gray lines.
const LAYERS = 'F.CrtYd,F.Fab,F.SilkS,F.Paste,F.Mask,F.Cu';
let cmd;
if (rawMod) {
const local = path.join(previewLocalDir(), safe + '.kicad_mod'); fs.writeFileSync(local, rawMod);
const landed = await landFile(local); if (!landed) throw new Error('send failed');
cmd = `set -e; D="$HOME/.klm-preview"; mkdir -p "$D/fp.pretty" "$D/fpout"; rm -f "$D/fpout/"*.svg "$D/fp.pretty/"*.kicad_mod; mv -f ${shq(landed)} "$D/fp.pretty/${safe}.kicad_mod"; kicad-cli fp export svg --layers ${LAYERS} --footprint ${shq(fpName)} -o "$D/fpout" "$D/fp.pretty" >/dev/null 2>&1 || true; cat "$D/fpout/"*.svg 2>/dev/null`;
} else {
if (!prettyPath) return '';
cmd = `set -e; D="$HOME/.klm-preview"; mkdir -p "$D/fpout"; rm -f "$D/fpout/"*.svg; kicad-cli fp export svg --layers ${LAYERS} --footprint ${shq(fpName)} -o "$D/fpout" ${shq(prettyPath)} >/dev/null 2>&1 || true; cat "$D/fpout/"*.svg 2>/dev/null`;
}
const svg = await runDesktopShell(cmd, 60);
const out = /<svg/i.test(svg) ? liftFootprintText(svg) : '';
PREVIEW_CACHE.set(key, out); return out;
}
// kicad-cli draws pad-number / reference / value text (<g class="stroked-text">) as part of its layer,
// so with pads plotted on top the numbers get hidden. Move every text group above everything else,
// preserving the stroke colour it inherited from its layer group, so pad numbers sit ON the pads.
function liftFootprintText(svg) {
const lifted = []; let out = ''; let i = 0; const styleStack = [];
while (i < svg.length) {
if (svg.startsWith('<g', i) && /^[\s>]/.test(svg[i + 2] || '')) {
const end = svg.indexOf('>', i) + 1; const tag = svg.slice(i, end);
if (/class="stroked-text"/.test(tag)) {
let depth = 0; const re = /<g\b[^>]*>|<\/g>/g; re.lastIndex = i; let m, j = i;
while ((m = re.exec(svg))) { if (m[0] === '</g>') { depth--; if (depth === 0) { j = re.lastIndex; break; } } else depth++; }
const inner = svg.slice(i, j);
const parentStyle = styleStack[styleStack.length - 1] || 'fill:none;stroke:#AFAFAF;stroke-width:0.12;stroke-linecap:round;stroke-linejoin:round';
lifted.push(`<g style="${parentStyle}">${inner}</g>`);
i = j; continue;
}
const sm = tag.match(/style="([^"]*)"/); styleStack.push(sm ? sm[1] : ''); out += tag; i = end; continue;
}
if (svg.startsWith('</g>', i)) { styleStack.pop(); out += '</g>'; i += 4; continue; }
out += svg[i]; i++;
}
if (!lifted.length) return out;
const idx = out.lastIndexOf('</g>');
return idx < 0 ? out : out.slice(0, idx) + lifted.join('\n') + out.slice(idx);
}
// resolve a footprint nick to its rendered SVG: loaded lib (incl. unsynced added) or a KiCad stock lib
async function renderFootprintForNick(nick, fpName) {
const lib = findFpLib(nick);
if (lib && lib.path) {
const fp = lib.footprints.find(f => f.name === fpName);
if (fp && fp.added && fp.rawMod) return renderFootprintSvg(null, fpName, fp.rawMod);
return renderFootprintSvg(lib.path, fpName, null);
}
if (nick) return renderFootprintSvg('/usr/share/kicad/footprints/' + nick + '.pretty', fpName, null);
return '';
}
function symLibTextFor(s) { return `(kicad_symbol_lib (version 20211014) (generator kicad-library-manager)\n ${s.raw.trim()}\n)\n`; }
// ---- 3D model preview: resolve a STEP path, pull it from the desktop, convert to GLB via step2glb, cache.
function expandModelPath(p) {
return expandPath((p || '').replace(/\$\{KICAD\d*_3DMODEL_DIR\}/gi, '/usr/share/kicad/3dmodels').replace(/\$\{KIPRJMOD\}/gi, ''));
}
async function footprintStepPath(lib, fp) {
let text;
if (fp.added && fp.rawMod) text = fp.rawMod;
else text = await runDesktopShell(`cat ${shq(lib.path + '/' + fp.file)} 2>/dev/null`, 40);
const m = (text || '').match(/\(model\s+"?([^"\)]+)"?/); if (!m) return null;
let mp = expandModelPath(m[1].trim());
if (/\.wrl$/i.test(mp)) mp = mp.replace(/\.wrl$/i, '.step'); // step2glb needs STEP, not VRML
return mp;
}
function glbDir() { const d = path.join(DATA_DIR, 'glb'); fs.mkdirSync(d, { recursive: true }); return d; }
async function glbForStepPath(stepPath) {
const key = crypto.createHash('sha1').update('step:' + stepPath).digest('hex');
const glbLocal = path.join(glbDir(), key + '.glb');
if (fs.existsSync(glbLocal)) return glbLocal;
const stepText = await runDesktopShell(`[ -f ${shq(stepPath)} ] && cat ${shq(stepPath)} || echo __KLM_NOFILE__`, 120);
if (!stepText || stepText.includes('__KLM_NOFILE__') || stepText.length < 20) return null;
const stepLocal = path.join(glbDir(), key + '.step'); fs.writeFileSync(stepLocal, stepText);
await new Promise((resolve, reject) => execFile('step2glb', ['convert', stepLocal, '-o', glbLocal],
{ timeout: 180000, maxBuffer: 64 << 20, env: process.env }, (e, so, se) => e ? reject(new Error('step2glb: ' + (se || e.message))) : resolve()));
return fs.existsSync(glbLocal) ? glbLocal : null;
}
async function pullGlb(glbPath) { // GLB is binary → base64 transfer
const key = crypto.createHash('sha1').update('glb:' + glbPath).digest('hex');
const local = path.join(glbDir(), key + '.glb');
if (fs.existsSync(local)) return local;
const b64 = await runDesktopShell(`[ -f ${shq(glbPath)} ] && base64 -w0 ${shq(glbPath)} || echo __KLM_NOFILE__`, 120);
if (!b64 || b64.includes('__KLM_NOFILE__')) return null;
fs.writeFileSync(local, Buffer.from(b64.trim(), 'base64'));
return local;
}
async function resolveGlb({ fpId, modelId }) {
if (fpId) {
let fp = null, lib = null;
for (const L of DB.libs.footprintLibs) { const f = L.footprints.find(x => x.id === fpId); if (f) { fp = f; lib = L; break; } }
if (!fp) return null;
const sp = await footprintStepPath(lib, fp); return sp ? glbForStepPath(sp) : null;
}
if (modelId) {
const md = DB.libs.models3d.find(m => m.id === modelId); if (!md) return null;
const pth = md._local || md.path || '';
if (/\.glb$/i.test(pth)) return md._local ? md._local : pullGlb(pth);
let sp = /\.wrl$/i.test(pth) ? pth.replace(/\.wrl$/i, '.step') : pth;
return /\.(step|stp)$/i.test(sp) ? glbForStepPath(sp) : null;
}
return null;
}
async function symbolOnDisk(libPath, name) {
const out = await runDesktopShell(`grep -F ${shq('(symbol "' + name + '"')} ${shq(libPath)} >/dev/null 2>&1 && echo YES || echo NO`, 30);
return /YES/.test(out);
}
// ----------------------------------------------------------------- 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'); }
res.writeHead(200, { 'Content-Type': MIME[path.extname(fp)] || 'application/octet-stream' });
res.end(fs.readFileSync(fp));
}
// Sanitized state for the SPA (omit the heavy backup snapshot; include history/sync flags).
function stateView() {
return {
libs: DB.libs,
probe: DB.probe,
sync: { active: DB.sync.active, status: DB.sync.status, lastSyncedAt: DB.sync.lastSyncedAt, log: DB.sync.log, error: DB.sync.error,
pendingTrash: (DB.sync.pendingTrash || []).length },
history: historyInfo(),
hasBackup: !!DB.backup,
};
}
// ----------------------------------------------------------------- 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);
if (p === '/api/state' && req.method === 'GET') return sendJSON(res, 200, stateView());
if (p === '/api/probe-request' && req.method === 'POST') {
const { edas } = await readJSON(req);
const sel = (edas && edas.length) ? edas : ['kicad'];
DB.probe = { ...DB.probe, status: 'probing', edas: sel, error: null }; save();
probe(sel).then(({ libs, tablesFound, counts }) => {
DB.libs = libs;
DB.backup = clone(libs); // always keep the initial state for revert
DB.sync = { active: false, status: 'idle', lastSyncedAt: null, log: [], error: null, trashed: [], pendingTrash: [], pendingUnregister: [], unregisteredTables: [] };
recomputeUsage();
historyInit('probe');
DB.probe = { status: 'done', probedAt: new Date().toISOString(), edas: sel, tablesFound, counts, error: null };
save();
}).catch(e => { DB.probe = { ...DB.probe, status: 'error', error: String(e && e.message || e) }; save(); });
return sendJSON(res, 200, { status: 'probing' });
}
let m;
if ((m = p.match(/^\/api\/symbol\/(.+)\/field$/)) && req.method === 'POST') {
const id = decodeURIComponent(m[1]);
const { field, value } = await readJSON(req);
try { const s = editSymbolField(id, field, value); return sendJSON(res, 200, { ok: true, symbol: s, history: historyInfo() }); }
catch (e) { return sendJSON(res, 400, { error: e.message }); }
}
if (p === '/api/add-symbol' && req.method === 'POST') {
const { lib, name } = await readJSON(req);
try { const s = addBlankSymbol(lib, name); return sendJSON(res, 200, { ok: true, symbol: s, history: historyInfo() }); }
catch (e) { return sendJSON(res, 400, { error: e.message }); }
}
if (p === '/api/add-footprint' && req.method === 'POST') {
const { lib, name } = await readJSON(req);
try { const f = addBlankFootprint(lib, name); return sendJSON(res, 200, { ok: true, footprint: f, history: historyInfo() }); }
catch (e) { return sendJSON(res, 400, { error: e.message }); }
}
if (p === '/api/bulk-add-symbols' && req.method === 'POST') {
const { lib, template, symbols, clear } = await readJSON(req);
if (!template || !Array.isArray(symbols)) return sendJSON(res, 400, { error: 'template and symbols[] required' });
try { const added = bulkAddSymbols(lib, template, symbols, !!clear); return sendJSON(res, 200, { ok: true, added, history: historyInfo() }); }
catch (e) { return sendJSON(res, 400, { error: e.message }); }
}
if (p === '/api/duplicate-symbol' && req.method === 'POST') {
const { id, name } = await readJSON(req);
try { const s = duplicateSymbol(id, name); return sendJSON(res, 200, { ok: true, symbol: s, history: historyInfo() }); }
catch (e) { return sendJSON(res, 400, { error: e.message }); }
}
if (p === '/api/wiki-add' && req.method === 'POST') {
const { mpn, symLib, fpLib } = await readJSON(req);
try { const added = await wikiAdd(mpn || '', symLib, fpLib); return sendJSON(res, 200, { ok: true, added, history: historyInfo() }); }
catch (e) { return sendJSON(res, 400, { error: e.message }); }
}
if (p === '/api/delete' && req.method === 'POST') {
const { mode, ids } = await readJSON(req);
if (!mode || !Array.isArray(ids) || !ids.length) return sendJSON(res, 400, { error: 'mode and ids required' });
const removed = deleteEntries(mode, ids);
return sendJSON(res, 200, { ok: true, removed, history: historyInfo() });
}
if (p === '/api/delete-library' && req.method === 'POST') {
const { mode, nick } = await readJSON(req);
if (!mode || !nick) return sendJSON(res, 400, { error: 'mode and nick required' });
try { const removed = deleteLibrary(mode, nick); return sendJSON(res, 200, { ok: true, removed, history: historyInfo() }); }
catch (e) { return sendJSON(res, 400, { error: e.message }); }
}
if (p === '/api/undo' && req.method === 'POST') { const ok = undo(); return sendJSON(res, 200, { ok, history: historyInfo() }); }
if (p === '/api/redo' && req.method === 'POST') { const ok = redo(); return sendJSON(res, 200, { ok, history: historyInfo() }); }
if (p === '/api/revert' && req.method === 'POST') {
if (!DB.backup) return sendJSON(res, 400, { error: 'no backup yet — probe first' });
DB.libs = clone(DB.backup); recomputeUsage();
DB.sync.pendingTrash = [];
commit('revert to backup');
return sendJSON(res, 200, { ok: true, history: historyInfo() });
}
if (p === '/api/sync' && req.method === 'POST') {
runSync().catch(e => { DB.sync = { ...DB.sync, status: 'error', error: String(e && e.message || e) }; save(); });
return sendJSON(res, 200, { ok: true, sync: DB.sync });
}
// ---- preview: render a symbol + its linked footprint (or a footprint + a using symbol) to SVG
if (p === '/api/preview/pair' && req.method === 'GET') {
const symId = u.searchParams.get('symbolId'), fpId = u.searchParams.get('footprintId');
const result = { left: null, right: null };
try {
if (symId) {
const hit = findSymbol(symId); if (!hit) return sendJSON(res, 404, { error: 'symbol not found' });
const s = hit.s;
result.left = { kind: 'symbol', name: s.name, id: s.id, svg: await renderSymbolSvg(s.name, symLibTextFor(s)) };
if (s.fpName) {
const svg = await renderFootprintForNick(s.fpNick || '', s.fpName);
result.right = { kind: 'footprint', name: s.footprint || s.fpName, svg, found: !!svg };
} else result.right = { kind: 'footprint', name: '', svg: '', found: false, reason: 'no linked footprint' };
} else if (fpId) {
let fp = null, lib = null;
for (const L of DB.libs.footprintLibs) { const f = L.footprints.find(x => x.id === fpId); if (f) { fp = f; lib = L; break; } }
if (!fp) return sendJSON(res, 404, { error: 'footprint not found' });
const svg = fp.added ? await renderFootprintSvg(null, fp.name, fp.rawMod) : await renderFootprintSvg(lib.path, fp.name, null);
result.right = { kind: 'footprint', name: lib.nick + ':' + fp.name, svg, found: !!svg };
const u0 = (fp.usedBy || [])[0];
if (u0) { const h = findSymbol(u0.id); if (h) result.left = { kind: 'symbol', name: h.s.name, id: h.s.id, svg: await renderSymbolSvg(h.s.name, symLibTextFor(h.s)) }; }
} else return sendJSON(res, 400, { error: 'symbolId or footprintId required' });
return sendJSON(res, 200, result);
} catch (e) { return sendJSON(res, 500, { error: e.message }); }
}
// ---- live JLCPCB stock + price for an LCSC part number (hover popover)
if (p === '/api/lcsc' && req.method === 'GET') {
const part = (u.searchParams.get('part') || '').trim();
if (!/^C\d{3,}$/i.test(part)) return sendJSON(res, 400, { error: 'not an LCSC part number' });
if (LCSC_CACHE.has(part.toUpperCase())) return sendJSON(res, 200, LCSC_CACHE.get(part.toUpperCase()));
try {
const r = await fetch(`${JLCPCB_API}/search?keyword=${encodeURIComponent(part)}&limit=6`, { signal: AbortSignal.timeout(15000) });
const j = r.ok ? await r.json() : {};
const comps = j.components || j.results || [];
const c = comps.find(x => (x.lcsc || '').toUpperCase() === part.toUpperCase()) || comps[0];
let out;
if (!c) out = { part: part.toUpperCase(), found: false };
else out = {
part: c.lcsc, found: true, stock: c.stock, basic: !!c.is_basic, preferred: !!c.is_preferred,
mfr: c.mfr || null, package: c.package || null, description: c.description || null,
unitPrice: c.unit_price_usd != null ? c.unit_price_usd : ((c.price_tiers || [])[0] || {}).price_usd,
tiers: (c.price_tiers || []).slice(0, 5).map(t => ({ q: t.qty_from, p: t.price_usd })),
url: c.jlcpcb_parts_url || c.jlcpcb_url || null,
};
LCSC_CACHE.set(part.toUpperCase(), out);
return sendJSON(res, 200, out);
} catch (e) { return sendJSON(res, 200, { part: part.toUpperCase(), found: false, error: e.message }); }
}
// ---- preview: a single footprint's SVG
if (p === '/api/preview/footprint' && req.method === 'GET') {
const id = u.searchParams.get('id');
let fp = null, lib = null;
for (const L of DB.libs.footprintLibs) { const f = L.footprints.find(x => x.id === id); if (f) { fp = f; lib = L; break; } }
if (!fp) return sendJSON(res, 404, { error: 'footprint not found' });
try {
const svg = fp.added ? await renderFootprintSvg(null, fp.name, fp.rawMod) : await renderFootprintSvg(lib.path, fp.name, null);
return sendJSON(res, 200, { name: lib.nick + ':' + fp.name, svg });
} catch (e) { return sendJSON(res, 500, { error: e.message }); }
}
// ---- preview: a 3D model as GLB (footprint's linked model, or a standalone model). Path ends .glb so Babylon detects it.
if (p === '/api/model.glb' && req.method === 'GET') {
try {
const glb = await resolveGlb({ fpId: u.searchParams.get('footprintId'), modelId: u.searchParams.get('modelId') });
if (!glb) return sendJSON(res, 404, { error: 'no 3D model available' });
const buf = fs.readFileSync(glb);
res.writeHead(200, { 'Content-Type': 'model/gltf-binary', 'Content-Length': buf.length, 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-cache' });
return res.end(buf);
} catch (e) { return sendJSON(res, 500, { error: e.message }); }
}
// ---- open a symbol in the KiCad Symbol Editor (offer to push it to disk first if it isn't there)
if (p === '/api/open-in-kicad' && req.method === 'POST') {
const { symbolId, push } = await readJSON(req);
const hit = findSymbol(symbolId); if (!hit) return sendJSON(res, 404, { error: 'symbol not found' });
const { lib, s } = hit;
try {
const onDisk = await symbolOnDisk(lib.path, s.name);
if (!onDisk && !push) return sendJSON(res, 200, { needsPush: true, lib: lib.nick, symbol: s.name });
if (!onDisk && push) await writeSymLib(lib);
await adomDesktopJSON('kicad_open_symbol_editor', { libraryName: lib.nick, symbolName: s.name });
return sendJSON(res, 200, { ok: true, opened: true, pushed: !onDisk && !!push });
} catch (e) { return sendJSON(res, 500, { error: e.message }); }
}
// ---- clean dead lib-table entries (paths that no longer exist) on demand
if (p === '/api/prune-tables' && req.method === 'POST') {
try { const r = await pruneDeadLibTableEntries(); return sendJSON(res, 200, { ok: true, removed: r.removed }); }
catch (e) { return sendJSON(res, 500, { error: e.message }); }
}
if (p === '/api/sync-library' && req.method === 'POST') {
const { mode, nick } = await readJSON(req);
if (!mode || !nick) return sendJSON(res, 400, { error: 'mode and nick required' });
try { const log = await syncOneLibrary(mode, nick); return sendJSON(res, 200, { ok: true, log }); }
catch (e) { return sendJSON(res, 500, { error: e.message }); }
}
return sendJSON(res, 404, { error: 'no route' });
} catch (e) {
return sendJSON(res, 500, { error: String(e && e.message || e) });
}
});
// rebuild usage + history root on boot if we have persisted libs
if (DB.libs && (DB.libs.symbolLibs.length || DB.libs.footprintLibs.length || DB.libs.models3d.length)) {
reDeriveMpn(); recomputeUsage(); backfillEditedFields(); historyInit(DB.probe && DB.probe.probedAt ? 'restored' : 'initial');
}
server.listen(PORT, () => {
console.log(`kicad-library-manager listening on http://localhost:${PORT}`);
console.log(` data: ${DATA_FILE}`);
});