diff --git a/.agents/skills/inkreach-teable/SKILL.md b/.agents/skills/inkreach-teable/SKILL.md index aae8755..ee63e7f 100644 --- a/.agents/skills/inkreach-teable/SKILL.md +++ b/.agents/skills/inkreach-teable/SKILL.md @@ -2,167 +2,109 @@ name: inkreach-teable description: >- Operate a deployed inkreach-teable instance (a Teable fork with a self-built - automation engine). Configure tables, fields, records, views, and automations - (8 triggers incl. recordCreated/Updated/Condition, formSubmitted, scheduledTime, - buttonClick, webhook; 7 actions: script, sendEmail, httpRequest, createRecord, - updateRecord, getRecords, sendNotification), plus secrets, execution history, and dynamic plugins - (install/uninstall, base-level mount nodes, developer whitelist, CSP apiOrigins). - Trigger when the user wants to manage tables/fields/records/views, build or - configure automations/workflows, set up button-field triggers, schedule jobs, - create webhooks, inspect run history, install or configure plugins, allow - developers to upload plugins, or troubleshoot a deployed inkreach-teable - instance - even without saying "inkreach-teable" but referencing Teable-style - IDs (bseXXX, tblXXX, fldXXX, recXXX, viwXXX, wflXXX) or ask to configure - tables/automation/plugins on the local/deployed instance. Also covers dev/deploy - tasks (build, restart systemd services). Uses the inkreach-teable CLI, which - targets the project's OWN automation contract (NOT the official teable CLI). + automation engine) via the inkreach-teable CLI: configure tables, fields, + records, views, and automations (8 triggers incl. recordCreated/Updated/ + Condition, formSubmitted, scheduledTime, buttonClick, webhook; 7 actions), + inspect field constraints (select choices, visibilityFilter conditions), + manage secrets, execution history, plugins, and record subscriptions. Use + when the user references Teable-style IDs (bseXXX, tblXXX, fldXXX, recXXX, + viwXXX, wflXXX) or wants to configure tables/automations/plugins on the + deployed instance — even without saying "inkreach-teable". The official + teable CLI does NOT work here (different automation contract). --- # inkreach-teable operations -This skill operates a deployed inkreach-teable instance via the `inkreach-teable` CLI. +Operate a deployed inkreach-teable instance via the `inkreach-teable` CLI. Respond in the user's language. Keep answers concise and action-oriented. -> **Critical**: inkreach-teable's automation engine is **self-built and differs from -> official teable**. The official `teable` CLI does NOT work for automations here. -> Always use the `inkreach-teable` CLI. See [Key Constraints](#3-key-constraints) for the -> exact key-name differences. +> **Critical**: the automation engine is **self-built and differs from official +> teable** — the official `teable` CLI corrupts or 404s on automations here. +> Exact key-name differences are in [Key Constraints](#3-key-constraints). -## 1. Prerequisites & Constraints +## 1. Prerequisites -- 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: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). -- **Unfamiliar commands**: `inkreach-teable --help` as a fallback. -- **Read the relevant guide before acting**: after entering a module, read the doc(s) marked "Required" in the module map. +- Install the CLI if missing: `bash scripts/install.sh` (relative to this skill dir). +- **Configure once**: `inkreach-teable auth login --endpoint http://localhost:3003 --token [--base-id bseXXX]`; verify with `auth status`. PAT comes from `auth token create --name x` (full token shown **once**) or the web UI (Settings → Personal Access Tokens). +- `--json` on any command returns raw JSON (for agent parsing). Fallback for unknown commands: `inkreach-teable --help`. +- **Read on demand**: consult the guide linked for your module when doing non-trivial work — no need to read everything up front. ## 2. Module & Command Navigation -### 2.1 Module Map - -| Module | What it solves | Entry commands | Guide to read | -|--------|---------------|----------------|---------------| -| Tables & Fields | Create/modify table structure | `table create`, `field create` | [tables-fields.md](guides/tables-fields.md), [field-types.md](api-reference/field-types.md) | -| Records | Read/write row data | `record get`, `record create/update/delete` | [cli-reference.md § record](guides/cli-reference.md#base--table--field--record--view) | -| Views | Persistent filtered/sorted perspectives | `view create/update` | [cli-reference.md § view](guides/cli-reference.md#base--table--field--record--view) | +| Module | What it solves | Entry commands | Detail doc | +|--------|---------------|----------------|------------| +| Tables & Fields | Create/modify structure; **inspect select-choice constraints** | `table create`, `field create`, `field list --with-options`, `field get` | [tables-fields.md](guides/tables-fields.md), [field-types.md](api-reference/field-types.md) | +| Records | Read/write row data | `record get`, `record create/update/delete` | [tables-fields.md § Records](guides/tables-fields.md) | +| Views | Filtered/sorted perspectives | `view create/update` | [tables-fields.md § Views](guides/tables-fields.md) | +| View presets | View-scoped shared filter/sort bundles + per-user personal config, applied via toolbar | `preset create/update/list/delete` | [tables-fields.md § View Presets](guides/tables-fields.md) | | Automation | Event-driven workflows | `workflow *`, `trigger set`, `action add` | [automation-guide.md](guides/automation-guide.md), [trigger-types.md](api-reference/trigger-types.md), [action-types.md](api-reference/action-types.md) | -| Secrets | Store API keys for script actions | `secret set/list/delete` | [automation-guide.md § Secrets](guides/automation-guide.md#secrets) | -| Execution History | Inspect run results / rerun failures | `runs list/get/rerun` | [cli-reference.md § runs](guides/cli-reference.md#runs) | -| Manual Trigger | Test by firing button-click/webhook | `fire button-click`, `fire webhook` | [automation-guide.md § Testing](guides/automation-guide.md#testing--triggering) | -| SQL | Read-only analytics, JOINs, aggregation | `sql ` | [cli-reference.md § sql](guides/cli-reference.md#sql) | -| Plugins | Install/manage dynamic plugins, developer whitelist, CSP | `plugin list/install/uninstall`, `plugin dev allow/revoke` | [cli-reference.md § plugin](guides/cli-reference.md#plugin) | -| Subscription Center | Record subscriptions (digest/instant email) | `subscription list/get/create/update/delete/events/send` | — (see below) | -| Dev/Deploy | Build, restart services, known gotchas | (systemd / pnpm build) | [development.md](guides/development.md) | +| Secrets | API keys for script actions | `secret set/list/delete` | [automation-guide.md § Secrets](guides/automation-guide.md) | +| Execution History | Run results / rerun failures | `runs list/get/rerun` | [automation-guide.md § Testing](guides/automation-guide.md) | +| Manual Trigger | Test button-click/webhook | `fire button-click`, `fire webhook` | [automation-guide.md § Testing](guides/automation-guide.md) | +| SQL | Read-only analytics, JOINs | `sql ''` | [cli-reference.md § sql](guides/cli-reference.md) | +| Plugins | Dynamic plugins, dev whitelist, CSP | `plugin *` | [cli-reference.md § plugin](guides/cli-reference.md) | +| Subscriptions | Record digest/instant email | `subscription *` | [subscription.md](guides/subscription.md) | +| Dev/Deploy | Build, restart, gotchas | systemd / pnpm | [development.md](guides/development.md) | -**Subscription quick reference** (full config via `subscription create/update`): -`--name --table-id --no-watch-created --no-watch-updated --field-ids '' --record-ids '' --display-field-id --attach-csv/--no-attach-csv --filter '' --trigger-on-enter --send-mode digest|instant --recipients '' --no-notify-in-app --schedule-type daily|weekly|hours|minutes --schedule-time HH:mm --schedule-weekday 0-6 --schedule-interval n --inactive` -- `--filter` uses the SAME IFilter shape as view filters; date values are `{"mode":"exactDate","exactDate":"2026-08-21","timeZone":"Asia/Shanghai"}`, user values are id arrays (`"Me"` resolves to the subscription creator). -- `--trigger-on-enter` = notify only on the transition INTO the filter condition (requires `--filter`); default is state semantics (every matching change). -- Filter fields must be visible to the creator (enforced server-side, fail-closed: unknown operator/deleted field never matches). +Full command syntax: [cli-reference.md](guides/cli-reference.md). Instance access & credentials: [instance-access.md](guides/instance-access.md). -### 2.2 Routing Rules +### Routing rules -> **Before executing**: read the documents marked "Required" in the guide for the module. - -| User intent | Correct module | Do NOT do this | -|-------------|---------------|----------------| -| Configure an automation | Automation: `workflow create` → `trigger set` → `action add` → `activate` | Manually editing DB rows | -| React to new records | Trigger `recordCreated` | Polling loop in a script | -| React to field changes | Trigger `recordUpdated` with `--field-ids` | `recordCondition` (ignores fields) | +| User intent | Correct approach | Do NOT | +|-------------|------------------|--------| +| Configure an automation | `workflow create` → `trigger set` → `action add` → `activate` | Editing DB rows | +| React to new records / field changes | `recordCreated` / `recordUpdated --field-ids` | Polling in a script; `recordCondition` ignores fields | | React to a condition matching | Trigger `recordCondition` (NOT recordMatchesConditions) | Official-teable key names | -| Button click starts a workflow | Trigger `buttonClick` + a Button-type field | Any other trigger type | -| Receive external HTTP | Trigger `webhook`; fire via `fire webhook` | Expecting auth (backend ignores it) | -| Schedule recurring job | Trigger `scheduledTime` (only type+interval work) | Passing triggerTime/weekdays (backend ignores) | -| Per-row AI / transformation | Action `script` reading `triggerInput` | Manual row-by-row via record API | -| Send notification | Action `sendNotification` (system in-app) | `sendEmail` (needs SMTP) unless email is truly required | -| Add a custom UI / view / panel | Plugins: `plugin install --position base` | Manually editing DB rows | -| Allow a user to upload plugins | Plugins: `plugin dev allow ` (admin only) | — | -| Plugin needs external API access | Plugins: declare `apiOrigins` in `plugin.json` manifest | Editing CSP in next.config.js (global) | -| Read records for later writes | `record get` (returns record IDs) | `sql` (no record IDs returned) | -| Analytics / aggregation / JOIN | `sql` with GROUP BY | Fetch all records + compute in code | -| Store an API key for a script | `secret set` → read in script via `process.env.NAME` | Hardcoding in script code | -| Inspect why a workflow didn't run | `runs list --status failed` → `runs get` | Guessing; re-activating blindly | - -### 2.3 Quick Syntax - -```bash -# Configure access (once) -inkreach-teable auth login --endpoint http://localhost:3003 --token - -# Create a table -inkreach-teable table create --name "Tasks" --base-id bseXXX \ - --fields '[{"name":"Title","type":"singleLineText"},{"name":"Status","type":"singleSelect","options":{"choices":["Todo","Done"]}}]' - -# Create a workflow + set buttonClick trigger + add a script action + activate -inkreach-teable workflow create --name "On Go click" --base-id bseXXX -inkreach-teable trigger set wflXXX --type buttonClick --table-id tblXXX --field-ids '["fldXXX"]' -inkreach-teable action add wflXXX --type script --code 'return triggerInput.record.fields' -inkreach-teable workflow activate wflXXX - -# Test by firing the button -inkreach-teable fire button-click tblXXX recXXX fldXXX -inkreach-teable runs list wflXXX -``` - -For complete syntax and all options, read [cli-reference.md](guides/cli-reference.md). +| Button click starts a workflow | `buttonClick` trigger + Button field (`options.workflow` object) | Other trigger types | +| Receive external HTTP | `webhook`; test via `fire webhook` | Expecting auth (backend ignores it) | +| Schedule recurring job | `scheduledTime` (only `timing.type` + `interval` honored) | triggerTime/weekdays (ignored) | +| Per-row transformation | `script` action reading `triggerInput` | Row-by-row record API | +| Send notification | `sendNotification` (in-app) | `sendEmail` (needs SMTP) unless email required | +| **Inspect select-choice constraints** | `field list --with-options`, `field get `, `field visible-choices` | `--json` + manual filtering | +| Read records for later writes | `record get` (returns record IDs) | `sql` (no record IDs) | +| Analytics / JOIN | `sql` with GROUP BY | Fetch all records + compute | +| Store an API key for scripts | `secret set` → `process.env.NAME` | Hardcoding | +| Workflow didn't run | `runs list --status failed` → `runs get` | Blind re-activate | ## 3. Key Constraints -These differ from official teable and from the (stale) `docs/automation/triggers.md`. **Use these exact values:** +These differ from official teable and from the (stale) `docs/automation/triggers.md`. -1. **Filter key is `conditions`** (NOT `filter`). Pass via `trigger set --conditions ''`. The JSON shape is `{conjunction:"and"|"or", filterSet:[{fieldId, operator, value}]}`. -2. **Watch-fields key is `fieldIds`** (NOT `watchFieldIds`). For `recordUpdated`/`buttonClick`. `[]` means watch all fields. -3. **Trigger category is `recordCondition`** (NOT `recordMatchesConditions`). It triggers on create+update when the record matches conditions. -4. **`scheduledTime` backend honors only `timing.type` + `timing.interval`**. `triggerTime`, `weekdays`, `daysOfMonth`, `starting`, `ending`, `tz` are all IGNORED by the backend. `weeks` type is hardcoded to every Sunday. Don't configure fields the backend won't use. -5. **Webhook real route is `/api/webhook/{baseId}/{workflowId}`** (no `/base/` or `/workflow/` path segments), and `authorization` config is UI-only — **backend does NOT validate it**. Fire via `inkreach-teable fire webhook `. -6. **Node updates use whole-graph replacement** (`PUT /node`). CLI commands (`trigger set`, `action add/update/delete`) handle GET→modify→PUT internally — always use them, never hand-edit partial graphs. -7. **`activate` creates a snapshot**: changes to nodes only take effect after re-activating. `hasDraft: true` means unactivated changes exist. -8. **`workflow delete` is a SOFT delete** (cascades to base_node, auto-token, trigger unregister). Test workflows should be `deactivate`d, not deleted. -9. **Secrets values are write-only** — `secret list` never returns the value. Scripts read them via `process.env.`. -10. **PAT format is `teable_{id}_{encryptedSign}`** — create via `auth token create`, the full string is returned **once**. -11. **Script sandbox has NO `fs`/`eval`/`Function`**, but has `triggerInput`, `console`, `fetch`, `process.env` (= secrets + `AUTOMATION_TOKEN` + `PUBLIC_ORIGIN`), and a controlled `require`. Use `triggerInput._accessToken` + `triggerInput._apiBaseUrl` to call back into the instance API. **npm deps supported**: declare via `action add/update --dependencies '[{"name":"axios","version":"1.6.0"}]'` (or shorthand `axios@1.6.0,dayjs`), then `require('axios')` works in-sandbox. **Requires** `npm` on the backend service PATH (see [action-types.md](api-reference/action-types.md) §script + [deployment/systemd.md](../../../../deployment/systemd.md) Troubleshooting) — missing it → `spawn npm ENOENT`. +1. **Filter key is `conditions`** (NOT `filter`): `trigger set --conditions ''`, shape `{conjunction, filterSet:[{fieldId, operator, value}]}`. +2. **Watch-fields key is `fieldIds`** (NOT `watchFieldIds`); `[]` = watch all fields. +3. **Trigger category `recordCondition`** (NOT `recordMatchesConditions`). +4. **`scheduledTime` honors only `timing.type` + `timing.interval`** — triggerTime/weekdays/daysOfMonth/starting/ending/tz are ignored; `weeks` is hardcoded to Sunday. +5. **Webhook route is `/api/webhook/{baseId}/{workflowId}`** (no `/base/` segment); `authorization` config is UI-only, backend never validates. Fire: `fire webhook `. +6. **Node updates are whole-graph replacement** — `trigger set` / `action add/update/delete` do GET→modify→PUT internally; never hand-edit partial graphs. +7. **`activate` creates a snapshot** — node changes take effect only after re-activating. `hasDraft: true` = unactivated changes. +8. **`workflow delete` is soft** (cascades to nodes, tokens, trigger unregister); test workflows should be `deactivate`d. +9. **Secrets are write-only** — `secret list` never returns values; scripts read `process.env.`. +10. **Button field binding**: `options.workflow` is an **object** `{id, name?}`, not a bare id string. The field's `isActive` snapshot can go stale; the API validates the workflow's DB `isActive` at click time (deactivated → 400). +11. **Script sandbox** has NO `fs`/`eval`/`Function`, but has `triggerInput`, `console`, `fetch`, `process.env` (secrets + `AUTOMATION_TOKEN` + `PUBLIC_ORIGIN`), and a controlled `require`. Call back into the instance via `triggerInput._accessToken` + `triggerInput._apiBaseUrl`. **npm deps**: declare with `action add/update --dependencies '[{"name":"axios","version":"1.6.0"}]'` (or `axios@1.6.0,dayjs`). Requires `npm` on the backend **service** PATH — systemd user units default to a PATH that omits the nvm bin dir, so deps fail with `spawn npm ENOENT`; fix by adding `Environment=PATH=:/usr/local/sbin:...` to `teable-backend.service`. +12. **Field options updates go through the convert API** — `field update --options` handles this; the plain PATCH update API strips `options`. -## 4. Execution Rules +## 4. Quick Syntax -### 4.1 Standard Order - -1. **Confirm context** — identify target base/table/workflow (`--base-id`, IDs in user's message). -2. **Read before write** — `table get` / `field list` / `workflow get` to confirm current state. -3. **Execute changes** — create/update via CLI commands. -4. **Verify** — re-read; for automations, `activate` then `runs list` after a test fire. - -### 4.2 Critical Rules (with reasoning) - -1. **Use `inkreach-teable` CLI, never `teable` CLI for automations** — official CLI uses incompatible contract (filter/watchFieldIds/recordMatchesConditions) and will 404 or corrupt config. -2. **Read [trigger-types.md](api-reference/trigger-types.md) before configuring a trigger** — contains exact config keys per trigger; guessing leads to silent misfires. -3. **Build the full chain then activate** — `workflow create` → `trigger set` → `action add` → `activate`. Editing nodes after activation requires re-`activate` to snapshot. -4. **For `buttonClick`, ensure the field is a Button field bound to this workflow** — create via `field create --type button` with `options.workflow.id` (the `workflow` value is an **object** `{id, name?}`, NOT a bare `workflowId` string — matching the DB shape), or the field won't render/click. Note: field `options` captures a stale `isActive` snapshot at creation; the API validates the DB `isActive` at click time instead, so a deactivated workflow returns 400. -5. **Read [action-types.md](api-reference/action-types.md) before writing scripts** — covers sandbox globals, variable references `{{nodeId.path}}`, and the `_accessToken`/`_apiBaseUrl` back-channel. +```bash +inkreach-teable auth login --endpoint http://localhost:3003 --token # once +inkreach-teable table create --name "Tasks" --fields '[{"name":"Title","type":"text"},{"name":"Status","type":"singleSelect","options":{"choices":["Todo","Done"]}}]' +inkreach-teable workflow create --name "On Go click" # → wflXXX +inkreach-teable trigger set wflXXX --type buttonClick --table-id tblXXX --field-ids '["fldXXX"]' +inkreach-teable action add wflXXX --type script --code 'return triggerInput.record.fields' +inkreach-teable workflow activate wflXXX +inkreach-teable fire button-click tblXXX recXXX fldXXX && inkreach-teable runs list wflXXX +``` ## 5. Common Errors & Recovery | Symptom | Cause | Fix | |---------|-------|-----| -| `MISSING_BASE_ID` | No base id resolved | Pass `--base-id`, or `auth login --base-id ` | -| `401` / "access denied" | Token invalid/expired or lacks scope | `auth status`; recreate PAT with needed scopes | -| `403` | Base role lacks permission | User must grant role (owner/creator) in web UI | -| Workflow never fires | Not activated, or trigger misconfigured | `workflow get` → check `isActive`; verify trigger type + tableId/fieldIds | -| `scheduledTime` fires at wrong time | Backend ignores most timing fields | Only `timing.type`+`interval` work; use cron-friendly intervals | -| button-click returns 400 "not active" | Workflow deactivated since field created | `workflow activate `, or field options are stale | -| `runs list` shows `failed` | Action error | `runs get ` → read `nodeResults[].error` | -| Secret not readable in script | Wrong env var name | Scripts use `process.env.` where NAME = secret name | - -## 6. API Reference Index - -Files in `api-reference/`, read when you need exact config formats: - -- **[trigger-types.md](api-reference/trigger-types.md)** — all 8 triggers: type string, config keys, output variables. -- **[action-types.md](api-reference/action-types.md)** — all 7 actions: config keys, sandbox globals, variable references. -- **[field-types.md](api-reference/field-types.md)** — Teable field types, aliases, and option shapes (incl. Button field with `workflowId`). - -## 7. Instance Access - -Endpoint, service status, and credential-acquisition steps: see [instance-access.md](guides/instance-access.md). -For dev/deploy (build, restart systemd, known gotchas): see [development.md](guides/development.md). +| `MISSING_BASE_ID` | No base id resolved | Pass `--base-id`, or `auth login --base-id` | +| `401` / access denied | Token invalid/expired or lacks scope | `auth status`; recreate PAT | +| `403` | Base role too low | Grant owner/creator role in web UI | +| Workflow never fires | Not activated / trigger misconfigured | `workflow get` → `isActive`; verify trigger type + tableId/fieldIds | +| `scheduledTime` wrong time | Backend ignores most timing fields | Only `timing.type`+`interval` work | +| button-click 400 "not active" | Workflow deactivated since field created | `workflow activate ` | +| `runs list` shows failed | Action error | `runs get ` → `nodeResults[].error` | +| Script deps fail `spawn npm ENOENT` | `npm` not on service PATH | See Key Constraint 11 | +| Record write 400 "does not meet the current row conditions" | Value violates choice `visibilityFilter` | `field visible-choices` to see allowed options | diff --git a/.agents/skills/inkreach-teable/api-reference/action-types.md b/.agents/skills/inkreach-teable/api-reference/action-types.md index a7ea0f1..e16bb50 100644 --- a/.agents/skills/inkreach-teable/api-reference/action-types.md +++ b/.agents/skills/inkreach-teable/api-reference/action-types.md @@ -83,7 +83,7 @@ inkreach-teable action update --dependencies '[{"name":"axios"}] inkreach-teable action update --dependencies '[]' # clear ``` -> ⚠️ **Deployment requirement**: the backend installs deps via `spawn('npm')`, so `npm` must be on the **service** PATH. systemd user units default to a PATH that omits the nvm bin dir → scripts with deps fail with `spawn npm ENOENT`. Fix: add `Environment=PATH=:/usr/local/sbin:...` to `teable-backend.service` (see [deployment/systemd.md](../../../../deployment/systemd.md) Troubleshooting). +> ⚠️ **Deployment requirement**: the backend installs deps via `spawn('npm')`, so `npm` must be on the **service** PATH. systemd user units default to a PATH that omits the nvm bin dir → scripts with deps fail with `spawn npm ENOENT`. Fix: add `Environment=PATH=:/usr/local/sbin:...` to `teable-backend.service`. **Config:** ```json diff --git a/.agents/skills/inkreach-teable/guides/cli-reference.md b/.agents/skills/inkreach-teable/guides/cli-reference.md index 91a316e..217909a 100644 --- a/.agents/skills/inkreach-teable/guides/cli-reference.md +++ b/.agents/skills/inkreach-teable/guides/cli-reference.md @@ -43,14 +43,16 @@ inkreach-teable table create --name [--base-id ] [--fields ''] inkreach-teable table update --name 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 +# field (get/update/visible-choices take — id or display name, auto-resolved) +inkreach-teable field list [--with-options] +# --with-options: OPTIONS summary column + per-choice visibility conditions for select fields +inkreach-teable field get +# human mode renders select choices with their visibilityFilter conditions; --json for raw 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 update [--name ] [--options ''] # --options goes via convert API +inkreach-teable field visible-choices # evaluate select-choice visibilityFilter inkreach-teable field delete # record @@ -67,6 +69,13 @@ inkreach-teable view get inkreach-teable view create --table-id --name [--type ] inkreach-teable view update [--name ] inkreach-teable view delete + +# view presets (view-scoped shared named filter/sort bundles, applied via toolbar) +inkreach-teable preset list [--view-id ] +inkreach-teable preset create --table-id --view-id --name [--description ] \ + [--filter ''] [--sort ''] [--group ''] [--options ''] [--column-meta ''] +inkreach-teable preset update --table-id [--name ] [--description ] [--filter '' ...] +inkreach-teable preset delete --table-id ``` ## workflow @@ -157,6 +166,21 @@ inkreach-teable fire webhook [--body ''] [--method < Manually trigger for testing. Webhook needs no auth (backend is `@Public`). +## subscription + +```bash +inkreach-teable subscription list +inkreach-teable subscription get +inkreach-teable subscription create --name --table-id [options] +inkreach-teable subscription update [options] +inkreach-teable subscription delete +inkreach-teable subscription events +inkreach-teable subscription send +``` + +Record digest/instant email subscriptions. Options (`--filter`, `--send-mode`, +`--schedule-*`, `--trigger-on-enter`, …) and semantics: [subscription.md](subscription.md). + ## sql ```bash diff --git a/.agents/skills/inkreach-teable/guides/subscription.md b/.agents/skills/inkreach-teable/guides/subscription.md new file mode 100644 index 0000000..f904bf2 --- /dev/null +++ b/.agents/skills/inkreach-teable/guides/subscription.md @@ -0,0 +1,49 @@ +# Record Subscriptions + +Digest/instant email subscriptions over table records. Full CRUD via +`subscription list/get/create/update/delete/events/send`. + +## Commands + +```bash +inkreach-teable subscription list +inkreach-teable subscription get +inkreach-teable subscription create --name "Daily escalations" --table-id tblXXX [options] +inkreach-teable subscription update [options] +inkreach-teable subscription delete +inkreach-teable subscription events +inkreach-teable subscription send # force a digest send now +``` + +## Options (create/update) + +| Option | Meaning | +|--------|---------| +| `--name` | Display name | +| `--table-id` | Target table | +| `--no-watch-created` / `--no-watch-updated` | Exclude create/update events | +| `--field-ids ''` | Restrict watched fields | +| `--record-ids ''` | Restrict watched records | +| `--display-field-id ` | Field shown in the digest row | +| `--attach-csv` / `--no-attach-csv` | Attach matching records as CSV | +| `--filter ''` | Scope: only records matching this filter | +| `--trigger-on-enter` | Notify only on the transition INTO the filter condition (requires `--filter`); default is state semantics (every matching change) | +| `--send-mode digest\|instant` | Digest (batched on schedule) or instant email | +| `--recipients ''` | Email recipients | +| `--no-notify-in-app` | Disable in-app notification | +| `--schedule-type daily\|weekly\|hours\|minutes` | Digest schedule kind | +| `--schedule-time HH:mm` | Daily/weekly fire time | +| `--schedule-weekday 0-6` | Weekly day (0 = Sunday) | +| `--schedule-interval n` | Interval for hours/minutes modes | +| `--inactive` | Create/update in inactive state | + +## Semantics & gotchas + +- `--filter` uses the SAME IFilter shape as view filters. Date values: + `{"mode":"exactDate","exactDate":"2026-08-21","timeZone":"Asia/Shanghai"}`. + User values are id arrays (`"Me"` resolves to the subscription creator). +- Filter fields must be **visible to the creator** (enforced server-side, + fail-closed: unknown operator or deleted field never matches). +- `--trigger-on-enter` implements "notify only when it starts matching" + (e.g. status turns critical); the default state semantics notify on every + matching change. diff --git a/.agents/skills/inkreach-teable/guides/tables-fields.md b/.agents/skills/inkreach-teable/guides/tables-fields.md index cc079c5..50353d1 100644 --- a/.agents/skills/inkreach-teable/guides/tables-fields.md +++ b/.agents/skills/inkreach-teable/guides/tables-fields.md @@ -44,20 +44,7 @@ inkreach-teable table create --name "" [--base-id ] \ | `--fields` | no | Array of field objects (see below) | | `--description` | no | Optional description | -**`--fields` format**: JSON array of `{name, type, [options], [description]}` objects. Allowed types and aliases: - -| Shorthand | Full type | Shorthand | Full type | -|-----------|-----------|-----------|-----------| -| `text`, `str` | `singleLineText` | `long` | `longText` | -| `num`, `int` | `number` | `check`, `bool` | `checkbox` | -| `sel`, `select` | `singleSelect` | `multi`, `tags` | `multipleSelect` | -| `date` | `date` | `rate`, `star` | `rating` | -| `user`, `person` | `user` | `file`, `attach` | `attachment` | -| `link`, `rel` | `link` | `rollup` | `rollup` | -| `formula` | `formula` | `auto` | `autoNumber` | -| `created` | `createdTime` | `modified` | `lastModifiedTime` | -| `createdBy` | `createdBy` | `modifiedBy` | `lastModifiedBy` | -| `button` | `button` | `condRollup` | `conditionalRollup` | +**`--fields` format**: JSON array of `{name, type, [options], [description]}` objects. Types accept full values or aliases (`sel` → `singleSelect`); per-type option shapes and inference rules: [field-types.md](../api-reference/field-types.md). **Compact shorthand** (in `--fields` only): `"name:type"` string format: @@ -103,35 +90,10 @@ inkreach-teable field create --table-id --name "" \ |------|----------|-------| | `--table-id` | yes | Target table | | `--name` | yes | Field display name | -| `--type` | yes | Full type name or alias (see [field-types.md](../api-reference/field-types.md)) | -| `--options` | depends on type | JSON object; see type-specific shapes below | +| `--type` | no | Full type name or alias; **omitted → inferred** from `--options` shape or `--name` pattern (rules: [field-types.md § Smart Inference](../api-reference/field-types.md)) | +| `--options` | depends on type | JSON object; select `choices` accept plain strings — see [field-types.md § Option Shapes](../api-reference/field-types.md) | | `--description` | no | Optional | -**Type inference from name**: when `--type` is omitted, the CLI infers from the field name: - -| Name pattern | Inferred type | Name pattern | Inferred type | -|-------------|---------------|-------------|---------------| -| `status`, `priority`, `stage`, `category`, `type`, `level` | `singleSelect` | `tag`, `label` | `multipleSelect` | -| `description`, `notes`, `comment`, `content`, `body` | `longText` | `count`, `amount`, `price`, `quantity`, `total`, `age` | `number` | -| `rating`, `score`, `stars` | `rating` | `is*`, `has*`, `active`, `enabled`, `done`, `completed` | `checkbox` | -| `assignee`, `owner`, `user` | `user` | `attachment`, `file`, `image`, `photo`, `document` | `attachment` | -| `email` | `singleLineText` | `url`, `link` (without table context) | `singleLineText` | -| `created time` / `created` | `createdTime` | `last modified` / `updated` | `lastModifiedTime` | -| `created by`, `author` | `createdBy` | `modified by`, `updated by` | `lastModifiedBy` | - -**Options by type** (most common): - -| Type | Required options | Shape | -|------|-----------------|-------| -| `singleSelect` / `multipleSelect` | `choices` | `{"choices":["A","B","C"]}` or `{"choices":[{"name":"A","color":"blue"}]}` — colors auto-assigned if omitted | -| `number` | none (defaults integer) | `{"formatting":{"precision":2}}` for decimal | -| `rating` | none (defaults max=5, star, yellow) | `{"max":5,"icon":"star","color":"yellowBright"}` | -| `date` | none | `{"formatting":{"date":"YYYY-MM-DD","time":"None","timeZone":"UTC"}}` | -| `link` | `foreignTableId` / `foreignTableName` | `{"foreignTableId":"tblXXX","relationship":"manyOne"}` | -| `formula` | `expression` | `{"expression":"{fieldId}+{otherFieldId}"}` | -| `rollup` | via `--lookup-options` | See [field-types.md](../api-reference/field-types.md) | -| `button` | `workflow` | `{"label":"Click","color":"blue","workflow":{"id":"wflXXX"}}` | - **Name resolution**: for `link` fields, use `foreignTableName` instead of `foreignTableId` — the CLI resolves it automatically: ```bash @@ -142,7 +104,7 @@ inkreach-teable field create --table-id tblXXX --name "Project" --type link \ ### Update & Delete ```bash -inkreach-teable field update [--name ""] [--options ''] +inkreach-teable field update [--name ""] [--options ''] inkreach-teable field delete ``` @@ -153,6 +115,27 @@ inkreach-teable field delete | `--name` + `--options` | Both applied via one convert call | | `field delete` | Irreversible — all cell data in that field is lost | +### Inspecting fields & constraints + +`field get` and `field visible-choices` accept **fieldId or field name** (the CLI resolves names automatically). Human mode renders select choices with their conditions — no `--json` post-processing needed: + +```bash +# One-field overview: which fields carry conditional choices +inkreach-teable field list tblXXX --with-options +# ID NAME TYPE OPTIONS +# ... 处理反馈 singleSelect 21 choices · 19 conditional +# followed by per-choice listings: name [color] + "when: " + +# Single field by name — choices expanded with human-readable conditions +inkreach-teable field get tblXXX 处理反馈 +# choices (21 · 19 conditional): +# 待工厂补发 [orange] +# when: 问题类型 is 物流问题-超时未揽收 OR 问题类型 is 物流问题-丢件 … + +# What a specific record may currently select +inkreach-teable field visible-choices tblXXX recXXX 处理反馈 +``` + ### Select-choice dependency (visibilityFilter) — 2026-08 Single/multiple-select choices may carry a `visibilityFilter` making them visible only when another select field matches: @@ -162,9 +145,10 @@ Single/multiple-select choices may carry a `visibilityFilter` making them visibl "filterSet":[{"fieldId":"fldDeviceType","operator":"is","value":"choPrinter"}]}}]} ``` -- **Read config**: `inkreach-teable field get ` → `options.choices[].visibilityFilter` +- **Read config**: `field get ` → `options.choices[].visibilityFilter` (rendered human-readable in default mode; raw JSON with `--json`) +- **Survey a whole table**: `field list --with-options` shows a constraint count per field plus per-choice conditions - **Write config**: `field update --options` (convert API). Backend rejects self-reference (`Self-reference in visibilityFilter is not allowed`) and dependency cycles (`Dependency cycle detected…`) with 400 — enforced on both V1 and V2 paths since fix `26ed1f732` -- **Evaluate for a record**: `inkreach-teable field visible-choices ` → choices currently selectable +- **Evaluate for a record**: `field visible-choices ` → choices currently selectable - **Write-time enforcement**: writing a record value that fails its visibility condition → 400 `Option "X" does not meet the current row conditions and cannot be selected.` --- @@ -346,6 +330,42 @@ Deleting a view does NOT delete the table or its data — only the view configur --- +## View Presets + +Presets are **view-scoped, named bundles of view configuration** (filter/sort/group, optionally options/columnMeta), stored server-side and shared with all collaborators — each view owns its own preset list. Users apply them from the toolbar preset dropdown (labeled by current state: 公共 / 个人 / active preset name; the old "个人" toggle button was merged into this dropdown). Applying seeds the user's personal-view overlay (persisted server-side per user per view), so operators can work in different presets simultaneously without touching the collaborative view. Managing presets requires table-level view-update permission. + +### Typical use + +One-click screening configs for operators, e.g. "pending orders" vs "in-progress orders" on an order table, without granting view-edit permission. + +### Commands + +```bash +# preset screening pending orders (bound to one view) +inkreach-teable preset create --table-id tblXxx --view-id viwXxx --name "待处理订单" \ + --filter '{"conjunction":"and","filterSet":[{"fieldId":"fldStatus","operator":"is","value":"待处理"}]}' + +# preset for in-progress orders, newest first +inkreach-teable preset create --table-id tblXxx --view-id viwXxx --name "正在处理的订单" \ + --filter '{"conjunction":"and","filterSet":[{"fieldId":"fldStatus","operator":"is","value":"处理中"}]}' \ + --sort '{"sortObjs":[{"fieldId":"fldCreated","order":"desc"}]}' + +inkreach-teable preset list tblXxx --view-id viwXxx +inkreach-teable preset update --table-id tblXxx --name "新名字" # or replace --filter/--sort/... +inkreach-teable preset delete --table-id tblXxx +``` + +### Semantics + +- Config keys you do NOT pass stay **unset**: on apply, the preset keeps the view's current value for those keys (a filter-only preset does not touch the sort). +- Keys you DO pass **replace** that aspect on apply. +- `--column-meta` snapshots field visibility/order — avoid unless needed: fields added later stay hidden while that preset is active (stale snapshot). +- Deleting a preset does not touch views or records; users applying it fall back to the public config on next switch. +- REST equivalents: `GET/POST /api/table/:tableId/view-preset` (list accepts `?viewId=`; create requires `viewId`), `PUT/DELETE /api/table/:tableId/view-preset/:presetId` (writes need view-update permission). +- Per-user personal config: `GET/PUT/DELETE /api/table/:tableId/view-preset/personal?viewId=` (view-read permission; PUT body `{config, enabled}`) — this is what the UI "个人配置" entry persists. + +--- + ## Value Formats Quick Reference | Field Type | Value format | Notes | diff --git a/dist/commands/resources.js b/dist/commands/resources.js index c717c1c..d24d86e 100644 --- a/dist/commands/resources.js +++ b/dist/commands/resources.js @@ -1,6 +1,6 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.baseResourceCommands = void 0; +exports.presetConfigFromOptions = exports.baseResourceCommands = void 0; /** * `inkreach-teable base|table|field|record|view` — resource CRUD commands. * @@ -9,9 +9,36 @@ exports.baseResourceCommands = void 0; const commander_1 = require("commander"); const ctx_1 = require("../lib/ctx"); const output_1 = require("../lib/output"); +const client_1 = require("../lib/client"); const field_normalize_1 = require("../lib/field-normalize"); +const field_display_1 = require("../lib/field-display"); +/** + * Fetch a field by ID or display name. A direct ID hit needs no extra call; + * a 400/404 miss (the API rejects non-ID paths) falls back to resolving the + * arg against the table's field list. Unresolvable input rethrows the + * original error so the user sees the underlying cause. + */ +async function getFieldFlexible(client, tableId, fieldIdOrName) { + try { + return (await client.getField(tableId, fieldIdOrName)); + } + catch (err) { + if (err instanceof client_1.ApiError && (err.status === 400 || err.status === 404)) { + const list = (await client.listFields(tableId)); + const hit = (0, field_normalize_1.findFieldByNameOrId)(list, fieldIdOrName); + if (hit) + return (await client.getField(tableId, hit.id)); + } + throw err; + } +} +/** fieldId → display-name map of a table, to humanize visibilityFilter references. */ +async function fieldNameMap(client, tableId) { + const list = (await client.listFields(tableId)); + return Object.fromEntries(list.map((f) => [f.id, f.name])); +} function baseResourceCommands() { - return [baseCmd(), tableCmd(), fieldCmd(), recordCmd(), viewCmd()]; + return [baseCmd(), tableCmd(), fieldCmd(), recordCmd(), viewCmd(), presetCmd()]; } exports.baseResourceCommands = baseResourceCommands; // ===================== base ===================== @@ -162,7 +189,9 @@ function fieldCmd() { cmd .command('list ') .description('List fields in a table') - .action((0, ctx_1.run)(async (ctx, args) => { + .option('--with-options', 'Add an OPTIONS summary column and expand select choices with their visibility conditions') + .action((0, ctx_1.run)(async (ctx, args, o) => { + const opts = o; const tableId = args[0]; const list = (await ctx.client.listFields(tableId)); if (ctx.json) @@ -172,28 +201,60 @@ function fieldCmd() { process.stdout.write('No fields found.\n'); return; } - (0, output_1.printTable)([ - { key: 'id', header: 'ID' }, - { key: 'name', header: 'NAME' }, - { key: 'type', header: 'TYPE' }, - ], list.map((f) => ({ - id: (0, output_1.truncate)(f.id), - name: (0, output_1.truncate)(f.name, 30), - type: f.type, - }))); + if (opts.withOptions) { + const nameMap = Object.fromEntries(list.map((f) => [String(f.id), String(f.name)])); + (0, output_1.printTable)([ + { key: 'id', header: 'ID' }, + { key: 'name', header: 'NAME' }, + { key: 'type', header: 'TYPE' }, + { key: 'options', header: 'OPTIONS' }, + ], list.map((f) => ({ + id: (0, output_1.truncate)(f.id), + name: (0, output_1.truncate)(f.name, 30), + type: f.type, + options: (0, field_display_1.summarizeFieldOptions)(f), + }))); + const selectFields = list.filter((f) => Array.isArray(f.options?.choices)); + for (const f of selectFields) { + const rows = (0, field_display_1.choiceRows)(f, nameMap); + const conditional = rows.filter((r) => r.when !== '(always)').length; + process.stdout.write(`\n◇ ${f.name} (${f.id}) — ${rows.length} choices · ${conditional} conditional\n`); + for (const r of rows) { + const color = r.color ? ` [${r.color}]` : ''; + process.stdout.write(` ${r.name}${color}\n`); + if (r.when !== '(always)') { + process.stdout.write(` when: ${r.when}\n`); + } + } + } + } + else { + (0, output_1.printTable)([ + { key: 'id', header: 'ID' }, + { key: 'name', header: 'NAME' }, + { key: 'type', header: 'TYPE' }, + ], list.map((f) => ({ + id: (0, output_1.truncate)(f.id), + name: (0, output_1.truncate)(f.name, 30), + type: f.type, + }))); + } } })); cmd - .command('get ') - .description('Get field details (full options incl. choice visibilityFilter)') + .command('get ') + .description('Get field details by id or name (full options; select choices rendered with visibility conditions)') .action((0, ctx_1.run)(async (ctx, args) => { - const [tableId, fieldId] = args; - const field = await ctx.client.getField(tableId, fieldId); + const [tableId, fieldIdOrName] = args; + const field = await getFieldFlexible(ctx.client, tableId, fieldIdOrName); if (ctx.json) (0, output_1.printJson)(field); else { - const f = field; - (0, output_1.printKv)(Object.entries(f).map(([k, v]) => [k, v])); + const choices = field.options?.choices; + const names = Array.isArray(choices) + ? await fieldNameMap(ctx.client, tableId) + : undefined; + process.stdout.write((0, field_display_1.formatFieldDetail)(field, names) + '\n'); } })); cmd @@ -242,27 +303,28 @@ function fieldCmd() { } })); cmd - .command('update ') - .description('Update a field. --name renames (PATCH); --options rewrites options via the convert API (the update API does not accept options)') + .command('update ') + .description('Update a field (by id or name). --name renames (PATCH); --options rewrites options via the convert API (the update API does not accept options)') .option('--name ', 'New field name') .option('--options ', 'New field options JSON (written via convert API)') .action((0, ctx_1.run)(async (ctx, args, o) => { - const [tableId, fieldId] = args; + const [tableId, fieldIdOrName] = args; const opts = o; if (!opts.name && !opts.options) { throw new Error('Nothing to update: pass --name and/or --options'); } if (opts.name && !opts.options) { - const field = await ctx.client.updateField(tableId, fieldId, { name: opts.name }); + const current = (await getFieldFlexible(ctx.client, tableId, fieldIdOrName)); + const field = await ctx.client.updateField(tableId, current.id, { name: opts.name }); if (ctx.json) (0, output_1.printJson)(field); else - process.stdout.write(`✓ Field ${fieldId} renamed\n`); + process.stdout.write(`✓ Field ${current.id} renamed\n`); return; } // Options (choices / visibilityFilter / ...) must go through convert. // Fetch the current field to reuse its type; merge name if given. - const current = (await ctx.client.getField(tableId, fieldId)); + const current = (await getFieldFlexible(ctx.client, tableId, fieldIdOrName)); const body = { type: current.type, options: JSON.parse(opts.options), @@ -270,18 +332,19 @@ function fieldCmd() { if (opts.name) body.name = opts.name; (0, field_normalize_1.normalizeFieldRo)(body); - const field = await ctx.client.convertField(tableId, fieldId, body); + const field = await ctx.client.convertField(tableId, current.id, body); if (ctx.json) (0, output_1.printJson)(field); else - process.stdout.write(`✓ Field ${fieldId} options updated (via convert)\n`); + process.stdout.write(`✓ Field ${current.id} options updated (via convert)\n`); })); cmd - .command('visible-choices ') + .command('visible-choices ') .description('Evaluate select-choice visibility for a record (respects choice visibilityFilter dependencies)') .action((0, ctx_1.run)(async (ctx, args) => { - const [tableId, recordId, fieldId] = args; - const res = await ctx.client.getVisibleChoices(tableId, recordId, fieldId); + const [tableId, recordId, fieldIdOrName] = args; + const field = await getFieldFlexible(ctx.client, tableId, fieldIdOrName); + const res = await ctx.client.getVisibleChoices(tableId, recordId, String(field.id)); if (ctx.json) (0, output_1.printJson)(res); else { @@ -536,3 +599,141 @@ function viewCmd() { })); return cmd; } +// ===================== view preset ===================== +/** Parse a --filter/--sort/... JSON option; empty/absent → undefined. */ +function parseJsonOption(value, optionName) { + if (value == null || value === '') + return undefined; + try { + return JSON.parse(value); + } + catch (err) { + throw new Error(`Invalid JSON for ${optionName}: ${err.message}`); + } +} +/** Collect preset config keys from options; absent keys stay out of the config. */ +function presetConfigFromOptions(opts) { + const config = {}; + const entries = [ + ['filter', '--filter'], + ['sort', '--sort'], + ['group', '--group'], + ['options', '--options'], + ['columnMeta', '--column-meta'], + ]; + for (const [key, optionName] of entries) { + const parsed = parseJsonOption(opts[key], optionName); + if (parsed !== undefined) + config[key] = parsed; + } + return config; +} +exports.presetConfigFromOptions = presetConfigFromOptions; +function presetCmd() { + const cmd = new commander_1.Command('preset'); + cmd.description('Manage preset view configurations (named filter/sort/group bundles, shared with all collaborators)'); + cmd + .command('list ') + .description('List view presets of a table (pass --view-id to scope to one view)') + .option('--view-id ', 'Only presets bound to this view') + .action((0, ctx_1.run)(async (ctx, args, o) => { + const tableId = args[0]; + const opts = o; + const list = (await ctx.client.listViewPresets(tableId, opts.viewId)); + if (ctx.json) + (0, output_1.printJson)(list); + else { + if (!list.length) { + process.stdout.write('No view presets found.\n'); + return; + } + (0, output_1.printTable)([ + { key: 'id', header: 'ID' }, + { key: 'name', header: 'NAME' }, + { key: 'config', header: 'CONFIG' }, + ], list.map((p) => { + const config = (p.config ?? {}); + const keys = Object.keys(config).filter((k) => config[k] != null); + return { + id: (0, output_1.truncate)(p.id), + name: (0, output_1.truncate)(p.name, 30), + config: keys.join(',') || '(empty)', + }; + })); + } + })); + cmd + .command('create') + .description('Create a view preset bound to one view (keys not passed stay unset — they keep the view values on apply)') + .requiredOption('--table-id ', 'Table id') + .requiredOption('--view-id ', 'View id the preset belongs to') + .requiredOption('--name ', 'Preset name') + .option('--description ', 'Preset description') + .option('--filter ', 'Filter config, e.g. \'{"conjunction":"and","filterSet":[{"fieldId":"fldX","operator":"is","value":"Pending"}]}\'') + .option('--sort ', 'Sort config, e.g. \'{"sortObjs":[{"fieldId":"fldX","order":"asc"}]}\'') + .option('--group ', 'Group config (JSON array of group items)') + .option('--options ', 'View options (JSON object)') + .option('--column-meta ', 'Column meta (field visibility/order, JSON object)') + .action((0, ctx_1.run)(async (ctx, _args, o) => { + const opts = o; + const body = { + name: opts.name, + viewId: opts.viewId, + config: presetConfigFromOptions(opts), + }; + if (opts.description) + body.description = opts.description; + const preset = (await ctx.client.createViewPreset(opts.tableId, body)); + if (ctx.json) + (0, output_1.printJson)(preset); + else { + (0, output_1.printKv)([ + ['id', preset.id], + ['name', preset.name], + ['order', preset.order], + ]); + } + })); + cmd + .command('update ') + .description('Update a view preset (rename and/or replace config keys)') + .requiredOption('--table-id ', 'Table id') + .option('--name ', 'New preset name') + .option('--description ', 'New preset description') + .option('--filter ', 'Replace filter config (JSON)') + .option('--sort ', 'Replace sort config (JSON)') + .option('--group ', 'Replace group config (JSON)') + .option('--options ', 'Replace view options (JSON object)') + .option('--column-meta ', 'Replace column meta (JSON object)') + .action((0, ctx_1.run)(async (ctx, args, o) => { + const presetId = args[0]; + const opts = o; + const body = {}; + if (opts.name) + body.name = opts.name; + if (opts.description !== undefined) + body.description = opts.description; + const config = presetConfigFromOptions(opts); + if (Object.keys(config).length) + body.config = config; + await ctx.client.updateViewPreset(opts.tableId, presetId, body); + if (ctx.json) + (0, output_1.printJson)({ ok: true, id: presetId }); + else + process.stdout.write(`✓ Preset ${presetId} updated\n`); + })); + cmd + .command('delete ') + .description('Delete a view preset') + .requiredOption('--table-id ', 'Table id') + .action((0, ctx_1.run)(async (ctx, args, o) => { + const presetId = args[0]; + const opts = o; + await ctx.client.deleteViewPreset(opts.tableId, presetId); + if (ctx.json) + (0, output_1.printJson)({ ok: true, id: presetId }); + else + process.stdout.write(`✓ Preset ${presetId} deleted\n`); + })); + return cmd; +} diff --git a/dist/commands/subscription.js b/dist/commands/subscription.js index dd7cbfc..755fc14 100644 --- a/dist/commands/subscription.js +++ b/dist/commands/subscription.js @@ -182,9 +182,7 @@ function subscriptionCommand() { } })); const getCmd = cmd.command('get ').description('Show full subscription config'); - getCmd - .option('--base-id ', 'Base id (overrides default)') - .action((0, ctx_1.run)(async (ctx, args, o) => { + getCmd.option('--base-id ', 'Base id (overrides default)').action((0, ctx_1.run)(async (ctx, args, o) => { const id = args[0]; const opts = o; const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId); @@ -234,7 +232,11 @@ function subscriptionCommand() { if (ctx.json) (0, output_1.printJson)(vo); else - (0, output_1.printKv)([['id', vo.id], ['name', vo.name], ['created', '✓']]); + (0, output_1.printKv)([ + ['id', vo.id], + ['name', vo.name], + ['created', '✓'], + ]); })); const updateCmd = cmd .command('update ') @@ -251,7 +253,10 @@ function subscriptionCommand() { if (ctx.json) (0, output_1.printJson)(vo); else - (0, output_1.printKv)([['id', vo.id], ['updated', '✓']]); + (0, output_1.printKv)([ + ['id', vo.id], + ['updated', '✓'], + ]); })); cmd .command('delete ') @@ -308,7 +313,10 @@ function subscriptionCommand() { if (ctx.json) (0, output_1.printJson)(result ?? { id }); else - (0, output_1.printKv)([['id', id], ['sent', '✓']]); + (0, output_1.printKv)([ + ['id', id], + ['sent', '✓'], + ]); })); return cmd; } diff --git a/dist/lib/client.js b/dist/lib/client.js index 92174aa..b4d88f7 100644 --- a/dist/lib/client.js +++ b/dist/lib/client.js @@ -154,6 +154,21 @@ class Client { deleteView(viewId) { return this.call('DELETE', `/api/view/${viewId}`); } + // ---- view preset (self-built contract) ---- + // Preset view configurations: VIEW-scoped named bundles of + // filter/sort/group(/options/columnMeta) shared with all collaborators. + listViewPresets(tableId, viewId) { + return this.call('GET', `/api/table/${tableId}/view-preset${viewId ? `?viewId=${encodeURIComponent(viewId)}` : ''}`); + } + createViewPreset(tableId, body) { + return this.call('POST', `/api/table/${tableId}/view-preset`, body); + } + updateViewPreset(tableId, presetId, body) { + return this.call('PUT', `/api/table/${tableId}/view-preset/${presetId}`, body); + } + deleteViewPreset(tableId, presetId) { + return this.call('DELETE', `/api/table/${tableId}/view-preset/${presetId}`); + } // ---- workflow (self-built contract) ---- listWorkflows(baseId) { return this.call('GET', `/api/base/${baseId}/workflow`); diff --git a/dist/lib/field-display.js b/dist/lib/field-display.js new file mode 100644 index 0000000..654d771 --- /dev/null +++ b/dist/lib/field-display.js @@ -0,0 +1,136 @@ +"use strict"; +/** + * Human-mode display helpers for field options — make select-choice + * constraints (choices, colors, per-choice visibilityFilter) readable + * without `--json` post-processing. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.formatFieldDetail = exports.choiceRows = exports.describeChoiceCondition = exports.summarizeFieldOptions = void 0; +const CONDITION_MAX_LEN = 90; +/** Summarize a field's options for the `field list --with-options` column. */ +function summarizeFieldOptions(field) { + const options = field.options; + const choices = options?.choices; + if (Array.isArray(choices) && choices.length) { + const conditional = choices.filter((c) => c && typeof c === 'object' && c.visibilityFilter).length; + return conditional + ? `${choices.length} choices · ${conditional} conditional` + : `${choices.length} choices`; + } + if (!options || typeof options !== 'object') + return '—'; + const type = field.type; + if (type === 'rating') { + return [ + `max=${options.max ?? 5}`, + typeof options.icon === 'string' ? `icon=${options.icon}` : '', + ] + .filter(Boolean) + .join(' '); + } + const formatting = options.formatting; + if (type === 'number' && formatting) { + return `precision=${formatting.precision ?? 0}`; + } + if ((type === 'date' || type === 'createdTime' || type === 'lastModifiedTime') && formatting) { + return [formatting.date, formatting.time && formatting.time !== 'None' ? formatting.time : ''] + .filter(Boolean) + .join(' '); + } + if (typeof options.expression === 'string') { + const e = options.expression; + return `expr: ${e.length > 40 ? e.slice(0, 39) + '…' : e}`; + } + return '—'; +} +exports.summarizeFieldOptions = summarizeFieldOptions; +function formatValue(value) { + if (value === null || value === undefined) + return ''; + if (Array.isArray(value)) + return value.map((v) => String(v)).join('|'); + if (typeof value === 'object') + return JSON.stringify(value); + return String(value); +} +/** One-line human summary of a choice's visibilityFilter; `(always)` when absent. */ +function describeChoiceCondition(vf, fieldNames) { + if (!vf || typeof vf !== 'object') + return '(always)'; + const { conjunction, filterSet } = vf; + if (!Array.isArray(filterSet) || !filterSet.length) + return '(always)'; + const joiner = conjunction === 'or' ? ' OR ' : ' AND '; + const rules = filterSet.map(({ fieldId, operator, value }) => { + const label = (fieldId && fieldNames?.[fieldId]) || fieldId || '?'; + const v = formatValue(value); + return v ? `${label} ${operator ?? '?'} ${v}` : `${label} ${operator ?? '?'}`; + }); + let text = ''; + let kept = 0; + for (const rule of rules) { + const candidate = text ? text + joiner + rule : rule; + if (candidate.length > CONDITION_MAX_LEN && kept > 0) + break; + text = candidate; + kept++; + } + const omitted = rules.length - kept; + return omitted > 0 ? `${text}… +${omitted} more` : text; +} +exports.describeChoiceCondition = describeChoiceCondition; +/** Choices of a select field as display rows; `[]` when the field has none. */ +function choiceRows(field, fieldNames) { + const choices = field.options?.choices; + if (!Array.isArray(choices)) + return []; + return choices + .filter((c) => !!c && typeof c === 'object') + .map((c) => ({ + name: String(c.name ?? ''), + color: typeof c.color === 'string' ? c.color : '', + when: describeChoiceCondition(c.visibilityFilter, fieldNames), + })); +} +exports.choiceRows = choiceRows; +function kvLine(key, value, keyWidth) { + const val = typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value ?? ''); + return `${key.padEnd(keyWidth)} ${val}`; +} +/** Full human-mode rendering for `field get`: kv lines, with select choices + * expanded into a per-choice condition listing instead of one JSON blob. */ +function formatFieldDetail(field, fieldNames) { + const lines = []; + const options = field.options; + const choices = options?.choices; + const restEntries = Object.entries({ ...field }); + const keyWidth = Math.max(...restEntries.map(([k]) => k.length), 'defaultValue'.length); + for (const [k, v] of restEntries) { + if (k === 'options') + continue; + lines.push(kvLine(k, v, keyWidth)); + } + if (Array.isArray(choices) && choices.length) { + const conditional = choices.filter((c) => c && typeof c === 'object' && c.visibilityFilter).length; + lines.push(''); + lines.push(`choices (${choices.length} · ${conditional} conditional):`); + for (const row of choiceRows(field, fieldNames)) { + const color = row.color ? ` [${row.color}]` : ''; + lines.push(` ${row.name}${color}`); + lines.push(` when: ${row.when}`); + } + const otherOpts = Object.fromEntries(Object.entries(options ?? {}).filter(([k]) => k !== 'choices')); + if (Object.keys(otherOpts).length) { + lines.push(''); + lines.push(kvLine('options (other)', otherOpts, keyWidth)); + } + } + else if (options && Object.keys(options).length) { + lines.push(kvLine('options', options, keyWidth)); + } + else { + lines.push(kvLine('options', undefined, keyWidth)); + } + return lines.join('\n'); +} +exports.formatFieldDetail = formatFieldDetail; diff --git a/src/commands/resources.spec.ts b/src/commands/resources.spec.ts new file mode 100644 index 0000000..325357d --- /dev/null +++ b/src/commands/resources.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { presetConfigFromOptions } from './resources'; + +describe('presetConfigFromOptions', () => { + it('parses provided JSON keys and drops absent ones', () => { + const config = presetConfigFromOptions({ + tableId: 'tblX', + name: 'n', + filter: '{"conjunction":"and","filterSet":[]}', + sort: '{"sortObjs":[{"fieldId":"fldA","order":"asc"}]}', + }); + + expect(config).toEqual({ + filter: { conjunction: 'and', filterSet: [] }, + sort: { sortObjs: [{ fieldId: 'fldA', order: 'asc' }] }, + }); + }); + + it('returns an empty config when nothing is provided', () => { + expect(presetConfigFromOptions({ tableId: 'tblX', name: 'n' })).toEqual({}); + }); + + it('throws a descriptive error on invalid JSON', () => { + expect(() => + presetConfigFromOptions({ tableId: 'tblX', name: 'n', filter: '{oops' }) + ).toThrowError(/Invalid JSON for --filter/); + }); +}); diff --git a/src/commands/resources.ts b/src/commands/resources.ts index 0e88d91..ec0ac84 100644 --- a/src/commands/resources.ts +++ b/src/commands/resources.ts @@ -4,8 +4,9 @@ * These endpoints are compatible with official teable open-api. */ import { Command } from 'commander'; -import { run, requireBaseId } from '../lib/ctx'; +import { run, requireBaseId, type Ctx } from '../lib/ctx'; import { printJson, printTable, printKv, truncate } from '../lib/output'; +import { Client, ApiError } from '../lib/client'; import { normalizeFieldRo, normalizeFieldRoArray, @@ -13,12 +14,43 @@ import { resolveFieldType, inferFieldType, findTableByNameOrId, + findFieldByNameOrId, type ITableLike, type IFieldLike, } from '../lib/field-normalize'; +import { formatFieldDetail, summarizeFieldOptions, choiceRows } from '../lib/field-display'; + +/** + * Fetch a field by ID or display name. A direct ID hit needs no extra call; + * a 400/404 miss (the API rejects non-ID paths) falls back to resolving the + * arg against the table's field list. Unresolvable input rethrows the + * original error so the user sees the underlying cause. + */ +async function getFieldFlexible( + client: Client, + tableId: string, + fieldIdOrName: string +): Promise> { + try { + return (await client.getField(tableId, fieldIdOrName)) as Record; + } catch (err) { + if (err instanceof ApiError && (err.status === 400 || err.status === 404)) { + const list = (await client.listFields(tableId)) as unknown as IFieldLike[]; + const hit = findFieldByNameOrId(list, fieldIdOrName); + if (hit) return (await client.getField(tableId, hit.id)) as Record; + } + throw err; + } +} + +/** fieldId → display-name map of a table, to humanize visibilityFilter references. */ +async function fieldNameMap(client: Client, tableId: string): Promise> { + const list = (await client.listFields(tableId)) as Array<{ id: string; name: string }>; + return Object.fromEntries(list.map((f) => [f.id, f.name])); +} export function baseResourceCommands(): Command[] { - return [baseCmd(), tableCmd(), fieldCmd(), recordCmd(), viewCmd()]; + return [baseCmd(), tableCmd(), fieldCmd(), recordCmd(), viewCmd(), presetCmd()]; } // ===================== base ===================== @@ -141,7 +173,11 @@ function tableCmd(): Command { 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) { + if ( + options && + typeof options.foreignTableName === 'string' && + !options.foreignTableId + ) { const t = findTableByNameOrId( (tables as unknown as ITableLike[]) || [], options.foreignTableName @@ -200,8 +236,13 @@ function fieldCmd(): Command { cmd .command('list ') .description('List fields in a table') + .option( + '--with-options', + 'Add an OPTIONS summary column and expand select choices with their visibility conditions' + ) .action( - run(async (ctx, args) => { + run(async (ctx, args, o) => { + const opts = o as { withOptions?: boolean }; const tableId = args[0] as string; const list = (await ctx.client.listFields(tableId)) as Array>; if (ctx.json) printJson(list); @@ -210,33 +251,73 @@ function fieldCmd(): Command { process.stdout.write('No fields found.\n'); return; } - printTable( - [ - { key: 'id', header: 'ID' }, - { key: 'name', header: 'NAME' }, - { key: 'type', header: 'TYPE' }, - ], - list.map((f) => ({ - id: truncate(f.id), - name: truncate(f.name, 30), - type: f.type, - })) - ); + if (opts.withOptions) { + const nameMap = Object.fromEntries(list.map((f) => [String(f.id), String(f.name)])); + printTable( + [ + { key: 'id', header: 'ID' }, + { key: 'name', header: 'NAME' }, + { key: 'type', header: 'TYPE' }, + { key: 'options', header: 'OPTIONS' }, + ], + list.map((f) => ({ + id: truncate(f.id), + name: truncate(f.name, 30), + type: f.type, + options: summarizeFieldOptions(f), + })) + ); + const selectFields = list.filter((f) => + Array.isArray((f.options as Record | undefined)?.choices) + ); + for (const f of selectFields) { + const rows = choiceRows(f, nameMap); + const conditional = rows.filter((r) => r.when !== '(always)').length; + process.stdout.write( + `\n◇ ${f.name} (${f.id}) — ${rows.length} choices · ${conditional} conditional\n` + ); + for (const r of rows) { + const color = r.color ? ` [${r.color}]` : ''; + process.stdout.write(` ${r.name}${color}\n`); + if (r.when !== '(always)') { + process.stdout.write(` when: ${r.when}\n`); + } + } + } + } else { + printTable( + [ + { key: 'id', header: 'ID' }, + { key: 'name', header: 'NAME' }, + { key: 'type', header: 'TYPE' }, + ], + list.map((f) => ({ + id: truncate(f.id), + name: truncate(f.name, 30), + type: f.type, + })) + ); + } } }) ); cmd - .command('get ') - .description('Get field details (full options incl. choice visibilityFilter)') + .command('get ') + .description( + 'Get field details by id or name (full options; select choices rendered with visibility conditions)' + ) .action( run(async (ctx, args) => { - const [tableId, fieldId] = args as [string, string]; - const field = await ctx.client.getField(tableId, fieldId); + const [tableId, fieldIdOrName] = args as [string, string]; + const field = await getFieldFlexible(ctx.client, tableId, fieldIdOrName); if (ctx.json) printJson(field); else { - const f = field as Record; - printKv(Object.entries(f).map(([k, v]) => [k, v])); + const choices = (field.options as Record | undefined)?.choices; + const names = Array.isArray(choices) + ? await fieldNameMap(ctx.client, tableId) + : undefined; + process.stdout.write(formatFieldDetail(field, names) + '\n'); } }) ); @@ -250,9 +331,15 @@ function fieldCmd(): Command { '--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( + '--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( + '--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) => { @@ -295,28 +382,32 @@ function fieldCmd(): Command { ); cmd - .command('update ') + .command('update ') .description( - 'Update a field. --name renames (PATCH); --options rewrites options via the convert API (the update API does not accept options)' + 'Update a field (by id or name). --name renames (PATCH); --options rewrites options via the convert API (the update API does not accept options)' ) .option('--name ', 'New field name') .option('--options ', 'New field options JSON (written via convert API)') .action( run(async (ctx, args, o) => { - const [tableId, fieldId] = args as [string, string]; + const [tableId, fieldIdOrName] = args as [string, string]; const opts = o as { name?: string; options?: string }; if (!opts.name && !opts.options) { throw new Error('Nothing to update: pass --name and/or --options'); } if (opts.name && !opts.options) { - const field = await ctx.client.updateField(tableId, fieldId, { name: opts.name }); + const current = (await getFieldFlexible(ctx.client, tableId, fieldIdOrName)) as { + id: string; + }; + const field = await ctx.client.updateField(tableId, current.id, { name: opts.name }); if (ctx.json) printJson(field); - else process.stdout.write(`✓ Field ${fieldId} renamed\n`); + else process.stdout.write(`✓ Field ${current.id} renamed\n`); return; } // Options (choices / visibilityFilter / ...) must go through convert. // Fetch the current field to reuse its type; merge name if given. - const current = (await ctx.client.getField(tableId, fieldId)) as { + const current = (await getFieldFlexible(ctx.client, tableId, fieldIdOrName)) as { + id: string; type: string; }; const body: Record = { @@ -325,21 +416,22 @@ function fieldCmd(): Command { }; if (opts.name) body.name = opts.name; normalizeFieldRo(body); - const field = await ctx.client.convertField(tableId, fieldId, body); + const field = await ctx.client.convertField(tableId, current.id, body); if (ctx.json) printJson(field); - else process.stdout.write(`✓ Field ${fieldId} options updated (via convert)\n`); + else process.stdout.write(`✓ Field ${current.id} options updated (via convert)\n`); }) ); cmd - .command('visible-choices ') + .command('visible-choices ') .description( 'Evaluate select-choice visibility for a record (respects choice visibilityFilter dependencies)' ) .action( run(async (ctx, args) => { - const [tableId, recordId, fieldId] = args as [string, string, string]; - const res = await ctx.client.getVisibleChoices(tableId, recordId, fieldId); + const [tableId, recordId, fieldIdOrName] = args as [string, string, string]; + const field = await getFieldFlexible(ctx.client, tableId, fieldIdOrName); + const res = await ctx.client.getVisibleChoices(tableId, recordId, String(field.id)); if (ctx.json) printJson(res); else { printTable( @@ -634,3 +726,174 @@ function viewCmd(): Command { return cmd; } + +// ===================== view preset ===================== + +/** Parse a --filter/--sort/... JSON option; empty/absent → undefined. */ +function parseJsonOption(value: string | undefined, optionName: string): unknown { + if (value == null || value === '') return undefined; + try { + return JSON.parse(value); + } catch (err) { + throw new Error(`Invalid JSON for ${optionName}: ${(err as Error).message}`); + } +} + +interface IPresetOptions { + tableId: string; + viewId?: string; + name?: string; + description?: string; + filter?: string; + sort?: string; + group?: string; + options?: string; + columnMeta?: string; +} + +/** Collect preset config keys from options; absent keys stay out of the config. */ +export function presetConfigFromOptions(opts: IPresetOptions): Record { + const config: Record = {}; + const entries: Array<[keyof IPresetOptions, string]> = [ + ['filter', '--filter'], + ['sort', '--sort'], + ['group', '--group'], + ['options', '--options'], + ['columnMeta', '--column-meta'], + ]; + for (const [key, optionName] of entries) { + const parsed = parseJsonOption(opts[key] as string | undefined, optionName); + if (parsed !== undefined) config[key] = parsed; + } + return config; +} + +function presetCmd(): Command { + const cmd = new Command('preset'); + cmd.description( + 'Manage preset view configurations (named filter/sort/group bundles, shared with all collaborators)' + ); + + cmd + .command('list ') + .description('List view presets of a table (pass --view-id to scope to one view)') + .option('--view-id ', 'Only presets bound to this view') + .action( + run(async (ctx, args, o) => { + const tableId = args[0] as string; + const opts = o as unknown as IPresetOptions; + const list = (await ctx.client.listViewPresets(tableId, opts.viewId)) as Array< + Record + >; + if (ctx.json) printJson(list); + else { + if (!list.length) { + process.stdout.write('No view presets found.\n'); + return; + } + printTable( + [ + { key: 'id', header: 'ID' }, + { key: 'name', header: 'NAME' }, + { key: 'config', header: 'CONFIG' }, + ], + list.map((p) => { + const config = (p.config ?? {}) as Record; + const keys = Object.keys(config).filter((k) => config[k] != null); + return { + id: truncate(p.id), + name: truncate(p.name, 30), + config: keys.join(',') || '(empty)', + }; + }) + ); + } + }) + ); + + cmd + .command('create') + .description( + 'Create a view preset bound to one view (keys not passed stay unset — they keep the view values on apply)' + ) + .requiredOption('--table-id ', 'Table id') + .requiredOption('--view-id ', 'View id the preset belongs to') + .requiredOption('--name ', 'Preset name') + .option('--description ', 'Preset description') + .option( + '--filter ', + 'Filter config, e.g. \'{"conjunction":"and","filterSet":[{"fieldId":"fldX","operator":"is","value":"Pending"}]}\'' + ) + .option( + '--sort ', + 'Sort config, e.g. \'{"sortObjs":[{"fieldId":"fldX","order":"asc"}]}\'' + ) + .option('--group ', 'Group config (JSON array of group items)') + .option('--options ', 'View options (JSON object)') + .option('--column-meta ', 'Column meta (field visibility/order, JSON object)') + .action( + run(async (ctx, _args, o) => { + const opts = o as unknown as IPresetOptions; + const body: Record = { + name: opts.name, + viewId: opts.viewId, + config: presetConfigFromOptions(opts), + }; + if (opts.description) body.description = opts.description; + const preset = (await ctx.client.createViewPreset(opts.tableId, body)) as Record< + string, + unknown + >; + if (ctx.json) printJson(preset); + else { + printKv([ + ['id', preset.id], + ['name', preset.name], + ['order', preset.order], + ]); + } + }) + ); + + cmd + .command('update ') + .description('Update a view preset (rename and/or replace config keys)') + .requiredOption('--table-id ', 'Table id') + .option('--name ', 'New preset name') + .option('--description ', 'New preset description') + .option('--filter ', 'Replace filter config (JSON)') + .option('--sort ', 'Replace sort config (JSON)') + .option('--group ', 'Replace group config (JSON)') + .option('--options ', 'Replace view options (JSON object)') + .option('--column-meta ', 'Replace column meta (JSON object)') + .action( + run(async (ctx, args, o) => { + const presetId = args[0] as string; + const opts = o as unknown as IPresetOptions; + const body: Record = {}; + if (opts.name) body.name = opts.name; + if (opts.description !== undefined) body.description = opts.description; + const config = presetConfigFromOptions(opts); + if (Object.keys(config).length) body.config = config; + await ctx.client.updateViewPreset(opts.tableId, presetId, body); + if (ctx.json) printJson({ ok: true, id: presetId }); + else process.stdout.write(`✓ Preset ${presetId} updated\n`); + }) + ); + + cmd + .command('delete ') + .description('Delete a view preset') + .requiredOption('--table-id ', 'Table id') + .action( + run(async (ctx, args, o) => { + const presetId = args[0] as string; + const opts = o as { tableId: string }; + await ctx.client.deleteViewPreset(opts.tableId, presetId); + if (ctx.json) printJson({ ok: true, id: presetId }); + else process.stdout.write(`✓ Preset ${presetId} deleted\n`); + }) + ); + + return cmd; +} diff --git a/src/lib/client.ts b/src/lib/client.ts index 75bf654..8f6dba6 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -166,6 +166,25 @@ export class Client { return this.call<{ id: string }>('DELETE', `/api/view/${viewId}`); } + // ---- view preset (self-built contract) ---- + // Preset view configurations: VIEW-scoped named bundles of + // filter/sort/group(/options/columnMeta) shared with all collaborators. + listViewPresets(tableId: string, viewId?: string) { + return this.call( + 'GET', + `/api/table/${tableId}/view-preset${viewId ? `?viewId=${encodeURIComponent(viewId)}` : ''}` + ); + } + createViewPreset(tableId: string, body: unknown) { + return this.call('POST', `/api/table/${tableId}/view-preset`, body); + } + updateViewPreset(tableId: string, presetId: string, body: unknown) { + return this.call('PUT', `/api/table/${tableId}/view-preset/${presetId}`, body); + } + deleteViewPreset(tableId: string, presetId: string) { + return this.call('DELETE', `/api/table/${tableId}/view-preset/${presetId}`); + } + // ---- workflow (self-built contract) ---- listWorkflows(baseId: string) { return this.call('GET', `/api/base/${baseId}/workflow`); diff --git a/src/lib/field-display.spec.ts b/src/lib/field-display.spec.ts new file mode 100644 index 0000000..beb952b --- /dev/null +++ b/src/lib/field-display.spec.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from 'vitest'; +import { + summarizeFieldOptions, + describeChoiceCondition, + choiceRows, + formatFieldDetail, +} from './field-display'; + +describe('summarizeFieldOptions (field list --with-options column)', () => { + it('counts select choices without conditions', () => { + const field = { + type: 'singleSelect', + options: { choices: [{ name: 'A' }, { name: 'B' }, { name: 'C' }] }, + }; + expect(summarizeFieldOptions(field)).toBe('3 choices'); + }); + + it('counts conditional choices (visibilityFilter)', () => { + const field = { + type: 'singleSelect', + options: { + choices: [ + { name: 'A' }, + { name: 'B', visibilityFilter: { conjunction: 'and', filterSet: [] } }, + { name: 'C', visibilityFilter: { conjunction: 'and', filterSet: [] } }, + ], + }, + }; + expect(summarizeFieldOptions(field)).toBe('3 choices · 2 conditional'); + }); + + it('summarizes rating options', () => { + expect(summarizeFieldOptions({ type: 'rating', options: { max: 5, icon: 'star' } })).toBe( + 'max=5 icon=star' + ); + }); + + it('summarizes number precision', () => { + expect( + summarizeFieldOptions({ + type: 'number', + options: { formatting: { precision: 2 } }, + }) + ).toBe('precision=2'); + }); + + it('summarizes date formatting', () => { + expect( + summarizeFieldOptions({ + type: 'date', + options: { formatting: { date: 'YYYY-MM-DD', time: 'HH:mm' } }, + }) + ).toBe('YYYY-MM-DD HH:mm'); + }); + + it('returns dash when no recognizable options', () => { + expect(summarizeFieldOptions({ type: 'singleLineText' })).toBe('—'); + expect(summarizeFieldOptions({ type: 'checkbox', options: {} })).toBe('—'); + }); +}); + +describe('describeChoiceCondition (visibilityFilter summary)', () => { + it('returns (always) when no filter', () => { + expect(describeChoiceCondition(undefined)).toBe('(always)'); + expect(describeChoiceCondition(null)).toBe('(always)'); + }); + + it('renders a single is rule with resolved field name', () => { + const vf = { + conjunction: 'and', + filterSet: [{ fieldId: 'fldA', operator: 'is', value: '物流问题-丢件' }], + }; + expect(describeChoiceCondition(vf, { fldA: '问题类型' })).toBe('问题类型 is 物流问题-丢件'); + }); + + it('falls back to fieldId when no name map', () => { + const vf = { + conjunction: 'and', + filterSet: [{ fieldId: 'fldA', operator: 'is', value: 'X' }], + }; + expect(describeChoiceCondition(vf)).toBe('fldA is X'); + }); + + it('joins rules with AND / OR and array values with |', () => { + const vfOr = { + conjunction: 'or', + filterSet: [ + { fieldId: 'fldA', operator: 'is', value: ['A1', 'A2'] }, + { fieldId: 'fldA', operator: 'is', value: 'B' }, + ], + }; + expect(describeChoiceCondition(vfOr, { fldA: '问题类型' })).toBe( + '问题类型 is A1|A2 OR 问题类型 is B' + ); + const vfAnd = { + conjunction: 'and', + filterSet: [ + { fieldId: 'fldA', operator: 'isNot', value: 'X' }, + { fieldId: 'fldB', operator: 'isNotEmpty', value: null }, + ], + }; + expect(describeChoiceCondition(vfAnd, { fldA: '甲', fldB: '乙' })).toBe( + '甲 isNot X AND 乙 isNotEmpty' + ); + }); + + it('truncates long rule lists with +N more', () => { + const vf = { + conjunction: 'or', + filterSet: Array.from({ length: 6 }, (_, i) => ({ + fieldId: 'fldA', + operator: 'is', + value: `value-number-${i}`, + })), + }; + const out = describeChoiceCondition(vf, { fldA: 'F' }); + // 4 rules fit the budget → the marker must report exactly the 2 omitted. + expect(out).toMatch(/\+2 more$/); + expect(out).not.toContain('value-number-4'); + expect(out).not.toContain('value-number-5'); + }); +}); + +describe('choiceRows (select field → display rows)', () => { + it('maps choices to name/color/when rows', () => { + const field = { + type: 'singleSelect', + options: { + choices: [ + { name: 'A', color: 'red' }, + { + name: 'B', + visibilityFilter: { + conjunction: 'and', + filterSet: [{ fieldId: 'fldX', operator: 'is', value: 'V' }], + }, + }, + ], + }, + }; + const rows = choiceRows(field, { fldX: 'X' }); + expect(rows).toEqual([ + { name: 'A', color: 'red', when: '(always)' }, + { name: 'B', color: '', when: 'X is V' }, + ]); + }); + + it('returns [] for non-select fields', () => { + expect(choiceRows({ type: 'number', options: {} })).toEqual([]); + expect(choiceRows({ type: 'singleSelect', options: {} })).toEqual([]); + }); +}); + +describe('formatFieldDetail (field get human mode)', () => { + it('expands select choices into lines with conditions', () => { + const field = { + id: 'fld1', + name: '处理反馈', + type: 'singleSelect', + unique: false, + options: { + defaultValue: 'A', + choices: [ + { name: 'A', color: 'blue' }, + { + name: 'B', + visibilityFilter: { + conjunction: 'and', + filterSet: [{ fieldId: 'fldT', operator: 'is', value: '丢件' }], + }, + }, + ], + }, + }; + const out = formatFieldDetail(field, { fldT: '问题类型' }); + expect(out).toContain('处理反馈'); + expect(out).toMatch(/choices \(2 · 1 conditional\)/); + expect(out).toContain('(always)'); + expect(out).toContain('问题类型 is 丢件'); + expect(out).toContain('defaultValue'); // non-choice options still visible + }); + + it('keeps non-select options as a JSON line', () => { + const field = { + id: 'fld2', + name: 'Score', + type: 'rating', + options: { max: 5, icon: 'star' }, + }; + const out = formatFieldDetail(field); + expect(out).toContain('max'); + expect(out).toContain('"max":5'); + }); +}); diff --git a/src/lib/field-display.ts b/src/lib/field-display.ts new file mode 100644 index 0000000..9bdd1c4 --- /dev/null +++ b/src/lib/field-display.ts @@ -0,0 +1,152 @@ +/** + * Human-mode display helpers for field options — make select-choice + * constraints (choices, colors, per-choice visibilityFilter) readable + * without `--json` post-processing. + */ + +const CONDITION_MAX_LEN = 90; + +/** Summarize a field's options for the `field list --with-options` column. */ +export function summarizeFieldOptions(field: Record): string { + const options = field.options as Record | undefined; + const choices = options?.choices; + if (Array.isArray(choices) && choices.length) { + const conditional = choices.filter( + (c) => c && typeof c === 'object' && (c as { visibilityFilter?: unknown }).visibilityFilter + ).length; + return conditional + ? `${choices.length} choices · ${conditional} conditional` + : `${choices.length} choices`; + } + if (!options || typeof options !== 'object') return '—'; + const type = field.type as string | undefined; + if (type === 'rating') { + return [ + `max=${options.max ?? 5}`, + typeof options.icon === 'string' ? `icon=${options.icon}` : '', + ] + .filter(Boolean) + .join(' '); + } + const formatting = options.formatting as Record | undefined; + if (type === 'number' && formatting) { + return `precision=${formatting.precision ?? 0}`; + } + if ((type === 'date' || type === 'createdTime' || type === 'lastModifiedTime') && formatting) { + return [formatting.date, formatting.time && formatting.time !== 'None' ? formatting.time : ''] + .filter(Boolean) + .join(' '); + } + if (typeof options.expression === 'string') { + const e = options.expression; + return `expr: ${e.length > 40 ? e.slice(0, 39) + '…' : e}`; + } + return '—'; +} + +interface IFilterItem { + fieldId?: string; + operator?: string; + value?: unknown; +} + +function formatValue(value: unknown): string { + if (value === null || value === undefined) return ''; + if (Array.isArray(value)) return value.map((v) => String(v)).join('|'); + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +} + +/** One-line human summary of a choice's visibilityFilter; `(always)` when absent. */ +export function describeChoiceCondition(vf: unknown, fieldNames?: Record): string { + if (!vf || typeof vf !== 'object') return '(always)'; + const { conjunction, filterSet } = vf as { conjunction?: string; filterSet?: IFilterItem[] }; + if (!Array.isArray(filterSet) || !filterSet.length) return '(always)'; + const joiner = conjunction === 'or' ? ' OR ' : ' AND '; + const rules = filterSet.map(({ fieldId, operator, value }) => { + const label = (fieldId && fieldNames?.[fieldId]) || fieldId || '?'; + const v = formatValue(value); + return v ? `${label} ${operator ?? '?'} ${v}` : `${label} ${operator ?? '?'}`; + }); + let text = ''; + let kept = 0; + for (const rule of rules) { + const candidate = text ? text + joiner + rule : rule; + if (candidate.length > CONDITION_MAX_LEN && kept > 0) break; + text = candidate; + kept++; + } + const omitted = rules.length - kept; + return omitted > 0 ? `${text}… +${omitted} more` : text; +} + +export interface IChoiceRow { + name: string; + color: string; + when: string; +} + +/** Choices of a select field as display rows; `[]` when the field has none. */ +export function choiceRows( + field: Record, + fieldNames?: Record +): IChoiceRow[] { + const choices = (field.options as Record | undefined)?.choices; + if (!Array.isArray(choices)) return []; + return choices + .filter((c): c is Record => !!c && typeof c === 'object') + .map((c) => ({ + name: String(c.name ?? ''), + color: typeof c.color === 'string' ? c.color : '', + when: describeChoiceCondition(c.visibilityFilter, fieldNames), + })); +} + +function kvLine(key: string, value: unknown, keyWidth: number): string { + const val = + typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value ?? ''); + return `${key.padEnd(keyWidth)} ${val}`; +} + +/** Full human-mode rendering for `field get`: kv lines, with select choices + * expanded into a per-choice condition listing instead of one JSON blob. */ +export function formatFieldDetail( + field: Record, + fieldNames?: Record +): string { + const lines: string[] = []; + const options = field.options as Record | undefined; + const choices = options?.choices; + const restEntries = Object.entries({ ...field }); + const keyWidth = Math.max(...restEntries.map(([k]) => k.length), 'defaultValue'.length); + + for (const [k, v] of restEntries) { + if (k === 'options') continue; + lines.push(kvLine(k, v, keyWidth)); + } + + if (Array.isArray(choices) && choices.length) { + const conditional = choices.filter( + (c) => c && typeof c === 'object' && (c as { visibilityFilter?: unknown }).visibilityFilter + ).length; + lines.push(''); + lines.push(`choices (${choices.length} · ${conditional} conditional):`); + for (const row of choiceRows(field, fieldNames)) { + const color = row.color ? ` [${row.color}]` : ''; + lines.push(` ${row.name}${color}`); + lines.push(` when: ${row.when}`); + } + const otherOpts = Object.fromEntries( + Object.entries(options ?? {}).filter(([k]) => k !== 'choices') + ); + if (Object.keys(otherOpts).length) { + lines.push(''); + lines.push(kvLine('options (other)', otherOpts, keyWidth)); + } + } else if (options && Object.keys(options).length) { + lines.push(kvLine('options', options, keyWidth)); + } else { + lines.push(kvLine('options', undefined, keyWidth)); + } + return lines.join('\n'); +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 2cb2285..7a63caf 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -15,5 +15,5 @@ "strict": true }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "**/*.spec.ts"] }