From e1a91dfa84d96b4862f2a2f601e460513c4e3006 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Sun, 16 Aug 2026 05:58:01 +0000 Subject: [PATCH] feat(cli+skill): make documented field UX real + fix skill doc drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .agents/skills/inkreach-teable/SKILL.md | 4 +- .../api-reference/field-types.md | 5 + .../inkreach-teable/guides/cli-reference.md | 20 +- .../inkreach-teable/guides/development.md | 19 +- .../inkreach-teable/guides/instance-access.md | 18 +- dist/commands/resources.js | 45 ++- dist/lib/config.js | 2 +- dist/lib/field-normalize.js | 289 ++++++++++++++++ package.json | 3 +- src/commands/resources.ts | 63 +++- src/lib/config.ts | 2 +- src/lib/field-normalize.spec.ts | 253 ++++++++++++++ src/lib/field-normalize.ts | 326 ++++++++++++++++++ 13 files changed, 1015 insertions(+), 34 deletions(-) create mode 100644 dist/lib/field-normalize.js create mode 100644 src/lib/field-normalize.spec.ts create mode 100644 src/lib/field-normalize.ts diff --git a/.agents/skills/inkreach-teable/SKILL.md b/.agents/skills/inkreach-teable/SKILL.md index 72c9e1d..f7bf014 100644 --- a/.agents/skills/inkreach-teable/SKILL.md +++ b/.agents/skills/inkreach-teable/SKILL.md @@ -31,7 +31,7 @@ Respond in the user's language. Keep answers concise and action-oriented. ## 1. Prerequisites & Constraints - All operations use the `inkreach-teable` CLI. Install via `bash scripts/install.sh` (relative to this skill dir) if `inkreach-teable` is not found. -- **Endpoint + token**: configure once with `inkreach-teable auth login --endpoint --token `. Default endpoint is `http://localhost:3000`. Verify with `inkreach-teable auth status`. +- **Endpoint + token**: configure once with `inkreach-teable auth login --endpoint --token `. Default endpoint is `http://localhost:3003`. Verify with `inkreach-teable auth status`. - **Getting a PAT**: `inkreach-teable auth token create --name my-cli` (returns the full token **only once**). Scopes default to automation+table+record read/write. Or create via the web UI (Settings → Personal Access Tokens). - **`--base-id`**: omit if a default was set via `auth login`; CLI uses it automatically. Ask user only if a command fails with MISSING_BASE_ID. - **`--json` mode**: add `--json` to any command for raw JSON output (for agent parsing). @@ -82,7 +82,7 @@ Respond in the user's language. Keep answers concise and action-oriented. ```bash # Configure access (once) -inkreach-teable auth login --endpoint http://localhost:3000 --token +inkreach-teable auth login --endpoint http://localhost:3003 --token # Create a table inkreach-teable table create --name "Tasks" --base-id bseXXX \ diff --git a/.agents/skills/inkreach-teable/api-reference/field-types.md b/.agents/skills/inkreach-teable/api-reference/field-types.md index db346bd..433042e 100644 --- a/.agents/skills/inkreach-teable/api-reference/field-types.md +++ b/.agents/skills/inkreach-teable/api-reference/field-types.md @@ -2,6 +2,11 @@ Complete reference of all Teable field types, aliases, option shapes, and smart inference rules. +> ✅ **Verified 2026-08**: aliases, string choices, compact `--fields` format, smart +> inference, and name resolution (`foreignTableName`/`linkFieldName`/`lookupFieldName`) +> are implemented in the CLI (`packages/inkreach-cli/src/lib/field-normalize.ts`) +> and covered by unit tests (`field-normalize.spec.ts`, 20 cases). + --- ## Type Overview diff --git a/.agents/skills/inkreach-teable/guides/cli-reference.md b/.agents/skills/inkreach-teable/guides/cli-reference.md index 7e32b39..525d981 100644 --- a/.agents/skills/inkreach-teable/guides/cli-reference.md +++ b/.agents/skills/inkreach-teable/guides/cli-reference.md @@ -6,7 +6,7 @@ Complete command reference for `inkreach-teable`. Global options apply to all co | Option | Description | |--------|-------------| -| `--endpoint ` | Backend URL (default `http://localhost:3000`) | +| `--endpoint ` | Backend URL (default `http://localhost:3003`) | | `--token ` | Personal Access Token | | `--base-id ` | Default base id | | `--json` | Output raw JSON (for agent parsing) | @@ -46,7 +46,9 @@ inkreach-teable table delete # field (get/update/delete take — 2026-08 fix; old /api/field/:id routes never existed) inkreach-teable field list inkreach-teable field get -inkreach-teable field create --table-id --name --type [--options ''] +inkreach-teable field create --table-id --name [--type ] [--options ''] \ + [--description ] [--lookup-options ''] [--is-lookup] +# --type optional: inferred from --options shape / --name when omitted inkreach-teable field update [--name ] [--options ''] # --options goes via convert API inkreach-teable field visible-choices # evaluate select-choice visibilityFilter inkreach-teable field delete @@ -174,8 +176,11 @@ Manage plugins and developer whitelist. # List plugins registered in the base inkreach-teable plugin list -# Register a plugin (set runtimeType, positions, logo) -inkreach-teable plugin register --name --positions '["base","panel"]' +# Register a URL-served plugin (runtimeType=url; status published immediately) +# positions: "base"|"panel"|"view"|"dashboard"|"contextMenu" +inkreach-teable plugin register --url --name [--positions '["panel"]'] [--logo ] [--description ] +# Bundle (upload) plugins use a different flow — see skill guides/tables-fields.md +# or api docs: create kebab-id plugin (developer whitelist) → upload → admin publish. # Developer whitelist (admin only) inkreach-teable plugin dev list @@ -184,12 +189,19 @@ inkreach-teable plugin dev revoke # remove # Install / uninstall (creates a base-level mount node) inkreach-teable plugin install --plugin --name "My Plugin" --position base +# ⚠️ For view/dashboard-position plugins, run `plugin config enable` BEFORE +# install, or the install API returns 403 RESTRICTED_RESOURCE +# 'Plugin is not enabled for this base'. inkreach-teable plugin installs # list installs in current base inkreach-teable plugin uninstall --install # Enable/disable plugins inkreach-teable plugin config enable --plugin inkreach-teable plugin config disable --plugin + +# CSP apiOrigins (external origins plugins may fetch) +inkreach-teable plugin csp list +inkreach-teable plugin csp set --origins '["http://192.168.x.x:8001"]' ``` Developer whitelist is admin-only (requires `user.isAdmin=true`). Routes: diff --git a/.agents/skills/inkreach-teable/guides/development.md b/.agents/skills/inkreach-teable/guides/development.md index 5695753..64a858a 100644 --- a/.agents/skills/inkreach-teable/guides/development.md +++ b/.agents/skills/inkreach-teable/guides/development.md @@ -13,10 +13,10 @@ inside NestJS is unstable and will hang or OOM-crash. | Service | Command | Port | Mode | |---------|---------|------|------| -| `teable-backend.service` | `node dist/index.js` | `:3000` | `NODE_ENV=production`, `BACKEND_SKIP_NEXT_START=true` | +| `teable-backend.service` | `node dist/index.js` | `:3003` | `NODE_ENV=production`, `BACKEND_SKIP_NEXT_START=true` | | `teable-frontend.service` | `next start -p 3002 -H 0.0.0.0` | `:3002` | `NODE_ENV=production` | -Users access `:3002`; the backend `:3000` serves the API only. +Users access `:3002`; the backend `:3003` serves the API only. ## Build & Restart @@ -37,7 +37,7 @@ systemctl --user restart teable-frontend.service Browser cache is aggressive — hard-refresh (Ctrl+Shift+R) after a rebuild. ### Recover from OOM crash -If `:3000` is unreachable but systemd says `active`: +If `:3003` is unreachable but systemd says `active`: ```bash systemctl --user stop teable-backend.service pkill -9 -f 'dist/index.js' @@ -46,6 +46,13 @@ systemctl --user start teable-backend.service ## Known Gotchas (from real debugging) +### Webpack cache may not rewrite `dist/index.js` after a workspace package rebuild +After rebuilding a workspace package (e.g. `@teable/openapi` dist), `pnpm build` in +`apps/nestjs-backend` can report "compiled successfully" while leaving `dist/index.js` +untouched (node_modules paths are treated as immutable cache inputs). Symptom: backend +behavior unchanged after a code change. Fix: `rm apps/nestjs-backend/dist/index.js` +then rebuild, and verify with `grep '' dist/index.js`. + ### Node v24 fetch drops the Authorization header The global `fetch()` on Node v24 (undici) drops the `Authorization` request header in certain cases (redirects, proxies). **Use `node:http`/`node:https`** @@ -121,16 +128,16 @@ due to the auth-header bug. Test workflows are **deactivated, not deleted** ```bash # Count workflows by active state -PGPASSWORD=teable psql -h localhost -U teable -d teable -c \ +PGPASSWORD= psql -h localhost -U teable -d teable -c \ "SELECT is_active, count(*) FROM workflow WHERE deleted_time IS NULL GROUP BY is_active;" # Find button fields and their bound workflows -PGPASSWORD=teable psql -h localhost -U teable -d teable -c \ +PGPASSWORD= psql -h localhost -U teable -d teable -c \ "SELECT id, name, options->'workflow'->>'id' AS wf_id, options->'workflow'->>'isActive' AS active FROM field WHERE type='button';" # Recent executions -PGPASSWORD=teable psql -h localhost -U teable -d teable -c \ +PGPASSWORD= psql -h localhost -U teable -d teable -c \ "SELECT id, workflow_id, status, started_at FROM workflow_execution ORDER BY started_at DESC LIMIT 10;" ``` diff --git a/.agents/skills/inkreach-teable/guides/instance-access.md b/.agents/skills/inkreach-teable/guides/instance-access.md index f8216f4..58e691c 100644 --- a/.agents/skills/inkreach-teable/guides/instance-access.md +++ b/.agents/skills/inkreach-teable/guides/instance-access.md @@ -8,29 +8,30 @@ endpoint/ports for your own deployment. | Service | URL / Port | Mode | Purpose | |---------|-----------|------|---------| -| Backend API | `http://localhost:3000` | production (`NODE_ENV=production`) | REST API, automation engine, websocket | +| Backend API | `http://localhost:3003` | production (`NODE_ENV=production`) | REST API, automation engine, websocket | | Frontend | `http://localhost:3002` | production | End-user web UI (Next.js) | | PostgreSQL | `localhost:5432` | — | Database (db: `teable`, user: `teable`) | | Redis | `localhost:6379` | — | BullMQ queue for automation runs, distributed lock | -- The frontend reads `SOCKET_PORT=3000` from `.env` to point its websocket at the backend. Don't let it fall back to `3001`. -- End users browse `:3002`. The backend `:3000` is the API only (`BACKEND_SKIP_NEXT_START=true` disables the embedded Next server). +- The frontend reads `SOCKET_PORT` from `.env` to point its websocket at the backend — verify it equals the backend port (3003 on this host), not the default 3001. +- End users browse `:3002`. The backend `:3003` is the API only (`BACKEND_SKIP_NEXT_START=true` disables the embedded Next server). +- ⚠️ Port map differs from upstream Teable docs (:3000) — on this host `:3000` is held by an **unrelated container** (`inkreach-web`). Always verify with `ss -tlnp` before assuming. ## Health Checks ```bash # Backend API up? -curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/api/auth/signin -X POST \ +curl -s -o /dev/null -w '%{http_code}' http://localhost:3003/api/auth/signin -X POST \ -H 'Content-Type: application/json' -d '{}' # expect 401 (endpoint exists, bad creds) # Frontend up? curl -s -o /dev/null -w '%{http_code}' http://localhost:3002 # expect 307 or 200 # Ports listening? -ss -tlnp | grep -E ':3000|:3002|:5432|:6379' +ss -tlnp | grep -E ':3003|:3002|:5432|:6379' ``` -If `:3000` is unreachable but systemd shows `active`, the worker likely OOM-crashed while the `pnpm` parent stayed alive: +If `:3003` is unreachable but systemd shows `active`, the worker likely OOM-crashed while the `pnpm` parent stayed alive: ```bash systemctl --user stop teable-backend.service pkill -9 -f 'dist/index.js' @@ -58,14 +59,15 @@ Note: `auth token list/create` may require a **session** (browser login) rather ### Configure the CLI ```bash -inkreach-teable auth login --endpoint http://localhost:3000 --token --base-id bseXXX +inkreach-teable auth login --endpoint http://localhost:3003 --token --base-id bseXXX inkreach-teable auth status # verify ``` ## Reference Credentials (local dev) > Never commit real secrets. The reference dev instance uses: -> - Admin: `hr_admin@xingchen.tech` / (ask maintainer) — for browser login only. +> - ⚠️ The old admin account `hr_admin@xingchen.tech` **no longer exists** (verified 2026-08). Current users: `inkreach@inkreach.pod` (admin) — password: ask maintainer, browser login only. +> - Throwaway accounts for testing can be created via `POST /api/auth/signup` (open on this instance). > - Base: `bseeZR7yiDNeMPCa4CN` (人事薪资管理) — the test base with ~600 automations. > - Test table: `tblzPOXQvUruQosKNP5` (has button fields for automation testing). diff --git a/dist/commands/resources.js b/dist/commands/resources.js index 5dc66d6..c717c1c 100644 --- a/dist/commands/resources.js +++ b/dist/commands/resources.js @@ -9,6 +9,7 @@ exports.baseResourceCommands = void 0; const commander_1 = require("commander"); const ctx_1 = require("../lib/ctx"); const output_1 = require("../lib/output"); +const field_normalize_1 = require("../lib/field-normalize"); function baseResourceCommands() { return [baseCmd(), tableCmd(), fieldCmd(), recordCmd(), viewCmd()]; } @@ -103,8 +104,20 @@ function tableCmd() { const opts = o; const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId); const body = { name: opts.name }; - if (opts.fields) - body.fields = JSON.parse(opts.fields); + if (opts.fields) { + body.fields = (0, field_normalize_1.normalizeFieldRoArray)(JSON.parse(opts.fields)); + // Resolve foreignTableName references against existing tables in the base. + const fields = body.fields; + const tables = await ctx.client.listTables(baseId).catch(() => []); + for (const f of fields) { + const options = f?.options; + if (options && typeof options.foreignTableName === 'string' && !options.foreignTableId) { + const t = (0, field_normalize_1.findTableByNameOrId)(tables || [], options.foreignTableName); + if (t) + options.foreignTableId = t.id; + } + } + } const table = await ctx.client.createTable(baseId, body); if (ctx.json) (0, output_1.printJson)(table); @@ -188,13 +201,34 @@ function fieldCmd() { .description('Create a new field') .requiredOption('--table-id ', 'Table id') .requiredOption('--name ', 'Field name') - .requiredOption('--type ', 'Field type (singleLineText, number, singleSelect, link, formula, etc.)') - .option('--options ', 'Field options JSON (type-specific)') + .option('--type ', 'Field type — full value (singleLineText, singleSelect, link…) or alias (text, sel, num…). Omitted: inferred from --options shape / --name') + .option('--options ', 'Field options JSON (type-specific; select choices accept plain strings)') + .option('--description ', 'Field description') + .option('--lookup-options ', 'Lookup options JSON (names like linkFieldName/lookupFieldName are resolved to IDs)') + .option('--is-lookup', 'Mark the field as a lookup field') .action((0, ctx_1.run)(async (ctx, _args, o) => { const opts = o; - const body = { name: opts.name, type: opts.type }; + const body = { 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 + ? (0, field_normalize_1.resolveFieldType)(opts.type) + : (0, field_normalize_1.inferFieldType)(opts.name, body.options); + (0, field_normalize_1.normalizeFieldRo)(body); + const errors = await (0, field_normalize_1.resolveFieldReferences)(body, { + listTables: (baseId) => ctx.client.listTables(baseId), + listFields: (tableId) => ctx.client.listFields(tableId), + 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) (0, output_1.printJson)(field); @@ -235,6 +269,7 @@ function fieldCmd() { }; if (opts.name) body.name = opts.name; + (0, field_normalize_1.normalizeFieldRo)(body); const field = await ctx.client.convertField(tableId, fieldId, body); if (ctx.json) (0, output_1.printJson)(field); diff --git a/dist/lib/config.js b/dist/lib/config.js index 70a7be5..618d94c 100644 --- a/dist/lib/config.js +++ b/dist/lib/config.js @@ -36,7 +36,7 @@ const path = __importStar(require("node:path")); const os = __importStar(require("node:os")); const CONFIG_DIR = path.join(os.homedir(), '.inkreach-teable'); const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json'); -exports.DEFAULT_ENDPOINT = 'http://localhost:3000'; +exports.DEFAULT_ENDPOINT = 'http://localhost:3003'; function readConfigFile() { try { const raw = fs.readFileSync(CONFIG_FILE, 'utf8'); diff --git a/dist/lib/field-normalize.js b/dist/lib/field-normalize.js new file mode 100644 index 0000000..6b9e1d5 --- /dev/null +++ b/dist/lib/field-normalize.js @@ -0,0 +1,289 @@ +"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; diff --git a/package.json b/package.json index 3f9817d..864a199 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "devDependencies": { "@types/node": "^22.18.0", "rimraf": "^5.0.5", - "typescript": "^5.4.3" + "typescript": "^5.4.3", + "vitest": "4.1.10" } } diff --git a/src/commands/resources.ts b/src/commands/resources.ts index 47523c1..0e88d91 100644 --- a/src/commands/resources.ts +++ b/src/commands/resources.ts @@ -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 = { 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[]; + const tables = await ctx.client.listTables(baseId).catch(() => []); + for (const f of fields) { + const options = f?.options as Record | 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 ', 'Table id') .requiredOption('--name ', 'Field name') - .requiredOption( + .option( '--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 ', 'Field options JSON (type-specific)') + .option('--options ', 'Field options JSON (type-specific; select choices accept plain strings)') + .option('--description ', 'Field description') + .option('--lookup-options ', '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 = { 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 = { 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, + listFields: (tableId) => ctx.client.listFields(tableId) as Promise, + 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`); diff --git a/src/lib/config.ts b/src/lib/config.ts index 0a86e84..ba00cd4 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -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 { try { diff --git a/src/lib/field-normalize.spec.ts b/src/lib/field-normalize.spec.ts new file mode 100644 index 0000000..bdb4f80 --- /dev/null +++ b/src/lib/field-normalize.spec.ts @@ -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).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(); + }); +}); diff --git a/src/lib/field-normalize.ts b/src/lib/field-normalize.ts new file mode 100644 index 0000000..6842069 --- /dev/null +++ b/src/lib/field-normalize.ts @@ -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 = { + 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): 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): Record { + 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; + 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) }) + : 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 { + 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 = { 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; + 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, + deps: { + listTables: (baseId: string) => Promise; + listFields: (tableId: string) => Promise; + baseId: string; + tableId?: string; + } +): Promise { + const errors: string[] = []; + const options = (body.options && typeof body.options === 'object' && !Array.isArray(body.options) + ? body.options + : undefined) as Record | 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) + : 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 }) + .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; +}