"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;