Skip to content

Commit f2ca58b

Browse files
cinderblockclaude
andcommitted
Support in-place edits to .prj component databases
Encodes the mdb half of the project: now that ewd writes a JSON for .prj files, ewe accepts the (edited) JSON back and patches the original .prj in-place. Mechanism (per plans/jet-in-place-edit.md): - Diff the edited JSON's text/memo cell values against what mdb-reader reads from the original .prj. - For each change where the new value has the same byte length and the old value appears exactly once in the raw file, overwrite the bytes at that offset. Encoding is latin1 for Jet 3, UTF-16LE for Jet 4 (detected from the version byte at offset 0x14). - Anything that violates those constraints (length change, missing, ambiguous, non-text column) is skipped with a reason. The result summary lists `N applied, M skipped` and `--verbose` enumerates each skipped change. Out of scope for this commit (documented in the plan file): - Row reflow / different-length text edits. - Numeric / date / OLE column edits. - Empty <-> non-empty changes. - Targeted (table, row, column) lookups when a value is ambiguous; today that's just skipped. Verified end-to-end on a real Stocked.prj: changed a vendor part number `WM10134CT-ND` -> `WMTESTING-ND` in the JSON, ran ewe, decoded the patched .prj, and the new value reads back. 72 tests pass across 5 files; CI runs check / typecheck / test on Ubuntu and Windows. ewe now recognizes `.json` inputs as the mdb edit path and routes the existing `.xml` inputs through the compressed-xml encoder unchanged. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 22e0e08 commit f2ca58b

4 files changed

Lines changed: 366 additions & 17 deletions

File tree

README.md

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,28 @@ Dates become ISO 8601 strings; binary blob fields (`ole` columns) become
5555
`{ "_bytes": "base64", "value": "..." }` envelopes so the data survives a
5656
JSON round-trip.
5757

58-
**Encode (write-back) is not implemented yet.** Writing a valid Jet 3
59-
file from scratch would mean reimplementing Access's page/B-tree storage
60-
engine, which is a multi-month undertaking. The planned path is in-place
61-
edits only: read an existing `.prj`, change cell values that fit in the
62-
existing page layout, write back. That's tractable; a full Jet writer
63-
is not.
58+
**Encode is in-place-edit only.** Writing a valid Jet 3 file from
59+
scratch would mean reimplementing Access's page/B-tree storage engine,
60+
which is out of scope. Instead `ewe` patches an existing `.prj`:
61+
62+
1. You decode with `ewd` to produce `<file>.prj.json`.
63+
2. You edit the JSON in your text editor (or with a script).
64+
3. You run `ewe edited.json` and `ewe` diffs the edited JSON against
65+
the original `.prj`, then byte-patches each changed cell.
66+
67+
Today's limits (each enforced by the encoder, with a clear "skipped"
68+
report when violated):
69+
70+
- Only `text` and `memo` columns. Numeric / date / OLE edits are skipped.
71+
- New value must have the same byte length as the old value. Length
72+
changes would require row reflow / free-space accounting (planned).
73+
- Old value must appear exactly once in the file. If it's ambiguous
74+
(collides with another row) or missing, the change is skipped.
75+
76+
These constraints cover the typical vendor / price / MPN editing
77+
workflow because those values are usually unique and you usually
78+
substitute one same-length identifier for another. The skipped list
79+
tells you exactly when they don't.
6480

6581
## Format detection
6682

@@ -99,6 +115,8 @@ Options:
99115

100116
## Encode
101117

118+
### Compressed-XML (greenfield encode from `.xml`)
119+
102120
```bash
103121
bun run ewe --verbose ./samples/Temp.ewprj.xml
104122
# or with explicit output:
@@ -110,16 +128,34 @@ bun run ewe --format multisim --output ./out.dat ./samples/Design1.ms14.xml
110128
By default, strips a trailing `.xml` from each input to derive the output
111129
path. Format is inferred from the output extension (see the table above).
112130

113-
Options:
131+
### MDB in-place edits from `.json`
114132

115-
- `-v`, `--verbose` — log per-section progress
133+
```bash
134+
# Decode first to produce the editable JSON.
135+
bun run ewd ./samples/Stocked.prj
136+
# Edit ./samples/Stocked.prj.json in your editor / script.
137+
# Then re-encode: ewe diffs vs the original and writes a patched copy.
138+
bun run ewe --verbose ./samples/Stocked.prj.json
139+
# -> samples/Stocked.prj.patched.prj
140+
```
141+
142+
`ewe` recognizes `.json` inputs and routes them through the in-place
143+
edit path. The original `.prj` location is taken from the JSON's
144+
`source` field (or `--source <path>` to override). The patched copy
145+
goes to `<source>.patched.prj` (or `--output <path>` to override).
146+
147+
Each `--verbose` run prints `N applied, M skipped` and lists every
148+
skipped change with its reason. Skipped changes leave the file
149+
untouched at that cell.
150+
151+
### Options
152+
153+
- `-v`, `--verbose` — log progress
116154
- `-c`, `--concurrent` — encode multiple files in parallel
117155
- `-o`, `--output <path>` — explicit output path (single-input only)
118-
- `-f`, `--format <key>` — force a format (`ewprj` or `multisim`)
119-
- positional args — XML files to encode
120-
121-
Trying to encode an `mdb` target fails with a clear error until
122-
in-place-edit support lands.
156+
- `-s`, `--source <path>` — for MDB JSON inputs: override the source `.prj`
157+
- `-f`, `--format <key>` — for compressed-XML: force a format (`ewprj` or `multisim`)
158+
- positional args — XML or JSON files to encode
123159

124160
### Compression ratio caveat
125161

src/encodeMdb.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { describe, expect, test } from 'bun:test';
2+
import { applyChange, detectJetVersion, textEncodingForJet } from './encodeMdb';
3+
4+
function jetHeader(versionByte: number): Buffer {
5+
const buf = Buffer.alloc(64);
6+
buf[0] = 0x00;
7+
buf[1] = 0x01;
8+
buf[2] = 0x00;
9+
buf[3] = 0x00;
10+
buf.write('Standard Jet DB', 4, 'latin1');
11+
buf[0x14] = versionByte;
12+
return buf;
13+
}
14+
15+
describe('detectJetVersion', () => {
16+
test('returns 3 for version byte 0x00', () => {
17+
expect(detectJetVersion(jetHeader(0x00))).toBe(3);
18+
});
19+
20+
test('returns 4 for version byte 0x01', () => {
21+
expect(detectJetVersion(jetHeader(0x01))).toBe(4);
22+
});
23+
24+
test('throws on an unrecognized version byte', () => {
25+
expect(() => detectJetVersion(jetHeader(0x09))).toThrow(/Unknown Jet version/);
26+
});
27+
});
28+
29+
describe('textEncodingForJet', () => {
30+
test('Jet 3 uses latin1', () => {
31+
expect(textEncodingForJet(3)).toBe('latin1');
32+
});
33+
34+
test('Jet 4 uses utf16le', () => {
35+
expect(textEncodingForJet(4)).toBe('utf16le');
36+
});
37+
});
38+
39+
describe('applyChange', () => {
40+
const change = (oldValue: string, newValue: string) => ({
41+
table: 'T',
42+
rowIndex: 0,
43+
column: 'C',
44+
oldValue,
45+
newValue,
46+
});
47+
48+
test('patches a unique same-length value in latin1', () => {
49+
const buf = Buffer.from('prefix WM10134CT-ND suffix unique data', 'latin1');
50+
const r = applyChange(buf, change('WM10134CT-ND', 'WMTESTING-ND'), 'latin1');
51+
expect(r).toEqual({ ok: true, offset: 7 });
52+
expect(buf.toString('latin1')).toBe('prefix WMTESTING-ND suffix unique data');
53+
});
54+
55+
test('refuses when the byte length differs', () => {
56+
const buf = Buffer.from('hello world', 'latin1');
57+
const r = applyChange(buf, change('hello', 'hellos'), 'latin1');
58+
expect(r.ok).toBe(false);
59+
if (!r.ok) expect(r.reason).toMatch(/byte length differs/);
60+
expect(buf.toString('latin1')).toBe('hello world'); // unchanged
61+
});
62+
63+
test('refuses when the old value is missing', () => {
64+
const buf = Buffer.from('hello world', 'latin1');
65+
const r = applyChange(buf, change('absent', 'newval'), 'latin1');
66+
expect(r.ok).toBe(false);
67+
if (!r.ok) expect(r.reason).toMatch(/not present/);
68+
});
69+
70+
test('refuses when the old value is ambiguous', () => {
71+
const buf = Buffer.from('foo bar foo baz', 'latin1');
72+
const r = applyChange(buf, change('foo', 'qux'), 'latin1');
73+
expect(r.ok).toBe(false);
74+
if (!r.ok) expect(r.reason).toMatch(/2 times/);
75+
expect(buf.toString('latin1')).toBe('foo bar foo baz'); // unchanged
76+
});
77+
78+
test('works with utf16le (Jet 4) encoding', () => {
79+
const buf = Buffer.concat([
80+
Buffer.from('prefix', 'latin1'),
81+
Buffer.from('OldVal', 'utf16le'),
82+
Buffer.from('suffix uniq', 'latin1'),
83+
]);
84+
const r = applyChange(buf, change('OldVal', 'NewVal'), 'utf16le');
85+
expect(r.ok).toBe(true);
86+
if (r.ok) {
87+
const patched = buf.subarray(r.offset, r.offset + 12).toString('utf16le');
88+
expect(patched).toBe('NewVal');
89+
}
90+
});
91+
92+
test('only patches one occurrence (mutates buffer in place)', () => {
93+
const original = Buffer.from('aaa unique12 bbb', 'latin1');
94+
const buf = Buffer.from(original);
95+
const r = applyChange(buf, change('unique12', 'changed!'), 'latin1');
96+
expect(r.ok).toBe(true);
97+
expect(buf.toString('latin1')).toBe('aaa changed! bbb');
98+
expect(original.toString('latin1')).toBe('aaa unique12 bbb'); // original Buffer untouched
99+
});
100+
});

src/encodeMdb.ts

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/**
2+
* In-place edit support for NI EW component databases (Jet 3 / Jet 4).
3+
*
4+
* Reads an edited JSON (produced by `ewd`) alongside the original `.prj`,
5+
* diffs cell values, and applies same-length byte patches to the original's
6+
* raw bytes. The result is a modified copy of the original `.prj` that
7+
* `mdb-reader` (and presumably Multisim) accept.
8+
*
9+
* Constraints for the first cut (per `plans/jet-in-place-edit.md`):
10+
*
11+
* - Only `text` and `memo` columns are eligible. Numeric / date / OLE
12+
* edits are skipped silently — that's a planned future expansion.
13+
* - New value must have the same byte length as the old value (no
14+
* row reflow). Length-changing edits are skipped.
15+
* - Old value must appear exactly once in the raw file bytes. If it's
16+
* missing (already changed?) or ambiguous (repeats), the change is
17+
* skipped with a reason.
18+
*
19+
* Anything skipped is reported so the caller can decide what to do. The
20+
* "applied" + "skipped" breakdown is the primary return value.
21+
*/
22+
23+
import { promises as fs } from 'node:fs';
24+
import MDBReader from 'mdb-reader';
25+
import type winston from 'winston';
26+
import { type MdbJson, normalizeValue } from './decodeMdb';
27+
28+
export interface CellChange {
29+
table: string;
30+
rowIndex: number;
31+
column: string;
32+
oldValue: string;
33+
newValue: string;
34+
}
35+
36+
export interface SkippedChange {
37+
change: CellChange;
38+
reason: string;
39+
}
40+
41+
export interface PatchResult {
42+
applied: CellChange[];
43+
skipped: SkippedChange[];
44+
outFile: string;
45+
}
46+
47+
type JetVersion = 3 | 4;
48+
type JetTextEncoding = 'latin1' | 'utf16le';
49+
50+
export function detectJetVersion(buffer: Buffer): JetVersion {
51+
const v = buffer[0x14];
52+
if (v === 0x00) return 3;
53+
if (v === 0x01) return 4;
54+
throw new Error(`Unknown Jet version byte at offset 0x14: 0x${v?.toString(16) ?? '??'}`);
55+
}
56+
57+
export function textEncodingForJet(version: JetVersion): JetTextEncoding {
58+
return version === 3 ? 'latin1' : 'utf16le';
59+
}
60+
61+
function isEditableTextType(type: string): boolean {
62+
return type === 'text' || type === 'memo';
63+
}
64+
65+
/** Compute the cell-level diff between an edited JSON and the original DB bytes. */
66+
export function diffMdbAgainstOriginal(edited: MdbJson, originalBuffer: Buffer): CellChange[] {
67+
const db = new MDBReader(originalBuffer);
68+
const originalTables = new Set(db.getTableNames());
69+
const changes: CellChange[] = [];
70+
71+
for (const [tableName, tableJson] of Object.entries(edited.tables)) {
72+
if (!originalTables.has(tableName)) continue;
73+
74+
const editableColumns = new Set(tableJson.columns.filter(c => isEditableTextType(c.type)).map(c => c.name));
75+
if (editableColumns.size === 0) continue;
76+
77+
const origRows = db.getTable(tableName).getData() as Record<string, unknown>[];
78+
79+
for (let i = 0; i < tableJson.rows.length; i++) {
80+
const editedRow = tableJson.rows[i];
81+
const origRow = origRows[i];
82+
if (!origRow) continue;
83+
84+
for (const col of editableColumns) {
85+
const newVal = editedRow[col];
86+
const oldNormalized = normalizeValue(origRow[col]);
87+
if (newVal === oldNormalized) continue;
88+
if (typeof newVal !== 'string' || typeof oldNormalized !== 'string') continue;
89+
if (newVal === '' || oldNormalized === '') continue; // empty<->non-empty needs row reflow
90+
changes.push({
91+
table: tableName,
92+
rowIndex: i,
93+
column: col,
94+
oldValue: oldNormalized,
95+
newValue: newVal,
96+
});
97+
}
98+
}
99+
}
100+
101+
return changes;
102+
}
103+
104+
/**
105+
* Apply a single change to `buffer` in place. Returns the offset that was
106+
* patched on success, or a reason string on failure. The buffer is mutated.
107+
*/
108+
export function applyChange(
109+
buffer: Buffer,
110+
change: CellChange,
111+
encoding: JetTextEncoding,
112+
): { ok: true; offset: number } | { ok: false; reason: string } {
113+
const oldBytes = Buffer.from(change.oldValue, encoding);
114+
const newBytes = Buffer.from(change.newValue, encoding);
115+
116+
if (oldBytes.length !== newBytes.length) {
117+
return { ok: false, reason: `byte length differs (${oldBytes.length} -> ${newBytes.length})` };
118+
}
119+
120+
const offsets: number[] = [];
121+
let from = 0;
122+
for (;;) {
123+
const i = buffer.indexOf(oldBytes, from);
124+
if (i === -1) break;
125+
offsets.push(i);
126+
from = i + 1;
127+
}
128+
129+
if (offsets.length === 0) return { ok: false, reason: 'old value not present in raw bytes' };
130+
if (offsets.length > 1) return { ok: false, reason: `old value appears ${offsets.length} times (ambiguous)` };
131+
132+
newBytes.copy(buffer, offsets[0]);
133+
return { ok: true, offset: offsets[0] };
134+
}
135+
136+
export interface EncodeMdbOptions {
137+
/** Source `.prj` to use as the template. Defaults to the JSON's `source` field. */
138+
source?: string;
139+
/** Output path for the patched file. Defaults to `<source>.patched.prj`. */
140+
outFile?: string;
141+
}
142+
143+
export async function encodeMdb(
144+
editedJsonPath: string,
145+
logger: winston.Logger,
146+
options: EncodeMdbOptions = {},
147+
): Promise<PatchResult> {
148+
const editedJson = JSON.parse(await fs.readFile(editedJsonPath, 'utf8')) as MdbJson;
149+
150+
if (editedJson.format !== 'mdb') {
151+
throw new Error(`Expected an mdb JSON document, got format=${String(editedJson.format)}`);
152+
}
153+
154+
const sourcePath = options.source ?? editedJson.source;
155+
if (!sourcePath) {
156+
throw new Error('No --source given and the JSON has no "source" field; cannot locate the template .prj.');
157+
}
158+
159+
const outFile = options.outFile ?? `${sourcePath}.patched.prj`;
160+
161+
logger.verbose(`Loading template ${sourcePath}`);
162+
const originalReadOnly = await fs.readFile(sourcePath);
163+
const jetVersion = detectJetVersion(originalReadOnly);
164+
const encoding = textEncodingForJet(jetVersion);
165+
logger.silly(`Jet version ${jetVersion}, text encoding ${encoding}`);
166+
167+
const changes = diffMdbAgainstOriginal(editedJson, originalReadOnly);
168+
logger.verbose(`Diff: ${changes.length} candidate cell change${changes.length === 1 ? '' : 's'}`);
169+
170+
const buffer = Buffer.from(originalReadOnly); // independent mutable copy
171+
const result: PatchResult = { applied: [], skipped: [], outFile };
172+
173+
for (const change of changes) {
174+
const r = applyChange(buffer, change, encoding);
175+
if (r.ok) {
176+
logger.silly(`patched ${change.table}[${change.rowIndex}].${change.column} @ ${r.offset}`);
177+
result.applied.push(change);
178+
} else {
179+
logger.silly(`skipped ${change.table}[${change.rowIndex}].${change.column}: ${r.reason}`);
180+
result.skipped.push({ change, reason: r.reason });
181+
}
182+
}
183+
184+
await fs.writeFile(outFile, buffer);
185+
logger.verbose(
186+
`Wrote ${outFile}: ${result.applied.length} applied, ${result.skipped.length} skipped, ${changes.length} total candidates`,
187+
);
188+
189+
return result;
190+
}

0 commit comments

Comments
 (0)