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/README.ko.md +48 -16
- package/README.md +54 -14
- package/bin/rootr.js +426 -34
- package/lib/client.js +118 -17
- package/lib/context.js +21 -0
- package/lib/mcp-tools/booking.js +11 -1
- package/lib/mcp-tools/databases.js +316 -8
- package/lib/mcp-tools/doc-mail.js +149 -27
- package/lib/mcp-tools/pages.js +23 -5
- package/lib/mcp-tools/public.js +8 -1
- package/lib/mcp-tools/workspaces.js +9 -1
- package/lib/mcp.js +43 -5
- package/package.json +1 -1
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
|
|
102
|
-
rootr mail
|
|
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 "..."]
|
|
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]
|
|
212
|
-
rootr mail
|
|
213
|
-
rootr mail
|
|
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
|
|
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
|
|
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
|
-
|
|
3100
|
-
|
|
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
|
-
|
|
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}` : ''} —
|
|
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
|
-
|
|
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 &&
|
|
3158
|
-
if (m.
|
|
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')
|
|
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}행만
|
|
3173
|
-
|
|
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)}) — 받는
|
|
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}). 상대 화면은
|
|
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
|
-
|
|
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
|
+
});
|