rootr-cli 0.24.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/bin/rootr.js CHANGED
@@ -6,6 +6,7 @@ import { dirname, join, basename, extname } from 'node:path';
6
6
  import { loadConfig, saveConfig, keyForWorkspace, maskKey, upsertKey, CONFIG_PATH, DEFAULT_BASE_URL } from '../lib/config.js';
7
7
  import { candidateClients, rememberWorkspace, resolveWorkspaceSwitch, surveyWorkspaces } from '../lib/credentials.js';
8
8
  import { RootrClient, RootrApiError, imageMarkdown } from '../lib/client.js';
9
+ import { setGlobalDocMailSink } from '../lib/context.js';
9
10
  import { resolveTarget, requireResolved, resolveMoveDestination } from '../lib/resolve.js';
10
11
  import { t as tr, lang } from '../lib/i18n.js';
11
12
 
@@ -39,7 +40,8 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
39
40
  rootr db button-run <path|id> <buttonId> [--rows id,id] 버튼 한 번 누르기 (행 만들기·값 채우기·AI 채우기)
40
41
  rootr db automations <path|id> [--file rules.json] [--clear] 자동 규칙 — "값이 이렇게 되면 이 일을 해라"·정해진 시각
41
42
  rootr db import-sheets <파일.xlsx> --ws <id> [--dry] 시트 수백 장짜리 엑셀을 표 하나로 접어 가져오기
42
- 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] 칸 정렬·줄바꿈·지난 날짜 빨강·시간·기간
43
45
  rootr db row-add <path|id> --values '<json>' [--body <md>|--body-file <f>] 행 추가(상세 내용 포함)
44
46
  rootr db row-body <path|id> <rowId> [--file <f>] 행의 상세 내용(본문 메모) 쓰기 — 없으면 표준입력
45
47
  rootr db rows-new <path|id> --json '[{…},…]' | --file <파일> 여러 행 한 번에 넣기 (하나라도 틀리면 전부 취소)
@@ -49,6 +51,11 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
49
51
  rootr db duplicates <path|id> (--row <rowId> | --values '<json>') [--json] 같은 사람(중복 감지 칸 값이 같은 행)과 그 이력
50
52
  rootr db merge <path|id> <rowId> --into <기존 rowId> 중복 행을 기존 행에 합치기(빈 칸만 채움, rowId 행은 지움)
51
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: 예시 꼴 목록
52
59
  rootr db history <path|id> [--row <rowId>] [--limit N] [--json] 행·표 변경 이력(누가·언제·어떻게·전후 값)
53
60
  rootr db restore <path|id> --at <ISO 시각> [--yes] 표 전체(행+칸·보기·버튼·규칙)를 그 시각으로 — --yes 없으면 미리보기만
54
61
  rootr db row-page <rowId> [--type RECORDING] 이 행만 원하는 타입으로 상세 붙이기(녹음·스프레드시트·서명문서…)
@@ -95,11 +102,16 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
95
102
  rootr sign list [--pending] [--status <S>] [--mode <M>] [--json] 서명문서 목록 (--pending: 내 차례)
96
103
  rootr sign bulk <id…> --code <6자리> 여러 건 한 번에 올리기+서명 (코드 하나로, 초안 포함)
97
104
  rootr sign ledger [--json] 워크스페이스 원장(해시 체인) 전체 검증
98
- rootr send <path|id> --to a@x.com[,b@y.com] [--message "..."] 문서(어떤 타입이든)를 이메일로 보내기 — 보낼 때의 복사본, 계정 없어도 열림
105
+ rootr send <path|id> --to a@x.com[,b@y.com] [--access read|comment|write] [--message "..."]
106
+ 문서(어떤 타입이든)를 이메일로 보내기 — 복사본이 아니라 원본을 그 이메일에 열어 줌(기본 보기)
99
107
  rootr mail [inbox|sent] [--limit N] [--q <찾기>] [--json] 문서함 — 받은 문서(기본)·보낸 문서
100
- rootr mail show <id> [--json] 받은/보낸 문서 열기(문서면 본문, 그 밖은 요약) — 받은 것은 읽음 처리
101
- rootr mail revoke <id> 보낸 문서 회수 — 받는 쪽에서 본문이 안 보임
102
- rootr mail hide <id> [--box inbox|sent] 내 문서함에서만 지우기(상대 화면은 그대로)
108
+ rootr mail show <id> [--node <아래 노드 id>] [--json] 받은/보낸 문서의 지금 내용(문서면 본문, 그 밖은 요약) — 받은 것은 읽음 처리
109
+ rootr mail access <id> read|comment|write 보낸 문서의 받는 사람 권한 바꾸기
110
+ rootr mail revoke <id> 보낸 문서 회수 — 받는 사람이 원본을 더 못 연다
111
+ rootr mail hide <id> [--box inbox|sent] 내 문서함에서만 지우기(받은 것을 지우면 그 계정으로는 더 안 열린다)
112
+ rootr mail webhook add <url> [--events …] | list | rm <id> | test <id>
113
+ 받은 문서·회수·읽음을 내 주소로 알려 주는 웹훅(내 계정 것, HMAC 서명)
114
+ ※ 다른 명령 뒤 새로 받은 문서가 있으면 stderr 로 한 줄 알림(끄기: ROOTR_MAIL_NOTICE=0)
103
115
  rootr config [--api-key <k>] [--workspace <w>] [--base-url <u>] 설정 조회/저장
104
116
  rootr config --add-key <k> [--label <name>] [--workspace <w>] 워크스페이스별 키 추가 등록
105
117
  rootr feedback "<무엇이 안 되는지>" [--kind bug|friction|idea|praise] [--email <주소>]
@@ -151,7 +163,8 @@ usage:
151
163
  rootr db button-run <path|id> <buttonId> [--rows id,id] press a button once (create rows, fill values, AI fill)
152
164
  rootr db automations <path|id> [--file rules.json] [--clear] automatic rules — "when a value becomes X, do Y", or at a set time
153
165
  rootr db import-sheets <file.xlsx> --ws <id> [--dry] fold a one-sheet-per-record workbook into ONE table
154
- 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
155
168
  rootr db row-add <path|id> --values '<json>' [--body <md>|--body-file <f>] add a row (with its body)
156
169
  rootr db row-body <path|id> <rowId> [--file <f>] write a row's body — reads stdin if no file
157
170
  rootr db rows-new <path|id> --json '[{…},…]' | --file <file> insert many rows at once (one bad row cancels all)
@@ -159,8 +172,15 @@ usage:
159
172
  rootr db rows-each <path|id> --json '[{"rowId":…,"values":{…}},…]' a different value per row, in one call
160
173
  rootr db rows-rm <path|id> --ids <id,id> [--yes] delete chosen rows at once — one blocked row rejects all
161
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)
162
177
  rootr db merge <path|id> <rowId> --into <existing rowId> merge a duplicate into the existing row (fills blanks, deletes rowId)
163
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
164
184
  rootr db history <path|id> [--row <rowId>] [--limit N] [--json] change history (who, when, how, before/after)
165
185
  rootr db restore <path|id> --at <ISO time> [--yes] restore the whole table (rows + columns/views/buttons/rules) — preview without --yes
166
186
  rootr db row-page <rowId> [--type RECORDING] give this row a typed detail page (recording, spreadsheet, signed doc…)
@@ -206,11 +226,13 @@ usage:
206
226
  rootr sign list [--pending] [--status <S>] [--mode <M>] [--json] list signed documents (--pending: waiting on you)
207
227
  rootr sign bulk <id…> --code <6 digits> submit+sign many at once (one code, drafts included)
208
228
  rootr sign ledger [--json] verify the whole workspace ledger (hash chain)
209
- rootr send <path|id> --to a@x.com[,b@y.com] [--message "..."] email any node as a frozen copy — opens without an account
229
+ rootr send <path|id> --to a@x.com[,b@y.com] [--access read|comment|write] [--message "..."]
230
+ give email addresses access to any node — the original itself, not a copy (default: read)
210
231
  rootr mail [inbox|sent] [--limit N] [--q <text>] [--json] your document mailbox — received (default) or sent
211
- rootr mail show <id> [--json] open a received/sent document (body for docs, a summary otherwise); marks received ones read
212
- rootr mail revoke <id> withdraw a document you sent — the recipient no longer sees the content
213
- rootr mail hide <id> [--box inbox|sent] remove from your own mailbox only (the other side keeps it)
232
+ rootr mail show <id> [--node <child id>] [--json] the document's current content (body for docs, a summary otherwise); marks received ones read
233
+ rootr mail access <id> read|comment|write change what the recipient of a document you sent can do
234
+ rootr mail revoke <id> withdraw a document you sent — the recipient can no longer open the original
235
+ rootr mail hide <id> [--box inbox|sent] remove from your own mailbox (a received one then stops opening for your account)
214
236
  rootr config [--api-key <k>] [--workspace <w>] [--base-url <u>] read/save configuration
215
237
  rootr config --add-key <k> [--label <name>] [--workspace <w>] register an extra per-workspace key
216
238
  rootr feedback "<what is not working>" [--kind bug|friction|idea|praise] [--email <address>]
@@ -1638,6 +1660,44 @@ async function cmdDbDuplicates(args) {
1638
1660
  }
1639
1661
  }
1640
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
+
1641
1701
  /** DEDUPE — `rootr db merge <표> <rowId> --into <기존 rowId>` */
1642
1702
  async function cmdDbMerge(args) {
1643
1703
  const [, target, rowId] = args._;
@@ -1653,6 +1713,43 @@ async function cmdDbMerge(args) {
1653
1713
  );
1654
1714
  }
1655
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
+
1656
1753
  /** ROW-HISTORY — `rootr db history <표> [--row <rowId>] [--limit N] [--json]` */
1657
1754
  async function cmdDbHistory(args) {
1658
1755
  const target = args._[1];
@@ -1698,6 +1795,75 @@ async function cmdDbRestore(args) {
1698
1795
  console.log(tr('되돌렸습니다.', 'Restored.'));
1699
1796
  }
1700
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
+
1701
1867
  /** DEDUPE-KEEP — `rootr db keep-duplicate <표> <rowId> --from <이전 rowId>` */
1702
1868
  async function cmdDbKeepDuplicate(args) {
1703
1869
  const [, target, rowId] = args._;
@@ -1749,6 +1915,18 @@ async function cmdPublish(args) {
1749
1915
  console.log(tr('이 종류는 공개할 수 없습니다(폴더).', `This node type cannot be published (folder).`));
1750
1916
  return;
1751
1917
  }
1918
+ if (st.closed === 'PAGE_PUBLISH_REQUIRES_TEAM') {
1919
+ // PAGE-PUBLISH-TEAM — 공개는 켜져 있지만 워크스페이스가 무료라 방문자에게 안 보인다. Team 으로 올리면 같은 주소가 다시 열린다.
1920
+ console.log(tr(`공개가 켜져 있지만 지금은 방문자에게 보이지 않습니다(주소: /p/${st.slug}).`, `Publishing is on, but visitors cannot see it right now (address: /p/${st.slug}).`));
1921
+ console.log(
1922
+ ' ' +
1923
+ tr(
1924
+ '페이지를 웹에 게시하는 기능은 Team 플랜에서만 됩니다. Team 으로 올리면 다시 발행하지 않아도 같은 주소로 열립니다.',
1925
+ 'Publishing pages to the web needs the Team plan. Upgrade to Team and the same address opens again — no need to publish again.',
1926
+ ),
1927
+ );
1928
+ return;
1929
+ }
1752
1930
  if (st.closed) {
1753
1931
  // 공개를 끈 것과 다르다 — 설정은 켜져 있고, "본인이 만든 행만" 표의 행에서 나온 것이라 지금 닫혀 있다(서버 응답의 closed)
1754
1932
  console.log(tr(`공개가 켜져 있지만 지금은 닫혀 있습니다(주소: /p/${st.slug}).`, `Publishing is on, but it is closed right now (address: /p/${st.slug}).`));
@@ -1833,6 +2011,15 @@ async function cmdPublish(args) {
1833
2011
  });
1834
2012
  console.log(tr(`공개했습니다: /p/${res.slug}`, `Published: /p/${res.slug}`));
1835
2013
  if (args.children) console.log(tr(' 이 문서 아래 전부(손자·증손자 포함)도 각자 주소로 공개됩니다.', ` Everything under this document (grandchildren included) gets its own public address.`));
2014
+ if (res.skippedPagesRequireTeam) {
2015
+ // PAGE-PUBLISH-TEAM — 무료 워크스페이스에서는 아래의 페이지(PAGE)는 따라 나가지 않는다.
2016
+ console.log(
2017
+ tr(
2018
+ ` 단, 아래의 페이지 ${res.skippedPagesRequireTeam}개는 공개하지 않았습니다 — 페이지를 웹에 게시하는 기능은 Team 플랜부터입니다.`,
2019
+ ` ${res.skippedPagesRequireTeam} page(s) underneath were NOT published — publishing pages to the web needs the Team plan.`,
2020
+ ),
2021
+ );
2022
+ }
1836
2023
  if (Object.keys(design).length) {
1837
2024
  console.log(tr(` 보이는 방식: ${res.design?.layout ?? 'table'} / ${res.design?.skin ?? 'clean'}`, ` looks like: ${res.design?.layout ?? 'table'} / ${res.design?.skin ?? 'clean'}`));
1838
2025
  }
@@ -1956,7 +2143,7 @@ async function cmdBooking(args) {
1956
2143
  ` [--duration 30] [--buffer 10] [--calendar-buffer 60] [--notice 2] [--ahead 60] [--per-day 4]\n` +
1957
2144
  ` [--block 2026-10-03,2026-10-09] [--approval instant|manual] [--tz Asia/Seoul]\n` +
1958
2145
  ` [--block-time "daily 12:00-13:00=점심, mon 09:00-11:00, fri"]\n` +
1959
- ` [--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` +
1960
2147
  ` [--self-move on|off] [--remind 1d,1h (안 보내려면 --remind off)]\n` +
1961
2148
  ` [--types "30분 상담=30m,데모=60m|화면 보며 설명"] [--guests on|off] [--max-guests 5] [--track-source on|off]\n` +
1962
2149
  ` [--after https://example.com/thanks]\n` +
@@ -1968,7 +2155,7 @@ async function cmdBooking(args) {
1968
2155
  ` [--duration 30] [--buffer 10] [--calendar-buffer 60] [--notice 2] [--ahead 60] [--per-day 4]\n` +
1969
2156
  ` [--block 2026-10-03,2026-10-09] [--approval instant|manual] [--tz Asia/Seoul]\n` +
1970
2157
  ` [--block-time "daily 12:00-13:00=lunch, mon 09:00-11:00, fri"]\n` +
1971
- ` [--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` +
1972
2159
  ` [--self-move on|off] [--remind 1d,1h (--remind off to stop)]\n` +
1973
2160
  ` [--types "Intro=30m,Demo=60m|walk through the product"] [--guests on|off] [--max-guests 5] [--track-source on|off]\n` +
1974
2161
  ` [--after https://example.com/thanks]\n` +
@@ -2022,6 +2209,7 @@ async function cmdBooking(args) {
2022
2209
  : tr(' 미리 알림: 안 보냄', ' reminders: off'),
2023
2210
  );
2024
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'));
2025
2213
  const mailTo = [
2026
2214
  ...(b.notifyVisitor === false ? [] : [tr('방문자', 'visitor')]),
2027
2215
  ...(b.notifyEmails ?? []),
@@ -2088,6 +2276,9 @@ async function cmdBooking(args) {
2088
2276
  if (args.block !== undefined) body.blockedDates = String(args.block).split(',').map((x) => x.trim()).filter(Boolean);
2089
2277
  // 목록을 통째로 바꾼다 — 빈 값을 주면 다 지운다.
2090
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
+ }
2091
2282
  if (args['notify-visitor'] !== undefined) body.notifyVisitor = args['notify-visitor'] !== 'off' && args['notify-visitor'] !== false;
2092
2283
  if (args['self-cancel'] !== undefined) body.allowVisitorCancel = args['self-cancel'] !== 'off' && args['self-cancel'] !== false;
2093
2284
  if (args['self-move'] !== undefined) body.allowVisitorReschedule = args['self-move'] !== 'off' && args['self-move'] !== false;
@@ -2330,8 +2521,15 @@ async function cmdDb(args) {
2330
2521
  if (sub === 'rows-set') return cmdDbRowsSet(args);
2331
2522
  if (sub === 'rows-rm') return cmdDbRowsRm(args);
2332
2523
  if (sub === 'duplicates') return cmdDbDuplicates(args);
2524
+ if (sub === 'pick') return cmdDbPick(args);
2525
+ if (sub === 'identify') return cmdDbIdentify(args);
2333
2526
  if (sub === 'merge') return cmdDbMerge(args);
2334
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);
2335
2533
  if (sub === 'history') return cmdDbHistory(args);
2336
2534
  if (sub === 'restore') return cmdDbRestore(args);
2337
2535
  if (sub === 'row-page') return cmdDbRowPage(args);
@@ -2343,9 +2541,10 @@ async function cmdDb(args) {
2343
2541
  if (sub === 'automations') return cmdDbAutomations(args);
2344
2542
  if (sub === 'import-sheets') return cmdDbImportSheets(args);
2345
2543
  if (sub === 'view') return cmdDbView(args);
2544
+ if (sub === 'column') return cmdDbColumn(args);
2346
2545
  if (sub !== 'create') {
2347
2546
  throw new Error(
2348
- 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)`)),
2349
2548
  );
2350
2549
  }
2351
2550
  const raw = typeof args.file === 'string' ? readFileSync(args.file, 'utf8') : await readStdin();
@@ -2507,8 +2706,19 @@ async function cmdDbView(args) {
2507
2706
  const cards = args.cards.split(',').map((c) => c.trim()).filter(Boolean);
2508
2707
  patch.cardProps = cards.length ? cards : null;
2509
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
+ }
2510
2720
  if (!Object.keys(patch).length) {
2511
- 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`));
2512
2722
  }
2513
2723
 
2514
2724
  const view = await client.updateDatabaseView(id, viewRef, patch);
@@ -2522,6 +2732,66 @@ async function cmdDbView(args) {
2522
2732
  }
2523
2733
 
2524
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
+
2525
2795
  /**
2526
2796
  * DB-BUTTON — 표 위의 "누르면 일하는 버튼".
2527
2797
  *
@@ -3074,7 +3344,7 @@ async function cmdSign(args) {
3074
3344
  // ---- doc mail (DOC-MAIL — 문서 송수신) ------------------------------------
3075
3345
 
3076
3346
  const MAIL_USAGE =
3077
- usage(`rootr mail [inbox|sent] | show <id> | revoke <id> | hide <id>\n`) +
3347
+ usage(`rootr mail [inbox|sent] | show <id> | access <id> <read|comment|write> | revoke <id> | hide <id> | webhook …\n`) +
3078
3348
  tr(' (자세한 건 rootr --help)', ` (see rootr --help)`);
3079
3349
 
3080
3350
  const NODE_TYPE_KO = {
@@ -3092,23 +3362,34 @@ const NODE_TYPE_KO = {
3092
3362
  SIGNED: tr('서명문서', 'signed document'),
3093
3363
  };
3094
3364
 
3365
+ const ACCESS_LABEL = {
3366
+ READ: tr('보기', 'view'),
3367
+ COMMENT: tr('댓글', 'comment'),
3368
+ WRITE: tr('편집', 'edit'),
3369
+ };
3370
+
3095
3371
  async function cmdSend(args) {
3096
3372
  const target = args._[0];
3097
3373
  const toArg = typeof args.to === 'string' ? args.to : '';
3098
3374
  const to = toArg.split(/[,;\s]+/).map((s) => s.trim()).filter(Boolean);
3099
- if (!target || !to.length) {
3100
- throw new Error(usage(tr(`rootr send <path|id> --to a@x.com[,b@y.com] [--message "..."]`, `rootr send <path|id> --to a@x.com[,b@y.com] [--message "..."]`)));
3375
+ const SEND_USAGE = `rootr send <path|id> --to a@x.com[,b@y.com] [--access read|comment|write] [--message "..."]`;
3376
+ if (!target || !to.length) throw new Error(usage(SEND_USAGE));
3377
+ const access = typeof args.access === 'string' ? args.access.trim().toLowerCase() : undefined;
3378
+ if (access && !['read', 'comment', 'write', 'view', 'edit'].includes(access)) {
3379
+ throw new Error(tr(`--access 는 read(보기)·comment(댓글)·write(편집) 중 하나입니다.`, `--access must be read, comment or write.`));
3101
3380
  }
3102
3381
  const client = makeClient();
3103
3382
  const nodeId = requireResolved(await resolveTarget(client, target));
3104
- const out = await client.sendNode(nodeId, { to, message: typeof args.message === 'string' ? args.message : undefined });
3383
+ const out = await client.sendNode(nodeId, { to, access, message: typeof args.message === 'string' ? args.message : undefined });
3105
3384
  if (args.json) return console.log(JSON.stringify(out, null, 2));
3106
3385
  const items = out.items ?? [];
3107
- console.log(tr(`${items.length}명에게 보냈습니다 (묶음 ${out.batchId})`, `Sent to ${items.length} ${items.length === 1 ? 'person' : 'people'} (batch ${out.batchId})`));
3386
+ const lvl = ACCESS_LABEL[out.access] ?? out.access;
3387
+ console.log(tr(`${items.length}명에게 보냈습니다 — 원본을 ${lvl} 권한으로 열어 줌 (묶음 ${out.batchId})`, `Sent to ${items.length} ${items.length === 1 ? 'person' : 'people'} — they can ${lvl} the original (batch ${out.batchId})`));
3108
3388
  for (const it of items) {
3109
3389
  console.log(
3110
3390
  ` ${it.to}\t${it.id}\t` +
3111
- (it.emailed ? tr('메일 보냄', 'emailed') : tr(`메일 실패${it.error ? `: ${it.error}` : ''} — 문서는 받은 문서함에 있습니다`, `email failed${it.error ? `: ${it.error}` : ''} — the copy is still in their inbox`)),
3391
+ (it.emailed ? tr('메일 보냄', 'emailed') : tr(`메일 실패${it.error ? `: ${it.error}` : ''} — 권한은 그대로 있습니다(받은 문서함)`, `email failed${it.error ? `: ${it.error}` : ''} — the access still stands (their inbox)`)) +
3392
+ (it.resent ? tr(' (다시 보냄 — 권한·메시지만 바꿈)', ' (re-sent — access/message updated)') : ''),
3112
3393
  );
3113
3394
  }
3114
3395
  }
@@ -3132,34 +3413,41 @@ async function cmdMail(args) {
3132
3413
  const type = NODE_TYPE_KO[m.nodeType] ?? m.nodeType;
3133
3414
  const state = m.revoked
3134
3415
  ? tr('[회수됨]', '[withdrawn]')
3416
+ : m.originalDeleted
3417
+ ? tr('[원본 삭제됨]', '[original deleted]')
3135
3418
  : box === 'inbox'
3136
3419
  ? m.readAt ? '' : tr('[새 문서]', '[new]')
3137
3420
  : m.readAt ? tr(`[읽음 ${mailWhen(m.readAt)}]`, `[read ${mailWhen(m.readAt)}]`) : tr('[안 읽음]', '[unread]');
3138
3421
  const who = box === 'inbox' ? `${m.senderName} <${m.senderEmail}>` : `→ ${m.recipientEmail}`;
3139
3422
  const mailFail = box === 'sent' && m.emailed === false ? tr(' [메일 실패]', ' [email failed]') : '';
3140
- console.log(`${m.id}\t${mailWhen(m.createdAt)}\t${type}\t${m.title}\t${who}${state ? ` ${state}` : ''}${mailFail}`);
3423
+ const lvl = ACCESS_LABEL[m.access] ? ` (${ACCESS_LABEL[m.access]})` : '';
3424
+ console.log(`${m.id}\t${mailWhen(m.createdAt)}\t${type}${lvl}\t${m.title}\t${who}${state ? ` ${state}` : ''}${mailFail}`);
3141
3425
  if (m.message) console.log(` ${String(m.message).split('\n')[0].slice(0, 100)}`);
3142
3426
  }
3143
3427
  if (out.nextCursor) console.log(tr(`\n다음 쪽: rootr mail ${box} --cursor ${out.nextCursor}`, `\nNext page: rootr mail ${box} --cursor ${out.nextCursor}`));
3144
3428
  return;
3145
3429
  }
3430
+ if (sub === 'webhook' || sub === 'webhooks') return cmdMailWebhook(client, args);
3146
3431
  const id = args._[1];
3147
3432
  if (!id) throw new Error(MAIL_USAGE);
3148
3433
  switch (sub) {
3149
3434
  case 'show':
3150
3435
  case 'read': {
3151
- const m = await client.getDocMail(id);
3436
+ const m = await client.getDocMail(id, { node: typeof args.node === 'string' ? args.node : undefined });
3152
3437
  if (args.json) return console.log(JSON.stringify(m, null, 2));
3153
- console.log(`${m.title} (${NODE_TYPE_KO[m.nodeType] ?? m.nodeType})`);
3438
+ console.log(`${m.title} (${NODE_TYPE_KO[m.nodeType] ?? m.nodeType} · ${tr('권한', 'access')} ${ACCESS_LABEL[m.access] ?? m.access})`);
3154
3439
  console.log(tr(`보낸 사람: ${m.senderName} <${m.senderEmail}> · ${m.workspaceName}`, `From: ${m.senderName} <${m.senderEmail}> · ${m.workspaceName}`));
3155
3440
  console.log(tr(`받는 사람: ${m.recipientEmail} · 보낸 때 ${mailWhen(m.createdAt)}${m.readAt ? ` · 읽음 ${mailWhen(m.readAt)}` : ''}`, `To: ${m.recipientEmail} · sent ${mailWhen(m.createdAt)}${m.readAt ? ` · read ${mailWhen(m.readAt)}` : ''}`));
3156
3441
  if (m.message) console.log(tr(`메시지: ${m.message}`, `Message: ${m.message}`));
3157
- if (m.revoked && !m.payload) return console.log(tr('\n보낸 사람이 이 문서를 회수했습니다.', '\nThe sender withdrew this document.'));
3158
- if (m.revoked) console.log(tr('(회수함 — 받는 쪽에서는 본문이 안 보입니다)', '(withdrawn — the recipient no longer sees the content)'));
3442
+ if (m.revoked && m.viewer !== 'sender') return console.log(tr('\n보낸 사람이 이 문서를 회수했습니다.', '\nThe sender withdrew this document.'));
3443
+ if (m.originalDeleted) return console.log(tr('\n원본이 삭제되었습니다.', '\nThe original has been deleted.'));
3444
+ if (m.revoked) console.log(tr('(회수함 — 받는 사람은 더 못 엽니다)', '(withdrawn — the recipient can no longer open it)'));
3445
+ if (m.openUrl) console.log(tr(`원본 열기(편집기): ${m.openUrl}`, `Open the original (editor): ${m.openUrl}`));
3446
+ if (m.picked) console.log(tr(`보고 있는 것: ${m.picked.name} (${m.picked.id})`, `Showing: ${m.picked.name} (${m.picked.id})`));
3159
3447
  const p = m.payload ?? {};
3160
3448
  console.log('');
3161
- if (p.kind === 'doc') return console.log(p.content ?? '');
3162
- if (p.kind === 'database') {
3449
+ if (typeof m.contentMd === 'string' || p.kind === 'doc') console.log(m.contentMd ?? p.content ?? '');
3450
+ else if (p.kind === 'database') {
3163
3451
  const props = p.properties ?? [];
3164
3452
  const rows = p.rows ?? [];
3165
3453
  console.log(props.map((c) => c.name).join('\t'));
@@ -3169,30 +3457,107 @@ async function cmdMail(args) {
3169
3457
  return v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v);
3170
3458
  }).join('\t'));
3171
3459
  }
3172
- if (p.truncated) console.log(tr(`… ${p.truncated.total}행 중 ${p.truncated.shown}행만 담겼습니다`, `… ${p.truncated.shown} of ${p.truncated.total} rows were included`));
3173
- return;
3460
+ if (p.truncated) console.log(tr(`… ${p.truncated.total}행 중 ${p.truncated.shown}행만 보입니다`, `… showing ${p.truncated.shown} of ${p.truncated.total} rows`));
3461
+ if (p.rowsHidden === 'booking') console.log(tr('(예약을 받는 표라 행은 안 보입니다)', '(a booking table — rows are not shown)'));
3462
+ } else if (p.kind) {
3463
+ const keys = Object.keys(p).filter((k) => k !== 'kind');
3464
+ console.log(tr(`[${p.kind}] 담긴 것: ${keys.join(', ')} — 전부 보려면 --json`, `[${p.kind}] contains: ${keys.join(', ')} — use --json for everything`));
3465
+ }
3466
+ if (m.children?.length) {
3467
+ console.log(tr('\n안에 든 것 (rootr mail show <id> --node <아래 id>):', '\nInside (rootr mail show <id> --node <child id>):'));
3468
+ for (const c of m.children) console.log(` ${c.id}\t${NODE_TYPE_KO[c.type] ?? c.type}\t${c.name}`);
3174
3469
  }
3175
- const keys = Object.keys(p).filter((k) => k !== 'kind');
3176
- console.log(tr(`[${p.kind}] 담긴 것: ${keys.join(', ')} — 전부 보려면 --json`, `[${p.kind}] contains: ${keys.join(', ')} — use --json for everything`));
3177
3470
  return;
3178
3471
  }
3472
+ case 'access': {
3473
+ const level = args._[2];
3474
+ if (!level || !['read', 'comment', 'write', 'view', 'edit'].includes(String(level).toLowerCase())) throw new Error(MAIL_USAGE);
3475
+ const out = await client.setDocMailAccess(id, String(level).toLowerCase());
3476
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
3477
+ return console.log(tr(`받는 사람 권한을 ${ACCESS_LABEL[out.access] ?? out.access}(으)로 바꿨습니다.`, `The recipient can now ${ACCESS_LABEL[out.access] ?? out.access}.`));
3478
+ }
3179
3479
  case 'revoke': {
3180
3480
  const out = await client.revokeDocMail(id);
3181
3481
  if (args.json) return console.log(JSON.stringify(out, null, 2));
3182
- return console.log(tr(`회수했습니다 (${mailWhen(out.revokedAt)}) — 받는 쪽에서 본문이 안 보입니다.`, `Withdrawn (${mailWhen(out.revokedAt)}) — the recipient no longer sees the content.`));
3482
+ return console.log(tr(`회수했습니다 (${mailWhen(out.revokedAt)}) — 받는 사람이 원본을 더 못 엽니다.`, `Withdrawn (${mailWhen(out.revokedAt)}) — the recipient can no longer open the original.`));
3183
3483
  }
3184
3484
  case 'hide':
3185
3485
  case 'rm': {
3186
3486
  const box = args.box === 'inbox' || args.box === 'sent' ? args.box : undefined;
3187
3487
  const out = await client.hideDocMail(id, { box });
3188
3488
  if (args.json) return console.log(JSON.stringify(out, null, 2));
3189
- return console.log(tr(`내 문서함에서 지웠습니다 (${out.hidden}). 상대 화면은 그대로입니다.`, `Removed from your mailbox (${out.hidden}). The other side still has it.`));
3489
+ return console.log(tr(`내 문서함에서 지웠습니다 (${out.hidden}). 상대 화면은 그대로입니다(받은 것을 지웠으면 내 계정으로는 더 안 열립니다).`, `Removed from your mailbox (${out.hidden}). The other side still has it (a removed received document no longer opens for your account).`));
3190
3490
  }
3191
3491
  default:
3192
3492
  throw new Error(MAIL_USAGE);
3193
3493
  }
3194
3494
  }
3195
3495
 
3496
+ const WEBHOOK_USAGE = usage(
3497
+ tr(
3498
+ `rootr mail webhook add <url> [--events doc_mail.received,doc_mail.revoked,doc_mail.read] [--name <이름>]\n` +
3499
+ ` rootr mail webhook list | rm <id> | test <id>`,
3500
+ `rootr mail webhook add <url> [--events doc_mail.received,doc_mail.revoked,doc_mail.read] [--name <name>]\n` +
3501
+ ` rootr mail webhook list | rm <id> | test <id>`,
3502
+ ),
3503
+ );
3504
+
3505
+ /** DOC-MAIL-HOOK — 내(계정) 받은 문서 웹훅. 워크스페이스 웹훅과 따로 — 받은 문서는 사람의 것이다. */
3506
+ async function cmdMailWebhook(client, args) {
3507
+ const op = args._[1];
3508
+ const arg = args._[2];
3509
+ switch (op) {
3510
+ case 'add': {
3511
+ if (!arg) throw new Error(WEBHOOK_USAGE);
3512
+ const events = typeof args.events === 'string' ? args.events.split(',').map((x) => x.trim()).filter(Boolean) : undefined;
3513
+ const out = await client.addDocMailWebhook({ url: arg, events, name: typeof args.name === 'string' ? args.name : undefined });
3514
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
3515
+ console.log(tr(`웹훅을 만들었습니다: ${out.id}`, `Webhook created: ${out.id}`));
3516
+ console.log(` url: ${out.url}`);
3517
+ console.log(` events: ${out.events.join(', ')}`);
3518
+ console.log(tr(` 비밀값(지금 한 번만 보입니다 — 서명 X-Rootr-Signature 검증용): ${out.secret}`, ` secret (shown once — verify X-Rootr-Signature with it): ${out.secret}`));
3519
+ return;
3520
+ }
3521
+ case 'list':
3522
+ case 'ls':
3523
+ case undefined: {
3524
+ const out = await client.listDocMailWebhooks();
3525
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
3526
+ if (!out.items?.length) return console.log(tr('웹훅이 없습니다. rootr mail webhook add <url>', 'No webhooks. rootr mail webhook add <url>'));
3527
+ for (const h of out.items) {
3528
+ const last = h.lastDelivery ? ` · ${h.lastDelivery.event} ${h.lastDelivery.status}${h.lastDelivery.responseCode ? ` ${h.lastDelivery.responseCode}` : ''}` : '';
3529
+ console.log(`${h.id}\t${h.active ? 'on' : 'off'}\t${h.url}\t${h.events.join(',')}${last}`);
3530
+ }
3531
+ return;
3532
+ }
3533
+ case 'rm':
3534
+ case 'remove':
3535
+ case 'delete': {
3536
+ if (!arg) throw new Error(WEBHOOK_USAGE);
3537
+ const out = await client.removeDocMailWebhook(arg);
3538
+ if (args.json) return console.log(JSON.stringify(out, null, 2));
3539
+ return console.log(tr(`웹훅을 지웠습니다: ${arg}`, `Webhook removed: ${arg}`));
3540
+ }
3541
+ case 'test': {
3542
+ if (!arg) throw new Error(WEBHOOK_USAGE);
3543
+ const queued = await client.testDocMailWebhook(arg);
3544
+ let d = null;
3545
+ for (let i = 0; i < 10; i++) {
3546
+ await new Promise((r) => setTimeout(r, 750));
3547
+ d = (await client.docMailWebhookDeliveries(arg)).items?.find((x) => x.id === queued.deliveryId) ?? null;
3548
+ if (d && d.status !== 'PENDING') break;
3549
+ }
3550
+ if (args.json) return console.log(JSON.stringify({ ...queued, delivery: d }, null, 2));
3551
+ if (!d || d.status === 'PENDING') return console.log(tr(`시험 전송을 큐에 넣었습니다(${queued.deliveryId}) — 아직 결과가 없습니다.`, `Test queued (${queued.deliveryId}) — no result yet.`));
3552
+ if (d.status === 'SUCCESS') return console.log(tr(`시험 전송 성공 (HTTP ${d.responseCode})`, `Test delivered (HTTP ${d.responseCode})`));
3553
+ process.exitCode = 1;
3554
+ return console.log(tr(`시험 전송 실패: ${d.error ?? d.responseCode} — 다시 시도합니다(최대 5번).`, `Test failed: ${d.error ?? d.responseCode} — it will be retried (up to 5 times).`));
3555
+ }
3556
+ default:
3557
+ throw new Error(WEBHOOK_USAGE);
3558
+ }
3559
+ }
3560
+
3196
3561
  async function main() {
3197
3562
  const argv = process.argv.slice(2);
3198
3563
  const args = parseArgs(argv);
@@ -3277,4 +3642,31 @@ async function main() {
3277
3642
  }
3278
3643
  }
3279
3644
 
3280
- main().catch(printErrorAndExit);
3645
+ /**
3646
+ * DOC-MAIL-NOTIFY — 아무 명령이든 끝난 뒤, 그 명령이 부른 API 응답 머리줄에 **새로 받은 문서**가 있었으면 stderr 로 한 줄
3647
+ * (stdout·--json 출력은 더럽히지 않는다). 따로 묻는 호출은 없다. 서버가 키마다 한 번만 알려 준다(channel 'cli').
3648
+ * `rootr mail`·`rootr mcp` 에서는 안 낸다(이미 문서함을 보고 있다 / 표준 입출력이 MCP 통로다).
3649
+ * 끄기: `ROOTR_MAIL_NOTICE=0`.
3650
+ */
3651
+ const docMailSink = { channel: 'cli', items: [] };
3652
+ const firstArg = process.argv.slice(2).find((a) => !a.startsWith('-'));
3653
+ const docMailNoticeOn = process.env.ROOTR_MAIL_NOTICE !== '0' && firstArg !== 'mail' && firstArg !== 'mcp';
3654
+ if (docMailNoticeOn) setGlobalDocMailSink(docMailSink);
3655
+ function printDocMailNotice() {
3656
+ if (!docMailNoticeOn || !docMailSink.items.length) return;
3657
+ const n = Math.max(docMailSink.items.length, docMailSink.newCount || 0);
3658
+ const first = docMailSink.items[0];
3659
+ console.error(
3660
+ tr(
3661
+ `📬 받은 문서 ${n}건 — 「${first.title}」 ${first.sender}${n > 1 ? ' 외' : ''} · rootr mail`,
3662
+ `📬 ${n} new document${n > 1 ? 's' : ''} received — "${first.title}" from ${first.sender}${n > 1 ? ' and more' : ''} · rootr mail`,
3663
+ ),
3664
+ );
3665
+ }
3666
+
3667
+ main()
3668
+ .then(printDocMailNotice)
3669
+ .catch((err) => {
3670
+ printDocMailNotice();
3671
+ printErrorAndExit(err);
3672
+ });