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.
← Commit history
Publish 1.0.0
9 files changed
+2418
DEVELOPING.mdadded+84@@ -0,0 +1,84 @@+# Developing kicad-library-manager — architecture & gotchas++Read this before extending the app. Everything here is a real lesson from the build.++## Architecture++- **`server.js`** — pure-Node `http` server (no native deps). Serves the SPA + a REST API.+ In-memory `DB` persisted to `data/data.json` (debounced). `DB = { libs, backup, probe, sync }`.+- **`public/index.html`** — the entire frontend: one vanilla-JS SPA, dark Adom theme. Polls+ `GET /api/state` every ~3s and re-renders. Three modes over one parsed model.+- **`cli.js`** — thin wrapper (`serve`, `app`, `probe`).+- No build step. `node server.js` (port 7823, `KLM_PORT`).++### Data model+`libs = { symbolLibs[], footprintLibs[], models3d[], defaultFpLibs[] }`+- `symbolLibs[]`: `{ nick, path, header, inTable, isDefault, symbols[] }`; symbol = `{ id:"nick::name",+ name, ref, value, footprint, fpNick, fpName, datasheet, mpn, mpnField, description, raw, pendingAdd?,+ pendingDelete?, pendingEdit? }`. `raw` is the original `(symbol …)` block — edits are surgical+ (`setSymProp` / `renameSymInBlock`) so sync re-emits faithfully.+- `footprintLibs[]`: `{ nick, path, inTable, isDefault, footprints[] }`; footprint = `{ id, name, file,+ model, models[], usedBy[], useCount, added?, rawMod?, pendingDelete? }`.+- `models3d[]`: `{ id, name, path, folder, usedBy[], useCount, added?, _local?, pendingDelete? }`.++## The desktop bridge (most important)++Barrett's machine is **Linux**, `HOME=/home/barrett-land`. Always reach the filesystem through+`adom-desktop` — never the container FS.++- **This bridge has NO `read_file` / `write_file`** ("Unknown desktop command"). Read via+ `shell_execute` `cat` (text) or `base64 -w0` (binary); write via `send_files` (lands in+ `~/Downloads`) then `mv`. `pull_file` exists under `files` too.+- `shell_execute` runs `sh -c` on the user's machine; `$HOME`, `$DISPLAY` etc. are the user's.+- KiCad GUI bridge verbs (`kicad_open_symbol_editor`, `kicad_install_symbol`, `kicad_screenshot_all`)+ are **Windows/Mac-only** — they return "not implemented on 'linux'". There is no CLI way to open+ the standalone Symbol Editor at a library on Linux, so "open in KiCad" was dropped.+- **Server-restart trap:** killing by `pkill -f server.js` matches the kill command's own shell.+ Kill node procs by walking `/proc/<pid>/cmdline`, or `fuser -k 7823/tcp`, in a SEPARATE command+ from the start. Start detached: `setsid node server.js >log 2>&1 < /dev/null &`.++## Parsing KiCad files++- `.kicad_sym`: `parseSymbolLib` walks top-level `(symbol "…")` blocks at depth 1 (nested units+ `Name_0_1`/`Name_1_1` stay inside their parent). `renameSymInBlock` renames **units first, then the+ exact top-level name** — doing it the other way corrupts `_copy` → `_copy_copy`.+- `.kicad_mod`: only the `(model …)` ref is parsed at probe; full text fetched on demand for preview.+- lib-tables: tolerant regex for `(lib (name …)(uri …))`; `isStockUri` flags KiCad default libs+ (`${KICAD*_*_DIR}`, `/usr/share/kicad`, etc.).+- **LCSC PN only:** `mpnOf` reads only LCSC/JLC properties (or a `C#####` value); `reDeriveMpn` re-runs+ it on boot so rule changes apply without a re-probe.++## Previews (kicad-cli on the desktop)++- Symbol: write the current `(symbol …)` block to a temp `.kicad_sym`, `kicad-cli sym export svg+ --symbol <name>`, `cat` the SVG back. **Recolor** for the dark canvas (`themeSymbolSvg`): light body,+ teal pins, muted text, transparent fills.+- Footprint: `kicad-cli fp export svg --layers F.CrtYd,F.Fab,F.SilkS,F.Paste,F.Mask,F.Cu` — kicad-cli+ honors the layer ORDER, so courtyard sits below silk and **pads render on top of the gray lines**.+ `liftFootprintText` then moves the `<g class="stroked-text">` groups (pad numbers / ref / value)+ above everything so the numbers sit on the pads.+- 3D: resolve the footprint's STEP model (expand `${KICAD*_3DMODEL_DIR}` → `/usr/share/kicad/3dmodels`),+ `cat` it from the desktop (STEP is ASCII), `step2glb convert` → GLB, serve at `/api/model.glb` (path+ ends `.glb` so Babylon detects the loader). Viewer is the Adom Babylon bundle loaded by **dynamic+ `import()`** (it's an ES module with relative chunk imports + CORS `*` — a classic `<script>` tag+ silently fails). All previews cached in-memory.+- Live LCSC stock/price: `/api/lcsc?part=C#####` → the shared JLCPCB service (`JLCPCB_API`, default the+ `john/service-jlcpcb` URL); hover popover in symbol mode.++## Pending model + sync++- Delete **flags** (`pendingDelete`, toggles) instead of removing; add sets `pendingAdd`/`added`; edit+ sets `pendingEdit`. The header chip shows `N new · N deleted · N changed`. All flags live in `DB.libs`+ so they're covered by the undo snapshots.+- **Sync is a repeatable one-shot** (no longer a stop/undo toggle): writes symbol libs+ (`buildSymLibText` excludes `pendingDelete`), writes added footprints/models, trashes flagged files to+ a reversible `.klm-trash/`, unregisters flagged whole libraries from the lib-tables, then+ `pruneAfterSync` drops deleted items + clears flags, and resets the button to idle.+- **Backup** (immutable snapshot at probe) powers Revert. Undo/redo is a snapshot tree (`HIST`,+ in-memory; full-libs snapshots incl. pending-trash so undo reverses a queued delete).+- ⚠️ Never clobber a user lib: writes go through `.klm.bak` backups; deletions are reversible (trash).++## Publishing++`adompkg publish --org adom`. The `files` allowlist EXCLUDES `data/` (the user's library data must+never ship). Hero is a billboard composed from `docs/shot.png`.
SKILL.mdadded+53@@ -0,0 +1,53 @@+---+name: kicad-library-manager+description: >+ Organize the user's personal KiCad libraries fast. Probes their machine (via adom-desktop) for the+ sym-lib-table / fp-lib-table and personal symbol/footprint/3D libraries, then shows three views —+ Symbol priority (inline-editable Ref / Footprint / Datasheet / LCSC PN / Description, footprint+ autocomplete, live JLCPCB stock+price on hover), Footprint priority (3D model + symbols-using-it),+ and 3D-model priority. Add from the Adom wiki or blank, duplicate symbols, flag deletes / adds with+ an undo-redo tree, preview symbol+footprint+3D (kicad-cli render + Babylon viewer), and sync to disk+ with reversible backups. Trigger words — kicad library manager, organize my kicad libraries, manage+ symbol/footprint libraries, view my libraries, edit symbol fields, library cleanup, klm.+---++# kicad-library-manager++A webview workbench for a user's **personal KiCad libraries**. It does not scrape vendors or+generate CAD from scratch — it reads what's already on the user's machine, lets them organize and+edit it, and writes changes back to disk.++## Running++```+node ~/.claude/skills/kicad-library-manager/server.js # or: kicad-library-manager serve+kicad-library-manager app # open as a Hydrogen webview tab (alias: klm)+```++Runs a pure-Node HTTP server on `:7823` (`KLM_PORT` to override) serving a single-file SPA.++## What it does++- **Probe** (via `adom-desktop shell_execute`): finds `sym-lib-table` / `fp-lib-table` and the user's+ personal `.kicad_sym` / `.pretty` / 3D model files; parses symbols, footprints and 3D models and+ builds the symbol↔footprint↔model usage graph. Keeps an immutable backup of the initial state.+- **Three modes:** Symbol priority (all fields inline-editable, footprint autocomplete, LCSC+ stock/price on hover), Footprint priority (3D model + the symbols using each footprint), 3D-model+ priority (grouped by folder).+- **Add / duplicate:** add a part from the Adom wiki (chip-fetcher style) or a blank symbol/footprint;+ duplicate a symbol.+- **Flags + undo:** delete flags a row red, add shows green, applied on Sync; full undo/redo tree+ (Ctrl+Z / Ctrl+Shift+Z), revert-to-backup.+- **Preview:** symbol + linked footprint rendered authoritatively by `kicad-cli` (recolored for a dark+ canvas, pad numbers on top); footprints get a live 3D model (STEP→GLB via step2glb, shown in the+ Adom Babylon viewer); standalone 3D-model preview.+- **Sync:** writes edits/additions/deletions to disk with `.klm.bak` backups + a reversible+ `.klm-trash/`; per-library or all-at-once; unregisters deleted libraries from the lib-tables.++## Dependencies on the host++- **adom-desktop** bridge (filesystem + shell on the user's machine).+- **kicad-cli** (KiCad 9) on the desktop — symbol/footprint SVG previews.+- **step2glb** + the JLCPCB search service — 3D conversion and live stock/price (graceful if absent).++See **DEVELOPING.md** for architecture and every hard-won gotcha.
cli.jsadded+37@@ -0,0 +1,37 @@+#!/usr/bin/env node+// Thin CLI wrapper for kicad-library-manager.+// klm serve start the server (port 7823, KLM_PORT to override)+// klm app open it as a Hydrogen webview (best-effort)+// klm probe kick a library probe via the running server+'use strict';+const { spawn, execFile } = require('child_process');+const path = require('path');+const http = require('http');++const ROOT = __dirname;+const PORT = parseInt(process.env.KLM_PORT || '7823', 10);+const cmd = process.argv[2] || 'serve';++function post(p, body) {+ return new Promise((resolve, reject) => {+ const data = Buffer.from(JSON.stringify(body || {}));+ const req = http.request({ host: '127.0.0.1', port: PORT, path: p, method: 'POST',+ headers: { 'Content-Type': 'application/json', 'Content-Length': data.length } },+ res => { let b = ''; res.on('data', d => b += d); res.on('end', () => resolve(b)); });+ req.on('error', reject); req.write(data); req.end();+ });+}++if (cmd === 'serve' || cmd === 'start') {+ const p = spawn(process.execPath, [path.join(ROOT, 'server.js')], { stdio: 'inherit', env: process.env });+ p.on('exit', code => process.exit(code || 0));+} else if (cmd === 'probe') {+ post('/api/probe-request', { edas: ['kicad'] }).then(r => { console.log(r); }).catch(e => { console.error(e.message); process.exit(1); });+} else if (cmd === 'app') {+ const url = `http://127.0.0.1:${PORT}/`;+ execFile('adom-cli', ['hydrogen', 'webview', 'open-or-refresh', '--name', 'KiCad Library Manager', '--url', url],+ (err, out) => { if (err) { console.error('Could not open webview:', err.message); console.log('Open manually:', url); } else console.log(out || ('Opened ' + url)); });+} else {+ console.log('usage: klm [serve|app|probe]');+ process.exit(1);+}
docs/shot.pngadded⋯ 1 unchanged line ⋯
install.shadded+16@@ -0,0 +1,16 @@+#!/bin/bash+set -e+PKG_DIR="$(cd "$(dirname "$0")" && pwd)"+DEST="$HOME/.claude/skills/kicad-library-manager"+mkdir -p "$DEST"+cp -r "$PKG_DIR/server.js" "$PKG_DIR/cli.js" "$PKG_DIR/public" "$PKG_DIR/SKILL.md" "$DEST/" 2>/dev/null || true+[ -f "$PKG_DIR/README.md" ] && cp "$PKG_DIR/README.md" "$DEST/README.md"+# expose the CLI+mkdir -p "$HOME/.local/bin"+cat > "$HOME/.local/bin/kicad-library-manager" <<EOF+#!/bin/bash+exec node "$DEST/cli.js" "\$@"+EOF+chmod +x "$HOME/.local/bin/kicad-library-manager"+ln -sf "$HOME/.local/bin/kicad-library-manager" "$HOME/.local/bin/klm" 2>/dev/null || true+echo "Installed kicad-library-manager (run: kicad-library-manager serve | klm app)"
package.jsonadded+35@@ -0,0 +1,35 @@+{+ "schema_version": 1,+ "type": "app",+ "slug": "kicad-library-manager",+ "title": "KiCad Library Manager",+ "brief": "Organize your KiCad symbol / footprint / 3D libraries fast — three priority views, inline editing, live previews, and one-click sync.",+ "version": "1.0.0",+ "description": "A fast workbench for your personal KiCad libraries. Probes your machine via adom-desktop for your sym-lib-table / fp-lib-table, then gives you three views — Symbol priority (inline-editable ref / footprint / datasheet / LCSC PN / description, with footprint autocomplete and live JLCPCB stock+price on hover), Footprint priority (3D model + the symbols using each footprint), and 3D-model priority. Add parts from the Adom wiki or blank, duplicate symbols, flag deletes (red) and adds (green) with an undo/redo tree, preview symbols + footprints + 3D models with kicad-cli rendering, and sync changes back to disk with reversible backups.",+ "dependencies": {},+ "tags": ["eda", "kicad", "library", "symbol", "footprint", "3d-model", "jlcpcb", "lcsc", "cad", "library-manager", "adom-desktop", "preview"],+ "hero": {+ "type": "billboard",+ "headline": "KiCad Library Manager",+ "subhead": "Three priority views, inline editing, live previews, one-click sync to disk.",+ "screenshot": "docs/shot.png"+ },+ "files": [+ "server.js",+ "cli.js",+ "public/index.html",+ "page.json",+ "SKILL.md",+ "README.md",+ "DEVELOPING.md",+ "install.sh",+ "uninstall.sh",+ "docs/shot.png",+ "package.json"+ ],+ "scripts": {+ "start": "node server.js",+ "install": "./install.sh",+ "uninstall": "./uninstall.sh"+ }+}
public/index.htmladded+919@@ -0,0 +1,919 @@+<!DOCTYPE html>+<html lang="en">+<head>+<meta charset="utf-8" />+<meta name="viewport" content="width=device-width, initial-scale=1" />+<title>KiCad Library Manager</title>+<style>+ /* Adom brand fonts — Familjen Grotesk (headlines), Satoshi (body), JetBrains Mono (code). */+ @font-face{font-family:'Familjen Grotesk';src:url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2') format('woff2');font-weight:700;font-display:swap}+ @font-face{font-family:'Familjen Grotesk';src:url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2') format('woff2');font-weight:500;font-display:swap}+ @font-face{font-family:'Satoshi';src:url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2');font-weight:400;font-display:swap}+ @font-face{font-family:'Satoshi';src:url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2');font-weight:500;font-display:swap}+ @font-face{font-family:'JetBrains Mono';src:url('https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx3cwhsk.woff2') format('woff2');font-weight:400;font-display:swap}+ :root{+ --bg:#0d1117; --surface:#161b22; --elevated:#1c2128; --overlay:#21262d;+ --border:#30363d; --border-muted:#21262d;+ --text:#e6edf3; --text-muted:#8b949e; --text-dim:#484f58;+ --accent:#00b8b0; --accent-bright:#00e6dc; --accent-dim:rgba(0,184,176,.12); --accent-border:rgba(0,184,176,.25);+ --green:#3fb950; --green-dim:rgba(63,185,80,.14);+ --yellow:#d29922; --yellow-dim:rgba(210,153,34,.14);+ --red:#f85149; --red-dim:rgba(248,81,73,.14);+ --purple:#8C6BF7; --purple-dim:rgba(140,107,247,.16); --row-hover:#1c2128;+ }+ *{box-sizing:border-box}+ html{height:100%}+ body{margin:0;min-height:100%;background:var(--bg);color:var(--text);font-family:'Satoshi',-apple-system,Segoe UI,Roboto,sans-serif;font-size:13px}+ .mono{font-family:'JetBrains Mono',ui-monospace,Menlo,Consolas,monospace}+ h1,h2,h3{font-family:'Familjen Grotesk',sans-serif;margin:0}+ button{font-family:inherit;cursor:pointer}++ .app-header{display:flex;align-items:center;gap:12px;height:46px;padding:0 14px;background:var(--surface);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:30}+ .brand{display:flex;align-items:center;gap:9px}+ .logo-chip{display:block;color:var(--accent-bright)}+ .app-name{font-family:'Familjen Grotesk',sans-serif;font-weight:600;font-size:15px;letter-spacing:.2px;color:var(--accent-bright)}+ .subject{margin-left:2px;color:var(--text-muted);font-size:12px}+ .subject b{color:var(--text);font-weight:600}+ .spacer{flex:1}+ .lib-status{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--text-muted)}+ .lib-status .dot{width:8px;height:8px;border-radius:50%;background:var(--text-dim);flex:none}+ .lib-status.ok .dot{background:var(--green);box-shadow:0 0 6px var(--green)}+ .lib-status.warn .dot{background:var(--yellow)}+ .lib-status.none .dot{background:var(--red)}++ .btn{background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:7px 12px;font-size:12.5px;font-weight:600;transition:.15s;white-space:nowrap}+ .btn:hover{border-color:var(--accent-border);background:var(--elevated)}+ .btn.primary{background:var(--accent);border-color:var(--accent);color:#04231f}+ .btn.primary:hover{background:var(--accent-bright)}+ .btn.danger:hover{border-color:var(--red);color:var(--red)}+ .btn.warn{border-color:rgba(210,153,34,.4)}+ .btn:disabled{opacity:.4;cursor:not-allowed}+ .btn.on{background:var(--accent-dim);border-color:var(--accent-border);color:var(--text)}+ .btn.syncing{background:var(--yellow-dim);border-color:var(--yellow);color:var(--text);animation:pulse 1.6s ease-in-out infinite}+ @keyframes pulse{50%{opacity:.6}}+ .icon-btn{background:var(--surface);border:1px solid var(--border);color:var(--text-muted);border-radius:6px;width:32px;height:30px;display:inline-flex;align-items:center;justify-content:center;font-size:14px;font-weight:600}+ .icon-btn:hover{border-color:var(--accent-border);color:var(--text)}+ .icon-btn:disabled{opacity:.3;cursor:not-allowed}+ .icon-btn:disabled:hover{border-color:var(--border);color:var(--text-muted)}+ .hist-group{display:inline-flex;gap:4px}++ .controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:9px 14px;background:var(--bg);border-bottom:1px solid var(--border-muted);position:sticky;top:var(--controls-top,46px);z-index:20}+ .pills{display:flex;gap:6px;background:var(--surface);border:1px solid var(--border);border-radius:9px;padding:3px}+ .pill{background:transparent;border:1px solid transparent;color:var(--text-muted);border-radius:7px;padding:6px 13px;font-size:12.5px;font-weight:600}+ .pill.active{color:var(--text);background:var(--accent-dim);border-color:var(--accent-border)}+ .pill:hover:not(.active){color:var(--text)}+ .search{flex:1;min-width:200px;max-width:420px}+ input[type=text],input.cell{background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:7px 10px;font-size:13px;width:100%}+ input[type=text]:focus,input.cell:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-dim)}+ .seltools{display:flex;align-items:center;gap:8px}+ .selcount{font-size:12px;color:var(--text-muted);min-width:84px}++ .wrap{padding:0 0 80px}+ table{width:100%;border-collapse:collapse}+ thead th{position:sticky;top:var(--stick-top,94px);z-index:10;background:var(--surface);color:var(--text-muted);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.5px;text-align:left;padding:8px 12px;border-bottom:1px solid var(--border)}+ .cat-row td{background:var(--elevated);border-top:1px solid var(--border);border-bottom:1px solid var(--border-muted);padding:6px 12px}+ .cat-name{font-family:'Satoshi',sans-serif;font-weight:600;font-size:12px;letter-spacing:.5px;color:var(--accent-bright)}+ .cat-path{color:var(--text-dim);font-size:10.5px;margin-left:9px}+ .cat-count{color:var(--text-dim);font-size:11px;margin-left:8px}+ .catwrap{display:flex;align-items:center;gap:0}+ .lib-sync{margin-left:auto;background:transparent;border:1px solid var(--border);color:var(--text-muted);border-radius:6px;padding:3px 10px;font-size:11px;font-weight:600;opacity:0;transition:.15s}+ .lib-del{margin-left:6px;background:transparent;border:1px solid var(--border);color:var(--text-muted);border-radius:6px;padding:3px 10px;font-size:11px;font-weight:600;opacity:0;transition:.15s}+ .cat-row:hover .lib-sync,.cat-row:hover .lib-del{opacity:1}+ .lib-sync:hover{border-color:var(--accent);color:var(--accent-bright);background:var(--accent-dim)}+ .lib-sync:disabled{opacity:.6;cursor:not-allowed}+ .lib-del:hover{border-color:var(--red);color:var(--red);background:var(--red-dim)}+ .flag{display:inline-block;font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;border-radius:4px;padding:1px 6px;margin-left:8px;vertical-align:1px}+ .flag.def{color:var(--purple);border:1px solid rgba(140,107,247,.45);background:var(--purple-dim)}+ .flag.warn{color:var(--yellow);border:1px solid rgba(210,153,34,.45);background:var(--yellow-dim)}+ .namewrap .flag{margin-left:6px}+ tbody tr.item{border-bottom:1px solid var(--border-muted)}+ tbody tr.item:hover{background:var(--row-hover)}+ tbody tr.item.sel{background:var(--accent-dim);box-shadow:inset 3px 0 0 var(--accent)}+ tbody tr.item.pending-add{background:var(--green-dim);box-shadow:inset 3px 0 0 var(--green)}+ tbody tr.item.pending-del{background:var(--red-dim);box-shadow:inset 3px 0 0 var(--red)}+ tbody tr.item.pending-del td .cell,tbody tr.item.pending-del td .model-name,tbody tr.item.pending-del td .name{opacity:.6}+ .cat-row.cat-del .cat-name{color:var(--red)}+ .pending-chip{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;font-weight:600;padding:3px 9px;border-radius:999px;border:1px solid var(--accent-border);background:var(--accent-dim);color:var(--text)}+ .pending-chip .pa{color:var(--green)} .pending-chip .pd{color:var(--red)} .pending-chip .pc{color:var(--yellow)}+ td{padding:6px 12px;vertical-align:middle}+ td.sel-col{width:34px;text-align:center}+ input[type=checkbox]{accent-color:var(--accent);width:15px;height:15px;cursor:pointer}+ .name{font-weight:600;font-family:'JetBrains Mono',monospace;font-size:12.5px}+ input.cell{padding:5px 8px;font-size:12px;font-family:'JetBrains Mono',ui-monospace,monospace}+ input.cell.ref{width:64px;text-align:center}+ .lcsc-pop{position:fixed;z-index:85;display:none;min-width:220px;max-width:300px;background:var(--overlay);border:1px solid var(--border);border-radius:9px;box-shadow:0 12px 34px rgba(0,0,0,.55);padding:10px 12px;font-size:12px}+ .lcsc-pop.show{display:block}+ .lcsc-hd{display:flex;align-items:center;gap:7px;font-family:'JetBrains Mono',monospace;font-weight:600;color:var(--accent-bright);margin-bottom:7px}+ .lcsc-hd .badge{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;border-radius:4px;padding:1px 5px}+ .lcsc-hd .badge.basic{color:var(--green);border:1px solid var(--green)}+ .lcsc-hd .badge.pref{color:var(--accent-bright);border:1px solid var(--accent-border)}+ .lcsc-hd .badge.ext{color:var(--text-dim);border:1px solid var(--border)}+ .lcsc-row{display:flex;justify-content:space-between;gap:14px;padding:2px 0;color:var(--text-muted)}+ .lcsc-row b{color:var(--text);font-family:'JetBrains Mono',monospace}+ .lcsc-row b.stock{color:var(--green)} .lcsc-row b.nostock{color:var(--red)}+ .lcsc-tiers{margin-top:5px;padding-top:6px;border-top:1px solid var(--border-muted);color:var(--text-dim);font-size:10.5px;line-height:1.6;font-family:'JetBrains Mono',monospace}+ .lcsc-desc{margin-top:6px;color:var(--text-muted);font-size:11px;line-height:1.4}+ .lcsc-link{display:inline-block;margin-top:7px;color:var(--accent);font-size:11px;text-decoration:none}+ .lcsc-link:hover{text-decoration:underline}+ .fp-auto{position:fixed;z-index:80;background:var(--overlay);border:1px solid var(--border);border-radius:9px;box-shadow:0 12px 34px rgba(0,0,0,.55);padding:5px;max-height:320px;overflow:auto}+ .fp-hd{font-size:10px;text-transform:uppercase;letter-spacing:.4px;color:var(--text-dim);padding:5px 8px 4px}+ .fp-opt{display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:6px;cursor:pointer}+ .fp-opt:hover,.fp-opt.on{background:var(--accent-dim)}+ .fp-nm{font-size:12px;color:var(--text)}+ .fp-tag{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;border-radius:4px;padding:0 5px;margin-left:auto;white-space:nowrap}+ .fp-tag.best{color:var(--accent-bright);border:1px solid var(--accent-border);background:var(--accent-dim)}+ .fp-tag.ext{color:var(--text-dim);border:1px solid var(--border)}+ input.cell.fpdef{background:rgba(140,107,247,.09);border-color:rgba(140,107,247,.22)}+ input.cell.fpdef:focus{border-color:rgba(140,107,247,.5);box-shadow:0 0 0 2px rgba(140,107,247,.14)}+ input.cell.short{max-width:150px}+ td.col-ref{width:70px}+ th.col-ds,td.col-ds{width:46px;text-align:center}+ .ds-icon{display:inline-flex;align-items:center;justify-content:center;width:28px;height:26px;border:1px solid var(--border);border-radius:6px;color:var(--text-dim);cursor:pointer;background:var(--surface)}+ .ds-icon.on{color:var(--green);border-color:rgba(63,185,80,.4)}+ .ds-icon:hover{border-color:var(--accent-border);background:var(--elevated)}+ .link{color:var(--accent);text-decoration:none}+ .link:hover{text-decoration:underline}+ .muted{color:var(--text-dim)}+ .pill-count{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--border);background:var(--surface);border-radius:20px;padding:3px 10px;font-size:12px;font-weight:600;cursor:pointer;color:var(--text)}+ .pill-count:hover{border-color:var(--accent-border)}+ .pill-count.zero{color:var(--text-dim);cursor:default}+ .pill-count .car{font-size:9px;transition:.15s}+ tr.subrow td{background:#10151c;padding:0 12px 0 46px;border-bottom:1px solid var(--border-muted)}+ .userlist{display:flex;flex-direction:column;gap:0;padding:6px 0}+ .userlist .u{display:flex;align-items:center;gap:10px;padding:5px 8px;border-radius:6px;font-size:12px}+ .userlist .u:hover{background:var(--row-hover)}+ .userlist .u .ref{font-family:'JetBrains Mono',monospace;color:var(--accent);width:36px}+ .userlist .u .nm{font-family:'JetBrains Mono',monospace;color:var(--text)}+ .userlist .u .lb{color:var(--text-dim);margin-left:auto;font-size:11px}+ .namewrap{display:flex;align-items:center;gap:5px}+ .dup-btn{flex:none;border:1px solid var(--border);background:var(--surface);color:var(--text-muted);border-radius:5px;width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;font-size:12px}+ .dup-btn:hover{border-color:var(--accent-border);color:var(--accent-bright)}+ .tabs{display:flex;gap:4px;margin-bottom:16px;background:var(--surface);border:1px solid var(--border);border-radius:9px;padding:3px}+ .tab{flex:1;background:transparent;border:1px solid transparent;color:var(--text-muted);border-radius:7px;padding:8px 12px;font-size:13px;font-weight:600}+ .tab.active{color:var(--text);background:var(--accent-dim);border-color:var(--accent-border)}+ .tabpane{display:none;flex-direction:column;gap:11px}+ .tabpane.active{display:flex}+ .field{display:flex;flex-direction:column;gap:5px}+ .field label{font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--text-dim)}+ .field select,.field input[type=text]{background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:8px 10px;font-size:13px;width:100%}+ .field select:focus,.field input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-dim)}+ .addmsg{font-size:12px;min-height:16px;color:var(--text-muted)}+ .addmsg.err{color:var(--red)} .addmsg.ok{color:var(--green)}+ .desc-cell{max-width:280px;display:flex;align-items:center;gap:6px}+ .desc-text{color:var(--text-muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}+ .desc-exp{flex:none;border:1px solid var(--border);background:var(--surface);color:var(--text-muted);border-radius:5px;width:22px;height:22px;display:inline-flex;align-items:center;justify-content:center;font-size:11px}+ .desc-exp:hover{border-color:var(--accent-border);color:var(--text)}+ .model-name{font-family:'JetBrains Mono',monospace;font-size:12.5px;font-weight:600}+ .modeltag{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;border-radius:4px;padding:1px 5px;margin-left:7px;border:1px solid var(--border);color:var(--text-dim)}+ .modeltag.step{color:var(--accent-bright);border-color:var(--accent-border)}+ .modeltag.wrl{color:var(--purple);border-color:rgba(140,107,247,.4)}+ .nomodel{color:var(--text-dim);font-style:italic;font-size:12px}++ .empty-state{text-align:center;color:var(--text-muted);padding:90px 20px}+ .empty-state h2{font-size:19px;margin-bottom:8px;color:var(--text)}+ .empty-state .big{font-size:34px;margin-bottom:14px}++ /* opaque tints: stack the translucent colour over an opaque base so table rows don't show through the sticky bar */+ .sync-banner{display:none;align-items:center;gap:10px;padding:8px 14px;background:linear-gradient(var(--yellow-dim),var(--yellow-dim)),var(--surface);border-bottom:1px solid rgba(210,153,34,.4);font-size:12.5px;color:var(--text);position:sticky;top:var(--header-h,46px);z-index:25}+ .sync-banner.show{display:flex}+ .sync-banner.synced{background:linear-gradient(var(--green-dim),var(--green-dim)),var(--surface);border-color:var(--green)}+ .sync-banner.errbar{background:linear-gradient(var(--red-dim),var(--red-dim)),var(--surface);border-color:var(--red)}+ .sync-banner .bmsg{flex:1;display:flex;align-items:center;gap:8px;min-width:0}+ .sync-banner .log{color:var(--text-muted);font-size:11.5px;margin-left:auto;max-width:55%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}+ .banner-x{flex:none;background:transparent;border:1px solid transparent;color:var(--text-muted);border-radius:6px;width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;font-size:13px;line-height:1}+ .banner-x:hover{border-color:var(--border);color:var(--text);background:rgba(255,255,255,.06)}++ .modal-bg{position:fixed;inset:0;background:rgba(1,4,9,.7);backdrop-filter:blur(6px);display:none;align-items:center;justify-content:center;z-index:50}+ .modal-bg.show{display:flex}+ .modal{background:var(--overlay);border:1px solid var(--border);border-radius:12px;padding:22px;width:560px;max-width:92vw;box-shadow:0 20px 60px rgba(0,0,0,.5)}+ .modal h2{font-size:17px;margin-bottom:6px}+ .modal p{color:var(--text-muted);margin:0 0 16px;line-height:1.5}+ .modal textarea{width:100%;min-height:150px;background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:11px;font-size:13px;font-family:'JetBrains Mono',monospace;line-height:1.5;resize:vertical}+ .modal textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-dim)}+ .modal .descmeta{font-size:11px;color:var(--text-dim);margin:0 0 8px}+ .modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}+ .modal .eda-pick{display:flex;flex-direction:column;gap:10px;margin-bottom:6px}+ .modal .eda-pick label{display:flex;align-items:center;gap:9px;padding:10px 12px;border:1px solid var(--border);border-radius:8px;cursor:pointer;font-size:13px}+ .modal .eda-pick label:hover{border-color:var(--accent-border);background:var(--accent-dim)}++ /* preview dock — styled like the wiki component-page viewer (toolbar + gridded dark canvas) */+ #previewDock{position:fixed;left:0;right:0;bottom:0;height:46vh;background:var(--bg);border-top:1px solid var(--border);z-index:45;display:none;flex-direction:column;box-shadow:0 -14px 44px rgba(0,0,0,.55)}+ #previewDock.show{display:flex}+ body.pv-open .wrap{padding-bottom:49vh}+ .pv-bar{display:flex;align-items:center;gap:10px;padding:8px 14px;background:var(--surface);border-bottom:1px solid var(--border);flex-shrink:0}+ .pv-title{font-family:'Familjen Grotesk',sans-serif;font-weight:600;color:var(--accent-bright);font-size:14px}+ .pv-sub{color:var(--text-muted);font-size:12px;font-family:'JetBrains Mono',monospace}+ .pv-body{flex:1;display:flex;min-height:0}+ .pv-pane{flex:1;display:flex;flex-direction:column;min-width:0;border-right:1px solid var(--border)}+ .pv-pane:last-child{border-right:none}+ .pv-pane-hd{display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--surface);border-bottom:1px solid var(--border-muted);font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted);flex-shrink:0}+ .pv-pane-hd .pn{color:var(--text);text-transform:none;letter-spacing:0;font-family:'JetBrains Mono',monospace;font-size:11px;margin-left:auto}+ .pv-canvas{flex:1;position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;padding:22px;background-color:#0d1117;background-image:radial-gradient(ellipse at 50% 50%,rgba(0,184,176,.045) 0%,transparent 70%),linear-gradient(rgba(255,255,255,.016) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.016) 1px,transparent 1px);background-size:100% 100%,20px 20px,20px 20px}+ .pv-canvas svg{width:100%;height:100%;display:block}+ .pv-canvas.is3d{padding:0}+ .pv-pane.hide{display:none}+ .pv-empty{color:var(--text-dim);font-size:13px;font-style:italic}+ .pv-foot{display:flex;align-items:center;gap:12px;padding:9px 14px;background:var(--surface);border-top:1px solid var(--border);flex-shrink:0}+ .pv-foot .pv-sub{font-family:'Satoshi',sans-serif}+ #toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:var(--elevated);border:1px solid var(--accent-border);color:var(--text);padding:10px 16px;border-radius:9px;font-size:12.5px;z-index:60;opacity:0;transition:.2s;pointer-events:none;max-width:80vw}+ #toast.show{opacity:1}+ .spin{width:13px;height:13px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;display:inline-block;animation:sp .7s linear infinite;vertical-align:-2px}+ @keyframes sp{to{transform:rotate(360deg)}}+</style>+</head>+<body>+ <header class="app-header">+ <div class="brand">+ <svg class="logo-chip" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">+ <rect x="7" y="7" width="10" height="10" rx="1.5"/><rect x="10.5" y="10.5" width="3" height="3" rx=".5"/>+ <line x1="10" y1="7" x2="10" y2="4"/><line x1="14" y1="7" x2="14" y2="4"/>+ <line x1="10" y1="17" x2="10" y2="20"/><line x1="14" y1="17" x2="14" y2="20"/>+ <line x1="7" y1="10" x2="4" y2="10"/><line x1="7" y1="14" x2="4" y2="14"/>+ <line x1="17" y1="10" x2="20" y2="10"/><line x1="17" y1="14" x2="20" y2="14"/>+ </svg>+ <span class="app-name">KiCad Library Manager</span>+ </div>+ <span class="subject" id="summary"></span>+ <span class="spacer"></span>+ <span class="lib-status" id="libStatus"><span class="dot"></span><span class="txt">not probed</span></span>+ <div class="hist-group">+ <button class="icon-btn" id="undoBtn" title="Undo (Ctrl+Z)" disabled>↶</button>+ <button class="icon-btn" id="redoBtn" title="Redo (Ctrl+Shift+Z)" disabled>↷</button>+ </div>+ <button class="btn warn" id="revertBtn" title="Revert all libraries to the initial probed state" disabled>⟲ Revert to backup</button>+ <span class="pending-chip" id="pendingChip" style="display:none"></span>+ <button class="btn primary" id="syncBtn" title="Write ALL your edits to disk">⇅ Sync all to disk</button>+ <button class="btn" id="probeBtn" title="Scan your computer for KiCad libraries">◎ Probe libraries</button>+ </header>++ <div class="sync-banner" id="syncBanner"></div>++ <div class="controls">+ <div class="pills" id="modePills">+ <button class="pill active" data-mode="symbol">◈ Symbols</button>+ <button class="pill" data-mode="footprint">▣ Footprints</button>+ <button class="pill" data-mode="model3d">⬡ 3D Models</button>+ </div>+ <input type="text" class="search" id="search" placeholder="Filter…" autocomplete="off" />+ <button class="btn primary" id="addBtn" title="Add a component from the Adom wiki, or a blank symbol/footprint">+ Add</button>+ <span class="spacer"></span>+ <div class="seltools">+ <button class="btn" id="hideFlaggedBtn" title="Hide rows flagged for deletion">⦸ Hide flagged</button>+ <span class="selcount" id="selCount">0 selected</span>+ <button class="btn" id="selectAll">✓ Select all</button>+ <button class="btn danger" id="deleteBtn" disabled>✕ Delete</button>+ </div>+ </div>++ <div class="wrap">+ <table id="tbl"><thead id="thead"></thead><tbody id="tbody"></tbody></table>+ <div class="empty-state" id="empty" style="display:none">+ <div class="big">◎</div>+ <h2>No libraries loaded yet</h2>+ <div>Click <b>Probe libraries</b> and I'll scan your computer (via adom-desktop) for your KiCad<br>symbol & footprint tables and your personal libraries.</div>+ </div>+ </div>++ <!-- probe modal -->+ <div class="modal-bg" id="probeModal">+ <div class="modal">+ <h2>Probe your KiCad libraries?</h2>+ <p>I'll scan your computer for your <b>sym-lib-table</b> / <b>fp-lib-table</b> and your personal symbol, footprint and 3D-model libraries, then load them here. Nothing is changed on disk until you press <b>Sync</b>.</p>+ <div class="modal-actions">+ <button class="btn" id="probeSkip">Not now</button>+ <button class="btn primary" id="probeGo">⌖ Probe my libraries</button>+ </div>+ </div>+ </div>++ <!-- add modal -->+ <div class="modal-bg" id="addModal">+ <div class="modal">+ <h2>Add a component</h2>+ <div class="tabs">+ <button class="tab active" data-tab="wiki">⌕ From Adom wiki</button>+ <button class="tab" data-tab="blank">+ Blank entry</button>+ </div>+ <div class="tabpane active" id="pane-wiki">+ <p style="margin:0 0 4px">Fetch an MPN's symbol, footprint & 3D model from the Adom wiki and file them into your libraries. Written to disk on <b>Sync</b>.</p>+ <div class="field"><label>MPN / part number</label><input type="text" id="wMpn" placeholder="e.g. STM32G473CEU6" autocomplete="off"></div>+ <div class="field"><label>Target symbol library</label><select id="wSymLib"></select></div>+ <div class="field"><label>Target footprint library</label><select id="wFpLib"></select></div>+ <div class="addmsg" id="wMsg"></div>+ <div class="modal-actions"><button class="btn" id="addClose1">Close</button><button class="btn primary" id="wGo">⌕ Fetch & add</button></div>+ </div>+ <div class="tabpane" id="pane-blank">+ <p style="margin:0 0 4px">Create an empty symbol or footprint in a library, then fill in the fields inline.</p>+ <div class="field"><label>Kind</label><select id="bKind"><option value="symbol">Symbol</option><option value="footprint">Footprint</option></select></div>+ <div class="field"><label>Target library</label><select id="bLib"></select></div>+ <div class="field"><label>Name</label><input type="text" id="bName" placeholder="e.g. MyPart_QFN48" autocomplete="off"></div>+ <div class="addmsg" id="bMsg"></div>+ <div class="modal-actions"><button class="btn" id="addClose2">Close</button><button class="btn primary" id="bGo">+ Create</button></div>+ </div>+ </div>+ </div>++ <!-- datasheet modal -->+ <div class="modal-bg" id="dsModal">+ <div class="modal" style="width:480px">+ <h2 id="dsTitle">Datasheet</h2>+ <p class="descmeta" id="dsMeta"></p>+ <div class="field"><label>Datasheet URL / path</label><input type="text" id="dsInput" placeholder="https://…" spellcheck="false"></div>+ <div class="modal-actions">+ <button class="btn" id="dsCancel">Close</button>+ <a class="btn" id="dsOpen" target="_blank" rel="noopener" style="display:none;text-decoration:none">↗ Open</a>+ <button class="btn primary" id="dsSave">Save</button>+ </div>+ </div>+ </div>++ <!-- description modal -->+ <div class="modal-bg" id="descModal">+ <div class="modal">+ <h2 id="descTitle">Description</h2>+ <p class="descmeta" id="descMeta"></p>+ <textarea id="descText" spellcheck="false"></textarea>+ <div class="modal-actions">+ <button class="btn" id="descCancel">Close</button>+ <button class="btn primary" id="descSave">Save</button>+ </div>+ </div>+ </div>++ <!-- preview dock — symbol (left) + linked footprint (right), styled like the wiki viewer -->+ <div id="previewDock">+ <div class="pv-bar">+ <span class="pv-title">Preview</span>+ <span class="pv-sub" id="pvSub"></span>+ <span class="spacer"></span>+ <button class="banner-x" id="pvClose" title="Close preview">✕</button>+ </div>+ <div class="pv-body">+ <div class="pv-pane" id="pvPaneLeft"><div class="pv-pane-hd"><span id="pvLeftHd">Schematic Symbol</span> <span class="pn" id="pvLeftName"></span></div><div class="pv-canvas" id="pvLeft"></div></div>+ <div class="pv-pane" id="pvPaneRight"><div class="pv-pane-hd"><span id="pvRightHd">PCB Footprint</span> <span class="pn" id="pvRightName"></span></div><div class="pv-canvas" id="pvRight"></div></div>+ </div>+ </div>++ <div id="toast"></div>++<script>+const $ = s => document.querySelector(s);+let STATE = { libs:{symbolLibs:[],footprintLibs:[],models3d:[]}, probe:{status:'idle'}, sync:{active:false}, history:{}, hasBackup:false };+let MODE = 'symbol';+let syncDismiss = null;+let HIDE_FLAGGED = false;+function findItem(mode,id){+ if(mode==='symbol'){ for(const l of STATE.libs.symbolLibs){ const s=l.symbols.find(x=>x.id===id); if(s) return s; } }+ else if(mode==='footprint'){ for(const l of STATE.libs.footprintLibs){ const f=l.footprints.find(x=>x.id===id); if(f) return f; } }+ else { return (STATE.libs.models3d||[]).find(m=>m.id===id); }+ return null;+}+let SEL = { symbol:new Set(), footprint:new Set(), model3d:new Set() };+let EXPANDED = new Set(); // footprint ids whose user-list subrow is open+let descId = null;++function toast(msg){ const t=$('#toast'); t.textContent=msg; t.classList.add('show'); clearTimeout(t._t); t._t=setTimeout(()=>t.classList.remove('show'),2800); }+function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,m=>({'&':'&','<':'<','>':'>','"':'"'}[m])); }+function apiUrl(path){ return new URL('api'+path, document.baseURI).href; }+async function api(path, opts){ const r = await fetch(apiUrl(path), opts); const j = await r.json().catch(()=>({})); if(!r.ok) throw Object.assign(new Error(j.error||r.statusText),{status:r.status}); return j; }++const DS_SVG = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`;+const MAG_SVG = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="10" cy="10" r="7"/><line x1="20" y1="20" x2="15" y2="15"/></svg>`;+function isUrl(v){ return /^(https?:\/\/|www\.|ftp:\/\/)/i.test((v||'').trim()); }+function curSel(){ return SEL[MODE]; }+function modelExt(name){ const e=(name||'').toLowerCase(); if(e.endsWith('.step')||e.endsWith('.stp'))return'step'; if(e.endsWith('.wrl'))return'wrl'; if(e.endsWith('.glb'))return'glb'; if(e.endsWith('.stl'))return'stl'; return''; }++function updateStickyOffsets(){+ const h=$('.app-header'), c=$('.controls'); if(!h||!c) return;+ const hh=h.offsetHeight, ch=c.offsetHeight;+ const sb=$('#syncBanner'); const bh=(sb&&sb.classList.contains('show'))?sb.offsetHeight:0;+ const r=document.documentElement.style;+ r.setProperty('--header-h', hh+'px'); // header sticks at 0+ r.setProperty('--controls-top', (hh+bh)+'px'); // banner sticks under header, controls under banner+ r.setProperty('--stick-top', (hh+bh+ch)+'px'); // column-header row sticks under controls+}+window.addEventListener('resize', updateStickyOffsets);++function updateLibStatus(){+ const el=$('#libStatus'); const pr=STATE.probe||{}; let cls,txt;+ if(pr.status==='probing'){ cls='warn'; txt='probing…'; }+ else if(pr.status==='error'){ cls='none'; txt='probe error'; }+ else if(pr.status==='done'){ const c=pr.counts||{}; cls='ok'; txt=`${c.symbols||0} symbols · ${c.footprints||0} footprints · ${c.models3d||0} models`; }+ else { cls='none'; txt='not probed'; }+ el.className='lib-status '+cls; el.querySelector('.txt').textContent=txt;+}+function updateSummary(){+ const L=STATE.libs;+ const counts={ symbol:L.symbolLibs.reduce((n,l)=>n+l.symbols.length,0), footprint:L.footprintLibs.reduce((n,l)=>n+l.footprints.length,0), model3d:L.models3d.length };+ $('#summary').innerHTML = STATE.probe.status==='done' ? `· <b>${counts[MODE]}</b> ${MODE==='model3d'?'3D models':MODE+'s'}` : '';+}+function updateHistButtons(){+ const h=STATE.history||{};+ $('#undoBtn').disabled=!h.canUndo; $('#redoBtn').disabled=!h.canRedo;+ $('#undoBtn').title = h.canUndo ? `Undo: ${h.undoLabel||''} (Ctrl+Z)` : 'Nothing to undo (Ctrl+Z)';+ $('#redoBtn').title = h.canRedo ? `Redo: ${h.redoLabel||''} (Ctrl+Shift+Z)` : 'Nothing to redo (Ctrl+Shift+Z)';+ $('#revertBtn').disabled=!STATE.hasBackup;+}+function updateSyncUI(){+ const s=STATE.sync||{}; const b=$('#syncBtn'); const banner=$('#syncBanner');+ const running = s.status==='running'||s.status==='reverting';+ b.disabled=running; b.classList.toggle('syncing',running); b.classList.toggle('primary',!running);+ b.textContent = running ? '● Syncing…' : '⇅ Sync all to disk';+ const sig=syncSig(s);+ if(running){ banner.className='sync-banner show'; banner.innerHTML=`<span class="spin"></span> ${s.status==='reverting'?'Undoing on-disk changes…':'Writing libraries to disk…'}`; return; }+ if(syncDismiss===sig){ banner.className='sync-banner'; return; } // user closed this one+ const closeX=`<button class="banner-x" data-closebanner title="Dismiss">✕</button>`;+ if(s.status==='synced'){ banner.className='sync-banner show synced'; banner.innerHTML=`<span class="bmsg">✓ Synced to disk${s.lastSyncedAt?' at '+new Date(s.lastSyncedAt).toLocaleTimeString():''} — ready to sync again<span class="log">${esc((s.log||[]).slice(-1)[0]||'')}</span></span>${closeX}`; }+ else if(s.status==='error'){ banner.className='sync-banner show errbar'; banner.innerHTML=`<span class="bmsg">⚠ Sync error: ${esc(s.error||'')}</span>${closeX}`; }+ else { banner.className='sync-banner'; }+}+function syncSig(s){ s=s||{}; return `${s.status}|${s.active}|${s.lastSyncedAt}|${(s.log||[]).slice(-1)[0]||''}`; }+$('#syncBanner').addEventListener('click', e=>{ if(e.target.closest('[data-closebanner]')){ syncDismiss=syncSig(STATE.sync); $('#syncBanner').className='sync-banner'; updateStickyOffsets(); } });++// ---------- rendering+const THEAD = {+ symbol:`<tr><th class="sel-col"></th><th>Symbol</th><th class="col-ref">Ref</th><th>Footprint</th><th class="col-ds" title="Datasheet">DS</th><th>LCSC / JLC PN</th><th>Description</th></tr>`,+ footprint:`<tr><th class="sel-col"></th><th>Footprint</th><th>3D model</th><th>Symbols using it</th></tr>`,+ model3d:`<tr><th class="sel-col"></th><th>3D model</th><th>Used by footprints</th></tr>`,+};+function matchSearch(text){ const q=$('#search').value.trim().toLowerCase(); if(!q) return true; return (text||'').toLowerCase().includes(q); }++function updatePendingChip(){+ const L=STATE.libs; let add=0,del=0,chg=0;+ for(const lib of L.symbolLibs) for(const s of lib.symbols){ if(s.pendingDelete)del++; else if(s.pendingAdd)add++; else if(s.pendingEdit)chg++; }+ for(const lib of L.footprintLibs) for(const f of lib.footprints){ if(f.pendingDelete)del++; else if(f.added)add++; }+ for(const md of L.models3d){ if(md.pendingDelete)del++; else if(md.added)add++; }+ const el=$('#pendingChip');+ if(!add&&!del&&!chg){ el.style.display='none'; return; }+ el.style.display='inline-flex';+ const parts=[];+ if(add) parts.push(`<span class="pa">${add} new</span>`);+ if(del) parts.push(`<span class="pd">${del} deleted</span>`);+ if(chg) parts.push(`<span class="pc">${chg} changed</span>`);+ el.innerHTML=parts.join(' · ');+}+function render(){+ updateLibStatus(); updateSummary(); updateHistButtons(); updateSyncUI(); updatePendingChip();+ const has = STATE.libs.symbolLibs.length||STATE.libs.footprintLibs.length||STATE.libs.models3d.length;+ $('#empty').style.display = has?'none':'block';+ $('#tbl').style.display = has?'table':'none';+ $('#thead').innerHTML = THEAD[MODE];+ const tbody=$('#tbody'); tbody.innerHTML='';+ if(MODE==='symbol') renderSymbol(tbody);+ else if(MODE==='footprint') renderFootprint(tbody);+ else renderModel(tbody);+ updateSelCount(); updateStickyOffsets();+}+function pendClass(it){ return it.pendingDelete?' pending-del':((it.pendingAdd||it.added)?' pending-add':''); }+function catRow(name, path, count, span, lib, del){+ const tr=document.createElement('tr'); tr.className='cat-row'+(lib&&lib.pendingDelete?' cat-del':'');+ let badges='';+ if(lib){+ if(lib.pendingDelete) badges+='<span class="flag warn" style="color:var(--red);border-color:rgba(248,81,73,.45);background:var(--red-dim)">flagged for deletion</span>';+ if(lib.isDefault) badges+='<span class="flag def" title="A KiCad default / stock library, not one of your personal libraries">KiCad default</span>';+ if(lib.inTable===false) badges+='<span class="flag warn" title="This library is not registered in your sym-lib-table / fp-lib-table">not in lib table</span>';+ }+ const libBtns = del ? `<button class="lib-sync" data-synclib="${esc(del.mode)}" data-nick="${esc(del.nick)}" title="Sync just this library to disk">⇅ Sync</button><button class="lib-del" data-dellib="${esc(del.mode)}" data-nick="${esc(del.nick)}" title="Delete this library">✕ Delete library</button>` : '';+ tr.innerHTML=`<td colspan="${span}"><div class="catwrap"><span class="cat-name">${esc(name)}</span>${badges}${path?`<span class="cat-path mono">${esc(path)}</span>`:''}<span class="cat-count">${count}</span>${libBtns}</div></td>`;+ return tr;+}+function selBox(id){ return `<td class="sel-col"><input type="checkbox" data-sel="${esc(id)}" ${curSel().has(id)?'checked':''}></td>`; }++function renderSymbol(tbody){+ for(const lib of STATE.libs.symbolLibs){+ const rows=lib.symbols.filter(s=>!(HIDE_FLAGGED&&s.pendingDelete) && matchSearch(`${s.name} ${s.ref} ${s.value} ${s.footprint} ${s.mpn} ${s.datasheet} ${s.description}`));+ if(!rows.length) continue;+ tbody.appendChild(catRow(lib.nick, lib.path, lib.symbols.length, 7, lib, {mode:'symbol',nick:lib.nick}));+ for(const s of rows){+ const tr=document.createElement('tr'); tr.className='item'+(curSel().has(s.id)?' sel':'')+pendClass(s); tr.dataset.id=s.id;+ const fpDefault = s.fpNick && (STATE.libs.defaultFpLibs||[]).some(n=>n.toLowerCase()===s.fpNick.toLowerCase());+ tr.innerHTML = selBox(s.id)++ `<td><div class="namewrap"><input class="cell name" data-id="${esc(s.id)}" data-field="name" value="${esc(s.name)}" spellcheck="false"><button class="dup-btn" data-previewsym="${esc(s.id)}" title="Preview symbol + footprint">${MAG_SVG}</button><button class="dup-btn" data-dup="${esc(s.id)}" title="Duplicate this symbol">⧉</button></div></td>`++ `<td class="col-ref"><input class="cell ref" data-id="${esc(s.id)}" data-field="ref" value="${esc(s.ref)}" spellcheck="false"></td>`++ `<td><input class="cell${fpDefault?' fpdef':''}" data-id="${esc(s.id)}" data-field="footprint" value="${esc(s.footprint)}" spellcheck="false" placeholder="lib:footprint" title="${fpDefault?'Linked footprint — KiCad default library':'Linked footprint (lib:name)'}"></td>`++ `<td class="col-ds"><span class="ds-icon ${isUrl(s.datasheet)?'on':''}" data-dsedit="${esc(s.id)}" title="${s.datasheet?'Datasheet: '+esc(s.datasheet)+' — click to edit':'No datasheet — click to add'}">${DS_SVG}</span></td>`++ `<td><input class="cell short" data-id="${esc(s.id)}" data-field="mpn" value="${esc(s.mpn)}" spellcheck="false" placeholder="—"></td>`++ `<td><div class="desc-cell"><span class="desc-text" title="${esc(s.description)}">${s.description?esc(s.description):'<span class=muted>—</span>'}</span><button class="desc-exp" data-desc="${esc(s.id)}" title="View / edit full description">⤢</button></div></td>`;+ tbody.appendChild(tr);+ }+ }+}+function renderFootprint(tbody){+ for(const lib of STATE.libs.footprintLibs){+ const rows=lib.footprints.filter(f=>!(HIDE_FLAGGED&&f.pendingDelete) && matchSearch(`${f.name} ${f.model} ${(f.usedBy||[]).map(u=>u.name).join(' ')}`));+ if(!rows.length) continue;+ tbody.appendChild(catRow(lib.nick+'.pretty', lib.path, lib.footprints.length, 4, lib, {mode:'footprint',nick:lib.nick}));+ for(const f of rows){+ const tr=document.createElement('tr'); tr.className='item'+(curSel().has(f.id)?' sel':'')+pendClass(f); tr.dataset.id=f.id;+ const mt=modelExt(f.model);+ const model = f.model ? `<span class="mono">${esc(f.model.split('/').pop())}</span>${mt?`<span class="modeltag ${mt}">${mt}</span>`:''}` : '<span class="nomodel">no 3D model</span>';+ const n=f.useCount||0; const open=EXPANDED.has(f.id);+ const count = n ? `<span class="pill-count" data-exp="${esc(f.id)}"><span class="car">${open?'▼':'▶'}</span>${n} symbol${n===1?'':'s'}</span>` : `<span class="pill-count zero">0 symbols</span>`;+ tr.innerHTML = selBox(f.id)++ `<td class="name"><div class="namewrap">${esc(f.name)}<button class="dup-btn" data-previewfp="${esc(f.id)}" title="Preview footprint">${MAG_SVG}</button></div></td>`++ `<td title="${esc(f.model||'')}">${model}</td>`++ `<td>${count}</td>`;+ tbody.appendChild(tr);+ if(open && n){+ const sub=document.createElement('tr'); sub.className='subrow';+ sub.innerHTML=`<td colspan="4"><div class="userlist">${f.usedBy.map(u=>`<div class="u"><span class="ref">${esc(u.ref||'')}</span><span class="nm">${esc(u.name)}</span><span class="lb">${esc(u.lib)}${u.value?' · '+esc(u.value):''}</span></div>`).join('')}</div></td>`;+ tbody.appendChild(sub);+ }+ }+ }+}+function renderModel(tbody){+ const groups={};+ for(const md of STATE.libs.models3d){ const f=md.folder||'(root)'; (groups[f]=groups[f]||[]).push(md); }+ for(const folder of Object.keys(groups).sort()){+ const list=groups[folder].filter(md=>!(HIDE_FLAGGED&&md.pendingDelete) && matchSearch(`${md.name} ${(md.usedBy||[]).map(u=>u.name).join(' ')}`));+ if(!list.length) continue;+ tbody.appendChild(catRow(folder, groups[folder][0].path?groups[folder][0].path.replace(/\/[^/]*$/,''):'', groups[folder].length, 3, null, {mode:'model3d',nick:folder}));+ for(const md of list){+ const tr=document.createElement('tr'); tr.className='item'+(curSel().has(md.id)?' sel':'')+pendClass(md); tr.dataset.id=md.id;+ const mt=modelExt(md.name);+ const users=(md.usedBy||[]);+ const usedBy = users.length ? users.map(u=>`<span class="mono">${esc(u.name)}</span>`).join(' <span class=muted>·</span> ') : '<span class="muted">not referenced by any footprint</span>';+ tr.innerHTML = selBox(md.id)++ `<td title="${esc(md.path||'')}"><div class="namewrap"><span class="model-name">${esc(md.name)}</span>${mt?`<span class="modeltag ${mt}">${mt}</span>`:''}<button class="dup-btn" data-previewmodel="${esc(md.id)}" title="Preview 3D model">${MAG_SVG}</button></div></td>`++ `<td>${usedBy}</td>`;+ tbody.appendChild(tr);+ }+ }+}++function selectionAllFlagged(){+ const ids=[...curSel()]; if(!ids.length) return false;+ return ids.every(id=>{ const it=findItem(MODE,id); return it && it.pendingDelete; });+}+function updateSelCount(){+ const n=curSel().size;+ $('#selCount').textContent = `${n} selected`;+ $('#deleteBtn').disabled = n===0;+ const restore = selectionAllFlagged();+ $('#deleteBtn').textContent = restore ? '↻ Restore' : '✕ Delete';+ $('#deleteBtn').classList.toggle('danger', !restore);+}++// ---------- events+$('#modePills').addEventListener('click', e=>{ const b=e.target.closest('.pill'); if(!b)return; MODE=b.dataset.mode; [...$('#modePills').children].forEach(p=>p.classList.toggle('active',p.dataset.mode===MODE)); render(); });+$('#search').addEventListener('input', ()=>render());++$('#tbody').addEventListener('click', e=>{+ const sb=e.target.closest('[data-sel]'); if(sb){ const id=sb.dataset.sel; if(sb.checked) curSel().add(id); else curSel().delete(id); e.target.closest('tr').classList.toggle('sel',sb.checked); updateSelCount(); return; }+ const exp=e.target.closest('[data-exp]'); if(exp){ const id=exp.dataset.exp; if(EXPANDED.has(id))EXPANDED.delete(id); else EXPANDED.add(id); render(); return; }+ const dx=e.target.closest('[data-desc]'); if(dx){ openDesc(dx.dataset.desc); return; }+ const dup=e.target.closest('[data-dup]'); if(dup){ duplicate(dup.dataset.dup); return; }+ const psym=e.target.closest('[data-previewsym]'); if(psym){ openPreview({symbolId:psym.dataset.previewsym}); return; }+ const pfp=e.target.closest('[data-previewfp]'); if(pfp){ openPreview({footprintId:pfp.dataset.previewfp}); return; }+ const pmd=e.target.closest('[data-previewmodel]'); if(pmd){ openPreview({modelId:pmd.dataset.previewmodel}); return; }+ const dse=e.target.closest('[data-dsedit]'); if(dse){ openDs(dse.dataset.dsedit); return; }+ const dl=e.target.closest('[data-dellib]'); if(dl){ deleteLibrary(dl.dataset.dellib, dl.dataset.nick); return; }+ const sl=e.target.closest('[data-synclib]'); if(sl){ syncLibrary(sl.dataset.synclib, sl.dataset.nick, sl); return; }+});+async function syncLibrary(mode, nick, btn){+ const what = mode==='model3d' ? `the 3D-model folder "${nick}"` : `the ${mode} library "${nick}"`;+ if(!confirm(`Sync ${what} to disk now? Existing files get a .klm.bak backup; this only writes this one library.`)) return;+ if(btn){ btn.disabled=true; btn.textContent='⏳ syncing…'; }+ try{ const r=await api('/sync-library',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode,nick})});+ toast(`Synced "${nick}" → ${(r.log||[]).join('; ')||'done'}`); poll();+ }catch(e){ toast('Sync failed: '+e.message); if(btn){ btn.disabled=false; btn.textContent='⇅ Sync'; } }+}+async function deleteLibrary(mode, nick){+ try{ const r=await api('/delete-library',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode,nick})});+ STATE.history=r.history; toast(`Toggled "${nick}" delete flag — applies on Sync`); poll();+ }catch(e){ toast('Failed: '+e.message); }+}+async function duplicate(id){+ try{ const r=await api('/duplicate-symbol',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})});+ STATE.history=r.history; toast('Duplicated → '+r.symbol.name); poll();+ }catch(e){ toast('Duplicate failed: '+e.message); }+}+// inline field edits (symbol mode)+$('#tbody').addEventListener('change', async e=>{+ const t=e.target; if(!t.classList.contains('cell')) return;+ const id=t.dataset.id, field=t.dataset.field, value=t.value;+ try{ const r=await api(`/symbol/${encodeURIComponent(id)}/field`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({field,value})});+ STATE.history=r.history; updateHistButtons();+ // update local symbol so a poll-free re-render stays consistent; id may change on rename+ applyLocalSymbolEdit(id, r.symbol);+ toast(`${field} updated`);+ }catch(err){ toast('Edit failed: '+err.message); poll(); }+});+$('#tbody').addEventListener('keydown', e=>{+ const t=e.target; if(!t.classList.contains('cell')) return;+ // footprint cell with an open suggestion list: arrows/enter/esc drive the dropdown+ if(t.dataset.field==='footprint' && fpDD.style.display!=='none'){+ if(e.key==='ArrowDown'){ e.preventDefault(); fpSel=Math.min(fpItems.length-1,fpSel+1); fpHighlight(); return; }+ if(e.key==='ArrowUp'){ e.preventDefault(); fpSel=Math.max(0,fpSel-1); fpHighlight(); return; }+ if(e.key==='Enter'){ if(fpSel>=0){ e.preventDefault(); applyFp(fpSel); return; } hideFpAuto(); }+ if(e.key==='Escape'){ e.preventDefault(); hideFpAuto(); return; }+ }+ if(e.key==='Enter') t.blur();+});+function applyLocalSymbolEdit(oldId, ns){+ for(const lib of STATE.libs.symbolLibs){ const i=lib.symbols.findIndex(s=>s.id===oldId); if(i>=0){ lib.symbols[i]=ns; if(oldId!==ns.id && curSel().has(oldId)){ curSel().delete(oldId); curSel().add(ns.id);} break; } }+}++$('#selectAll').onclick=()=>{+ const ids=visibleIds(); const sel=curSel();+ const allOn=ids.length && ids.every(id=>sel.has(id));+ if(allOn){ ids.forEach(id=>sel.delete(id)); } else { ids.forEach(id=>sel.add(id)); }+ $('#selectAll').textContent = allOn?'✓ Select all':'✕ Deselect all';+ render();+};+function visibleIds(){+ const ids=[];+ const ok=it=>!(HIDE_FLAGGED&&it.pendingDelete);+ if(MODE==='symbol') for(const l of STATE.libs.symbolLibs) for(const s of l.symbols){ if(ok(s)&&matchSearch(`${s.name} ${s.ref} ${s.value} ${s.footprint} ${s.mpn} ${s.datasheet} ${s.description}`)) ids.push(s.id); }+ else if(MODE==='footprint') for(const l of STATE.libs.footprintLibs) for(const f of l.footprints){ if(ok(f)&&matchSearch(`${f.name} ${f.model}`)) ids.push(f.id); }+ else for(const md of STATE.libs.models3d){ if(ok(md)&&matchSearch(md.name)) ids.push(md.id); }+ return ids;+}++$('#deleteBtn').onclick=async()=>{+ const ids=[...curSel()]; if(!ids.length) return;+ const noun = MODE==='model3d'?'3D model':MODE;+ const restore = selectionAllFlagged();+ try{ const r=await api('/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode:MODE,ids})});+ curSel().clear(); STATE.history=r.history;+ toast(restore ? `Restored ${r.removed} ${noun}${r.removed===1?'':'s'}` : `Flagged ${r.removed} ${noun}${r.removed===1?'':'s'} for deletion — applies on Sync`); poll();+ }catch(err){ toast('Failed: '+err.message); }+};+$('#hideFlaggedBtn').onclick=()=>{ HIDE_FLAGGED=!HIDE_FLAGGED; $('#hideFlaggedBtn').classList.toggle('on',HIDE_FLAGGED); $('#hideFlaggedBtn').textContent=HIDE_FLAGGED?'⦸ Showing active':'⦸ Hide flagged'; render(); };++$('#undoBtn').onclick=()=>doHist('/undo');+$('#redoBtn').onclick=()=>doHist('/redo');+async function doHist(path){ try{ const r=await api(path,{method:'POST'}); STATE.history=r.history; if(!r.ok){ toast(path==='/undo'?'Nothing to undo':'Nothing to redo'); return; } toast(path==='/undo'?'Undone':'Redone'); poll(); }catch(e){ toast('Failed: '+e.message); } }++window.addEventListener('keydown', e=>{+ if(e.target && /INPUT|TEXTAREA/.test(e.target.tagName) && !(e.ctrlKey||e.metaKey)) return;+ const k=e.key.toLowerCase();+ if((e.ctrlKey||e.metaKey) && k==='z' && !e.shiftKey){ e.preventDefault(); doHist('/undo'); }+ else if((e.ctrlKey||e.metaKey) && ((k==='z'&&e.shiftKey)||k==='y')){ e.preventDefault(); doHist('/redo'); }+});++$('#revertBtn').onclick=async()=>{+ if(!confirm('Revert ALL libraries back to the initial probed state? Your edits and deletions in the app will be discarded (this itself is undoable). Disk is only touched on Sync.')) return;+ try{ const r=await api('/revert',{method:'POST'}); STATE.history=r.history; toast('Reverted to backup'); poll(); }catch(e){ toast('Revert failed: '+e.message); }+};++$('#syncBtn').onclick=async()=>{+ if(STATE.sync&&STATE.sync.status==='running') return;+ if(!confirm('Write the app\'s pending changes to your computer?\n\nExisting files get a .klm.bak backup; deletions move to a reversible .klm-trash folder.')) return;+ try{ await api('/sync',{method:'POST'}); toast('Syncing to disk…'); poll(); }+ catch(e){ toast('Sync failed: '+e.message); }+};++// ---------- description modal+function findSym(id){ for(const l of STATE.libs.symbolLibs){ const s=l.symbols.find(x=>x.id===id); if(s) return s; } return null; }+function openDesc(id){ const s=findSym(id); if(!s) return; descId=id; $('#descTitle').textContent='Description — '+s.name; $('#descMeta').textContent=`${s.lib} · ${s.ref||'?'} · ${s.value||''}`; $('#descText').value=s.description||''; $('#descModal').classList.add('show'); $('#descText').focus(); }+$('#descCancel').onclick=()=>{ $('#descModal').classList.remove('show'); descId=null; };+$('#descSave').onclick=async()=>{+ if(!descId) return; const value=$('#descText').value;+ try{ const r=await api(`/symbol/${encodeURIComponent(descId)}/field`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({field:'description',value})});+ STATE.history=r.history; applyLocalSymbolEdit(descId,r.symbol); $('#descModal').classList.remove('show'); descId=null; toast('Description updated'); render();+ }catch(e){ toast('Save failed: '+e.message); }+};++// ---------- LCSC stock/price hover popover+const lcscPop=document.createElement('div'); lcscPop.className='lcsc-pop'; document.body.appendChild(lcscPop);+const LCSC_DATA=new Map(); let lcscTimer=null, lcscHideTimer=null, lcscCur=null;+function fmtNum(n){ return (n==null)?'—':Number(n).toLocaleString(); }+function fmtPrice(p){ return (p==null)?'—':('$'+Number(p).toFixed(4).replace(/0+$/,'').replace(/\.$/,'')); }+function positionLcsc(el){ const r=el.getBoundingClientRect(); lcscPop.style.left=Math.min(r.left, window.innerWidth-310)+'px'; let top=r.bottom+6; if(top>window.innerHeight-180) top=Math.max(8,r.top-186); lcscPop.style.top=top+'px'; }+function renderLcsc(d){+ if(!d || !d.found){ lcscPop.innerHTML=`<div class="lcsc-hd">${esc(d&&d.part||'')}</div><div class="lcsc-row"><span>Not found on JLCPCB</span></div>`; return; }+ const tier=(d.tiers&&d.tiers[0])||{}; const inStock=d.stock>0;+ const badge = d.basic?'<span class="badge basic">basic</span>':d.preferred?'<span class="badge pref">preferred</span>':'<span class="badge ext">extended</span>';+ lcscPop.innerHTML=+ `<div class="lcsc-hd">${esc(d.part)}${badge}</div>`++ `<div class="lcsc-row"><span>Stock</span><b class="${inStock?'stock':'nostock'}">${fmtNum(d.stock)}</b></div>`++ `<div class="lcsc-row"><span>Unit price</span><b>${fmtPrice(d.unitPrice)}</b></div>`++ (d.mfr?`<div class="lcsc-row"><span>Mfr PN</span><b>${esc(d.mfr)}</b></div>`:'')++ (d.package?`<div class="lcsc-row"><span>Package</span><b>${esc(d.package)}</b></div>`:'')++ (d.tiers&&d.tiers.length?`<div class="lcsc-tiers">${d.tiers.map(t=>`${fmtNum(t.q)}+ ${fmtPrice(t.p)}`).join('<br>')}</div>`:'')++ (d.url?`<a class="lcsc-link" href="${esc(d.url)}" target="_blank" rel="noopener">View on JLCPCB ↗</a>`:'');+}+async function showLcsc(el){+ const part=(el.value||'').trim(); if(!/^C\d{3,}$/i.test(part)){ hideLcsc(); return; }+ lcscCur=part; positionLcsc(el); lcscPop.classList.add('show');+ if(LCSC_DATA.has(part)){ renderLcsc(LCSC_DATA.get(part)); return; }+ lcscPop.innerHTML=`<div class="lcsc-hd">${esc(part)}</div><div class="lcsc-row"><span><span class="spin"></span> fetching stock & price…</span></div>`;+ try{ const d=await api('/lcsc?part='+encodeURIComponent(part)); LCSC_DATA.set(part,d); if(lcscCur===part) renderLcsc(d); }+ catch(e){ if(lcscCur===part) lcscPop.innerHTML=`<div class="lcsc-row"><span>lookup failed</span></div>`; }+}+function hideLcsc(){ lcscPop.classList.remove('show'); lcscCur=null; }+$('#tbody').addEventListener('mouseover', e=>{+ const t=e.target; if(!(t.classList&&t.classList.contains('cell')&&t.dataset.field==='mpn')) return;+ clearTimeout(lcscHideTimer); clearTimeout(lcscTimer); lcscTimer=setTimeout(()=>showLcsc(t), 350);+});+$('#tbody').addEventListener('mouseout', e=>{+ const t=e.target; if(!(t.classList&&t.classList.contains('cell')&&t.dataset.field==='mpn')) return;+ clearTimeout(lcscTimer); lcscHideTimer=setTimeout(hideLcsc, 200);+});+lcscPop.addEventListener('mouseover', ()=>clearTimeout(lcscHideTimer));+lcscPop.addEventListener('mouseout', ()=>{ lcscHideTimer=setTimeout(hideLcsc, 200); });+window.addEventListener('scroll', ()=>{ if(lcscPop.classList.contains('show')) hideLcsc(); }, true);++// ---------- footprint autocomplete / recommendations+let FP_CANDS=null, fpInput=null, fpSel=-1, fpItems=[];+const fpDD=document.createElement('div'); fpDD.className='fp-auto'; fpDD.style.display='none'; document.body.appendChild(fpDD);+function fpCandidates(){+ const m=new Map();+ for(const lib of STATE.libs.footprintLibs) for(const f of lib.footprints){ const k=lib.nick+':'+f.name; if(!m.has(k)) m.set(k,{fp:k,personal:true}); }+ // also offer footprints that other symbols already reference (captures KiCad-stock libs like LED_SMD:…)+ for(const lib of STATE.libs.symbolLibs) for(const s of lib.symbols){ if(s.footprint&&s.footprint.includes(':')&&!m.has(s.footprint)) m.set(s.footprint,{fp:s.footprint,personal:false}); }+ return [...m.values()];+}+const SIZE_RE=/(01005|0201|0402|0603|0805|1206|1210|1812|2010|2512|2920|qfn|tqfp|lqfp|qfp|sot-?23|sot-?223|sot-?89|soic|sop|tssop|msop|son|dfn|bga|wlcsp|csp|to-?252|to-?263|dpak|d2pak|sod-?\d+|sma|smb|smc|udfn|dip)/gi;+function sizeToks(str){ const out=new Set(); let m; const s=(str||'').toLowerCase(); while((m=SIZE_RE.exec(s))) out.add(m[1].replace(/-/g,'')); return [...out]; }+const FAMILY={R:['resistor','res','_r_'],C:['capacitor','cap','_c_'],L:['inductor','ind','ferrite','choke','_l_'],FB:['ferrite','bead'],D:['led','diode','sod','sma','smb','smc'],LED:['led'],Y:['crystal','osc','resonator'],X:['crystal','osc'],U:['qfn','qfp','soic','sop','tssop','dfn','son','bga','sot','dip','lga'],Q:['sot','to-','dpak','transistor'],J:['connector','header','jst','molex','conn','receptacle','pin','usb'],P:['connector','header','pin'],SW:['switch','button','tactile'],K:['relay'],TP:['testpoint','test_point']};+function subseq(h,n){ let i=0; for(let k=0;k<h.length&&i<n.length;k++) if(h[k]===n[i]) i++; return i>=n.length; }+function rankFps(sym, query){+ if(!FP_CANDS) FP_CANDS=fpCandidates();+ const q=(query||'').toLowerCase().trim();+ const toks=sizeToks(`${sym.name} ${sym.value} ${sym.footprint}`);+ const fam=FAMILY[(sym.ref||'').replace(/[^A-Za-z]/g,'').toUpperCase()]||[];+ const out=[];+ for(const c of FP_CANDS){+ const name=c.fp.toLowerCase(); let score=0;+ if(q){ const i=name.indexOf(q); if(i===0) score+=60; else if(i>0) score+=28; else if(subseq(name,q)) score+=5; else continue; }+ for(const t of toks) if(name.includes(t)) score+=18;+ for(const k of fam) if(name.includes(k)) score+=8;+ if(c.personal) score+=3;+ out.push({fp:c.fp, personal:c.personal, score});+ }+ out.sort((a,b)=>b.score-a.score||a.fp.localeCompare(b.fp));+ return out.slice(0,8);+}+function showFpAuto(input){+ fpInput=input; const sym=findSym(input.dataset.id); if(!sym){ hideFpAuto(); return; }+ const list=rankFps(sym, input.value); fpItems=list; fpSel=-1;+ if(!list.length){ hideFpAuto(); return; }+ const typed=input.value.trim();+ fpDD.innerHTML=`<div class="fp-hd">${typed?'Matching footprints':'Suggested for '+esc(sym.ref||'this symbol')+(sizeToks(sym.name+' '+sym.value).length?' · '+sizeToks(sym.name+' '+sym.value).join(' '):'')}</div>`++ list.map((c,i)=>`<div class="fp-opt" data-i="${i}"><span class="fp-nm mono">${esc(c.fp)}</span>${i===0&&!typed?'<span class="fp-tag best">best match</span>':(c.personal?'':'<span class="fp-tag ext">used elsewhere</span>')}</div>`).join('');+ const r=input.getBoundingClientRect();+ fpDD.style.left=r.left+'px'; fpDD.style.top=(r.bottom+4)+'px'; fpDD.style.minWidth=Math.max(r.width,260)+'px'; fpDD.style.display='block';+}+function hideFpAuto(){ fpDD.style.display='none'; fpInput=null; fpSel=-1; fpItems=[]; }+function fpHighlight(){ [...fpDD.querySelectorAll('.fp-opt')].forEach((el,i)=>el.classList.toggle('on',i===fpSel)); }+function applyFp(i){ if(!fpInput||!fpItems[i]) return; const inp=fpInput; inp.value=fpItems[i].fp; hideFpAuto(); inp.dispatchEvent(new Event('change',{bubbles:true})); }+fpDD.addEventListener('mousedown', e=>{ const o=e.target.closest('.fp-opt'); if(o){ e.preventDefault(); applyFp(+o.dataset.i); } });+$('#tbody').addEventListener('focusin', e=>{ if(e.target.classList&&e.target.classList.contains('cell')&&e.target.dataset.field==='footprint'){ FP_CANDS=null; showFpAuto(e.target); } });+$('#tbody').addEventListener('input', e=>{ if(e.target.classList&&e.target.classList.contains('cell')&&e.target.dataset.field==='footprint') showFpAuto(e.target); });+$('#tbody').addEventListener('focusout', e=>{ if(e.target.classList&&e.target.classList.contains('cell')&&e.target.dataset.field==='footprint'){ const inp=e.target; setTimeout(()=>{ if(document.activeElement!==inp) hideFpAuto(); },150); } });+window.addEventListener('scroll', ()=>{ if(fpDD.style.display!=='none') hideFpAuto(); }, true);++// ---------- preview dock+function stripSvg(s){+ if(!s) return ''; let i=s.indexOf('<svg'); if(i<0) return ''; s=s.slice(i);+ // drop the root width/height (in mm) so the viewBox drives scaling and the SVG auto-fits the canvas+ s=s.replace(/(<svg\b[^>]*?)\s+width="[^"]*"/i,'$1').replace(/(<svg\b[^>]*?)\s+height="[^"]*"/i,'$1');+ if(!/preserveAspectRatio=/i.test(s)) s=s.replace(/<svg\b/i,'<svg preserveAspectRatio="xMidYMid meet"');+ return s;+}+function renderPane(canvasEl, nameEl, pane, kind){+ const svg=stripSvg(pane&&pane.svg);+ canvasEl.classList.remove('is3d');+ if(nameEl) nameEl.textContent = (pane&&pane.name)||'';+ if(svg){ canvasEl.innerHTML=svg; }+ else { canvasEl.innerHTML=`<span class="pv-empty">${pane&&pane.reason?esc(pane.reason):('no '+kind+' to preview')}</span>`; }+}+// ---- 3D viewer (Adom Babylon viewer, loaded from the wiki CDN — same engine as the wiki component pages)+const VIEWER_ESM='https://wiki.adom.inc/static/vendor/adom-3d-viewer-babylon9/adom-3d-viewer-babylon9.esm.js?v=20260611d';+const VIEWER_CSS='https://wiki.adom.inc/static/vendor/adom-3d-viewer-babylon9/style.css';+let viewerLibP=null, cur3d=null;+function ensureViewerLib(){+ if(viewerLibP) return viewerLibP;+ viewerLibP=(async()=>{+ window.process=window.process||{env:{NODE_ENV:'production'}}; window.global=window.global||window;+ const css=document.createElement('link'); css.rel='stylesheet'; css.href=VIEWER_CSS; document.head.appendChild(css);+ // it's an ES module (with relative chunk imports) — must be imported, not loaded as a classic script+ await import(VIEWER_ESM);+ if(!window.Adom3DViewerBabylon9 || !window.Adom3DViewerBabylon9.init) throw new Error('viewer global not set');+ return window.Adom3DViewerBabylon9;+ })();+ return viewerLibP;+}+async function show3D(canvasEl, glbUrl){+ canvasEl.classList.add('is3d');+ canvasEl.innerHTML='<span class="pv-empty"><span class="spin"></span> loading 3D…</span>';+ try{+ const probe=await fetch(glbUrl,{headers:{Range:'bytes=0-3'}});+ if(!probe.ok) throw new Error(probe.status===404?'no 3D model':('HTTP '+probe.status));+ const V=await ensureViewerLib(); if(!V||!V.init) throw new Error('viewer unavailable');+ canvasEl.innerHTML='';+ const host=document.createElement('div'); host.style.cssText='position:absolute;inset:0'; canvasEl.appendChild(host);+ try{ if(cur3d&&cur3d.dispose) cur3d.dispose(); }catch(e){}+ const v=V.init(host,{zUp:true,showViewCube:true,showGround:true}); cur3d=v;+ await v.loadModel(glbUrl); try{v.frameModel();}catch(e){} try{ if(v.toggleAxes) v.toggleAxes('world',false); }catch(e){}+ }catch(e){ canvasEl.innerHTML=`<span class="pv-empty">${e.message==='no 3D model'?'no 3D model linked to this footprint':('3D unavailable: '+esc(e.message))}</span>`; }+}+async function openPreview(q){+ $('#previewDock').classList.add('show'); document.body.classList.add('pv-open'); updateStickyOffsets();+ $('#pvLeftName').textContent=''; $('#pvRightName').textContent=''; $('#pvSub').textContent='';+ if(q.symbolId){+ $('#pvPaneRight').classList.remove('hide'); $('#pvLeftHd').textContent='Schematic Symbol'; $('#pvRightHd').textContent='PCB Footprint';+ $('#pvLeft').innerHTML='<span class="spin"></span>'; $('#pvRight').innerHTML='<span class="spin"></span>';+ try{ const r=await api('/preview/pair?symbolId='+encodeURIComponent(q.symbolId));+ renderPane($('#pvLeft'),$('#pvLeftName'),r.left,'symbol'); renderPane($('#pvRight'),$('#pvRightName'),r.right,'footprint');+ $('#pvSub').textContent=(r.left&&r.left.name)||'';+ }catch(e){ $('#pvLeft').innerHTML='<span class="pv-empty">render failed</span>'; toast('Preview failed: '+e.message); }+ } else if(q.footprintId){+ $('#pvPaneRight').classList.remove('hide'); $('#pvLeftHd').textContent='PCB Footprint'; $('#pvRightHd').textContent='3D Model';+ $('#pvLeft').innerHTML='<span class="spin"></span>'; $('#pvRightName').textContent='';+ try{ const r=await api('/preview/footprint?id='+encodeURIComponent(q.footprintId));+ renderPane($('#pvLeft'),$('#pvLeftName'),r,'footprint'); $('#pvSub').textContent=r.name||'';+ }catch(e){ $('#pvLeft').innerHTML='<span class="pv-empty">render failed</span>'; }+ show3D($('#pvRight'), apiUrl('/model.glb?footprintId='+encodeURIComponent(q.footprintId)));+ } else if(q.modelId){+ $('#pvPaneRight').classList.add('hide'); $('#pvLeftHd').textContent='3D Model';+ const md=(STATE.libs.models3d||[]).find(m=>m.id===q.modelId);+ $('#pvLeftName').textContent=md?md.name:''; $('#pvSub').textContent=md?md.name:'';+ show3D($('#pvLeft'), apiUrl('/model.glb?modelId='+encodeURIComponent(q.modelId)));+ }+}+$('#pvClose').onclick=()=>{ try{ if(cur3d&&cur3d.dispose) cur3d.dispose(); }catch(e){} cur3d=null; $('#previewDock').classList.remove('show'); document.body.classList.remove('pv-open'); updateStickyOffsets(); };++// ---------- datasheet modal+let dsId=null;+function openDs(id){ const s=findSym(id); if(!s) return; dsId=id;+ $('#dsTitle').textContent='Datasheet — '+s.name;+ $('#dsMeta').textContent=`${s.lib} · ${s.ref||'?'}`;+ $('#dsInput').value=s.datasheet||'';+ const o=$('#dsOpen'); if(isUrl(s.datasheet)){ o.style.display='inline-flex'; o.href=s.datasheet; } else o.style.display='none';+ $('#dsModal').classList.add('show'); $('#dsInput').focus();+}+$('#dsInput').addEventListener('input',()=>{ const v=$('#dsInput').value.trim(); const o=$('#dsOpen'); if(isUrl(v)){ o.style.display='inline-flex'; o.href=v; } else o.style.display='none'; });+$('#dsCancel').onclick=()=>{ $('#dsModal').classList.remove('show'); dsId=null; };+$('#dsSave').onclick=async()=>{+ if(!dsId) return; const value=$('#dsInput').value.trim();+ try{ const r=await api(`/symbol/${encodeURIComponent(dsId)}/field`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({field:'datasheet',value})});+ STATE.history=r.history; applyLocalSymbolEdit(dsId,r.symbol); $('#dsModal').classList.remove('show'); dsId=null; toast('Datasheet updated'); render();+ }catch(e){ toast('Save failed: '+e.message); }+};++// ---------- add modal+function fillLibSelect(sel, libs, placeholder){ sel.innerHTML = libs.length ? libs.map(l=>`<option value="${esc(l.nick)}">${esc(l.nick)}</option>`).join('') : `<option value="">${placeholder}</option>`; }+function openAdd(){+ fillLibSelect($('#wSymLib'), STATE.libs.symbolLibs, 'no symbol libraries');+ fillLibSelect($('#wFpLib'), STATE.libs.footprintLibs, 'no footprint libraries');+ syncBlankLib();+ $('#wMsg').textContent=''; $('#bMsg').textContent='';+ $('#addModal').classList.add('show'); $('#wMpn').focus();+}+function syncBlankLib(){ const kind=$('#bKind').value; fillLibSelect($('#bLib'), kind==='symbol'?STATE.libs.symbolLibs:STATE.libs.footprintLibs, 'no libraries'); }+$('#addBtn').onclick=()=>{ if(STATE.probe.status!=='done'){ toast('Probe your libraries first'); return; } openAdd(); };+$('#bKind').onchange=syncBlankLib;+document.querySelectorAll('#addModal .tab').forEach(t=>t.onclick=()=>{+ document.querySelectorAll('#addModal .tab').forEach(x=>x.classList.toggle('active',x===t));+ document.querySelectorAll('#addModal .tabpane').forEach(p=>p.classList.toggle('active',p.id==='pane-'+t.dataset.tab));+});+$('#addClose1').onclick=$('#addClose2').onclick=()=>$('#addModal').classList.remove('show');+$('#wGo').onclick=async()=>{+ const mpn=$('#wMpn').value.trim(); if(!mpn){ $('#wMsg').className='addmsg err'; $('#wMsg').textContent='Enter an MPN'; return; }+ $('#wMsg').className='addmsg'; $('#wMsg').innerHTML='<span class="spin"></span> searching the wiki…'; $('#wGo').disabled=true;+ try{ const r=await api('/wiki-add',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mpn,symLib:$('#wSymLib').value,fpLib:$('#wFpLib').value})});+ STATE.history=r.history; const a=r.added; const got=[a.symbol&&'symbol',a.footprint&&'footprint',a.model3d&&'3D model'].filter(Boolean);+ $('#wMsg').className='addmsg ok'; $('#wMsg').textContent=got.length?`Added ${got.join(', ')} from ${a.slug}. Sync to write to disk.`:`Found ${a.slug} but no CAD files.`;+ $('#wMpn').value=''; poll();+ }catch(e){ $('#wMsg').className='addmsg err'; $('#wMsg').textContent='Failed: '+e.message; }+ finally{ $('#wGo').disabled=false; }+};+$('#bGo').onclick=async()=>{+ const kind=$('#bKind').value, lib=$('#bLib').value, name=$('#bName').value.trim();+ if(!lib){ $('#bMsg').className='addmsg err'; $('#bMsg').textContent='No target library'; return; }+ try{ const r=await api(kind==='symbol'?'/add-symbol':'/add-footprint',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({lib,name})});+ STATE.history=r.history; const nm=(r.symbol||r.footprint).name; $('#bMsg').className='addmsg ok'; $('#bMsg').textContent=`Created ${kind} "${nm}" in ${lib}.`;+ $('#bName').value=''; if(kind==='symbol') MODE='symbol'; else MODE='footprint';+ [...$('#modePills').children].forEach(p=>p.classList.toggle('active',p.dataset.mode===MODE)); poll();+ }catch(e){ $('#bMsg').className='addmsg err'; $('#bMsg').textContent='Failed: '+e.message; }+};++// ---------- probe+$('#probeBtn').onclick=()=>$('#probeModal').classList.add('show');+$('#probeSkip').onclick=()=>$('#probeModal').classList.remove('show');+$('#probeGo').onclick=async()=>{+ $('#probeModal').classList.remove('show');+ try{ await api('/probe-request',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({edas:['kicad']})}); toast('Probing your libraries…'); poll(); }+ catch(e){ toast('Probe failed: '+e.message); }+};++// ---------- poll+function focusGuard(){ const a=document.activeElement; return a && (a.classList.contains('cell')||a.id==='descText'||a.id==='search'); }+async function poll(){+ try{ const s=await api('/state'); STATE=s; if(!focusGuard()) render(); else { updateLibStatus(); updateHistButtons(); updateSyncUI(); } }catch(e){}+}+async function init(){+ await poll();+ const pr=STATE.probe||{};+ const has=STATE.libs.symbolLibs.length||STATE.libs.footprintLibs.length;+ if(!has && pr.status!=='probing') $('#probeModal').classList.add('show');+ setInterval(poll, 3000);+}+init();+</script>+</body>+</html>
server.jsadded+1269@@ -0,0 +1,1269 @@+'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: '' };+}+// 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; // mark as changed (unless it's a brand-new symbol)+ 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;+}+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; } }+ 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)`);+ // 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;+ }+ } 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);+ 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 }); }+ }+ 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(); 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}`);+});
uninstall.shadded+5@@ -0,0 +1,5 @@+#!/bin/bash+set -e+rm -rf "$HOME/.claude/skills/kicad-library-manager"+rm -f "$HOME/.local/bin/kicad-library-manager" "$HOME/.local/bin/klm"+echo "Uninstalled kicad-library-manager"