rootr-cli 0.17.3 → 0.18.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 +58 -1
- package/README.md +40 -0
- package/bin/rootr.js +207 -2
- package/lib/client.js +71 -6
- package/lib/mcp-tools/databases.js +262 -13
- package/lib/mcp-tools/forms.js +60 -0
- package/package.json +1 -1
package/README.ko.md
CHANGED
|
@@ -221,6 +221,22 @@ rootr config # 등록된 키 목록 확인
|
|
|
221
221
|
업그레이드 링크가 담긴 안내가 나옵니다. 응답 헤더로도 확인할 수 있습니다
|
|
222
222
|
(`X-RateLimit-Remaining`, `X-RateLimit-Reset`, `X-RateLimit-Window`).
|
|
223
223
|
|
|
224
|
+
#### 처음 옮겨 올 때: 이관 유예 72시간
|
|
225
|
+
|
|
226
|
+
옮겨 오는 일은 한 번뿐인데 하루 한도와 맞지 않습니다 — 2만 행짜리 표는 500행씩
|
|
227
|
+
나눠 읽어도 한 번 훑는 데 42회가 듭니다. 그래서 **워크스페이스당 한 번, 72시간**
|
|
228
|
+
동안 조회 한도를 풉니다.
|
|
229
|
+
|
|
230
|
+
워크스페이스 **설정 → 요금제**에서 켜거나, 관리자 키로 이렇게 켤 수 있습니다:
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
curl -X POST https://rootr.io/api/v1/workspaces/<워크스페이스 id>/billing/migration-grace \
|
|
234
|
+
-H "x-api-key: $ROOTR_API_KEY"
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
같은 주소에 `GET` 하면 언제 켰는지·언제 끝나는지를 돌려줍니다. 한 번 쓴 뒤에는
|
|
238
|
+
거절되고 "언제 썼는지"를 함께 알려 줍니다.
|
|
239
|
+
|
|
224
240
|
### 서명문서 · 연구노트 (sign)
|
|
225
241
|
|
|
226
242
|
서명문서는 마크다운 문서인데 **올리는 순간 본문이 얼고**, 결재라인에 적힌 사람들이
|
|
@@ -429,6 +445,11 @@ rootr memory forget <id> # 폐기(--hard면 행 삭제)
|
|
|
429
445
|
| `rootr_list_database_buttons` | 표 위 **버튼** 목록 보기(고칠 수 있는지 `myLevel`도 함께) |
|
|
430
446
|
| `rootr_set_database_buttons` | 버튼 만들고 고치기 — **목록 통째로 교체**(남길 것도 같이 보내야 함) |
|
|
431
447
|
| `rootr_run_database_button` | 버튼 누르기 — 행 만들기·값 채우기·AI로 칸 채우기가 순서대로 |
|
|
448
|
+
| `rootr_list_database_automations` | 표의 **자동 규칙** 목록 보기 |
|
|
449
|
+
| `rootr_set_database_automations` | 자동 규칙 저장 — "값이 이렇게 되면 이 일을 해라"·정해진 시각(목록 통째로 교체) |
|
|
450
|
+
| `rootr_import_folded_sheets` | 시트가 수백 장인 엑셀을 **표 하나로 접어** 가져오기(먼저 `dryRun`) |
|
|
451
|
+
| `rootr_form_mapping` | 폼 질문이 표의 **어느 칸**으로 가는지 + 짝 없는 질문(값이 버려짐) |
|
|
452
|
+
| `rootr_set_form_mapping` | 질문↔칸 짝을 손으로 정하기 — 이름이 달라도 값이 안 사라지게 |
|
|
432
453
|
| `rootr_list_document_commits` | 공개 문서에 온 변경 제안 목록 |
|
|
433
454
|
| `rootr_merge_document_commit` | 변경 제안 반영(세 벌 병합 — 덮어쓰기 아님) |
|
|
434
455
|
| `rootr_reject_document_commit` | 변경 제안 거절(사유는 제안자에게 전달) |
|
|
@@ -451,6 +472,7 @@ rootr memory forget <id> # 폐기(--hard면 행 삭제)
|
|
|
451
472
|
rootr db buttons <표> # 지금 달린 버튼 보기
|
|
452
473
|
rootr db buttons <표> --file buttons.json # 목록 통째로 저장(남길 것도 같이)
|
|
453
474
|
rootr db button-run <표> <버튼id> # 한 번 누르기 (--rows id,id 로 고른 행 지정)
|
|
475
|
+
rootr db automations <표> --file rules.json # 자동 규칙 — 사람이 안 눌러도 도는 것
|
|
454
476
|
rootr db view <표> <뷰> --scale year # 달력 보기를 주·달·해 중에서
|
|
455
477
|
```
|
|
456
478
|
|
|
@@ -462,7 +484,38 @@ rootr db view <표> <뷰> --scale year # 달력 보기를 주·달·해
|
|
|
462
484
|
] }]
|
|
463
485
|
```
|
|
464
486
|
|
|
465
|
-
|
|
487
|
+
**다른 표에 행 만들기.** `add_row` 에 `database`(표 id 또는 트리 경로)와
|
|
488
|
+
`link: { "relation": "<대상 표의 관계 칸>", "from": "selected" }` 를 주면, **누른 행이 이미
|
|
489
|
+
연결된 행**이 그 표에 생깁니다. 지원자 행에서 전화 버튼을 누르면 콜 이력에 그 지원자가 붙은
|
|
490
|
+
행이 생기는 식입니다 — 이게 없으면 표를 오가며 같은 사람을 두 번 찾습니다.
|
|
491
|
+
|
|
492
|
+
```json
|
|
493
|
+
[{ "label": "부재 기록", "icon": "📞", "steps": [
|
|
494
|
+
{ "kind": "add_row", "database": "/01. 채용 파이프라인/콜 이력",
|
|
495
|
+
"values": { "결과": "부재", "통화일시": "@today", "재통화 예정일": "@today+1" },
|
|
496
|
+
"link": { "relation": "지원자", "from": "selected" } }
|
|
497
|
+
] }]
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
**사람이 기억해서 눌러야 하는 일**은 버튼이 아니라 **자동 규칙**으로 겁니다 — 버튼은 안 누른
|
|
501
|
+
날 그 일이 그냥 빠집니다. 단계 문법은 버튼과 똑같고, 조건은 거의 언제나 `changed_to` 입니다
|
|
502
|
+
(`is` 는 그 뒤로 그 행을 고칠 때마다 계속 참이라 행이 몇 벌씩 생깁니다).
|
|
503
|
+
|
|
504
|
+
```json
|
|
505
|
+
[{ "label": "투입확정 해피콜 3건", "on": ["updated"],
|
|
506
|
+
"when": { "property": "상태", "op": "changed_to", "value": "투입확정" },
|
|
507
|
+
"steps": [
|
|
508
|
+
{ "kind": "add_row", "database": "/03. 투입·운영/해피콜",
|
|
509
|
+
"values": { "차수": "1차", "예정일": "@today+7" },
|
|
510
|
+
"link": { "relation": "지원자", "from": "selected" } }
|
|
511
|
+
] },
|
|
512
|
+
{ "label": "아침 재통화 대기열", "on": ["schedule"], "schedule": { "at": "09:00" },
|
|
513
|
+
"when": { "property": "재통화 예정일", "op": "is", "value": "@today" },
|
|
514
|
+
"steps": [{ "kind": "update_rows", "target": "selected", "values": { "오늘 할 일": true } }] }]
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
스캐폴드로 표를 지을 때도 `database.buttons` · `database.automations` 에 같은 모양을 넣으면
|
|
518
|
+
**표와 함께** 달립니다.
|
|
466
519
|
|
|
467
520
|
**행에 댓글 달기.** 표의 한 행에 댓글을 달려면 `rootr_comment_document` 에 **표**를
|
|
468
521
|
`target` 으로, **행 id**를 `blockId` 으로 줍니다(읽기는 `rootr_list_document_comments`,
|
|
@@ -479,6 +532,10 @@ rootr db view <표> <뷰> --scale year # 달력 보기를 주·달·해
|
|
|
479
532
|
명령줄에서는 `rootr access <경로|id> --private`, `--grant <userId> --level READ`,
|
|
480
533
|
`--revoke <userId>` 입니다. (웹에 공개하는 것과는 다릅니다 — 그건 `rootr_set_document_public`.)
|
|
481
534
|
|
|
535
|
+
질문이 표의 어느 칸으로 가는지는 `rootr form mapping <폼>` 으로 봅니다. 질문 이름과 칸 이름이
|
|
536
|
+
같아야 값이 넘어가므로, 다르면 **아무 경고 없이 그 답이 버려집니다** — 표를 직접 만들어 연결한
|
|
537
|
+
직후에는 반드시 한 번 확인하세요. 짝을 손으로 정하려면 `--set '{"질문id":"칸id"}'` 입니다.
|
|
538
|
+
|
|
482
539
|
응답 모을 곳은 `rootr form collect <폼> --to db|sheet`로 **만들면서** 연결합니다 —
|
|
483
540
|
표로 만들면 폼 질문 그대로 칸이 생기므로, 표를 미리 만들고 칸 이름을 맞출 필요가 없습니다
|
|
484
541
|
(이름이 하나라도 어긋나면 그 칸은 영영 빈 칸으로 남습니다). 스캐폴드 트리에서는 폼 노드에
|
package/README.md
CHANGED
|
@@ -193,9 +193,26 @@ rootr db lock /projects/tasks --schema managers --rows own --locked "Approved,Sc
|
|
|
193
193
|
rootr db notify /projects/tasks --file rules.json # notification rules
|
|
194
194
|
rootr db buttons /projects/tasks --file buttons.json # buttons that do work when pressed
|
|
195
195
|
rootr db button-run /projects/tasks fill-summary --rows r1,r2
|
|
196
|
+
rootr db automations /projects/tasks --file rules.json # "when a value becomes X, do Y" (also: at a set time)
|
|
197
|
+
rootr db rows-new /projects/tasks --file rows.json --upsert-key 외부키 # idempotent load: retries cannot duplicate
|
|
198
|
+
rootr db import-sheets sites.xlsx --ws <id> --dry # fold a one-sheet-per-record workbook into ONE table
|
|
196
199
|
rootr db row-page <rowId> --type RECORDING # give one row a typed detail page
|
|
197
200
|
```
|
|
198
201
|
|
|
202
|
+
`db automations` takes the same step syntax as `db buttons`, so there is nothing new to
|
|
203
|
+
learn — the difference is that nobody has to press it. A rule is
|
|
204
|
+
`{ label, on: ["updated"], when: { property, op: "changed_to", value }, steps: [...] }`;
|
|
205
|
+
add `on: ["schedule"]` with `schedule: { at: "09:00" }` for a daily job (Korean time).
|
|
206
|
+
Prefer `changed_to` over `is`: `is` stays true on every later edit, so a rule that creates
|
|
207
|
+
rows would create them again each time the row is saved.
|
|
208
|
+
|
|
209
|
+
`--upsert-key` is what makes a migration or a sync safe to retry — without it, retrying
|
|
210
|
+
after a partial failure silently double-loads (a real incident: 903 duplicate rows).
|
|
211
|
+
|
|
212
|
+
`db import-sheets` always deserves a `--dry` first: it prints the shape it read, the
|
|
213
|
+
columns it will make and the first rows, and creates nothing. Folding hundreds of sheets
|
|
214
|
+
the wrong way costs far more to undo than to check.
|
|
215
|
+
|
|
199
216
|
### Forms
|
|
200
217
|
|
|
201
218
|
```bash
|
|
@@ -204,10 +221,17 @@ rootr form new /intake/request --collect db \
|
|
|
204
221
|
rootr form show /intake/request # read the questions before changing them
|
|
205
222
|
rootr form set /intake/request --fields '[...]' # replaces the question set
|
|
206
223
|
rootr form collect /intake/request --to sheet # create and wire a destination
|
|
224
|
+
rootr form mapping /intake/request # which column each question feeds
|
|
225
|
+
rootr form mapping /intake/request --set '{"f_name":"p_title"}'
|
|
207
226
|
rootr form responses /intake/request --latest # --latest = newest one per person
|
|
208
227
|
rootr form response-edit <responseId> --values '{"Email":"a@b.com"}'
|
|
209
228
|
```
|
|
210
229
|
|
|
230
|
+
Run `form mapping` right after pointing a form at a hand-built table. Answers reach the
|
|
231
|
+
table by matching the question name to the column name, so a question the table has no
|
|
232
|
+
column for is **dropped with no error anywhere** — this command is the only place that
|
|
233
|
+
says so, and `--set` pins the pairs by hand when the wording differs ("성함" vs "이름").
|
|
234
|
+
|
|
211
235
|
`--fields` takes a JSON array as a **string**, not a file path.
|
|
212
236
|
|
|
213
237
|
18 question types, including `text`, `email`, `phone`, `url`, `rating`, `scale`, `time`, `file`,
|
|
@@ -341,6 +365,22 @@ At 80% of the limit, MCP tool responses append a note with the remaining count.
|
|
|
341
365
|
get a 429 with an upgrade link. The response headers carry the same information
|
|
342
366
|
(`X-RateLimit-Remaining`, `X-RateLimit-Reset`, `X-RateLimit-Window`).
|
|
343
367
|
|
|
368
|
+
#### First migration: a 72-hour grace period
|
|
369
|
+
|
|
370
|
+
Moving your data in happens once, but it does not fit a daily cap — a 20,000-row
|
|
371
|
+
table costs 42 calls to read through even in 500-row pages. So each workspace can
|
|
372
|
+
lift the read limit **once, for 72 hours**.
|
|
373
|
+
|
|
374
|
+
Turn it on in **Settings → Plan**, or with an admin key:
|
|
375
|
+
|
|
376
|
+
```bash
|
|
377
|
+
curl -X POST https://rootr.io/api/v1/workspaces/<workspace id>/billing/migration-grace \
|
|
378
|
+
-H "x-api-key: $ROOTR_API_KEY"
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
`GET` on the same path tells you when it started and when it ends. A second request
|
|
382
|
+
is refused and says when the grace was used.
|
|
383
|
+
|
|
344
384
|
## MCP tools
|
|
345
385
|
|
|
346
386
|
`rootr mcp` speaks MCP over stdio. 167 tools:
|
package/bin/rootr.js
CHANGED
|
@@ -35,6 +35,8 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
|
|
|
35
35
|
rootr db notify <path|id> [--file rules.json] [--clear] 표 알림 규칙 보기·저장
|
|
36
36
|
rootr db buttons <path|id> [--file buttons.json] [--clear] 표 위 "누르면 일하는 버튼" 보기·저장
|
|
37
37
|
rootr db button-run <path|id> <buttonId> [--rows id,id] 버튼 한 번 누르기 (행 만들기·값 채우기·AI 채우기)
|
|
38
|
+
rootr db automations <path|id> [--file rules.json] [--clear] 자동 규칙 — "값이 이렇게 되면 이 일을 해라"·정해진 시각
|
|
39
|
+
rootr db import-sheets <파일.xlsx> --ws <id> [--dry] 시트 수백 장짜리 엑셀을 표 하나로 접어 가져오기
|
|
38
40
|
rootr db view <path|id> [뷰이름] [--scale week|month|year] [--name 새이름] 뷰 목록·달력 주/달/해 단위
|
|
39
41
|
rootr db row-add <path|id> --values '<json>' [--body <md>|--body-file <f>] 행 추가(상세 내용 포함)
|
|
40
42
|
rootr db row-body <path|id> <rowId> [--file <f>] 행의 상세 내용(본문 메모) 쓰기 — 없으면 표준입력
|
|
@@ -62,6 +64,7 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
|
|
|
62
64
|
rootr form show <path|id> [--json] 질문·설정 보기 (고치기 전에 먼저)
|
|
63
65
|
rootr form set <path|id> --fields '<json>' | --config '<json>' 질문·설정 고치기 (설정은 준 칸만 바뀜)
|
|
64
66
|
rootr form collect <path|id> --to db|sheet 응답 모을 곳을 새로 만들어 연결 (표는 질문 그대로 칸 생성)
|
|
67
|
+
rootr form mapping <path|id> [--set '<{"질문id":"칸id"}>'] 질문이 표의 어느 칸으로 가는지 보기·정하기 (짝 없으면 값이 버려짐)
|
|
65
68
|
rootr form responses <path|id> [--latest] [--json] 폼에 들어온 응답 보기 (--latest: 사람마다 최신 1건만)
|
|
66
69
|
rootr form response-edit <응답id> --values '<json>' 이미 낸 응답 고치기 (폼 검사 규칙 다시 통과)
|
|
67
70
|
rootr form response-rm <응답id> [--yes] 응답 한 건 삭제 (되돌릴 수 없음)
|
|
@@ -126,6 +129,8 @@ usage:
|
|
|
126
129
|
rootr db notify <path|id> [--file rules.json] [--clear] view/save notification rules
|
|
127
130
|
rootr db buttons <path|id> [--file buttons.json] [--clear] view/save buttons that do work when pressed
|
|
128
131
|
rootr db button-run <path|id> <buttonId> [--rows id,id] press a button once (create rows, fill values, AI fill)
|
|
132
|
+
rootr db automations <path|id> [--file rules.json] [--clear] automatic rules — "when a value becomes X, do Y", or at a set time
|
|
133
|
+
rootr db import-sheets <file.xlsx> --ws <id> [--dry] fold a one-sheet-per-record workbook into ONE table
|
|
129
134
|
rootr db view <path|id> [view name] [--scale week|month|year] [--name <new name>] list views; calendar week/month/year
|
|
130
135
|
rootr db row-add <path|id> --values '<json>' [--body <md>|--body-file <f>] add a row (with its body)
|
|
131
136
|
rootr db row-body <path|id> <rowId> [--file <f>] write a row's body — reads stdin if no file
|
|
@@ -908,6 +913,7 @@ async function cmdForm(args) {
|
|
|
908
913
|
if (sub === 'show') return cmdFormShow(args);
|
|
909
914
|
if (sub === 'set') return cmdFormSet(args);
|
|
910
915
|
if (sub === 'collect') return cmdFormCollect(args);
|
|
916
|
+
if (sub === 'mapping') return cmdFormMapping(args);
|
|
911
917
|
if (sub === 'responses') return cmdFormResponses(args);
|
|
912
918
|
if (sub === 'response-edit') return cmdFormResponseEdit(args);
|
|
913
919
|
if (sub === 'response-rm') return cmdFormResponseRm(args);
|
|
@@ -1067,6 +1073,64 @@ async function cmdFormCollect(args) {
|
|
|
1067
1073
|
);
|
|
1068
1074
|
}
|
|
1069
1075
|
|
|
1076
|
+
/**
|
|
1077
|
+
* FORM-MAP — 질문이 표의 **어느 칸으로** 가는지 보고, 손으로 정한다.
|
|
1078
|
+
*
|
|
1079
|
+
* 폼 응답은 질문 이름과 칸 이름이 같아야 넘어간다. 다르면 **아무 경고 없이** 빠져서,
|
|
1080
|
+
* "응답은 들어오는데 행이 안 생긴다"가 며칠씩 이어진다(2026-09-18 클린베테랑 신고 6번).
|
|
1081
|
+
*
|
|
1082
|
+
* rootr form mapping <path|id>
|
|
1083
|
+
* rootr form mapping <path|id> --set '{"f_name":"p_title"}'
|
|
1084
|
+
*/
|
|
1085
|
+
async function cmdFormMapping(args) {
|
|
1086
|
+
const target = args._[1];
|
|
1087
|
+
if (!target) throw new Error(usage(`rootr form mapping <path|id> [--set '<{"질문id":"칸id"}>'] [--json]`));
|
|
1088
|
+
const client = makeClient();
|
|
1089
|
+
const id = await resolveNodeId(client, target);
|
|
1090
|
+
let result;
|
|
1091
|
+
if (args.set) {
|
|
1092
|
+
let fieldMap;
|
|
1093
|
+
try {
|
|
1094
|
+
fieldMap = JSON.parse(args.set);
|
|
1095
|
+
} catch {
|
|
1096
|
+
throw new Error(tr('--set 은 {"질문id":"칸id"} 모양의 JSON이어야 합니다.', `--set must be JSON shaped like {"fieldId":"columnId"}.`));
|
|
1097
|
+
}
|
|
1098
|
+
result = await client.setFormMapping(id, fieldMap);
|
|
1099
|
+
} else {
|
|
1100
|
+
result = await client.formMapping(id);
|
|
1101
|
+
}
|
|
1102
|
+
if (args.json) {
|
|
1103
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
if (!result.targetDatabaseId) {
|
|
1107
|
+
console.log(tr('이 폼은 표에 연결돼 있지 않습니다 — rootr form collect 로 먼저 연결하세요.', `This form is not linked to a table — link one with "rootr form collect" first.`));
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
const HOW = {
|
|
1111
|
+
manual: tr('손으로 정함', `set by hand`),
|
|
1112
|
+
auto: tr('이름이 같아서', `matched by name`),
|
|
1113
|
+
none: tr('✗ 짝 없음 — 값이 버려집니다', `✗ no column — this answer is dropped`),
|
|
1114
|
+
};
|
|
1115
|
+
for (const f of result.fields) {
|
|
1116
|
+
console.log(`${f.label} → ${f.propertyName ?? '—'} (${HOW[f.how] ?? f.how})`);
|
|
1117
|
+
}
|
|
1118
|
+
if (result.unmatched?.length) {
|
|
1119
|
+
console.log('');
|
|
1120
|
+
console.log(
|
|
1121
|
+
tr(
|
|
1122
|
+
`표로 안 넘어가는 질문 ${result.unmatched.length}개: ${result.unmatched.join(', ')}`,
|
|
1123
|
+
`${result.unmatched.length} questions never reach the table: ${result.unmatched.join(', ')}`,
|
|
1124
|
+
),
|
|
1125
|
+
);
|
|
1126
|
+
console.log(tr('--set 으로 칸을 정해 주거나, 표에 같은 이름의 칸을 만드세요.', `Pin them with --set, or add columns with the same names to the table.`));
|
|
1127
|
+
}
|
|
1128
|
+
if (result.lastError) {
|
|
1129
|
+
console.log('');
|
|
1130
|
+
console.log(tr(`마지막 옮기기 실패: ${result.lastError.message}`, `Last mirror failure: ${result.lastError.message}`));
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1070
1134
|
async function cmdFormResponses(args) {
|
|
1071
1135
|
const target = args._[1];
|
|
1072
1136
|
if (!target) throw new Error(usage(`rootr form responses <path|id> [--latest] [--json]`));
|
|
@@ -1169,10 +1233,149 @@ async function cmdDbRowsNew(args) {
|
|
|
1169
1233
|
if (!rows.length) throw new Error(tr('넣을 행이 없습니다.', `No rows to insert.`));
|
|
1170
1234
|
const client = makeClient();
|
|
1171
1235
|
const id = await resolveDatabaseId(client, target);
|
|
1172
|
-
|
|
1236
|
+
// BULK-UPSERT — 열쇠 칸을 주면 **다시 보내도 행이 두 벌이 안 된다**. 이관·동기화는
|
|
1237
|
+
// 중간에 끊기고 재시도되기 마련이고, 그때 중복이 조용히 쌓이는 것이 가장 나쁘다
|
|
1238
|
+
// (2026-09-18 클린베테랑: 재시도 한 번에 903건 중복).
|
|
1239
|
+
const upsertKey = args['upsert-key'] ?? args.upsertKey;
|
|
1240
|
+
const res = await client.bulkCreateRows(id, rows, upsertKey);
|
|
1241
|
+
if (upsertKey) {
|
|
1242
|
+
console.log(
|
|
1243
|
+
tr(
|
|
1244
|
+
`${res.created}개 행을 넣고 ${res.updated ?? 0}개는 이미 있어서 고쳤습니다(열쇠 칸: ${upsertKey}).`,
|
|
1245
|
+
`${res.created} rows inserted, ${res.updated ?? 0} already existed and were updated (key column: ${upsertKey}).`,
|
|
1246
|
+
),
|
|
1247
|
+
);
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1173
1250
|
console.log(tr(`${res.created}개 행을 넣었습니다.`, `${res.created} rows inserted.`));
|
|
1174
1251
|
}
|
|
1175
1252
|
|
|
1253
|
+
/**
|
|
1254
|
+
* DB-AUTO — 표의 **자동 규칙**(값이 이렇게 되면 이 일을 해라).
|
|
1255
|
+
*
|
|
1256
|
+
* rootr db automations <path|id> 지금 걸린 규칙 보기
|
|
1257
|
+
* rootr db automations <path|id> --file a.json 규칙 목록을 통째로 갈아끼우기
|
|
1258
|
+
* rootr db automations <path|id> --clear 규칙 전부 없애기
|
|
1259
|
+
*
|
|
1260
|
+
* 파일 모양(단계는 버튼과 같다):
|
|
1261
|
+
* [{ "label":"투입확정 해피콜", "on":["updated"],
|
|
1262
|
+
* "when":{"property":"상태","op":"changed_to","value":"투입확정"},
|
|
1263
|
+
* "steps":[{"kind":"add_row","database":"/03. 투입·운영/해피콜",
|
|
1264
|
+
* "values":{"차수":"1차","예정일":"@today+7"},
|
|
1265
|
+
* "link":{"relation":"지원자","from":"selected"}}] }]
|
|
1266
|
+
*/
|
|
1267
|
+
async function cmdDbAutomations(args) {
|
|
1268
|
+
const target = args._[1];
|
|
1269
|
+
if (!target) throw new Error(usage(`rootr db automations <path|id> [--file rules.json] [--clear] [--json]`));
|
|
1270
|
+
const client = makeClient();
|
|
1271
|
+
const id = await resolveDatabaseId(client, target);
|
|
1272
|
+
|
|
1273
|
+
if (args.clear) {
|
|
1274
|
+
const cleared = await client.setDatabaseAutomations(id, []);
|
|
1275
|
+
console.log(tr('자동 규칙을 모두 지웠습니다.', `All automatic rules cleared.`));
|
|
1276
|
+
if (args.json) console.log(JSON.stringify(cleared, null, 2));
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
if (args.file) {
|
|
1280
|
+
let rules;
|
|
1281
|
+
try {
|
|
1282
|
+
rules = JSON.parse(readFileSync(args.file, 'utf8'));
|
|
1283
|
+
} catch {
|
|
1284
|
+
throw new Error(
|
|
1285
|
+
tr(
|
|
1286
|
+
'규칙 파일을 JSON으로 읽을 수 없습니다. [{ "label": "...", "when": {...}, "steps": [...] }] 형태여야 합니다.',
|
|
1287
|
+
`Could not read the rules file as JSON. It must look like [{ "label": "...", "when": {...}, "steps": [...] }].`,
|
|
1288
|
+
),
|
|
1289
|
+
);
|
|
1290
|
+
}
|
|
1291
|
+
if (!Array.isArray(rules)) {
|
|
1292
|
+
throw new Error(tr('규칙 파일은 배열이어야 합니다(규칙 목록 전체).', `The rules file must be an array (the whole rule list).`));
|
|
1293
|
+
}
|
|
1294
|
+
const saved = await client.setDatabaseAutomations(id, rules);
|
|
1295
|
+
console.log(tr(`자동 규칙 ${saved.automations.length}개를 저장했습니다.`, `Saved ${saved.automations.length} automatic rules.`));
|
|
1296
|
+
if (args.json) console.log(JSON.stringify(saved, null, 2));
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
const { automations } = await client.listDatabaseAutomations(id);
|
|
1301
|
+
if (args.json) {
|
|
1302
|
+
console.log(JSON.stringify(automations, null, 2));
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
if (!automations.length) {
|
|
1306
|
+
console.log(tr('자동 규칙이 없습니다.', `No automatic rules.`));
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
const WHEN = {
|
|
1310
|
+
created: tr('행이 생기면', `when a row is created`),
|
|
1311
|
+
updated: tr('값이 바뀌면', `when a value changes`),
|
|
1312
|
+
schedule: tr('정해진 시각에', `at a set time`),
|
|
1313
|
+
};
|
|
1314
|
+
for (const a of automations) {
|
|
1315
|
+
const state = a.enabled === false ? tr(' (꺼짐)', ` (paused)`) : '';
|
|
1316
|
+
console.log(`${a.label}${state} (${a.id})`);
|
|
1317
|
+
const when = (a.on ?? []).map((e) => WHEN[e] ?? e).join(', ');
|
|
1318
|
+
const at = a.schedule ? ` ${a.schedule.at}` : '';
|
|
1319
|
+
console.log(` ${tr('언제', `when`)}: ${when}${at}`);
|
|
1320
|
+
if (a.when) console.log(` ${tr('조건', `if`)}: ${a.when.property} ${a.when.op} ${a.when.value ?? ''}`);
|
|
1321
|
+
for (const [i, st] of (a.steps ?? []).entries()) {
|
|
1322
|
+
const where = st.database ? ` → ${st.database}` : '';
|
|
1323
|
+
console.log(` ${i + 1}. ${st.kind}${where}`);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
/**
|
|
1329
|
+
* XLSX-FOLD — 시트가 수백 장인 엑셀을 **표 하나로 접어** 가져온다.
|
|
1330
|
+
*
|
|
1331
|
+
* rootr db import-sheets <파일.xlsx> --ws <id> --dry 먼저 써 보기(아무것도 안 만듦)
|
|
1332
|
+
* rootr db import-sheets <파일.xlsx> --ws <id> --name "사업장"
|
|
1333
|
+
* rootr db import-sheets <파일.xlsx> --ws <id> --db <표> 이미 있는 표에 넣기
|
|
1334
|
+
*/
|
|
1335
|
+
async function cmdDbImportSheets(args) {
|
|
1336
|
+
const file = args._[1];
|
|
1337
|
+
if (!file) throw new Error(usage(`rootr db import-sheets <파일.xlsx> --ws <workspaceId> [--dry] [--mode vertical|header] [--name "표 이름"] [--db <path|id>] [--upsert-key <칸>] [--skip "시트1,시트2"] [--header-row 2] [--sheet-column "사업장"]`));
|
|
1338
|
+
const ws = args.ws ?? args.workspace;
|
|
1339
|
+
if (!ws) throw new Error(tr('--ws 로 워크스페이스 id를 알려 주세요.', `Pass --ws with the workspace id.`));
|
|
1340
|
+
const client = makeClient();
|
|
1341
|
+
const body = {
|
|
1342
|
+
xlsx: readFileSync(file).toString('base64'),
|
|
1343
|
+
dryRun: Boolean(args.dry ?? args.dryRun),
|
|
1344
|
+
...(args.mode ? { mode: args.mode } : {}),
|
|
1345
|
+
...(args.name ? { name: args.name } : {}),
|
|
1346
|
+
...(args['sheet-column'] ? { sheetNameColumn: args['sheet-column'] } : {}),
|
|
1347
|
+
...(args.skip ? { skipSheets: String(args.skip).split(',').map((x) => x.trim()).filter(Boolean) } : {}),
|
|
1348
|
+
...(args['header-row'] ? { headerRow: Number(args['header-row']) } : {}),
|
|
1349
|
+
...(args['upsert-key'] ? { upsertKey: args['upsert-key'] } : {}),
|
|
1350
|
+
...(args.parent ? { parentId: args.parent } : {}),
|
|
1351
|
+
};
|
|
1352
|
+
if (args.db) body.databaseId = await resolveDatabaseId(client, args.db);
|
|
1353
|
+
const res = await client.importFoldedSheets(ws, body);
|
|
1354
|
+
if (args.json) {
|
|
1355
|
+
console.log(JSON.stringify(res, null, 2));
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
const MODE = { vertical: tr('시트 한 장이 행 하나', `one sheet = one row`), header: tr('시트마다 같은 머리글', `same header in every sheet`) };
|
|
1359
|
+
if (res.dryRun) {
|
|
1360
|
+
console.log(tr(`써 보기 — 아무것도 만들지 않았습니다.`, `Dry run — nothing was created.`));
|
|
1361
|
+
console.log(`${tr('읽은 모양', `detected shape`)}: ${MODE[res.mode] ?? res.mode} · ${tr('시트', `sheets`)} ${res.sheets} → ${tr('행', `rows`)} ${res.rowCount}`);
|
|
1362
|
+
console.log(`${tr('칸', `columns`)}: ${res.columns.join(', ')}`);
|
|
1363
|
+
for (const line of res.notes ?? []) console.log(` · ${line}`);
|
|
1364
|
+
console.log(tr('맞으면 --dry 를 빼고 다시 실행하세요.', `Run it again without --dry if that looks right.`));
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
console.log(
|
|
1368
|
+
tr(
|
|
1369
|
+
`표에 ${res.created}행을 넣었습니다${res.updated ? ` (${res.updated}행은 고쳤습니다)` : ''}. ${res.url ?? res.databaseId}`,
|
|
1370
|
+
`Inserted ${res.created} rows${res.updated ? ` (updated ${res.updated})` : ''}. ${res.url ?? res.databaseId}`,
|
|
1371
|
+
),
|
|
1372
|
+
);
|
|
1373
|
+
for (const line of res.notes ?? []) console.log(` · ${line}`);
|
|
1374
|
+
if (res.skippedColumns?.length) {
|
|
1375
|
+
console.log(tr(`표에 없어서 버린 칸: ${res.skippedColumns.join(', ')}`, `Columns dropped (not in the table): ${res.skippedColumns.join(', ')}`));
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1176
1379
|
/**
|
|
1177
1380
|
* DB-BULK — `rootr db rows-each <표> --json '[{"rowId":"…","values":{…}},…]'`
|
|
1178
1381
|
*
|
|
@@ -1500,10 +1703,12 @@ async function cmdDb(args) {
|
|
|
1500
1703
|
if (sub === 'notify') return cmdDbNotify(args);
|
|
1501
1704
|
if (sub === 'buttons') return cmdDbButtons(args);
|
|
1502
1705
|
if (sub === 'button-run') return cmdDbButtonRun(args);
|
|
1706
|
+
if (sub === 'automations') return cmdDbAutomations(args);
|
|
1707
|
+
if (sub === 'import-sheets') return cmdDbImportSheets(args);
|
|
1503
1708
|
if (sub === 'view') return cmdDbView(args);
|
|
1504
1709
|
if (sub !== 'create') {
|
|
1505
1710
|
throw new Error(
|
|
1506
|
-
usage(tr(`rootr db create|rows|row-add|rows-new|row-body|rows-set|rows-each|rows-rm|row-page|row-page-type|lock|notify|buttons|button-run|view … (자세한 건 rootr --help)`, `rootr db create|rows|row-add|rows-new|row-body|rows-set|rows-each|rows-rm|row-page|row-page-type|lock|notify|buttons|button-run|view … (see rootr --help)`)),
|
|
1711
|
+
usage(tr(`rootr db create|rows|row-add|rows-new|row-body|rows-set|rows-each|rows-rm|row-page|row-page-type|lock|notify|buttons|button-run|automations|import-sheets|view … (자세한 건 rootr --help)`, `rootr db create|rows|row-add|rows-new|row-body|rows-set|rows-each|rows-rm|row-page|row-page-type|lock|notify|buttons|button-run|view … (see rootr --help)`)),
|
|
1507
1712
|
);
|
|
1508
1713
|
}
|
|
1509
1714
|
const raw = typeof args.file === 'string' ? readFileSync(args.file, 'utf8') : await readStdin();
|
package/lib/client.js
CHANGED
|
@@ -511,6 +511,65 @@ export class RootrClient {
|
|
|
511
511
|
);
|
|
512
512
|
}
|
|
513
513
|
|
|
514
|
+
// ── DB-AUTO — "값이 이렇게 되면 이 일을 해라" 규칙 ───────────────────────
|
|
515
|
+
//
|
|
516
|
+
// 버튼과 같은 단계 문법을 쓰되 사람이 안 눌러도 돈다. 2026-09-18 클린베테랑 요청
|
|
517
|
+
// 4번: 투입 확정 시 해피콜 3건을 사람이 버튼으로 잡고 있었고, 안 누르면 빠졌다.
|
|
518
|
+
|
|
519
|
+
/** 이 표의 자동 규칙 목록. */
|
|
520
|
+
async listDatabaseAutomations(databaseId) {
|
|
521
|
+
return this.request(`/databases/${encodeURIComponent(databaseId)}/automations`);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** 자동 규칙을 **통째로** 갈아끼운다(MANAGE 필요). 남길 규칙도 같이 보내야 한다. */
|
|
525
|
+
async setDatabaseAutomations(databaseId, automations) {
|
|
526
|
+
return this.request(`/databases/${encodeURIComponent(databaseId)}/automations`, {
|
|
527
|
+
method: 'PUT',
|
|
528
|
+
body: { automations },
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ── FORM-MAP — 폼 질문이 표의 어느 칸으로 가는가 ───────────────────────
|
|
533
|
+
|
|
534
|
+
/** 질문마다 어느 칸으로 들어가는지 + 짝 없는 질문(값이 버려진다). */
|
|
535
|
+
async formMapping(formId) {
|
|
536
|
+
return this.request(`/forms/${encodeURIComponent(formId)}/mapping`);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** 질문↔칸 짝을 손으로 정한다. `{ 질문id: 칸id }`, 값이 null이면 자동으로 되돌린다. */
|
|
540
|
+
async setFormMapping(formId, fieldMap) {
|
|
541
|
+
return this.request(`/forms/${encodeURIComponent(formId)}/mapping`, {
|
|
542
|
+
method: 'PUT',
|
|
543
|
+
body: { fieldMap },
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ── XLSX-FOLD — 시트 복제형 엑셀을 표 하나로 접기 ──────────────────────
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* 시트가 수백 장인 엑셀을 표 한 개로 접어 가져온다(2026-09-18 요청 7번).
|
|
551
|
+
* `dryRun: true`면 아무것도 만들지 않고 어떻게 접히는지만 돌려준다.
|
|
552
|
+
*/
|
|
553
|
+
async importFoldedSheets(workspaceId, body) {
|
|
554
|
+
return this.request(`/workspaces/${encodeURIComponent(workspaceId)}/databases/import/sheets`, {
|
|
555
|
+
method: 'POST',
|
|
556
|
+
body,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ── MIGRATION-GRACE — 초기 이관 동안 하루 조회 한도 풀기 ────────────────
|
|
561
|
+
|
|
562
|
+
async startMigrationGrace(workspaceId) {
|
|
563
|
+
return this.request(`/workspaces/${encodeURIComponent(workspaceId)}/billing/migration-grace`, {
|
|
564
|
+
method: 'POST',
|
|
565
|
+
body: {},
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async migrationGrace(workspaceId) {
|
|
570
|
+
return this.request(`/workspaces/${encodeURIComponent(workspaceId)}/billing/migration-grace`);
|
|
571
|
+
}
|
|
572
|
+
|
|
514
573
|
// ── PUBLIC-DOCS — 발행한 문서에 올라온 변경 제안 ────────────────────────
|
|
515
574
|
|
|
516
575
|
async listDocumentCommits(documentId, status = 'OPEN') {
|
|
@@ -927,10 +986,12 @@ export class RootrClient {
|
|
|
927
986
|
* DB-BULK — 여러 행을 **한 번에 넣는다**. 전부 아니면 전무다.
|
|
928
987
|
* 칸은 이름으로도 id로도 적을 수 있다.
|
|
929
988
|
*/
|
|
930
|
-
async bulkCreateRows(databaseId, rows) {
|
|
989
|
+
async bulkCreateRows(databaseId, rows, upsertKey) {
|
|
931
990
|
return this.request(`/databases/${encodeURIComponent(databaseId)}/rows/bulk`, {
|
|
932
991
|
method: 'POST',
|
|
933
|
-
|
|
992
|
+
// BULK-UPSERT — 열쇠 칸을 주면 이미 있는 행은 고치고 없는 것만 만든다.
|
|
993
|
+
// 재시도가 안전해야 외부 시스템 동기화를 만들 수 있다(2026-09-18 요청 5번).
|
|
994
|
+
body: { rows, ...(upsertKey ? { upsertKey } : {}) },
|
|
934
995
|
});
|
|
935
996
|
}
|
|
936
997
|
|
|
@@ -1018,18 +1079,22 @@ export class RootrClient {
|
|
|
1018
1079
|
// PATCH /databases/:id replaces the whole properties array, so rebuilding it
|
|
1019
1080
|
// client-side drops any column left out. These merge server-side instead.
|
|
1020
1081
|
|
|
1021
|
-
async addColumn(
|
|
1082
|
+
async addColumn(
|
|
1083
|
+
databaseId,
|
|
1084
|
+
{ name, type, options, relationDatabaseId, reverseName, formula, rollup, prefix } = {},
|
|
1085
|
+
) {
|
|
1022
1086
|
return this.request(`/databases/${encodeURIComponent(databaseId)}/columns`, {
|
|
1023
1087
|
method: 'POST',
|
|
1024
1088
|
// 계산 칸(수식·롤업·번호)은 설정이 곧 그 칸이라 같이 보내야 한다.
|
|
1025
|
-
|
|
1089
|
+
// reverseName 은 상대 표에 짝 칸을 만든다(양방향 관계).
|
|
1090
|
+
body: { name, type, options, relationDatabaseId, reverseName, formula, rollup, prefix },
|
|
1026
1091
|
});
|
|
1027
1092
|
}
|
|
1028
1093
|
|
|
1029
|
-
async updateColumn(databaseId, propertyId, { name, options } = {}) {
|
|
1094
|
+
async updateColumn(databaseId, propertyId, { name, options, formula, rollup, prefix, reverseName } = {}) {
|
|
1030
1095
|
return this.request(
|
|
1031
1096
|
`/databases/${encodeURIComponent(databaseId)}/columns/${encodeURIComponent(propertyId)}`,
|
|
1032
|
-
{ method: 'PATCH', body: { name, options } },
|
|
1097
|
+
{ method: 'PATCH', body: { name, options, formula, rollup, prefix, reverseName } },
|
|
1033
1098
|
);
|
|
1034
1099
|
}
|
|
1035
1100
|
|
|
@@ -198,9 +198,32 @@ export function registerDatabaseTools(server) {
|
|
|
198
198
|
'show_original',
|
|
199
199
|
'percent_checked',
|
|
200
200
|
]),
|
|
201
|
+
where: z
|
|
202
|
+
.array(
|
|
203
|
+
z.object({
|
|
204
|
+
property: z.string(),
|
|
205
|
+
op: z.enum([
|
|
206
|
+
'is',
|
|
207
|
+
'is_not',
|
|
208
|
+
'contains',
|
|
209
|
+
'not_contains',
|
|
210
|
+
'is_empty',
|
|
211
|
+
'is_not_empty',
|
|
212
|
+
'gt',
|
|
213
|
+
'gte',
|
|
214
|
+
'lt',
|
|
215
|
+
'lte',
|
|
216
|
+
]),
|
|
217
|
+
value: z.string().optional(),
|
|
218
|
+
}),
|
|
219
|
+
)
|
|
220
|
+
.optional()
|
|
221
|
+
.describe(
|
|
222
|
+
'Filter the LINKED rows before aggregating, e.g. [{ property: "결과", op: "is", value: "부재" }]',
|
|
223
|
+
),
|
|
201
224
|
})
|
|
202
225
|
.optional()
|
|
203
|
-
.describe('rollup only: what to gather through which relation'),
|
|
226
|
+
.describe('rollup only: what to gather through which relation (where? filters the linked rows first)'),
|
|
204
227
|
prefix: z.string().optional().describe('unique_id only: e.g. "TASK" → "TASK-42"'),
|
|
205
228
|
}),
|
|
206
229
|
)
|
|
@@ -564,20 +587,32 @@ export function registerDatabaseTools(server) {
|
|
|
564
587
|
'Add MANY rows to a Rootr (루터) DATABASE in one call. Use this instead of calling rootr_add_row in a ' +
|
|
565
588
|
'loop — a loop leaves a half-filled table when one call fails, and burns the read quota. ' +
|
|
566
589
|
'All-or-nothing: if any row fails validation nothing is created, and the error says which index broke. ' +
|
|
567
|
-
'Column keys may be property NAMES or ids. Up to 500 rows per call
|
|
590
|
+
'Column keys may be property NAMES or ids. Up to 500 rows per call.\n' +
|
|
591
|
+
'Pass `upsertKey` (a column name or id) to make the call IDEMPOTENT: a row whose key already exists is ' +
|
|
592
|
+
'UPDATED instead of duplicated, so a retried or replayed batch cannot double-load. Use it for every ' +
|
|
593
|
+
'migration and every sync from an outside system — without it a retry after a partial failure silently ' +
|
|
594
|
+
'creates duplicates (a real 903-row incident). The key column must be a plain stored column ' +
|
|
595
|
+
'(text/number/date/select), not a formula, rollup or relation. Returns `created` and `updated`.',
|
|
568
596
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
569
597
|
inputSchema: {
|
|
570
598
|
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
571
599
|
rows: z
|
|
572
600
|
.array(z.record(z.any()))
|
|
573
601
|
.describe('Each entry is one row: { "칸 이름 또는 id": 값, … }'),
|
|
602
|
+
upsertKey: z
|
|
603
|
+
.string()
|
|
604
|
+
.optional()
|
|
605
|
+
.describe(
|
|
606
|
+
'Column (name or id) holding an external id. Rows whose key already exists are updated, not duplicated — ' +
|
|
607
|
+
'makes the call safe to retry.',
|
|
608
|
+
),
|
|
574
609
|
},
|
|
575
610
|
},
|
|
576
|
-
async ({ databaseId, rows }) => {
|
|
611
|
+
async ({ databaseId, rows, upsertKey }) => {
|
|
577
612
|
try {
|
|
578
613
|
const client = makeClient();
|
|
579
614
|
const id = await resolveNodeId(client, databaseId);
|
|
580
|
-
return jsonResult(await client.bulkCreateRows(id, rows));
|
|
615
|
+
return jsonResult(await client.bulkCreateRows(id, rows, upsertKey));
|
|
581
616
|
} catch (err) {
|
|
582
617
|
return errorResult(err);
|
|
583
618
|
}
|
|
@@ -813,10 +848,20 @@ export function registerDatabaseTools(server) {
|
|
|
813
848
|
'relation | formula | rollup | created_time | last_edited_time | created_by | last_edited_by | unique_id ' +
|
|
814
849
|
'("title" is not allowed: a database already has exactly one title column). ' +
|
|
815
850
|
'For select/multi_select/status pass options: [{ name, color, group? }] (group ∈ todo|in_progress|done, status only). ' +
|
|
816
|
-
'For relation pass relationDatabaseId (the node id of the table it points at)
|
|
851
|
+
'For relation pass relationDatabaseId (the node id of the table it points at), and reverseName to make it ' +
|
|
852
|
+
'TWO-WAY: the other table gets a matching column with that name, and filling either side fills the other. ' +
|
|
853
|
+
'Use it whenever both tables need the link (calls↔applicants) — a one-way column means aggregates on the ' +
|
|
854
|
+
'other side are silently wrong until someone backfills them by hand. ' +
|
|
817
855
|
'For formula pass formula, e.g. "{수량} * {단가}" or "if({금액} >= 5000, \\"큰 건\\", \\"작은 건\\")" — ' +
|
|
818
856
|
'available functions: if, concat, length, upper, lower, contains, abs, round, floor, ceil, min, max, dateBetween, now, empty. ' +
|
|
819
|
-
'For rollup pass rollup: { relation, property?, function }
|
|
857
|
+
'For rollup pass rollup: { relation, property?, function, where? } ' +
|
|
858
|
+
'(function ∈ count|count_values|sum|average|min|max|show_original|percent_checked). ' +
|
|
859
|
+
'`where` filters the LINKED rows before aggregating — [{ property, op, value? }] with ' +
|
|
860
|
+
'op ∈ is|is_not|contains|not_contains|is_empty|is_not_empty|gt|gte|lt|lte. That is how you build ' +
|
|
861
|
+
'"how many of this applicant\'s calls were no-answer": { relation: "콜", function: "count", ' +
|
|
862
|
+
'where: [{ property: "결과", op: "is", value: "부재" }] }. ' +
|
|
863
|
+
'A rollup may also read a FORMULA column in the linked table, and min/max work on date columns ' +
|
|
864
|
+
'(that is how "last call date" is built). ' +
|
|
820
865
|
'For unique_id pass prefix to get "TASK-42" style ids. ' +
|
|
821
866
|
'created_time / last_edited_time / created_by / last_edited_by / unique_id / formula / rollup are filled in by the server — ' +
|
|
822
867
|
'they take no value and rootr_update_row will refuse to set them. ' +
|
|
@@ -857,6 +902,13 @@ export function registerDatabaseTools(server) {
|
|
|
857
902
|
.string()
|
|
858
903
|
.optional()
|
|
859
904
|
.describe('relation only: the node id of the database this column points at'),
|
|
905
|
+
reverseName: z
|
|
906
|
+
.string()
|
|
907
|
+
.optional()
|
|
908
|
+
.describe(
|
|
909
|
+
'relation only: make it TWO-WAY — the linked table gets a matching column with this name, and filling ' +
|
|
910
|
+
'one side fills the other. Needs write access to that table too.',
|
|
911
|
+
),
|
|
860
912
|
formula: z
|
|
861
913
|
.string()
|
|
862
914
|
.optional()
|
|
@@ -875,6 +927,29 @@ export function registerDatabaseTools(server) {
|
|
|
875
927
|
'show_original',
|
|
876
928
|
'percent_checked',
|
|
877
929
|
]),
|
|
930
|
+
where: z
|
|
931
|
+
.array(
|
|
932
|
+
z.object({
|
|
933
|
+
property: z.string(),
|
|
934
|
+
op: z.enum([
|
|
935
|
+
'is',
|
|
936
|
+
'is_not',
|
|
937
|
+
'contains',
|
|
938
|
+
'not_contains',
|
|
939
|
+
'is_empty',
|
|
940
|
+
'is_not_empty',
|
|
941
|
+
'gt',
|
|
942
|
+
'gte',
|
|
943
|
+
'lt',
|
|
944
|
+
'lte',
|
|
945
|
+
]),
|
|
946
|
+
value: z.string().optional(),
|
|
947
|
+
}),
|
|
948
|
+
)
|
|
949
|
+
.optional()
|
|
950
|
+
.describe(
|
|
951
|
+
'Filter the LINKED rows before aggregating, e.g. [{ property: "결과", op: "is", value: "부재" }]',
|
|
952
|
+
),
|
|
878
953
|
})
|
|
879
954
|
.optional()
|
|
880
955
|
.describe('rollup only: what to gather through which relation'),
|
|
@@ -906,9 +981,14 @@ export function registerDatabaseTools(server) {
|
|
|
906
981
|
{
|
|
907
982
|
title: 'Rename a column / set its options',
|
|
908
983
|
description:
|
|
909
|
-
'Rename ONE column of a Rootr (루터) DATABASE,
|
|
984
|
+
'Rename ONE column of a Rootr (루터) DATABASE, replace the choices of a select/multi_select column, edit a ' +
|
|
985
|
+
'formula or rollup config, or turn an existing relation column TWO-WAY. ' +
|
|
910
986
|
'The column TYPE cannot be changed — that would rewrite every row — so add a new column and move the values instead. ' +
|
|
911
|
-
'Call rootr_read_database first to get the exact propertyId
|
|
987
|
+
'Call rootr_read_database first to get the exact propertyId.\n' +
|
|
988
|
+
'`reverseName` on a relation column pairs it with the linked table: that table gets a matching column with ' +
|
|
989
|
+
'this name and filling either side fills the other. Pass an empty string to unpair (neither column is deleted). ' +
|
|
990
|
+
'Use it to make an existing one-way link two-way rather than rebuilding the table — a one-way column leaves ' +
|
|
991
|
+
'every aggregate on the other side wrong until somebody backfills it by hand.',
|
|
912
992
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
913
993
|
inputSchema: {
|
|
914
994
|
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
@@ -918,13 +998,30 @@ export function registerDatabaseTools(server) {
|
|
|
918
998
|
.array(z.object({ name: z.string(), color: z.string() }))
|
|
919
999
|
.optional()
|
|
920
1000
|
.describe('select/multi_select only: REPLACES the full choice list'),
|
|
1001
|
+
formula: z.string().optional().describe('formula only: the new expression'),
|
|
1002
|
+
rollup: z
|
|
1003
|
+
.record(z.any())
|
|
1004
|
+
.optional()
|
|
1005
|
+
.describe('rollup only: { relation, property?, function, where? } — replaces the whole config'),
|
|
1006
|
+
prefix: z.string().optional().describe('unique_id only: the new prefix'),
|
|
1007
|
+
reverseName: z
|
|
1008
|
+
.string()
|
|
1009
|
+
.optional()
|
|
1010
|
+
.describe('relation only: pair it two-way under this name in the linked table; "" unpairs'),
|
|
921
1011
|
},
|
|
922
1012
|
},
|
|
923
|
-
async ({ databaseId, propertyId, name, options }) => {
|
|
1013
|
+
async ({ databaseId, propertyId, name, options, formula, rollup, prefix, reverseName }) => {
|
|
924
1014
|
try {
|
|
925
1015
|
const client = makeClient();
|
|
926
1016
|
const id = await resolveNodeId(client, databaseId);
|
|
927
|
-
const property = await client.updateColumn(id, propertyId, {
|
|
1017
|
+
const property = await client.updateColumn(id, propertyId, {
|
|
1018
|
+
name,
|
|
1019
|
+
options,
|
|
1020
|
+
formula,
|
|
1021
|
+
rollup,
|
|
1022
|
+
prefix,
|
|
1023
|
+
reverseName,
|
|
1024
|
+
});
|
|
928
1025
|
return jsonResult({ property });
|
|
929
1026
|
} catch (err) {
|
|
930
1027
|
return errorResult(err);
|
|
@@ -1123,14 +1220,20 @@ export function registerDatabaseTools(server) {
|
|
|
1123
1220
|
'dropped. Up to 12 buttons, 8 steps each.\n' +
|
|
1124
1221
|
'Steps run in order and the row a step created is what the NEXT step works on, which is how ' +
|
|
1125
1222
|
'"add a row → have AI write its summary → open it" becomes one press:\n' +
|
|
1126
|
-
'· {kind:"add_row", values?} — make a row, optionally pre-filled. Column keys may be
|
|
1223
|
+
'· {kind:"add_row", values?, database?, link?} — make a row, optionally pre-filled. Column keys may be ' +
|
|
1224
|
+
'NAMES or ids. `database` (a table id or tree path like "/01. 채용/콜 이력") puts the row in ANOTHER ' +
|
|
1225
|
+
'table — omit it for this one. `link: { relation, from: "selected"|"created" }` wires the new row to the ' +
|
|
1226
|
+
'row the person pressed on, where `relation` is the relation column IN THE TARGET table that points back ' +
|
|
1227
|
+
'here. That pair is how "press 전화 on an applicant → a call row already linked to them" becomes one ' +
|
|
1228
|
+
'press instead of switching tables and searching the person again.\n' +
|
|
1127
1229
|
'· {kind:"update_rows", target:"created"|"selected", values} — set values on the rows this button just ' +
|
|
1128
1230
|
'made, or on the rows the person ticked in the table.\n' +
|
|
1129
1231
|
'· {kind:"ai_fill", target, propId, prompt} — let AI write ONE cell. The prompt may quote the row with ' +
|
|
1130
1232
|
'{칸이름}, e.g. "{제목}과 {메모}를 한 문장으로 요약해". If the AI fails the button keeps going.\n' +
|
|
1131
1233
|
'· {kind:"open_row"} — open the row this button made (UI only; over the API it just reports the id).\n' +
|
|
1132
1234
|
'Values accept @today, @today+7 and @me — the server fills them in at press time, not now.\n' +
|
|
1133
|
-
'A button never grants permission: it runs as whoever presses it
|
|
1235
|
+
'A button never grants permission: it runs as whoever presses it — writing into another table needs ' +
|
|
1236
|
+
'write access there too.',
|
|
1134
1237
|
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
1135
1238
|
inputSchema: {
|
|
1136
1239
|
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
@@ -1168,7 +1271,8 @@ export function registerDatabaseTools(server) {
|
|
|
1168
1271
|
'Pass `rowIds` only when a step targets "selected" — that is the set of rows the person would have ' +
|
|
1169
1272
|
'ticked in the table; without it such a step reaches no rows and the server says so. ' +
|
|
1170
1273
|
'Returns `created` (the ARRAY of row ids it made, not a count), `updated` and `filled` counts, the ' +
|
|
1171
|
-
'`openRowId` a final "open_row" step points at
|
|
1274
|
+
'`openRowId` a final "open_row" step points at (with `openDatabaseId` when that row is in another ' +
|
|
1275
|
+
'table), `createdIn` telling which table each new row landed in, and — if the AI could not write a cell — `aiFailed` ' +
|
|
1172
1276
|
'with the reason (the rest of the button still ran). ' +
|
|
1173
1277
|
'It runs with YOUR permissions: a button that writes a locked column stops there if you cannot edit it.',
|
|
1174
1278
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
@@ -1191,4 +1295,149 @@ export function registerDatabaseTools(server) {
|
|
|
1191
1295
|
}
|
|
1192
1296
|
},
|
|
1193
1297
|
);
|
|
1298
|
+
|
|
1299
|
+
// ---- DB-AUTO: "값이 이렇게 되면 이 일을 해라" ----------------------------
|
|
1300
|
+
// 2026-09-18 클린베테랑 요청 4번. 버튼과 같은 단계 문법을 쓰되 사람이 안 눌러도 돈다.
|
|
1301
|
+
|
|
1302
|
+
server.registerTool(
|
|
1303
|
+
'rootr_list_database_automations',
|
|
1304
|
+
{
|
|
1305
|
+
title: 'List the automatic rules on a database',
|
|
1306
|
+
description:
|
|
1307
|
+
'Show the automatic rules of a Rootr (루터) DATABASE — "when a value becomes X, do Y". Call this before ' +
|
|
1308
|
+
'rootr_set_database_automations, which replaces the whole list.',
|
|
1309
|
+
annotations: { readOnlyHint: true },
|
|
1310
|
+
inputSchema: { databaseId: z.string().describe(DATABASE_TARGET_DESC) },
|
|
1311
|
+
},
|
|
1312
|
+
async ({ databaseId }) => {
|
|
1313
|
+
try {
|
|
1314
|
+
const client = makeClient();
|
|
1315
|
+
const id = await resolveNodeId(client, databaseId);
|
|
1316
|
+
return jsonResult(await client.listDatabaseAutomations(id));
|
|
1317
|
+
} catch (err) {
|
|
1318
|
+
return errorResult(err);
|
|
1319
|
+
}
|
|
1320
|
+
},
|
|
1321
|
+
);
|
|
1322
|
+
|
|
1323
|
+
server.registerTool(
|
|
1324
|
+
'rootr_set_database_automations',
|
|
1325
|
+
{
|
|
1326
|
+
title: 'Create or edit the automatic rules on a database',
|
|
1327
|
+
description:
|
|
1328
|
+
'Replace the WHOLE automatic-rule list of a Rootr (루터) DATABASE (needs MANAGE). Full replace, not a ' +
|
|
1329
|
+
'merge — send back the rules you want to KEEP. Up to 10 rules, 8 steps each.\n' +
|
|
1330
|
+
'A rule is { label, enabled?, on, when?, schedule?, steps } and the steps are the SAME ones a button ' +
|
|
1331
|
+
'uses (add_row / update_rows / ai_fill), so there is nothing new to learn. The row that woke the rule ' +
|
|
1332
|
+
'arrives in the steps as target "selected".\n' +
|
|
1333
|
+
'· on: ["updated"] | ["created"] | ["schedule"] (one or more).\n' +
|
|
1334
|
+
'· when: { property, op, value? } — op ∈ changed_to|changed|is|is_not|contains|gt|gte|lt|lte|is_empty|is_not_empty. ' +
|
|
1335
|
+
'Prefer `changed_to` for state transitions: "is" is true on EVERY later edit too, so a rule that creates ' +
|
|
1336
|
+
'follow-up rows would create them again and again.\n' +
|
|
1337
|
+
'· schedule: { at: "09:00", days?: [1,2,3,4,5] } — required when on includes "schedule". Korean time, ' +
|
|
1338
|
+
'once a day; `when` then picks WHICH rows to act on and may use @today / @today+7 in its value.\n' +
|
|
1339
|
+
'Typical uses from the field: 투입확정 → create three follow-up-call rows at +7/+30/+90 days; ' +
|
|
1340
|
+
'부재 3회 초과 → set the applicant to 보류; every morning at 09:00 → flag rows whose 재통화 예정일 is today.\n' +
|
|
1341
|
+
'Rules run as the person who SAVED them, never with more permission than that. A rule that a computed ' +
|
|
1342
|
+
'column (rollup/formula) triggers must use a value comparison, not changed/changed_to — the server ' +
|
|
1343
|
+
'refuses those instead of quietly never firing.',
|
|
1344
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
1345
|
+
inputSchema: {
|
|
1346
|
+
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
1347
|
+
automations: z
|
|
1348
|
+
.array(
|
|
1349
|
+
z.object({
|
|
1350
|
+
id: z.string().optional().describe('Keep an existing rule by passing its id; omit to create one'),
|
|
1351
|
+
label: z.string().describe('What this rule is for, e.g. "투입확정 해피콜 3건"'),
|
|
1352
|
+
enabled: z.boolean().optional().describe('false pauses it without deleting it'),
|
|
1353
|
+
on: z
|
|
1354
|
+
.array(z.enum(['created', 'updated', 'schedule']))
|
|
1355
|
+
.optional()
|
|
1356
|
+
.describe('When it wakes up. Defaults to ["updated"].'),
|
|
1357
|
+
when: z
|
|
1358
|
+
.object({
|
|
1359
|
+
property: z.string().describe('Column to watch (name or id)'),
|
|
1360
|
+
op: z.enum([
|
|
1361
|
+
'changed_to',
|
|
1362
|
+
'changed',
|
|
1363
|
+
'is',
|
|
1364
|
+
'is_not',
|
|
1365
|
+
'contains',
|
|
1366
|
+
'gt',
|
|
1367
|
+
'gte',
|
|
1368
|
+
'lt',
|
|
1369
|
+
'lte',
|
|
1370
|
+
'is_empty',
|
|
1371
|
+
'is_not_empty',
|
|
1372
|
+
]),
|
|
1373
|
+
value: z.string().optional(),
|
|
1374
|
+
})
|
|
1375
|
+
.optional()
|
|
1376
|
+
.describe('Omit to run on every matching event'),
|
|
1377
|
+
schedule: z
|
|
1378
|
+
.object({
|
|
1379
|
+
at: z.string().describe('"HH:MM", Korean time'),
|
|
1380
|
+
days: z.array(z.number()).optional().describe('0=Sun … 6=Sat; omit for every day'),
|
|
1381
|
+
})
|
|
1382
|
+
.optional(),
|
|
1383
|
+
steps: z
|
|
1384
|
+
.array(z.record(z.any()))
|
|
1385
|
+
.describe('Ordered steps — same syntax as a button (max 8; "open_row" is not allowed here)'),
|
|
1386
|
+
}),
|
|
1387
|
+
)
|
|
1388
|
+
.describe('The COMPLETE rule list for this table. [] removes every rule.'),
|
|
1389
|
+
},
|
|
1390
|
+
},
|
|
1391
|
+
async ({ databaseId, automations }) => {
|
|
1392
|
+
try {
|
|
1393
|
+
const client = makeClient();
|
|
1394
|
+
const id = await resolveNodeId(client, databaseId);
|
|
1395
|
+
return jsonResult(await client.setDatabaseAutomations(id, automations));
|
|
1396
|
+
} catch (err) {
|
|
1397
|
+
return errorResult(err);
|
|
1398
|
+
}
|
|
1399
|
+
},
|
|
1400
|
+
);
|
|
1401
|
+
|
|
1402
|
+
// ---- XLSX-FOLD: 시트 복제형 엑셀을 표 하나로 --------------------------
|
|
1403
|
+
|
|
1404
|
+
server.registerTool(
|
|
1405
|
+
'rootr_import_folded_sheets',
|
|
1406
|
+
{
|
|
1407
|
+
title: 'Import a sheet-per-record Excel file as ONE table',
|
|
1408
|
+
description:
|
|
1409
|
+
'Fold a workbook that keeps ONE SHEET PER RECORD (783 sheets, one per site; 1,281, one per interview) ' +
|
|
1410
|
+
'into a single Rootr (루터) DATABASE. Very common in small-company Excel, and until now it needed a ' +
|
|
1411
|
+
'hand-written parser.\n' +
|
|
1412
|
+
'· mode "vertical" — one sheet IS one row: label in the left cell, value to its right. Sheet name is kept ' +
|
|
1413
|
+
'as a column.\n' +
|
|
1414
|
+
'· mode "header" — every sheet repeats the same header row; all their data rows are merged and the sheet ' +
|
|
1415
|
+
'name becomes a column (that is the month, or the branch).\n' +
|
|
1416
|
+
'Omit mode and the server works it out from the file. ALWAYS call with dryRun:true first and show the ' +
|
|
1417
|
+
'person the detected mode, columns and the first rows — folding 783 sheets the wrong way is far more ' +
|
|
1418
|
+
'expensive to undo than to check. Pass upsertKey to make re-imports idempotent.',
|
|
1419
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
1420
|
+
inputSchema: {
|
|
1421
|
+
workspaceId: z.string().describe('Workspace id'),
|
|
1422
|
+
xlsx: z.string().describe('The .xlsx file, base64-encoded'),
|
|
1423
|
+
dryRun: z.boolean().optional().describe('true = show what would happen, create nothing'),
|
|
1424
|
+
mode: z.enum(['vertical', 'header']).optional(),
|
|
1425
|
+
sheetNameColumn: z.string().optional().describe('Column name for the sheet name'),
|
|
1426
|
+
skipSheets: z.array(z.string()).optional().describe('Sheet names to ignore (템플릿·설명 sheets)'),
|
|
1427
|
+
headerRow: z.number().optional().describe('header mode: which row holds the headers (1-based)'),
|
|
1428
|
+
name: z.string().optional().describe('Name for the new table'),
|
|
1429
|
+
parentId: z.string().optional().describe('Folder to create it under'),
|
|
1430
|
+
databaseId: z.string().optional().describe('Load into an EXISTING table instead of creating one'),
|
|
1431
|
+
upsertKey: z.string().optional().describe('Column holding an external id, so re-imports update instead of duplicate'),
|
|
1432
|
+
},
|
|
1433
|
+
},
|
|
1434
|
+
async ({ workspaceId, ...rest }) => {
|
|
1435
|
+
try {
|
|
1436
|
+
const client = makeClient();
|
|
1437
|
+
return jsonResult(await client.importFoldedSheets(workspaceId, rest));
|
|
1438
|
+
} catch (err) {
|
|
1439
|
+
return errorResult(err);
|
|
1440
|
+
}
|
|
1441
|
+
},
|
|
1442
|
+
);
|
|
1194
1443
|
}
|
package/lib/mcp-tools/forms.js
CHANGED
|
@@ -355,4 +355,64 @@ export function registerFormTools(server) {
|
|
|
355
355
|
}
|
|
356
356
|
},
|
|
357
357
|
);
|
|
358
|
+
|
|
359
|
+
// ---- FORM-MAP: 질문이 표의 어느 칸으로 들어가는가 ---------------------
|
|
360
|
+
// 2026-09-18 클린베테랑 요청 6번: 질문 이름과 칸 이름이 다르면 값이 조용히 빠졌고,
|
|
361
|
+
// 폼 10개 전부가 그래서 "응답은 들어오는데 행이 안 생기는" 상태였다.
|
|
362
|
+
|
|
363
|
+
server.registerTool(
|
|
364
|
+
'rootr_form_mapping',
|
|
365
|
+
{
|
|
366
|
+
title: 'Show which table column each form question feeds',
|
|
367
|
+
description:
|
|
368
|
+
'For a Rootr (루터) FORM linked to a DATABASE, show question → column for every question, and — the ' +
|
|
369
|
+
'point of this tool — `unmatched`: the questions with NO column, whose answers are silently dropped ' +
|
|
370
|
+
'when someone submits. Check this right after linking a form to a table or renaming anything: a ' +
|
|
371
|
+
'mismatch produces no error anywhere, and the loss is usually found weeks later. ' +
|
|
372
|
+
'`how` is "manual" (someone chose it), "auto" (names matched) or "none" (dropped). ' +
|
|
373
|
+
'`lastError` carries why the most recent submission failed to reach the table, if it did.',
|
|
374
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
375
|
+
inputSchema: {
|
|
376
|
+
formId: z.string().describe(`FORM node id${NODE_TARGET_HINT}`),
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
async ({ formId }) => {
|
|
380
|
+
try {
|
|
381
|
+
const client = makeClient();
|
|
382
|
+
formId = await resolveNodeId(client, formId);
|
|
383
|
+
return jsonResult(await client.formMapping(formId));
|
|
384
|
+
} catch (err) {
|
|
385
|
+
return errorResult(err);
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
server.registerTool(
|
|
391
|
+
'rootr_set_form_mapping',
|
|
392
|
+
{
|
|
393
|
+
title: 'Wire form questions to table columns by hand',
|
|
394
|
+
description:
|
|
395
|
+
'Pin which DATABASE column each question of a Rootr (루터) FORM writes into: { "<fieldId>": "<columnId>" }. ' +
|
|
396
|
+
'Questions you leave out keep matching BY NAME, and passing null for one puts it back to automatic. ' +
|
|
397
|
+
'Use this whenever the question wording differs from the column header ("성함" vs "이름") — otherwise ' +
|
|
398
|
+
'that answer is dropped with no warning. Call rootr_form_mapping first for the ids and the current state. ' +
|
|
399
|
+
'A field or column that does not exist is refused, not ignored.',
|
|
400
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
401
|
+
inputSchema: {
|
|
402
|
+
formId: z.string().describe(`FORM node id${NODE_TARGET_HINT}`),
|
|
403
|
+
fieldMap: z
|
|
404
|
+
.record(z.string().nullable())
|
|
405
|
+
.describe('{ "<질문 id>": "<칸 id>" } — null puts that question back to name matching'),
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
async ({ formId, fieldMap }) => {
|
|
409
|
+
try {
|
|
410
|
+
const client = makeClient();
|
|
411
|
+
formId = await resolveNodeId(client, formId);
|
|
412
|
+
return jsonResult(await client.setFormMapping(formId, fieldMap));
|
|
413
|
+
} catch (err) {
|
|
414
|
+
return errorResult(err);
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
);
|
|
358
418
|
}
|