Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,17 @@ Versions follow [Semantic Versioning](https://semver.org/) — minor bump per su
warning rather than locking the column. BROWSE renders a lookup column as a dropdown —
`DISPLAY` labels shown while editing, the stored code shown once committed, matching
`LIST`/report output.
**Forms (`@ SAY GET`) do not honor `LOOKUP` yet** — that ships in #59, which is when the
full feature is documented in the README command reference.
- **Field-bound `@ SAY GET`** (#59). `@ r,c SAY "…" GET <name>` now binds directly to the
active table's column when one matches — dBASE III's actual behavior — instead of only
ever collecting into a memory variable. Fields take precedence over a memory variable of
the same name (why the `m_` prefix convention exists). A field-bound `GET` needs a current
record and prefills from it; a lookup column renders the same picker BROWSE does. `READ`'s
submit validates every field-bound value (declared type + lookup membership) before
writing any of them — a rejection sends a new `form-error` message that keeps the form
open with the bad fields outlined, rather than silently discarding the valid ones.
Writes target the row captured at `GET` time, mirroring how `grid-edit` already writes by
rowid. This is the PR that promotes `LOOKUP` to the README command reference in full —
both BROWSE and forms now declare, enforce, and render it end to end.

## [1.2.0] — 2026-07-09 — TIME columns, WEEK()/DATEADD(), BROWSE cell validation, Overtime demo

Expand Down
17 changes: 13 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ WebBase-III supports **unlimited work areas** (no DOS 10-area limit). Cross-area

Declared types are recorded per `(database, table, column)` in `server/ColumnMetaStore.ts`, because SQLite only keeps a storage affinity: `TIME`/`DATE`/`CHAR` are all `TEXT`, `LOGICAL`/`INT` are both `INTEGER`, and a `NUM(p,s)` qualifier is lost entirely.

#### `LOOKUP` column qualifier (BROWSE/REPLACE done; forms in progress — v1.3.0)
#### `LOOKUP` column qualifier (v1.3.0)

Any column may add a `LOOKUP` clause after its type, constraining it to a set of legal
values — a WebBase-III extension with no dBASE III ancestor (dBASE III+ only had
Expand All @@ -220,9 +220,18 @@ matching `LIST`/report output — only the edit-mode dropdown shows `DISPLAY` la
`REPLACE` and `grid-edit` both enforce membership, re-resolving fresh at write time (not
trusting a client-held list) so a value that just became legal or just stopped being legal
is judged correctly; an unresolvable lookup degrades to free entry with a warning rather
than locking the column. **Forms (`@ SAY GET`) do not honor `LOOKUP` yet** — that lands in
a follow-up PR (#59, field-bound `GET`) before this section is promoted to the README
command reference.
than locking the column.

`@ r,c SAY "…" GET <name>` binds to the active table's column when one matches — dBASE III's
actual behavior, and why the field takes precedence over a memory variable of the same name
(the `m_` prefix convention exists for this reason). A field-bound `GET` requires a current
record (`APPEND RECORD` first — `** Error: GET <field>: no current record` otherwise),
prefills from the record, and renders a picker when the column has a resolvable lookup.
`READ`'s submit is all-or-nothing across every field-bound `GET` in the form: every value is
validated (declared type + lookup membership) before any is written, and a rejection sends a
`form-error` message that keeps the form open with the offending fields outlined — Escape
still writes nothing. Writes target the rowid captured at `GET` time (`fetchCurrentRow`),
mirroring `grid-edit`, so pointer motion between `GET` and submit can't retarget the write.

#### Cell validation (`BROWSE`)

Expand Down
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,13 +266,20 @@ value that just became legal, or just stopped being legal, is judged correctly
stale cached list). A lookup that can't be resolved (source table dropped, empty, or over
1000 distinct values) degrades to free entry with a warning instead of locking the column.

Forms pick it up too: `@ r,c SAY "…" GET <name>` binds to the active table's column when
one matches — a field takes precedence over a memory variable of the same name, which is
why programs use an `m_` prefix for scratch variables. A field-bound `GET` needs a current
record (`APPEND RECORD` first) and prefills from it; if the column has a lookup, the form
renders the same picker BROWSE does. `READ` validates every field-bound value before
writing any of them — a rejection keeps the form open with the bad field outlined instead
of silently dropping the others.

> **Deviation from dBASE III:** `LOOKUP` has no dBASE III ancestor — dBASE III+ only offered
> `PICTURE "@M a,b,c"`, a literal value list cycled with the spacebar, with no table-driven
> form and no display label. This is a WebBase-III extension, in the same spirit as
> unlimited work areas and `alias.field` dot notation.
>
> Forms (`@ SAY GET`) don't offer a `LOOKUP` picker yet — only BROWSE and `REPLACE` enforce
> it today.
> unlimited work areas and `alias.field` dot notation. Field-bound `GET` *is* authentic
> dBASE III behavior — WebBase-III's own memory-variable-only forms were the deviation,
> now corrected.

> **CSV format (`COPY TO` / `APPEND FROM`):** Unlike dBASE III's headerless,
> positional `DELIMITED`/`SDF` formats, WebBase-III uses modern **header-based CSV**
Expand Down Expand Up @@ -348,7 +355,7 @@ stale cached list). A lookup that can't be resolved (source table dropped, empty
| `? <expr>[, <expr>...]` | Evaluate expression(s) and print the result (numbers right-justified; bare `?` prints a blank line; `??` also accepted) |
| `STORE <val> TO <var>` | Assign a variable |
| `INPUT "prompt" TO <var>` | Collect keyboard input |
| `@ r,c SAY "text" GET <var>` | Define a form field |
| `@ r,c SAY "text" GET <var>` | Define a form field; a name matching a column of the active table binds that column (lookup columns render a picker) |
| `READ` | Display the form and wait for submit |

### Control flow
Expand Down
53 changes: 48 additions & 5 deletions server/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ export class Session {
// Whether pendingContinuation was captured while a program (DO <name>) was
// running — its resumption must re-enter program scope (e.g. silent STORE).
private pendingFromProgram = false;
// The field list of the form currently awaiting submit. form-submit resolves
// write targets from THIS, never from the client's message — a forged
// form-submit cannot redirect a write to an arbitrary column (same reasoning
// as grid-edit's authoritative re-check).
private pendingFormFields: import('../src/shared/types.js').FormField[] | null = null;

private dirty = false;

Expand Down Expand Up @@ -48,12 +53,48 @@ export class Session {
break;

case 'form-submit': {
// Always store what the form collected. A bare `INPUT "…" TO var` at the
// REPL leaves no continuation (there is no following statement), and
// gating the assignment on one silently discarded the typed value. (#50)
const fields = this.pendingFormFields ?? [];
type FieldTarget = Extract<NonNullable<import('../src/shared/types.js').FormField['target']>, { kind: 'field' }>;
const fieldByName = new Map<string, FieldTarget>();
for (const f of fields) {
if (f.target?.kind === 'field') fieldByName.set(f.varName, f.target);
}
// Variables first — and always. A bare `INPUT "…" TO var` at the REPL
// leaves no continuation, and gating the assignment on one silently
// discarded the typed value (#50). Keys that are not field targets of
// THIS form are variables, whatever the client claims.
for (const [k, v] of Object.entries(msg.values)) {
this.executor.setVar(k, v);
if (!fieldByName.has(k)) this.executor.setVar(k, v);
}
// Field writes are all-or-nothing: validate every value (declared type
// + freshly-resolved lookup membership) before writing any.
const errors: { varName: string; message: string }[] = [];
const writes: { table: string; column: string; rowid: number; value: string }[] = [];
for (const [varName, t] of fieldByName) {
const value = msg.values[varName] ?? '';
let meta = columnMetaStore.getColumnType(t.db, t.table, t.column);
if (meta?.lookup) {
const options = await resolveLookup(this.bridge, meta.lookup);
meta = { ...meta, options: options ?? undefined };
}
const err = validateCellValue(t.column, value, meta);
if (err) errors.push({ varName, message: err });
else writes.push({ table: t.table, column: t.column, rowid: t.rowid, value });
}
if (errors.length) {
// Keep pendingFormFields AND pendingContinuation intact: the client
// keeps the form open and resubmits corrected values.
this.send({ type: 'form-error', errors });
break;
}
this.pendingFormFields = null;
for (const w of writes) {
await this.bridge.exec(
`UPDATE ${q(w.table)} SET ${q(w.column)} = ? WHERE rowid = ?`,
[w.value, w.rowid]
);
}
this.send({ type: 'view-terminal' });
if (this.pendingContinuation !== null) {
const cont = this.pendingContinuation;
const fromProgram = this.pendingFromProgram;
Expand All @@ -66,7 +107,6 @@ export class Session {
if (fromProgram) this.executor.exitProgram();
}
} else {
this.send({ type: 'view-terminal' });
this.sendStatus();
}
break;
Expand Down Expand Up @@ -164,6 +204,7 @@ export class Session {
}

case 'grid-exit':
this.pendingFormFields = null;
this.send({ type: 'view-terminal' });
if (this.pendingContinuation) {
const cont = this.pendingContinuation;
Expand All @@ -187,6 +228,7 @@ export class Session {
if (this.pendingContinuation !== null) {
const wasProgram = this.pendingFromProgram;
this.pendingContinuation = null;
this.pendingFormFields = null;
this.pendingFromProgram = false;
this.executor.resetProgramDepth();
if (wasProgram) {
Expand Down Expand Up @@ -309,6 +351,7 @@ export class Session {
if (result.action === 'FORM_READY' && result.formFields) {
this.pendingContinuation = result.continuation ?? null;
this.pendingFromProgram = this.executor.isInProgram();
this.pendingFormFields = result.formFields;
this.send({ type: 'form-open', fields: result.formFields });
return true;
}
Expand Down
35 changes: 33 additions & 2 deletions src/interpreter/Executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,8 +557,39 @@ export class Executor implements IndexCommandsHost {
const row = Number(this.evalExpr(rowE));
const col = Number(this.evalExpr(colE));
const text = String(this.evalExpr(textE));
this.pendingForm.push({ row, col, label: text, varName });
return { output: [] };
const out: OutputLine[] = [];

// Field binding: a GET whose name matches a column of the active table edits
// the current record (dBASE III behavior — fields shadow memory variables;
// this is why the m_ prefix convention exists). Capturing the rowid here means
// form-submit writes by rowid, like grid-edit — pointer motion between here
// and submit cannot retarget the write.
if (this.area.table) {
const cols = await this.db.getStructure(this.area.table);
const match = cols.find(c => c.name.toUpperCase() === varName.toUpperCase());
if (match) {
const cur = await this.fetchCurrentRow();
if (!cur) throw new Error(`GET ${match.name}: no current record`);
const field: FormField = {
row, col, label: text, varName: match.name,
target: {
kind: 'field', column: match.name, table: this.area.table,
db: this.area.db ?? '', rowid: Number(cur._rowid),
},
value: String(cur[match.name] ?? ''),
};
const info = this.columnMetaStore?.getColumnType(this.metaDb, this.area.table, match.name);
if (info?.lookup) {
const options = await resolveLookup(this.db, info.lookup);
if (options) field.options = options;
else out.push({ text: `** Warning: lookup for ${match.name} could not be resolved — free entry`, cls: 'warn' });
}
this.pendingForm.push(field);
return { output: out };
}
}
this.pendingForm.push({ row, col, label: text, varName, target: { kind: 'var' }, value: '' });
return { output: out };
}

private doRead(): ExecResult {
Expand Down
10 changes: 10 additions & 0 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,16 @@ export interface FormField {
row: number;
col: number;
label: string;
/** The submit key. For a field-bound GET this is the column name. */
varName: string;
/** What form-submit writes. Absent (legacy INPUT/@SAY paths) means 'var'. */
target?:
| { kind: 'var' }
| { kind: 'field'; column: string; table: string; db: string; rowid: number };
/** Prefill. Field GETs carry the record's value; var GETs stay '' (unchanged UX). */
value?: string;
/** Resolved lookup options — render a <select>. Absent = free text. */
options?: import('./cellValidation').LookupOption[];
}

export interface OutputLine {
Expand Down Expand Up @@ -152,6 +161,7 @@ export type ServerMessage =
| { type: 'clear' }
| { type: 'report-preview'; html: string }
| { type: 'error'; message: string }
| { type: 'form-error'; errors: { varName: string; message: string }[] }
| { type: 'catalog'; catalog: Catalog }
| { type: 'data-changed'; db: string; table: string }
| { type: 'csv-download'; filename: string; content: string }
Expand Down
2 changes: 2 additions & 0 deletions src/styles/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,8 @@ select.cell-ed { min-width: 110px; }
padding: 1px 5px; outline: none; min-width: 120px;
}
.f-get:focus { border-color: #0088ff; background: #001c35; }
select.f-get { min-width: 140px; }
.f-get.f-invalid { border-color: #cc0000; background: #2a0000; }

/* ── Program Editor ─────────────────────────────────────────────────────── */
#editor-view {
Expand Down
12 changes: 9 additions & 3 deletions src/terminal/Terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,13 @@ export class Terminal {
this.openForm(m.fields);
});

ws.on('form-error', (msg) => {
this.form?.showErrors((msg as any).errors);
});

ws.on('view-terminal', () => {
this.showTerminal();
if (this.form) this.closeForm();
else this.showTerminal();
});

ws.on('program-open', (msg) => {
Expand Down Expand Up @@ -260,16 +265,17 @@ export class Terminal {
(values) => {
const obj: Record<string, string> = {};
values.forEach((v, k) => { obj[k] = v; });
// The form stays open until the server answers: view-terminal closes
// it, form-error keeps it up with the offending fields outlined.
this.ws.send({ type: 'form-submit', values: obj });
this.closeForm();
},
() => {
this.ws.send({ type: 'grid-exit' });
this.closeForm();
this.printLine('READ cancelled', 'warn');
}
);
this.form.render(fields, new Map());
this.form.render(fields);
}

private closeForm() {
Expand Down
Loading
Loading