rootr-cli 0.30.1 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +20 -0
- package/README.md +13 -0
- package/bin/rootr.js +80 -7
- package/lib/client.js +13 -6
- package/lib/mcp-tools/databases.js +141 -9
- package/lib/mcp-tools/forms.js +49 -7
- package/lib/mcp-tools/workspaces.js +5 -1
- package/package.json +1 -1
package/README.ko.md
CHANGED
|
@@ -555,6 +555,7 @@ rootr memory forget <id> # 폐기(--hard면 행 삭제)
|
|
|
555
555
|
rootr db buttons <표> # 지금 달린 버튼 보기
|
|
556
556
|
rootr db buttons <표> --file buttons.json # 목록 통째로 저장(남길 것도 같이)
|
|
557
557
|
rootr db button-run <표> <버튼id> # 한 번 누르기 (--rows id,id 로 고른 행 지정)
|
|
558
|
+
rootr db button-run <표> <버튼id> --input-file 원문.txt # 글 받는 버튼에 글 넣어 누르기(--input "글" · --input - 도 됨)
|
|
558
559
|
rootr db automations <표> --file rules.json # 자동 규칙 — 사람이 안 눌러도 도는 것
|
|
559
560
|
rootr db view <표> <뷰> --scale year # 달력 보기를 주·달·해 중에서
|
|
560
561
|
rootr db view <표> <뷰> --density compact --freeze 1 # 촘촘한 줄 · 왼쪽 첫 칸 고정
|
|
@@ -584,6 +585,20 @@ rootr db column <표> 특이사항 --wrap on --align center # 긴 글 줄바꿈
|
|
|
584
585
|
] }]
|
|
585
586
|
```
|
|
586
587
|
|
|
588
|
+
**누르면 글부터 받는 버튼.** 버튼에 `input` 을 달면 누를 때 글 상자가 뜨고, 적은 글이 단계 값·AI 지시문의
|
|
589
|
+
`{@input}` 자리에 들어갑니다. "행을 만들고 → 원문 칸에 붙여 넣고 → 버튼을 눌러 AI 로 나머지 칸 채우기"가
|
|
590
|
+
**버튼 → 붙여 넣기 → 확인** 한 번이 됩니다(고른 행이 없어도 됩니다). 여러 건을 한 번에 붙여 넣으면 `split` 으로
|
|
591
|
+
건마다 한 번씩 돌립니다(`blank_line` 빈 줄 · `separator` 구분 글자 · `ai` AI 가 건의 시작 줄을 찾음, 한 번에 50건까지).
|
|
592
|
+
|
|
593
|
+
```json
|
|
594
|
+
[{ "label": "원문으로 행 만들기", "icon": "📋",
|
|
595
|
+
"input": { "label": "붙여 넣을 원문", "multiline": true, "split": { "by": "separator", "separator": "---" } },
|
|
596
|
+
"steps": [
|
|
597
|
+
{ "kind": "add_row", "values": { "원문": "{@input}", "상태": "접수" } },
|
|
598
|
+
{ "kind": "ai_fill", "target": "created", "propId": "제목", "prompt": "{원문}에서 제목만 한 줄로 뽑아." }
|
|
599
|
+
] }]
|
|
600
|
+
```
|
|
601
|
+
|
|
587
602
|
**사람이 기억해서 눌러야 하는 일**은 버튼이 아니라 **자동 규칙**으로 겁니다 — 버튼은 안 누른
|
|
588
603
|
날 그 일이 그냥 빠집니다. 단계 문법은 버튼과 똑같고, 조건은 거의 언제나 `changed_to` 입니다
|
|
589
604
|
(`is` 는 그 뒤로 그 행을 고칠 때마다 계속 참이라 행이 몇 벌씩 생깁니다).
|
|
@@ -648,6 +663,11 @@ rootr db column <표> 특이사항 --wrap on --align center # 긴 글 줄바꿈
|
|
|
648
663
|
(이름이 하나라도 어긋나면 그 칸은 영영 빈 칸으로 남습니다). 스캐폴드 트리에서는 폼 노드에
|
|
649
664
|
`form.collectInto: "database" | "spreadsheet"`로 같은 일을 합니다.
|
|
650
665
|
|
|
666
|
+
**다른 표에서 고르기**(`"type": "relation"`) 질문은 다른 표의 행을 찾아 고릅니다(`"relationDatabaseId"` 또는 응답 표의 관계 칸이
|
|
667
|
+
가리키는 표, 여러 개면 `"multiple": true`). 행 id 는 `rootr form pick <폼> <질문> [검색어]`로 찾아 답에 넣습니다. 관계 칸에는
|
|
668
|
+
이 질문만 들어가고, 글자 질문이 걸려 있으면 `form mapping` 이 "넣을 수 없음"으로 알립니다. 바깥 사람이 고르게 하려면 질문에
|
|
669
|
+
`"publicPick": { "enabled": true }` 를 켭니다.
|
|
670
|
+
|
|
651
671
|
폼 응답은 `rootr form responses <폼> [--latest]`로 보고, `rootr form response-edit <응답id>
|
|
652
672
|
--values '{"필드id":"값"}'`로 고치고, `rootr form response-rm <응답id> --yes`로 지웁니다.
|
|
653
673
|
`--latest`는 같은 사람이 여러 번 낸 것 중 마지막 한 건만 남깁니다(익명 제출은 합치지 않습니다).
|
package/README.md
CHANGED
|
@@ -211,18 +211,27 @@ rootr db restore /hr/applicants --at 2026-10-15T09:00:00+09:00 --yes # rows + c
|
|
|
211
211
|
rootr db view /projects/tasks Calendar --scale month # views: table, board, calendar, timeline
|
|
212
212
|
rootr db view /hr/applicants Board --cards "Interview date,Phone" # fields shown on board cards
|
|
213
213
|
rootr db view /hr/applicants Table --density compact --freeze 1 # compact rows, keep the first column fixed
|
|
214
|
+
rootr db view /ops/requests Table --entry-row on --day-pager "Received" # Excel-like empty bottom row · one day per page (◀ ▶ / Today)
|
|
215
|
+
rootr db view /ops/requests Table --quick "Region,Owner" --row-hidden on # columns pinned atop the filter panel · hidden columns still shown in the row page
|
|
214
216
|
rootr db column /hr/applicants "Applied on" --overdue off --time off --range off # plain single date, no red
|
|
215
217
|
rootr db column /hr/applicants Notes --wrap on --align center # wrap long text, center it
|
|
216
218
|
rootr db lock /projects/tasks --schema managers --rows own --locked "Approved,Score"
|
|
217
219
|
rootr db notify /projects/tasks --file rules.json # notification rules
|
|
218
220
|
rootr db buttons /projects/tasks --file buttons.json # buttons that do work when pressed
|
|
219
221
|
rootr db button-run /projects/tasks fill-summary --rows r1,r2
|
|
222
|
+
rootr db button-run /projects/tasks from-text --input-file raw.txt # a button with `input` (also --input "text" / --input -)
|
|
220
223
|
rootr db automations /projects/tasks --file rules.json # "when a value becomes X, do Y" (also: at a set time)
|
|
221
224
|
rootr db rows-new /projects/tasks --file rows.json --upsert-key 외부키 # idempotent load: retries cannot duplicate
|
|
222
225
|
rootr db import-sheets sites.xlsx --ws <id> --dry # fold a one-sheet-per-record workbook into ONE table
|
|
223
226
|
rootr db row-page <rowId> --type RECORDING # give one row a typed detail page
|
|
224
227
|
```
|
|
225
228
|
|
|
229
|
+
A button can **ask for text first**: give it `input: { label, multiline?, required?, split? }` and pressing it opens a
|
|
230
|
+
text box; the text goes wherever a step says `{@input}` (e.g. `add_row` values `{ "Original": "{@input}" }`, then an
|
|
231
|
+
`ai_fill` with `target: "created"` parses it into the other columns — no row has to be selected). `split`
|
|
232
|
+
(`{ by: "blank_line" }`, `{ by: "separator", separator: "---" }` or `{ by: "ai" }`) runs the steps once per pasted
|
|
233
|
+
item, up to 50.
|
|
234
|
+
|
|
226
235
|
`db automations` takes the same step syntax as `db buttons`, so there is nothing new to
|
|
227
236
|
learn — the difference is that nobody has to press it. A rule is
|
|
228
237
|
`{ label, on: ["updated"], when: { property, op: "changed_to", value }, steps: [...] }`;
|
|
@@ -263,6 +272,10 @@ rootr form submit /intake/request --values '{"f_email":"a@b.com"}' \
|
|
|
263
272
|
A `file` question takes one file unless it has `"multiple": true` (add `"maxFiles": 5`
|
|
264
273
|
to cap it, default 10); then the answer is an array. `"allowImages": true` on a
|
|
265
274
|
`text`/`textarea` question lets respondents put images inside their answer.
|
|
275
|
+
A `relation` question picks rows from another table (`"relationDatabaseId"`, or the table the response table's
|
|
276
|
+
relation column points to; `"multiple": true` for several). Find ids with `rootr form pick <form> <question> [search]`
|
|
277
|
+
and answer with the row id. A relation column only takes a `relation` question; `form mapping` marks a text question
|
|
278
|
+
there as "can't go in". Outsiders can pick only if the question has `"publicPick": { "enabled": true }`.
|
|
266
279
|
|
|
267
280
|
Run `form mapping` right after pointing a form at a hand-built table. Answers reach the
|
|
268
281
|
table by matching the question name to the column name, so a question the table has no
|
package/bin/rootr.js
CHANGED
|
@@ -40,10 +40,11 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
|
|
|
40
40
|
rootr db lock <path|id> [--schema managers] [--rows own] [--locked "승인,점수"] 표 자물쇠 보기·걸기
|
|
41
41
|
rootr db notify <path|id> [--file rules.json] [--clear] 표 알림 규칙 보기·저장
|
|
42
42
|
rootr db buttons <path|id> [--file buttons.json] [--clear] 표 위 "누르면 일하는 버튼" 보기·저장
|
|
43
|
-
rootr db button-run <path|id> <buttonId> [--rows id,id]
|
|
43
|
+
rootr db button-run <path|id> <buttonId> [--rows id,id] [--input 글|--input-file f|--input -] 버튼 한 번 누르기 (행 만들기·값 채우기·AI 채우기 · 글 받는 버튼이면 --input)
|
|
44
44
|
rootr db automations <path|id> [--file rules.json] [--clear] 자동 규칙 — "값이 이렇게 되면 이 일을 해라"·정해진 시각
|
|
45
45
|
rootr db import-sheets <파일.xlsx> --ws <id> [--dry] 시트 수백 장짜리 엑셀을 표 하나로 접어 가져오기
|
|
46
46
|
rootr db view <path|id> [뷰이름] [--scale week|month|year] [--name 새이름] [--cards "칸,칸"] 뷰 목록·달력 주/달/해 단위·보드 카드에 보일 칸 ·[--density compact|normal] [--freeze 0~3] 촘촘하게·왼쪽 칸 고정
|
|
47
|
+
rootr db view <path|id> <뷰이름> [--row-hidden on|off] [--quick "칸,칸"] [--entry-row on|off|top|bottom] [--personal-filters on|off] [--day-pager 날짜칸|off] 행 열면 감춘 칸 보이기·필터 맨 위 고정 칸·맨 아래 입력 줄·하루씩 보기
|
|
47
48
|
rootr db column <path|id> <칸> [--align left|center|right] [--wrap on|off] [--overdue on|off] [--time on|off] [--range on|off] 칸 정렬·줄바꿈·지난 날짜 빨강·시간·기간
|
|
48
49
|
rootr db row-add <path|id> --values '<json>' [--body <md>|--body-file <f>] 행 추가(상세 내용 포함)
|
|
49
50
|
rootr db row-body <path|id> <rowId> [--file <f>] 행의 상세 내용(본문 메모) 쓰기 — 없으면 표준입력
|
|
@@ -96,6 +97,7 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
|
|
|
96
97
|
rootr form submit <path|id> [--values '<json>'] [--files '{"질문id":["a.pdf","b.pdf"]}'] 응답 내기 (파일은 올린 뒤 답으로 붙임 — 여러 개 받는 질문은 배열로)
|
|
97
98
|
rootr form response-edit <응답id> --values '<json>' 이미 낸 응답 고치기 (폼 검사 규칙 다시 통과)
|
|
98
99
|
rootr form response-rm <응답id> [--yes] 응답 한 건 삭제 (되돌릴 수 없음)
|
|
100
|
+
rootr form pick <path|id> <질문 이름|id> [검색어] [--json] "다른 표에서 고르기" 질문에서 고를 행 찾기(답에는 행 id 를 넣는다)
|
|
99
101
|
rootr sign new <경로> [--file <f>] [--reviewer <userId>] [--line <line.json>] 서명문서 만들기(본문·결재라인까지)
|
|
100
102
|
rootr sign show <path|id> [--json] 결재라인 진행·서명 목록 보기
|
|
101
103
|
rootr sign line <path|id> --file <line.json> 결재라인 다시 짜기 (올리기 전에만)
|
|
@@ -181,10 +183,11 @@ usage:
|
|
|
181
183
|
rootr db lock <path|id> [--schema managers] [--rows own] [--locked "Approved,Score"] view/set table locks
|
|
182
184
|
rootr db notify <path|id> [--file rules.json] [--clear] view/save notification rules
|
|
183
185
|
rootr db buttons <path|id> [--file buttons.json] [--clear] view/save buttons that do work when pressed
|
|
184
|
-
rootr db button-run <path|id> <buttonId> [--rows id,id]
|
|
186
|
+
rootr db button-run <path|id> <buttonId> [--rows id,id] [--input text|--input-file f|--input -] press a button once (create rows, fill values, AI fill · --input for a button that asks for text)
|
|
185
187
|
rootr db automations <path|id> [--file rules.json] [--clear] automatic rules — "when a value becomes X, do Y", or at a set time
|
|
186
188
|
rootr db import-sheets <file.xlsx> --ws <id> [--dry] fold a one-sheet-per-record workbook into ONE table
|
|
187
189
|
rootr db view <path|id> [view name] [--scale week|month|year] [--name <new name>] list views; calendar week/month/year [--density compact|normal] [--freeze 0-3] compact rows · frozen left columns
|
|
190
|
+
rootr db view <path|id> <view name> [--row-hidden on|off] [--quick "field,field"] [--entry-row on|off|top|bottom] [--personal-filters on|off] [--day-pager <date field>|off] hidden fields shown in the row page · pinned filters · entry row · one day at a time
|
|
188
191
|
rootr db column <path|id> <column> [--align left|center|right] [--wrap on|off] [--overdue on|off] [--time on|off] [--range on|off] column alignment · wrapping · overdue red · time · end date
|
|
189
192
|
rootr db row-add <path|id> --values '<json>' [--body <md>|--body-file <f>] add a row (with its body)
|
|
190
193
|
rootr db row-body <path|id> <rowId> [--file <f>] write a row's body — reads stdin if no file
|
|
@@ -238,6 +241,7 @@ usage:
|
|
|
238
241
|
rootr form submit <path|id> [--values '<json>'] [--files '{"fieldId":["a.pdf","b.pdf"]}'] submit a response (files are uploaded, then attached — several for a "multiple" file question)
|
|
239
242
|
rootr form response-edit <responseId> --values '<json>' edit a submitted response (revalidated against the form)
|
|
240
243
|
rootr form response-rm <responseId> [--yes] delete one response (cannot be undone)
|
|
244
|
+
rootr form pick <path|id> <question name|id> [search] [--json] find rows for a "pick from another table" question (answer with the row id)
|
|
241
245
|
rootr sign new <path> [--file <f>] [--reviewer <userId>] [--line <line.json>] create a signed document (body + approval line)
|
|
242
246
|
rootr sign show <path|id> [--json] approval-line progress and signatures
|
|
243
247
|
rootr sign line <path|id> --file <line.json> rebuild the approval line (before submitting only)
|
|
@@ -1109,11 +1113,39 @@ async function cmdForm(args) {
|
|
|
1109
1113
|
if (sub === 'submit') return cmdFormSubmit(args);
|
|
1110
1114
|
if (sub === 'response-edit') return cmdFormResponseEdit(args);
|
|
1111
1115
|
if (sub === 'response-rm') return cmdFormResponseRm(args);
|
|
1116
|
+
if (sub === 'pick') return cmdFormPick(args);
|
|
1112
1117
|
throw new Error(
|
|
1113
|
-
usage(tr(`rootr form new|show|set|collect|responses|submit|response-edit|response-rm … (자세한 건 rootr --help)`, `rootr form new|show|set|collect|responses|submit|response-edit|response-rm … (see rootr --help)`)),
|
|
1118
|
+
usage(tr(`rootr form new|show|set|collect|responses|submit|response-edit|response-rm|pick … (자세한 건 rootr --help)`, `rootr form new|show|set|collect|responses|submit|response-edit|response-rm|pick … (see rootr --help)`)),
|
|
1114
1119
|
);
|
|
1115
1120
|
}
|
|
1116
1121
|
|
|
1122
|
+
/**
|
|
1123
|
+
* FORM-REL-PICK — `rootr form pick <폼> <질문> [검색어]`: "다른 표에서 고르기" 질문에서 고를 행 찾기. 나온 id 를
|
|
1124
|
+
* `rootr form submit --values '{"<질문id>":"<행id>"}'` 로 낸다(여러 개 받는 질문이면 배열).
|
|
1125
|
+
*/
|
|
1126
|
+
async function cmdFormPick(args) {
|
|
1127
|
+
const [, target, question, ...words] = args._;
|
|
1128
|
+
if (!target || !question) throw new Error(usage(tr(`rootr form pick <path|id> <질문 이름|id> [검색어] [--json]`, `rootr form pick <path|id> <question name|id> [search] [--json]`)));
|
|
1129
|
+
const client = makeClient();
|
|
1130
|
+
const formId = await resolveNodeId(client, target);
|
|
1131
|
+
const form = await client.getForm(formId);
|
|
1132
|
+
const field = (form.fields ?? []).find((f) => f.id === question) ?? (form.fields ?? []).find((f) => f.name === question);
|
|
1133
|
+
if (!field || field.type !== 'relation') {
|
|
1134
|
+
throw new Error(tr(`"${question}"은(는) 이 폼의 "다른 표에서 고르기" 질문이 아닙니다.`, `"${question}" is not a "pick from another table" question on this form.`));
|
|
1135
|
+
}
|
|
1136
|
+
const res = await client.formRelationOptions(formId, field.id, words.join(' '));
|
|
1137
|
+
if (args.json) {
|
|
1138
|
+
console.log(JSON.stringify(res, null, 2));
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
if (!res.rows?.length) {
|
|
1142
|
+
console.log(tr('맞는 행이 없습니다.', 'No matching rows.'));
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
for (const r of res.rows) console.log(`${r.id} ${r.title}${r.subtitle ? ` · ${r.subtitle}` : ''}`);
|
|
1146
|
+
if (res.hasMore) console.log(tr('(더 있습니다 — 검색어를 더 적어 주세요)', '(more — narrow the search)'));
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1117
1149
|
/**
|
|
1118
1150
|
* FORM-TALLY — `rootr form new <경로> --fields '<json>'`
|
|
1119
1151
|
*
|
|
@@ -2960,8 +2992,30 @@ async function cmdDbView(args) {
|
|
|
2960
2992
|
if (!Number.isInteger(n) || n < 0 || n > 3) throw new Error(tr('--freeze 는 0~3 (왼쪽에 붙여 둘 칸 수)', `--freeze must be 0-3 (columns kept on the left)`));
|
|
2961
2993
|
patch.frozenColumns = n;
|
|
2962
2994
|
}
|
|
2995
|
+
// VIEW-ROW-SHOW-HIDDEN · VIEW-ENTRY-ROW — on|off. VIEW-QUICK-FILTER — 필터 창 맨 위에 고정할 칸(쉼표로, "" 이면 끔).
|
|
2996
|
+
// VIEW-DAY-PAGER — 하루씩 볼 날짜 칸(이름 또는 id), off 이면 끔.
|
|
2997
|
+
const onOff = (flag, raw) => {
|
|
2998
|
+
const v = String(raw).toLowerCase();
|
|
2999
|
+
if (['on', 'true', '1'].includes(v)) return true;
|
|
3000
|
+
if (['off', 'false', '0'].includes(v)) return null;
|
|
3001
|
+
throw new Error(tr(`--${flag} 는 on 또는 off`, `--${flag} must be on or off`));
|
|
3002
|
+
};
|
|
3003
|
+
if (args['row-hidden'] !== undefined) patch.rowPaneShowHidden = onOff('row-hidden', args['row-hidden']);
|
|
3004
|
+
if (args['personal-filters'] !== undefined) patch.filtersPersonalOnly = onOff('personal-filters', args['personal-filters']);
|
|
3005
|
+
if (args['entry-row'] !== undefined) {
|
|
3006
|
+
const v = String(args['entry-row']).toLowerCase();
|
|
3007
|
+
patch.entryRow = v === 'top' || v === 'bottom' ? v : onOff('entry-row', args['entry-row']);
|
|
3008
|
+
}
|
|
3009
|
+
if (typeof args.quick === 'string') {
|
|
3010
|
+
const quick = args.quick.split(',').map((c) => c.trim()).filter(Boolean);
|
|
3011
|
+
patch.quickFilters = quick.length ? quick : null;
|
|
3012
|
+
}
|
|
3013
|
+
if (args['day-pager'] !== undefined) {
|
|
3014
|
+
const v = String(args['day-pager']).trim();
|
|
3015
|
+
patch.dayPager = !v || v === 'off' || v === 'false' ? null : { dateProp: v };
|
|
3016
|
+
}
|
|
2963
3017
|
if (!Object.keys(patch).length) {
|
|
2964
|
-
throw new Error(tr('바꿀 것을 주세요: --scale week|month|year, --name 새이름, --cards "칸,칸", --density compact|normal
|
|
3018
|
+
throw new Error(tr('바꿀 것을 주세요: --scale week|month|year, --name 새이름, --cards "칸,칸", --density compact|normal, --freeze 0~3, --row-hidden on|off, --quick "칸,칸", --entry-row on|off|top|bottom, --personal-filters on|off 또는 --day-pager 날짜칸|off', `Give something to change: --scale week|month|year, --name <new name>, --cards "field,field", --density compact|normal, --freeze 0-3, --row-hidden on|off, --quick "field,field", --entry-row on|off|top|bottom, --personal-filters on|off, or --day-pager <date field>|off`));
|
|
2965
3019
|
}
|
|
2966
3020
|
|
|
2967
3021
|
const view = await client.updateDatabaseView(id, viewRef, patch);
|
|
@@ -3094,6 +3148,11 @@ async function cmdDbButtons(args) {
|
|
|
3094
3148
|
console.log(`${b.icon ? b.icon + ' ' : ''}${b.label} (${b.id})`);
|
|
3095
3149
|
for (const [i, st] of (b.steps ?? []).entries()) {
|
|
3096
3150
|
const what = WHAT[st.kind] ?? st.kind;
|
|
3151
|
+
if (i === 0 && b.input) {
|
|
3152
|
+
// BTN-INPUT — 누르면 글부터 받는 버튼. button-run 에 --input 이 필요하다는 것을 여기서 보인다.
|
|
3153
|
+
const split = b.input.split ? ` · ${tr('나누기', 'split')}: ${b.input.split.by}${b.input.split.separator ? ` "${b.input.split.separator}"` : ''}` : '';
|
|
3154
|
+
console.log(` ${tr('글 받기', 'asks for text')}: "${b.input.label}"${split} ${tr('(button-run --input)', '(button-run --input)')}`);
|
|
3155
|
+
}
|
|
3097
3156
|
const where = st.target ? ` · ${WHERE[st.target] ?? st.target}` : '';
|
|
3098
3157
|
const detail =
|
|
3099
3158
|
st.kind === 'ai_fill'
|
|
@@ -3119,7 +3178,7 @@ async function cmdDbButtonRun(args) {
|
|
|
3119
3178
|
const target = args._[1];
|
|
3120
3179
|
const buttonId = args._[2];
|
|
3121
3180
|
if (!target || !buttonId) {
|
|
3122
|
-
throw new Error(usage(`rootr db button-run <path|id> <buttonId> [--rows id1,id2] [--json]`));
|
|
3181
|
+
throw new Error(usage(`rootr db button-run <path|id> <buttonId> [--rows id1,id2] [--input <글> | --input-file <파일> | --input -] [--json]`));
|
|
3123
3182
|
}
|
|
3124
3183
|
const client = makeClient();
|
|
3125
3184
|
const id = await resolveDatabaseId(client, target);
|
|
@@ -3127,8 +3186,17 @@ async function cmdDbButtonRun(args) {
|
|
|
3127
3186
|
typeof args.rows === 'string'
|
|
3128
3187
|
? args.rows.split(',').map((x) => x.trim()).filter(Boolean)
|
|
3129
3188
|
: undefined;
|
|
3130
|
-
|
|
3131
|
-
const
|
|
3189
|
+
// BTN-INPUT — 글을 받는 버튼에 넣을 글. 여러 줄 원문은 파일이나 표준입력(`--input -`)으로.
|
|
3190
|
+
const input =
|
|
3191
|
+
typeof args['input-file'] === 'string'
|
|
3192
|
+
? readFileSync(args['input-file'], 'utf8')
|
|
3193
|
+
: args.input === '-'
|
|
3194
|
+
? await readStdin()
|
|
3195
|
+
: typeof args.input === 'string'
|
|
3196
|
+
? args.input
|
|
3197
|
+
: undefined;
|
|
3198
|
+
|
|
3199
|
+
const r = await client.runDatabaseButton(id, buttonId, { rowIds, input });
|
|
3132
3200
|
if (args.json) {
|
|
3133
3201
|
console.log(JSON.stringify(r, null, 2));
|
|
3134
3202
|
return;
|
|
@@ -3154,6 +3222,11 @@ async function cmdDbButtonRun(args) {
|
|
|
3154
3222
|
);
|
|
3155
3223
|
for (const n of r.notes ?? []) console.log(`· ${n}`);
|
|
3156
3224
|
if (r.linkWarning) console.log(`· ${r.linkWarning}`);
|
|
3225
|
+
// BTN-INPUT — 여러 건으로 나눠 돌렸으면 몇 건이 됐는지, 막힌 건은 몇 번째가 왜.
|
|
3226
|
+
if (r.records) {
|
|
3227
|
+
console.log(tr(`${r.records.total}건 중 ${r.records.done}건 실행`, `${r.records.done} of ${r.records.total} items ran`));
|
|
3228
|
+
for (const e of r.records.errors ?? []) console.log(tr(`· ${e.index}번째 건 실패 — ${e.message}`, `· item ${e.index} failed — ${e.message}`));
|
|
3229
|
+
}
|
|
3157
3230
|
}
|
|
3158
3231
|
|
|
3159
3232
|
|
package/lib/client.js
CHANGED
|
@@ -643,10 +643,11 @@ export class RootrClient {
|
|
|
643
643
|
}
|
|
644
644
|
|
|
645
645
|
/** 버튼 한 번 누르기. `rowIds` 는 "고른 행"을 대상으로 하는 단계가 있을 때만 쓴다. */
|
|
646
|
-
async runDatabaseButton(databaseId, buttonId, { rowIds } = {}) {
|
|
646
|
+
async runDatabaseButton(databaseId, buttonId, { rowIds, input } = {}) {
|
|
647
|
+
// BTN-INPUT — 글을 받는 버튼이면 그 글(`{@input}` 자리). 안 받는 버튼에는 싣지 않는다(서버가 400 으로 말한다).
|
|
647
648
|
return this.request(
|
|
648
649
|
`/databases/${encodeURIComponent(databaseId)}/buttons/${encodeURIComponent(buttonId)}/run`,
|
|
649
|
-
{ method: 'POST', body: { rowIds } },
|
|
650
|
+
{ method: 'POST', body: { rowIds, ...(input !== undefined ? { input } : {}) } },
|
|
650
651
|
);
|
|
651
652
|
}
|
|
652
653
|
|
|
@@ -1132,11 +1133,11 @@ export class RootrClient {
|
|
|
1132
1133
|
*/
|
|
1133
1134
|
async createDatabaseView(
|
|
1134
1135
|
databaseId,
|
|
1135
|
-
{ name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale, density, frozenColumns } = {},
|
|
1136
|
+
{ name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale, density, frozenColumns, rowPaneShowHidden, quickFilters, entryRow, filtersPersonalOnly, dayPager } = {},
|
|
1136
1137
|
) {
|
|
1137
1138
|
return this.request(`/databases/${encodeURIComponent(databaseId)}/views`, {
|
|
1138
1139
|
method: 'POST',
|
|
1139
|
-
body: { name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale, density, frozenColumns },
|
|
1140
|
+
body: { name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale, density, frozenColumns, rowPaneShowHidden, quickFilters, entryRow, filtersPersonalOnly, dayPager },
|
|
1140
1141
|
});
|
|
1141
1142
|
}
|
|
1142
1143
|
|
|
@@ -1318,14 +1319,14 @@ export class RootrClient {
|
|
|
1318
1319
|
async updateColumn(
|
|
1319
1320
|
databaseId,
|
|
1320
1321
|
propertyId,
|
|
1321
|
-
{ name, options, optionRenames, formula, rollup, prefix, reverseName, searchExcluded, dedupe, dedupeShow, relationFilter, align, wrap, highlightOverdue, allowTime, allowRange, dedupeCarry, validate, identifyProps } = {},
|
|
1322
|
+
{ name, options, optionRenames, formula, rollup, prefix, reverseName, searchExcluded, dedupe, dedupeShow, relationFilter, align, wrap, highlightOverdue, allowTime, allowRange, dedupeCarry, keySync, validate, identifyProps } = {},
|
|
1322
1323
|
) {
|
|
1323
1324
|
return this.request(
|
|
1324
1325
|
`/databases/${encodeURIComponent(databaseId)}/columns/${encodeURIComponent(propertyId)}`,
|
|
1325
1326
|
{
|
|
1326
1327
|
method: 'PATCH',
|
|
1327
1328
|
// identifyProps — 제목 칸만: 다른 표에서 이 표의 행을 고를 때 제목 옆에 붙일 칸(REL-PICK-IDENTIFY).
|
|
1328
|
-
body: { name, options, optionRenames, formula, rollup, prefix, reverseName, searchExcluded, dedupe, dedupeShow, relationFilter, align, wrap, highlightOverdue, allowTime, allowRange, dedupeCarry, validate, identifyProps },
|
|
1329
|
+
body: { name, options, optionRenames, formula, rollup, prefix, reverseName, searchExcluded, dedupe, dedupeShow, relationFilter, align, wrap, highlightOverdue, allowTime, allowRange, dedupeCarry, keySync, validate, identifyProps },
|
|
1329
1330
|
},
|
|
1330
1331
|
);
|
|
1331
1332
|
}
|
|
@@ -1995,6 +1996,12 @@ export class RootrClient {
|
|
|
1995
1996
|
return this.request(`/forms/${encodeURIComponent(formId)}`);
|
|
1996
1997
|
}
|
|
1997
1998
|
|
|
1999
|
+
/** FORM-REL-PICK — "다른 표에서 고르기" 질문의 고를 행 목록(그 표를 읽을 수 있으면 표 전체에서). */
|
|
2000
|
+
async formRelationOptions(formId, fieldId, q = '') {
|
|
2001
|
+
const qs = new URLSearchParams({ field: fieldId, q });
|
|
2002
|
+
return this.request(`/forms/${encodeURIComponent(formId)}/relation-options?${qs}`);
|
|
2003
|
+
}
|
|
2004
|
+
|
|
1998
2005
|
/** Merge-patch fields/targetDatabaseId/config/icon. */
|
|
1999
2006
|
async updateForm(formId, { fields, targetDatabaseId, config, icon } = {}) {
|
|
2000
2007
|
return this.request(`/forms/${encodeURIComponent(formId)}`, {
|
|
@@ -81,6 +81,60 @@ const SORT_SHAPE = z.object({
|
|
|
81
81
|
|
|
82
82
|
const SORT_DESC = 'Row order in this view.';
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* VIEW-ROW-SHOW-HIDDEN · VIEW-QUICK-FILTER · VIEW-ENTRY-ROW · VIEW-DAY-PAGER — 표 보기 설정 넷. 만들기(rootr_create_view)와
|
|
86
|
+
* 고치기(rootr_update_view)가 같은 모양을 쓴다(한쪽만 고치면 "만들 때는 되는데 바꿀 때는 안 된다"가 된다).
|
|
87
|
+
*/
|
|
88
|
+
const VIEW_EXTRA_SHAPE = {
|
|
89
|
+
rowPaneShowHidden: z
|
|
90
|
+
.boolean()
|
|
91
|
+
.nullable()
|
|
92
|
+
.optional()
|
|
93
|
+
.describe(
|
|
94
|
+
'Columns hidden in THIS view (hiddenProps) are hidden in the table only — when a row is opened (side pane / row page) ' +
|
|
95
|
+
'they are shown expanded instead of folded under "N hidden". false/null = default (folded).',
|
|
96
|
+
),
|
|
97
|
+
quickFilters: z
|
|
98
|
+
.array(z.string())
|
|
99
|
+
.max(6)
|
|
100
|
+
.nullable()
|
|
101
|
+
.optional()
|
|
102
|
+
.describe(
|
|
103
|
+
'Columns pinned at the TOP of the filter panel (names or ids, in order, up to 6). Each gets a direct input with no ' +
|
|
104
|
+
'operator picker: text/title/phone/email/url → "contains", select/status → "is" (pick an option), multi_select/person → ' +
|
|
105
|
+
'has value, relation → pick linked rows, date → that day. null or [] turns it off.',
|
|
106
|
+
),
|
|
107
|
+
entryRow: z
|
|
108
|
+
.union([z.boolean(), z.enum(['top', 'bottom'])])
|
|
109
|
+
.nullable()
|
|
110
|
+
.optional()
|
|
111
|
+
.describe(
|
|
112
|
+
'table view: keep one always-empty entry row (spreadsheet-like) — true/"bottom" at the bottom, "top" above the rows ' +
|
|
113
|
+
'(for tables read newest-first; rows made there stick to the top). Typing a value into any of its cells ' +
|
|
114
|
+
'creates the row (pre-filled to match the view filters, like the "new row" button) and a fresh empty row appears. ' +
|
|
115
|
+
'Focus/click alone creates nothing. false/null = off.',
|
|
116
|
+
),
|
|
117
|
+
filtersPersonalOnly: z
|
|
118
|
+
.boolean()
|
|
119
|
+
.nullable()
|
|
120
|
+
.optional()
|
|
121
|
+
.describe(
|
|
122
|
+
'table view: filters set in this view always apply only to the person setting them (no "share with everyone" switch) — ' +
|
|
123
|
+
'for views several people search at once. Conditions already shared stay. false/null = off. (Filters are just-me by default anyway; ' +
|
|
124
|
+
'this removes the switch.)',
|
|
125
|
+
),
|
|
126
|
+
dayPager: z
|
|
127
|
+
.union([z.object({ dateProp: z.string() }), z.string()])
|
|
128
|
+
.nullable()
|
|
129
|
+
.optional()
|
|
130
|
+
.describe(
|
|
131
|
+
'table view: show ONE DAY at a time — {dateProp: "<date column name or id>"} (must be a date column). A bar with ' +
|
|
132
|
+
'◀ date ▶ / Today / date picker appears; only rows whose date falls on the chosen day are listed (combined with the ' +
|
|
133
|
+
'view filters, applied server-side so counts/paging stay right on big tables), rows created in the view get that ' +
|
|
134
|
+
'day, and "search all dates" lifts the day limit. View filters on that same date column are replaced by the pager. null = off.',
|
|
135
|
+
),
|
|
136
|
+
};
|
|
137
|
+
|
|
84
138
|
/**
|
|
85
139
|
* DB-BODY — 행의 상세 내용(본문 메모). 화면에서 칸반 카드를 열면 속성 아래에 붙는 그 글이며,
|
|
86
140
|
* 실체는 표 밑에 달리는 문서 하나다. 예전에는 REST 2단계로만 닿을 수 있어서 CLI·MCP에서는
|
|
@@ -197,6 +251,7 @@ export function registerDatabaseTools(server) {
|
|
|
197
251
|
.object({
|
|
198
252
|
relation: z.string(),
|
|
199
253
|
property: z.string().optional(),
|
|
254
|
+
sameKey: z.string().optional().describe('a key column (title/text/phone/email/url, name or id): also gather what the OTHER rows with the same key value link to (deduplicated) — per-person totals when one person has several rows'),
|
|
200
255
|
function: z.enum([
|
|
201
256
|
'count',
|
|
202
257
|
'count_values',
|
|
@@ -342,9 +397,10 @@ export function registerDatabaseTools(server) {
|
|
|
342
397
|
'table view: keep the first N VISIBLE columns fixed on the left while scrolling sideways (Excel freeze panes). ' +
|
|
343
398
|
'0/null = off. e.g. 1 keeps the title column (상호명) in place.',
|
|
344
399
|
),
|
|
400
|
+
...VIEW_EXTRA_SHAPE,
|
|
345
401
|
},
|
|
346
402
|
},
|
|
347
|
-
async ({ databaseId, name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale, density, frozenColumns }) => {
|
|
403
|
+
async ({ databaseId, name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale, density, frozenColumns, rowPaneShowHidden, quickFilters, entryRow, filtersPersonalOnly, dayPager }) => {
|
|
348
404
|
try {
|
|
349
405
|
const client = makeClient();
|
|
350
406
|
const id = await resolveNodeId(client, databaseId);
|
|
@@ -360,6 +416,11 @@ export function registerDatabaseTools(server) {
|
|
|
360
416
|
calendarScale,
|
|
361
417
|
density,
|
|
362
418
|
frozenColumns,
|
|
419
|
+
rowPaneShowHidden,
|
|
420
|
+
quickFilters,
|
|
421
|
+
entryRow,
|
|
422
|
+
filtersPersonalOnly,
|
|
423
|
+
dayPager,
|
|
363
424
|
});
|
|
364
425
|
return jsonResult(view);
|
|
365
426
|
} catch (err) {
|
|
@@ -404,7 +465,8 @@ export function registerDatabaseTools(server) {
|
|
|
404
465
|
'Columns may be given by NAME or id; the title column cannot be hidden and is ignored if listed. ' +
|
|
405
466
|
'Pass an empty array to show everything again. Only the fields you pass change, and other views are untouched. ' +
|
|
406
467
|
'For an at-a-glance table also pass density "compact" (lower rows, smaller text) and frozenColumns N (first N visible ' +
|
|
407
|
-
'columns stay fixed while scrolling sideways).'
|
|
468
|
+
'columns stay fixed while scrolling sideways). Data-entry helpers: rowPaneShowHidden (hidden columns still show when a row ' +
|
|
469
|
+
'is opened), quickFilters (columns pinned at the top of the filter panel, just type), entryRow (always-empty row, Excel-like; "top" puts it above the rows), filtersPersonalOnly (filters always just-me) and dayPager {dateProp} (one day per page with ◀ ▶ / Today, new rows get that day).',
|
|
408
470
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
409
471
|
inputSchema: {
|
|
410
472
|
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
@@ -474,6 +536,7 @@ export function registerDatabaseTools(server) {
|
|
|
474
536
|
'table view: keep the first N VISIBLE columns fixed on the left while scrolling sideways (Excel freeze panes). ' +
|
|
475
537
|
'0/null = off. e.g. 1 keeps the title column (상호명) in place.',
|
|
476
538
|
),
|
|
539
|
+
...VIEW_EXTRA_SHAPE,
|
|
477
540
|
},
|
|
478
541
|
},
|
|
479
542
|
async ({
|
|
@@ -492,6 +555,11 @@ export function registerDatabaseTools(server) {
|
|
|
492
555
|
rowColors,
|
|
493
556
|
density,
|
|
494
557
|
frozenColumns,
|
|
558
|
+
rowPaneShowHidden,
|
|
559
|
+
quickFilters,
|
|
560
|
+
entryRow,
|
|
561
|
+
filtersPersonalOnly,
|
|
562
|
+
dayPager,
|
|
495
563
|
}) => {
|
|
496
564
|
try {
|
|
497
565
|
const client = makeClient();
|
|
@@ -510,6 +578,11 @@ export function registerDatabaseTools(server) {
|
|
|
510
578
|
rowColors,
|
|
511
579
|
density,
|
|
512
580
|
frozenColumns,
|
|
581
|
+
rowPaneShowHidden,
|
|
582
|
+
quickFilters,
|
|
583
|
+
entryRow,
|
|
584
|
+
filtersPersonalOnly,
|
|
585
|
+
dayPager,
|
|
513
586
|
});
|
|
514
587
|
return jsonResult({ view });
|
|
515
588
|
} catch (err) {
|
|
@@ -980,6 +1053,8 @@ export function registerDatabaseTools(server) {
|
|
|
980
1053
|
'"how many of this applicant\'s calls were no-answer": { relation: "콜", function: "count", ' +
|
|
981
1054
|
'where: [{ property: "결과", op: "is", value: "부재" }] }. ' +
|
|
982
1055
|
'A rollup may also read a FORMULA column in the linked table, and min/max work on date columns ' +
|
|
1056
|
+
'(sameKey: "<key column>" also gathers the links of the other rows with the same key value — e.g. "all calls of this person" when ' +
|
|
1057
|
+
'the same phone has several rows; nothing is copied, each linked row counts once) ' +
|
|
983
1058
|
'(that is how "last call date" is built). ' +
|
|
984
1059
|
'For unique_id pass prefix to get "TASK-42" style ids. ' +
|
|
985
1060
|
'For vector pass dimensions — an embedding column YOU fill with rootr_set_row_vectors and search with ' +
|
|
@@ -1040,6 +1115,7 @@ export function registerDatabaseTools(server) {
|
|
|
1040
1115
|
rollup: z
|
|
1041
1116
|
.object({
|
|
1042
1117
|
relation: z.string().describe('the relation column (id or name) to follow'),
|
|
1118
|
+
sameKey: z.string().optional().describe('a key column (title/text/phone/email/url, name or id): also gather what the OTHER rows with the same key value link to (deduplicated) — per-person totals when one person has several rows'),
|
|
1043
1119
|
property: z.string().optional().describe('the column to read in the linked table'),
|
|
1044
1120
|
function: z.enum([
|
|
1045
1121
|
'count',
|
|
@@ -1194,7 +1270,7 @@ export function registerDatabaseTools(server) {
|
|
|
1194
1270
|
rollup: z
|
|
1195
1271
|
.record(z.any())
|
|
1196
1272
|
.optional()
|
|
1197
|
-
.describe('rollup only: { relation, property?, function, where? } — replaces the whole config'),
|
|
1273
|
+
.describe('rollup only: { relation, property?, function, where?, sameKey? } — replaces the whole config'),
|
|
1198
1274
|
prefix: z.string().optional().describe('unique_id only: the new prefix'),
|
|
1199
1275
|
type: z.string().optional().describe('Not changeable — passing it is refused with the reason'),
|
|
1200
1276
|
dimensions: z.number().optional().describe('Not changeable (vector only) — passing it is refused with the reason'),
|
|
@@ -1277,6 +1353,19 @@ export function registerDatabaseTools(server) {
|
|
|
1277
1353
|
'relation only: which rows of the linked table may be linked (all rules must match; propId is a column of the LINKED table). ' +
|
|
1278
1354
|
'e.g. [{propId:"진행상태",op:"is",values:["채용"]}]. The picker hides others and the server refuses new links that fail. null turns it off.',
|
|
1279
1355
|
),
|
|
1356
|
+
keySync: z
|
|
1357
|
+
.record(z.any())
|
|
1358
|
+
.nullable()
|
|
1359
|
+
.optional()
|
|
1360
|
+
.describe(
|
|
1361
|
+
'On a KEY column (title/text/phone/email/url): keep rows that share the same key value (e.g. the same phone kept as several ' +
|
|
1362
|
+
'rows) in step. { columns: { "<column>": "mirror" | "append" | "items" } } (names or ids, ≤20). After a row is EDITED (cell, ' +
|
|
1363
|
+
'row panel, bulk edit, button, automation, REST/MCP, or a link made from the other side of a two-way relation) the change is ' +
|
|
1364
|
+
'copied to the other rows with the same key: mirror = copy the changed value; append (text/title) = append only the newly ' +
|
|
1365
|
+
'added text, skipped if the other row already has it; items (relation/multi_select) = add the added items, remove the removed ' +
|
|
1366
|
+
'ones. Not on row creation, key changes, imports, restores or merges; copied writes do not wake automations (no loops). ' +
|
|
1367
|
+
'Keys compare like dedupe. Existing differences are not back-filled. null turns it off.',
|
|
1368
|
+
),
|
|
1280
1369
|
dedupeCarry: z
|
|
1281
1370
|
.record(z.any())
|
|
1282
1371
|
.nullable()
|
|
@@ -1299,7 +1388,9 @@ export function registerDatabaseTools(server) {
|
|
|
1299
1388
|
'sources?: "all" (default — per column take the most recent earlier row that HAS a value) | "latest" (only the latest earlier row); ' +
|
|
1300
1389
|
'strategy?: default per column, one of fill_empty (default) | prefer_new | prefer_old | latest | skip; columns?: { "<column>": ' +
|
|
1301
1390
|
'fill_empty | prefer_new | prefer_old | latest | skip | max | min | sum (number) | earliest | latest_date (date) | union ' +
|
|
1302
|
-
'(multi_select/relation/files/person) | { append: { sep?: " / ", datePrefix?: true
|
|
1391
|
+
'(multi_select/relation/files/person) | { append: { sep?: " / ", datePrefix?: true, unique?: true, emptyText?: "기존 메모 없음", ' +
|
|
1392
|
+
'history?: { format: "{date}지원", dateFormat?: "YY-MM-DD", sep?: ", ", only?: true } } } (text — unique keeps a piece once, ' +
|
|
1393
|
+
'emptyText stands in for an empty earlier note, history adds ONE line of earlier rows\' dates, only = just that line) } — a strategy on the wrong column ' +
|
|
1303
1394
|
'type is a 400; exclude = skip, overwrite = prefer_old; representative?: "latest" | "oldest", representativeWhen?: [{ propId, op, ' +
|
|
1304
1395
|
'values }] (first row matching wins). Every operation returns an opId (undo with rootr_dedupe_undo); preview with ' +
|
|
1305
1396
|
'rootr_dedupe_preview. ' +
|
|
@@ -1359,6 +1450,7 @@ export function registerDatabaseTools(server) {
|
|
|
1359
1450
|
allowTime,
|
|
1360
1451
|
allowRange,
|
|
1361
1452
|
dedupeCarry,
|
|
1453
|
+
keySync,
|
|
1362
1454
|
validate,
|
|
1363
1455
|
identifyProps,
|
|
1364
1456
|
type,
|
|
@@ -1400,6 +1492,7 @@ export function registerDatabaseTools(server) {
|
|
|
1400
1492
|
allowTime,
|
|
1401
1493
|
allowRange,
|
|
1402
1494
|
dedupeCarry,
|
|
1495
|
+
keySync,
|
|
1403
1496
|
validate,
|
|
1404
1497
|
identifyProps,
|
|
1405
1498
|
});
|
|
@@ -1974,16 +2067,31 @@ export function registerDatabaseTools(server) {
|
|
|
1974
2067
|
'· {kind:"update_rows", target:"created"|"selected", values} — set values on the rows this button just ' +
|
|
1975
2068
|
'made, or on the rows the person ticked in the table.\n' +
|
|
1976
2069
|
'· {kind:"ai_fill", target, propId, prompt} — let AI write ONE cell. The prompt may quote the row with ' +
|
|
1977
|
-
'{칸이름}, e.g. "{제목}과 {메모}를 한 문장으로 요약해". If the AI fails the button keeps going
|
|
2070
|
+
'{칸이름}, e.g. "{제목}과 {메모}를 한 문장으로 요약해". If the AI fails the button keeps going. ' +
|
|
2071
|
+
'Pass fields:[{propId, prompt?}] (≤20) instead of propId to fill SEVERAL columns with ONE AI call from the same prompt ' +
|
|
2072
|
+
'(e.g. split one pasted text into many columns in one step; missing values stay empty).\n' +
|
|
1978
2073
|
'· {kind:"open_row"} — open the row this button made (UI only; over the API it just reports the id).\n' +
|
|
1979
2074
|
'Values accept @today, @today+7 and @me — the server fills them in at press time, not now. A text value ' +
|
|
1980
2075
|
'may also quote the pressed row with {칸이름}, e.g. "{이름} {통화 건수+1}차": computed columns (rollup, ' +
|
|
1981
|
-
'formula) are read too, {칸이름+N} / {칸이름-N} adds to a numeric value
|
|
2076
|
+
'formula) are read too, {칸이름+N} / {칸이름-N} adds to a numeric value (on a DATE column: N days later/earlier, ' +
|
|
2077
|
+
'{투입예정일+3}); {칸:YY-MM-DD} formats a date; {칸+3|@today+3} or {메모|없음} gives the value used when the column is ' +
|
|
2078
|
+
'empty; {@today} / {@today+7} / {@today:YY-MM-DD} work inside text too. APPEND A LINE: a value { append: "{@today:YY-MM-DD} ' +
|
|
2079
|
+
'{상태}", sep?: "\\n", unique?: true } on a text column adds that line to the END of the cell at write time (same line already ' +
|
|
2080
|
+
'there → skipped) instead of rewriting the whole cell. When several rows are ' +
|
|
1982
2081
|
'selected each new row is filled from its own source row. A relation column resolves to the linked row: ' +
|
|
1983
2082
|
'{지원자} is its TITLE (not the id) and {지원자.통화 건수} reads one of its columns — EXCEPT when the value ' +
|
|
1984
2083
|
'is exactly "{관계 칸}" and the column it goes into is a relation to the SAME table: then the row ids are ' +
|
|
1985
2084
|
'copied (e.g. 담당 매니저: "{배정 매니저}"). Ids the receiving column\'s relationFilter rejects are left out ' +
|
|
1986
2085
|
'of that cell only (the rest still runs), commented on the row and reported in `notes`.\n' +
|
|
2086
|
+
'ASK FOR TEXT FIRST: give the button `input: { label, placeholder?, multiline?, required?, split? }` and ' +
|
|
2087
|
+
'pressing it opens a text box (label = the prompt; required defaults to true = empty text does not run). ' +
|
|
2088
|
+
'The text goes wherever a step writes {@input} — add_row/update_rows values (e.g. {"원문":"{@input}"}) or ' +
|
|
2089
|
+
'an ai_fill prompt — so "paste the raw text → a new row holds it → ai_fill target:\"created\" parses it into ' +
|
|
2090
|
+
'the other columns" is one press with NO row selected. `split` runs the steps once PER ITEM when several are ' +
|
|
2091
|
+
'pasted together: {by:"blank_line"}, {by:"separator", separator:"---"} or {by:"ai"} (AI only returns where ' +
|
|
2092
|
+
'each item starts; the text itself is cut server-side); max 50 items. Saving is refused when a step uses ' +
|
|
2093
|
+
'{@input} but the button has no `input`, or the button has `input` but no step uses {@input}. Automations ' +
|
|
2094
|
+
'cannot use {@input}.\n' +
|
|
1987
2095
|
'A button never grants permission: it runs as whoever presses it — writing into another table needs ' +
|
|
1988
2096
|
'write access there too.',
|
|
1989
2097
|
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
@@ -1995,6 +2103,23 @@ export function registerDatabaseTools(server) {
|
|
|
1995
2103
|
id: z.string().optional().describe('Keep an existing button by passing its id; omit to create one'),
|
|
1996
2104
|
label: z.string().describe('Button text, e.g. "접수"'),
|
|
1997
2105
|
icon: z.string().optional().describe('One emoji shown on the button'),
|
|
2106
|
+
input: z
|
|
2107
|
+
.object({
|
|
2108
|
+
label: z.string().describe('Prompt shown above the text box, e.g. "붙여 넣을 원문"'),
|
|
2109
|
+
placeholder: z.string().optional().describe('Greyed-out example inside the box'),
|
|
2110
|
+
multiline: z.boolean().optional().describe('Multi-line text area (default single line)'),
|
|
2111
|
+
required: z.boolean().optional().describe('Default true — empty text does not run'),
|
|
2112
|
+
split: z
|
|
2113
|
+
.union([
|
|
2114
|
+
z.object({ by: z.literal('blank_line') }),
|
|
2115
|
+
z.object({ by: z.literal('separator'), separator: z.string() }),
|
|
2116
|
+
z.object({ by: z.literal('ai') }),
|
|
2117
|
+
])
|
|
2118
|
+
.optional()
|
|
2119
|
+
.describe('Run once per item when several are pasted together (max 50)'),
|
|
2120
|
+
})
|
|
2121
|
+
.optional()
|
|
2122
|
+
.describe('Ask for text when pressed; steps use it as {@input}. Omit for a plain button.'),
|
|
1998
2123
|
steps: z
|
|
1999
2124
|
.array(z.record(z.any()))
|
|
2000
2125
|
.describe('Ordered steps — see the tool description for each kind (max 8)'),
|
|
@@ -2028,7 +2153,10 @@ export function registerDatabaseTools(server) {
|
|
|
2028
2153
|
'with the reason (the rest of the button still ran). `aiSkipped` counts rows where the AI answered but the ' +
|
|
2029
2154
|
'answer could not go in the cell (e.g. not one of a select\'s options), with one sample in `aiSkippedAnswer`; ' +
|
|
2030
2155
|
'`aiUnchanged` counts rows whose AI answer equals the current value (left as is, not counted in `filled`). ' +
|
|
2031
|
-
'It runs with YOUR permissions: a button that writes a locked column stops there if you cannot edit it.'
|
|
2156
|
+
'It runs with YOUR permissions: a button that writes a locked column stops there if you cannot edit it. ' +
|
|
2157
|
+
'A button with `input` (see rootr_list_database_buttons) needs `input` — the text a person would paste into its ' +
|
|
2158
|
+
'box; it fills {@input}. With `input.split` the result also has `records: { total, done, failed, errors: ' +
|
|
2159
|
+
'[{ index, message }] }` (an item that fails is rolled back on its own; the others stay).',
|
|
2032
2160
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
2033
2161
|
inputSchema: {
|
|
2034
2162
|
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
@@ -2037,13 +2165,17 @@ export function registerDatabaseTools(server) {
|
|
|
2037
2165
|
.array(z.string())
|
|
2038
2166
|
.optional()
|
|
2039
2167
|
.describe('Rows for steps whose target is "selected". Omit for buttons that only add rows.'),
|
|
2168
|
+
input: z
|
|
2169
|
+
.string()
|
|
2170
|
+
.optional()
|
|
2171
|
+
.describe('Text for a button that asks for input (its `input` config) — fills {@input}. Max 30,000 chars.'),
|
|
2040
2172
|
},
|
|
2041
2173
|
},
|
|
2042
|
-
async ({ databaseId, buttonId, rowIds }) => {
|
|
2174
|
+
async ({ databaseId, buttonId, rowIds, input }) => {
|
|
2043
2175
|
try {
|
|
2044
2176
|
const client = makeClient();
|
|
2045
2177
|
const id = await resolveNodeId(client, databaseId);
|
|
2046
|
-
return jsonResult(await client.runDatabaseButton(id, buttonId, { rowIds }));
|
|
2178
|
+
return jsonResult(await client.runDatabaseButton(id, buttonId, { rowIds, input }));
|
|
2047
2179
|
} catch (err) {
|
|
2048
2180
|
return errorResult(err);
|
|
2049
2181
|
}
|
package/lib/mcp-tools/forms.js
CHANGED
|
@@ -12,7 +12,7 @@ const fieldShape = z
|
|
|
12
12
|
.enum([
|
|
13
13
|
'text', 'textarea', 'number', 'select', 'multi_select', 'checkbox', 'date',
|
|
14
14
|
'email', 'phone', 'url', 'rating', 'scale', 'time', 'file',
|
|
15
|
-
'matrix', 'ranking', 'csat', 'nps',
|
|
15
|
+
'matrix', 'ranking', 'csat', 'nps', 'relation',
|
|
16
16
|
])
|
|
17
17
|
.describe('Field input type'),
|
|
18
18
|
required: z.boolean().optional().describe('Whether the field must be filled in'),
|
|
@@ -23,8 +23,18 @@ const fieldShape = z
|
|
|
23
23
|
.describe('Choices for select/multi_select/matrix/ranking fields'),
|
|
24
24
|
help: z.string().optional().describe('One line under the question, e.g. "예: 010-0000-0000"'),
|
|
25
25
|
placeholder: z.string().optional().describe('Greyed-out example inside the input'),
|
|
26
|
-
min: z
|
|
27
|
-
|
|
26
|
+
min: z
|
|
27
|
+
.number()
|
|
28
|
+
.optional()
|
|
29
|
+
.describe('rating/scale: lowest number (rating 1, scale 0 or 1). number: smallest accepted answer — below it the submission is REFUSED (400).'),
|
|
30
|
+
max: z
|
|
31
|
+
.number()
|
|
32
|
+
.optional()
|
|
33
|
+
.describe('rating/scale: highest number (rating 5, scale 10; 2–10). number: largest accepted answer — above it the submission is REFUSED.'),
|
|
34
|
+
rangeMessage: z
|
|
35
|
+
.string()
|
|
36
|
+
.optional()
|
|
37
|
+
.describe('number only: sentence shown when the answer is outside min/max (≤200 chars). Default "N 이상 M 이하로 적어 주세요".'),
|
|
28
38
|
rows: z.array(z.string()).optional().describe('matrix only: the rows down the left side'),
|
|
29
39
|
page: z.number().optional().describe('Which page this question is on (1-based). Split a long form.'),
|
|
30
40
|
hidden: z
|
|
@@ -38,7 +48,19 @@ const fieldShape = z
|
|
|
38
48
|
multiple: z
|
|
39
49
|
.boolean()
|
|
40
50
|
.optional()
|
|
41
|
-
.describe('file
|
|
51
|
+
.describe('file: true = accept SEVERAL files (answer = array of { id, filename, size }). relation: true = pick several rows. Default: one.'),
|
|
52
|
+
relationDatabaseId: z
|
|
53
|
+
.string()
|
|
54
|
+
.optional()
|
|
55
|
+
.describe('relation only: table (id or "/path") to pick rows from. Omit to use the table that the response table\'s relation column (same name / field map) points to.'),
|
|
56
|
+
publicPick: z
|
|
57
|
+
.object({
|
|
58
|
+
enabled: z.boolean(),
|
|
59
|
+
showProps: z.array(z.string()).max(2).optional(),
|
|
60
|
+
filter: z.array(z.object({ propId: z.string(), op: z.enum(['is', 'is_not_empty']), values: z.array(z.string()).optional() })).optional(),
|
|
61
|
+
})
|
|
62
|
+
.optional()
|
|
63
|
+
.describe('relation only: let people WITHOUT access to the table (public link) search and pick. Off by default. Even on: a 2+ char search is required, ≤20 rows, only the title + `showProps` (≤2 column names) are shown, `filter` adds conditions on top of the relation column\'s relationFilter.'),
|
|
42
64
|
maxFiles: z
|
|
43
65
|
.number()
|
|
44
66
|
.optional()
|
|
@@ -65,6 +87,18 @@ const FIELDS_GUIDE =
|
|
|
65
87
|
'Per-field extras: `help` (a hint line), `placeholder`, `page` (split into multiple pages), `hidden` + URL ' +
|
|
66
88
|
'params (record the traffic source), `showIf` (show only when an earlier answer matches), `range: false` on a ' +
|
|
67
89
|
'date field (ask for ONE date — by default a date field also shows an optional end date). ' +
|
|
90
|
+
'`relation` = PICK ROWS FROM ANOTHER TABLE: respondents search the table (`relationDatabaseId`, or the table the ' +
|
|
91
|
+
'response table\'s relation column points to) and pick a row (`multiple: true` for several); the answer is stored as ' +
|
|
92
|
+
'{ id, title } and lands as a real link in the response table\'s relation column. Only `relation` questions can go ' +
|
|
93
|
+
'into a relation column (a text question there is reported as "can\'t go in" by the mapping). Picking is for members who ' +
|
|
94
|
+
'can read that table; set `publicPick: { enabled: true, showProps? }` to allow it on the public link too. To answer one ' +
|
|
95
|
+
'via API, find row ids with `rootr form pick` / GET /v1/forms/:id/relation-options?field=&q= (or rootr_pick_rows on the ' +
|
|
96
|
+
'table) and send the row id (or an array). ' +
|
|
97
|
+
'`min`/`max` on a ' +
|
|
98
|
+
'number field (an allowed range — answers outside it are REFUSED on every submit path, public link included; ' +
|
|
99
|
+
'decimals ok; either side may be omitted) with optional `rangeMessage` (what the respondent reads instead of the ' +
|
|
100
|
+
'default "N 이상 M 이하로 적어 주세요"). This blocks the form only — a column `validate` rule on the linked table ' +
|
|
101
|
+
'just warns. ' +
|
|
68
102
|
'Read the form first (rootr_read_form) before updating so ' +
|
|
69
103
|
'you keep each field\'s `id` stable — response values are keyed by field id, so renaming/dropping an id orphans ' +
|
|
70
104
|
'past responses\' answers for that field.';
|
|
@@ -82,6 +116,9 @@ const FORM_CONFIG_DESC =
|
|
|
82
116
|
'`mirror: { fieldMap, defaults: { <표의 칸 이름 또는 id>: value } }` — `defaults` stamps extra values onto the ' +
|
|
83
117
|
'row a response creates, for columns the person did not answer; use it to start a 신청 at "상신 중" so a ' +
|
|
84
118
|
'manager has something to move to 승인/거절. What the person actually answered always wins. ' +
|
|
119
|
+
// FORM-DEFAULTS-SUBMITTER(이슈 #227/#228) — 적어 두지 않으면 에이전트는 "누가 냈는지"를 칸에 남기는 길을 모른다.
|
|
120
|
+
'A default of `"@me"` becomes the logged-in submitter\'s name (person column → [name], title/text → name; ' +
|
|
121
|
+
'left empty for anonymous submissions) — use it to record who applied; `"@today"`/`"@today+7"` for dates. ' +
|
|
85
122
|
'`publicSubmit` ("anon"|"login") — who may answer from outside the workspace. Publishing the form ' +
|
|
86
123
|
'(`rootr_publish_node`, or the 웹에 공개 switch) turns this on by itself, so you almost never set it by hand; ' +
|
|
87
124
|
'to stop taking answers use `closesAt`/`maxResponses`, or unpublish. ' +
|
|
@@ -234,7 +271,9 @@ export function registerFormTools(server) {
|
|
|
234
271
|
'exact field ids and types. File questions: upload each file first (rootr_upload_attachment, or ' +
|
|
235
272
|
'rootr_create_upload_url → PUT → rootr_complete_upload) and pass `{ id, filename }` — an ARRAY of those when ' +
|
|
236
273
|
'the field has `multiple: true` (more than `maxFiles` is refused; several files on a single-file question is ' +
|
|
237
|
-
'refused). Images in a text answer with `allowImages` go in as markdown: `` using the upload\'s url.'
|
|
274
|
+
'refused). Images in a text answer with `allowImages` go in as markdown: `` using the upload\'s url. ' +
|
|
275
|
+
'"relation" (pick from another table) questions take the picked row id — or an array of ids when `multiple` — ' +
|
|
276
|
+
'found with rootr_pick_rows on that table; rows that do not exist, you cannot see, or break the column\'s condition are refused (400).',
|
|
238
277
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
239
278
|
inputSchema: {
|
|
240
279
|
formId: z.string().describe(`FORM node id${NODE_TARGET_HINT}`),
|
|
@@ -394,7 +433,9 @@ export function registerFormTools(server) {
|
|
|
394
433
|
'point of this tool — `unmatched`: the questions with NO column, whose answers are silently dropped ' +
|
|
395
434
|
'when someone submits. Check this right after linking a form to a table or renaming anything: a ' +
|
|
396
435
|
'mismatch produces no error anywhere, and the loss is usually found weeks later. ' +
|
|
397
|
-
'`how` is "manual" (someone chose it), "auto" (names matched)
|
|
436
|
+
'`how` is "manual" (someone chose it), "auto" (names matched), "none" (dropped) or "unfit" (the name matches ' +
|
|
437
|
+
'but the column cannot take that question — e.g. a text question on a relation column; `why` says so, and it ' +
|
|
438
|
+
'counts as dropped: change the question to `relation` or pin another column). ' +
|
|
398
439
|
'`lastError` carries why the most recent submission failed to reach the table, if it did.',
|
|
399
440
|
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
400
441
|
inputSchema: {
|
|
@@ -421,7 +462,8 @@ export function registerFormTools(server) {
|
|
|
421
462
|
'Questions you leave out keep matching BY NAME, and passing null for one puts it back to automatic. ' +
|
|
422
463
|
'Use this whenever the question wording differs from the column header ("성함" vs "이름") — otherwise ' +
|
|
423
464
|
'that answer is dropped with no warning. Call rootr_form_mapping first for the ids and the current state. ' +
|
|
424
|
-
'A field or column that does not exist is refused, not ignored.'
|
|
465
|
+
'A field or column that does not exist is refused, not ignored. A relation column only takes a `relation` ' +
|
|
466
|
+
'(pick from another table) question pointing at the same table.',
|
|
425
467
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
426
468
|
inputSchema: {
|
|
427
469
|
formId: z.string().describe(`FORM node id${NODE_TARGET_HINT}`),
|
|
@@ -44,7 +44,8 @@ const ACCESS_HINT =
|
|
|
44
44
|
'A phone/email/name property may carry `dedupe: true` (warn when the same person is entered again) with ' +
|
|
45
45
|
'`dedupeShow: ["상태","메모","통화 기록"]` (fields of the existing row the warning shows — a relation shows its latest 5 rows) and ' +
|
|
46
46
|
'`dedupeCarry: { label: "재등록", action: "carry"|"merge_into_old"|"merge_all"|"ask", sources: "all", columns: { "메모": { append: { sep: " / " } } }, exclude: ["접수일"], mark: { "재등록 여부": true, "구분": { map: { "A": "A(재등록)" }, else: "{value} (재등록)" } }, cases: [{ when: { within: { column: "접수일", days: 0 } }, action: "merge_all", mark: {} }, { action: "carry" }] }` ' +
|
|
47
|
-
'(same entry came back: copy the earlier row into the new one — see rootr_update_column);
|
|
47
|
+
'(same entry came back: copy the earlier row into the new one — see rootr_update_column); a key property (title/text/phone/email/url) may carry ' +
|
|
48
|
+
'`keySync: { columns: { "상태": "mirror", "메모": "append", "연락 기록": "items" } }` (rows sharing that key keep those columns in step after edits — see rootr_update_column); any property may carry ' +
|
|
48
49
|
'`validate: { kind: "pattern"|"range"|"date"|"birthdate", pattern?, flags?, negate?, min?, max?, message? }` (flags a wrong value on every new write, never blocks); ' +
|
|
49
50
|
'a relation property may carry `relationFilter: [{ propId, op: "is"|"is_not_empty", values? }]` (which linked rows may be picked). ' +
|
|
50
51
|
'The title property may carry `identifyProps: [{ propId: "연락처", mask: "last4" }, { propId: "지원일" }]` (shown next to the name ' +
|
|
@@ -54,6 +55,9 @@ const ACCESS_HINT =
|
|
|
54
55
|
'Any property may carry `align: "left"|"center"|"right"` and `wrap: true`; a date property `highlightOverdue: true` ' +
|
|
55
56
|
'(past dates red — off by default), `allowTime: false` (no time) and `allowRange: false` (no end date). A table view ' +
|
|
56
57
|
'config may carry `density: "compact"` and `frozenColumns: 1` (first N visible columns fixed while scrolling sideways). ' +
|
|
58
|
+
'Data-entry table views may also carry `entryRow: true` (always-empty bottom row), `dayPager: {dateProp: "<date column>"}` ' +
|
|
59
|
+
'(one day at a time), `quickFilters: ["<column>", …]` (pinned at the top of the filter panel, max 6) and `rowPaneShowHidden: true` ' +
|
|
60
|
+
'(columns hidden in the view still show when a row is opened). ' +
|
|
57
61
|
'A PAGE node may carry `page.analytics: true` (visit tracking — an "Analytics" folder with a visit LOG, a daily visits table and a dashboard is created next to it; records once published). ' +
|
|
58
62
|
'A DOCUMENT\'s `content` may embed a live ' +
|
|
59
63
|
'table/sheet/deck with a line `[[embed:<name of a node in this same tree>]]` or `[[embed:/absolute/path]]` — it is ' +
|