← Commit history
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 &amp; 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 &amp; 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 &amp; 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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 &amp; 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>&nbsp; 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"