chore: merge view-preset live-sync + share-db drift reconcile (restores preset feature)
This commit is contained in:
@@ -2,167 +2,109 @@
|
|||||||
name: inkreach-teable
|
name: inkreach-teable
|
||||||
description: >-
|
description: >-
|
||||||
Operate a deployed inkreach-teable instance (a Teable fork with a self-built
|
Operate a deployed inkreach-teable instance (a Teable fork with a self-built
|
||||||
automation engine). Configure tables, fields, records, views, and automations
|
automation engine) via the inkreach-teable CLI: configure tables, fields,
|
||||||
(8 triggers incl. recordCreated/Updated/Condition, formSubmitted, scheduledTime,
|
records, views, and automations (8 triggers incl. recordCreated/Updated/
|
||||||
buttonClick, webhook; 7 actions: script, sendEmail, httpRequest, createRecord,
|
Condition, formSubmitted, scheduledTime, buttonClick, webhook; 7 actions),
|
||||||
updateRecord, getRecords, sendNotification), plus secrets, execution history, and dynamic plugins
|
inspect field constraints (select choices, visibilityFilter conditions),
|
||||||
(install/uninstall, base-level mount nodes, developer whitelist, CSP apiOrigins).
|
manage secrets, execution history, plugins, and record subscriptions. Use
|
||||||
Trigger when the user wants to manage tables/fields/records/views, build or
|
when the user references Teable-style IDs (bseXXX, tblXXX, fldXXX, recXXX,
|
||||||
configure automations/workflows, set up button-field triggers, schedule jobs,
|
viwXXX, wflXXX) or wants to configure tables/automations/plugins on the
|
||||||
create webhooks, inspect run history, install or configure plugins, allow
|
deployed instance — even without saying "inkreach-teable". The official
|
||||||
developers to upload plugins, or troubleshoot a deployed inkreach-teable
|
teable CLI does NOT work here (different automation contract).
|
||||||
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).
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# inkreach-teable operations
|
# 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.
|
Respond in the user's language. Keep answers concise and action-oriented.
|
||||||
|
|
||||||
> **Critical**: inkreach-teable's automation engine is **self-built and differs from
|
> **Critical**: the automation engine is **self-built and differs from official
|
||||||
> official teable**. The official `teable` CLI does NOT work for automations here.
|
> teable** — the official `teable` CLI corrupts or 404s on automations here.
|
||||||
> Always use the `inkreach-teable` CLI. See [Key Constraints](#3-key-constraints) for the
|
> Exact key-name differences are in [Key Constraints](#3-key-constraints).
|
||||||
> exact key-name differences.
|
|
||||||
|
|
||||||
## 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.
|
- Install the CLI if missing: `bash scripts/install.sh` (relative to this skill dir).
|
||||||
- **Endpoint + token**: configure once with `inkreach-teable auth login --endpoint <url> --token <PAT>`. Default endpoint is `http://localhost:3003`. Verify with `inkreach-teable auth status`.
|
- **Configure once**: `inkreach-teable auth login --endpoint http://localhost:3003 --token <PAT> [--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).
|
||||||
- **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).
|
- `--json` on any command returns raw JSON (for agent parsing). Fallback for unknown commands: `inkreach-teable <cmd> --help`.
|
||||||
- **`--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.
|
- **Read on demand**: consult the guide linked for your module when doing non-trivial work — no need to read everything up front.
|
||||||
- **`--json` mode**: add `--json` to any command for raw JSON output (for agent parsing).
|
|
||||||
- **Unfamiliar commands**: `inkreach-teable <cmd> --help` as a fallback.
|
|
||||||
- **Read the relevant guide before acting**: after entering a module, read the doc(s) marked "Required" in the module map.
|
|
||||||
|
|
||||||
## 2. Module & Command Navigation
|
## 2. Module & Command Navigation
|
||||||
|
|
||||||
### 2.1 Module Map
|
| Module | What it solves | Entry commands | Detail doc |
|
||||||
|
|--------|---------------|----------------|------------|
|
||||||
| Module | What it solves | Entry commands | Guide to read |
|
| 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) |
|
||||||
| 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) |
|
| Views | Filtered/sorted perspectives | `view create/update` | [tables-fields.md § Views](guides/tables-fields.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) |
|
| 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) |
|
||||||
| Views | Persistent filtered/sorted perspectives | `view create/update` | [cli-reference.md § view](guides/cli-reference.md#base--table--field--record--view) |
|
|
||||||
| 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) |
|
| 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) |
|
| Secrets | API keys for script actions | `secret set/list/delete` | [automation-guide.md § Secrets](guides/automation-guide.md) |
|
||||||
| Execution History | Inspect run results / rerun failures | `runs list/get/rerun` | [cli-reference.md § runs](guides/cli-reference.md#runs) |
|
| Execution History | Run results / rerun failures | `runs list/get/rerun` | [automation-guide.md § Testing](guides/automation-guide.md) |
|
||||||
| Manual Trigger | Test by firing button-click/webhook | `fire button-click`, `fire webhook` | [automation-guide.md § Testing](guides/automation-guide.md#testing--triggering) |
|
| Manual Trigger | Test button-click/webhook | `fire button-click`, `fire webhook` | [automation-guide.md § Testing](guides/automation-guide.md) |
|
||||||
| SQL | Read-only analytics, JOINs, aggregation | `sql <sql>` | [cli-reference.md § sql](guides/cli-reference.md#sql) |
|
| SQL | Read-only analytics, JOINs | `sql '<sql>'` | [cli-reference.md § sql](guides/cli-reference.md) |
|
||||||
| 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) |
|
| Plugins | Dynamic plugins, dev whitelist, CSP | `plugin *` | [cli-reference.md § plugin](guides/cli-reference.md) |
|
||||||
| Subscription Center | Record subscriptions (digest/instant email) | `subscription list/get/create/update/delete/events/send` | — (see below) |
|
| Subscriptions | Record digest/instant email | `subscription *` | [subscription.md](guides/subscription.md) |
|
||||||
| Dev/Deploy | Build, restart services, known gotchas | (systemd / pnpm build) | [development.md](guides/development.md) |
|
| Dev/Deploy | Build, restart, gotchas | systemd / pnpm | [development.md](guides/development.md) |
|
||||||
|
|
||||||
**Subscription quick reference** (full config via `subscription create/update`):
|
Full command syntax: [cli-reference.md](guides/cli-reference.md). Instance access & credentials: [instance-access.md](guides/instance-access.md).
|
||||||
`--name --table-id --no-watch-created --no-watch-updated --field-ids '<json>' --record-ids '<json>' --display-field-id --attach-csv/--no-attach-csv --filter '<IFilter json>' --trigger-on-enter --send-mode digest|instant --recipients '<a@x.com,b@y.com>' --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).
|
|
||||||
|
|
||||||
### 2.2 Routing Rules
|
### Routing rules
|
||||||
|
|
||||||
> **Before executing**: read the documents marked "Required" in the guide for the module.
|
| User intent | Correct approach | Do NOT |
|
||||||
|
|-------------|------------------|--------|
|
||||||
| User intent | Correct module | Do NOT do this |
|
| 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 |
|
||||||
| 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) |
|
|
||||||
| React to a condition matching | Trigger `recordCondition` (NOT recordMatchesConditions) | Official-teable key names |
|
| 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 |
|
| Button click starts a workflow | `buttonClick` trigger + Button field (`options.workflow` object) | Other trigger types |
|
||||||
| Receive external HTTP | Trigger `webhook`; fire via `fire webhook` | Expecting auth (backend ignores it) |
|
| Receive external HTTP | `webhook`; test via `fire webhook` | Expecting auth (backend ignores it) |
|
||||||
| Schedule recurring job | Trigger `scheduledTime` (only type+interval work) | Passing triggerTime/weekdays (backend ignores) |
|
| Schedule recurring job | `scheduledTime` (only `timing.type` + `interval` honored) | triggerTime/weekdays (ignored) |
|
||||||
| Per-row AI / transformation | Action `script` reading `triggerInput` | Manual row-by-row via record API |
|
| Per-row transformation | `script` action reading `triggerInput` | Row-by-row record API |
|
||||||
| Send notification | Action `sendNotification` (system in-app) | `sendEmail` (needs SMTP) unless email is truly required |
|
| Send notification | `sendNotification` (in-app) | `sendEmail` (needs SMTP) unless email required |
|
||||||
| Add a custom UI / view / panel | Plugins: `plugin install --position base` | Manually editing DB rows |
|
| **Inspect select-choice constraints** | `field list --with-options`, `field get <tbl> <name>`, `field visible-choices` | `--json` + manual filtering |
|
||||||
| Allow a user to upload plugins | Plugins: `plugin dev allow <userIdOrEmail>` (admin only) | — |
|
| Read records for later writes | `record get` (returns record IDs) | `sql` (no record IDs) |
|
||||||
| Plugin needs external API access | Plugins: declare `apiOrigins` in `plugin.json` manifest | Editing CSP in next.config.js (global) |
|
| Analytics / JOIN | `sql` with GROUP BY | Fetch all records + compute |
|
||||||
| Read records for later writes | `record get` (returns record IDs) | `sql` (no record IDs returned) |
|
| Store an API key for scripts | `secret set` → `process.env.NAME` | Hardcoding |
|
||||||
| Analytics / aggregation / JOIN | `sql` with GROUP BY | Fetch all records + compute in code |
|
| Workflow didn't run | `runs list --status failed` → `runs get` | Blind re-activate |
|
||||||
| 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 <PAT>
|
|
||||||
|
|
||||||
# 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).
|
|
||||||
|
|
||||||
## 3. Key Constraints
|
## 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 '<json>'`. The JSON shape is `{conjunction:"and"|"or", filterSet:[{fieldId, operator, value}]}`.
|
1. **Filter key is `conditions`** (NOT `filter`): `trigger set --conditions '<json>'`, shape `{conjunction, filterSet:[{fieldId, operator, value}]}`.
|
||||||
2. **Watch-fields key is `fieldIds`** (NOT `watchFieldIds`). For `recordUpdated`/`buttonClick`. `[]` means watch all fields.
|
2. **Watch-fields key is `fieldIds`** (NOT `watchFieldIds`); `[]` = watch all fields.
|
||||||
3. **Trigger category is `recordCondition`** (NOT `recordMatchesConditions`). It triggers on create+update when the record matches conditions.
|
3. **Trigger category `recordCondition`** (NOT `recordMatchesConditions`).
|
||||||
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.
|
4. **`scheduledTime` honors only `timing.type` + `timing.interval`** — triggerTime/weekdays/daysOfMonth/starting/ending/tz are ignored; `weeks` is hardcoded to Sunday.
|
||||||
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 <baseId> <wfId>`.
|
5. **Webhook route is `/api/webhook/{baseId}/{workflowId}`** (no `/base/` segment); `authorization` config is UI-only, backend never validates. Fire: `fire webhook <baseId> <wfId>`.
|
||||||
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.
|
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**: changes to nodes only take effect after re-activating. `hasDraft: true` means unactivated changes exist.
|
7. **`activate` creates a snapshot** — node changes take effect only after re-activating. `hasDraft: true` = unactivated changes.
|
||||||
8. **`workflow delete` is a SOFT delete** (cascades to base_node, auto-token, trigger unregister). Test workflows should be `deactivate`d, not deleted.
|
8. **`workflow delete` is soft** (cascades to nodes, tokens, trigger unregister); test workflows should be `deactivate`d.
|
||||||
9. **Secrets values are write-only** — `secret list` never returns the value. Scripts read them via `process.env.<NAME>`.
|
9. **Secrets are write-only** — `secret list` never returns values; scripts read `process.env.<NAME>`.
|
||||||
10. **PAT format is `teable_{id}_{encryptedSign}`** — create via `auth token create`, the full string is returned **once**.
|
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`. 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`.
|
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=<nvm-bin>:/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
|
```bash
|
||||||
|
inkreach-teable auth login --endpoint http://localhost:3003 --token <PAT> # once
|
||||||
1. **Confirm context** — identify target base/table/workflow (`--base-id`, IDs in user's message).
|
inkreach-teable table create --name "Tasks" --fields '[{"name":"Title","type":"text"},{"name":"Status","type":"singleSelect","options":{"choices":["Todo","Done"]}}]'
|
||||||
2. **Read before write** — `table get` / `field list` / `workflow get` to confirm current state.
|
inkreach-teable workflow create --name "On Go click" # → wflXXX
|
||||||
3. **Execute changes** — create/update via CLI commands.
|
inkreach-teable trigger set wflXXX --type buttonClick --table-id tblXXX --field-ids '["fldXXX"]'
|
||||||
4. **Verify** — re-read; for automations, `activate` then `runs list` after a test fire.
|
inkreach-teable action add wflXXX --type script --code 'return triggerInput.record.fields'
|
||||||
|
inkreach-teable workflow activate wflXXX
|
||||||
### 4.2 Critical Rules (with reasoning)
|
inkreach-teable fire button-click tblXXX recXXX fldXXX && inkreach-teable runs list wflXXX
|
||||||
|
```
|
||||||
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.
|
|
||||||
|
|
||||||
## 5. Common Errors & Recovery
|
## 5. Common Errors & Recovery
|
||||||
|
|
||||||
| Symptom | Cause | Fix |
|
| Symptom | Cause | Fix |
|
||||||
|---------|-------|-----|
|
|---------|-------|-----|
|
||||||
| `MISSING_BASE_ID` | No base id resolved | Pass `--base-id`, or `auth login --base-id <id>` |
|
| `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 |
|
| `401` / access denied | Token invalid/expired or lacks scope | `auth status`; recreate PAT |
|
||||||
| `403` | Base role lacks permission | User must grant role (owner/creator) in web UI |
|
| `403` | Base role too low | Grant owner/creator role in web UI |
|
||||||
| Workflow never fires | Not activated, or trigger misconfigured | `workflow get` → check `isActive`; verify trigger type + tableId/fieldIds |
|
| Workflow never fires | Not activated / trigger misconfigured | `workflow get` → `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 |
|
| `scheduledTime` wrong time | Backend ignores most timing fields | Only `timing.type`+`interval` work |
|
||||||
| button-click returns 400 "not active" | Workflow deactivated since field created | `workflow activate <id>`, or field options are stale |
|
| button-click 400 "not active" | Workflow deactivated since field created | `workflow activate <id>` |
|
||||||
| `runs list` shows `failed` | Action error | `runs get <wfId> <execId>` → read `nodeResults[].error` |
|
| `runs list` shows failed | Action error | `runs get <wfId> <execId>` → `nodeResults[].error` |
|
||||||
| Secret not readable in script | Wrong env var name | Scripts use `process.env.<NAME>` where NAME = secret name |
|
| 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 |
|
||||||
## 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).
|
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ inkreach-teable action update <wfId> <nodeId> --dependencies '[{"name":"axios"}]
|
|||||||
inkreach-teable action update <wfId> <nodeId> --dependencies '[]' # clear
|
inkreach-teable action update <wfId> <nodeId> --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=<nvm-bin>:/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=<nvm-bin>:/usr/local/sbin:...` to `teable-backend.service`.
|
||||||
|
|
||||||
**Config:**
|
**Config:**
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -43,14 +43,16 @@ inkreach-teable table create --name <n> [--base-id <id>] [--fields '<json>']
|
|||||||
inkreach-teable table update <tableId> --name <n>
|
inkreach-teable table update <tableId> --name <n>
|
||||||
inkreach-teable table delete <tableId>
|
inkreach-teable table delete <tableId>
|
||||||
|
|
||||||
# field (get/update/delete take <tableId> <fieldId> — 2026-08 fix; old /api/field/:id routes never existed)
|
# field (get/update/visible-choices take <tableId> <fieldIdOrName> — id or display name, auto-resolved)
|
||||||
inkreach-teable field list <tableId>
|
inkreach-teable field list <tableId> [--with-options]
|
||||||
inkreach-teable field get <tableId> <fieldId>
|
# --with-options: OPTIONS summary column + per-choice visibility conditions for select fields
|
||||||
|
inkreach-teable field get <tableId> <fieldIdOrName>
|
||||||
|
# human mode renders select choices with their visibilityFilter conditions; --json for raw
|
||||||
inkreach-teable field create --table-id <tid> --name <n> [--type <t|alias>] [--options '<json>'] \
|
inkreach-teable field create --table-id <tid> --name <n> [--type <t|alias>] [--options '<json>'] \
|
||||||
[--description <d>] [--lookup-options '<json>'] [--is-lookup]
|
[--description <d>] [--lookup-options '<json>'] [--is-lookup]
|
||||||
# --type optional: inferred from --options shape / --name when omitted
|
# --type optional: inferred from --options shape / --name when omitted
|
||||||
inkreach-teable field update <tableId> <fieldId> [--name <n>] [--options '<json>'] # --options goes via convert API
|
inkreach-teable field update <tableId> <fieldIdOrName> [--name <n>] [--options '<json>'] # --options goes via convert API
|
||||||
inkreach-teable field visible-choices <tableId> <recordId> <fieldId> # evaluate select-choice visibilityFilter
|
inkreach-teable field visible-choices <tableId> <recordId> <fieldIdOrName> # evaluate select-choice visibilityFilter
|
||||||
inkreach-teable field delete <tableId> <fieldId>
|
inkreach-teable field delete <tableId> <fieldId>
|
||||||
|
|
||||||
# record
|
# record
|
||||||
@@ -67,6 +69,13 @@ inkreach-teable view get <viewId>
|
|||||||
inkreach-teable view create --table-id <tid> --name <n> [--type <t>]
|
inkreach-teable view create --table-id <tid> --name <n> [--type <t>]
|
||||||
inkreach-teable view update <viewId> [--name <n>]
|
inkreach-teable view update <viewId> [--name <n>]
|
||||||
inkreach-teable view delete <viewId>
|
inkreach-teable view delete <viewId>
|
||||||
|
|
||||||
|
# view presets (view-scoped shared named filter/sort bundles, applied via toolbar)
|
||||||
|
inkreach-teable preset list <tableId> [--view-id <vid>]
|
||||||
|
inkreach-teable preset create --table-id <tid> --view-id <vid> --name <n> [--description <d>] \
|
||||||
|
[--filter '<json>'] [--sort '<json>'] [--group '<json>'] [--options '<json>'] [--column-meta '<json>']
|
||||||
|
inkreach-teable preset update <presetId> --table-id <tid> [--name <n>] [--description <d>] [--filter '<json>' ...]
|
||||||
|
inkreach-teable preset delete <presetId> --table-id <tid>
|
||||||
```
|
```
|
||||||
|
|
||||||
## workflow
|
## workflow
|
||||||
@@ -157,6 +166,21 @@ inkreach-teable fire webhook <baseId> <workflowId> [--body '<json>'] [--method <
|
|||||||
|
|
||||||
Manually trigger for testing. Webhook needs no auth (backend is `@Public`).
|
Manually trigger for testing. Webhook needs no auth (backend is `@Public`).
|
||||||
|
|
||||||
|
## subscription
|
||||||
|
|
||||||
|
```bash
|
||||||
|
inkreach-teable subscription list
|
||||||
|
inkreach-teable subscription get <subscriptionId>
|
||||||
|
inkreach-teable subscription create --name <n> --table-id <tid> [options]
|
||||||
|
inkreach-teable subscription update <subscriptionId> [options]
|
||||||
|
inkreach-teable subscription delete <subscriptionId>
|
||||||
|
inkreach-teable subscription events <subscriptionId>
|
||||||
|
inkreach-teable subscription send <subscriptionId>
|
||||||
|
```
|
||||||
|
|
||||||
|
Record digest/instant email subscriptions. Options (`--filter`, `--send-mode`,
|
||||||
|
`--schedule-*`, `--trigger-on-enter`, …) and semantics: [subscription.md](subscription.md).
|
||||||
|
|
||||||
## sql
|
## sql
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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 <subscriptionId>
|
||||||
|
inkreach-teable subscription create --name "Daily escalations" --table-id tblXXX [options]
|
||||||
|
inkreach-teable subscription update <subscriptionId> [options]
|
||||||
|
inkreach-teable subscription delete <subscriptionId>
|
||||||
|
inkreach-teable subscription events <subscriptionId>
|
||||||
|
inkreach-teable subscription send <subscriptionId> # 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 '<json>'` | Restrict watched fields |
|
||||||
|
| `--record-ids '<json>'` | Restrict watched records |
|
||||||
|
| `--display-field-id <fld>` | Field shown in the digest row |
|
||||||
|
| `--attach-csv` / `--no-attach-csv` | Attach matching records as CSV |
|
||||||
|
| `--filter '<IFilter json>'` | 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 '<a@x.com,b@y.com>'` | 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.
|
||||||
@@ -44,20 +44,7 @@ inkreach-teable table create --name "<name>" [--base-id <id>] \
|
|||||||
| `--fields` | no | Array of field objects (see below) |
|
| `--fields` | no | Array of field objects (see below) |
|
||||||
| `--description` | no | Optional description |
|
| `--description` | no | Optional description |
|
||||||
|
|
||||||
**`--fields` format**: JSON array of `{name, type, [options], [description]}` objects. Allowed types and aliases:
|
**`--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).
|
||||||
|
|
||||||
| 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` |
|
|
||||||
|
|
||||||
**Compact shorthand** (in `--fields` only): `"name:type"` string format:
|
**Compact shorthand** (in `--fields` only): `"name:type"` string format:
|
||||||
|
|
||||||
@@ -103,35 +90,10 @@ inkreach-teable field create --table-id <tid> --name "<name>" \
|
|||||||
|------|----------|-------|
|
|------|----------|-------|
|
||||||
| `--table-id` | yes | Target table |
|
| `--table-id` | yes | Target table |
|
||||||
| `--name` | yes | Field display name |
|
| `--name` | yes | Field display name |
|
||||||
| `--type` | yes | Full type name or alias (see [field-types.md](../api-reference/field-types.md)) |
|
| `--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; see type-specific shapes below |
|
| `--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 |
|
| `--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:
|
**Name resolution**: for `link` fields, use `foreignTableName` instead of `foreignTableId` — the CLI resolves it automatically:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -142,7 +104,7 @@ inkreach-teable field create --table-id tblXXX --name "Project" --type link \
|
|||||||
### Update & Delete
|
### Update & Delete
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
inkreach-teable field update <tableId> <fieldId> [--name "<n>"] [--options '<json>']
|
inkreach-teable field update <tableId> <fieldIdOrName> [--name "<n>"] [--options '<json>']
|
||||||
inkreach-teable field delete <tableId> <fieldId>
|
inkreach-teable field delete <tableId> <fieldId>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -153,6 +115,27 @@ inkreach-teable field delete <tableId> <fieldId>
|
|||||||
| `--name` + `--options` | Both applied via one convert call |
|
| `--name` + `--options` | Both applied via one convert call |
|
||||||
| `field delete` | Irreversible — all cell data in that field is lost |
|
| `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: <condition summary>"
|
||||||
|
|
||||||
|
# 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
|
### Select-choice dependency (visibilityFilter) — 2026-08
|
||||||
|
|
||||||
Single/multiple-select choices may carry a `visibilityFilter` making them visible only when another select field matches:
|
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"}]}}]}
|
"filterSet":[{"fieldId":"fldDeviceType","operator":"is","value":"choPrinter"}]}}]}
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Read config**: `inkreach-teable field get <tableId> <fieldId>` → `options.choices[].visibilityFilter`
|
- **Read config**: `field get <tableId> <fieldIdOrName>` → `options.choices[].visibilityFilter` (rendered human-readable in default mode; raw JSON with `--json`)
|
||||||
|
- **Survey a whole table**: `field list <tableId> --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`
|
- **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 <tableId> <recordId> <fieldId>` → choices currently selectable
|
- **Evaluate for a record**: `field visible-choices <tableId> <recordId> <fieldIdOrName>` → 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.`
|
- **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 <presetId> --table-id tblXxx --name "新名字" # or replace --filter/--sort/...
|
||||||
|
inkreach-teable preset delete <presetId> --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
|
## Value Formats Quick Reference
|
||||||
|
|
||||||
| Field Type | Value format | Notes |
|
| Field Type | Value format | Notes |
|
||||||
|
|||||||
Vendored
+230
-29
@@ -1,6 +1,6 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
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.
|
* `inkreach-teable base|table|field|record|view` — resource CRUD commands.
|
||||||
*
|
*
|
||||||
@@ -9,9 +9,36 @@ exports.baseResourceCommands = void 0;
|
|||||||
const commander_1 = require("commander");
|
const commander_1 = require("commander");
|
||||||
const ctx_1 = require("../lib/ctx");
|
const ctx_1 = require("../lib/ctx");
|
||||||
const output_1 = require("../lib/output");
|
const output_1 = require("../lib/output");
|
||||||
|
const client_1 = require("../lib/client");
|
||||||
const field_normalize_1 = require("../lib/field-normalize");
|
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() {
|
function baseResourceCommands() {
|
||||||
return [baseCmd(), tableCmd(), fieldCmd(), recordCmd(), viewCmd()];
|
return [baseCmd(), tableCmd(), fieldCmd(), recordCmd(), viewCmd(), presetCmd()];
|
||||||
}
|
}
|
||||||
exports.baseResourceCommands = baseResourceCommands;
|
exports.baseResourceCommands = baseResourceCommands;
|
||||||
// ===================== base =====================
|
// ===================== base =====================
|
||||||
@@ -162,7 +189,9 @@ function fieldCmd() {
|
|||||||
cmd
|
cmd
|
||||||
.command('list <tableId>')
|
.command('list <tableId>')
|
||||||
.description('List fields in a table')
|
.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 tableId = args[0];
|
||||||
const list = (await ctx.client.listFields(tableId));
|
const list = (await ctx.client.listFields(tableId));
|
||||||
if (ctx.json)
|
if (ctx.json)
|
||||||
@@ -172,28 +201,60 @@ function fieldCmd() {
|
|||||||
process.stdout.write('No fields found.\n');
|
process.stdout.write('No fields found.\n');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
(0, output_1.printTable)([
|
if (opts.withOptions) {
|
||||||
{ key: 'id', header: 'ID' },
|
const nameMap = Object.fromEntries(list.map((f) => [String(f.id), String(f.name)]));
|
||||||
{ key: 'name', header: 'NAME' },
|
(0, output_1.printTable)([
|
||||||
{ key: 'type', header: 'TYPE' },
|
{ key: 'id', header: 'ID' },
|
||||||
], list.map((f) => ({
|
{ key: 'name', header: 'NAME' },
|
||||||
id: (0, output_1.truncate)(f.id),
|
{ key: 'type', header: 'TYPE' },
|
||||||
name: (0, output_1.truncate)(f.name, 30),
|
{ key: 'options', header: 'OPTIONS' },
|
||||||
type: f.type,
|
], 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
|
cmd
|
||||||
.command('get <tableId> <fieldId>')
|
.command('get <tableId> <fieldIdOrName>')
|
||||||
.description('Get field details (full options incl. choice visibilityFilter)')
|
.description('Get field details by id or name (full options; select choices rendered with visibility conditions)')
|
||||||
.action((0, ctx_1.run)(async (ctx, args) => {
|
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||||
const [tableId, fieldId] = args;
|
const [tableId, fieldIdOrName] = args;
|
||||||
const field = await ctx.client.getField(tableId, fieldId);
|
const field = await getFieldFlexible(ctx.client, tableId, fieldIdOrName);
|
||||||
if (ctx.json)
|
if (ctx.json)
|
||||||
(0, output_1.printJson)(field);
|
(0, output_1.printJson)(field);
|
||||||
else {
|
else {
|
||||||
const f = field;
|
const choices = field.options?.choices;
|
||||||
(0, output_1.printKv)(Object.entries(f).map(([k, v]) => [k, v]));
|
const names = Array.isArray(choices)
|
||||||
|
? await fieldNameMap(ctx.client, tableId)
|
||||||
|
: undefined;
|
||||||
|
process.stdout.write((0, field_display_1.formatFieldDetail)(field, names) + '\n');
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
cmd
|
cmd
|
||||||
@@ -242,27 +303,28 @@ function fieldCmd() {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
cmd
|
cmd
|
||||||
.command('update <tableId> <fieldId>')
|
.command('update <tableId> <fieldIdOrName>')
|
||||||
.description('Update a field. --name renames (PATCH); --options rewrites options via the convert API (the update API does not accept options)')
|
.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 <name>', 'New field name')
|
.option('--name <name>', 'New field name')
|
||||||
.option('--options <json>', 'New field options JSON (written via convert API)')
|
.option('--options <json>', 'New field options JSON (written via convert API)')
|
||||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||||
const [tableId, fieldId] = args;
|
const [tableId, fieldIdOrName] = args;
|
||||||
const opts = o;
|
const opts = o;
|
||||||
if (!opts.name && !opts.options) {
|
if (!opts.name && !opts.options) {
|
||||||
throw new Error('Nothing to update: pass --name and/or --options');
|
throw new Error('Nothing to update: pass --name and/or --options');
|
||||||
}
|
}
|
||||||
if (opts.name && !opts.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)
|
if (ctx.json)
|
||||||
(0, output_1.printJson)(field);
|
(0, output_1.printJson)(field);
|
||||||
else
|
else
|
||||||
process.stdout.write(`✓ Field ${fieldId} renamed\n`);
|
process.stdout.write(`✓ Field ${current.id} renamed\n`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Options (choices / visibilityFilter / ...) must go through convert.
|
// Options (choices / visibilityFilter / ...) must go through convert.
|
||||||
// Fetch the current field to reuse its type; merge name if given.
|
// 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 = {
|
const body = {
|
||||||
type: current.type,
|
type: current.type,
|
||||||
options: JSON.parse(opts.options),
|
options: JSON.parse(opts.options),
|
||||||
@@ -270,18 +332,19 @@ function fieldCmd() {
|
|||||||
if (opts.name)
|
if (opts.name)
|
||||||
body.name = opts.name;
|
body.name = opts.name;
|
||||||
(0, field_normalize_1.normalizeFieldRo)(body);
|
(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)
|
if (ctx.json)
|
||||||
(0, output_1.printJson)(field);
|
(0, output_1.printJson)(field);
|
||||||
else
|
else
|
||||||
process.stdout.write(`✓ Field ${fieldId} options updated (via convert)\n`);
|
process.stdout.write(`✓ Field ${current.id} options updated (via convert)\n`);
|
||||||
}));
|
}));
|
||||||
cmd
|
cmd
|
||||||
.command('visible-choices <tableId> <recordId> <fieldId>')
|
.command('visible-choices <tableId> <recordId> <fieldIdOrName>')
|
||||||
.description('Evaluate select-choice visibility for a record (respects choice visibilityFilter dependencies)')
|
.description('Evaluate select-choice visibility for a record (respects choice visibilityFilter dependencies)')
|
||||||
.action((0, ctx_1.run)(async (ctx, args) => {
|
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||||
const [tableId, recordId, fieldId] = args;
|
const [tableId, recordId, fieldIdOrName] = args;
|
||||||
const res = await ctx.client.getVisibleChoices(tableId, recordId, fieldId);
|
const field = await getFieldFlexible(ctx.client, tableId, fieldIdOrName);
|
||||||
|
const res = await ctx.client.getVisibleChoices(tableId, recordId, String(field.id));
|
||||||
if (ctx.json)
|
if (ctx.json)
|
||||||
(0, output_1.printJson)(res);
|
(0, output_1.printJson)(res);
|
||||||
else {
|
else {
|
||||||
@@ -536,3 +599,141 @@ function viewCmd() {
|
|||||||
}));
|
}));
|
||||||
return cmd;
|
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 <tableId>')
|
||||||
|
.description('List view presets of a table (pass --view-id to scope to one view)')
|
||||||
|
.option('--view-id <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 <id>', 'Table id')
|
||||||
|
.requiredOption('--view-id <id>', 'View id the preset belongs to')
|
||||||
|
.requiredOption('--name <name>', 'Preset name')
|
||||||
|
.option('--description <text>', 'Preset description')
|
||||||
|
.option('--filter <json>', 'Filter config, e.g. \'{"conjunction":"and","filterSet":[{"fieldId":"fldX","operator":"is","value":"Pending"}]}\'')
|
||||||
|
.option('--sort <json>', 'Sort config, e.g. \'{"sortObjs":[{"fieldId":"fldX","order":"asc"}]}\'')
|
||||||
|
.option('--group <json>', 'Group config (JSON array of group items)')
|
||||||
|
.option('--options <json>', 'View options (JSON object)')
|
||||||
|
.option('--column-meta <json>', '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 <presetId>')
|
||||||
|
.description('Update a view preset (rename and/or replace config keys)')
|
||||||
|
.requiredOption('--table-id <id>', 'Table id')
|
||||||
|
.option('--name <name>', 'New preset name')
|
||||||
|
.option('--description <text>', 'New preset description')
|
||||||
|
.option('--filter <json>', 'Replace filter config (JSON)')
|
||||||
|
.option('--sort <json>', 'Replace sort config (JSON)')
|
||||||
|
.option('--group <json>', 'Replace group config (JSON)')
|
||||||
|
.option('--options <json>', 'Replace view options (JSON object)')
|
||||||
|
.option('--column-meta <json>', '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 <presetId>')
|
||||||
|
.description('Delete a view preset')
|
||||||
|
.requiredOption('--table-id <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;
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+14
-6
@@ -182,9 +182,7 @@ function subscriptionCommand() {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
const getCmd = cmd.command('get <subscriptionId>').description('Show full subscription config');
|
const getCmd = cmd.command('get <subscriptionId>').description('Show full subscription config');
|
||||||
getCmd
|
getCmd.option('--base-id <id>', 'Base id (overrides default)').action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||||
.option('--base-id <id>', 'Base id (overrides default)')
|
|
||||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
|
||||||
const id = args[0];
|
const id = args[0];
|
||||||
const opts = o;
|
const opts = o;
|
||||||
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||||
@@ -234,7 +232,11 @@ function subscriptionCommand() {
|
|||||||
if (ctx.json)
|
if (ctx.json)
|
||||||
(0, output_1.printJson)(vo);
|
(0, output_1.printJson)(vo);
|
||||||
else
|
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
|
const updateCmd = cmd
|
||||||
.command('update <subscriptionId>')
|
.command('update <subscriptionId>')
|
||||||
@@ -251,7 +253,10 @@ function subscriptionCommand() {
|
|||||||
if (ctx.json)
|
if (ctx.json)
|
||||||
(0, output_1.printJson)(vo);
|
(0, output_1.printJson)(vo);
|
||||||
else
|
else
|
||||||
(0, output_1.printKv)([['id', vo.id], ['updated', '✓']]);
|
(0, output_1.printKv)([
|
||||||
|
['id', vo.id],
|
||||||
|
['updated', '✓'],
|
||||||
|
]);
|
||||||
}));
|
}));
|
||||||
cmd
|
cmd
|
||||||
.command('delete <subscriptionId>')
|
.command('delete <subscriptionId>')
|
||||||
@@ -308,7 +313,10 @@ function subscriptionCommand() {
|
|||||||
if (ctx.json)
|
if (ctx.json)
|
||||||
(0, output_1.printJson)(result ?? { id });
|
(0, output_1.printJson)(result ?? { id });
|
||||||
else
|
else
|
||||||
(0, output_1.printKv)([['id', id], ['sent', '✓']]);
|
(0, output_1.printKv)([
|
||||||
|
['id', id],
|
||||||
|
['sent', '✓'],
|
||||||
|
]);
|
||||||
}));
|
}));
|
||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+15
@@ -154,6 +154,21 @@ class Client {
|
|||||||
deleteView(viewId) {
|
deleteView(viewId) {
|
||||||
return this.call('DELETE', `/api/view/${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) ----
|
// ---- workflow (self-built contract) ----
|
||||||
listWorkflows(baseId) {
|
listWorkflows(baseId) {
|
||||||
return this.call('GET', `/api/base/${baseId}/workflow`);
|
return this.call('GET', `/api/base/${baseId}/workflow`);
|
||||||
|
|||||||
Vendored
+136
@@ -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;
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
+298
-35
@@ -4,8 +4,9 @@
|
|||||||
* These endpoints are compatible with official teable open-api.
|
* These endpoints are compatible with official teable open-api.
|
||||||
*/
|
*/
|
||||||
import { Command } from 'commander';
|
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 { printJson, printTable, printKv, truncate } from '../lib/output';
|
||||||
|
import { Client, ApiError } from '../lib/client';
|
||||||
import {
|
import {
|
||||||
normalizeFieldRo,
|
normalizeFieldRo,
|
||||||
normalizeFieldRoArray,
|
normalizeFieldRoArray,
|
||||||
@@ -13,12 +14,43 @@ import {
|
|||||||
resolveFieldType,
|
resolveFieldType,
|
||||||
inferFieldType,
|
inferFieldType,
|
||||||
findTableByNameOrId,
|
findTableByNameOrId,
|
||||||
|
findFieldByNameOrId,
|
||||||
type ITableLike,
|
type ITableLike,
|
||||||
type IFieldLike,
|
type IFieldLike,
|
||||||
} from '../lib/field-normalize';
|
} 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<Record<string, unknown>> {
|
||||||
|
try {
|
||||||
|
return (await client.getField(tableId, fieldIdOrName)) as Record<string, unknown>;
|
||||||
|
} 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<string, unknown>;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** fieldId → display-name map of a table, to humanize visibilityFilter references. */
|
||||||
|
async function fieldNameMap(client: Client, tableId: string): Promise<Record<string, string>> {
|
||||||
|
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[] {
|
export function baseResourceCommands(): Command[] {
|
||||||
return [baseCmd(), tableCmd(), fieldCmd(), recordCmd(), viewCmd()];
|
return [baseCmd(), tableCmd(), fieldCmd(), recordCmd(), viewCmd(), presetCmd()];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================== base =====================
|
// ===================== base =====================
|
||||||
@@ -141,7 +173,11 @@ function tableCmd(): Command {
|
|||||||
const tables = await ctx.client.listTables(baseId).catch(() => []);
|
const tables = await ctx.client.listTables(baseId).catch(() => []);
|
||||||
for (const f of fields) {
|
for (const f of fields) {
|
||||||
const options = f?.options as Record<string, unknown> | undefined;
|
const options = f?.options as Record<string, unknown> | undefined;
|
||||||
if (options && typeof options.foreignTableName === 'string' && !options.foreignTableId) {
|
if (
|
||||||
|
options &&
|
||||||
|
typeof options.foreignTableName === 'string' &&
|
||||||
|
!options.foreignTableId
|
||||||
|
) {
|
||||||
const t = findTableByNameOrId(
|
const t = findTableByNameOrId(
|
||||||
(tables as unknown as ITableLike[]) || [],
|
(tables as unknown as ITableLike[]) || [],
|
||||||
options.foreignTableName
|
options.foreignTableName
|
||||||
@@ -200,8 +236,13 @@ function fieldCmd(): Command {
|
|||||||
cmd
|
cmd
|
||||||
.command('list <tableId>')
|
.command('list <tableId>')
|
||||||
.description('List fields in a table')
|
.description('List fields in a table')
|
||||||
|
.option(
|
||||||
|
'--with-options',
|
||||||
|
'Add an OPTIONS summary column and expand select choices with their visibility conditions'
|
||||||
|
)
|
||||||
.action(
|
.action(
|
||||||
run(async (ctx, args) => {
|
run(async (ctx, args, o) => {
|
||||||
|
const opts = o as { withOptions?: boolean };
|
||||||
const tableId = args[0] as string;
|
const tableId = args[0] as string;
|
||||||
const list = (await ctx.client.listFields(tableId)) as Array<Record<string, unknown>>;
|
const list = (await ctx.client.listFields(tableId)) as Array<Record<string, unknown>>;
|
||||||
if (ctx.json) printJson(list);
|
if (ctx.json) printJson(list);
|
||||||
@@ -210,33 +251,73 @@ function fieldCmd(): Command {
|
|||||||
process.stdout.write('No fields found.\n');
|
process.stdout.write('No fields found.\n');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
printTable(
|
if (opts.withOptions) {
|
||||||
[
|
const nameMap = Object.fromEntries(list.map((f) => [String(f.id), String(f.name)]));
|
||||||
{ key: 'id', header: 'ID' },
|
printTable(
|
||||||
{ key: 'name', header: 'NAME' },
|
[
|
||||||
{ key: 'type', header: 'TYPE' },
|
{ key: 'id', header: 'ID' },
|
||||||
],
|
{ key: 'name', header: 'NAME' },
|
||||||
list.map((f) => ({
|
{ key: 'type', header: 'TYPE' },
|
||||||
id: truncate(f.id),
|
{ key: 'options', header: 'OPTIONS' },
|
||||||
name: truncate(f.name, 30),
|
],
|
||||||
type: f.type,
|
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<string, unknown> | 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
|
cmd
|
||||||
.command('get <tableId> <fieldId>')
|
.command('get <tableId> <fieldIdOrName>')
|
||||||
.description('Get field details (full options incl. choice visibilityFilter)')
|
.description(
|
||||||
|
'Get field details by id or name (full options; select choices rendered with visibility conditions)'
|
||||||
|
)
|
||||||
.action(
|
.action(
|
||||||
run(async (ctx, args) => {
|
run(async (ctx, args) => {
|
||||||
const [tableId, fieldId] = args as [string, string];
|
const [tableId, fieldIdOrName] = args as [string, string];
|
||||||
const field = await ctx.client.getField(tableId, fieldId);
|
const field = await getFieldFlexible(ctx.client, tableId, fieldIdOrName);
|
||||||
if (ctx.json) printJson(field);
|
if (ctx.json) printJson(field);
|
||||||
else {
|
else {
|
||||||
const f = field as Record<string, unknown>;
|
const choices = (field.options as Record<string, unknown> | undefined)?.choices;
|
||||||
printKv(Object.entries(f).map(([k, v]) => [k, v]));
|
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 <type>',
|
'--type <type>',
|
||||||
'Field type — full value (singleLineText, singleSelect, link…) or alias (text, sel, num…). Omitted: inferred from --options shape / --name'
|
'Field type — full value (singleLineText, singleSelect, link…) or alias (text, sel, num…). Omitted: inferred from --options shape / --name'
|
||||||
)
|
)
|
||||||
.option('--options <json>', 'Field options JSON (type-specific; select choices accept plain strings)')
|
.option(
|
||||||
|
'--options <json>',
|
||||||
|
'Field options JSON (type-specific; select choices accept plain strings)'
|
||||||
|
)
|
||||||
.option('--description <d>', 'Field description')
|
.option('--description <d>', 'Field description')
|
||||||
.option('--lookup-options <json>', 'Lookup options JSON (names like linkFieldName/lookupFieldName are resolved to IDs)')
|
.option(
|
||||||
|
'--lookup-options <json>',
|
||||||
|
'Lookup options JSON (names like linkFieldName/lookupFieldName are resolved to IDs)'
|
||||||
|
)
|
||||||
.option('--is-lookup', 'Mark the field as a lookup field')
|
.option('--is-lookup', 'Mark the field as a lookup field')
|
||||||
.action(
|
.action(
|
||||||
run(async (ctx, _args, o) => {
|
run(async (ctx, _args, o) => {
|
||||||
@@ -295,28 +382,32 @@ function fieldCmd(): Command {
|
|||||||
);
|
);
|
||||||
|
|
||||||
cmd
|
cmd
|
||||||
.command('update <tableId> <fieldId>')
|
.command('update <tableId> <fieldIdOrName>')
|
||||||
.description(
|
.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 <name>', 'New field name')
|
.option('--name <name>', 'New field name')
|
||||||
.option('--options <json>', 'New field options JSON (written via convert API)')
|
.option('--options <json>', 'New field options JSON (written via convert API)')
|
||||||
.action(
|
.action(
|
||||||
run(async (ctx, args, o) => {
|
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 };
|
const opts = o as { name?: string; options?: string };
|
||||||
if (!opts.name && !opts.options) {
|
if (!opts.name && !opts.options) {
|
||||||
throw new Error('Nothing to update: pass --name and/or --options');
|
throw new Error('Nothing to update: pass --name and/or --options');
|
||||||
}
|
}
|
||||||
if (opts.name && !opts.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);
|
if (ctx.json) printJson(field);
|
||||||
else process.stdout.write(`✓ Field ${fieldId} renamed\n`);
|
else process.stdout.write(`✓ Field ${current.id} renamed\n`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Options (choices / visibilityFilter / ...) must go through convert.
|
// Options (choices / visibilityFilter / ...) must go through convert.
|
||||||
// Fetch the current field to reuse its type; merge name if given.
|
// 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;
|
type: string;
|
||||||
};
|
};
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
@@ -325,21 +416,22 @@ function fieldCmd(): Command {
|
|||||||
};
|
};
|
||||||
if (opts.name) body.name = opts.name;
|
if (opts.name) body.name = opts.name;
|
||||||
normalizeFieldRo(body);
|
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);
|
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
|
cmd
|
||||||
.command('visible-choices <tableId> <recordId> <fieldId>')
|
.command('visible-choices <tableId> <recordId> <fieldIdOrName>')
|
||||||
.description(
|
.description(
|
||||||
'Evaluate select-choice visibility for a record (respects choice visibilityFilter dependencies)'
|
'Evaluate select-choice visibility for a record (respects choice visibilityFilter dependencies)'
|
||||||
)
|
)
|
||||||
.action(
|
.action(
|
||||||
run(async (ctx, args) => {
|
run(async (ctx, args) => {
|
||||||
const [tableId, recordId, fieldId] = args as [string, string, string];
|
const [tableId, recordId, fieldIdOrName] = args as [string, string, string];
|
||||||
const res = await ctx.client.getVisibleChoices(tableId, recordId, fieldId);
|
const field = await getFieldFlexible(ctx.client, tableId, fieldIdOrName);
|
||||||
|
const res = await ctx.client.getVisibleChoices(tableId, recordId, String(field.id));
|
||||||
if (ctx.json) printJson(res);
|
if (ctx.json) printJson(res);
|
||||||
else {
|
else {
|
||||||
printTable(
|
printTable(
|
||||||
@@ -634,3 +726,174 @@ function viewCmd(): Command {
|
|||||||
|
|
||||||
return cmd;
|
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<string, unknown> {
|
||||||
|
const config: Record<string, unknown> = {};
|
||||||
|
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 <tableId>')
|
||||||
|
.description('List view presets of a table (pass --view-id to scope to one view)')
|
||||||
|
.option('--view-id <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<string, unknown>
|
||||||
|
>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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 <id>', 'Table id')
|
||||||
|
.requiredOption('--view-id <id>', 'View id the preset belongs to')
|
||||||
|
.requiredOption('--name <name>', 'Preset name')
|
||||||
|
.option('--description <text>', 'Preset description')
|
||||||
|
.option(
|
||||||
|
'--filter <json>',
|
||||||
|
'Filter config, e.g. \'{"conjunction":"and","filterSet":[{"fieldId":"fldX","operator":"is","value":"Pending"}]}\''
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
'--sort <json>',
|
||||||
|
'Sort config, e.g. \'{"sortObjs":[{"fieldId":"fldX","order":"asc"}]}\''
|
||||||
|
)
|
||||||
|
.option('--group <json>', 'Group config (JSON array of group items)')
|
||||||
|
.option('--options <json>', 'View options (JSON object)')
|
||||||
|
.option('--column-meta <json>', 'Column meta (field visibility/order, JSON object)')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, _args, o) => {
|
||||||
|
const opts = o as unknown as IPresetOptions;
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
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 <presetId>')
|
||||||
|
.description('Update a view preset (rename and/or replace config keys)')
|
||||||
|
.requiredOption('--table-id <id>', 'Table id')
|
||||||
|
.option('--name <name>', 'New preset name')
|
||||||
|
.option('--description <text>', 'New preset description')
|
||||||
|
.option('--filter <json>', 'Replace filter config (JSON)')
|
||||||
|
.option('--sort <json>', 'Replace sort config (JSON)')
|
||||||
|
.option('--group <json>', 'Replace group config (JSON)')
|
||||||
|
.option('--options <json>', 'Replace view options (JSON object)')
|
||||||
|
.option('--column-meta <json>', '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<string, unknown> = {};
|
||||||
|
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 <presetId>')
|
||||||
|
.description('Delete a view preset')
|
||||||
|
.requiredOption('--table-id <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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -166,6 +166,25 @@ export class Client {
|
|||||||
return this.call<{ id: string }>('DELETE', `/api/view/${viewId}`);
|
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<unknown[]>(
|
||||||
|
'GET',
|
||||||
|
`/api/table/${tableId}/view-preset${viewId ? `?viewId=${encodeURIComponent(viewId)}` : ''}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
createViewPreset(tableId: string, body: unknown) {
|
||||||
|
return this.call<unknown>('POST', `/api/table/${tableId}/view-preset`, body);
|
||||||
|
}
|
||||||
|
updateViewPreset(tableId: string, presetId: string, body: unknown) {
|
||||||
|
return this.call<unknown>('PUT', `/api/table/${tableId}/view-preset/${presetId}`, body);
|
||||||
|
}
|
||||||
|
deleteViewPreset(tableId: string, presetId: string) {
|
||||||
|
return this.call<void>('DELETE', `/api/table/${tableId}/view-preset/${presetId}`);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- workflow (self-built contract) ----
|
// ---- workflow (self-built contract) ----
|
||||||
listWorkflows(baseId: string) {
|
listWorkflows(baseId: string) {
|
||||||
return this.call<unknown[]>('GET', `/api/base/${baseId}/workflow`);
|
return this.call<unknown[]>('GET', `/api/base/${baseId}/workflow`);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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, unknown>): string {
|
||||||
|
const options = field.options as Record<string, unknown> | 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<string, unknown> | 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, string>): 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<string, unknown>,
|
||||||
|
fieldNames?: Record<string, string>
|
||||||
|
): IChoiceRow[] {
|
||||||
|
const choices = (field.options as Record<string, unknown> | undefined)?.choices;
|
||||||
|
if (!Array.isArray(choices)) return [];
|
||||||
|
return choices
|
||||||
|
.filter((c): c is Record<string, unknown> => !!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<string, unknown>,
|
||||||
|
fieldNames?: Record<string, string>
|
||||||
|
): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const options = field.options as Record<string, unknown> | 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');
|
||||||
|
}
|
||||||
+1
-1
@@ -15,5 +15,5 @@
|
|||||||
"strict": true
|
"strict": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist", "**/*.spec.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user