← Commit history
DEVELOPING.md+52
@@ -78,6 +78,58 @@ Barrett's machine is **Linux**, `HOME=/home/barrett-land`. Always reach the file   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). +## Project Doctor (reconcile a project against the library)++- **Model:** `DB.project` is a pure **projection** over `DB.libs` — `recomputeProjectStatuses()` re-derives+  every item's status + the health buckets on *every* `projectView()` (each state poll), so edits/undo/sync+  show up instantly and it never drifts. Items keep `_block` (the embedded symbol/footprint def, for the+  lib_symbols cache) and `DB.project._instances` keeps lightweight `{libId, fp}` pairs for the per-part+  health count. Both are stripped from the API view.+- **Classification rule (asymmetric, by design):** symbol stock = **bad** (you want your own), footprint+  stock = **healthy**. Health buckets per placed part: red = anything missing, amber = stock symbol, green+  = your own. Headline % = green/total. Stock nicks come from `DB.probe.libTable` (persisted at probe — a+  pre-1.2 data.json has none, so re-probe once for correct stock detection).+- **Symbol view is split by (symbol, footprint).** A symbol used with two footprints lists as two+  rows so each row has one editable footprint. The item `id` is `` `${libId} | ${fp}` `` and `symId`+  keeps the bare lib_id (used for classification + the lib_symbols def rename). ⚠️ The row-key+  separator MUST be a normal string (`" | "`) — a literal NUL byte makes the whole `id` un-JSON-able+  and un-greppable (grep flips the file to "binary"); that bit us once.+- Both the symbol and footprint cells are `dcell` inputs: `data-pkind` = the item's kind (drives which+  list the edit posts to), `data-pfield` = which field (drives the autocomplete pool: your symbols vs+  your footprints). The footprint edit resolves change/rename against the **footprint's** nick, not the+  symbol's.+- **Footprint matching is geometric, not lexical.** `parseFootprintPads` + `footprintFingerprint`+  reduce a footprint to {pad count, smd/tht, pitch (min centre-to-centre), median pad size, body box};+  `geomScoreFp` compares two fingerprints with **sorted** dims (rotation/scale-invariant) — pad-count+  is the gate (≠ by >2 → 0). Your library footprints are fingerprinted once per probe (`libFpGeom`,+  one batched `cat` of every `.pretty/*.kicad_mod`, cached in `LIB_FP_GEOM`, reset on probe); the+  project side parses the placed footprint block from the `.kicad_pcb`. `geomSuggestFp` ranks by shape+  and **falls back to name** (`suggestFrom`) when the footprint isn't on the board (no geometry). The+  suggestion's `why` (`"N pads · Pmm pitch"`) is shown as the dropdown match tag. Symbols still match by+  name for now — pin-count geometry is the planned next step.+- **KiCad stock footprints are matched too** (`stockFpGeom`). The ~15k default footprints are 180MB on+  disk, so we DON'T transfer them — `fp-fingerprint.py` runs on the **desktop** (one pass over the+  `.pretty` dirs, ~2s) and only the ~3.5MB of fingerprints come back, cached to+  `data/stock-fp-geom.json` (one-time across restarts; rebuild = delete the file). The script is shipped+  in the repo and pushed to the desktop base64-encoded (avoids regex-backslash mangling). Stock matches+  show in the dropdown straight from the scorer (they're not in the client's personal pool), and+  `resolveFpTarget` treats a stock-nick footprint as a **change** (it resolves via KiCad's global table),+  not a rename.+- **Edits only ever rewrite the PROJECT, never the libraries.** `projectEdit` stores `it.edit`;+  `resolveSymTarget`/`resolveFpTarget` decide **change** (typed name is in your library → repoint the+  project's `lib_id`/footprint to your nick) vs **rename** (not in library → relabel inside the project).+- **The rewrite** (`rewriteProject`, folded into `runSync`): re-reads the project fresh, then surgically+  rewrites each instance/footprint **by reference designator** (`rewriteSchText`/`rewritePcbText`), backing+  up each file to `.klm.bak`. ⚠️ An instance's `lib_id` MUST have a matching `(lib_symbols …)` entry or+  KiCad shows it "missing" — `ensureLibSymbols` injects the target def (your library symbol's geometry for+  a change, the project's own renamed def for a rename); it's idempotent (skips ids already cached). PCB+  footprints are self-contained, so only their name line is rewritten. After writing, it re-scans so the+  view reflects the rewritten files.+- **Testing the rewrite:** never run a real Sync against the user's project just to test — `runSync` also+  writes their libraries. Validate the pure `rewriteSchText`/`ensureLibSymbols` against a copied+  `.kicad_sch` in a node harness (check: only the targeted ref changed, parens stay balanced, cache def+  injected once).+ ## Publishing  `adompkg publish --org adom`. The `files` allowlist EXCLUDES `data/` (the user's library data must
README.md+18
@@ -22,6 +22,24 @@ optional one-click adds from the Adom wiki).   of symbols using it (click to expand the list). - **3D-model priority** — grouped by folder, with the footprints that reference each model. +## Project Doctor++Downloaded someone else's project and their libraries are a nightmare? Click **Project Doctor**,+pick a project (auto-discovered from your Documents / Downloads / KiCad folders, or paste a+`.kicad_pro` path), and the same table shows every symbol & footprint the project uses:++- **What's missing is red.** Each row is classified against *your* library — green (you have it),+  amber (a KiCad **stock symbol** — works, but not yours), or red (**missing**). Stock *footprints*+  count as healthy; stock *symbols* are flagged so you can swap in your own.+- **A health bar** scores the project: a segmented green / amber / red bar with a+  `N your own · N stock symbol · N missing` breakdown.+- **Remap inline.** Every symbol / footprint is an editable, autocompleting bar — type or pick one+  of your own library parts. If the name is in your library it's a **change** (the project gets+  repointed to your part); if it isn't, it's a **rename** (relabelled inside the project).+- **Sync rewrites the project** (never your libraries) to apply the remaps — `.kicad_sch` / `.kicad_pcb`+  `lib_id` + footprint references, with the symbol cached into `lib_symbols` so KiCad resolves it.+  Every project file is backed up to `.klm.bak` first.+ ## Highlights  - **Probe** finds your `sym-lib-table` / `fp-lib-table` and personal libraries automatically.
SKILL.md+8
@@ -43,6 +43,14 @@ Runs a pure-Node HTTP server on `:7823` (`KLM_PORT` to override) serving a singl   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.+- **Project Doctor:** reconcile a downloaded project against the user's library. `POST /api/projects`+  discovers `*.kicad_pro`; `POST /api/project-scan {pro}` parses the project's `.kicad_sch` (instances ++  embedded `lib_symbols`) and `.kicad_pcb`, classifies each symbol/footprint as have / stock / missing,+  and scores health (green = your own · amber = stock symbol · red = missing; stock footprints are+  healthy, stock symbols flagged). Every row is an editable autocompleting bar: typing a name that+  exists in the user's library is a **change** (repoint), otherwise a **rename**. `POST /api/project-edit`+  records edits; **Sync rewrites the project files only** (never the libraries) — `lib_id` + footprint+  refs, injecting the symbol into `lib_symbols`, with `.klm.bak` backups.  ## Dependencies on the host 
cli.js+5−1
@@ -28,7 +28,11 @@ if (cmd === 'serve' || cmd === 'start') { } 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}/`;+  // The Hydrogen webview front-end runs in the user's browser, so a raw+  // 127.0.0.1 URL points at their machine, not this container. Route through+  // code-server's port proxy ($VSCODE_PROXY_URI) so the container port is reachable.+  const proxy = process.env.VSCODE_PROXY_URI;+  const url = proxy ? proxy.replace('{{port}}', String(PORT)) : `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 {
fp-fingerprint.pyadded+39
@@ -0,0 +1,39 @@+import os, re, json, sys+at_re = re.compile(r'\(at\s+(-?[\d.]+)\s+(-?[\d.]+)')+size_re = re.compile(r'\(size\s+(-?[\d.]+)\s+(-?[\d.]+)\)')+type_re = re.compile(r'(?:"(?:[^"\\]|\\.)*"|\S+)\s+(\w+)')+def fingerprint(text):+    pads = []+    for chunk in text.split('(pad ')[1:]:+        m = type_re.match(chunk)+        ptype = m.group(1) if m else ''+        a = at_re.search(chunk); s = size_re.search(chunk)+        if not a or not s: continue+        pads.append((ptype, float(a.group(1)), float(a.group(2)), float(s.group(1)), float(s.group(2))))+    if not pads: return None+    n = len(pads); smd = sum(1 for p in pads if p[0] == 'smd')+    minx = min(p[1]-p[3]/2 for p in pads); maxx = max(p[1]+p[3]/2 for p in pads)+    miny = min(p[2]-p[4]/2 for p in pads); maxy = max(p[2]+p[4]/2 for p in pads)+    sw = sorted(p[3] for p in pads); sh = sorted(p[4] for p in pads)+    pitch = float('inf'); cap = pads[:60]+    for i in range(len(cap)):+        for j in range(i+1, len(cap)):+            d = ((cap[i][1]-cap[j][1])**2 + (cap[i][2]-cap[j][2])**2) ** 0.5+            if 0.01 < d < pitch: pitch = d+    if pitch == float('inf'): pitch = 0+    r = lambda x: round(x, 2)+    return {'n': n, 'smd': smd, 'tht': n-smd, 'bw': r(maxx-minx), 'bh': r(maxy-miny),+            'padW': r(sw[len(sw)//2]), 'padH': r(sh[len(sh)//2]), 'pitch': r(pitch)}+out = []+for d in sys.argv[1:]:+    if not os.path.isdir(d): continue+    for entry in os.listdir(d):+        if not entry.endswith('.pretty'): continue+        nick = entry[:-7]; pdir = os.path.join(d, entry)+        for f in os.listdir(pdir):+            if not f.endswith('.kicad_mod'): continue+            try: t = open(os.path.join(pdir, f), encoding='utf-8', errors='ignore').read()+            except Exception: continue+            fp = fingerprint(t)+            if fp: out.append({'id': nick+':'+f[:-10], 'name': f[:-10], 'nick': nick, 'fp': fp})+sys.stdout.write(json.dumps(out))
package.json+11−4
@@ -4,9 +4,9 @@   "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.1.1",+  "version": "1.2.0",   "visibility": "public",-  "changelog": "## 1.1.1\n- Sync now prunes dead lib-table entries (paths that no longer exist) from sym-lib-table / fp-lib-table, so KiCad stops erroring on launch after a library is deleted. Backs up each table to .klm.bak. Also exposed as POST /api/prune-tables.\n\n## 1.1.0\n- Footprint previews: pads render above silkscreen with pad numbers on top\n- 3D model preview (STEP→GLB) for footprints and a dedicated 3D Models view\n- Live JLCPCB stock + price on hover over an LCSC part number\n- Pending changes: deletes flag red, adds green, edits outline the changed field orange; header chip shows new / deleted / changed counts\n- Sync is a repeatable one-shot (resets after) with per-library sync; deleted libraries get unregistered from the lib-tables\n- Tabs renamed to Symbols / Footprints / 3D Models",+  "changelog": "## 1.2.0\n- **Project Doctor**: reconcile a downloaded KiCad project against your own library. Click *Project Doctor*, pick a project (auto-discovered from Documents/Downloads/KiCad, or by path), and the same table lists every symbol & footprint the project uses — flagging what isn't in your library in **red**, and scoring **project health** with a segmented bar (green = your own · amber = stock symbol · red = missing). A stock *footprint* counts as healthy; a stock *symbol* is flagged (use your own).\n- Footprint recommendations use **geometric (shape) matching**, not names: pad count, pitch, pad size and body box are fingerprinted from the project's placed footprint and matched against your library **and the full KiCad default library** (~15k footprints, fingerprinted on the desktop in ~2s and cached). So a custom `2920L030DR` finds `Fuse:Fuse_2920_7451Metric` and a `C_0402_1005Metric` finds `Capacitor_SMD:C_0402_1005Metric` — by shape, with zero name overlap. The dropdown tags each match with its shape (e.g. `2 pads · 0.96mm pitch`); falls back to name matching when a footprint isn't placed on the board.\n- Every symbol/footprint is an **editable, autocompleting bar** — type or pick one of your own library parts to remap onto it. If the name exists in your library it's a **change** (repoints the project to your part); if it doesn't it's a **rename** (relabels it inside the project). Edits only ever touch the *project*, never your libraries, and are tracked like any pending change.\n- **Sync** rewrites the downloaded project's .kicad_sch/.kicad_pcb to apply the remaps (lib_id + footprint references, with the symbol's lib_symbols cache injected so KiCad resolves it), backing up every file to .klm.bak first. New endpoints: POST /api/projects, /api/project-scan, /api/project-edit, /api/project-close.\n- Fixed: `klm app` now opens the webview through $VSCODE_PROXY_URI so it's reachable from the Hydrogen front-end (was a raw 127.0.0.1 URL that couldn't connect).\n\n## 1.1.1\n- Sync now prunes dead lib-table entries (paths that no longer exist) from sym-lib-table / fp-lib-table, so KiCad stops erroring on launch after a library is deleted. Backs up each table to .klm.bak. Also exposed as POST /api/prune-tables.\n\n## 1.1.0\n- Footprint previews: pads render above silkscreen with pad numbers on top\n- 3D model preview (STEP→GLB) for footprints and a dedicated 3D Models view\n- Live JLCPCB stock + price on hover over an LCSC part number\n- Pending changes: deletes flag red, adds green, edits outline the changed field orange; header chip shows new / deleted / changed counts\n- Sync is a repeatable one-shot (resets after) with per-library sync; deleted libraries get unregistered from the lib-tables\n- Tabs renamed to Symbols / Footprints / 3D Models",   "discovery_triggers": [     "kicad library manager",     "organize my kicad libraries",@@ -16,11 +16,17 @@     "kicad library cleanup",     "preview symbol footprint 3d model",     "lcsc stock and price",+    "project doctor",+    "reconcile a kicad project",+    "what's missing from this project",+    "fix a downloaded kicad project",+    "missing symbols and footprints",+    "project health check",     "klm"   ],-  "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.",+  "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. **Project Doctor** reconciles a downloaded project against your library: it lists the project's symbols & footprints, flags what you're missing (red), scores project health, and lets you remap each one onto your own parts via an editable autocompleting bar — then Sync rewrites the project's files (never your libraries) to match, with backups.",   "dependencies": {},-  "tags": ["eda", "kicad", "library", "symbol", "footprint", "3d-model", "jlcpcb", "lcsc", "cad", "library-manager", "adom-desktop", "preview"],+  "tags": ["eda", "kicad", "library", "symbol", "footprint", "3d-model", "jlcpcb", "lcsc", "cad", "library-manager", "adom-desktop", "preview", "project-doctor", "reconcile"],   "hero": {     "type": "billboard",     "headline": "KiCad Library Manager",@@ -30,6 +36,7 @@   "files": [     "server.js",     "cli.js",+    "fp-fingerprint.py",     "public/index.html",     "page.json",     "SKILL.md",
page.json+13−4
@@ -4,8 +4,8 @@   "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.1.1",-  "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.",+  "version": "1.2.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. **Project Doctor** reconciles a downloaded project against your library: it lists the project's symbols & footprints, flags what you're missing (red), scores project health, and lets you remap each one onto your own parts via an editable autocompleting bar — then Sync rewrites the project's files (never your libraries) to match, with backups.",   "dependencies": {},   "tags": [     "eda",@@ -19,7 +19,9 @@     "cad",     "library-manager",     "adom-desktop",-    "preview"+    "preview",+    "project-doctor",+    "reconcile"   ],   "hero": {     "type": "billboard",@@ -30,6 +32,7 @@   "files": [     "server.js",     "cli.js",+    "fp-fingerprint.py",     "public/index.html",     "page.json",     "SKILL.md",@@ -60,7 +63,13 @@     "kicad library cleanup",     "preview symbol footprint 3d model",     "lcsc stock and price",+    "project doctor",+    "reconcile a kicad project",+    "what's missing from this project",+    "fix a downloaded kicad project",+    "missing symbols and footprints",+    "project health check",     "klm"   ],-  "changelog": "## 1.1.1\n- Sync now prunes dead lib-table entries (paths that no longer exist) from sym-lib-table / fp-lib-table, so KiCad stops erroring on launch after a library is deleted. Backs up each table to .klm.bak. Also exposed as POST /api/prune-tables.\n\n## 1.1.0\n- Footprint previews: pads render above silkscreen with pad numbers on top\n- 3D model preview (STEP→GLB) for footprints and a dedicated 3D Models view\n- Live JLCPCB stock + price on hover over an LCSC part number\n- Pending changes: deletes flag red, adds green, edits outline the changed field orange; header chip shows new / deleted / changed counts\n- Sync is a repeatable one-shot (resets after) with per-library sync; deleted libraries get unregistered from the lib-tables\n- Tabs renamed to Symbols / Footprints / 3D Models"+  "changelog": "## 1.2.0\n- **Project Doctor**: reconcile a downloaded KiCad project against your own library. Click *Project Doctor*, pick a project (auto-discovered from Documents/Downloads/KiCad, or by path), and the same table lists every symbol & footprint the project uses — flagging what isn't in your library in **red**, and scoring **project health** with a segmented bar (green = your own · amber = stock symbol · red = missing). A stock *footprint* counts as healthy; a stock *symbol* is flagged (use your own).\n- Footprint recommendations use **geometric (shape) matching**, not names: pad count, pitch, pad size and body box are fingerprinted from the project's placed footprint and matched against your library **and the full KiCad default library** (~15k footprints, fingerprinted on the desktop in ~2s and cached). So a custom `2920L030DR` finds `Fuse:Fuse_2920_7451Metric` and a `C_0402_1005Metric` finds `Capacitor_SMD:C_0402_1005Metric` — by shape, with zero name overlap. The dropdown tags each match with its shape (e.g. `2 pads · 0.96mm pitch`); falls back to name matching when a footprint isn't placed on the board.\n- Every symbol/footprint is an **editable, autocompleting bar** — type or pick one of your own library parts to remap onto it. If the name exists in your library it's a **change** (repoints the project to your part); if it doesn't it's a **rename** (relabels it inside the project). Edits only ever touch the *project*, never your libraries, and are tracked like any pending change.\n- **Sync** rewrites the downloaded project's .kicad_sch/.kicad_pcb to apply the remaps (lib_id + footprint references, with the symbol's lib_symbols cache injected so KiCad resolves it), backing up every file to .klm.bak first. New endpoints: POST /api/projects, /api/project-scan, /api/project-edit, /api/project-close.\n- Fixed: `klm app` now opens the webview through $VSCODE_PROXY_URI so it's reachable from the Hydrogen front-end (was a raw 127.0.0.1 URL that couldn't connect).\n\n## 1.1.1\n- Sync now prunes dead lib-table entries (paths that no longer exist) from sym-lib-table / fp-lib-table, so KiCad stops erroring on launch after a library is deleted. Backs up each table to .klm.bak. Also exposed as POST /api/prune-tables.\n\n## 1.1.0\n- Footprint previews: pads render above silkscreen with pad numbers on top\n- 3D model preview (STEP→GLB) for footprints and a dedicated 3D Models view\n- Live JLCPCB stock + price on hover over an LCSC part number\n- Pending changes: deletes flag red, adds green, edits outline the changed field orange; header chip shows new / deleted / changed counts\n- Sync is a repeatable one-shot (resets after) with per-library sync; deleted libraries get unregistered from the lib-tables\n- Tabs renamed to Symbols / Footprints / 3D Models" }\ No newline at end of file
public/index.html+273−6
@@ -201,6 +201,60 @@   .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)} +  /* ---- Project Doctor ---- */+  .doc-banner{display:none;flex-direction:column;gap:9px;padding:11px 14px;background:linear-gradient(var(--accent-dim),var(--accent-dim)),var(--surface);border-bottom:1px solid var(--accent-border);position:sticky;top:var(--doc-top,46px);z-index:26}+  .doc-banner.show{display:flex}+  .doc-top{display:flex;align-items:center;gap:11px}+  .doc-proj{font-family:'Familjen Grotesk',sans-serif;font-weight:600;font-size:14px;color:var(--accent-bright);display:inline-flex;align-items:center;gap:7px}+  .doc-path{color:var(--text-dim);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:40vw}+  .health-wrap{display:flex;align-items:center;gap:13px}+  .health-pct{font-family:'Familjen Grotesk',sans-serif;font-weight:700;font-size:18px;min-width:54px}+  .health-bar{flex:1;max-width:460px;height:11px;border-radius:7px;background:var(--overlay);border:1px solid var(--border);overflow:hidden;display:flex}+  .health-seg{height:100%;transition:width .4s}+  .health-seg.g{background:var(--green)} .health-seg.a{background:var(--yellow)} .health-seg.r{background:var(--red)}+  .health-break{color:var(--text-muted);font-size:12px;display:inline-flex;gap:12px}+  .health-break b{font-family:'JetBrains Mono',monospace}+  .health-break .hg b{color:var(--green)} .health-break .ha b{color:var(--yellow)} .health-break .hr b{color:var(--red)}+  /* doctor rows */+  tbody tr.doc-row.bad{box-shadow:inset 3px 0 0 var(--red);background:var(--red-dim)}+  tbody tr.doc-row.stockrow{box-shadow:inset 3px 0 0 var(--yellow);background:var(--yellow-dim)}+  tbody tr.doc-row.okrow{box-shadow:inset 3px 0 0 var(--green)}+  tbody tr.doc-row.edited-row{box-shadow:inset 3px 0 0 #e8923c;background:rgba(232,146,60,.09)}+  tbody tr.doc-row:hover{filter:brightness(1.12)}+  .dedit{display:flex;align-items:center;gap:0;flex-wrap:wrap}+  input.dcell{background:var(--surface);border:1px solid var(--border);color:var(--text);border-radius:7px;padding:5px 8px;font-size:12px;font-family:'JetBrains Mono',ui-monospace,monospace;max-width:340px}+  input.dcell::placeholder{color:var(--text-dim)}+  input.dcell:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-dim)}+  input.dcell.edited{border-color:#e8923c;box-shadow:0 0 0 2px rgba(232,146,60,.35)}+  input.dcell.edited:focus{border-color:#e8923c;box-shadow:0 0 0 3px rgba(232,146,60,.45)}+  .edit-tag{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;border-radius:4px;padding:1px 6px;margin-left:8px;white-space:nowrap}+  .edit-tag.change{color:var(--green);border:1px solid rgba(63,185,80,.5);background:var(--green-dim)}+  .edit-tag.rename{color:var(--yellow);border:1px solid rgba(210,153,34,.5);background:var(--yellow-dim)}+  td.dstat-col{width:150px}+  .dstat{display:inline-flex;align-items:center;gap:5px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;border-radius:5px;padding:2px 8px;white-space:nowrap}+  .dstat.have,.dstat.imported{color:var(--green);border:1px solid rgba(63,185,80,.5);background:var(--green-dim)}+  .dstat.stock{color:var(--yellow);border:1px solid rgba(210,153,34,.5);background:var(--yellow-dim)}+  .dstat.stockfp{color:var(--accent-bright);border:1px solid var(--accent-border);background:var(--accent-dim)}+  .dstat.missing{color:var(--red);border:1px solid rgba(248,81,73,.55);background:var(--red-dim)}+  .doc-name{font-family:'JetBrains Mono',monospace;font-size:12.5px;font-weight:600}+  .refs{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text-muted)}+  .refs .more{color:var(--text-dim)}+  .reco{display:flex;align-items:center;gap:7px;flex-wrap:wrap}+  .sug-chip{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text);border:1px solid var(--border);background:var(--surface);border-radius:5px;padding:2px 7px;cursor:default}+  .sug-chip .sc{color:var(--text-dim);margin-left:6px;font-size:9.5px}+  .sug-none{color:var(--text-dim);font-size:11px;font-style:italic}+  .imp-btn{border:1px solid var(--accent-border);background:var(--accent-dim);color:var(--accent-bright);border-radius:6px;padding:4px 10px;font-size:11px;font-weight:700;white-space:nowrap}+  .imp-btn:hover{background:var(--accent);color:#04231f;border-color:var(--accent)}+  .imp-btn:disabled{opacity:.35;cursor:not-allowed}+  .proj-list{max-height:330px;overflow:auto;border:1px solid var(--border);border-radius:9px;background:var(--surface)}+  .proj-item{display:flex;flex-direction:column;gap:2px;padding:9px 12px;border-bottom:1px solid var(--border-muted);cursor:pointer}+  .proj-item:last-child{border-bottom:none}+  .proj-item:hover{background:var(--accent-dim)}+  .proj-item .pn{font-weight:600;color:var(--text);font-size:13px}+  .proj-item .pp{font-size:10.5px;color:var(--text-dim);font-family:'JetBrains Mono',monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}+  .proj-item .dl{color:var(--accent-bright);font-weight:700}+  .proj-empty{padding:20px;text-align:center;color:var(--text-muted);font-size:12.5px}+   /* 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}@@ -248,16 +302,33 @@     <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="doctorBtn" title="Reconcile a downloaded project against your library">Project Doctor</button>     <button class="btn" id="probeBtn" title="Scan your computer for KiCad libraries">⌖ Probe libraries</button>   </header>    <div class="sync-banner" id="syncBanner"></div> +  <!-- Project Doctor banner: project name, health bar, exit -->+  <div class="doc-banner" id="docBanner">+    <div class="doc-top">+      <span class="doc-proj"><span id="docName"></span></span>+      <span class="doc-path mono" id="docPath"></span>+      <span class="spacer"></span>+      <button class="btn" id="docRescan" title="Re-scan this project">⟳ Re-scan</button>+      <button class="btn warn" id="docExit" title="Exit Project Doctor, back to your library">✕ Exit Doctor</button>+    </div>+    <div class="health-wrap">+      <span class="health-pct" id="healthPct">—</span>+      <div class="health-bar"><div class="health-fill" id="healthFill" style="width:0%"></div></div>+      <span class="health-break" id="healthBreak"></span>+    </div>+  </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>+      <button class="pill" id="pillModel3d" 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>@@ -345,6 +416,18 @@     </div>   </div> +  <!-- Project Doctor: project picker -->+  <div class="modal-bg" id="projModal">+    <div class="modal" style="width:660px">+      <h2>Project Doctor</h2>+      <p>Pick a KiCad project to reconcile against <b>your</b> library. I'll flag every symbol &amp; footprint that isn't in your library, recommend substitutes you already own, and score the project's health.</p>+      <div class="field"><label>Projects found on your computer</label><div class="proj-list" id="projList"><div class="proj-empty"><span class="spin"></span> scanning…</div></div></div>+      <div class="field"><label>…or paste a .kicad_pro path</label><input type="text" id="projPath" placeholder="/home/you/Downloads/board/board.kicad_pro" autocomplete="off"></div>+      <div class="addmsg" id="projMsg"></div>+      <div class="modal-actions"><button class="btn" id="projClose">Close</button><button class="btn primary" id="projGoPath">Open path</button></div>+    </div>+  </div>+   <!-- preview dock — symbol (left) + linked footprint (right), styled like the wiki viewer -->   <div id="previewDock">     <div class="pv-bar">@@ -365,6 +448,7 @@ const $ = s => document.querySelector(s); let STATE = { libs:{symbolLibs:[],footprintLibs:[],models3d:[]}, probe:{status:'idle'}, sync:{active:false}, history:{}, hasBackup:false }; let MODE = 'symbol';+let DOCTOR = false;        // Project Doctor mode active let syncDismiss = null; let HIDE_FLAGGED = false; function findItem(mode,id){@@ -392,10 +476,12 @@ 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 db=$('#docBanner'); const dh=(db&&db.classList.contains('show'))?db.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+  r.setProperty('--header-h', hh+'px');             // header sticks at 0+  r.setProperty('--doc-top', (hh+bh)+'px');         // doctor banner sticks under header + sync banner+  r.setProperty('--controls-top', (hh+bh+dh)+'px'); // controls under whatever banners show+  r.setProperty('--stick-top', (hh+bh+dh+ch)+'px'); // column-header row sticks under controls } window.addEventListener('resize', updateStickyOffsets); @@ -408,6 +494,7 @@ function updateLibStatus(){   el.className='lib-status '+cls; el.querySelector('.txt').textContent=txt; } function updateSummary(){+  if(DOCTOR && STATE.project){ const c=STATE.project.counts||{}; const n=MODE==='footprint'?c.footprints:c.symbols; $('#summary').innerHTML=`· <b>${n||0}</b> ${MODE==='footprint'?'footprints':'symbols'} in project`; return; }   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'}` : '';@@ -448,17 +535,21 @@ function updatePendingChip(){   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 pe=(STATE.project&&STATE.project.counts&&STATE.project.counts.edits)||0;   const el=$('#pendingChip');-  if(!add&&!del&&!chg){ el.style.display='none'; return; }+  if(!add&&!del&&!chg&&!pe){ 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>`);+  if(pe) parts.push(`<span class="pc">${pe} project edit${pe===1?'':'s'}</span>`);   el.innerHTML=parts.join(' · '); } function render(){   updateLibStatus(); updateSummary(); updateHistButtons(); updateSyncUI(); updatePendingChip();+  applyDoctorChrome();+  if(DOCTOR){ renderDoctor(); updateStickyOffsets(); return; }   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';@@ -561,10 +652,186 @@ function updateSelCount(){   $('#deleteBtn').classList.toggle('danger', !restore); } +// ---------- Project Doctor+const DOC_THEAD = {+  symbol:`<tr><th class="dstat-col">Status</th><th>Symbol</th><th>Footprint</th><th>Value</th><th>Used by</th></tr>`,+  footprint:`<tr><th class="dstat-col">Status</th><th>Footprint</th><th>Used by</th></tr>`,+};+function applyDoctorChrome(){+  const on = DOCTOR;+  if(on && MODE==='model3d') MODE='symbol';+  $('#docBanner').classList.toggle('show', on && !!STATE.project);+  $('#doctorBtn').classList.toggle('on', on);+  $('#pillModel3d').style.display = on?'none':'';+  $('#addBtn').style.display = on?'none':'';+  const st=document.querySelector('.seltools'); if(st) st.style.display = on?'none':'';+  [...$('#modePills').children].forEach(pl=>pl.classList.toggle('active',pl.dataset.mode===MODE));+  if(on && STATE.project) updateHealthBar();+}+function updateHealthBar(){+  const p=STATE.project; if(!p) return; const c=p.counts||{}; const total=c.parts||0;+  const g=c.green||0, a=c.amber||0, r=c.red||0, pct=c.pct||0;+  $('#docName').textContent = p.name||'';+  $('#docPath').textContent = p.dir||'';+  $('#healthPct').textContent = total? pct+'%':'—';+  $('#healthPct').style.color = pct>=85?'var(--green)':pct>=50?'var(--yellow)':'var(--red)';+  const w=x=>total?(100*x/total):0;+  const bar=$('#healthFill'); bar.style.cssText='width:100%;height:100%;display:flex';+  bar.innerHTML = `<div class="health-seg g" style="width:${w(g)}%"></div><div class="health-seg a" style="width:${w(a)}%"></div><div class="health-seg r" style="width:${w(r)}%"></div>`;+  $('#healthBreak').innerHTML = `<span class="hg"><b>${g}</b> your own</span><span class="ha"><b>${a}</b> stock symbol</span><span class="hr"><b>${r}</b> missing</span>`;+}+function docStatus(it, mode){+  if(it.status==='imported') return {row:'okrow', cls:'imported', label:'✓ added'};+  if(it.status==='have') return {row:'okrow', cls:'have', label:'✓ in your library'};+  if(it.status==='missing') return {row:'bad', cls:'missing', label:'✕ missing'};+  if(mode==='footprint') return {row:'', cls:'stockfp', label:'stock ✓'};   // stock footprint = healthy+  return {row:'stockrow', cls:'stock', label:'stock'};                        // stock symbol = flagged+}+function docFindItem(kind,id){ if(!STATE.project) return null; for(const g of (kind==='symbol'?STATE.project.symbolLibs:STATE.project.footprintLibs)){ const it=g.items.find(x=>x.id===id); if(it) return it; } return null; }+async function postProjectEdit(kind,id,field,value){+  try{ const r=await api('/project-edit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({kind,id,field,value})}); STATE.project=r.project; render(); }+  catch(e){ toast('Edit failed: '+e.message); }+}+function docEditCell(it, pkind, field, origVal){+  const edited = !!(it.edit && it.edit[field]!=null);+  const cur = edited ? it.edit[field] : (origVal||'');+  const ei = it.editInfo && it.editInfo[field];+  let tag='';+  if(edited){+    if(ei && ei.action==='change') tag=`<span class="edit-tag change" title="repoints the project to your ${esc(ei.libId||ei.fpId||'')}">↳ change</span>`;+    else tag=`<span class="edit-tag rename" title="renames it to “${esc(cur)}” inside the project (not in your library)">✎ rename</span>`;+  }+  return `<td><div class="dedit"><input class="dcell${edited?' edited':''}" data-pkind="${esc(pkind)}" data-pid="${esc(it.id)}" data-pfield="${esc(field)}" value="${esc(cur)}" spellcheck="false" placeholder="${esc(origVal||'')}" autocomplete="off" title="Type or pick a ${field==='symbol'?'symbol':'footprint'} from your library to remap onto — clear to undo">${tag}</div></td>`;+}+function docRow(it, mode){+  const st=docStatus(it,mode);+  const tr=document.createElement('tr'); tr.className=('item doc-row '+(it.edit?'edited-row':st.row)).trim(); tr.dataset.id=it.id;+  const refs=(it.refs||[]);+  const refsTxt=(refs.slice(0,8).map(esc).join(' ')||'<span class=muted>—</span>')+(refs.length>8?` <span class="more">+${refs.length-8}</span>`:'')+(it.count>1?` <span class="more">(${it.count}×)</span>`:'');+  const statusTd=`<td class="dstat-col"><span class="dstat ${st.cls}">${st.label}</span></td>`;+  const usedByTd=`<td class="refs">${refsTxt}</td>`;+  if(mode==='symbol'){+    const valDisp = it.value ? `<span class="refs">${esc(it.value)}${it.valMulti?' <span class="more">…</span>':''}</span>` : '<span class="muted">—</span>';+    tr.innerHTML = statusTd + docEditCell(it,'symbol','symbol',it.name) + docEditCell(it,'symbol','footprint',it.footprint) + `<td>${valDisp}</td>` + usedByTd;+  } else tr.innerHTML = statusTd + docEditCell(it,'footprint','footprint',it.name) + usedByTd;+  return tr;+}+function docCatRow(g, mode, count){+  const tr=document.createElement('tr'); tr.className='cat-row';+  const span = mode==='symbol'?5:3;+  tr.innerHTML=`<td colspan="${span}"><div class="catwrap"><span class="cat-name">${esc(g.name)}</span>${count>1?`<span class="cat-count">${count}</span>`:''}</div></td>`;+  return tr;+}+function renderDoctor(){+  const p=STATE.project;+  $('#tbl').style.display = p?'table':'none';+  $('#empty').style.display = p?'none':'block';+  const tbody=$('#tbody'); tbody.innerHTML='';+  if(!p){ $('#thead').innerHTML=''; return; }+  const mode = MODE==='footprint'?'footprint':'symbol';+  $('#thead').innerHTML = DOC_THEAD[mode];+  for(const g of (mode==='symbol'?p.symbolLibs:p.footprintLibs)){+    const items=g.items.filter(it=>matchSearch(`${it.name} ${it.nick} ${(it.refs||[]).join(' ')} ${(it.suggestions||[]).map(s=>s.name).join(' ')}`));+    if(!items.length) continue;+    tbody.appendChild(docCatRow(g, mode, items.length));+    for(const it of items) tbody.appendChild(docRow(it, mode));+  }+}+async function openDoctor(){+  $('#projModal').classList.add('show'); $('#projMsg').textContent=''; $('#projPath').value='';+  $('#projList').innerHTML='<div class="proj-empty"><span class="spin"></span> scanning your computer for projects…</div>';+  try{+    const r=await api('/projects',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});+    const list=r.projects||[];+    if(!list.length){ $('#projList').innerHTML='<div class="proj-empty">No .kicad_pro files found under Documents / Downloads / KiCad.</div>'; return; }+    $('#projList').innerHTML=list.map(p=>`<div class="proj-item" data-pro="${esc(p.pro)}"><span class="pn">${esc(p.name)}${/(^|\/)Downloads(\/|$)/.test(p.dir)?' <span class="dl">· Downloads</span>':''}</span><span class="pp">${esc(p.dir)}</span></div>`).join('');+  }catch(e){ $('#projList').innerHTML=`<div class="proj-empty">Couldn't list projects: ${esc(e.message)}</div>`; }+}+async function pickProject(pro){+  if(!pro) return;+  $('#projMsg').className='addmsg'; $('#projMsg').innerHTML='<span class="spin"></span> scanning project…';+  try{+    const r=await api('/project-scan',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({pro})});+    STATE.project=r.project; DOCTOR=true; if(MODE==='model3d')MODE='symbol';+    ensureStockFps();   // preload KiCad default footprints for the remap autocomplete+    $('#projModal').classList.remove('show'); render();+  }catch(e){ $('#projMsg').className='addmsg err'; $('#projMsg').textContent='Scan failed: '+e.message; }+}+async function exitDoctor(){ DOCTOR=false; STATE.project=null; try{ await api('/project-close',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}); }catch(e){} render(); }+async function rescanProject(){ if(!STATE.project) return; toast('Re-scanning…'); await pickProject(STATE.project.pro); }++// doctor inline-edit autocomplete: a dropdown of YOUR library symbols/footprints to remap onto+const docDD=document.createElement('div'); docDD.className='fp-auto'; docDD.style.display='none'; document.body.appendChild(docDD);+let docInput=null, docItems=[], docSel=-1;+function docSuggestions(item, field){            // the right recommendation list for this cell+  if(!item) return [];+  if(field==='footprint') return item.fpSuggestions || item.suggestions || [];  // symbol-row fp vs fp-row+  return item.suggestions || [];+}+let STOCK_FPS=null, STOCK_FPS_LOADING=false;+async function ensureStockFps(){ if(STOCK_FPS||STOCK_FPS_LOADING) return; STOCK_FPS_LOADING=true; try{ const r=await api('/stock-footprints'); STOCK_FPS=r.footprints||[]; }catch(e){ STOCK_FPS=[]; } finally{ STOCK_FPS_LOADING=false; } }+function docCandidates(field, query, item){+  const q=(query||'').trim().toLowerCase();+  const pool=[];+  if(field==='symbol'){ for(const l of STATE.libs.symbolLibs){ if(l.isDefault) continue; for(const s of l.symbols) pool.push({value:l.nick+':'+s.name, name:s.name}); } }+  else {+    for(const l of STATE.libs.footprintLibs){ if(l.isDefault) continue; for(const f of l.footprints) pool.push({value:l.nick+':'+f.name, name:f.name}); }+    if(STOCK_FPS) for(const s of STOCK_FPS) pool.push(s);   // + the KiCad default footprints+  }+  if(!q){+    // recommendations first (straight from the scorer, so KiCad-stock matches show even though+    // they're not in your local pool), then the rest of your library+    const recs=docSuggestions(item, field);+    const seen=new Set(); const first=[];+    for(const r of recs){ const val=(r.nick?r.nick+':':'')+r.name; if(seen.has(val))continue; seen.add(val); first.push({value:val, name:r.name, rec:true, why:r.why}); }+    const rest=pool.filter(p=>!seen.has(p.value));+    return [...first,...rest].slice(0,16);+  }+  return pool.filter(p=>p.name.toLowerCase().includes(q)||p.value.toLowerCase().includes(q))+    .sort((a,b)=>((a.name.toLowerCase().startsWith(q)?0:1)-(b.name.toLowerCase().startsWith(q)?0:1))||a.name.length-b.name.length)+    .slice(0,16);+}+function showDocAuto(input, seed){+  const item=docFindItem(input.dataset.pkind, input.dataset.pid);+  const q = seed ? '' : input.value;             // on focus/click: show recommendations, ignore current value+  const list=docCandidates(input.dataset.pfield, q, item); docItems=list; docSel=-1; docInput=input;+  if(!list.length){ hideDocAuto(); return; }+  const field=input.dataset.pfield;+  docDD.innerHTML=`<div class="fp-hd">recommended ${field==='symbol'?'symbols':'footprints'} in your library — pick one</div>`+list.map((o,i)=>`<div class="fp-opt" data-i="${i}"><span class="fp-nm">${esc(o.value)}</span>${o.rec?`<span class="fp-tag best">${o.why?esc(o.why):'match'}</span>`:''}</div>`).join('');+  const r=input.getBoundingClientRect(); docDD.style.left=r.left+'px'; docDD.style.top=(r.bottom+4)+'px'; docDD.style.minWidth=Math.max(r.width,260)+'px'; docDD.style.display='block';+}+function hideDocAuto(){ docDD.style.display='none'; docInput=null; docSel=-1; docItems=[]; }+function docHi(){ [...docDD.querySelectorAll('.fp-opt')].forEach((el,i)=>el.classList.toggle('on',i===docSel)); }+function applyDocPick(i){ if(!docInput||!docItems[i]) return; const inp=docInput; inp.value=docItems[i].value; hideDocAuto(); inp.dispatchEvent(new Event('change',{bubbles:true})); }+docDD.addEventListener('mousedown', e=>{ const o=e.target.closest('[data-i]'); if(o){ e.preventDefault(); applyDocPick(+o.dataset.i); } });+ // ---------- 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()); +// Project Doctor wiring+$('#doctorBtn').onclick=()=>openDoctor();+$('#docExit').onclick=()=>exitDoctor();+$('#docRescan').onclick=()=>rescanProject();+$('#projClose').onclick=()=>$('#projModal').classList.remove('show');+$('#projGoPath').onclick=()=>pickProject($('#projPath').value.trim());+$('#projPath').addEventListener('keydown', e=>{ if(e.key==='Enter') pickProject($('#projPath').value.trim()); });+$('#projList').addEventListener('click', e=>{ const it=e.target.closest('[data-pro]'); if(it) pickProject(it.dataset.pro); });+// doctor inline edits + autocomplete (dcell inputs; distinct from the library's .cell handlers)+$('#tbody').addEventListener('input', e=>{ if(e.target.classList&&e.target.classList.contains('dcell')) showDocAuto(e.target,false); });+$('#tbody').addEventListener('focusin', e=>{ if(e.target.classList&&e.target.classList.contains('dcell')){ const inp=e.target; if(inp.dataset.pfield==='footprint') ensureStockFps(); showDocAuto(inp,true); try{ inp.select(); }catch(_){} } });+$('#tbody').addEventListener('focusout', e=>{ if(e.target.classList&&e.target.classList.contains('dcell')){ const inp=e.target; setTimeout(()=>{ if(document.activeElement!==inp) hideDocAuto(); },150); } });+$('#tbody').addEventListener('change', e=>{ const t=e.target; if(t.classList&&t.classList.contains('dcell')) postProjectEdit(t.dataset.pkind,t.dataset.pid,t.dataset.pfield,t.value); });+$('#tbody').addEventListener('keydown', e=>{+  const t=e.target; if(!t.classList||!t.classList.contains('dcell')) return;+  if(docDD.style.display==='block' && docItems.length){+    if(e.key==='ArrowDown'){ e.preventDefault(); docSel=Math.min(docItems.length-1,docSel+1); docHi(); return; }+    if(e.key==='ArrowUp'){ e.preventDefault(); docSel=Math.max(0,docSel-1); docHi(); return; }+    if(e.key==='Enter'){ e.preventDefault(); if(docSel>=0) applyDocPick(docSel); else t.blur(); return; }+    if(e.key==='Escape'){ hideDocAuto(); return; }+  } else if(e.key==='Enter'){ t.blur(); }+});+ $('#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; }@@ -908,7 +1175,7 @@ $('#probeGo').onclick=async()=>{ };  // ---------- poll-function focusGuard(){ const a=document.activeElement; return a && (a.classList.contains('cell')||a.id==='descText'||a.id==='search'); }+function focusGuard(){ const a=document.activeElement; return a && (a.classList.contains('cell')||a.classList.contains('dcell')||a.id==='descText'||a.id==='search'||a.id==='projPath'); } async function poll(){   try{ const s=await api('/state'); STATE=s; if(!focusGuard()) render(); else { updateLibStatus(); updateHistButtons(); updateSyncUI(); } }catch(e){} }
server.js+580−4
@@ -49,7 +49,8 @@ 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 },+    probe: { status: 'idle', probedAt: null, edas: ['kicad'], tablesFound: [], libTable: { sym: [], fp: [] }, counts: {}, error: null },+    project: null,                // Project Doctor: the currently-scanned project (see scanProject)     sync: { active: false, status: 'idle', lastSyncedAt: null, log: [], error: null, trashed: [], pendingTrash: [], pendingUnregister: [], unregisteredTables: [] },   }; }@@ -299,12 +300,14 @@ async function probe(edas) {   const symNickByPath = {}, symNickByBase = {}, fpNickByPath = {}, fpNickByBase = {};   const defaultFpLibs = new Set(), defaultSymLibs = new Set();  // nicks that point at KiCad stock libs   const tablesFound = [];+  const libTable = { sym: [], fp: [] };  // every registered lib nick (incl. stock) → uri + stock flag, for Project Doctor   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);+      (isFp ? libTable.fp : libTable.sym).push({ nick: lib.name, uri: lib.uri, stock });       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); }     }@@ -355,7 +358,7 @@ async function probe(edas) {     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 };+  return { libs: { symbolLibs, footprintLibs, models3d, defaultFpLibs: [...defaultFpLibs] }, tablesFound, libTable, counts }; }  // ----------------------------------------------------------------- usage maps (footprint<->symbol, model<->footprint)@@ -826,6 +829,9 @@ async function runSync() {       if (lib.pendingDelete) continue;       log.push(...await writeAddedFootprints(lib)); newFp += lib.footprints.filter(f => !f.added && f.rawMod).length;     }+    // 2b) Project Doctor: rewrite the open project's .kicad_sch/.kicad_pcb to use your library+    try { const plog = await rewriteProject(); if (plog.length) log.push(...plog); }+    catch (e) { log.push('FAIL project rewrite — ' + e.message); }     // 3) send any staged 3D models (from wiki-add) into the 3D folder     let new3d = 0;     for (const md of DB.libs.models3d) {@@ -955,6 +961,551 @@ async function syncOneLibrary(mode, nick) {   return log; } +// ===================================================================== Project Doctor+// Reconcile a downloaded KiCad project against the user's own library: list every symbol /+// footprint the project uses, classify each as in-your-library / KiCad-stock / missing, score a+// project "health %", and (on demand) lift the part the project already embeds — symbols from the+// schematic's (lib_symbols …) block, footprints from the .kicad_pcb — into one of your personal+// libraries as a normal pendingAdd, so the existing Sync writes it to disk.+//+// Classification rules (per the user): a STOCK footprint counts as healthy (an 0402 is an 0402),+// but a STOCK symbol counts as bad — you want your own symbol, not KiCad's.++// Paths that are never real user projects (build output, vendored copies, VCS internals).+const PROJ_SKIP = /(^|\/)(node_modules|\.git|target|build|dist|out|bundle|__pycache__)(\/|$)|\.AppDir(\/|$)|\/backups?\//i;++// Discover *.kicad_pro on the user's machine, most-recently-modified first.+async function findProjects() {+  const dirs = ['"$HOME/Documents"', '"$HOME/KiCad"', '"$HOME/kicad"', '"$HOME/Projects"', '"$HOME/Downloads"', '"$HOME/Desktop"'];+  const cmd = `find ${dirs.join(' ')} -iname '*.kicad_pro' -printf '%T@\\t%p\\n' 2>/dev/null | sort -rn | head -400`;+  const out = await runDesktopShell(cmd, 60);+  const seen = new Set(), projs = [];+  for (const line of (out || '').split('\n')) {+    const raw = line.includes('\t') ? line.slice(line.indexOf('\t') + 1) : line;+    const pth = raw.trim(); if (!pth || PROJ_SKIP.test(pth)) continue;+    const dir = dirOf(pth), name = baseOf(pth).replace(/\.kicad_pro$/i, '');+    if (seen.has(pth)) continue; seen.add(pth);+    projs.push({ pro: pth, dir, name });+  }+  return projs;+}++// One shell pass: cat every schematic + board + project-local lib-table in the project dir.+function scanProjectShell(dir) {+  const d = shq(dir);+  return [+    `D=${d}`,+    `for f in "$D"/*.kicad_sch; do [ -f "$f" ] || continue; case "$f" in *-bak*|*.bak*|*-backups*) continue;; esac; echo "@@SCH $f"; cat "$f"; echo; echo "@@END"; done`,+    `for f in "$D"/*.kicad_pcb; do [ -f "$f" ] || continue; case "$f" in *-bak*|*.bak*|*-backups*) continue;; esac; echo "@@PCB $f"; cat "$f"; echo; echo "@@END"; done`,+    `for t in "$D/sym-lib-table" "$D/fp-lib-table"; do [ -f "$t" ] && { echo "@@PTABLE $t"; cat "$t"; echo; echo "@@END"; }; done`,+  ].join('\n');+}+function parseScanOutput(out) {+  const lines = (out || '').split('\n'); const schs = [], pcbs = [], ptables = []; let i = 0;+  const read = (arr, pfxLen, line) => { const pth = line.slice(pfxLen).trim(); const buf = []; i++; while (i < lines.length && lines[i] !== '@@END') buf.push(lines[i++]); arr.push({ path: pth, text: buf.join('\n') }); i++; };+  while (i < lines.length) {+    const line = lines[i];+    if (line.startsWith('@@SCH ')) read(schs, 6, line);+    else if (line.startsWith('@@PCB ')) read(pcbs, 6, line);+    else if (line.startsWith('@@PTABLE ')) read(ptables, 9, line);+    else i++;+  }+  return { schs, pcbs, ptables };+}++// Walk every top-level (symbol …) block in a .kicad_sch (definitions in lib_symbols + placed+// instances); nested sub-units are consumed inside their parent block, never matched separately.+function walkBlocks(text, head) {+  const out = []; let i = 0; const open = '(' + head;+  while (i < text.length) {+    const c = text[i];+    if (c === '"') { i = skipString(text, i); continue; }+    if (c === '(' && text.startsWith(open, i) && /^[\s("]/.test(text[i + open.length] || ' ')) { const b = balanced(text, i); out.push(b); i += b.length; continue; }+    i++;+  }+  return out;+}+function collectSch(text, instances, symDefs) {+  for (const block of walkBlocks(text, 'symbol')) {+    if (/^\(symbol\s+"/.test(block)) {                       // a library DEFINITION (full "Lib:Name")+      const m = block.match(/^\(symbol\s+"((?:[^"\\]|\\.)*)"/); if (!m) continue;+      const id = unesc(m[1]); if (!symDefs.has(id)) symDefs.set(id, block);+      const bare = id.split(':').pop(); const k = 'name:' + bare.toLowerCase(); if (!symDefs.has(k)) symDefs.set(k, block);+    } else if (/^\(symbol\s*\(/.test(block)) {               // a placed INSTANCE+      const lm = block.match(/\(lib_id\s+"((?:[^"\\]|\\.)*)"/); if (!lm) continue;+      const props = symProps(block);+      const ref = (props.Reference || '').trim(); if (!ref || ref.startsWith('#')) continue;  // skip power/virtual+      instances.push({ libId: unesc(lm[1]), ref, value: (props.Value || '').trim(), fp: (props.Footprint || '').trim() });+    }+  }+}+function collectPcb(text, fpBlocks) {+  for (const block of walkBlocks(text, 'footprint')) {+    const m = block.match(/^\(footprint\s+"((?:[^"\\]|\\.)*)"/); if (!m) continue;+    const bare = unesc(m[1]).split(':').pop().toLowerCase();+    if (!fpBlocks.has(bare)) fpBlocks.set(bare, block);+  }+}++// Turn a board-embedded (footprint …) block into a clean library .kicad_mod: rename to the bare+// name, drop the footprint-level placement, and strip board-only refs (nets, uuids, paths).+function pcbFootprintToMod(block, bareName) {+  let b = block.replace(/^\(footprint\s+"(?:[^"\\]|\\.)*"/, `(footprint "${escStr(bareName)}"`);+  // remove the footprint's own placement (at …) — the FIRST (at …) before any pad/graphic/property+  const cut = b.search(/\((?:pad|fp_text|fp_line|fp_poly|fp_circle|fp_arc|fp_rect|property|attr|model)\b/);+  if (cut > 0) b = b.slice(0, cut).replace(/\s*\(at\s+-?[\d.]+\s+-?[\d.]+(?:\s+-?[\d.eE]+)?\)/, '') + b.slice(cut);+  b = b.replace(/\s*\(net\s+\d+\s+"(?:[^"\\]|\\.)*"\)/g, '')   // pad net assignments+       .replace(/\s*\(net\s+\d+\)/g, '')+       .replace(/\s*\(uuid\s+"[^"]*"\)/g, '').replace(/\s*\(tstamp\s+[^)\s]+\)/g, '')+       .replace(/\s*\(path\s+"[^"]*"\)/g, '').replace(/\s*\(sheetname\s+"[^"]*"\)/g, '').replace(/\s*\(sheetfile\s+"[^"]*"\)/g, '');+  return b;+}++// ---- matching / recommendation engine+function tokset(s) { return new Set((s || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)); }+function sizeTokset(s) { return new Set((String(s || '').toLowerCase().match(/\b\d{4}\b|qfn\d*|qfp\d*|soic\d*|sop\d*|tssop\d*|sot\d*|sod\d*|dfn\d*|bga\d*|lqfp\d*|\d+x\d+/g) || [])); }+function simScore(a, b) {+  const A = tokset(a), B = tokset(b); if (!A.size || !B.size) return 0;+  let inter = 0; for (const x of A) if (B.has(x)) inter++;+  const jac = inter / (A.size + B.size - inter);+  const sa = sizeTokset(a), sb = sizeTokset(b); let bonus = 0; for (const x of sa) if (sb.has(x)) bonus += 0.4;+  return jac + bonus;+}+function suggestFrom(name, pool, extra) {+  const scored = pool.map(c => { let s = simScore(name, c.name); if (extra) s += extra(c); return { id: c.id, name: c.name, nick: c.nick, score: +s.toFixed(3) }; });+  return scored.filter(x => x.score > 0.18).sort((a, b) => b.score - a.score).slice(0, 3);+}+// Symbol recommendations: match on NAME + VALUE (so a 4.7uF cap finds your JLC C_4.7uF…), with a+// reference-family boost (a 'C' part surfaces your capacitor library, an 'R' your resistor library).+function suggestSymbols(it, pool) {+  const q = (it.name + ' ' + (it.value || '')).trim();+  const refL = (it.refs && it.refs[0]) ? it.refs[0].replace(/\d+$/, '').toLowerCase() : '';+  const scored = pool.map(c => {+    let s = simScore(q, (c.name + ' ' + (c.value || '')).trim());+    if (it.value && c.value && it.value.toLowerCase() === c.value.toLowerCase()) s += 0.3;+    if (refL && c.ref && refL === c.ref.replace(/\d+$/, '').toLowerCase()) s += 0.15;+    return { id: c.id, name: c.name, nick: c.nick, score: +s.toFixed(3) };+  });+  return scored.filter(x => x.score > 0.2).sort((a, b) => b.score - a.score).slice(0, 6);+}++// ---- geometric (shape-based) footprint matching: compares what a footprint actually LOOKS like —+// pad count, pitch, pad size, body box — instead of its name. Rotation/scale-invariant, all in-memory.+const r2 = x => Math.round(x * 100) / 100;+function parseFootprintPads(text) {+  const pads = [];+  for (const b of walkBlocks(text || '', 'pad')) {+    const head = b.match(/^\(pad\s+(?:"(?:[^"\\]|\\.)*"|\S+)\s+(\w+)\s+(\w+)/);   // (pad "1" <type> <shape>+    const at = b.match(/\(at\s+(-?[\d.]+)\s+(-?[\d.]+)/);+    const size = b.match(/\(size\s+(-?[\d.]+)\s+(-?[\d.]+)\)/);+    const drill = b.match(/\(drill\s+([\d.]+)/);+    if (!at || !size) continue;+    pads.push({ type: head ? head[1] : '', x: +at[1], y: +at[2], w: +size[1], h: +size[2], drill: drill ? +drill[1] : 0 });+  }+  return pads;+}+function footprintFingerprint(pads) {+  if (!pads || !pads.length) return null;+  const n = pads.length, smd = pads.filter(p => p.type === 'smd').length;+  let minx = Infinity, maxx = -Infinity, miny = Infinity, maxy = -Infinity;+  for (const p of pads) { minx = Math.min(minx, p.x - p.w / 2); maxx = Math.max(maxx, p.x + p.w / 2); miny = Math.min(miny, p.y - p.h / 2); maxy = Math.max(maxy, p.y + p.h / 2); }+  const ws = pads.map(p => p.w).sort((a, b) => a - b), hs = pads.map(p => p.h).sort((a, b) => a - b);+  const med = a => a[Math.floor(a.length / 2)];+  let pitch = Infinity;                                          // smallest centre-to-centre pad spacing ≈ pitch+  for (let i = 0; i < pads.length; i++) for (let j = i + 1; j < pads.length; j++) { const d = Math.hypot(pads[i].x - pads[j].x, pads[i].y - pads[j].y); if (d > 0.01) pitch = Math.min(pitch, d); }+  return { n, smd, tht: n - smd, bw: r2(maxx - minx), bh: r2(maxy - miny), padW: r2(med(ws)), padH: r2(med(hs)), pitch: isFinite(pitch) ? r2(pitch) : 0 };+}+// 0..1 similarity of two footprint fingerprints (sorted dims → rotation-invariant)+function geomScoreFp(a, b) {+  if (!a || !b) return 0;+  if (a.n !== b.n) return 0;                                     // must have the SAME pad/pin count — never match a different count+  const rel = (x, y) => { const m = Math.max(x, y); return m ? 1 - Math.min(1, Math.abs(x - y) / m) : 1; };+  const sd = (x, y) => [Math.min(x, y), Math.max(x, y)];+  const [aps, apl] = sd(a.padW, a.padH), [bps, bpl] = sd(b.padW, b.padH);+  const [abs, abl] = sd(a.bw, a.bh), [bbs, bbl] = sd(b.bw, b.bh);+  const pitchScore = rel(a.pitch, b.pitch);+  const sizeScore = (rel(aps, bps) + rel(apl, bpl)) / 2;+  const bboxScore = (rel(abs, bbs) + rel(abl, bbl)) / 2;+  const typeScore = (a.smd > 0) === (b.smd > 0) ? 1 : 0.4;+  return +(0.42 * pitchScore + 0.30 * sizeScore + 0.18 * bboxScore + 0.10 * typeScore).toFixed(3);+}+// Fingerprint every personal-library footprint (one batched bridge read of the .kicad_mod files). Cached; reset on probe.+let LIB_FP_GEOM = null;+async function libFpGeom() {+  if (LIB_FP_GEOM) return LIB_FP_GEOM;+  const map = [];+  const libs = DB.libs.footprintLibs.filter(l => !l.isDefault && l.path);+  if (libs.length) {+    const script = libs.map(l => `for m in ${shq(l.path)}/*.kicad_mod; do [ -f "$m" ] || continue; echo "@@FP $m"; cat "$m"; echo; echo "@@END"; done`).join('\n');+    const out = await runDesktopShell(script, 120);+    const lines = (out || '').split('\n'); let i = 0;+    const byPath = {}; for (const l of libs) byPath[l.path] = l.nick;+    while (i < lines.length) {+      if (lines[i].startsWith('@@FP ')) {+        const pth = lines[i].slice(5).trim(); const buf = []; i++;+        while (i < lines.length && lines[i] !== '@@END') buf.push(lines[i++]); i++;+        const dir = dirOf(pth), nick = byPath[dir] || baseOf(dir).replace(/\.pretty$/i, ''), name = baseOf(pth).replace(/\.kicad_mod$/i, '');+        const fp = footprintFingerprint(parseFootprintPads(buf.join('\n')));+        if (fp) map.push({ id: nick + ':' + name, name, nick, fp });+      } else i++;+    }+  }+  LIB_FP_GEOM = map; return map;+}+// Fingerprint the KiCad STOCK footprint libraries too (≈15k footprints). The fingerprinting runs on+// the DESKTOP (one python pass over the .pretty dirs, ~2s) and only the ~4MB of fingerprints come+// back — never the 180MB of files. Cached to disk so it's a one-time cost across restarts.+const STOCK_GEOM_FILE = path.join(DATA_DIR, 'stock-fp-geom.json');+const STOCK_FP_ROOTS = ['/usr/share/kicad/footprints', '/usr/local/share/kicad/footprints',+  '/Applications/KiCad/KiCad.app/Contents/SharedSupport/footprints', '/snap/kicad/current/usr/share/kicad/footprints'];+let STOCK_FP_GEOM = null, STOCK_FP_BUILD = null;+async function stockFpGeom() {+  if (STOCK_FP_GEOM) return STOCK_FP_GEOM;+  try { const cached = JSON.parse(fs.readFileSync(STOCK_GEOM_FILE, 'utf8')); if (Array.isArray(cached) && cached.length) { STOCK_FP_GEOM = cached; return cached; } } catch {}+  if (STOCK_FP_BUILD) return STOCK_FP_BUILD;                     // a build is already in flight+  STOCK_FP_BUILD = (async () => {+    try {+      const py = fs.readFileSync(path.join(ROOT, 'fp-fingerprint.py'));+      const b64 = py.toString('base64');+      const roots = STOCK_FP_ROOTS.map(shq).join(' ');+      const cmd = `printf %s ${shq(b64)} | base64 -d > /tmp/.klm_fp.py && python3 /tmp/.klm_fp.py ${roots}`;+      const out = await runDesktopShell(cmd, 180);+      const arr = JSON.parse(out.trim() || '[]');+      STOCK_FP_GEOM = Array.isArray(arr) ? arr : [];+      try { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(STOCK_GEOM_FILE, JSON.stringify(STOCK_FP_GEOM)); } catch {}+      console.log(`stock footprint geometry: fingerprinted ${STOCK_FP_GEOM.length} KiCad-default footprints`);+    } catch (e) { console.error('stock geom build failed', e.message); STOCK_FP_GEOM = STOCK_FP_GEOM || []; }+    finally { STOCK_FP_BUILD = null; }+    return STOCK_FP_GEOM;+  })();+  return STOCK_FP_BUILD;+}+// Rank your library footprints (+ KiCad stock) by SHAPE against a project footprint; fall back to name if no geometry.+function geomSuggestFp(projText, fallbackName, fpAll) {+  const projFp = footprintFingerprint(parseFootprintPads(projText || ''));+  const pool = (LIB_FP_GEOM || []).concat(STOCK_FP_GEOM || []);+  if (!projFp || !pool.length) return suggestFrom(fallbackName, fpAll);+  const scored = pool.map(g => ({ id: g.id, name: g.name, nick: g.nick, score: geomScoreFp(projFp, g.fp), geom: true, why: `${projFp.n} pads · ${projFp.pitch}mm pitch` }))+    .filter(x => x.score > 0.6).sort((a, b) => b.score - a.score).slice(0, 4);+  return scored.length ? scored : suggestFrom(fallbackName, fpAll);+}++// Build the personal-inventory + stock-nick lookups the classifier needs.+function buildInventory() {+  const symByNick = new Map(), fpByNick = new Map(), symAll = [], fpAll = [];+  const stockSym = new Set(), stockFp = new Set();+  for (const lib of DB.libs.symbolLibs) {+    if (lib.isDefault) { stockSym.add(lib.nick.toLowerCase()); continue; }+    const set = symByNick.get(lib.nick.toLowerCase()) || new Set();+    for (const s of lib.symbols) { set.add(s.name.toLowerCase()); symAll.push({ id: s.id, name: s.name, nick: lib.nick, ref: s.ref, value: s.value }); }+    symByNick.set(lib.nick.toLowerCase(), set);+  }+  for (const lib of DB.libs.footprintLibs) {+    if (lib.isDefault) { stockFp.add(lib.nick.toLowerCase()); continue; }+    const set = fpByNick.get(lib.nick.toLowerCase()) || new Set();+    for (const f of lib.footprints) { set.add(f.name.toLowerCase()); fpAll.push({ id: f.id, name: f.name, nick: lib.nick }); }+    fpByNick.set(lib.nick.toLowerCase(), set);+  }+  const lt = (DB.probe && DB.probe.libTable) || { sym: [], fp: [] };+  for (const e of (lt.sym || [])) if (e.stock) stockSym.add((e.nick || '').toLowerCase());+  for (const e of (lt.fp || [])) if (e.stock) stockFp.add((e.nick || '').toLowerCase());+  return { symByNick, fpByNick, symAll, fpAll, stockSym, stockFp };+}+function splitId(id) { const parts = (id || '').split(':'); return parts.length > 1 ? { nick: parts[0], name: parts.slice(1).join(':') } : { nick: '', name: id || '' }; }+function classifySym(inv, id) { const { nick, name } = splitId(id); const set = inv.symByNick.get(nick.toLowerCase()); if (set && set.has(name.toLowerCase())) return 'have'; if (inv.stockSym.has(nick.toLowerCase())) return 'stock'; return 'missing'; }+function classifyFp(inv, id) { const { nick, name } = splitId(id); const set = inv.fpByNick.get(nick.toLowerCase()); if (set && set.has(name.toLowerCase())) return 'have'; if (inv.stockFp.has(nick.toLowerCase())) return 'stock'; return 'missing'; }++// Re-derive every project item's status + the health counts from the CURRENT library inventory.+// Run on every state view so an imported part shows as "have" immediately and undo reverts it —+// the project model stays a pure projection over the live libs, never a stale snapshot.+function recomputeProjectStatuses() {+  if (!DB.project) return;+  const inv = buildInventory();+  for (const g of DB.project.symbolLibs) {+    for (const it of g.items) {+      it.status = classifySym(inv, it.symId || it.id);+      it.editInfo = null;+      if (it.edit) {+        const info = {};+        if (it.edit.symbol) info.symbol = resolveSymTarget(inv, it.nick, it.edit.symbol);+        if (it.edit.footprint != null) info.footprint = resolveFpTarget(inv, splitId(it.footprint || '').nick, it.edit.footprint);+        if (it.edit.value != null) info.value = { action: 'change' };+        it.editInfo = info;+      }+    }+  }+  for (const g of DB.project.footprintLibs) {+    for (const it of g.items) {+      it.status = classifyFp(inv, it.id);+      it.editInfo = (it.edit && it.edit.footprint != null) ? { footprint: resolveFpTarget(inv, it.nick, it.edit.footprint) } : null;+    }+  }+  let green = 0, amber = 0, red = 0, badSymParts = 0, badFpParts = 0;+  for (const ins of (DB.project._instances || [])) {+    const ss = classifySym(inv, ins.libId), fs = ins.fp ? classifyFp(inv, ins.fp) : 'have';+    if (ss === 'missing' || fs === 'missing') red++; else if (ss === 'stock') amber++; else green++;+    if (ss !== 'have') badSymParts++; if (ins.fp && fs === 'missing') badFpParts++;+  }+  const total = (DB.project._instances || []).length;+  const symItems = DB.project.symbolLibs.flatMap(g => g.items), fpItems = DB.project.footprintLibs.flatMap(g => g.items);+  DB.project.counts = {+    parts: total, green, amber, red,+    pct: total ? Math.round((100 * green) / total) : 100,+    loadsPct: total ? Math.round((100 * (green + amber)) / total) : 100,+    symbols: symItems.length, symbolsBad: symItems.filter(e => e.status !== 'have').length,+    footprints: fpItems.length, footprintsBad: fpItems.filter(e => e.status === 'missing').length,+    badSymParts, badFpParts,+    edits: symItems.filter(e => e.edit).length + fpItems.filter(e => e.edit).length,+  };+}++async function scanProject(proPath) {+  const dir = dirOf(proPath), name = baseOf(proPath).replace(/\.kicad_pro$/i, '');+  const out = await runDesktopShell(scanProjectShell(dir), 120);+  const { schs, pcbs } = parseScanOutput(out);+  if (!schs.length && !pcbs.length) throw new Error('no .kicad_sch / .kicad_pcb found next to ' + baseOf(proPath));++  const instances = [], symDefs = new Map(), fpBlocks = new Map();+  for (const s of schs) collectSch(s.text, instances, symDefs);+  for (const pc of pcbs) collectPcb(pc.text, fpBlocks);++  const inv = buildInventory();+  await libFpGeom();                              // fingerprint your library footprints (cached) for shape matching+  await stockFpGeom();                            // + the KiCad default footprint libraries (cached to disk)+  const symStatus = id => classifySym(inv, id);+  const fpStatus = id => classifyFp(inv, id);++  // ---- distinct symbols+  const symMap = new Map();+  // one row per distinct (symbol, footprint) pair — a symbol used with 2 footprints lists as 2 rows+  for (const ins of instances) {+    const key = ins.libId + ' | ' + (ins.fp || '');+    let e = symMap.get(key);+    if (!e) { const { nick, name } = splitId(ins.libId); e = { id: key, symId: ins.libId, nick, name, footprint: ins.fp || '', refs: [], value: ins.value, count: 0, status: symStatus(ins.libId), _block: symDefs.get(ins.libId) || symDefs.get('name:' + name.toLowerCase()) || null, _vals: new Set() }; symMap.set(key, e); }+    e.count++; if (ins.ref && e.refs.length < 60) e.refs.push(ins.ref); if (ins.value) e._vals.add(ins.value); if (!e.value && ins.value) e.value = ins.value;+  }+  for (const e of symMap.values()) {+    e.value = e._vals.size === 1 ? [...e._vals][0] : (e._vals.size > 1 ? [...e._vals][0] : (e.value || ''));+    e.valMulti = e._vals.size > 1; delete e._vals;+    // recommendations shown the moment you click a cell — symbols by name+value, footprints by shape+    e.suggestions = suggestSymbols(e, inv.symAll);+    // footprint recs use SHAPE matching against the project's placed footprint (from the board)+    e.fpSuggestions = e.footprint ? geomSuggestFp(fpBlocks.get(splitId(e.footprint).name.toLowerCase()), splitId(e.footprint).name, inv.fpAll) : [];+  }++  // ---- distinct footprints+  const fpMap = new Map();+  for (const ins of instances) {+    if (!ins.fp) continue;+    let e = fpMap.get(ins.fp);+    if (!e) { const { nick, name } = splitId(ins.fp); e = { id: ins.fp, nick, name, refs: [], count: 0, status: fpStatus(ins.fp), _block: fpBlocks.get(name.toLowerCase()) || null }; fpMap.set(ins.fp, e); }+    e.count++; if (ins.ref && e.refs.length < 60) e.refs.push(ins.ref);+  }+  for (const e of fpMap.values()) e.suggestions = geomSuggestFp(e._block, e.name, inv.fpAll);++  // ---- group by the part's own NAME (not the author's library), bad items first within each name+  const order = { missing: 0, stock: 1, have: 2, imported: 3 };+  const group = (map) => {+    const g = new Map();+    for (const e of map.values()) { const key = e.name; const grp = g.get(key) || { name: e.name, items: [] }; grp.items.push(e); g.set(key, grp); }+    const arr = [...g.values()]; for (const grp of arr) grp.items.sort((a, b) => (order[a.status] - order[b.status]) || (a.footprint || '').localeCompare(b.footprint || ''));+    return arr.sort((a, b) => a.name.localeCompare(b.name));+  };++  // Statuses + health (the three buckets red/amber/green) are derived live by+  // recomputeProjectStatuses() so the project model always reflects the current library.+  DB.project = {+    pro: proPath, dir, name, scannedAt: new Date().toISOString(),+    symbolLibs: group(symMap), footprintLibs: group(fpMap), counts: {},+    _instances: instances.map(i => ({ libId: i.libId, fp: i.fp })),+  };+  recomputeProjectStatuses();+  save();+  return DB.project;+}++// slim copy of DB.project for the API (drop the heavy embedded raw blocks, keep a hasBlock flag)+function projectView() {+  if (!DB.project) return null;+  recomputeProjectStatuses();   // always reflect the live library (imports, undo, sync)+  const slim = it => { const { _block, ...rest } = it; return { ...rest, hasBlock: !!_block }; };+  const map = arr => (arr || []).map(g => ({ name: g.name, items: g.items.map(slim) }));+  const p = DB.project;+  return { pro: p.pro, dir: p.dir, name: p.name, scannedAt: p.scannedAt, counts: p.counts, symbolLibs: map(p.symbolLibs), footprintLibs: map(p.footprintLibs) };+}+function projectFindItem(kind, id) {+  if (!DB.project) return null;+  for (const g of (kind === 'symbol' ? DB.project.symbolLibs : DB.project.footprintLibs)) { const it = g.items.find(x => x.id === id); if (it) return it; }+  return null;+}+// Record an inline edit on a project item. Edits ONLY ever change the downloaded project (never+// your library): on Sync we rewrite the project's .kicad_sch/.kicad_pcb references. Empty value+// clears the edit. The rename-vs-change meaning is derived live in recomputeProjectStatuses.+function projectEdit(kind, id, field, value) {+  const it = projectFindItem(kind, id); if (!it) throw new Error('project item not found');+  if (!['symbol', 'footprint', 'value'].includes(field)) throw new Error('bad field: ' + field);+  if (kind === 'footprint' && field !== 'footprint') throw new Error('footprint rows only edit the footprint');+  value = (value == null ? '' : String(value)).trim();+  it.edit = it.edit || {};+  const orig = field === 'symbol' ? it.name : field === 'footprint' ? (kind === 'symbol' ? (it.footprint || '') : it.name) : (it.value || '');+  if (value === '' || value === orig) delete it.edit[field]; else it.edit[field] = value;+  if (!Object.keys(it.edit).length) delete it.edit;+  save();+  recomputeProjectStatuses();+  return it;+}+// All pending project edits, normalised for the rewrite (one per affected reference designator).+function collectProjectEdits() {+  if (!DB.project) return { sym: [], fp: [] };+  const inv = buildInventory();+  const sym = [], fp = [];+  for (const g of DB.project.symbolLibs) for (const it of g.items) {+    if (!it.edit) continue;+    const e = { refs: it.refs || [], oldId: it.id };+    if (it.edit.symbol) { const r = resolveSymTarget(inv, it.nick, it.edit.symbol); e.newLibId = r.libId; e.action = r.action; }+    if (it.edit.footprint != null) { const r = resolveFpTarget(inv, splitId(it.footprint || '').nick, it.edit.footprint); e.newFp = r.fpId; }+    if (it.edit.value != null) e.newValue = it.edit.value;+    sym.push(e);+  }+  for (const g of DB.project.footprintLibs) for (const it of g.items) {+    if (!it.edit || it.edit.footprint == null) continue;+    const r = resolveFpTarget(inv, it.nick, it.edit.footprint);+    fp.push({ refs: it.refs || [], oldFp: it.id, newFp: r.fpId, action: r.action });+  }+  return { sym, fp };+}+// Given a typed symbol name, decide whether it CHANGES the project to one of your library symbols+// (name exists in your library → repoint to that nick) or just RENAMES it within the project.+// origNick is the part's current library nick, used as the fallback nick for a rename.+function resolveSymTarget(inv, origNick, typed) {+  const { nick: tn, name: tname } = splitId(typed.includes(':') ? typed : ':' + typed);+  for (const [nick, set] of inv.symByNick) if ((!tn || nick === tn.toLowerCase()) && set.has(tname.toLowerCase())) {+    const realNick = [...DB.libs.symbolLibs].find(l => l.nick.toLowerCase() === nick && !l.isDefault);+    return { libId: (realNick ? realNick.nick : tn) + ':' + tname, action: 'change' };+  }+  return { libId: (origNick || tn) ? ((origNick || tn) + ':' + tname) : tname, action: 'rename' };+}+function resolveFpTarget(inv, origNick, typed) {+  const { nick: tn, name: tname } = splitId(typed.includes(':') ? typed : ':' + typed);+  for (const [nick, set] of inv.fpByNick) if ((!tn || nick === tn.toLowerCase()) && set.has(tname.toLowerCase())) {+    const realNick = [...DB.libs.footprintLibs].find(l => l.nick.toLowerCase() === nick && !l.isDefault);+    return { fpId: (realNick ? realNick.nick : tn) + ':' + tname, action: 'change' };+  }+  // a KiCad stock footprint library (registered globally) also resolves → treat as a change, not a rename+  if (tn && inv.stockFp.has(tn.toLowerCase())) return { fpId: tn + ':' + tname, action: 'change' };+  return { fpId: (origNick || tn) ? ((origNick || tn) + ':' + tname) : tname, action: 'rename' };+}++// ---- rewrite the project files to apply the edits (the only thing Doctor writes). Backs up each+// file to .klm.bak first, then surgically rewrites each instance/footprint by reference designator.+function refSetFromEdits(edits) { const m = new Map(); for (const e of edits) for (const r of (e.refs || [])) m.set(r, e); return m; }+function blockRef(block) {+  const m = block.match(/\(property\s+"Reference"\s+"((?:[^"\\]|\\.)*)"/) || block.match(/\(fp_text\s+reference\s+"((?:[^"\\]|\\.)*)"/) || block.match(/\(property\s+"Reference"\s+"([^"]*)"/);+  return m ? unesc(m[1]) : '';+}+function rewriteSchText(text, symByRef, fpByRef, defs) {+  let out = '', i = 0;+  for (const block of walkBlocks(text, 'symbol')) {+    const at = text.indexOf(block, i); if (at < 0) continue;+    out += text.slice(i, at); i = at + block.length;+    if (/^\(symbol\s*\(/.test(block)) {                       // an instance+      const ref = blockRef(block); let b = block;+      const se = symByRef.get(ref), fe = fpByRef.get(ref);+      if (se) {+        if (se.newLibId) b = b.replace(/\(lib_id\s+"(?:[^"\\]|\\.)*"/, `(lib_id "${escStr(se.newLibId)}"`);+        if (se.newValue != null) b = b.replace(/(\(property\s+"Value"\s+")(?:[^"\\]|\\.)*(")/, `$1${escStr(se.newValue)}$2`);+        if (se.newFp != null) b = b.replace(/(\(property\s+"Footprint"\s+")(?:[^"\\]|\\.)*(")/, `$1${escStr(se.newFp)}$2`);+      }+      if (fe && fe.newFp) b = b.replace(/(\(property\s+"Footprint"\s+")(?:[^"\\]|\\.)*(")/, `$1${escStr(fe.newFp)}$2`);+      out += b;+    } else out += block;+  }+  out += text.slice(i);+  return ensureLibSymbols(out, defs);+}+// Every instance's lib_id must have a matching entry in the schematic's embedded (lib_symbols …)+// cache or KiCad shows it as "missing". After a remap, inject the target symbol's definition.+function ensureLibSymbols(text, defs) {+  if (!defs || !defs.size) return text;+  const idx = text.indexOf('(lib_symbols'); if (idx < 0) return text;+  const block = balanced(text, idx);+  const need = [];+  for (const [id, def] of defs) {+    if (!text.includes(`(lib_id "${id}"`)) continue;             // not used in this file+    const re = new RegExp('\\(symbol\\s+"' + id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '"');+    if (re.test(block)) continue;                                 // already cached+    need.push(def);+  }+  if (!need.length) return text;+  const insertAt = idx + block.length - 1;                        // just before lib_symbols' closing ')'+  return text.slice(0, insertAt) + need.map(b => '\t\t' + b.trim() + '\n').join('') + '\t' + text.slice(insertAt);+}+function rewritePcbText(text, fpByRef) {+  let out = '', i = 0;+  for (const block of walkBlocks(text, 'footprint')) {+    const at = text.indexOf(block, i); if (at < 0) continue;+    out += text.slice(i, at); i = at + block.length;+    const ref = blockRef(block); const fe = fpByRef.get(ref); let b = block;+    if (fe && fe.newFp) b = b.replace(/^\(footprint\s+"(?:[^"\\]|\\.)*"/, `(footprint "${escStr(fe.newFp)}"`);+    out += b;+  }+  out += text.slice(i);+  return out;+}+async function rewriteProject() {+  const log = [];+  if (!DB.project) return log;+  const { sym, fp } = collectProjectEdits();+  if (!sym.length && !fp.length) return log;+  const dir = DB.project.dir;+  const out = await runDesktopShell(scanProjectShell(dir), 120);+  const { schs, pcbs } = parseScanOutput(out);+  const symByRef = refSetFromEdits(sym);+  const fpByRefSch = refSetFromEdits([...sym.filter(e => e.newFp != null), ...fp]);+  const fpByRefPcb = refSetFromEdits([...fp, ...sym.filter(e => e.newFp != null).map(e => ({ refs: e.refs, newFp: e.newFp }))]);+  // For each symbol remap, the new lib_id needs a (lib_symbols …) cache entry: use YOUR library+  // symbol's geometry for a 'change', or the project's own embedded def (renamed) for a 'rename'.+  const defs = new Map();+  for (const e of sym) {+    if (!e.newLibId || defs.has(e.newLibId)) continue;+    const { nick, name } = splitId(e.newLibId);+    let raw = null;+    if (e.action === 'change') {+      const lib = DB.libs.symbolLibs.find(l => l.nick.toLowerCase() === nick.toLowerCase() && !l.isDefault);+      const s = lib && lib.symbols.find(x => x.name.toLowerCase() === name.toLowerCase());+      if (s) raw = renameSymInBlock(s.raw, s.name, e.newLibId);+    }+    if (!raw) { const it = projectFindItem('symbol', e.oldId); if (it && it._block) raw = renameSymInBlock(it._block, it.symId || it.id, e.newLibId); }+    if (raw) defs.set(e.newLibId, raw);+  }+  for (const s of schs) {+    const nt = rewriteSchText(s.text, symByRef, fpByRefSch, defs);+    if (nt === s.text) continue;+    const tmp = path.join(DATA_DIR, 'out'); fs.mkdirSync(tmp, { recursive: true });+    const local = path.join(tmp, baseOf(s.path)); fs.writeFileSync(local, nt);+    const sent = await sendFiles([local], dirOf(s.path), baseOf(s.path), { backup: true });+    log.push(`${sent.ok ? 'OK' : 'FAIL'} schematic ${baseOf(s.path)}`);+  }+  for (const pc of pcbs) {+    const nt = rewritePcbText(pc.text, fpByRefPcb);+    if (nt === pc.text) continue;+    const tmp = path.join(DATA_DIR, 'out'); fs.mkdirSync(tmp, { recursive: true });+    const local = path.join(tmp, baseOf(pc.path)); fs.writeFileSync(local, nt);+    const sent = await sendFiles([local], dirOf(pc.path), baseOf(pc.path), { backup: true });+    log.push(`${sent.ok ? 'OK' : 'FAIL'} board ${baseOf(pc.path)}`);+  }+  log.push(`Applied ${sym.length + fp.length} project edit(s)`);+  // re-scan from the rewritten files so statuses + health reflect reality (edits are now on disk)+  try { const pro = DB.project.pro; await scanProject(pro); } catch (e) { log.push('note: re-scan after rewrite failed — ' + e.message); }+  return log;+}+function projectPendingCount() {+  if (!DB.project) return 0; let n = 0;+  for (const g of DB.project.symbolLibs) for (const it of g.items) if (it.edit) n++;+  for (const g of DB.project.footprintLibs) for (const it of g.items) if (it.edit) n++;+  return n;+}+ // ----------------------------------------------------------------- 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; }@@ -1127,6 +1678,7 @@ function stateView() {   return {     libs: DB.libs,     probe: DB.probe,+    project: projectView(),     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(),@@ -1147,13 +1699,14 @@ const server = http.createServer(async (req, res) => {       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 }) => {+      probe(sel).then(({ libs, tablesFound, libTable, 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();+        LIB_FP_GEOM = null;                       // library footprints changed → drop cached shape fingerprints         historyInit('probe');-        DB.probe = { status: 'done', probedAt: new Date().toISOString(), edas: sel, tablesFound, counts, error: null };+        DB.probe = { status: 'done', probedAt: new Date().toISOString(), edas: sel, tablesFound, libTable, 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' });@@ -1316,6 +1869,29 @@ const server = http.createServer(async (req, res) => {       catch (e) { return sendJSON(res, 500, { error: e.message }); }     } +    // ---- Project Doctor+    if (p === '/api/projects' && req.method === 'POST') {+      try { const projects = await findProjects(); return sendJSON(res, 200, { ok: true, projects }); }+      catch (e) { return sendJSON(res, 500, { error: e.message }); }+    }+    if (p === '/api/project-scan' && req.method === 'POST') {+      const { pro } = await readJSON(req);+      if (!pro) return sendJSON(res, 400, { error: 'pro path required' });+      try { await scanProject(pro); return sendJSON(res, 200, { ok: true, project: projectView() }); }+      catch (e) { return sendJSON(res, 500, { error: e.message }); }+    }+    if (p === '/api/project-close' && req.method === 'POST') { DB.project = null; save(); return sendJSON(res, 200, { ok: true }); }+    if (p === '/api/stock-footprints' && req.method === 'GET') {        // KiCad default footprints, for the remap autocomplete+      try { await stockFpGeom(); return sendJSON(res, 200, { ok: true, footprints: (STOCK_FP_GEOM || []).map(g => ({ value: g.id, name: g.name })) }); }+      catch (e) { return sendJSON(res, 500, { error: e.message }); }+    }+    if (p === '/api/project-edit' && req.method === 'POST') {+      const { kind, id, field, value } = await readJSON(req);+      if (!kind || !id || !field) return sendJSON(res, 400, { error: 'kind, id and field required' });+      try { projectEdit(kind, id, field, value); return sendJSON(res, 200, { ok: true, project: projectView() }); }+      catch (e) { return sendJSON(res, 400, { error: e.message }); }+    }+     return sendJSON(res, 404, { error: 'no route' });   } catch (e) {     return sendJSON(res, 500, { error: String(e && e.message || e) });