app
KiCad Library Manager
Public Made by Adomby adom
Organize your KiCad symbol / footprint / 3D libraries fast — three priority views, inline editing, live previews, and one-click sync.
← Commit history
Publish 1.2.0
9 files changed
+999−19
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" }
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 & 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) });