feat(cli+skill): make documented field UX real + fix skill doc drift
CLI (packages/inkreach-cli):
- new src/lib/field-normalize.ts: type aliases (sel/num/text/mul/…),
select string-choices -> {name}, compact --fields 'Name:type:choices'
expansion, smart type inference from --options shape / --name
(--type now optional), name->ID resolution for foreignTableName /
linkFieldName / lookupFieldName (incl. link-field foreignTableId
backfill + unknown-key stripping), rating/number/button required-
option defaults
- field create: +--description/--lookup-options/--is-lookup
- field update --options + table create --fields route through the
normalizer; DEFAULT_ENDPOINT :3000 -> :3003
- 20 unit tests (field-normalize.spec.ts, vitest); live-verified:
string choices, alias, foreignTableName, lookup-by-names, compact
format, inference (priority->singleSelect, max:5->rating)
Skill docs (.agents/skills/inkreach-teable):
- ports :3000 -> :3003 everywhere (backend real port; :3000 is an
unrelated container on this host)
- remove dead hr_admin account; note throwaway signup + real admin
- plugin register: actual URL-based flags; document plugin csp
list/set; view/dashboard install requires config enable first
- field-types.md marked verified-2026-08 (aliases/inference/name
resolution now implemented + tested)
- development.md: webpack dist/index.js cache trap; PGPASSWORD
placeholder instead of wrong literal
This commit is contained in:
@@ -6,6 +6,16 @@
|
||||
import { Command } from 'commander';
|
||||
import { run, requireBaseId } from '../lib/ctx';
|
||||
import { printJson, printTable, printKv, truncate } from '../lib/output';
|
||||
import {
|
||||
normalizeFieldRo,
|
||||
normalizeFieldRoArray,
|
||||
resolveFieldReferences,
|
||||
resolveFieldType,
|
||||
inferFieldType,
|
||||
findTableByNameOrId,
|
||||
type ITableLike,
|
||||
type IFieldLike,
|
||||
} from '../lib/field-normalize';
|
||||
|
||||
export function baseResourceCommands(): Command[] {
|
||||
return [baseCmd(), tableCmd(), fieldCmd(), recordCmd(), viewCmd()];
|
||||
@@ -124,7 +134,22 @@ function tableCmd(): Command {
|
||||
const opts = o as { name: string; baseId?: string; fields?: string };
|
||||
const baseId = requireBaseId(ctx, opts.baseId);
|
||||
const body: Record<string, unknown> = { name: opts.name };
|
||||
if (opts.fields) body.fields = JSON.parse(opts.fields);
|
||||
if (opts.fields) {
|
||||
body.fields = normalizeFieldRoArray(JSON.parse(opts.fields));
|
||||
// Resolve foreignTableName references against existing tables in the base.
|
||||
const fields = body.fields as Record<string, unknown>[];
|
||||
const tables = await ctx.client.listTables(baseId).catch(() => []);
|
||||
for (const f of fields) {
|
||||
const options = f?.options as Record<string, unknown> | undefined;
|
||||
if (options && typeof options.foreignTableName === 'string' && !options.foreignTableId) {
|
||||
const t = findTableByNameOrId(
|
||||
(tables as unknown as ITableLike[]) || [],
|
||||
options.foreignTableName
|
||||
);
|
||||
if (t) options.foreignTableId = t.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
const table = await ctx.client.createTable(baseId, body);
|
||||
if (ctx.json) printJson(table);
|
||||
else {
|
||||
@@ -221,16 +246,41 @@ function fieldCmd(): Command {
|
||||
.description('Create a new field')
|
||||
.requiredOption('--table-id <id>', 'Table id')
|
||||
.requiredOption('--name <name>', 'Field name')
|
||||
.requiredOption(
|
||||
.option(
|
||||
'--type <type>',
|
||||
'Field type (singleLineText, number, singleSelect, link, formula, etc.)'
|
||||
'Field type — full value (singleLineText, singleSelect, link…) or alias (text, sel, num…). Omitted: inferred from --options shape / --name'
|
||||
)
|
||||
.option('--options <json>', 'Field options JSON (type-specific)')
|
||||
.option('--options <json>', 'Field options JSON (type-specific; select choices accept plain strings)')
|
||||
.option('--description <d>', 'Field description')
|
||||
.option('--lookup-options <json>', 'Lookup options JSON (names like linkFieldName/lookupFieldName are resolved to IDs)')
|
||||
.option('--is-lookup', 'Mark the field as a lookup field')
|
||||
.action(
|
||||
run(async (ctx, _args, o) => {
|
||||
const opts = o as { tableId: string; name: string; type: string; options?: string };
|
||||
const body: Record<string, unknown> = { name: opts.name, type: opts.type };
|
||||
const opts = o as {
|
||||
tableId: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
options?: string;
|
||||
description?: string;
|
||||
lookupOptions?: string;
|
||||
isLookup?: boolean;
|
||||
};
|
||||
const body: Record<string, unknown> = { name: opts.name };
|
||||
if (opts.options) body.options = JSON.parse(opts.options);
|
||||
if (opts.description) body.description = opts.description;
|
||||
if (opts.lookupOptions) body.lookupOptions = JSON.parse(opts.lookupOptions);
|
||||
if (opts.isLookup) body.isLookup = true;
|
||||
body.type = opts.type
|
||||
? resolveFieldType(opts.type)
|
||||
: inferFieldType(opts.name, body.options);
|
||||
normalizeFieldRo(body);
|
||||
const errors = await resolveFieldReferences(body, {
|
||||
listTables: (baseId) => ctx.client.listTables(baseId) as Promise<ITableLike[]>,
|
||||
listFields: (tableId) => ctx.client.listFields(tableId) as Promise<IFieldLike[]>,
|
||||
baseId: ctx.baseId!,
|
||||
tableId: opts.tableId,
|
||||
});
|
||||
if (errors.length) throw new Error(errors.join('; '));
|
||||
const field = await ctx.client.createField(opts.tableId, body);
|
||||
if (ctx.json) printJson(field);
|
||||
else {
|
||||
@@ -274,6 +324,7 @@ function fieldCmd(): Command {
|
||||
options: JSON.parse(opts.options as string),
|
||||
};
|
||||
if (opts.name) body.name = opts.name;
|
||||
normalizeFieldRo(body);
|
||||
const field = await ctx.client.convertField(tableId, fieldId, body);
|
||||
if (ctx.json) printJson(field);
|
||||
else process.stdout.write(`✓ Field ${fieldId} options updated (via convert)\n`);
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ export interface IConfig {
|
||||
const CONFIG_DIR = path.join(os.homedir(), '.inkreach-teable');
|
||||
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
||||
|
||||
export const DEFAULT_ENDPOINT = 'http://localhost:3000';
|
||||
export const DEFAULT_ENDPOINT = 'http://localhost:3003';
|
||||
|
||||
export function readConfigFile(): Partial<IConfig> {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
expandCompactFieldSpec,
|
||||
findFieldByNameOrId,
|
||||
findTableByNameOrId,
|
||||
normalizeFieldRo,
|
||||
inferFieldType,
|
||||
normalizeFieldRoArray,
|
||||
resolveFieldReferences,
|
||||
resolveFieldType,
|
||||
} from './field-normalize';
|
||||
|
||||
describe('resolveFieldType', () => {
|
||||
it('resolves documented aliases', () => {
|
||||
expect(resolveFieldType('sel')).toBe('singleSelect');
|
||||
expect(resolveFieldType('text')).toBe('singleLineText');
|
||||
expect(resolveFieldType('multi')).toBe('multipleSelect');
|
||||
expect(resolveFieldType('num')).toBe('number');
|
||||
expect(resolveFieldType('attach')).toBe('attachment');
|
||||
expect(resolveFieldType('condrollup')).toBe('conditionalRollup');
|
||||
expect(resolveFieldType('SEL')).toBe('singleSelect'); // case-insensitive
|
||||
});
|
||||
it('passes full values and unknowns through untouched', () => {
|
||||
expect(resolveFieldType('singleSelect')).toBe('singleSelect');
|
||||
expect(resolveFieldType('formula')).toBe('formula');
|
||||
expect(resolveFieldType('nope-xyz')).toBe('nope-xyz');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeFieldRo', () => {
|
||||
it('converts plain-string select choices to {name} objects', () => {
|
||||
const body = normalizeFieldRo({
|
||||
name: 'S',
|
||||
type: 'sel',
|
||||
options: { choices: ['Todo', 'Done'] },
|
||||
});
|
||||
expect(body.type).toBe('singleSelect');
|
||||
expect((body.options as { choices: unknown[] }).choices).toEqual([{ name: 'Todo' }, { name: 'Done' }]);
|
||||
});
|
||||
it('leaves object choices untouched; number options get formatting default', () => {
|
||||
// non-select: choices are NOT rewritten…
|
||||
const body = normalizeFieldRo({
|
||||
name: 'N',
|
||||
type: 'number',
|
||||
options: { precision: 2, choices: ['x'] },
|
||||
});
|
||||
expect(body.options).toMatchObject({ precision: 2, choices: ['x'] });
|
||||
// …but required-option defaults ARE backfilled for number
|
||||
expect((body.options as Record<string, unknown>).formatting).toEqual({
|
||||
precision: 0,
|
||||
type: 'decimal',
|
||||
});
|
||||
const sel = normalizeFieldRo({
|
||||
name: 'S',
|
||||
type: 'singleSelect',
|
||||
options: { choices: [{ name: 'A', color: 'red' }] },
|
||||
});
|
||||
expect((sel.options as { choices: unknown[] }).choices).toEqual([{ name: 'A', color: 'red' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeFieldRoArray', () => {
|
||||
it('normalizes each entry, expands compact strings, tolerates invalid entries', () => {
|
||||
const out = normalizeFieldRoArray([
|
||||
{ name: 'A', type: 'sel', options: { choices: ['x'] } },
|
||||
{ name: 'B', type: 'text' },
|
||||
'Status:sel:Todo,Done',
|
||||
42,
|
||||
]) as unknown[];
|
||||
expect(out[0]).toMatchObject({ type: 'singleSelect' });
|
||||
expect(out[1]).toMatchObject({ type: 'singleLineText' });
|
||||
expect(out[2]).toEqual({
|
||||
name: 'Status',
|
||||
type: 'singleSelect',
|
||||
options: { choices: [{ name: 'Todo' }, { name: 'Done' }] },
|
||||
});
|
||||
expect(out[3]).toBe(42); // passes through; API will report it
|
||||
});
|
||||
it('passes non-arrays through', () => {
|
||||
expect(normalizeFieldRoArray(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findTableByNameOrId / findFieldByNameOrId', () => {
|
||||
const tables = [
|
||||
{ id: 'tbl1', name: 'Projects' },
|
||||
{ id: 'tbl2', name: 'Clients' },
|
||||
];
|
||||
const fields = [
|
||||
{ id: 'fld1', name: 'Title' },
|
||||
{ id: 'fld2', name: 'Client' },
|
||||
];
|
||||
it('matches by id first, then exact name, then case-insensitive', () => {
|
||||
expect(findTableByNameOrId(tables, 'tbl2')?.id).toBe('tbl2');
|
||||
expect(findTableByNameOrId(tables, 'Clients')?.id).toBe('tbl2');
|
||||
expect(findTableByNameOrId(tables, 'clients')?.id).toBe('tbl2');
|
||||
expect(findTableByNameOrId(tables, 'nope')).toBeUndefined();
|
||||
expect(findFieldByNameOrId(fields, 'title')?.id).toBe('fld1');
|
||||
expect(findFieldByNameOrId(fields, 'fld2')?.id).toBe('fld2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveFieldReferences', () => {
|
||||
const tables = [
|
||||
{ id: 'tblF', name: 'Projects' },
|
||||
{ id: 'tblS', name: 'Staff' },
|
||||
];
|
||||
|
||||
it('resolves foreignTableName to foreignTableId and strips the hint', async () => {
|
||||
const body = { options: { foreignTableName: 'Projects', relationship: 'manyOne' } };
|
||||
const errors = await resolveFieldReferences(body, {
|
||||
listTables: vi.fn().mockResolvedValue(tables),
|
||||
listFields: vi.fn(),
|
||||
baseId: 'bse1',
|
||||
});
|
||||
expect(errors).toEqual([]);
|
||||
expect(body.options).toMatchObject({ foreignTableId: 'tblF', relationship: 'manyOne' });
|
||||
expect(body.options).not.toHaveProperty('foreignTableName');
|
||||
});
|
||||
|
||||
it('reports unknown foreignTableName without touching the body', async () => {
|
||||
const body = { options: { foreignTableName: 'Ghost' } };
|
||||
const errors = await resolveFieldReferences(body, {
|
||||
listTables: vi.fn().mockResolvedValue(tables),
|
||||
listFields: vi.fn(),
|
||||
baseId: 'bse1',
|
||||
});
|
||||
expect(errors[0]).toContain('Ghost');
|
||||
expect(body.options).not.toHaveProperty('foreignTableId');
|
||||
});
|
||||
|
||||
it('resolves linkFieldName + lookupFieldName via the link field options', async () => {
|
||||
const body = {
|
||||
type: 'singleLineText',
|
||||
isLookup: true,
|
||||
lookupOptions: { linkFieldName: 'Client', lookupFieldName: 'Title' },
|
||||
};
|
||||
const listFields = vi.fn(async (tableId: string) =>
|
||||
tableId === 'tbl1'
|
||||
? [
|
||||
{ id: 'fldLink', name: 'Client', options: { foreignTableId: 'tblF' } },
|
||||
]
|
||||
: [{ id: 'fldT', name: 'Title' }]
|
||||
);
|
||||
const errors = await resolveFieldReferences(body, {
|
||||
listTables: vi.fn().mockResolvedValue(tables),
|
||||
listFields,
|
||||
baseId: 'bse1',
|
||||
tableId: 'tbl1',
|
||||
});
|
||||
expect(errors).toEqual([]);
|
||||
expect(body.lookupOptions).toEqual({
|
||||
linkFieldId: 'fldLink',
|
||||
lookupFieldId: 'fldT',
|
||||
foreignTableId: 'tblF', // backfilled — Ro schema requires it
|
||||
});
|
||||
// resolved foreign table fields were actually queried
|
||||
expect(listFields).toHaveBeenCalledWith('tblF');
|
||||
});
|
||||
|
||||
it('keeps explicitly provided IDs untouched', async () => {
|
||||
const body = { lookupOptions: { linkFieldId: 'fldL', lookupFieldId: 'fldT', foreignTableId: 'tblF' } };
|
||||
const listFields = vi.fn();
|
||||
const errors = await resolveFieldReferences(body, {
|
||||
listTables: vi.fn(),
|
||||
listFields,
|
||||
baseId: 'bse1',
|
||||
tableId: 'tbl1',
|
||||
});
|
||||
expect(errors).toEqual([]);
|
||||
expect(listFields).not.toHaveBeenCalled();
|
||||
expect(body.lookupOptions).toEqual({ linkFieldId: 'fldL', lookupFieldId: 'fldT', foreignTableId: 'tblF' });
|
||||
});
|
||||
|
||||
it('errors when linkFieldName cannot be found', async () => {
|
||||
const body = { lookupOptions: { linkFieldName: 'Ghost', lookupFieldName: 'Title' } };
|
||||
const errors = await resolveFieldReferences(body, {
|
||||
listTables: vi.fn().mockResolvedValue(tables),
|
||||
listFields: vi.fn().mockResolvedValue([{ id: 'fld1', name: 'Title' }]),
|
||||
baseId: 'bse1',
|
||||
tableId: 'tbl1',
|
||||
});
|
||||
expect(errors.some((e) => e.includes('Ghost'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandCompactFieldSpec', () => {
|
||||
it('expands name:type:choices', () => {
|
||||
expect(expandCompactFieldSpec('Status:sel:Todo,Doing,Done')).toEqual({
|
||||
name: 'Status',
|
||||
type: 'singleSelect',
|
||||
options: { choices: [{ name: 'Todo' }, { name: 'Doing' }, { name: 'Done' }] },
|
||||
});
|
||||
expect(expandCompactFieldSpec('Tags:mul:Bug,Feature')).toMatchObject({ type: 'multipleSelect' });
|
||||
});
|
||||
it('expands name:type and bare name', () => {
|
||||
expect(expandCompactFieldSpec('Notes:long')).toEqual({ name: 'Notes', type: 'longText' });
|
||||
expect(expandCompactFieldSpec('Title')).toEqual({ name: 'Title', type: 'singleLineText' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('inferFieldType', () => {
|
||||
it('infers from options shape', () => {
|
||||
expect(inferFieldType('X', { choices: ['a'] })).toBe('singleSelect');
|
||||
expect(inferFieldType('X', { max: 5 })).toBe('rating');
|
||||
expect(inferFieldType('X', { foreignTableName: 'T' })).toBe('link');
|
||||
expect(inferFieldType('X', { expression: '1+1' })).toBe('formula');
|
||||
expect(inferFieldType('X', { expression: '1+1', linkFieldId: 'fldL', condition: {} })).toBe('conditionalRollup');
|
||||
expect(inferFieldType('X', { workflow: { id: 'wfl1' } })).toBe('button');
|
||||
});
|
||||
it('infers from field name', () => {
|
||||
expect(inferFieldType('status', undefined)).toBe('singleSelect');
|
||||
expect(inferFieldType('Priority', undefined)).toBe('singleSelect');
|
||||
expect(inferFieldType('tags', undefined)).toBe('multipleSelect');
|
||||
expect(inferFieldType('description', undefined)).toBe('longText');
|
||||
expect(inferFieldType('amount', undefined)).toBe('number');
|
||||
expect(inferFieldType('score', undefined)).toBe('rating');
|
||||
expect(inferFieldType('isActive', undefined)).toBe('checkbox');
|
||||
expect(inferFieldType('hasChildren', undefined)).toBe('checkbox');
|
||||
expect(inferFieldType('assignee', undefined)).toBe('user');
|
||||
expect(inferFieldType('photo', undefined)).toBe('attachment');
|
||||
expect(inferFieldType('email', undefined)).toBe('singleLineText');
|
||||
expect(inferFieldType('created time', undefined)).toBe('createdTime');
|
||||
expect(inferFieldType('created by', undefined)).toBe('createdBy');
|
||||
expect(inferFieldType('modified by', undefined)).toBe('lastModifiedBy');
|
||||
});
|
||||
it('falls back to singleLineText', () => {
|
||||
expect(inferFieldType('mystery', undefined)).toBe('singleLineText');
|
||||
expect(inferFieldType(undefined, undefined)).toBe('singleLineText');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyOptionDefaults (via normalizeFieldRo)', () => {
|
||||
it('backfills rating icon/color/max when options supplied partially', () => {
|
||||
const body = normalizeFieldRo({ name: 'R', type: 'rate', options: { max: 5 } });
|
||||
expect(body.options).toEqual({ max: 5, icon: 'star', color: 'yellowBright' });
|
||||
});
|
||||
it('backfills number formatting and button label/color', () => {
|
||||
expect(normalizeFieldRo({ name: 'N', type: 'num', options: {} }).options).toEqual({
|
||||
formatting: { precision: 0, type: 'decimal' },
|
||||
});
|
||||
expect(normalizeFieldRo({ name: 'B', type: 'button', options: {} }).options).toEqual({
|
||||
label: 'Button',
|
||||
color: 'teal',
|
||||
});
|
||||
});
|
||||
it('leaves full options and absent options untouched', () => {
|
||||
expect(
|
||||
normalizeFieldRo({ name: 'R', type: 'rating', options: { max: 3, icon: 'heart', color: 'redBright' } }).options
|
||||
).toEqual({ max: 3, icon: 'heart', color: 'redBright' });
|
||||
expect(normalizeFieldRo({ name: 'R', type: 'rating' }).options).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Field-definition normalization for `field create` / `table create --fields`.
|
||||
*
|
||||
* The raw API is strict: `type` must be the full enum value and select
|
||||
* `choices` must be objects (`{name, ...}`) — plain strings 400. These helpers
|
||||
* accept the friendlier documented forms (aliases like `sel`, string choices,
|
||||
* `foreignTableName` / `linkFieldName` / `lookupFieldName` to be resolved to
|
||||
* IDs) so the examples in the skill docs actually work.
|
||||
*/
|
||||
|
||||
const TYPE_ALIASES: Record<string, string> = {
|
||||
text: 'singleLineText',
|
||||
str: 'singleLineText',
|
||||
long: 'longText',
|
||||
num: 'number',
|
||||
int: 'number',
|
||||
check: 'checkbox',
|
||||
bool: 'checkbox',
|
||||
sel: 'singleSelect',
|
||||
select: 'singleSelect',
|
||||
multi: 'multipleSelect',
|
||||
tags: 'multipleSelect',
|
||||
rate: 'rating',
|
||||
star: 'rating',
|
||||
user: 'user',
|
||||
person: 'user',
|
||||
file: 'attachment',
|
||||
attach: 'attachment',
|
||||
mul: 'multipleSelect',
|
||||
link: 'link',
|
||||
rel: 'link',
|
||||
rollup: 'rollup',
|
||||
condrollup: 'conditionalRollup',
|
||||
formula: 'formula',
|
||||
auto: 'autoNumber',
|
||||
created: 'createdTime',
|
||||
modified: 'lastModifiedTime',
|
||||
createdby: 'createdBy',
|
||||
modifiedby: 'lastModifiedBy',
|
||||
button: 'button',
|
||||
};
|
||||
|
||||
/** Resolve a type alias (`sel`) to its full value (`singleSelect`). Unknown values pass through untouched. */
|
||||
export function resolveFieldType(type: string): string {
|
||||
return TYPE_ALIASES[type.toLowerCase()] ?? type;
|
||||
}
|
||||
|
||||
const SELECT_TYPES = new Set(['singleSelect', 'multipleSelect']);
|
||||
|
||||
/** Required-option defaults for types whose Ro schema has none — applied only
|
||||
* when the user supplied an options object that is missing required keys. */
|
||||
function applyOptionDefaults(type: string, options: Record<string, unknown>): void {
|
||||
if (type === 'rating') {
|
||||
if (options.icon === undefined) options.icon = 'star';
|
||||
if (options.color === undefined) options.color = 'yellowBright';
|
||||
if (options.max === undefined) options.max = 5;
|
||||
} else if (type === 'number') {
|
||||
if (options.formatting === undefined) {
|
||||
options.formatting = { precision: 0, type: 'decimal' };
|
||||
}
|
||||
} else if (type === 'button') {
|
||||
if (options.label === undefined) options.label = 'Button';
|
||||
if (options.color === undefined) options.color = 'teal';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize one field body in place:
|
||||
* - resolve `type` aliases
|
||||
* - convert select `options.choices` string entries to `{name}` objects
|
||||
* - backfill required-option defaults for rating/number/button
|
||||
*/
|
||||
export function normalizeFieldRo(body: Record<string, unknown>): Record<string, unknown> {
|
||||
if (typeof body.type === 'string') {
|
||||
body.type = resolveFieldType(body.type);
|
||||
}
|
||||
const type = body.type as string | undefined;
|
||||
if (
|
||||
type &&
|
||||
body.options &&
|
||||
typeof body.options === 'object' &&
|
||||
!Array.isArray(body.options)
|
||||
) {
|
||||
const options = body.options as Record<string, unknown>;
|
||||
if (SELECT_TYPES.has(type) && Array.isArray(options.choices)) {
|
||||
options.choices = options.choices.map((c) =>
|
||||
typeof c === 'string' ? { name: c } : c
|
||||
);
|
||||
}
|
||||
applyOptionDefaults(type, options);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/** Apply normalizeFieldRo to every entry of a `--fields` array (invalid input passes through so the API reports it). */
|
||||
export function normalizeFieldRoArray(fields: unknown): unknown {
|
||||
if (!Array.isArray(fields)) return fields;
|
||||
return fields.map((f) =>
|
||||
typeof f === 'string'
|
||||
? expandCompactFieldSpec(f)
|
||||
: f && typeof f === 'object' && !Array.isArray(f)
|
||||
? normalizeFieldRo({ ...(f as Record<string, unknown>) })
|
||||
: f
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand the compact `--fields` string form "Name[:type[:choice,choice,…]]"
|
||||
* (e.g. "Status:sel:Todo,Done", "Notes:long", "Title") into a field body.
|
||||
* Bare names become singleLineText; a bare type token is resolved through
|
||||
* the alias map; a third segment lists select choices (strings).
|
||||
*/
|
||||
export function expandCompactFieldSpec(spec: string): Record<string, unknown> {
|
||||
const parts = spec.split(':').map((p) => p.trim());
|
||||
const name = parts[0];
|
||||
const type = parts[1] ? resolveFieldType(parts[1]) : inferFieldType(name, undefined);
|
||||
const body: Record<string, unknown> = { name, type };
|
||||
if (parts.length > 2 && parts[2]) {
|
||||
const choices = parts[2].split(',').map((c) => c.trim()).filter(Boolean);
|
||||
body.options = { choices: choices.map((c) => ({ name: c })) };
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
const NAME_INFER_RULES: Array<[RegExp, string]> = [
|
||||
[/^(status|priority|category|type|stage|level)$/i, 'singleSelect'],
|
||||
[/^(tags?|labels?)$/i, 'multipleSelect'],
|
||||
[/^(description|notes|comment|content|body)$/i, 'longText'],
|
||||
[/^(count|amount|price|quantity|total|number|age)$/i, 'number'],
|
||||
[/^(rating|score|stars?)$/i, 'rating'],
|
||||
[/^(is.+|has.+|active|enabled|done|completed)$/i, 'checkbox'],
|
||||
[/^(assignee|owner|user)$/i, 'user'],
|
||||
[/^(attachment|file|image|photo|document)$/i, 'attachment'],
|
||||
[/^(email|url)$/i, 'singleLineText'],
|
||||
[/^(created( time)?|created)$/i, 'createdTime'],
|
||||
[/^(last modified|updated)$/i, 'lastModifiedTime'],
|
||||
[/^(created by|author)$/i, 'createdBy'],
|
||||
[/^(modified by|updated by)$/i, 'lastModifiedBy'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Infer the field type when `--type` is omitted: first from options shape
|
||||
* (choices/max/foreignTableName/expression/workflow), then from the field
|
||||
* name, falling back to singleLineText.
|
||||
*/
|
||||
export function inferFieldType(
|
||||
name: string | undefined,
|
||||
options: unknown
|
||||
): string {
|
||||
if (options && typeof options === 'object' && !Array.isArray(options)) {
|
||||
const o = options as Record<string, unknown>;
|
||||
if (Array.isArray(o.choices)) return 'singleSelect';
|
||||
if (typeof o.max === 'number') return 'rating';
|
||||
if (typeof o.foreignTableName === 'string' || typeof o.foreignTableId === 'string') {
|
||||
return 'link';
|
||||
}
|
||||
if (typeof o.expression === 'string') {
|
||||
// conditionalRollup when combined with link + condition; plain formula otherwise
|
||||
return typeof o.linkFieldId === 'string' && o.condition ? 'conditionalRollup' : 'formula';
|
||||
}
|
||||
if (o.workflow && typeof o.workflow === 'object') return 'button';
|
||||
}
|
||||
if (name) {
|
||||
for (const [re, type] of NAME_INFER_RULES) {
|
||||
if (re.test(name)) return type;
|
||||
}
|
||||
}
|
||||
return 'singleLineText';
|
||||
}
|
||||
|
||||
export interface ITableLike {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Find a table by ID or (exact, then case-insensitive) name. Returns undefined when not found. */
|
||||
export function findTableByNameOrId(
|
||||
tables: ITableLike[],
|
||||
nameOrId: string
|
||||
): ITableLike | undefined {
|
||||
return (
|
||||
tables.find((t) => t.id === nameOrId) ??
|
||||
tables.find((t) => t.name === nameOrId) ??
|
||||
tables.find((t) => t.name.toLowerCase() === nameOrId.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
export interface IFieldLike {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Find a field by ID or (exact, then case-insensitive) name. Returns undefined when not found. */
|
||||
export function findFieldByNameOrId(
|
||||
fields: IFieldLike[],
|
||||
nameOrId: string
|
||||
): IFieldLike | undefined {
|
||||
return (
|
||||
fields.find((f) => f.id === nameOrId) ??
|
||||
fields.find((f) => f.name === nameOrId) ??
|
||||
fields.find((f) => f.name.toLowerCase() === nameOrId.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve name references in link `options` and `lookupOptions` to IDs:
|
||||
* - options.foreignTableName → options.foreignTableId
|
||||
* - lookupOptions.linkFieldName → linkFieldId (fields of THIS table)
|
||||
* - lookupOptions.lookupFieldName → lookupFieldId (fields of the foreign table)
|
||||
*
|
||||
* `listTables(baseId)`, `listFields(tableId)` are client calls; only invoked when
|
||||
* a *Name key is present. Existing IDs are left untouched. Returns error strings
|
||||
* (not throws) so the caller can decide to fail with a helpful message.
|
||||
*/
|
||||
export async function resolveFieldReferences(
|
||||
body: Record<string, unknown>,
|
||||
deps: {
|
||||
listTables: (baseId: string) => Promise<ITableLike[]>;
|
||||
listFields: (tableId: string) => Promise<IFieldLike[]>;
|
||||
baseId: string;
|
||||
tableId?: string;
|
||||
}
|
||||
): Promise<string[]> {
|
||||
const errors: string[] = [];
|
||||
const options = (body.options && typeof body.options === 'object' && !Array.isArray(body.options)
|
||||
? body.options
|
||||
: undefined) as Record<string, unknown> | undefined;
|
||||
|
||||
if (options && typeof options.foreignTableName === 'string' && !options.foreignTableId) {
|
||||
const tables = await deps.listTables(deps.baseId);
|
||||
const t = findTableByNameOrId(tables, options.foreignTableName);
|
||||
if (!t) {
|
||||
errors.push(
|
||||
`foreignTableName "${options.foreignTableName}" not found in base ${deps.baseId}`
|
||||
);
|
||||
} else {
|
||||
options.foreignTableId = t.id;
|
||||
delete options.foreignTableName; // API rejects unknown keys
|
||||
}
|
||||
}
|
||||
|
||||
const lookup =
|
||||
body.lookupOptions &&
|
||||
typeof body.lookupOptions === 'object' &&
|
||||
!Array.isArray(body.lookupOptions)
|
||||
? (body.lookupOptions as Record<string, unknown>)
|
||||
: undefined;
|
||||
|
||||
if (lookup) {
|
||||
// For lookup fields the foreign table is the target of the LINK field
|
||||
// referenced by linkFieldId — there is no options.foreignTableId on the
|
||||
// lookup field itself. Resolve the link field first, then use its options.
|
||||
let linkForeignTableId: string | undefined;
|
||||
const linkRef =
|
||||
typeof lookup.linkFieldId === 'string'
|
||||
? lookup.linkFieldId
|
||||
: typeof lookup.linkFieldName === 'string'
|
||||
? lookup.linkFieldName
|
||||
: undefined;
|
||||
const hasNameHints =
|
||||
typeof lookup.linkFieldName === 'string' || typeof lookup.lookupFieldName === 'string';
|
||||
const needForeignFromLink =
|
||||
hasNameHints &&
|
||||
typeof lookup.foreignTableId !== 'string' &&
|
||||
!(options && typeof options.foreignTableId === 'string');
|
||||
if (linkRef && deps.tableId && (hasNameHints || needForeignFromLink)) {
|
||||
const fields = (await deps.listFields(deps.tableId)) ?? [];
|
||||
const linkField = findFieldByNameOrId(fields, linkRef);
|
||||
if (!linkField && typeof lookup.linkFieldId !== 'string') {
|
||||
errors.push(`linkFieldName "${lookup.linkFieldName}" not found in table ${deps.tableId}`);
|
||||
}
|
||||
if (linkField) {
|
||||
lookup.linkFieldId = linkField.id;
|
||||
const linkOptions = (linkField as unknown as { options?: Record<string, unknown> })
|
||||
.options;
|
||||
const fid = linkOptions && typeof linkOptions.foreignTableId === 'string'
|
||||
? linkOptions.foreignTableId
|
||||
: undefined;
|
||||
if (fid) linkForeignTableId = fid;
|
||||
}
|
||||
}
|
||||
|
||||
const foreignTableId =
|
||||
(options && typeof options.foreignTableId === 'string' && options.foreignTableId) ||
|
||||
(typeof lookup.foreignTableId === 'string' && lookup.foreignTableId) ||
|
||||
linkForeignTableId ||
|
||||
(typeof lookup.linkFieldId === 'string' && lookup.linkFieldId); // conditional rollup: foreign table only
|
||||
|
||||
// Backfill foreignTableId (Ro schema requires it even for plain lookups,
|
||||
// where it is derived from the link field) once resolved.
|
||||
if (linkForeignTableId && typeof lookup.foreignTableId !== 'string') {
|
||||
lookup.foreignTableId = linkForeignTableId;
|
||||
}
|
||||
|
||||
if (typeof lookup.linkFieldName === 'string' && !lookup.linkFieldId) {
|
||||
if (!deps.tableId) {
|
||||
errors.push('linkFieldName resolution requires --table-id (creating a field)');
|
||||
}
|
||||
// (resolution already attempted above; if it failed an error was pushed)
|
||||
}
|
||||
if (typeof lookup.lookupFieldName === 'string' && !lookup.lookupFieldId) {
|
||||
const foreign =
|
||||
(options && typeof options.foreignTableId === 'string' && options.foreignTableId) ||
|
||||
foreignTableId;
|
||||
if (!foreign) {
|
||||
errors.push('lookupFieldName resolution requires foreignTableId (or foreignTableName)');
|
||||
} else {
|
||||
const fields = await deps.listFields(foreign);
|
||||
const f = findFieldByNameOrId(fields, lookup.lookupFieldName);
|
||||
if (!f) {
|
||||
errors.push(
|
||||
`lookupFieldName "${lookup.lookupFieldName}" not found in foreign table ${foreign}`
|
||||
);
|
||||
} else {
|
||||
lookup.lookupFieldId = f.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The API rejects unknown keys — strip the name hints (only when resolved;
|
||||
// unresolved ones produced errors above and the caller will abort).
|
||||
if (lookup.linkFieldId) delete lookup.linkFieldName;
|
||||
if (lookup.lookupFieldId) delete lookup.lookupFieldName;
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
Reference in New Issue
Block a user