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
290 lines
12 KiB
JavaScript
290 lines
12 KiB
JavaScript
"use strict";
|
|
/**
|
|
* 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.
|
|
*/
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.resolveFieldReferences = exports.findFieldByNameOrId = exports.findTableByNameOrId = exports.inferFieldType = exports.expandCompactFieldSpec = exports.normalizeFieldRoArray = exports.normalizeFieldRo = exports.resolveFieldType = void 0;
|
|
const TYPE_ALIASES = {
|
|
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. */
|
|
function resolveFieldType(type) {
|
|
return TYPE_ALIASES[type.toLowerCase()] ?? type;
|
|
}
|
|
exports.resolveFieldType = resolveFieldType;
|
|
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, options) {
|
|
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
|
|
*/
|
|
function normalizeFieldRo(body) {
|
|
if (typeof body.type === 'string') {
|
|
body.type = resolveFieldType(body.type);
|
|
}
|
|
const type = body.type;
|
|
if (type &&
|
|
body.options &&
|
|
typeof body.options === 'object' &&
|
|
!Array.isArray(body.options)) {
|
|
const options = body.options;
|
|
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;
|
|
}
|
|
exports.normalizeFieldRo = normalizeFieldRo;
|
|
/** Apply normalizeFieldRo to every entry of a `--fields` array (invalid input passes through so the API reports it). */
|
|
function normalizeFieldRoArray(fields) {
|
|
if (!Array.isArray(fields))
|
|
return fields;
|
|
return fields.map((f) => typeof f === 'string'
|
|
? expandCompactFieldSpec(f)
|
|
: f && typeof f === 'object' && !Array.isArray(f)
|
|
? normalizeFieldRo({ ...f })
|
|
: f);
|
|
}
|
|
exports.normalizeFieldRoArray = normalizeFieldRoArray;
|
|
/**
|
|
* 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).
|
|
*/
|
|
function expandCompactFieldSpec(spec) {
|
|
const parts = spec.split(':').map((p) => p.trim());
|
|
const name = parts[0];
|
|
const type = parts[1] ? resolveFieldType(parts[1]) : inferFieldType(name, undefined);
|
|
const body = { 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;
|
|
}
|
|
exports.expandCompactFieldSpec = expandCompactFieldSpec;
|
|
const NAME_INFER_RULES = [
|
|
[/^(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.
|
|
*/
|
|
function inferFieldType(name, options) {
|
|
if (options && typeof options === 'object' && !Array.isArray(options)) {
|
|
const o = options;
|
|
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';
|
|
}
|
|
exports.inferFieldType = inferFieldType;
|
|
/** Find a table by ID or (exact, then case-insensitive) name. Returns undefined when not found. */
|
|
function findTableByNameOrId(tables, nameOrId) {
|
|
return (tables.find((t) => t.id === nameOrId) ??
|
|
tables.find((t) => t.name === nameOrId) ??
|
|
tables.find((t) => t.name.toLowerCase() === nameOrId.toLowerCase()));
|
|
}
|
|
exports.findTableByNameOrId = findTableByNameOrId;
|
|
/** Find a field by ID or (exact, then case-insensitive) name. Returns undefined when not found. */
|
|
function findFieldByNameOrId(fields, nameOrId) {
|
|
return (fields.find((f) => f.id === nameOrId) ??
|
|
fields.find((f) => f.name === nameOrId) ??
|
|
fields.find((f) => f.name.toLowerCase() === nameOrId.toLowerCase()));
|
|
}
|
|
exports.findFieldByNameOrId = findFieldByNameOrId;
|
|
/**
|
|
* 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.
|
|
*/
|
|
async function resolveFieldReferences(body, deps) {
|
|
const errors = [];
|
|
const options = (body.options && typeof body.options === 'object' && !Array.isArray(body.options)
|
|
? body.options
|
|
: 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
|
|
: 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;
|
|
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
|
|
.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;
|
|
}
|
|
exports.resolveFieldReferences = resolveFieldReferences;
|