rootr-cli 0.25.0 → 0.26.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
@@ -530,6 +530,10 @@ rootr db buttons <표> --file buttons.json # 목록 통째로 저장(남길 것
530
530
  rootr db button-run <표> <버튼id> # 한 번 누르기 (--rows id,id 로 고른 행 지정)
531
531
  rootr db automations <표> --file rules.json # 자동 규칙 — 사람이 안 눌러도 도는 것
532
532
  rootr db view <표> <뷰> --scale year # 달력 보기를 주·달·해 중에서
533
+ rootr db view <표> <뷰> --density compact --freeze 1 # 촘촘한 줄 · 왼쪽 첫 칸 고정
534
+ rootr db column <표> 지원일 --time off --range off # 시간·기간 없이 날짜 하나만
535
+ rootr db column <표> 마감일 --overdue on # 지난 날짜를 빨갛게(기본은 꺼짐)
536
+ rootr db column <표> 특이사항 --wrap on --align center # 긴 글 줄바꿈 · 가운데 정렬
533
537
  ```
534
538
 
535
539
  ```json
@@ -859,11 +863,20 @@ rootr db view <표> <뷰> --scale year # 달력 보기를 주·달·해
859
863
  `rootr_append_page_blocks`, `rootr_update_page`, `rootr_update_page_block`,
860
864
  `rootr_read_page_live_data`)로 저작합니다.
861
865
 
862
- HTML을 짜는 것이 아니라 **정해진 블록 15종을 골라 쌓습니다** — `hero` `text`
866
+ HTML을 짜는 것이 아니라 **정해진 블록 16종을 골라 쌓습니다** — `hero` `text`
863
867
  `features` `gallery` `pricing` `testimonials` `faq` `timeline` `cta` `footer` +
864
868
  표에서 값을 끌어오는 `liveStats` `liveTable` `liveChart` + 신청을 받는 `form` +
865
869
  방문자가 시간을 잡는 `booking`(`{bookingNodeId, headline?, type?}` — 예약을 켜고 공개한 표의 노드 id) +
866
- 자유 블록 `custom`.
870
+ 모션그래픽 동영상 `video` + 자유 블록 `custom`.
871
+
872
+ **같은 페이지 안 이동** — 버튼·링크의 `target`에 `{"kind":"block","blockId":"booking"}`을 주면 새 탭 없이
873
+ 그 블록까지 부드럽게 내려갑니다(인스타그램 앱 안 브라우저에서 사람을 잃지 않는 광고 랜딩용). 가리킨 블록이
874
+ 페이지에 없으면 400이라, 만들 때 그 블록에 `id`를 직접 붙여 둡니다. `text` 본문에서는 `[글](#블록id)`.
875
+
876
+ **동영상(Team 플랜)** — `hero.background`에 `{"kind":"video","attachmentId":"…","posterAttachmentId":"…"}`,
877
+ 동영상 블록은 `{attachmentId, posterAttachmentId?, caption?, aspect?: "16:9"|"9:16"|"1:1"|"4:5", bleed?}`.
878
+ 둘 다 소리 없이 자동 재생·반복·컨트롤 없음이고, 동작 줄이기를 켠 방문자에게는 포스터 그림만 보입니다.
879
+ 히어로 동영상에는 `portraitAttachmentId`·`portraitPosterAttachmentId`(휴대폰 세로판)를 더할 수 있습니다 — 세로 화면은 그 판을, 아니면 기본판을 하나만 내려받습니다.
867
880
 
868
881
  **랜딩 페이지**용으로 두 블록은 가운데 본문 기둥을 벗어나 화면 폭 전체를 쓸 수 있습니다.
869
882
  `hero`에 `size: "screen"`을 주면 화면 한 장을 채우는 큰 제목(화면 폭 전체·화면 높이·큰 글자)이 되고,
package/README.md CHANGED
@@ -201,10 +201,18 @@ rootr db rows-rm /projects/tasks --ids r1,r2 --yes
201
201
  rootr db duplicates /hr/applicants --row r9 # same person (dedupe column) + latest linked history
202
202
  rootr db merge /hr/applicants r9 --into r1 # merge a duplicate into the existing row
203
203
  rootr db keep-duplicate /hr/applicants r9 --from r1 # keep as a new row, link both
204
+ rootr db rename-col /hr/applicants "대처-클레임" "대처-청소 후 사진 제출" --dry # what follows a rename (formulas, {col} templates, rollups, forms, pages); drop --dry to rename
205
+ rootr db carry-over /hr/applicants r9 # re-application: copy the latest earlier row into r9 (earlier row untouched)
206
+ rootr db column-set /hr/applicants Phone --json '{"dedupe":true,"dedupeCarry":{"auto":true,"exclude":["Applied on"],"mark":{"Re-applied":true}}}'
207
+ rootr db column-set /hr/applicants Phone --json '{"validate":{"kind":"mobile_kr","mode":"block"}}' # also date · birthdate · range, mode warn
208
+ rootr db rule-violations /hr/applicants # existing values that break a validate rule (nothing changed)
204
209
  rootr db history /hr/applicants --row r1 # who changed what, when, how
205
210
  rootr db restore /hr/applicants --at 2026-10-15T09:00:00+09:00 --yes # rows + columns/views back to that time
206
211
  rootr db view /projects/tasks Calendar --scale month # views: table, board, calendar, timeline
207
212
  rootr db view /hr/applicants Board --cards "Interview date,Phone" # fields shown on board cards
213
+ rootr db view /hr/applicants Table --density compact --freeze 1 # compact rows, keep the first column fixed
214
+ rootr db column /hr/applicants "Applied on" --overdue off --time off --range off # plain single date, no red
215
+ rootr db column /hr/applicants Notes --wrap on --align center # wrap long text, center it
208
216
  rootr db lock /projects/tasks --schema managers --rows own --locked "Approved,Score"
209
217
  rootr db notify /projects/tasks --file rules.json # notification rules
210
218
  rootr db buttons /projects/tasks --file buttons.json # buttons that do work when pressed
@@ -506,11 +514,23 @@ signed-document, issue, CRM and LOG tools need `docs:read` / `docs:write`; `root
506
514
  ### Pages
507
515
 
508
516
  A PAGE is **a screen for people outside the workspace** — an event announcement, a product page,
509
- a sign-up. You do not write HTML: you stack **15 predefined block types** — `hero`, `text`,
517
+ a sign-up. You do not write HTML: you stack **16 predefined block types** — `hero`, `text`,
510
518
  `features`, `gallery`, `pricing`, `testimonials`, `faq`, `timeline`, `cta`, `footer`, the
511
519
  table-backed `liveStats`, `liveTable`, `liveChart`, the `form` block, the `booking` block
512
520
  (a visitor scheduling calendar: `{bookingNodeId, headline?, type?}` — the node id of a published
513
- table with visitor booking on) — plus the free-form `custom`.
521
+ table with visitor booking on), the `video` block (a looping motion-graphic clip) — plus the free-form `custom`.
522
+
523
+ **Same-page links.** A button or link `target` can be `{"kind":"block","blockId":"booking"}` — it
524
+ jumps to that block on the same page (smooth scroll, same tab; ideal for a hero "Book now" button
525
+ on an ad landing page opened in an in-app browser). The block must exist on the page (else 400),
526
+ so give it an explicit `id`. In a `text` block, `[label](#<blockId>)` does the same.
527
+
528
+ **Videos (Team plan).** `hero.background` can be `{"kind":"video","attachmentId":"…","posterAttachmentId":"…"}`
529
+ and the `video` block is `{attachmentId, posterAttachmentId?, caption?, aspect?: "16:9"|"9:16"|"1:1"|"4:5", bleed?}`.
530
+ Both autoplay muted and looped with no controls; the poster shows for reduced-motion visitors. A hero video can add
531
+ `portraitAttachmentId` / `portraitPosterAttachmentId` — a vertical cut that portrait phone screens play instead (only one file is downloaded). Upload the
532
+ mp4 with `POST /v1/attachments/upload?workspaceId=…&nodeId=<page>&mimeType=video/mp4` (body = bytes,
533
+ `Content-Type: application/octet-stream`).
514
534
 
515
535
  For a **landing page**, two options let a block use the whole page width instead of the centred
516
536
  text column: `hero` with `size: "screen"` is a full-screen hero (edge to edge, one viewport tall,
package/bin/rootr.js CHANGED
@@ -40,7 +40,8 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
40
40
  rootr db button-run <path|id> <buttonId> [--rows id,id] 버튼 한 번 누르기 (행 만들기·값 채우기·AI 채우기)
41
41
  rootr db automations <path|id> [--file rules.json] [--clear] 자동 규칙 — "값이 이렇게 되면 이 일을 해라"·정해진 시각
42
42
  rootr db import-sheets <파일.xlsx> --ws <id> [--dry] 시트 수백 장짜리 엑셀을 표 하나로 접어 가져오기
43
- rootr db view <path|id> [뷰이름] [--scale week|month|year] [--name 새이름] [--cards "칸,칸"] 뷰 목록·달력 주/달/해 단위·보드 카드에 보일 칸
43
+ rootr db view <path|id> [뷰이름] [--scale week|month|year] [--name 새이름] [--cards "칸,칸"] 뷰 목록·달력 주/달/해 단위·보드 카드에 보일 칸 ·[--density compact|normal] [--freeze 0~3] 촘촘하게·왼쪽 칸 고정
44
+ rootr db column <path|id> <칸> [--align left|center|right] [--wrap on|off] [--overdue on|off] [--time on|off] [--range on|off] 칸 정렬·줄바꿈·지난 날짜 빨강·시간·기간
44
45
  rootr db row-add <path|id> --values '<json>' [--body <md>|--body-file <f>] 행 추가(상세 내용 포함)
45
46
  rootr db row-body <path|id> <rowId> [--file <f>] 행의 상세 내용(본문 메모) 쓰기 — 없으면 표준입력
46
47
  rootr db rows-new <path|id> --json '[{…},…]' | --file <파일> 여러 행 한 번에 넣기 (하나라도 틀리면 전부 취소)
@@ -50,6 +51,11 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
50
51
  rootr db duplicates <path|id> (--row <rowId> | --values '<json>') [--json] 같은 사람(중복 감지 칸 값이 같은 행)과 그 이력
51
52
  rootr db merge <path|id> <rowId> --into <기존 rowId> 중복 행을 기존 행에 합치기(빈 칸만 채움, rowId 행은 지움)
52
53
  rootr db keep-duplicate <path|id> <rowId> --from <이전 rowId> 새 행으로 두되 이전 행과 서로 링크
54
+ rootr db rename-col <path|id> <칸 이름|id> <새 이름> [--dry] 칸 이름 바꾸기 — 수식·버튼/규칙 {칸}·다른 표 롤업·폼 질문·페이지 블록도 함께(--dry: 무엇이 바뀌는지만)
55
+ rootr db carry-over <path|id> <rowId> [--from <이전 rowId>] 이전 정보 가져오기 — 같은 사람의 이전 행 값을 새 행으로(이전 행은 그대로, 이름은 표의 dedupeCarry.label)
56
+ rootr db column-set <path|id> <칸 이름|id> --json '<설정>' 칸 설정 바꾸기 — 예: '{"validate":{"kind":"pattern","pattern":"[A-Z]{2}-\\\\d{4}","message":"사번 꼴이 아닙니다"}}', '{"dedupeCarry":{…}}'
57
+ rootr db rule-violations <path|id> [--limit N] [--json] 지금 값 가운데 칸 검사(validate)에 안 맞는 것(고치지 않는다)
58
+ rootr db rule-test --json '<규칙>' <값> [<값>…] [--type number] 값 검사 규칙을 걸기 전에 시험(저장 안 함) · --presets: 예시 꼴 목록
53
59
  rootr db history <path|id> [--row <rowId>] [--limit N] [--json] 행·표 변경 이력(누가·언제·어떻게·전후 값)
54
60
  rootr db restore <path|id> --at <ISO 시각> [--yes] 표 전체(행+칸·보기·버튼·규칙)를 그 시각으로 — --yes 없으면 미리보기만
55
61
  rootr db row-page <rowId> [--type RECORDING] 이 행만 원하는 타입으로 상세 붙이기(녹음·스프레드시트·서명문서…)
@@ -157,7 +163,8 @@ usage:
157
163
  rootr db button-run <path|id> <buttonId> [--rows id,id] press a button once (create rows, fill values, AI fill)
158
164
  rootr db automations <path|id> [--file rules.json] [--clear] automatic rules — "when a value becomes X, do Y", or at a set time
159
165
  rootr db import-sheets <file.xlsx> --ws <id> [--dry] fold a one-sheet-per-record workbook into ONE table
160
- rootr db view <path|id> [view name] [--scale week|month|year] [--name <new name>] list views; calendar week/month/year
166
+ 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
167
+ 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
161
168
  rootr db row-add <path|id> --values '<json>' [--body <md>|--body-file <f>] add a row (with its body)
162
169
  rootr db row-body <path|id> <rowId> [--file <f>] write a row's body — reads stdin if no file
163
170
  rootr db rows-new <path|id> --json '[{…},…]' | --file <file> insert many rows at once (one bad row cancels all)
@@ -165,8 +172,15 @@ usage:
165
172
  rootr db rows-each <path|id> --json '[{"rowId":…,"values":{…}},…]' a different value per row, in one call
166
173
  rootr db rows-rm <path|id> --ids <id,id> [--yes] delete chosen rows at once — one blocked row rejects all
167
174
  rootr db duplicates <path|id> (--row <rowId> | --values '<json>') [--json] rows of the same person (same dedupe value) with history
175
+ rootr db pick <path|id> [name] [--limit N] [--json] find rows to link — whole table, newest first, with the identify line (phone last 4 · date)
176
+ rootr db identify <path|id> <col[:last4],col…> | --clear columns shown next to a row's title when another table picks it (same-name people)
168
177
  rootr db merge <path|id> <rowId> --into <existing rowId> merge a duplicate into the existing row (fills blanks, deletes rowId)
169
178
  rootr db keep-duplicate <path|id> <rowId> --from <earlier rowId> keep as a new row but link both rows
179
+ rootr db rename-col <path|id> <column name|id> <new name> [--dry] rename a column — formulas, button/rule {col}, other tables' rollups, form questions and page blocks follow (--dry: list only)
180
+ rootr db carry-over <path|id> <rowId> [--from <earlier rowId>] carry the person's previous row into this row (earlier row untouched)
181
+ rootr db column-set <path|id> <column name|id> --json '<settings>' change a column's settings, e.g. '{"validate":{"kind":"pattern","pattern":"[A-Z]{2}-\\\\d{4}","message":"not an employee id"}}'
182
+ rootr db rule-violations <path|id> [--limit N] [--json] current values that break a column's validate rule (nothing is changed)
183
+ rootr db rule-test --json '<rule>' <value> [<value>…] [--type number] try a validate rule on sample values (saves nothing) · --presets: example patterns
170
184
  rootr db history <path|id> [--row <rowId>] [--limit N] [--json] change history (who, when, how, before/after)
171
185
  rootr db restore <path|id> --at <ISO time> [--yes] restore the whole table (rows + columns/views/buttons/rules) — preview without --yes
172
186
  rootr db row-page <rowId> [--type RECORDING] give this row a typed detail page (recording, spreadsheet, signed doc…)
@@ -1646,6 +1660,44 @@ async function cmdDbDuplicates(args) {
1646
1660
  }
1647
1661
  }
1648
1662
 
1663
+ /** REL-PICK — `rootr db pick <표> [이름]` : 관계 칸에 이을 행 찾기(표 전체, 같은 이름은 최근 행 먼저, 구분 글자). */
1664
+ async function cmdDbPick(args) {
1665
+ const [, target, ...words] = args._;
1666
+ if (!target) throw new Error(usage(`rootr db pick <path|id> [name] [--limit N] [--json]`));
1667
+ const client = makeClient();
1668
+ const id = await resolveDatabaseId(client, target);
1669
+ const res = await client.pickRows(id, { q: words.join(' '), limit: args.limit ? Number(args.limit) : undefined });
1670
+ if (args.json) return console.log(JSON.stringify(res, null, 2));
1671
+ if (!res.rows.length) return console.log(tr('맞는 행이 없습니다.', 'No matching rows.'));
1672
+ for (const r of res.rows) console.log(`- ${r.title || '(제목 없음)'}${r.subtitle ? ` (${r.subtitle})` : ''}\t${r.id}`);
1673
+ if (res.hasMore) console.log(tr(`… ${res.total}개 중 ${res.rows.length}개 — 이름을 더 적거나 전화 끝자리로 좁히세요.`, `… ${res.rows.length} of ${res.total} — narrow it down.`));
1674
+ }
1675
+
1676
+ /** REL-PICK-IDENTIFY — `rootr db identify <표> 연락처:last4,지원일` : 제목 칸의 identifyProps 를 건다(--clear 로 끈다). */
1677
+ async function cmdDbIdentify(args) {
1678
+ const [, target, spec] = args._;
1679
+ if (!target || (!spec && !args.clear)) throw new Error(usage(`rootr db identify <path|id> <col[:last4],col…> | --clear`));
1680
+ const client = makeClient();
1681
+ const id = await resolveDatabaseId(client, target);
1682
+ const db = await client.getDatabase(id);
1683
+ const title = (db.properties || []).find((p) => p.type === 'title');
1684
+ if (!title) throw new Error(tr('이 표에 제목 칸이 없습니다.', 'This table has no title column.'));
1685
+ const identifyProps = args.clear
1686
+ ? null
1687
+ : String(spec)
1688
+ .split(',')
1689
+ .map((x) => x.trim())
1690
+ .filter(Boolean)
1691
+ .map((x) => {
1692
+ const [propId, mask] = x.split(':');
1693
+ return mask ? { propId: propId.trim(), mask: mask.trim() } : { propId: propId.trim() };
1694
+ });
1695
+ const res = await client.updateColumn(id, title.id, { identifyProps });
1696
+ const names = new Map((db.properties || []).map((p) => [p.id, p.name]));
1697
+ const shown = (res.identifyProps || []).map((it) => `${names.get(it.propId) ?? it.propId}${it.mask ? `(${it.mask})` : ''}`);
1698
+ console.log(shown.length ? tr(`구분 칸: ${shown.join(' · ')}`, `Identify columns: ${shown.join(' · ')}`) : tr('구분 칸을 껐습니다.', 'Identify columns cleared.'));
1699
+ }
1700
+
1649
1701
  /** DEDUPE — `rootr db merge <표> <rowId> --into <기존 rowId>` */
1650
1702
  async function cmdDbMerge(args) {
1651
1703
  const [, target, rowId] = args._;
@@ -1661,6 +1713,43 @@ async function cmdDbMerge(args) {
1661
1713
  );
1662
1714
  }
1663
1715
 
1716
+ /**
1717
+ * COL-RENAME — `rootr db rename-col <표> <칸 이름|id> <새 이름> [--dry] [--json]`
1718
+ * 칸 이름을 바꾸면 그 칸을 이름으로 가리키던 수식·버튼/자동 규칙 치환·다른 표 롤업·이어진 폼 질문·페이지 블록이 함께 바뀐다.
1719
+ * --dry 는 무엇이 바뀌는지만 보여 준다(쓰지 않는다).
1720
+ */
1721
+ async function cmdDbRenameCol(args) {
1722
+ const [, target, col, ...rest] = args._;
1723
+ const to = rest.join(' ').trim();
1724
+ if (!target || !col || !to) throw new Error(usage(`rootr db rename-col <path|id> <column name|id> <new name> [--dry] [--json]`));
1725
+ const client = makeClient();
1726
+ const id = await resolveDatabaseId(client, target);
1727
+ const db = await client.getDatabase(id);
1728
+ const prop =
1729
+ (db.properties ?? []).find((p) => p.id === col) ??
1730
+ (db.properties ?? []).find((p) => p.name.trim().toLowerCase() === String(col).trim().toLowerCase());
1731
+ if (!prop) throw new Error(tr(`"${col}" 칸이 이 표에 없습니다.`, `No column "${col}" in this table.`));
1732
+ const cascade = args.dry
1733
+ ? await client.columnRenameImpact(id, prop.id, to)
1734
+ : (await client.updateColumn(id, prop.id, { name: to })).renameCascade ?? null;
1735
+ if (args.json) return console.log(JSON.stringify({ propertyId: prop.id, from: prop.name, to, dryRun: !!args.dry, renameCascade: cascade }, null, 2));
1736
+ console.log(
1737
+ args.dry
1738
+ ? tr(`미리보기: 「${prop.name}」 → 「${to}」`, `Preview: "${prop.name}" → "${to}"`)
1739
+ : tr(`바꿨습니다: 「${prop.name}」 → 「${to}」`, `Renamed "${prop.name}" → "${to}"`),
1740
+ );
1741
+ if (!cascade || !cascade.summary) console.log(tr('이 칸을 이름으로 가리키는 곳이 없습니다.', 'Nothing else refers to this column by name.'));
1742
+ else console.log(cascade.summary);
1743
+ for (const f of cascade?.formulas ?? []) console.log(` ${tr('수식', 'formula')} ${f.column}: ${f.before} → ${f.after}`);
1744
+ for (const b of cascade?.buttons ?? []) console.log(` ${tr('버튼', 'button')} ${b.table} / ${b.label}`);
1745
+ for (const a of cascade?.automations ?? []) console.log(` ${tr('자동 규칙', 'automation')} ${a.table} / ${a.label}`);
1746
+ for (const r of cascade?.rollups ?? []) console.log(` ${tr('롤업', 'rollup')} ${r.table} / ${r.column}`);
1747
+ for (const f of cascade?.forms ?? []) for (const q of f.pinned) console.log(` ${tr('폼', 'form')} ${f.form} / ${q.field} → ${q.column}`);
1748
+ for (const p of cascade?.pages ?? []) console.log(` ${tr('페이지', 'page')} ${p.page} (${p.blocks})`);
1749
+ for (const w of cascade?.warnings ?? []) console.log(`⚠ ${w}`);
1750
+ if (args.dry) console.log(tr('미리보기입니다. 바꾸려면 --dry 를 빼세요.', 'Preview only. Drop --dry to rename.'));
1751
+ }
1752
+
1664
1753
  /** ROW-HISTORY — `rootr db history <표> [--row <rowId>] [--limit N] [--json]` */
1665
1754
  async function cmdDbHistory(args) {
1666
1755
  const target = args._[1];
@@ -1706,6 +1795,75 @@ async function cmdDbRestore(args) {
1706
1795
  console.log(tr('되돌렸습니다.', 'Restored.'));
1707
1796
  }
1708
1797
 
1798
+ /** DEDUPE-CARRY(P1) — `rootr db carry-over <표> <rowId> [--from <이전 rowId>]` */
1799
+ async function cmdDbCarryOver(args) {
1800
+ const [, target, rowId] = args._;
1801
+ if (!target || !rowId) throw new Error(usage(`rootr db carry-over <path|id> <rowId> [--from <rowId>]`));
1802
+ const client = makeClient();
1803
+ const id = await resolveDatabaseId(client, target);
1804
+ const res = await client.carryOver(id, rowId, typeof args.from === 'string' ? args.from : undefined);
1805
+ if (args.json) return console.log(JSON.stringify(res, null, 2));
1806
+ console.log(
1807
+ tr(
1808
+ `이전 행(${res.fromTitle || res.fromRowId})에서 가져왔습니다 — 옮긴 칸: ${res.copied.join(', ') || '없음'}${res.marked.length ? ` · 표시: ${res.marked.join(', ')}` : ''}`,
1809
+ `Carried from ${res.fromTitle || res.fromRowId} — copied: ${res.copied.join(', ') || 'none'}${res.marked.length ? ` · marked: ${res.marked.join(', ')}` : ''}`,
1810
+ ),
1811
+ );
1812
+ }
1813
+
1814
+ /** `rootr db column-set <표> <칸> --json '<PATCH 본문>'` — 칸 설정(validate·dedupe·dedupeCarry·이름·선택지…)을 바꾼다. */
1815
+ async function cmdDbColumnSet(args) {
1816
+ const [, target, column] = args._;
1817
+ if (!target || !column || typeof args.json !== 'string') throw new Error(usage(`rootr db column-set <path|id> <column> --json '<settings>'`));
1818
+ let body;
1819
+ try {
1820
+ body = JSON.parse(args.json);
1821
+ } catch {
1822
+ throw new Error(tr('--json 이 JSON 이 아닙니다.', '--json is not valid JSON.'));
1823
+ }
1824
+ const client = makeClient();
1825
+ const id = await resolveDatabaseId(client, target);
1826
+ const db = await client.getDatabase(id);
1827
+ const prop = (db.properties ?? []).find((p) => p.id === column) ?? (db.properties ?? []).find((p) => p.name.trim().toLowerCase() === String(column).trim().toLowerCase());
1828
+ if (!prop) throw new Error(tr(`"${column}" 칸이 없습니다. 칸: ${(db.properties ?? []).map((p) => p.name).join(', ')}`, `No column "${column}".`));
1829
+ const res = await client.updateColumn(id, prop.id, body);
1830
+ console.log(JSON.stringify(res, null, 2));
1831
+ }
1832
+
1833
+ /** VALUE-RULE(P2) — `rootr db rule-violations <표> [--limit N] [--json]` */
1834
+ async function cmdDbRuleViolations(args) {
1835
+ const [, target] = args._;
1836
+ if (!target) throw new Error(usage(`rootr db rule-violations <path|id> [--limit N]`));
1837
+ const client = makeClient();
1838
+ const id = await resolveDatabaseId(client, target);
1839
+ const res = await client.ruleViolations(id, { limit: args.limit ? Number(args.limit) : undefined });
1840
+ if (args.json) return console.log(JSON.stringify(res, null, 2));
1841
+ if (!res.checkedColumns.length) return console.log(tr('검사를 건 칸이 없습니다.', 'No column has a validate rule.'));
1842
+ console.log(tr(`검사한 칸: ${res.checkedColumns.join(', ')} · 안 맞는 값 ${res.total}건`, `Checked: ${res.checkedColumns.join(', ')} · ${res.total} violations`));
1843
+ for (const it of res.items) console.log(` ${it.rowId} ${it.title || '-'} ${it.propName}: ${it.value} — ${it.message}`);
1844
+ }
1845
+
1846
+ /** VALUE-RULE — `rootr db rule-test --json '<규칙>' <값>…` · `rootr db rule-test --presets` */
1847
+ async function cmdDbRuleTest(args) {
1848
+ const client = makeClient();
1849
+ if (args.presets) {
1850
+ const { presets } = await client.valueRulePresets();
1851
+ if (args.json) return console.log(JSON.stringify(presets, null, 2));
1852
+ for (const p of presets) console.log(` ${p.id.padEnd(12)} ${tr(p.label.ko, p.label.en)} ${p.pattern}${p.flags ? ` (flags ${p.flags})` : ''} ${tr('예', 'e.g.')} ${p.example}`);
1853
+ return;
1854
+ }
1855
+ const values = args._.slice(1);
1856
+ if (typeof args.json !== 'string' || !values.length) throw new Error(usage(`rootr db rule-test --json '{"pattern":"\\\\d{5}"}' <값> [<값>…]`));
1857
+ let rule;
1858
+ try {
1859
+ rule = JSON.parse(args.json);
1860
+ } catch {
1861
+ throw new Error(tr('--json 이 JSON 이 아닙니다.', '--json is not valid JSON.'));
1862
+ }
1863
+ const res = await client.testValueRule(rule, values, typeof args.type === 'string' ? args.type : undefined);
1864
+ for (const r of res.results) console.log(` ${r.ok ? '✓' : '✗'} ${r.value}${r.ok ? '' : ` — ${r.message}`}`);
1865
+ }
1866
+
1709
1867
  /** DEDUPE-KEEP — `rootr db keep-duplicate <표> <rowId> --from <이전 rowId>` */
1710
1868
  async function cmdDbKeepDuplicate(args) {
1711
1869
  const [, target, rowId] = args._;
@@ -1985,7 +2143,7 @@ async function cmdBooking(args) {
1985
2143
  ` [--duration 30] [--buffer 10] [--calendar-buffer 60] [--notice 2] [--ahead 60] [--per-day 4]\n` +
1986
2144
  ` [--block 2026-10-03,2026-10-09] [--approval instant|manual] [--tz Asia/Seoul]\n` +
1987
2145
  ` [--block-time "daily 12:00-13:00=점심, mon 09:00-11:00, fri"]\n` +
1988
- ` [--meet on|off] [--notify-visitor on|off] [--notify-email a@b.com,c@d.com] [--self-cancel on|off]\n` +
2146
+ ` [--meet on|off] [--all-day-busy on|off] [--notify-visitor on|off] [--notify-email a@b.com,c@d.com] [--self-cancel on|off]\n` +
1989
2147
  ` [--self-move on|off] [--remind 1d,1h (안 보내려면 --remind off)]\n` +
1990
2148
  ` [--types "30분 상담=30m,데모=60m|화면 보며 설명"] [--guests on|off] [--max-guests 5] [--track-source on|off]\n` +
1991
2149
  ` [--after https://example.com/thanks]\n` +
@@ -1997,7 +2155,7 @@ async function cmdBooking(args) {
1997
2155
  ` [--duration 30] [--buffer 10] [--calendar-buffer 60] [--notice 2] [--ahead 60] [--per-day 4]\n` +
1998
2156
  ` [--block 2026-10-03,2026-10-09] [--approval instant|manual] [--tz Asia/Seoul]\n` +
1999
2157
  ` [--block-time "daily 12:00-13:00=lunch, mon 09:00-11:00, fri"]\n` +
2000
- ` [--meet on|off] [--notify-visitor on|off] [--notify-email a@b.com,c@d.com] [--self-cancel on|off]\n` +
2158
+ ` [--meet on|off] [--all-day-busy on|off] [--notify-visitor on|off] [--notify-email a@b.com,c@d.com] [--self-cancel on|off]\n` +
2001
2159
  ` [--self-move on|off] [--remind 1d,1h (--remind off to stop)]\n` +
2002
2160
  ` [--types "Intro=30m,Demo=60m|walk through the product"] [--guests on|off] [--max-guests 5] [--track-source on|off]\n` +
2003
2161
  ` [--after https://example.com/thanks]\n` +
@@ -2051,6 +2209,7 @@ async function cmdBooking(args) {
2051
2209
  : tr(' 미리 알림: 안 보냄', ' reminders: off'),
2052
2210
  );
2053
2211
  if (b.google?.meet) console.log(tr(' 구글 밋: 예약마다 회의 만듦', ' Google Meet: a room per booking'));
2212
+ if (b.google && b.google.allDayBusy === false) console.log(tr(' 하루 종일 일정: 안 막음', ' All-day events: do not block'));
2054
2213
  const mailTo = [
2055
2214
  ...(b.notifyVisitor === false ? [] : [tr('방문자', 'visitor')]),
2056
2215
  ...(b.notifyEmails ?? []),
@@ -2117,6 +2276,9 @@ async function cmdBooking(args) {
2117
2276
  if (args.block !== undefined) body.blockedDates = String(args.block).split(',').map((x) => x.trim()).filter(Boolean);
2118
2277
  // 목록을 통째로 바꾼다 — 빈 값을 주면 다 지운다.
2119
2278
  if (args.meet !== undefined) body.google = { ...(body.google ?? {}), meet: args.meet !== 'off' && args.meet !== false };
2279
+ if (args['all-day-busy'] !== undefined) {
2280
+ body.google = { ...(body.google ?? {}), allDayBusy: args['all-day-busy'] !== 'off' && args['all-day-busy'] !== false };
2281
+ }
2120
2282
  if (args['notify-visitor'] !== undefined) body.notifyVisitor = args['notify-visitor'] !== 'off' && args['notify-visitor'] !== false;
2121
2283
  if (args['self-cancel'] !== undefined) body.allowVisitorCancel = args['self-cancel'] !== 'off' && args['self-cancel'] !== false;
2122
2284
  if (args['self-move'] !== undefined) body.allowVisitorReschedule = args['self-move'] !== 'off' && args['self-move'] !== false;
@@ -2359,8 +2521,15 @@ async function cmdDb(args) {
2359
2521
  if (sub === 'rows-set') return cmdDbRowsSet(args);
2360
2522
  if (sub === 'rows-rm') return cmdDbRowsRm(args);
2361
2523
  if (sub === 'duplicates') return cmdDbDuplicates(args);
2524
+ if (sub === 'pick') return cmdDbPick(args);
2525
+ if (sub === 'identify') return cmdDbIdentify(args);
2362
2526
  if (sub === 'merge') return cmdDbMerge(args);
2363
2527
  if (sub === 'keep-duplicate') return cmdDbKeepDuplicate(args);
2528
+ if (sub === 'rename-col') return cmdDbRenameCol(args);
2529
+ if (sub === 'carry-over') return cmdDbCarryOver(args);
2530
+ if (sub === 'column-set') return cmdDbColumnSet(args);
2531
+ if (sub === 'rule-violations') return cmdDbRuleViolations(args);
2532
+ if (sub === 'rule-test') return cmdDbRuleTest(args);
2364
2533
  if (sub === 'history') return cmdDbHistory(args);
2365
2534
  if (sub === 'restore') return cmdDbRestore(args);
2366
2535
  if (sub === 'row-page') return cmdDbRowPage(args);
@@ -2372,9 +2541,10 @@ async function cmdDb(args) {
2372
2541
  if (sub === 'automations') return cmdDbAutomations(args);
2373
2542
  if (sub === 'import-sheets') return cmdDbImportSheets(args);
2374
2543
  if (sub === 'view') return cmdDbView(args);
2544
+ if (sub === 'column') return cmdDbColumn(args);
2375
2545
  if (sub !== 'create') {
2376
2546
  throw new Error(
2377
- usage(tr(`rootr db create|rows|row-add|rows-new|row-body|rows-set|rows-each|rows-rm|duplicates|merge|keep-duplicate|history|restore|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|duplicates|merge|keep-duplicate|history|restore|row-page|row-page-type|lock|notify|buttons|button-run|view … (see rootr --help)`)),
2547
+ usage(tr(`rootr db create|rows|row-add|rows-new|row-body|rows-set|rows-each|rows-rm|duplicates|merge|keep-duplicate|history|restore|row-page|row-page-type|lock|notify|buttons|button-run|automations|import-sheets|view|column … (자세한 건 rootr --help)`, `rootr db create|rows|row-add|rows-new|row-body|rows-set|rows-each|rows-rm|duplicates|merge|keep-duplicate|history|restore|row-page|row-page-type|lock|notify|buttons|button-run|view|column … (see rootr --help)`)),
2378
2548
  );
2379
2549
  }
2380
2550
  const raw = typeof args.file === 'string' ? readFileSync(args.file, 'utf8') : await readStdin();
@@ -2536,8 +2706,19 @@ async function cmdDbView(args) {
2536
2706
  const cards = args.cards.split(',').map((c) => c.trim()).filter(Boolean);
2537
2707
  patch.cardProps = cards.length ? cards : null;
2538
2708
  }
2709
+ // VIEW-DENSITY · FREEZE-COLS — 표 보기를 한눈에(촘촘한 줄·왼쪽 칸 고정).
2710
+ if (args.density !== undefined) {
2711
+ const density = String(args.density);
2712
+ if (!['compact', 'normal'].includes(density)) throw new Error(tr('--density 는 compact · normal 중 하나여야 합니다.', `--density must be compact or normal.`));
2713
+ patch.density = density;
2714
+ }
2715
+ if (args.freeze !== undefined) {
2716
+ const n = Number(args.freeze);
2717
+ 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)`));
2718
+ patch.frozenColumns = n;
2719
+ }
2539
2720
  if (!Object.keys(patch).length) {
2540
- throw new Error(tr('바꿀 것을 주세요: --scale week|month|year, --name 새이름 또는 --cards "칸,칸"', `Give something to change: --scale week|month|year, --name <new name>, or --cards "field,field"`));
2721
+ 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`));
2541
2722
  }
2542
2723
 
2543
2724
  const view = await client.updateDatabaseView(id, viewRef, patch);
@@ -2551,6 +2732,66 @@ async function cmdDbView(args) {
2551
2732
  }
2552
2733
 
2553
2734
 
2735
+ /**
2736
+ * `rootr db column <path|id> <칸 이름|id> [--align left|center|right|auto] [--wrap on|off] [--overdue on|off] [--time on|off] [--range on|off]`
2737
+ *
2738
+ * 칸 하나의 표시 설정(COL-ALIGN·COL-WRAP·DATE-OVERDUE·DATE-NO-TIME/RANGE). 준 것만 바뀐다. `--align auto` 는 기본(숫자 오른쪽).
2739
+ * 옵션 없이 부르면 지금 설정을 보여 준다. 2026-10-21 클린베테랑 P4·P8·P9·P10.
2740
+ */
2741
+ async function cmdDbColumn(args) {
2742
+ const target = args._[1];
2743
+ const ref = args._[2];
2744
+ const USAGE = tr(
2745
+ `rootr db column <path|id> <칸> [--align left|center|right|auto] [--wrap on|off] [--overdue on|off] [--time on|off] [--range on|off] [--json]`,
2746
+ `rootr db column <path|id> <column> [--align left|center|right|auto] [--wrap on|off] [--overdue on|off] [--time on|off] [--range on|off] [--json]`,
2747
+ );
2748
+ if (!target || !ref) throw new Error(usage(USAGE));
2749
+ const client = makeClient();
2750
+ const id = await resolveDatabaseId(client, target);
2751
+ const db = await client.getDatabase(id);
2752
+ const prop = (db.properties ?? []).find((p) => p.id === ref || p.name.trim().toLowerCase() === String(ref).trim().toLowerCase());
2753
+ if (!prop) throw new Error(tr(`"${ref}" 칸이 없습니다. 칸: ${(db.properties ?? []).map((p) => p.name).join(', ')}`, `No column "${ref}". Columns: ${(db.properties ?? []).map((p) => p.name).join(', ')}`));
2754
+ const onOff = (key) => {
2755
+ const v = args[key];
2756
+ if (v === undefined) return undefined;
2757
+ if (v === 'on' || v === true || v === 'true') return true;
2758
+ if (v === 'off' || v === 'false') return false;
2759
+ throw new Error(tr(`--${key} 는 on · off 중 하나여야 합니다.`, `--${key} must be on or off.`));
2760
+ };
2761
+ const patch = {};
2762
+ if (args.align !== undefined) {
2763
+ const a = String(args.align);
2764
+ if (!['left', 'center', 'right', 'auto'].includes(a)) throw new Error(tr('--align 은 left · center · right · auto', `--align must be left, center, right or auto`));
2765
+ patch.align = a === 'auto' ? null : a;
2766
+ }
2767
+ const wrap = onOff('wrap');
2768
+ if (wrap !== undefined) patch.wrap = wrap;
2769
+ const overdue = onOff('overdue');
2770
+ if (overdue !== undefined) patch.highlightOverdue = overdue;
2771
+ const time = onOff('time');
2772
+ if (time !== undefined) patch.allowTime = time;
2773
+ const range = onOff('range');
2774
+ if (range !== undefined) patch.allowRange = range;
2775
+ const show = (p) => {
2776
+ const parts = [
2777
+ `${tr('정렬', 'align')}=${p.align ?? tr('기본', 'auto')}`,
2778
+ `${tr('줄바꿈', 'wrap')}=${p.wrap ? 'on' : 'off'}`,
2779
+ ];
2780
+ if (p.type === 'date') {
2781
+ parts.push(`${tr('지난 날짜 빨강', 'overdue')}=${p.highlightOverdue ? 'on' : 'off'}`);
2782
+ parts.push(`${tr('시간', 'time')}=${p.allowTime === false ? 'off' : 'on'}`);
2783
+ parts.push(`${tr('기간', 'range')}=${p.allowRange === false ? 'off' : 'on'}`);
2784
+ }
2785
+ return `${p.name} (${p.type}) ${parts.join(' ')}`;
2786
+ };
2787
+ if (!Object.keys(patch).length) {
2788
+ console.log(args.json ? JSON.stringify(prop, null, 2) : show(prop));
2789
+ return;
2790
+ }
2791
+ const updated = await client.updateColumn(id, prop.id, patch);
2792
+ console.log(args.json ? JSON.stringify(updated, null, 2) : tr(`칸을 고쳤습니다: ${show(updated)}`, `Column updated: ${show(updated)}`));
2793
+ }
2794
+
2554
2795
  /**
2555
2796
  * DB-BUTTON — 표 위의 "누르면 일하는 버튼".
2556
2797
  *
package/lib/client.js CHANGED
@@ -1071,11 +1071,11 @@ export class RootrClient {
1071
1071
  */
1072
1072
  async createDatabaseView(
1073
1073
  databaseId,
1074
- { name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale } = {},
1074
+ { name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale, density, frozenColumns } = {},
1075
1075
  ) {
1076
1076
  return this.request(`/databases/${encodeURIComponent(databaseId)}/views`, {
1077
1077
  method: 'POST',
1078
- body: { name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale },
1078
+ body: { name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale, density, frozenColumns },
1079
1079
  });
1080
1080
  }
1081
1081
 
@@ -1242,31 +1242,78 @@ export class RootrClient {
1242
1242
 
1243
1243
  async addColumn(
1244
1244
  databaseId,
1245
- { name, type, options, relationDatabaseId, reverseName, formula, rollup, prefix, searchExcluded, dimensions } = {},
1245
+ { name, type, options, relationDatabaseId, reverseName, formula, rollup, prefix, searchExcluded, dimensions, align, wrap, highlightOverdue, allowTime, allowRange } = {},
1246
1246
  ) {
1247
1247
  return this.request(`/databases/${encodeURIComponent(databaseId)}/columns`, {
1248
1248
  method: 'POST',
1249
1249
  // 계산 칸(수식·롤업·번호)은 설정이 곧 그 칸이라 같이 보내야 한다.
1250
1250
  // reverseName 은 상대 표에 짝 칸을 만든다(양방향 관계).
1251
1251
  // searchExcluded 는 "검색 색인에서 제외"(표 관리자만).
1252
- body: { name, type, options, relationDatabaseId, reverseName, formula, rollup, prefix, searchExcluded, dimensions },
1252
+ // align·wrap·highlightOverdue·allowTime·allowRange 는 칸 표시 설정(정렬·줄바꿈·지난 날짜 빨강·시간·기간).
1253
+ body: { name, type, options, relationDatabaseId, reverseName, formula, rollup, prefix, searchExcluded, dimensions, align, wrap, highlightOverdue, allowTime, allowRange },
1253
1254
  });
1254
1255
  }
1255
1256
 
1256
1257
  async updateColumn(
1257
1258
  databaseId,
1258
1259
  propertyId,
1259
- { name, options, optionRenames, formula, rollup, prefix, reverseName, searchExcluded, dedupe, dedupeShow, relationFilter } = {},
1260
+ { name, options, optionRenames, formula, rollup, prefix, reverseName, searchExcluded, dedupe, dedupeShow, relationFilter, align, wrap, highlightOverdue, allowTime, allowRange, dedupeCarry, validate, identifyProps } = {},
1260
1261
  ) {
1261
1262
  return this.request(
1262
1263
  `/databases/${encodeURIComponent(databaseId)}/columns/${encodeURIComponent(propertyId)}`,
1263
1264
  {
1264
1265
  method: 'PATCH',
1265
- body: { name, options, optionRenames, formula, rollup, prefix, reverseName, searchExcluded, dedupe, dedupeShow, relationFilter },
1266
+ // identifyProps — 제목 칸만: 다른 표에서 이 표의 행을 고를 때 제목 옆에 붙일 칸(REL-PICK-IDENTIFY).
1267
+ body: { name, options, optionRenames, formula, rollup, prefix, reverseName, searchExcluded, dedupe, dedupeShow, relationFilter, align, wrap, highlightOverdue, allowTime, allowRange, dedupeCarry, validate, identifyProps },
1266
1268
  },
1267
1269
  );
1268
1270
  }
1269
1271
 
1272
+ /** DEDUPE-CARRY(P1) — 같은 사람의 이전 행 값을 `rowId` 행으로 가져온다(`from` 없으면 같은 값의 가장 최근 행). 이전 행은 그대로. */
1273
+ async carryOver(databaseId, rowId, from) {
1274
+ return this.request(`/databases/${encodeURIComponent(databaseId)}/rows/${encodeURIComponent(rowId)}/carry-over`, {
1275
+ method: 'POST',
1276
+ body: from ? { from } : {},
1277
+ });
1278
+ }
1279
+
1280
+ /** VALUE-RULE(P2) — 지금 들어 있는 값 가운데 칸 검사에 안 맞는 것(고치지 않는다). */
1281
+ async ruleViolations(databaseId, { limit } = {}) {
1282
+ const q = limit ? `?limit=${encodeURIComponent(limit)}` : '';
1283
+ return this.request(`/databases/${encodeURIComponent(databaseId)}/rule-violations${q}`);
1284
+ }
1285
+
1286
+ /** VALUE-RULE — 미리 채운 예시 꼴(제품이 정한 규칙이 아니라 고쳐 쓸 출발점). */
1287
+ async valueRulePresets() {
1288
+ return this.request('/value-rules/presets');
1289
+ }
1290
+
1291
+ /** VALUE-RULE — 규칙을 걸기 전에 시험 값으로 돌려 본다(아무것도 저장하지 않는다). */
1292
+ async testValueRule(rule, values, type) {
1293
+ return this.request('/value-rules/test', { method: 'POST', body: { rule, values, ...(type ? { type } : {}) } });
1294
+ }
1295
+
1296
+ /**
1297
+ * COL-RENAME — 이 칸을 `name` 으로 바꾸면 함께 바뀌는 것(수식·버튼/자동 규칙 치환·다른 표 롤업·이어진 폼 질문·페이지 블록)과
1298
+ * 따라갈 수 없는 것(warnings). 아무것도 쓰지 않는다. 실제로 바꾸면 updateColumn 응답의 `renameCascade` 에 같은 모양으로 온다.
1299
+ */
1300
+ async columnRenameImpact(databaseId, propertyId, name) {
1301
+ return this.request(
1302
+ `/databases/${encodeURIComponent(databaseId)}/columns/${encodeURIComponent(propertyId)}/rename-impact?name=${encodeURIComponent(name)}`,
1303
+ );
1304
+ }
1305
+
1306
+ /**
1307
+ * REL-PICK — 관계 칸에 이을 행을 표 전체에서 제목(과 구분 칸)으로 찾는다. `{ rows: [{ id, title, subtitle }], total, hasMore }`.
1308
+ * subtitle 은 그 표 제목 칸의 identifyProps(예: 연락처 끝 4자리 · 지원일) — 같은 이름을 가른다.
1309
+ */
1310
+ async pickRows(databaseId, { q, filters, ids, limit } = {}) {
1311
+ return this.request(`/databases/${encodeURIComponent(databaseId)}/rows/pick`, {
1312
+ method: 'POST',
1313
+ body: { q, filters, ids, limit },
1314
+ });
1315
+ }
1316
+
1270
1317
  /**
1271
1318
  * DEDUPE(F5) — 중복 감지 칸 값이 같은 기존 행. `rowId` 를 주면 그 행의 지금 값으로(그 행은 빠진다), 아니면 `values`
1272
1319
  * ({칸 id 또는 이름: 값})로 찾는다. 기존 행마다 보일 칸(preview)과 이어진 행 최근 5건(history)이 함께 온다.
@@ -50,12 +50,22 @@ const GOOGLE_SCHEMA = z.object({
50
50
  busyCalendarIds: z
51
51
  .array(z.string())
52
52
  .optional()
53
- .describe('Calendars whose busy times block bookings. Only free/busy is read — never event titles.'),
53
+ .describe(
54
+ 'Calendars whose busy times block bookings. Only busy times and the dates of all-day events are read — never titles or guests.',
55
+ ),
54
56
  writeCalendarId: z
55
57
  .string()
56
58
  .nullable()
57
59
  .optional()
58
60
  .describe('Calendar that receives confirmed bookings. null = do not write anything to Google.'),
61
+ allDayBusy: z
62
+ .boolean()
63
+ .optional()
64
+ .describe(
65
+ 'All-day events block the whole day (default true). Google marks all-day events "free" by default, so busy times alone miss ' +
66
+ 'vacations, trips and holidays; a public holiday calendar in busyCalendarIds then closes holidays. Working-location, birthday ' +
67
+ 'and declined events never block. false = only busy times block.',
68
+ ),
59
69
  meet: z
60
70
  .boolean()
61
71
  .optional()
@@ -59,6 +59,8 @@ const FILTER_SHAPE = z.array(
59
59
  .optional()
60
60
  .describe(
61
61
  'Values the op needs. is/is_not/contains → one or more (OR between them). ' +
62
+ 'Text matching ignores case, spaces and brackets/punctuation ( ) [ ] · - . , / and treats ㈜ as (주). On a RELATION column, contains matches the linked rows\' ' +
63
+ 'titles (e.g. a business name) and is/is_not take linked row ids (from rootr_pick_rows) or exact titles. ' +
62
64
  'before/after/on → one date "YYYY-MM-DD". between → two dates. eq/gt/lt → one number. ' +
63
65
  'is_empty/is_not_empty/today/this_week/this_month/past/upcoming/checked/unchecked take none.',
64
66
  ),
@@ -324,9 +326,25 @@ export function registerDatabaseTools(server) {
324
326
  'Default month. The year view shows twelve mini-months at once (darker = more rows that day). ' +
325
327
  'Saved on the view, so everyone who opens it sees that span.',
326
328
  ),
329
+ density: z
330
+ .enum(['normal', 'compact'])
331
+ .nullable()
332
+ .optional()
333
+ .describe('table view: "compact" = lower rows and smaller text so more rows fit on one screen. null/"normal" = default.'),
334
+ frozenColumns: z
335
+ .number()
336
+ .int()
337
+ .min(0)
338
+ .max(3)
339
+ .nullable()
340
+ .optional()
341
+ .describe(
342
+ 'table view: keep the first N VISIBLE columns fixed on the left while scrolling sideways (Excel freeze panes). ' +
343
+ '0/null = off. e.g. 1 keeps the title column (상호명) in place.',
344
+ ),
327
345
  },
328
346
  },
329
- async ({ databaseId, name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale }) => {
347
+ async ({ databaseId, name, type, groupBy, dateProp, endDateProp, filters, sort, colWidths, calendarScale, density, frozenColumns }) => {
330
348
  try {
331
349
  const client = makeClient();
332
350
  const id = await resolveNodeId(client, databaseId);
@@ -340,6 +358,8 @@ export function registerDatabaseTools(server) {
340
358
  sort,
341
359
  colWidths,
342
360
  calendarScale,
361
+ density,
362
+ frozenColumns,
343
363
  });
344
364
  return jsonResult(view);
345
365
  } catch (err) {
@@ -382,7 +402,9 @@ export function registerDatabaseTools(server) {
382
402
  'the same table can look different per view (a "담당자" view hiding the amounts, etc). Hiding a column ' +
383
403
  'does NOT erase its values: it disappears from that view only and comes back when you unhide it. ' +
384
404
  'Columns may be given by NAME or id; the title column cannot be hidden and is ignored if listed. ' +
385
- 'Pass an empty array to show everything again. Only the fields you pass change, and other views are untouched.',
405
+ 'Pass an empty array to show everything again. Only the fields you pass change, and other views are untouched. ' +
406
+ '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).',
386
408
  annotations: { readOnlyHint: false, destructiveHint: false },
387
409
  inputSchema: {
388
410
  databaseId: z.string().describe(DATABASE_TARGET_DESC),
@@ -436,6 +458,22 @@ export function registerDatabaseTools(server) {
436
458
  'Tint rows/cards by value: the first matching rule wins. e.g. [{propId:"진행상태",op:"is",values:["채용불가"],color:"red"}, ' +
437
459
  '{propId:"면접예정일",op:"is_not_empty",color:"yellow"}]. Colors: gray, brown, orange, yellow, green, blue, purple, pink, red. null or [] turns it off.',
438
460
  ),
461
+ density: z
462
+ .enum(['normal', 'compact'])
463
+ .nullable()
464
+ .optional()
465
+ .describe('table view: "compact" = lower rows and smaller text so more rows fit on one screen. null/"normal" = default.'),
466
+ frozenColumns: z
467
+ .number()
468
+ .int()
469
+ .min(0)
470
+ .max(3)
471
+ .nullable()
472
+ .optional()
473
+ .describe(
474
+ 'table view: keep the first N VISIBLE columns fixed on the left while scrolling sideways (Excel freeze panes). ' +
475
+ '0/null = off. e.g. 1 keeps the title column (상호명) in place.',
476
+ ),
439
477
  },
440
478
  },
441
479
  async ({
@@ -452,6 +490,8 @@ export function registerDatabaseTools(server) {
452
490
  calendarScale,
453
491
  cardProps,
454
492
  rowColors,
493
+ density,
494
+ frozenColumns,
455
495
  }) => {
456
496
  try {
457
497
  const client = makeClient();
@@ -468,6 +508,8 @@ export function registerDatabaseTools(server) {
468
508
  calendarScale,
469
509
  cardProps,
470
510
  rowColors,
511
+ density,
512
+ frozenColumns,
471
513
  });
472
514
  return jsonResult({ view });
473
515
  } catch (err) {
@@ -551,6 +593,40 @@ export function registerDatabaseTools(server) {
551
593
  },
552
594
  );
553
595
 
596
+ // REL-PICK(P5) — 화면의 관계 고르기와 같은 찾기. 표 전체를 읽어 이름을 손으로 견주지 않게(9천 행이면 수 MB).
597
+ server.registerTool(
598
+ 'rootr_pick_rows',
599
+ {
600
+ title: 'Find rows to link (relation picker search)',
601
+ description:
602
+ 'Search a Rootr (루터) DATABASE for rows to put into a relation column — the same search the app\'s relation picker uses. ' +
603
+ 'Searches the WHOLE table by title: exact name → names starting with q → names containing q → the identify columns ' +
604
+ '(e.g. last 4 phone digits "5678"); newest rows first within each rank. Returns `{ rows: [{ id, title, subtitle }], total, hasMore }` ' +
605
+ 'only — `subtitle` comes from the title column\'s identifyProps (rootr_update_column) such as "***-5678 · 2026-10-02", so ' +
606
+ 'people with the same name can be told apart. Pass the relation column\'s relationFilter as `filters` to see only linkable rows, ' +
607
+ 'or `ids` to label rows you already have. Matching ignores spaces/case/brackets/punctuation and treats ㈜ as (주).',
608
+ annotations: { readOnlyHint: true, destructiveHint: false },
609
+ inputSchema: {
610
+ databaseId: z.string().describe('The TARGET database (the one the relation column points at): ' + DATABASE_TARGET_DESC),
611
+ q: z.string().optional().describe('Name (or part of it, or last phone digits). Empty → newest rows.'),
612
+ filters: z
613
+ .array(z.object({ propId: z.string(), op: z.string(), values: z.array(z.string()).optional() }))
614
+ .optional()
615
+ .describe('Optional conditions on the target table, e.g. the relation column\'s relationFilter'),
616
+ ids: z.array(z.string()).optional().describe('Only these row ids (to label them); q/filters are ignored'),
617
+ limit: z.number().optional().describe('Default 30, max 100'),
618
+ },
619
+ },
620
+ async ({ databaseId, q, filters, ids, limit }) => {
621
+ try {
622
+ const client = makeClient();
623
+ return jsonResult(await client.pickRows(await resolveNodeId(client, databaseId), { q, filters, ids, limit }));
624
+ } catch (err) {
625
+ return errorResult(err);
626
+ }
627
+ },
628
+ );
629
+
554
630
  server.registerTool(
555
631
  'rootr_add_row',
556
632
  {
@@ -908,6 +984,8 @@ export function registerDatabaseTools(server) {
908
984
  'rootr_query_vectors (Rootr never embeds it for you; people see only "vector · N dims" in the grid). ' +
909
985
  'created_time / last_edited_time / created_by / last_edited_by / unique_id / formula / rollup are filled in by the server — ' +
910
986
  'they take no value and rootr_update_row will refuse to set them. ' +
987
+ 'Display options: align (left|center|right), wrap true; date columns: highlightOverdue true (past dates red — off by default), ' +
988
+ 'allowTime false (no time), allowRange false (no end date). ' +
911
989
  'Returns the created column WITH ITS id.',
912
990
  annotations: { readOnlyHint: false, destructiveHint: false },
913
991
  inputSchema: {
@@ -1014,9 +1092,38 @@ export function registerDatabaseTools(server) {
1014
1092
  'chat, button AI-fill steps, formula/rollup columns and MCP reads still see the values; to hide values from ' +
1015
1093
  'people, use an "own rows only" table or a private node. Table managers only.',
1016
1094
  ),
1095
+ align: z
1096
+ .enum(['left', 'center', 'right'])
1097
+ .optional()
1098
+ .describe('Text alignment of this column in the table (default: numbers right, the rest left). null resets.'),
1099
+ wrap: z
1100
+ .boolean()
1101
+ .optional()
1102
+ .describe('true = long text wraps inside the cell instead of being cut off (the row grows). false/null = one line.'),
1103
+ highlightOverdue: z
1104
+ .boolean()
1105
+ .optional()
1106
+ .describe(
1107
+ 'date only: show PAST dates in red (deadline-like columns). Default OFF — application/call dates stay plain. ' +
1108
+ 'Refused (400) on a non-date column.',
1109
+ ),
1110
+ allowTime: z
1111
+ .boolean()
1112
+ .optional()
1113
+ .describe(
1114
+ 'date only: false = no 「시간」 toggle; any value written later is stored as a plain date (time dropped). ' +
1115
+ 'Existing values keep showing. true/null = allowed (default).',
1116
+ ),
1117
+ allowRange: z
1118
+ .boolean()
1119
+ .optional()
1120
+ .describe(
1121
+ 'date only: false = no 「기간」 (end date) button; values written later keep only the start date. ' +
1122
+ 'Existing ranges keep showing and can be cleared. true/null = allowed (default).',
1123
+ ),
1017
1124
  },
1018
1125
  },
1019
- async ({ databaseId, name, type, options, relationDatabaseId, formula, rollup, prefix, searchExcluded, dimensions }) => {
1126
+ async ({ databaseId, name, type, options, relationDatabaseId, formula, rollup, prefix, searchExcluded, dimensions, align, wrap, highlightOverdue, allowTime, allowRange }) => {
1020
1127
  try {
1021
1128
  const client = makeClient();
1022
1129
  const id = await resolveNodeId(client, databaseId);
@@ -1030,6 +1137,11 @@ export function registerDatabaseTools(server) {
1030
1137
  prefix,
1031
1138
  searchExcluded,
1032
1139
  dimensions,
1140
+ align,
1141
+ wrap,
1142
+ highlightOverdue,
1143
+ allowTime,
1144
+ allowRange,
1033
1145
  });
1034
1146
  return jsonResult({ property });
1035
1147
  } catch (err) {
@@ -1049,15 +1161,29 @@ export function registerDatabaseTools(server) {
1049
1161
  'The column TYPE and a vector column\'s dimensions cannot be changed — that would rewrite every row (or every stored vector) — ' +
1050
1162
  'so passing type or dimensions is refused; add a new column and move the values instead. ' +
1051
1163
  'Call rootr_read_database first to get the exact propertyId.\n' +
1164
+ 'RENAMES CASCADE: a new `name` also rewrites everything that refers to the column BY NAME, in the same transaction — ' +
1165
+ 'formulas ({col} / prop("col")) in this table, button/automation {col} {col+1} {relation.col} templates here and in ' +
1166
+ 'tables that link to this one, rollups in other tables (pinned to the column id), linked form questions (kept connected ' +
1167
+ 'by id; the question text stays) and page live blocks (liveTable/liveStats/liveChart). Do NOT edit the formula first ' +
1168
+ '(a formula naming a column that does not exist yet is refused). The result has `renameCascade` {formulas, buttons, ' +
1169
+ 'automations, rollups, forms, pages, warnings, summary}; `warnings` lists what could not follow (e.g. two form questions ' +
1170
+ 'now landing on one column). Pass dryRun: true with `name` to get that list without changing anything.\n' +
1052
1171
  '`reverseName` on a relation column pairs it with the linked table: that table gets a matching column with ' +
1053
1172
  'this name and filling either side fills the other. Pass an empty string to unpair (neither column is deleted). ' +
1054
1173
  'Use it to make an existing one-way link two-way rather than rebuilding the table — a one-way column leaves ' +
1055
- 'every aggregate on the other side wrong until somebody backfills it by hand.',
1174
+ 'every aggregate on the other side wrong until somebody backfills it by hand.\n' +
1175
+ 'Display options (only what you pass changes; null resets): align left|center|right, wrap true (long text wraps), ' +
1176
+ 'and for DATE columns highlightOverdue true (past dates in red — off by default), allowTime false (no time), ' +
1177
+ 'allowRange false (no end date). e.g. { "allowTime": false, "allowRange": false } makes 「지원일」 a plain single date.',
1056
1178
  annotations: { readOnlyHint: false, destructiveHint: false },
1057
1179
  inputSchema: {
1058
1180
  databaseId: z.string().describe(DATABASE_TARGET_DESC),
1059
1181
  propertyId: z.string().describe('Column id from rootr_read_database (NOT the header text)'),
1060
- name: z.string().optional().describe('New header text'),
1182
+ name: z.string().optional().describe('New header text — formulas, button/automation templates, rollups, linked forms and page blocks follow it'),
1183
+ dryRun: z
1184
+ .boolean()
1185
+ .optional()
1186
+ .describe('With `name` only: return what the rename would also change (renameCascade) without renaming'),
1061
1187
  options: z
1062
1188
  .array(z.object({ name: z.string(), color: z.string() }))
1063
1189
  .optional()
@@ -1107,6 +1233,40 @@ export function registerDatabaseTools(server) {
1107
1233
  'shows the latest 5 linked rows (contact history) and the total. Names or ids; an unknown name is refused (400), not dropped. ' +
1108
1234
  'null/[] = automatic (status, notes, dates, rollups; relations as history).',
1109
1235
  ),
1236
+ align: z
1237
+ .enum(['left', 'center', 'right'])
1238
+ .nullable()
1239
+ .optional()
1240
+ .describe('Text alignment of this column in the table (default: numbers right, the rest left). null resets.'),
1241
+ wrap: z
1242
+ .boolean()
1243
+ .nullable()
1244
+ .optional()
1245
+ .describe('true = long text wraps inside the cell instead of being cut off (the row grows). false/null = one line.'),
1246
+ highlightOverdue: z
1247
+ .boolean()
1248
+ .nullable()
1249
+ .optional()
1250
+ .describe(
1251
+ 'date only: show PAST dates in red (deadline-like columns). Default OFF — application/call dates stay plain. ' +
1252
+ 'Refused (400) on a non-date column.',
1253
+ ),
1254
+ allowTime: z
1255
+ .boolean()
1256
+ .nullable()
1257
+ .optional()
1258
+ .describe(
1259
+ 'date only: false = no 「시간」 toggle; any value written later is stored as a plain date (time dropped). ' +
1260
+ 'Existing values keep showing. true/null = allowed (default).',
1261
+ ),
1262
+ allowRange: z
1263
+ .boolean()
1264
+ .nullable()
1265
+ .optional()
1266
+ .describe(
1267
+ 'date only: false = no 「기간」 (end date) button; values written later keep only the start date. ' +
1268
+ 'Existing ranges keep showing and can be cleared. true/null = allowed (default).',
1269
+ ),
1110
1270
  relationFilter: z
1111
1271
  .array(z.object({ propId: z.string(), op: z.enum(['is', 'is_not_empty']), values: z.array(z.string()).optional() }))
1112
1272
  .nullable()
@@ -1115,6 +1275,48 @@ export function registerDatabaseTools(server) {
1115
1275
  'relation only: which rows of the linked table may be linked (all rules must match; propId is a column of the LINKED table). ' +
1116
1276
  'e.g. [{propId:"진행상태",op:"is",values:["채용"]}]. The picker hides others and the server refuses new links that fail. null turns it off.',
1117
1277
  ),
1278
+ dedupeCarry: z
1279
+ .record(z.any())
1280
+ .nullable()
1281
+ .optional()
1282
+ .describe(
1283
+ 'With dedupe on: "carry over previous info" (the same entry came back). When a NEW row has the same value as an earlier row, copy the ' +
1284
+ 'earlier row\'s values INTO the new row (the earlier row is untouched). { auto?: true = do it on every entry path (row add, cell, ' +
1285
+ 'form, button/automation add_row, REST/MCP/bulk) without asking; false/omitted = only the duplicate dialog button / ' +
1286
+ 'rootr_carry_over. label?: name of this action for the table (≤30 chars, e.g. "재등록"; toasts/merge comments use it; blank = neutral ' +
1287
+ 'default), exclude?: [columns not copied, e.g. "접수일"], overwrite?: [columns copied even if the new row has a value; ' +
1288
+ 'default fills empty cells only], relations?: true to copy relation columns too (default false — they are two-way), ' +
1289
+ 'latestBy?: date column or "createdAt" — which earlier row wins when several match (latest), mark?: values stamped on the new ' +
1290
+ 'row: { "재등록 여부": true, "구분": { map: { "A": "A(재등록)" }, else: "재등록" } } — map looks at the value the ' +
1291
+ 'NEW row came in with; else covers any value not in map; if that column is still EMPTY when carried, the mark waits and is ' +
1292
+ 'applied once to the first value written there later (e.g. a later button step); missing select options are added }. ' +
1293
+ 'Names or ids. null turns it off.',
1294
+ ),
1295
+ validate: z
1296
+ .record(z.any())
1297
+ .nullable()
1298
+ .optional()
1299
+ .describe(
1300
+ 'Value check for NEW writes to this column — it only FLAGS a wrong input: the value is saved exactly as typed and the ' +
1301
+ 'response carries warnings[] (send checkWarnings: true on row PATCH/form submit to get 422 VALUE_WARNINGS first). The user ' +
1302
+ 'writes the rule: { kind: "pattern", pattern: "<regex the WHOLE value must match>", flags?: "i"|"s"|"m", negate?: true ' +
1303
+ '(flag values that CONTAIN the pattern instead), message?: "{name}: {value} …" } — linear-time engine, no back-references/' +
1304
+ 'look-arounds, max 500 chars; starting points via rootr_test_value_rule { presets: true } (email, URL, intl/KR/US phone, ' +
1305
+ 'postal codes …); formats differ per country, so pick/adapt one rather than assuming Korean formats. Other kinds: ' +
1306
+ '"range" (number: min/max), "date" (text reads as a date), "birthdate" (text/date; min/max = age). askDeleteRow?: true — ' +
1307
+ 'the in-app dialog also offers deleting the row. Applies to cells, row panel, forms, REST/MCP/bulk, buttons/automations. ' +
1308
+ 'Existing values are not changed. null turns it off.',
1309
+ ),
1310
+ identifyProps: z
1311
+ .array(z.object({ propId: z.string(), mask: z.enum(['last4']).optional() }))
1312
+ .nullable()
1313
+ .optional()
1314
+ .describe(
1315
+ 'TITLE column only: up to 3 columns of THIS table shown next to the title wherever another table picks one of these rows ' +
1316
+ '(relation picker, relation filter, rootr_pick_rows `subtitle`) so same-name rows can be told apart. mask "last4" shows only ' +
1317
+ 'the last 4 digits (***-5678) — use it for phone numbers. Names or ids; unknown/relation/files columns are refused (400). ' +
1318
+ 'e.g. on 지원자.이름: [{propId:"연락처",mask:"last4"},{propId:"지원일"}]. null/[] turns it off.',
1319
+ ),
1118
1320
  },
1119
1321
  },
1120
1322
  async ({
@@ -1131,8 +1333,17 @@ export function registerDatabaseTools(server) {
1131
1333
  dedupe,
1132
1334
  dedupeShow,
1133
1335
  relationFilter,
1336
+ align,
1337
+ wrap,
1338
+ highlightOverdue,
1339
+ allowTime,
1340
+ allowRange,
1341
+ dedupeCarry,
1342
+ validate,
1343
+ identifyProps,
1134
1344
  type,
1135
1345
  dimensions,
1346
+ dryRun,
1136
1347
  }) => {
1137
1348
  // 조용히 무시하지 않는다 — 못 바꾸는 것을 넘기면 까닭과 할 일을 말한다(벡터 차원을 바꾸면 저장된 벡터가 전부 틀려진다).
1138
1349
  if (type !== undefined || dimensions !== undefined) {
@@ -1146,6 +1357,11 @@ export function registerDatabaseTools(server) {
1146
1357
  try {
1147
1358
  const client = makeClient();
1148
1359
  const id = await resolveNodeId(client, databaseId);
1360
+ // COL-RENAME — 미리 보기: 이름을 바꾸면 무엇이 함께 바뀌는지(쓰지 않는다).
1361
+ if (dryRun) {
1362
+ if (typeof name !== 'string' || !name.trim()) return errorResult(new Error('dryRun 은 name(바꿀 이름)과 함께 보내 주세요.'));
1363
+ return jsonResult({ dryRun: true, renameCascade: await client.columnRenameImpact(id, propertyId, name) });
1364
+ }
1149
1365
  const property = await client.updateColumn(id, propertyId, {
1150
1366
  name,
1151
1367
  options,
@@ -1158,8 +1374,18 @@ export function registerDatabaseTools(server) {
1158
1374
  dedupe,
1159
1375
  dedupeShow,
1160
1376
  relationFilter,
1377
+ align,
1378
+ wrap,
1379
+ highlightOverdue,
1380
+ allowTime,
1381
+ allowRange,
1382
+ dedupeCarry,
1383
+ validate,
1384
+ identifyProps,
1161
1385
  });
1162
- return jsonResult({ property });
1386
+ // COL-RENAME — 함께 바뀐 것을 위로 꺼내 둔다(에이전트가 경고를 놓치지 않게).
1387
+ const { renameCascade, ...column } = property ?? {};
1388
+ return jsonResult(renameCascade ? { property: column, renameCascade } : { property });
1163
1389
  } catch (err) {
1164
1390
  return errorResult(err);
1165
1391
  }
@@ -1181,7 +1407,7 @@ export function registerDatabaseTools(server) {
1181
1407
  'and spaces. Give `rowId` to check an existing row (it is excluded from the result) or `values` ({column name or id: value}) ' +
1182
1408
  'to check before adding. Each match comes with the fields the duplicate dialog shows (`preview`: status, notes, dates, rollups ' +
1183
1409
  '— or the column\'s dedupeShow list) and `history`: the latest 5 linked rows per relation column (e.g. call log) with the total. ' +
1184
- 'Only rows the caller can see are returned. Then use rootr_merge_rows or rootr_keep_duplicate.',
1410
+ 'Only rows the caller can see are returned. Then use rootr_merge_rows, rootr_keep_duplicate or rootr_carry_over (copy the earlier row into the new one).',
1185
1411
  annotations: { readOnlyHint: true, destructiveHint: false },
1186
1412
  inputSchema: {
1187
1413
  databaseId: z.string().describe(DATABASE_TARGET_DESC),
@@ -1208,7 +1434,7 @@ export function registerDatabaseTools(server) {
1208
1434
  description:
1209
1435
  'Merge row `rowId` into row `into` of the same Rootr (루터) DATABASE — the "same person applied again" case. Empty cells of ' +
1210
1436
  '`into` are filled from `rowId` (existing values are kept), relation/multi-select/people/files are unioned (so a call log linked ' +
1211
- 'to the new row moves to the old one), a "🔁 재지원" comment listing the new values is left on `into`, and `rowId` is DELETED ' +
1437
+ 'to the new row moves to the old one), a "🔁 <dedupeCarry.label or 다시 들어옴>" comment listing the new values is left on `into`, and `rowId` is DELETED ' +
1212
1438
  '(row history records it as merged into `into`). Both rows must be editable by the caller. Not reversible except by the table history.',
1213
1439
  annotations: { readOnlyHint: false, destructiveHint: true },
1214
1440
  inputSchema: {
@@ -1253,6 +1479,88 @@ export function registerDatabaseTools(server) {
1253
1479
  },
1254
1480
  );
1255
1481
 
1482
+ server.registerTool(
1483
+ 'rootr_carry_over',
1484
+ {
1485
+ title: "Carry over a person's previous row into this row",
1486
+ description:
1487
+ 'The "이전 정보 가져오기" choice: copy the values of an EARLIER row of the same person (same dedupe value, e.g. phone) ' +
1488
+ 'INTO row `rowId`, following the dedupe column\'s dedupeCarry settings (excluded columns such as the entry date, ' +
1489
+ 'marks such as 재등록 여부=true / 구분 A→A(재등록)). The earlier row is NOT changed and nothing is linked. ' +
1490
+ 'Without `from` the most recent matching row (latestBy, else creation time) is used. Row history records it as carried from that ' +
1491
+ 'row. Tables with dedupeCarry.auto do this by themselves on every write path — use this for tables without auto or to pick a ' +
1492
+ 'specific earlier row. Only rows the caller can see are used.',
1493
+ annotations: { readOnlyHint: false, destructiveHint: false },
1494
+ inputSchema: {
1495
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
1496
+ rowId: z.string().describe('The new row that receives the values'),
1497
+ from: z.string().optional().describe('The earlier row to copy from (default: latest row with the same dedupe value)'),
1498
+ },
1499
+ },
1500
+ async ({ databaseId, rowId, from }) => {
1501
+ try {
1502
+ const client = makeClient();
1503
+ const id = await resolveNodeId(client, databaseId);
1504
+ return jsonResult(await client.carryOver(id, rowId, from));
1505
+ } catch (err) {
1506
+ return errorResult(err);
1507
+ }
1508
+ },
1509
+ );
1510
+
1511
+ server.registerTool(
1512
+ 'rootr_rule_violations',
1513
+ {
1514
+ title: 'List existing values that break column validation rules',
1515
+ description:
1516
+ 'Read-only. Lists cells of a Rootr (루터) DATABASE whose CURRENT value breaks the column\'s validate rule (rootr_update_column ' +
1517
+ 'validate: pattern / range / date / birthdate). Rules only check new writes, so values entered before the rule (or imported) ' +
1518
+ 'stay as they were — use this to find and fix them. Returns { checkedColumns, total, items: [{ rowId, title, propName, value, message }] }.',
1519
+ annotations: { readOnlyHint: true, destructiveHint: false },
1520
+ inputSchema: {
1521
+ databaseId: z.string().describe(DATABASE_TARGET_DESC),
1522
+ limit: z.number().int().min(1).max(1000).optional().describe('Max items (default 200)'),
1523
+ },
1524
+ },
1525
+ async ({ databaseId, limit }) => {
1526
+ try {
1527
+ const client = makeClient();
1528
+ const id = await resolveNodeId(client, databaseId);
1529
+ return jsonResult(await client.ruleViolations(id, { limit }));
1530
+ } catch (err) {
1531
+ return errorResult(err);
1532
+ }
1533
+ },
1534
+ );
1535
+
1536
+ server.registerTool(
1537
+ 'rootr_test_value_rule',
1538
+ {
1539
+ title: 'Try a column validation rule on sample values (or list example patterns)',
1540
+ description:
1541
+ 'Read-only, saves nothing. Runs a validate rule (the same shape rootr_update_column takes, e.g. { kind: "pattern", pattern: ' +
1542
+ '"\\\\+\\\\d{1,3} .+", message: "…" }) against sample values and returns { rule, results: [{ value, ok, message? }] } — use it to ' +
1543
+ 'check a pattern before putting it on a column. `presets: true` returns example patterns to start from (email, URL, ' +
1544
+ 'international / KR / US phone, postal codes, digits only, …); formats differ per country and company, so adapt them.',
1545
+ annotations: { readOnlyHint: true, destructiveHint: false },
1546
+ inputSchema: {
1547
+ rule: z.record(z.any()).optional().describe('The validate rule to try (omit with presets: true)'),
1548
+ values: z.array(z.union([z.string(), z.number()])).max(20).optional().describe('Up to 20 sample values'),
1549
+ type: z.string().optional().describe('Column type the rule is for (default "text"; "number" for range)'),
1550
+ presets: z.boolean().optional().describe('true = list example patterns instead'),
1551
+ },
1552
+ },
1553
+ async ({ rule, values, type, presets }) => {
1554
+ try {
1555
+ const client = makeClient();
1556
+ if (presets || !rule) return jsonResult(await client.valueRulePresets());
1557
+ return jsonResult(await client.testValueRule(rule, values ?? [], type));
1558
+ } catch (err) {
1559
+ return errorResult(err);
1560
+ }
1561
+ },
1562
+ );
1563
+
1256
1564
  // ── ROW-HISTORY / RESTORE(F6·F7·G3) — 행·표 이력과 되돌리기 ─────────────────
1257
1565
 
1258
1566
  server.registerTool(
@@ -26,7 +26,11 @@ import {
26
26
  const BLOCK_GUIDE =
27
27
  'A page is `blocks[]` (ordered, max 200) + `config`. Each block is {id?, type, props}. ' +
28
28
  'You assemble a page from these block types:\n' +
29
- '· hero {title, subtitle?, align?:"left"|"center", size?:"normal"|"screen", background?:{kind:"color",color}|{kind:"image",attachmentId}, button?:{label,target}} — the opening banner. ' +
29
+ '· hero {title, subtitle?, align?:"left"|"center", size?:"normal"|"screen", background?:{kind:"color",color}|{kind:"image",attachmentId}|{kind:"video",attachmentId,posterAttachmentId?,portraitAttachmentId?,portraitPosterAttachmentId?}, button?:{label,target}} — the opening banner. ' +
30
+ 'background kind:"video" = a looping MOTION-GRAPHIC background (autoplay, muted, loop, inline, no controls; same dark shade and white text as an image; ' +
31
+ 'covers the full-screen hero too). posterAttachmentId (an image) is shown while loading and INSTEAD of the video for visitors who ask for reduced motion or whose browser blocks autoplay — ' +
32
+ 'make the poster the video\'s first frame. portraitAttachmentId (+ portraitPosterAttachmentId) is an optional PHONE/PORTRAIT cut (e.g. 1080×1920): ' +
33
+ 'a portrait screen plays it and a landscape screen plays the default one — only ONE file is downloaded. Without it, phones crop the middle strip of a 16:9 video. ' +
30
34
  'size:"screen" makes it a FULL-SCREEN landing hero: edge to edge and one viewport tall (minus the top name bar, capped at 1200px), big headline, content centred vertically ' +
31
35
  '(default "normal" = a compact banner inside the text column)\n' +
32
36
  '· text {body (markdown), title?} — prose section; markdown is sanitized when rendered\n' +
@@ -57,6 +61,14 @@ const BLOCK_GUIDE =
57
61
  'needs on a short phone and nothing is clipped. Inside, take the frame height with `height:100%` (html and body already are 100%) ' +
58
62
  'and centre the content with flexbox. bleed:true + minHeight:"screen" = a full-screen section; stack several for a ' +
59
63
  'section-per-screen landing page.\n' +
64
+ '· video {attachmentId, posterAttachmentId?, caption?, aspect?:"16:9"|"9:16"|"1:1"|"4:5" (default "16:9"), bleed?:boolean} — a MOTION-GRAPHIC clip ' +
65
+ '(always autoplay, muted, loop, playsinline, no controls — it is a moving picture, not a talk with sound). bleed:true spans the full page width. ' +
66
+ 'Same poster / reduced-motion rule as the hero video background.\n' +
67
+ 'VIDEOS (hero video background, video block): upload the file first — POST /v1/attachments/upload?workspaceId=<ws>&nodeId=<page>&filename=a.mp4&mimeType=video/mp4 ' +
68
+ 'with the raw bytes as the body and Content-Type application/octet-stream (H.264 MP4 with +faststart plays everywhere incl. iPhone). ' +
69
+ 'The attachment must belong to the page\'s workspace (else 400 PAGE_MEDIA_INVALID; a poster must be an image). Videos on pages need the TEAM plan ' +
70
+ '(Free → 403 PLAN_UPGRADE_REQUIRED, same as slide videos). Read responses carry a short-lived `vt` playback signature on each video spot; ' +
71
+ 'the bare /raw address of a video answers 403 (hotlink protection) — never store `vt`, it is dropped on save.\n' +
60
72
  'LAYOUT: any block takes `width:"full"|"twoThirds"|"half"|"third"` — neighbouring blocks sit side by side (phones stack them). ' +
61
73
  'That is how a page becomes a DASHBOARD: a row of `chart:"kpi"` blocks at width "third", then charts at width "half". ' +
62
74
  'A hero with size:"screen" and a custom block with bleed:true always take the whole row — `width` is ignored (not stored) on them.\n' +
@@ -90,10 +102,15 @@ const BLOCK_GUIDE =
90
102
  'saved; it is never stored as a silently empty block. `column`/`labelColumn`/' +
91
103
  '`valueColumn`/`orderBy.column` are the column NAMES as shown in the table (not column ids). A table or form that ' +
92
104
  'sits in a PRIVATE subtree is silently skipped on the published page — that is deliberate, never a bug to work around.\n' +
93
- 'LINKS: every `target` is {kind:"url", href} (absolute http(s)://, mailto: or tel: only) or {kind:"node", nodeId} ' +
94
- 'for a document in the same workspace. Internal path strings are rejected. A node link renders as the in-app ' +
105
+ 'LINKS: every `target` is {kind:"url", href} (absolute http(s)://, mailto: or tel: only), {kind:"node", nodeId} ' +
106
+ 'for a document in the same workspace, or {kind:"block", blockId} to JUMP TO ANOTHER BLOCK ON THIS SAME PAGE ' +
107
+ '(e.g. a hero button "Book a free consult" → the booking block further down: smooth scroll in the same tab, no new tab — ' +
108
+ 'use this for landing pages opened in Instagram/Facebook in-app browsers). blockId must be the id of a block on this page ' +
109
+ '(else 400) — when you create the page, give the target block an explicit `id` (e.g. "booking") so you can point at it. ' +
110
+ 'Internal path strings are rejected. A url link opens in a new tab; a node link renders as the in-app ' +
95
111
  'document for members, as the public address on the published page, and is hidden entirely if that document is ' +
96
- 'not published — so link to nodes rather than pasting addresses.\n' +
112
+ 'not published — so link to nodes rather than pasting addresses. Inside a text block\'s markdown, `[label](#<blockId>)` ' +
113
+ 'is a same-page link to that block.\n' +
97
114
  'CONFIG: {theme, accentColor?, navEnabled?, metaPixelId?}. theme is one of classic | minimal | warm | pastel | editorial | mono | ' +
98
115
  'dark | midnight. accentColor is a hex like "#1A4A7A"; leave it out and the theme picks a matching accent.' +
99
116
  ' metaPixelId is the numeric Meta (Facebook/Instagram) Pixel ID: when set, the PUBLISHED page reports PageView and, when a visitor books through a booking block, Schedule to that pixel (never in the editor); send "" to turn it off. ' +
@@ -42,10 +42,18 @@ const ACCESS_HINT =
42
42
  '`searchExcluded: true` (values stay out of the search index behind ask / AI explore / similar rows — not a read permission) and a `type: "vector"` ' +
43
43
  'property needs `dimensions` (an embedding column agents fill with rootr_set_row_vectors). ' +
44
44
  'A phone/email/name property may carry `dedupe: true` (warn when the same person is entered again) with ' +
45
- '`dedupeShow: ["진행상태","특이사항","콜 이력"]` (fields of the existing row the warning shows — a relation shows its latest 5 rows); ' +
45
+ '`dedupeShow: ["상태","메모","통화 기록"]` (fields of the existing row the warning shows — a relation shows its latest 5 rows) and ' +
46
+ '`dedupeCarry: { label: "재등록", auto: true, exclude: ["접수일"], mark: { "재등록 여부": true, "구분": { map: { "A": "A(재등록)" }, else: "재등록" } } }` ' +
47
+ '(same entry came back: copy the earlier row into the new one — see rootr_update_column); any property may carry ' +
48
+ '`validate: { kind: "pattern"|"range"|"date"|"birthdate", pattern?, flags?, negate?, min?, max?, message? }` (flags a wrong value on every new write, never blocks); ' +
46
49
  'a relation property may carry `relationFilter: [{ propId, op: "is"|"is_not_empty", values? }]` (which linked rows may be picked). ' +
50
+ 'The title property may carry `identifyProps: [{ propId: "연락처", mask: "last4" }, { propId: "지원일" }]` (shown next to the name ' +
51
+ 'wherever another table picks these rows, so same-name people can be told apart). ' +
47
52
  'A board view config may carry `cardProps: ["면접예정일","연락처"]` (fields on each card, rollups too) and any view `rowColors` ' +
48
53
  '(`[{ propId, op: "is"|"is_not_empty", values?, color }]`). ' +
54
+ 'Any property may carry `align: "left"|"center"|"right"` and `wrap: true`; a date property `highlightOverdue: true` ' +
55
+ '(past dates red — off by default), `allowTime: false` (no time) and `allowRange: false` (no end date). A table view ' +
56
+ 'config may carry `density: "compact"` and `frozenColumns: 1` (first N visible columns fixed while scrolling sideways). ' +
49
57
  'A DOCUMENT\'s `content` may embed a live ' +
50
58
  'table/sheet/deck with a line `[[embed:<name of a node in this same tree>]]` or `[[embed:/absolute/path]]` — it is ' +
51
59
  'linked to the real node after the whole tree is built (an unknown name rolls the whole tree back).';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rootr-cli",
3
- "version": "0.25.0",
3
+ "version": "0.26.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": {