rootr-cli 0.30.1 → 0.31.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 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] 버튼 한 번 누르기 (행 만들기·값 채우기·AI 채우기)
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] [--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] press a button once (create rows, fill values, AI fill)
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] [--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,26 @@ 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['entry-row'] !== undefined) patch.entryRow = onOff('entry-row', args['entry-row']);
3005
+ if (typeof args.quick === 'string') {
3006
+ const quick = args.quick.split(',').map((c) => c.trim()).filter(Boolean);
3007
+ patch.quickFilters = quick.length ? quick : null;
3008
+ }
3009
+ if (args['day-pager'] !== undefined) {
3010
+ const v = String(args['day-pager']).trim();
3011
+ patch.dayPager = !v || v === 'off' || v === 'false' ? null : { dateProp: v };
3012
+ }
2963
3013
  if (!Object.keys(patch).length) {
2964
- throw new Error(tr('바꿀 것을 주세요: --scale week|month|year, --name 새이름, --cards "칸,칸", --density compact|normal 또는 --freeze 0~3', `Give something to change: --scale week|month|year, --name <new name>, --cards "field,field", --density compact|normal, or --freeze 0-3`));
3014
+ 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 또는 --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, or --day-pager <date field>|off`));
2965
3015
  }
2966
3016
 
2967
3017
  const view = await client.updateDatabaseView(id, viewRef, patch);
@@ -3094,6 +3144,11 @@ async function cmdDbButtons(args) {
3094
3144
  console.log(`${b.icon ? b.icon + ' ' : ''}${b.label} (${b.id})`);
3095
3145
  for (const [i, st] of (b.steps ?? []).entries()) {
3096
3146
  const what = WHAT[st.kind] ?? st.kind;
3147
+ if (i === 0 && b.input) {
3148
+ // BTN-INPUT — 누르면 글부터 받는 버튼. button-run 에 --input 이 필요하다는 것을 여기서 보인다.
3149
+ const split = b.input.split ? ` · ${tr('나누기', 'split')}: ${b.input.split.by}${b.input.split.separator ? ` "${b.input.split.separator}"` : ''}` : '';
3150
+ console.log(` ${tr('글 받기', 'asks for text')}: "${b.input.label}"${split} ${tr('(button-run --input)', '(button-run --input)')}`);
3151
+ }
3097
3152
  const where = st.target ? ` · ${WHERE[st.target] ?? st.target}` : '';
3098
3153
  const detail =
3099
3154
  st.kind === 'ai_fill'
@@ -3119,7 +3174,7 @@ async function cmdDbButtonRun(args) {
3119
3174
  const target = args._[1];
3120
3175
  const buttonId = args._[2];
3121
3176
  if (!target || !buttonId) {
3122
- throw new Error(usage(`rootr db button-run <path|id> <buttonId> [--rows id1,id2] [--json]`));
3177
+ throw new Error(usage(`rootr db button-run <path|id> <buttonId> [--rows id1,id2] [--input <글> | --input-file <파일> | --input -] [--json]`));
3123
3178
  }
3124
3179
  const client = makeClient();
3125
3180
  const id = await resolveDatabaseId(client, target);
@@ -3127,8 +3182,17 @@ async function cmdDbButtonRun(args) {
3127
3182
  typeof args.rows === 'string'
3128
3183
  ? args.rows.split(',').map((x) => x.trim()).filter(Boolean)
3129
3184
  : undefined;
3130
-
3131
- const r = await client.runDatabaseButton(id, buttonId, { rowIds });
3185
+ // BTN-INPUT — 글을 받는 버튼에 넣을 글. 여러 줄 원문은 파일이나 표준입력(`--input -`)으로.
3186
+ const input =
3187
+ typeof args['input-file'] === 'string'
3188
+ ? readFileSync(args['input-file'], 'utf8')
3189
+ : args.input === '-'
3190
+ ? await readStdin()
3191
+ : typeof args.input === 'string'
3192
+ ? args.input
3193
+ : undefined;
3194
+
3195
+ const r = await client.runDatabaseButton(id, buttonId, { rowIds, input });
3132
3196
  if (args.json) {
3133
3197
  console.log(JSON.stringify(r, null, 2));
3134
3198
  return;
@@ -3154,6 +3218,11 @@ async function cmdDbButtonRun(args) {
3154
3218
  );
3155
3219
  for (const n of r.notes ?? []) console.log(`· ${n}`);
3156
3220
  if (r.linkWarning) console.log(`· ${r.linkWarning}`);
3221
+ // BTN-INPUT — 여러 건으로 나눠 돌렸으면 몇 건이 됐는지, 막힌 건은 몇 번째가 왜.
3222
+ if (r.records) {
3223
+ console.log(tr(`${r.records.total}건 중 ${r.records.done}건 실행`, `${r.records.done} of ${r.records.total} items ran`));
3224
+ for (const e of r.records.errors ?? []) console.log(tr(`· ${e.index}번째 건 실패 — ${e.message}`, `· item ${e.index} failed — ${e.message}`));
3225
+ }
3157
3226
  }
3158
3227
 
3159
3228
 
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, 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, 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,50 @@ 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
+ .boolean()
109
+ .nullable()
110
+ .optional()
111
+ .describe(
112
+ 'table view: keep one always-empty entry row at the bottom (spreadsheet-like). Typing a value into any of its cells ' +
113
+ 'creates the row (pre-filled to match the view filters, like the "new row" button) and a fresh empty row appears. ' +
114
+ 'Focus/click alone creates nothing. false/null = off.',
115
+ ),
116
+ dayPager: z
117
+ .union([z.object({ dateProp: z.string() }), z.string()])
118
+ .nullable()
119
+ .optional()
120
+ .describe(
121
+ 'table view: show ONE DAY at a time — {dateProp: "<date column name or id>"} (must be a date column). A bar with ' +
122
+ '◀ date ▶ / Today / date picker appears; only rows whose date falls on the chosen day are listed (combined with the ' +
123
+ 'view filters, applied server-side so counts/paging stay right on big tables), rows created in the view get that ' +
124
+ 'day, and "search all dates" lifts the day limit. View filters on that same date column are replaced by the pager. null = off.',
125
+ ),
126
+ };
127
+
84
128
  /**
85
129
  * DB-BODY — 행의 상세 내용(본문 메모). 화면에서 칸반 카드를 열면 속성 아래에 붙는 그 글이며,
86
130
  * 실체는 표 밑에 달리는 문서 하나다. 예전에는 REST 2단계로만 닿을 수 있어서 CLI·MCP에서는
@@ -197,6 +241,7 @@ export function registerDatabaseTools(server) {
197
241
  .object({
198
242
  relation: z.string(),
199
243
  property: z.string().optional(),
244
+ 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
245
  function: z.enum([
201
246
  'count',
202
247
  'count_values',
@@ -342,9 +387,10 @@ export function registerDatabaseTools(server) {
342
387
  'table view: keep the first N VISIBLE columns fixed on the left while scrolling sideways (Excel freeze panes). ' +
343
388
  '0/null = off. e.g. 1 keeps the title column (상호명) in place.',
344
389
  ),
390
+ ...VIEW_EXTRA_SHAPE,
345
391
  },
346
392
  },
347
- async ({ databaseId, name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale, density, frozenColumns }) => {
393
+ async ({ databaseId, name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale, density, frozenColumns, rowPaneShowHidden, quickFilters, entryRow, dayPager }) => {
348
394
  try {
349
395
  const client = makeClient();
350
396
  const id = await resolveNodeId(client, databaseId);
@@ -360,6 +406,10 @@ export function registerDatabaseTools(server) {
360
406
  calendarScale,
361
407
  density,
362
408
  frozenColumns,
409
+ rowPaneShowHidden,
410
+ quickFilters,
411
+ entryRow,
412
+ dayPager,
363
413
  });
364
414
  return jsonResult(view);
365
415
  } catch (err) {
@@ -404,7 +454,9 @@ export function registerDatabaseTools(server) {
404
454
  'Columns may be given by NAME or id; the title column cannot be hidden and is ignored if listed. ' +
405
455
  'Pass an empty array to show everything again. Only the fields you pass change, and other views are untouched. ' +
406
456
  '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).',
457
+ 'columns stay fixed while scrolling sideways). Data-entry helpers: rowPaneShowHidden (hidden columns still show when a row ' +
458
+ 'is opened), quickFilters (columns pinned at the top of the filter panel, just type), entryRow (always-empty bottom row, ' +
459
+ 'Excel-like) and dayPager {dateProp} (one day per page with ◀ ▶ / Today, new rows get that day).',
408
460
  annotations: { readOnlyHint: false, destructiveHint: false },
409
461
  inputSchema: {
410
462
  databaseId: z.string().describe(DATABASE_TARGET_DESC),
@@ -474,6 +526,7 @@ export function registerDatabaseTools(server) {
474
526
  'table view: keep the first N VISIBLE columns fixed on the left while scrolling sideways (Excel freeze panes). ' +
475
527
  '0/null = off. e.g. 1 keeps the title column (상호명) in place.',
476
528
  ),
529
+ ...VIEW_EXTRA_SHAPE,
477
530
  },
478
531
  },
479
532
  async ({
@@ -492,6 +545,10 @@ export function registerDatabaseTools(server) {
492
545
  rowColors,
493
546
  density,
494
547
  frozenColumns,
548
+ rowPaneShowHidden,
549
+ quickFilters,
550
+ entryRow,
551
+ dayPager,
495
552
  }) => {
496
553
  try {
497
554
  const client = makeClient();
@@ -510,6 +567,10 @@ export function registerDatabaseTools(server) {
510
567
  rowColors,
511
568
  density,
512
569
  frozenColumns,
570
+ rowPaneShowHidden,
571
+ quickFilters,
572
+ entryRow,
573
+ dayPager,
513
574
  });
514
575
  return jsonResult({ view });
515
576
  } catch (err) {
@@ -980,6 +1041,8 @@ export function registerDatabaseTools(server) {
980
1041
  '"how many of this applicant\'s calls were no-answer": { relation: "콜", function: "count", ' +
981
1042
  'where: [{ property: "결과", op: "is", value: "부재" }] }. ' +
982
1043
  'A rollup may also read a FORMULA column in the linked table, and min/max work on date columns ' +
1044
+ '(sameKey: "<key column>" also gathers the links of the other rows with the same key value — e.g. "all calls of this person" when ' +
1045
+ 'the same phone has several rows; nothing is copied, each linked row counts once) ' +
983
1046
  '(that is how "last call date" is built). ' +
984
1047
  'For unique_id pass prefix to get "TASK-42" style ids. ' +
985
1048
  'For vector pass dimensions — an embedding column YOU fill with rootr_set_row_vectors and search with ' +
@@ -1040,6 +1103,7 @@ export function registerDatabaseTools(server) {
1040
1103
  rollup: z
1041
1104
  .object({
1042
1105
  relation: z.string().describe('the relation column (id or name) to follow'),
1106
+ 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
1107
  property: z.string().optional().describe('the column to read in the linked table'),
1044
1108
  function: z.enum([
1045
1109
  'count',
@@ -1194,7 +1258,7 @@ export function registerDatabaseTools(server) {
1194
1258
  rollup: z
1195
1259
  .record(z.any())
1196
1260
  .optional()
1197
- .describe('rollup only: { relation, property?, function, where? } — replaces the whole config'),
1261
+ .describe('rollup only: { relation, property?, function, where?, sameKey? } — replaces the whole config'),
1198
1262
  prefix: z.string().optional().describe('unique_id only: the new prefix'),
1199
1263
  type: z.string().optional().describe('Not changeable — passing it is refused with the reason'),
1200
1264
  dimensions: z.number().optional().describe('Not changeable (vector only) — passing it is refused with the reason'),
@@ -1277,6 +1341,19 @@ export function registerDatabaseTools(server) {
1277
1341
  'relation only: which rows of the linked table may be linked (all rules must match; propId is a column of the LINKED table). ' +
1278
1342
  'e.g. [{propId:"진행상태",op:"is",values:["채용"]}]. The picker hides others and the server refuses new links that fail. null turns it off.',
1279
1343
  ),
1344
+ keySync: z
1345
+ .record(z.any())
1346
+ .nullable()
1347
+ .optional()
1348
+ .describe(
1349
+ '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 ' +
1350
+ 'rows) in step. { columns: { "<column>": "mirror" | "append" | "items" } } (names or ids, ≤20). After a row is EDITED (cell, ' +
1351
+ 'row panel, bulk edit, button, automation, REST/MCP, or a link made from the other side of a two-way relation) the change is ' +
1352
+ 'copied to the other rows with the same key: mirror = copy the changed value; append (text/title) = append only the newly ' +
1353
+ 'added text, skipped if the other row already has it; items (relation/multi_select) = add the added items, remove the removed ' +
1354
+ 'ones. Not on row creation, key changes, imports, restores or merges; copied writes do not wake automations (no loops). ' +
1355
+ 'Keys compare like dedupe. Existing differences are not back-filled. null turns it off.',
1356
+ ),
1280
1357
  dedupeCarry: z
1281
1358
  .record(z.any())
1282
1359
  .nullable()
@@ -1359,6 +1436,7 @@ export function registerDatabaseTools(server) {
1359
1436
  allowTime,
1360
1437
  allowRange,
1361
1438
  dedupeCarry,
1439
+ keySync,
1362
1440
  validate,
1363
1441
  identifyProps,
1364
1442
  type,
@@ -1400,6 +1478,7 @@ export function registerDatabaseTools(server) {
1400
1478
  allowTime,
1401
1479
  allowRange,
1402
1480
  dedupeCarry,
1481
+ keySync,
1403
1482
  validate,
1404
1483
  identifyProps,
1405
1484
  });
@@ -1984,6 +2063,15 @@ export function registerDatabaseTools(server) {
1984
2063
  'is exactly "{관계 칸}" and the column it goes into is a relation to the SAME table: then the row ids are ' +
1985
2064
  'copied (e.g. 담당 매니저: "{배정 매니저}"). Ids the receiving column\'s relationFilter rejects are left out ' +
1986
2065
  'of that cell only (the rest still runs), commented on the row and reported in `notes`.\n' +
2066
+ 'ASK FOR TEXT FIRST: give the button `input: { label, placeholder?, multiline?, required?, split? }` and ' +
2067
+ 'pressing it opens a text box (label = the prompt; required defaults to true = empty text does not run). ' +
2068
+ 'The text goes wherever a step writes {@input} — add_row/update_rows values (e.g. {"원문":"{@input}"}) or ' +
2069
+ 'an ai_fill prompt — so "paste the raw text → a new row holds it → ai_fill target:\"created\" parses it into ' +
2070
+ 'the other columns" is one press with NO row selected. `split` runs the steps once PER ITEM when several are ' +
2071
+ 'pasted together: {by:"blank_line"}, {by:"separator", separator:"---"} or {by:"ai"} (AI only returns where ' +
2072
+ 'each item starts; the text itself is cut server-side); max 50 items. Saving is refused when a step uses ' +
2073
+ '{@input} but the button has no `input`, or the button has `input` but no step uses {@input}. Automations ' +
2074
+ 'cannot use {@input}.\n' +
1987
2075
  'A button never grants permission: it runs as whoever presses it — writing into another table needs ' +
1988
2076
  'write access there too.',
1989
2077
  annotations: { readOnlyHint: false, destructiveHint: true },
@@ -1995,6 +2083,23 @@ export function registerDatabaseTools(server) {
1995
2083
  id: z.string().optional().describe('Keep an existing button by passing its id; omit to create one'),
1996
2084
  label: z.string().describe('Button text, e.g. "접수"'),
1997
2085
  icon: z.string().optional().describe('One emoji shown on the button'),
2086
+ input: z
2087
+ .object({
2088
+ label: z.string().describe('Prompt shown above the text box, e.g. "붙여 넣을 원문"'),
2089
+ placeholder: z.string().optional().describe('Greyed-out example inside the box'),
2090
+ multiline: z.boolean().optional().describe('Multi-line text area (default single line)'),
2091
+ required: z.boolean().optional().describe('Default true — empty text does not run'),
2092
+ split: z
2093
+ .union([
2094
+ z.object({ by: z.literal('blank_line') }),
2095
+ z.object({ by: z.literal('separator'), separator: z.string() }),
2096
+ z.object({ by: z.literal('ai') }),
2097
+ ])
2098
+ .optional()
2099
+ .describe('Run once per item when several are pasted together (max 50)'),
2100
+ })
2101
+ .optional()
2102
+ .describe('Ask for text when pressed; steps use it as {@input}. Omit for a plain button.'),
1998
2103
  steps: z
1999
2104
  .array(z.record(z.any()))
2000
2105
  .describe('Ordered steps — see the tool description for each kind (max 8)'),
@@ -2028,7 +2133,10 @@ export function registerDatabaseTools(server) {
2028
2133
  'with the reason (the rest of the button still ran). `aiSkipped` counts rows where the AI answered but the ' +
2029
2134
  'answer could not go in the cell (e.g. not one of a select\'s options), with one sample in `aiSkippedAnswer`; ' +
2030
2135
  '`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.',
2136
+ 'It runs with YOUR permissions: a button that writes a locked column stops there if you cannot edit it. ' +
2137
+ 'A button with `input` (see rootr_list_database_buttons) needs `input` — the text a person would paste into its ' +
2138
+ 'box; it fills {@input}. With `input.split` the result also has `records: { total, done, failed, errors: ' +
2139
+ '[{ index, message }] }` (an item that fails is rolled back on its own; the others stay).',
2032
2140
  annotations: { readOnlyHint: false, destructiveHint: false },
2033
2141
  inputSchema: {
2034
2142
  databaseId: z.string().describe(DATABASE_TARGET_DESC),
@@ -2037,13 +2145,17 @@ export function registerDatabaseTools(server) {
2037
2145
  .array(z.string())
2038
2146
  .optional()
2039
2147
  .describe('Rows for steps whose target is "selected". Omit for buttons that only add rows.'),
2148
+ input: z
2149
+ .string()
2150
+ .optional()
2151
+ .describe('Text for a button that asks for input (its `input` config) — fills {@input}. Max 30,000 chars.'),
2040
2152
  },
2041
2153
  },
2042
- async ({ databaseId, buttonId, rowIds }) => {
2154
+ async ({ databaseId, buttonId, rowIds, input }) => {
2043
2155
  try {
2044
2156
  const client = makeClient();
2045
2157
  const id = await resolveNodeId(client, databaseId);
2046
- return jsonResult(await client.runDatabaseButton(id, buttonId, { rowIds }));
2158
+ return jsonResult(await client.runDatabaseButton(id, buttonId, { rowIds, input }));
2047
2159
  } catch (err) {
2048
2160
  return errorResult(err);
2049
2161
  }
@@ -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.number().optional().describe('rating/scale only: lowest number (rating 1, scale 0 or 1)'),
27
- max: z.number().optional().describe('rating/scale only: highest number (rating 5, scale 10). 2–10.'),
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 only: true = accept SEVERAL files; the answer becomes an array of { id, filename, size }. Default: one 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.';
@@ -234,7 +268,9 @@ export function registerFormTools(server) {
234
268
  'exact field ids and types. File questions: upload each file first (rootr_upload_attachment, or ' +
235
269
  'rootr_create_upload_url → PUT → rootr_complete_upload) and pass `{ id, filename }` — an ARRAY of those when ' +
236
270
  '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: `![name](url)` using the upload\'s url.',
271
+ 'refused). Images in a text answer with `allowImages` go in as markdown: `![name](url)` using the upload\'s url. ' +
272
+ '"relation" (pick from another table) questions take the picked row id — or an array of ids when `multiple` — ' +
273
+ '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
274
  annotations: { readOnlyHint: false, destructiveHint: false },
239
275
  inputSchema: {
240
276
  formId: z.string().describe(`FORM node id${NODE_TARGET_HINT}`),
@@ -394,7 +430,9 @@ export function registerFormTools(server) {
394
430
  'point of this tool — `unmatched`: the questions with NO column, whose answers are silently dropped ' +
395
431
  'when someone submits. Check this right after linking a form to a table or renaming anything: a ' +
396
432
  'mismatch produces no error anywhere, and the loss is usually found weeks later. ' +
397
- '`how` is "manual" (someone chose it), "auto" (names matched) or "none" (dropped). ' +
433
+ '`how` is "manual" (someone chose it), "auto" (names matched), "none" (dropped) or "unfit" (the name matches ' +
434
+ 'but the column cannot take that question — e.g. a text question on a relation column; `why` says so, and it ' +
435
+ 'counts as dropped: change the question to `relation` or pin another column). ' +
398
436
  '`lastError` carries why the most recent submission failed to reach the table, if it did.',
399
437
  annotations: { readOnlyHint: true, destructiveHint: false },
400
438
  inputSchema: {
@@ -421,7 +459,8 @@ export function registerFormTools(server) {
421
459
  'Questions you leave out keep matching BY NAME, and passing null for one puts it back to automatic. ' +
422
460
  'Use this whenever the question wording differs from the column header ("성함" vs "이름") — otherwise ' +
423
461
  '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.',
462
+ 'A field or column that does not exist is refused, not ignored. A relation column only takes a `relation` ' +
463
+ '(pick from another table) question pointing at the same table.',
425
464
  annotations: { readOnlyHint: false, destructiveHint: false },
426
465
  inputSchema: {
427
466
  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); any property may carry ' +
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 ' +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rootr-cli",
3
- "version": "0.30.1",
3
+ "version": "0.31.0",
4
4
  "description": "Official Rootr CLI + MCP server — read, write, edit and search Rootr documents from the shell or from an AI agent.",
5
5
  "type": "module",
6
6
  "bin": {