Covers: file map, KV prefix table, all data model shapes, full API route index, frontend section layout, image file lifecycle, code style preferences, git conventions, and a pointer to future-plans.md. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
393 lines
14 KiB
Markdown
393 lines
14 KiB
Markdown
# CLAUDE.md — Electronics Inventory
|
||
|
||
Agent orientation file. Read this before touching code.
|
||
|
||
---
|
||
|
||
## What this project is
|
||
|
||
A self-hosted electronics inventory web app. Tracks components, PDFs/datasheets,
|
||
physical storage grids (photographed and de-perspectived), and bins. Single-user,
|
||
no auth. Node.js + Express 5 backend, vanilla JS SPA frontend, flat NDJSON
|
||
key-value store for persistence.
|
||
|
||
---
|
||
|
||
## File map
|
||
|
||
```
|
||
server.mjs Entry point. All Express routes. ~940 lines.
|
||
lib/
|
||
storage.mjs KV store CRUD wrappers (one section per entity type).
|
||
Read this to understand what data exists and its prefix.
|
||
kv-store.mjs Flat NDJSON key-value store (Simple_KeyValue_Store class).
|
||
Auto-loads on construct, auto-flushes with debounce.
|
||
grid-image.mjs Image processing: de-perspective a photo into a grid of
|
||
cell images using sharp. compute_bin_size() lives here.
|
||
ids.mjs generate_id() — timestamp-base36 + random suffix.
|
||
(Planned: migrate to sequential integers.)
|
||
public/
|
||
app.mjs SPA. All rendering, routing, dialog logic. ~2600 lines.
|
||
Sections separated by // --- comments.
|
||
lib/api.mjs All fetch wrappers. Read this for the full API surface.
|
||
lib/dom.mjs Tiny DOM helpers: qs(), clone(), show(), hide().
|
||
views/grid-setup.mjs Grid_Setup class — canvas corner editor with pan/zoom.
|
||
Used for both grid source images and bin corner editing.
|
||
templates.html All HTML templates (id="t-*"). Injected into body at init.
|
||
style.css All styles. Single file (planned: split per section).
|
||
index.html Shell. Loads app.mjs as module. Nav buttons hardcoded.
|
||
tools/
|
||
mv-sync.c / mv-sync renameat2(RENAME_NOREPLACE) binary for atomic rename
|
||
without overwrite. Used by settle_image_filename().
|
||
Makefile Builds mv-sync.
|
||
data/
|
||
inventory.ndjson The database. All entities in one flat KV file.
|
||
images/ All uploaded and processed images.
|
||
pdfs/ Uploaded PDF files.
|
||
thumbs/ PDF thumbnails (generated by pdftoppm).
|
||
```
|
||
|
||
---
|
||
|
||
## KV store — key prefixes
|
||
|
||
| Prefix | Entity | Storage function family |
|
||
|---------|---------------------|---------------------------|
|
||
| `f:` | Field definitions | get_field / set_field |
|
||
| `c:` | Components | get_component / set_component |
|
||
| `i:` | Inventory entries | get_inventory_entry / set_inventory_entry |
|
||
| `d:` | Grid drafts | get_grid_draft / set_grid_draft |
|
||
| `s:` | Source images | get_source_image / add_source_image |
|
||
| `g:` | Grid images | get_grid_image / set_grid_image |
|
||
| `ct:` | Component templates | get_component_template / set_component_template |
|
||
| `pdf:` | PDF files | get_pdf / set_pdf |
|
||
| `bt:` | Bin types | get_bin_type / set_bin_type |
|
||
| `bin:` | Bins | get_bin / set_bin |
|
||
|
||
All `list_*()` functions do a full-scan `startsWith(prefix)` over the store.
|
||
|
||
---
|
||
|
||
## Data model shapes
|
||
|
||
### Field definition (`f:`)
|
||
```js
|
||
{
|
||
id: string, // generate_id()
|
||
name: string, // e.g. 'resistance'
|
||
unit: string, // e.g. 'Ω' — optional
|
||
description: string,
|
||
created_at: number, // ms timestamp
|
||
}
|
||
```
|
||
|
||
### Component (`c:`)
|
||
```js
|
||
{
|
||
id: string,
|
||
name: string,
|
||
description: string,
|
||
fields: { [field_id]: string }, // values keyed by field definition id
|
||
images: string[], // filenames in data/images/
|
||
file_ids: string[], // linked PDF ids
|
||
created_at: number,
|
||
updated_at: number,
|
||
}
|
||
```
|
||
|
||
### Inventory entry (`i:`)
|
||
```js
|
||
{
|
||
id: string,
|
||
component_id: string,
|
||
location_type: 'physical' | 'bom' | 'digital' | 'grid',
|
||
location_ref: string, // free text for physical/bom/digital
|
||
quantity: string,
|
||
notes: string,
|
||
grid_id: string | null, // set when location_type === 'grid'
|
||
grid_row: number | null,
|
||
grid_col: number | null,
|
||
images: string[],
|
||
created_at: number,
|
||
updated_at: number,
|
||
}
|
||
```
|
||
|
||
### Source image (`s:`)
|
||
```js
|
||
{
|
||
id: string, // filename in data/images/ (used as key too)
|
||
original_name: string,
|
||
width: number,
|
||
height: number,
|
||
uses: ('grid' | 'bin')[], // which features reference this image
|
||
created_at: number,
|
||
}
|
||
```
|
||
|
||
### Grid draft (`d:`)
|
||
```js
|
||
{
|
||
id: string,
|
||
source_id: string, // source image filename
|
||
rows: number,
|
||
cols: number,
|
||
corners: [{x,y}, {x,y}, {x,y}, {x,y}], // TL, TR, BR, BL in image coords
|
||
created_at: number,
|
||
updated_at: number,
|
||
}
|
||
```
|
||
|
||
### Grid image (`g:`) — result of processing a grid draft
|
||
```js
|
||
{
|
||
id: string,
|
||
source_id: string,
|
||
rows: number,
|
||
cols: number,
|
||
corners: [{x,y}, ...],
|
||
panels: [[{ filename, component_id?, notes? }, ...], ...], // [row][col]
|
||
created_at: number,
|
||
updated_at: number,
|
||
}
|
||
```
|
||
|
||
### Component template (`ct:`)
|
||
```js
|
||
{
|
||
id: string,
|
||
name: string,
|
||
formatter: string, // JS function body string, compiled at runtime
|
||
created_at: number,
|
||
updated_at: number,
|
||
}
|
||
```
|
||
|
||
### PDF (`pdf:`)
|
||
```js
|
||
{
|
||
id: string,
|
||
display_name: string,
|
||
filename: string, // in data/pdfs/
|
||
thumb_prefix: string, // in data/thumbs/ — pdftoppm output prefix
|
||
created_at: number,
|
||
}
|
||
```
|
||
|
||
### Bin type (`bt:`)
|
||
```js
|
||
{
|
||
id: string,
|
||
name: string,
|
||
phys_w: number, // mm
|
||
phys_h: number, // mm
|
||
description: string,
|
||
fields: { [field_id]: string },
|
||
created_at: number,
|
||
updated_at: number,
|
||
}
|
||
```
|
||
|
||
### Bin (`bin:`)
|
||
```js
|
||
{
|
||
id: string,
|
||
name: string,
|
||
type_id: string | null, // ref to bin type
|
||
source_id: string, // source image filename (always kept)
|
||
source_w: number,
|
||
source_h: number,
|
||
corners: [{x,y}, {x,y}, {x,y}, {x,y}], // TL, TR, BR, BL in image coords
|
||
phys_w: number | null, // mm — null means infer from corners
|
||
phys_h: number | null,
|
||
image_filename: string | null, // processed output in data/images/; null if not yet processed
|
||
bin_w: number | null, // px dimensions of processed output
|
||
bin_h: number | null,
|
||
fields: { [field_id]: string },
|
||
contents: [ // embedded content items
|
||
{
|
||
id: string,
|
||
type: 'component' | 'item',
|
||
component_id: string | null, // set when type === 'component'
|
||
name: string | null, // set when type === 'item'
|
||
quantity: string,
|
||
notes: string,
|
||
created_at: number,
|
||
}
|
||
],
|
||
created_at: number,
|
||
updated_at: number,
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## API routes (server.mjs)
|
||
|
||
All responses: `{ ok: true, ...data }` or `{ ok: false, error: string }`.
|
||
|
||
```
|
||
GET /api/fields
|
||
POST /api/fields body: { name, unit?, description? }
|
||
PUT /api/fields/:id
|
||
DELETE /api/fields/:id
|
||
|
||
GET /api/components
|
||
POST /api/components body: { name, description?, fields? }
|
||
GET /api/components/:id
|
||
PUT /api/components/:id body: { name?, description?, fields?, file_ids? }
|
||
DELETE /api/components/:id
|
||
POST /api/components/:id/images multipart: images[]
|
||
DELETE /api/components/:id/images/:img_id
|
||
|
||
GET /api/inventory
|
||
POST /api/inventory body: { component_id, location_type, ... }
|
||
PUT /api/inventory/:id
|
||
DELETE /api/inventory/:id
|
||
POST /api/inventory/:id/images multipart: images[]
|
||
DELETE /api/inventory/:id/images/:img_id
|
||
|
||
GET /api/grid-drafts
|
||
POST /api/grid-drafts body: { source_id, rows, cols }
|
||
PUT /api/grid-drafts/:id
|
||
DELETE /api/grid-drafts/:id
|
||
|
||
GET /api/source-images
|
||
POST /api/source-images multipart: images[] — creates source records (uses: ['grid'])
|
||
PUT /api/source-images/:id body: { uses }
|
||
DELETE /api/source-images/:id guarded: refused if any grid/bin still references it
|
||
|
||
GET /api/grid-images
|
||
GET /api/grid-images/:id
|
||
POST /api/grid-images body: { draft_id } — processes draft → grid image
|
||
PUT /api/grid-images/:id/panels/:pi body: { component_id?, notes? }
|
||
DELETE /api/grid-images/:id
|
||
|
||
GET /api/component-templates
|
||
POST /api/component-templates body: { name, formatter }
|
||
PUT /api/component-templates/:id
|
||
DELETE /api/component-templates/:id
|
||
|
||
GET /api/pdfs
|
||
POST /api/pdfs multipart: file, display_name, filename
|
||
PUT /api/pdfs/:id body: { display_name, filename }
|
||
DELETE /api/pdfs/:id guarded: refused if any component references it
|
||
|
||
GET /api/bin-types
|
||
POST /api/bin-types body: { name, phys_w, phys_h, description?, fields? }
|
||
PUT /api/bin-types/:id
|
||
DELETE /api/bin-types/:id guarded: refused if any bin references it
|
||
|
||
GET /api/bins
|
||
GET /api/bins/:id
|
||
POST /api/bins multipart: image, name?, type_id? — upload + create
|
||
POST /api/bins/from-source body: { source_id, name?, type_id? }
|
||
PUT /api/bins/:id body: { name?, type_id?, fields? }
|
||
PUT /api/bins/:id/corners body: { corners, phys_w?, phys_h? } — triggers reprocess
|
||
DELETE /api/bins/:id only deletes processed image_filename, not source
|
||
POST /api/bins/:id/contents body: { type, component_id?, name?, quantity, notes }
|
||
PUT /api/bins/:id/contents/:cid body: { quantity?, notes?, name? }
|
||
DELETE /api/bins/:id/contents/:cid
|
||
|
||
POST /api/maintenance/purge-missing-sources removes source KV entries whose files are gone
|
||
POST /api/maintenance/pdf-thumbs regenerates missing PDF thumbnails
|
||
```
|
||
|
||
---
|
||
|
||
## Frontend (app.mjs) structure
|
||
|
||
Module-level state variables at the top (`all_components`, `all_fields`, etc.).
|
||
All loaded once at startup via parallel API calls, mutated in place on changes.
|
||
|
||
**Key patterns:**
|
||
- `clone('t-template-id')` — clones a `<template>` into a live element
|
||
- `qs(el, '#id')` — scoped querySelector
|
||
- Dialog callbacks stored as module-level `let x_dialog_callback = null`,
|
||
set by the open function, called by the init-registered submit/save handler
|
||
- `render()` — top-level re-render, called after navigation or data changes
|
||
- `navigate(path)` — pushes history + calls render()
|
||
- `build_field_editor(rows_el, sel_el, new_btn_el, initial_fields)` — shared
|
||
helper that wires up field row editing; returns `{ get_fields() }`
|
||
|
||
**Section layout (by line range, approximate):**
|
||
```
|
||
1–90 Imports, state vars, startup data load
|
||
91–250 Helper functions (field rendering, search, formatters)
|
||
251–700 Components section (list, detail panel, edit dialog)
|
||
701–1000 Inventory section
|
||
1001–1600 Grids section (list, draft editor, grid viewer)
|
||
1601–1900 Component templates, field definitions dialogs
|
||
1901–2000 Images admin section
|
||
2001–2450 Bins section (list, source list, types list, bin editor)
|
||
2450–2500 Routing (parse_url, navigate, render)
|
||
2500–end init() — dialog injection, event handler registration
|
||
```
|
||
|
||
---
|
||
|
||
## Image file lifecycle
|
||
|
||
```
|
||
Upload → multer writes temp file to data/images/<generate_id()><ext>
|
||
→ settle_image_filename() renames to original filename using
|
||
rename_no_replace (atomic, no overwrite); falls back to temp name
|
||
→ source image KV record created with id = final filename
|
||
|
||
Grid processing:
|
||
source image + corners + rows/cols → sharp perspective transform
|
||
→ one cell image per panel → filenames stored in grid_image.panels[r][c]
|
||
|
||
Bin processing:
|
||
source image + corners → single de-perspectived image
|
||
→ stored as bin.image_filename (separate from bin.source_id)
|
||
→ source_id is never deleted when bin is deleted; only image_filename is
|
||
```
|
||
|
||
---
|
||
|
||
## Code style (owner preferences)
|
||
|
||
- **Indentation**: tabs, display width 4
|
||
- **Braces**: always, even single-line bodies — `if (x) { return; }`
|
||
- **Naming**: `lower_snek_case` functions/locals/singletons,
|
||
`CAPITAL_SNEK_CASE` top-level constants, `Title_Snek_Case` classes
|
||
- **Quotes**: single quotes preferred
|
||
- **Modules**: `.mjs` extension always
|
||
- **State**: stateful components must be classes; no bare module-level
|
||
variables for app state (module scope is for constants and exports only)
|
||
- **IDs**: prefer sequential integers over timestamp+random (migration pending)
|
||
- **No CDN URLs** in HTML; vendor libs via make build from node_modules
|
||
- **Error handling**: try/catch ENOENT, never existsSync-then-read (TOCTOU)
|
||
- **No over-engineering**: don't add helpers, abstractions, or error handling
|
||
for scenarios that can't happen; three similar lines beats a premature helper
|
||
|
||
---
|
||
|
||
## Git conventions (this project)
|
||
|
||
```
|
||
git config user.name 'mikael-lovqvists-claude-agent'
|
||
git config user.email 'mikaels.claude.agent@efforting.tech'
|
||
```
|
||
|
||
- Small, logical commits — one concern per commit
|
||
- Stage files explicitly by name, never `git add -A` or `git add .`
|
||
- Always `git status` and read every line before staging
|
||
- Commit message: imperative mood, explain *why* not *what*
|
||
|
||
---
|
||
|
||
## Known limitations / planned rewrite notes
|
||
|
||
See [`future-plans.md`](future-plans.md) for full detail. Key points relevant
|
||
to coding decisions:
|
||
|
||
- `app.mjs` will be split into per-section view modules
|
||
- KV store will become hierarchical (no more prefix convention)
|
||
- IDs will migrate to sequential integers
|
||
- CSS will be split per section with a build-step concatenation
|
||
- SSE for live updates across devices
|
||
- Generic fields on all entity types (already done for components, bins, bin types)
|
||
- A complete rewrite is planned; this codebase is the learning prototype
|