rootr-cli 0.18.0 → 0.20.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 +56 -2
- package/README.md +52 -5
- package/bin/rootr.js +483 -13
- package/lib/client.js +220 -24
- package/lib/credentials.js +64 -7
- package/lib/mcp-tools/ask.js +6 -1
- package/lib/mcp-tools/attachments.js +153 -15
- package/lib/mcp-tools/booking.js +403 -0
- package/lib/mcp-tools/databases.js +230 -9
- package/lib/mcp-tools/documents.js +183 -16
- package/lib/mcp-tools/forms.js +3 -1
- package/lib/mcp-tools/logs.js +1 -1
- package/lib/mcp-tools/misc.js +9 -3
- package/lib/mcp-tools/pages.js +31 -7
- package/lib/mcp-tools/presentations.js +36 -12
- package/lib/mcp-tools/public.js +4 -1
- package/lib/mcp-tools/recordings.js +39 -15
- package/lib/mcp-tools/shared.js +28 -0
- package/lib/mcp-tools/workspaces.js +197 -79
- package/lib/mcp.js +3 -0
- package/lib/resolve.js +43 -1
- package/package.json +1 -1
package/bin/rootr.js
CHANGED
|
@@ -5,7 +5,7 @@ import { dirname, join, basename, extname } from 'node:path';
|
|
|
5
5
|
|
|
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
|
-
import { RootrClient, RootrApiError } from '../lib/client.js';
|
|
8
|
+
import { RootrClient, RootrApiError, imageMarkdown } from '../lib/client.js';
|
|
9
9
|
import { resolveTarget, requireResolved, resolveMoveDestination } from '../lib/resolve.js';
|
|
10
10
|
import { t as tr, lang } from '../lib/i18n.js';
|
|
11
11
|
|
|
@@ -19,6 +19,7 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
|
|
|
19
19
|
rootr read <path|id> [--json] 문서 읽기 (마크다운 또는 JSON)
|
|
20
20
|
rootr write <path|id> [--file <f>] [--if-match <etag>] 문서 전체 교체(파일 없으면 생성)
|
|
21
21
|
rootr append <path|id> [<text>] [--heading "## X"] [--file <f>] 문서 끝(또는 헤딩 아래)에 안전하게 추가
|
|
22
|
+
rootr embed <문서 path|id> <끼울 노드 path|id> [--heading "## X"] 문서 안에 표·시트·발표자료를 **살아 있는 채로** 끼워 넣기
|
|
22
23
|
rootr edit <path|id> --find <text> --replace <text> [--all] 본문 내 텍스트 치환
|
|
23
24
|
rootr comments <path|id> [--block <id>] [--json] 문서에 달린 댓글 읽기 (공개 페이지 댓글 포함)
|
|
24
25
|
rootr search <query> 워크스페이스 검색
|
|
@@ -53,6 +54,12 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
|
|
|
53
54
|
rootr publish <path|id> --status 지금 공개인지·주소·하위 포함 여부
|
|
54
55
|
rootr publish <표> --layout board --skin paper 공개한 표를 무슨 꼴로 보일지 (배치 9종 × 분위기 6종)
|
|
55
56
|
rootr publish <문서> --comments [false] [--expires <ISO>] [--skin <분위기>] 문서 전용 — 댓글(편집 제안)·만료일·스킨 16종
|
|
57
|
+
rootr booking show <표> 방문자 일정 등록(예약) 설정 보기
|
|
58
|
+
rootr booking set <표> --on --days 1,2,3,4,5 --hours 10:00-18:00 --duration 30 예약 켜기·규칙
|
|
59
|
+
rootr booking set <표> --ask "연락처:required" --approval manual 방문자에게 더 묻기 · 승인 후 확정
|
|
60
|
+
rootr booking set <표> --gcal-connection <id> --gcal-busy <캘린더> --gcal-write <캘린더> 구글 캘린더 연동
|
|
61
|
+
rootr booking slots <표> [--from 2026-10-01] [--to 2026-10-31] 방문자에게 보이는 빈 시간
|
|
62
|
+
rootr gcal list|connect|calendars <id>|disconnect <id> 구글 캘린더 계정 연결(동의는 브라우저에서 한 번)
|
|
56
63
|
rootr readreq <path|id> [--json] 읽음 확인 현황 — 누가 읽었나·완료율
|
|
57
64
|
rootr readreq <path|id> --users <id,id> [--due <ISO>] [--ack] 읽어야 할 사람 지정(명단 통째로 교체)
|
|
58
65
|
rootr readreq <path|id> --clear 읽음 확인 해제
|
|
@@ -90,12 +97,12 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
|
|
|
90
97
|
rootr scaffold review --file tree.json [--json] 짓기 2단계 — 만들기 전에 검사받기(점수·고칠 곳)
|
|
91
98
|
rootr scaffold apply --file tree.json [--root /경로] 짓기 3단계 — 실제로 만들기(검사 결과도 같이 나옴)
|
|
92
99
|
rootr scaffold built [--root /경로] [--json] 짓고 나서 — 실제로 만들어진 것의 **모양**을 검사
|
|
93
|
-
rootr scaffold test [--root /경로] [--json] 짓고 나서 — 실제로 **돌려 본다**(
|
|
100
|
+
rootr scaffold test [--root /경로] [--json] 짓고 나서 — 실제로 **돌려 본다**(관계·롤업·수식·폼·시트·대시보드 차트). 실패 시 exit 1
|
|
94
101
|
rootr memory [--query <q>] [--kind <k>] [--json] 팀의 기억 회상 — AI 에이전트가 읽는 같은 목록
|
|
95
102
|
rootr memory add "<내용>" [--kind 지시|사람|프로젝트|참고] [--source <경로|id>] [--related a,b] 기억 저장
|
|
96
103
|
rootr memory edit <id> [--content "<내용>"] [--kind …] [--source …] [--related a,b] 기억 고치기(준 칸만)
|
|
97
104
|
rootr memory forget <id> [--hard] 기억 폐기(--hard면 행 삭제)
|
|
98
|
-
rootr mcp MCP stdio 서버 구동 (도구
|
|
105
|
+
rootr mcp MCP stdio 서버 구동 (도구 178종 — README 참고)
|
|
99
106
|
rootr --help 도움말
|
|
100
107
|
rootr --version 버전 출력
|
|
101
108
|
|
|
@@ -113,6 +120,7 @@ usage:
|
|
|
113
120
|
rootr read <path|id> [--json] read a document (markdown, or JSON)
|
|
114
121
|
rootr write <path|id> [--file <f>] [--if-match <etag>] replace a document (creates it if missing)
|
|
115
122
|
rootr append <path|id> [<text>] [--heading "## X"] [--file <f>] append safely to the end (or under a heading)
|
|
123
|
+
rootr embed <doc path|id> <node path|id> [--heading "## X"] embed a LIVE table/sheet/deck inside a document
|
|
116
124
|
rootr edit <path|id> --find <text> --replace <text> [--all] replace text inside the body
|
|
117
125
|
rootr comments <path|id> [--block <id>] [--json] read comments (including ones left on the public page)
|
|
118
126
|
rootr search <query> search the workspace
|
|
@@ -147,6 +155,12 @@ usage:
|
|
|
147
155
|
rootr publish <path|id> --status is it public, at what URL, children included?
|
|
148
156
|
rootr publish <table> --layout board --skin paper how a published table looks (9 layouts × 6 skins)
|
|
149
157
|
rootr publish <doc> --comments [false] [--expires <ISO>] [--skin <skin>] documents only — comments, expiry, 16 skins
|
|
158
|
+
rootr booking show <table> show visitor booking settings
|
|
159
|
+
rootr booking set <table> --on --days 1,2,3,4,5 --hours 10:00-18:00 --duration 30 turn on + rules
|
|
160
|
+
rootr booking set <table> --ask "Phone:required" --approval manual ask more · confirm after approval
|
|
161
|
+
rootr booking set <table> --gcal-connection <id> --gcal-busy <cal> --gcal-write <cal> Google Calendar
|
|
162
|
+
rootr booking slots <table> [--from 2026-10-01] [--to 2026-10-31] open times visitors see
|
|
163
|
+
rootr gcal list|connect|calendars <id>|disconnect <id> connect a Google account (consent once in a browser)
|
|
150
164
|
rootr readreq <path|id> [--json] read receipts — who has read it, completion rate
|
|
151
165
|
rootr readreq <path|id> --users <id,id> [--due <ISO>] [--ack] set who must read it (replaces the whole list)
|
|
152
166
|
rootr readreq <path|id> --clear clear the read requirement
|
|
@@ -183,12 +197,12 @@ usage:
|
|
|
183
197
|
rootr scaffold review --file tree.json [--json] build step 2 — get graded before anything is created
|
|
184
198
|
rootr scaffold apply --file tree.json [--root /path] build step 3 — actually create it (grades come along)
|
|
185
199
|
rootr scaffold built [--root /path] [--json] after building — inspect the **shape** of what exists
|
|
186
|
-
rootr scaffold test [--root /path] [--json] after building — actually **exercise** it (relations, rollups, formulas, forms, sheets). Exits 1 on failure
|
|
200
|
+
rootr scaffold test [--root /path] [--json] after building — actually **exercise** it (relations, rollups, formulas, forms, sheets, dashboard charts). Exits 1 on failure
|
|
187
201
|
rootr memory [--query <q>] [--kind <k>] [--json] recall team memory — the same list AI agents read
|
|
188
202
|
rootr memory add "<content>" [--kind instruction|person|project|reference] [--source <path|id>] [--related a,b] save a memory
|
|
189
203
|
rootr memory edit <id> [--content "<content>"] [--kind …] [--source …] [--related a,b] edit a memory (only the fields you pass)
|
|
190
204
|
rootr memory forget <id> [--hard] retire a memory (--hard deletes the row)
|
|
191
|
-
rootr mcp run the MCP stdio server (
|
|
205
|
+
rootr mcp run the MCP stdio server (178 tools — see the README)
|
|
192
206
|
rootr --help this help
|
|
193
207
|
rootr --version print the version
|
|
194
208
|
|
|
@@ -359,7 +373,9 @@ async function cmdWrite(args) {
|
|
|
359
373
|
content,
|
|
360
374
|
});
|
|
361
375
|
const id = created.id || (created.node && created.node.id);
|
|
362
|
-
|
|
376
|
+
// 서버가 실제로 둔 경로(이름에 꼬리가 붙는 자리에서는 요청한 경로와 다르다 — 그 경로로 다시 써야 같은 문서에 닿는다)
|
|
377
|
+
const at = created.path || (created.node && created.node.path) || resolved.path;
|
|
378
|
+
console.error(`created ${at} (id ${id})`);
|
|
363
379
|
return;
|
|
364
380
|
}
|
|
365
381
|
|
|
@@ -367,6 +383,25 @@ async function cmdWrite(args) {
|
|
|
367
383
|
console.error(`wrote ${target} (etag ${etag || 'n/a'})`);
|
|
368
384
|
}
|
|
369
385
|
|
|
386
|
+
/**
|
|
387
|
+
* EMBED-API — 문서 안에 다른 노드를 끼워 넣는다(편집기의 "끼워 넣기" 블록과 같다). 둘 다 경로든 id 든 된다.
|
|
388
|
+
* 서버가 문서 끝(또는 헤딩 아래)에 `[[embed:<id>]]` 한 줄을 붙이고, 편집기는 그 줄을 살아 있는 표로 그린다.
|
|
389
|
+
*/
|
|
390
|
+
async function cmdEmbed(args) {
|
|
391
|
+
const [doc, node] = args._;
|
|
392
|
+
if (!doc || !node) throw new Error(usage(`rootr embed <doc path|id> <node path|id> [--heading "## X"]`));
|
|
393
|
+
const client = makeClient();
|
|
394
|
+
const docId = requireResolved(await resolveTarget(client, doc));
|
|
395
|
+
const nodeId = requireResolved(await resolveTarget(client, node));
|
|
396
|
+
const heading = typeof args.heading === 'string' ? args.heading : undefined;
|
|
397
|
+
const res = await client.embedNode(docId, { node: nodeId, underHeading: heading });
|
|
398
|
+
if (args.json) {
|
|
399
|
+
console.log(JSON.stringify(res, null, 2));
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
console.error(tr(`끼워 넣음: ${node} → ${doc} (${res.marker})`, `embedded ${node} into ${doc} (${res.marker})`));
|
|
403
|
+
}
|
|
404
|
+
|
|
370
405
|
async function cmdAppend(args) {
|
|
371
406
|
const target = args._[0];
|
|
372
407
|
if (!target) throw new Error(usage(`rootr append <path|id> [<text>] [--heading "## X"] [--file <f>]`));
|
|
@@ -527,7 +562,8 @@ async function cmdAttach(args) {
|
|
|
527
562
|
});
|
|
528
563
|
}
|
|
529
564
|
|
|
530
|
-
|
|
565
|
+
// ISSUE-61 — --workspace 로 고른 워크스페이스에 묶인 키로(MCP 첨부 도구와 같은 결함: 기본 키로 가면 403)
|
|
566
|
+
const client = new RootrClient({ ...cfg, workspace, apiKey: keyForWorkspace(cfg, workspace) });
|
|
531
567
|
const data = readFileSync(filePath);
|
|
532
568
|
const filename = basename(filePath);
|
|
533
569
|
const mimeType = args.mime || MIME_BY_EXT[extname(filePath).toLowerCase()] || 'application/octet-stream';
|
|
@@ -540,7 +576,7 @@ async function cmdAttach(args) {
|
|
|
540
576
|
|
|
541
577
|
const { id } = await client.uploadAttachment(workspace, { filename, mimeType, nodeId, data });
|
|
542
578
|
const url = client.attachmentRawUrl(id);
|
|
543
|
-
const markdown =
|
|
579
|
+
const markdown = imageMarkdown(args.alt || filename, url);
|
|
544
580
|
|
|
545
581
|
if (nodeId) {
|
|
546
582
|
const result = await client.appendDocument(nodeId, { content: markdown, underHeading: args.heading });
|
|
@@ -617,7 +653,12 @@ async function cmdAsk(args) {
|
|
|
617
653
|
if (citations.length) {
|
|
618
654
|
console.log('\nCitations:');
|
|
619
655
|
for (const c of citations) {
|
|
620
|
-
|
|
656
|
+
// DB-SEARCH — MCP rootr_ask 와 같은 모양: 표의 행은 "표 › 행 제목 (행 주소)".
|
|
657
|
+
if (c.rowId) {
|
|
658
|
+
console.log(`- ${c.documentTitle || c.documentPath} › ${c.rowTitle || '(untitled row)'}${c.url ? ` (${c.url})` : ''}: ${c.quote}`);
|
|
659
|
+
} else {
|
|
660
|
+
console.log(`- ${c.documentPath}${c.url ? ` (${c.url})` : ''}: ${c.quote}`);
|
|
661
|
+
}
|
|
621
662
|
}
|
|
622
663
|
}
|
|
623
664
|
}
|
|
@@ -1544,6 +1585,20 @@ async function cmdPublish(args) {
|
|
|
1544
1585
|
console.log(tr('이 종류는 공개할 수 없습니다(폴더).', `This node type cannot be published (folder).`));
|
|
1545
1586
|
return;
|
|
1546
1587
|
}
|
|
1588
|
+
if (st.closed) {
|
|
1589
|
+
// 공개를 끈 것과 다르다 — 설정은 켜져 있고, "본인이 만든 행만" 표의 행에서 나온 것이라 지금 닫혀 있다(서버 응답의 closed)
|
|
1590
|
+
console.log(tr(`공개가 켜져 있지만 지금은 닫혀 있습니다(주소: /p/${st.slug}).`, `Publishing is on, but it is closed right now (address: /p/${st.slug}).`));
|
|
1591
|
+
console.log(
|
|
1592
|
+
' ' +
|
|
1593
|
+
tr(
|
|
1594
|
+
st.closedTableGone
|
|
1595
|
+
? '"본인이 만든 행만 보기"를 켠 표의 행에서 나온 것이라 로그인하지 않은 사람에게는 열리지 않습니다. 그 표는 이미 지워져 표 설정을 바꿀 수 없습니다 — 내용을 새 문서에 옮겨 적어 그 문서를 공개하세요.'
|
|
1596
|
+
: '"본인이 만든 행만 보기"를 켠 표의 행에서 나온 것이라 로그인하지 않은 사람에게는 열리지 않습니다. 표 권한에서 그 설정을 끄거나 행 연결을 끊으면 같은 주소로 다시 열립니다.',
|
|
1597
|
+
st.closedReason ?? 'This item comes from a row of a table with "Own rows only" turned on, so it does not open for people who are not signed in.',
|
|
1598
|
+
),
|
|
1599
|
+
);
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1547
1602
|
if (!st.isPublic) {
|
|
1548
1603
|
console.log(st.slug ? tr(`닫혀 있습니다(주소는 남아 있음: /p/${st.slug})`, `Closed (the address is still reserved: /p/${st.slug})`) : tr('아직 공개한 적이 없습니다.', `Never published.`));
|
|
1549
1604
|
return;
|
|
@@ -1625,6 +1680,405 @@ async function cmdPublish(args) {
|
|
|
1625
1680
|
* 표·폴더에 걸면 그 안의 문서 전부에 걸린다(완료율은 사람마다 굴러 올라온다).
|
|
1626
1681
|
* 읽음 기록은 사람의 것이라, 사람이 붙지 않은 열쇠로는 걸 수 없다.
|
|
1627
1682
|
*/
|
|
1683
|
+
/**
|
|
1684
|
+
* BOOKING — 공개한 표를 **예약 페이지**로 쓰는 명령(창업자 지시 2026-09-23).
|
|
1685
|
+
*
|
|
1686
|
+
* 화면에서 하던 것을 그대로 명령줄에서도 할 수 있어야 한다 —
|
|
1687
|
+
* 켜기·규칙·확정 방식·물어볼 칸·구글 캘린더 고르기, 그리고 방문자가 보는 빈 시간 확인까지.
|
|
1688
|
+
*/
|
|
1689
|
+
const BOOKING_WEEKDAYS = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
|
|
1690
|
+
|
|
1691
|
+
/**
|
|
1692
|
+
* `--block-time` 한 토막을 규칙 한 줄로. 사람이 손으로 쓰는 자리라 너그럽게 읽는다.
|
|
1693
|
+
* "daily 12:00-13:00=점심" · "mon 09:00-11:00" · "fri" · "2026-10-05 13:00-15:00"
|
|
1694
|
+
* "12:00-13:00"(앞을 비우면 매일) · "mon 14:00-"(뒤를 비우면 그날 끝까지)
|
|
1695
|
+
*/
|
|
1696
|
+
function parseBlockTime(tok) {
|
|
1697
|
+
let t = String(tok).trim();
|
|
1698
|
+
if (!t) return null;
|
|
1699
|
+
let label;
|
|
1700
|
+
const eq = t.indexOf('=');
|
|
1701
|
+
if (eq >= 0) {
|
|
1702
|
+
label = t.slice(eq + 1).trim();
|
|
1703
|
+
t = t.slice(0, eq).trim();
|
|
1704
|
+
}
|
|
1705
|
+
const pad = (v) => (v && /^\d:/.test(v) ? `0${v}` : v);
|
|
1706
|
+
let start;
|
|
1707
|
+
let end;
|
|
1708
|
+
const m = t.match(/(\d{1,2}:\d{2})?\s*-\s*(\d{1,2}:\d{2})?$/);
|
|
1709
|
+
if (m && (m[1] || m[2])) {
|
|
1710
|
+
start = pad(m[1]);
|
|
1711
|
+
end = pad(m[2]);
|
|
1712
|
+
t = t.slice(0, m.index).trim();
|
|
1713
|
+
}
|
|
1714
|
+
const head = t.toLowerCase();
|
|
1715
|
+
let out;
|
|
1716
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(head)) out = { kind: 'date', date: head };
|
|
1717
|
+
else if (head in BOOKING_WEEKDAYS) out = { kind: 'weekly', weekday: BOOKING_WEEKDAYS[head] };
|
|
1718
|
+
else if (!head || head === 'daily' || head === 'every') out = { kind: 'daily' };
|
|
1719
|
+
else {
|
|
1720
|
+
throw new Error(
|
|
1721
|
+
tr(
|
|
1722
|
+
`막는 시간을 못 읽었습니다: "${tok}"\n 이렇게 적어 주세요: daily 12:00-13:00 · mon 09:00-11:00 · fri · 2026-10-05 13:00-15:00`,
|
|
1723
|
+
`Could not read the blocked time: "${tok}"\n Write it like: daily 12:00-13:00 · mon 09:00-11:00 · fri · 2026-10-05 13:00-15:00`,
|
|
1724
|
+
),
|
|
1725
|
+
);
|
|
1726
|
+
}
|
|
1727
|
+
if (start) out.start = start;
|
|
1728
|
+
if (end) out.end = end;
|
|
1729
|
+
if (label) out.label = label;
|
|
1730
|
+
return out;
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
/**
|
|
1734
|
+
* `30분 상담=30m|화면 보며 설명` 한 토막을 약속 종류 한 줄로.
|
|
1735
|
+
* 길이를 못 읽으면 버린다 — 조용히 기본 길이로 만들면 "30분으로 적었는데 60분이 잡힌다"가 된다.
|
|
1736
|
+
*/
|
|
1737
|
+
function parseType(part) {
|
|
1738
|
+
const t = String(part || '').trim();
|
|
1739
|
+
if (!t) return null;
|
|
1740
|
+
const [head, description] = t.split('|');
|
|
1741
|
+
const eq = head.lastIndexOf('=');
|
|
1742
|
+
if (eq < 0) return null;
|
|
1743
|
+
const name = head.slice(0, eq).trim();
|
|
1744
|
+
const min = parseRemind(head.slice(eq + 1));
|
|
1745
|
+
if (!name || !min) return null;
|
|
1746
|
+
return { name, durationMin: min, ...(description && description.trim() ? { description: description.trim() } : {}) };
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
/**
|
|
1750
|
+
* `1d`·`3h`·`90m`·`1440` 을 분으로. 사람이 손으로 쓰는 자리라 너그럽게 읽는다.
|
|
1751
|
+
* 읽을 수 없으면 null — 조용히 0으로 만들면 "곧 시작합니다"가 약속 시각에 나간다.
|
|
1752
|
+
*/
|
|
1753
|
+
function parseRemind(raw) {
|
|
1754
|
+
const t = String(raw || '').trim().toLowerCase();
|
|
1755
|
+
const m = /^(\d+)\s*(d|일|h|시간|m|분)?$/.exec(t);
|
|
1756
|
+
if (!m) return null;
|
|
1757
|
+
const n = Number(m[1]);
|
|
1758
|
+
if (!n) return null;
|
|
1759
|
+
const unit = m[2] || 'm';
|
|
1760
|
+
if (unit === 'd' || unit === '일') return n * 1440;
|
|
1761
|
+
if (unit === 'h' || unit === '시간') return n * 60;
|
|
1762
|
+
return n;
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
/** 1440 → `1d`. 사람이 읽는 줄에 쓴다. */
|
|
1766
|
+
function remindText(min) {
|
|
1767
|
+
if (min % 1440 === 0) return `${min / 1440}d`;
|
|
1768
|
+
if (min % 60 === 0) return `${min / 60}h`;
|
|
1769
|
+
return `${min}m`;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
/** 규칙 한 줄을 사람이 읽을 한 마디로(booking show 용). */
|
|
1773
|
+
function blockTimeText(b) {
|
|
1774
|
+
const when =
|
|
1775
|
+
b.kind === 'date'
|
|
1776
|
+
? b.date
|
|
1777
|
+
: b.kind === 'weekly'
|
|
1778
|
+
? Object.keys(BOOKING_WEEKDAYS).find((k) => BOOKING_WEEKDAYS[k] === b.weekday)
|
|
1779
|
+
: tr('매일', 'daily');
|
|
1780
|
+
const span = b.start || b.end ? ` ${b.start ?? ''}-${b.end ?? ''}` : tr(' 하루 전체', ' all day');
|
|
1781
|
+
return `${when}${span}${b.label ? ` (${b.label})` : ''}`;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
async function cmdBooking(args) {
|
|
1785
|
+
const sub = args._[0];
|
|
1786
|
+
const target = args._[1];
|
|
1787
|
+
if (!sub || (sub !== 'slots' && !target)) {
|
|
1788
|
+
throw new Error(
|
|
1789
|
+
usage(tr(`rootr booking show <path|id>`, `rootr booking show <path|id>`)) +
|
|
1790
|
+
tr(
|
|
1791
|
+
`\n rootr booking set <path|id> [--on|--off] [--days 1,2,3,4,5] [--hours 10:00-18:00]\n` +
|
|
1792
|
+
` [--duration 30] [--buffer 10] [--notice 2] [--ahead 60] [--per-day 4]\n` +
|
|
1793
|
+
` [--block 2026-10-03,2026-10-09] [--approval instant|manual] [--tz Asia/Seoul]\n` +
|
|
1794
|
+
` [--block-time "daily 12:00-13:00=점심, mon 09:00-11:00, fri"]\n` +
|
|
1795
|
+
` [--meet on|off] [--notify-visitor on|off] [--notify-email a@b.com,c@d.com] [--self-cancel on|off]\n` +
|
|
1796
|
+
` [--self-move on|off] [--remind 1d,1h (안 보내려면 --remind off)]\n` +
|
|
1797
|
+
` [--types "30분 상담=30m,데모=60m|화면 보며 설명"] [--guests on|off] [--max-guests 5]\n` +
|
|
1798
|
+
` [--after https://example.com/thanks]\n` +
|
|
1799
|
+
` [--hosts "김상담=a@acme.com,이데모=b@acme.com"] [--assign roundrobin|collective|off]\n` +
|
|
1800
|
+
` [--ask "연락처:required,상담 종류"] [--message "안내 문구"]\n` +
|
|
1801
|
+
` [--gcal-connection <id>] [--gcal-busy <캘린더id,...>] [--gcal-write <캘린더id|none>]\n` +
|
|
1802
|
+
` rootr booking slots <path|id> [--from 2026-10-01] [--to 2026-10-31] [--type <종류 이름표>]\n`,
|
|
1803
|
+
`\n rootr booking set <path|id> [--on|--off] [--days 1,2,3,4,5] [--hours 10:00-18:00]\n` +
|
|
1804
|
+
` [--duration 30] [--buffer 10] [--notice 2] [--ahead 60] [--per-day 4]\n` +
|
|
1805
|
+
` [--block 2026-10-03,2026-10-09] [--approval instant|manual] [--tz Asia/Seoul]\n` +
|
|
1806
|
+
` [--block-time "daily 12:00-13:00=lunch, mon 09:00-11:00, fri"]\n` +
|
|
1807
|
+
` [--meet on|off] [--notify-visitor on|off] [--notify-email a@b.com,c@d.com] [--self-cancel on|off]\n` +
|
|
1808
|
+
` [--self-move on|off] [--remind 1d,1h (--remind off to stop)]\n` +
|
|
1809
|
+
` [--types "Intro=30m,Demo=60m|walk through the product"] [--guests on|off] [--max-guests 5]\n` +
|
|
1810
|
+
` [--after https://example.com/thanks]\n` +
|
|
1811
|
+
` [--hosts "Sam=a@acme.com,Lee=b@acme.com"] [--assign roundrobin|collective|off]\n` +
|
|
1812
|
+
` [--ask "Phone:required,Topic"] [--message "note for visitors"]\n` +
|
|
1813
|
+
` [--gcal-connection <id>] [--gcal-busy <calendarId,...>] [--gcal-write <calendarId|none>]\n` +
|
|
1814
|
+
` rootr booking slots <path|id> [--from 2026-10-01] [--to 2026-10-31] [--type <type id>]\n`,
|
|
1815
|
+
),
|
|
1816
|
+
);
|
|
1817
|
+
}
|
|
1818
|
+
const client = makeClient();
|
|
1819
|
+
const id = await resolveDatabaseId(client, target);
|
|
1820
|
+
|
|
1821
|
+
if (sub === 'show') {
|
|
1822
|
+
const st = await client.getBooking(id);
|
|
1823
|
+
if (args.json) return console.log(JSON.stringify(st, null, 2));
|
|
1824
|
+
const b = st.booking;
|
|
1825
|
+
if (!b?.enabled) {
|
|
1826
|
+
console.log(tr('방문자 일정 등록: 꺼져 있음', `Visitor booking: off`));
|
|
1827
|
+
if (!st.published) console.log(tr(' (이 표는 아직 웹에 공개되지 않았습니다 — rootr publish 를 먼저)', ` (not published yet — run rootr publish first)`));
|
|
1828
|
+
return;
|
|
1829
|
+
}
|
|
1830
|
+
const days = (b.weekdays || []).map((d) => DAY_NAMES[d]).join('·');
|
|
1831
|
+
console.log(tr(`방문자 일정 등록: 켜짐`, `Visitor booking: on`));
|
|
1832
|
+
console.log(tr(` 받는 때: ${days} ${b.startTime}~${b.endTime} (${b.timezone})`, ` when: ${days} ${b.startTime}-${b.endTime} (${b.timezone})`));
|
|
1833
|
+
console.log(tr(` 한 번 길이: ${b.durationMin}분 · 사이 여유 ${b.bufferMin}분`, ` slot: ${b.durationMin} min · gap ${b.bufferMin} min`));
|
|
1834
|
+
console.log(tr(` ${b.minNoticeHours}시간 전까지 · ${b.maxDaysAhead}일 뒤까지 · 하루 ${b.maxPerDay || '제한 없음'}`, ` ${b.minNoticeHours}h notice · up to ${b.maxDaysAhead} days · per day ${b.maxPerDay || 'unlimited'}`));
|
|
1835
|
+
console.log(tr(` 확정: ${b.approval === 'manual' ? '승인 후' : '바로'}`, ` confirm: ${b.approval === 'manual' ? 'after approval' : 'instantly'}`));
|
|
1836
|
+
if (b.blockedDates?.length) console.log(tr(` 쉬는 날: ${b.blockedDates.join(', ')}`, ` days off: ${b.blockedDates.join(', ')}`));
|
|
1837
|
+
if (b.types?.length) {
|
|
1838
|
+
console.log(tr(' 약속 종류:', ' event types:'));
|
|
1839
|
+
for (const ty of b.types) {
|
|
1840
|
+
console.log(` ${ty.name} · ${ty.durationMin}${tr('분', ' min')}${ty.description ? ` — ${ty.description}` : ''} (?type=${ty.id})`);
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
if (b.hosts?.length) {
|
|
1844
|
+
const mode = b.assign === 'collective' ? tr('다 같이', 'collective') : b.assign === 'roundRobin' ? tr('돌아가며', 'round robin') : tr('안 씀', 'off');
|
|
1845
|
+
console.log(tr(` 담당자(${mode}):`, ` hosts (${mode}):`));
|
|
1846
|
+
for (const h of b.hosts) console.log(` ${h.name} <${h.email}>${h.google ? tr(' · 구글 연결됨', ' · google linked') : ''}`);
|
|
1847
|
+
}
|
|
1848
|
+
if (b.allowGuests && b.maxGuests > 0) {
|
|
1849
|
+
console.log(tr(` 손님 함께 부르기: 최대 ${b.maxGuests}명`, ` guests: up to ${b.maxGuests}`));
|
|
1850
|
+
}
|
|
1851
|
+
if (b.redirectUrl) console.log(tr(` 잡은 뒤 보낼 곳: ${b.redirectUrl}`, ` after booking: ${b.redirectUrl}`));
|
|
1852
|
+
if (b.allowVisitorCancel === false) console.log(tr(' 방문자 직접 취소: 꺼짐', ' visitor self-cancel: off'));
|
|
1853
|
+
if (b.allowVisitorReschedule === false) console.log(tr(' 방문자 직접 시간 변경: 꺼짐', ' visitor reschedule: off'));
|
|
1854
|
+
console.log(
|
|
1855
|
+
b.reminders?.length
|
|
1856
|
+
? tr(` 미리 알림: ${b.reminders.map(remindText).join(', ')} 전`, ` reminders: ${b.reminders.map(remindText).join(', ')} before`)
|
|
1857
|
+
: tr(' 미리 알림: 안 보냄', ' reminders: off'),
|
|
1858
|
+
);
|
|
1859
|
+
if (b.google?.meet) console.log(tr(' 구글 밋: 예약마다 회의 만듦', ' Google Meet: a room per booking'));
|
|
1860
|
+
const mailTo = [
|
|
1861
|
+
...(b.notifyVisitor === false ? [] : [tr('방문자', 'visitor')]),
|
|
1862
|
+
...(b.notifyEmails ?? []),
|
|
1863
|
+
];
|
|
1864
|
+
console.log(
|
|
1865
|
+
mailTo.length
|
|
1866
|
+
? tr(` 결과 메일: ${mailTo.join(', ')}`, ` result email: ${mailTo.join(', ')}`)
|
|
1867
|
+
: tr(' 결과 메일: 안 보냄', ' result email: off'),
|
|
1868
|
+
);
|
|
1869
|
+
if (b.blockedTimes?.length) {
|
|
1870
|
+
console.log(tr(' 막는 시간:', ' blocked times:'));
|
|
1871
|
+
for (const bt of b.blockedTimes) console.log(` ${blockTimeText(bt)}`);
|
|
1872
|
+
}
|
|
1873
|
+
if (b.askFields?.length) console.log(tr(` 더 묻는 칸: ${b.askFields.length}개`, ` extra fields: ${b.askFields.length}`));
|
|
1874
|
+
if (st.googleAccount) {
|
|
1875
|
+
const g = st.googleAccount;
|
|
1876
|
+
console.log(tr(` 구글 캘린더: ${g.email}${g.broken ? ' (연결 끊김 — 다시 연결하세요)' : ''}`, ` Google Calendar: ${g.email}${g.broken ? ' (disconnected — reconnect)' : ''}`));
|
|
1877
|
+
console.log(tr(` 막는 캘린더 ${b.google?.busyCalendarIds?.length || 0}개 · 적는 곳: ${b.google?.writeCalendarId || '안 적음'}`, ` blocking ${b.google?.busyCalendarIds?.length || 0} · writes to: ${b.google?.writeCalendarId || 'none'}`));
|
|
1878
|
+
} else {
|
|
1879
|
+
console.log(tr(' 구글 캘린더: 안 씀', ` Google Calendar: not used`));
|
|
1880
|
+
}
|
|
1881
|
+
if (st.syncError) console.log(tr(` ⚠ 구글에 못 적은 예약이 있습니다(5분마다 다시 시도).`, ` ⚠ some bookings failed to sync (retrying every 5 min).`));
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
if (sub === 'slots') {
|
|
1886
|
+
const st = await client.nodePublicStatus(id);
|
|
1887
|
+
if (!st.slug) throw new Error(tr('공개된 표가 아닙니다 — rootr publish 를 먼저 하세요.', `Not published — run rootr publish first.`));
|
|
1888
|
+
// 기간을 안 주면 오늘부터 한 달 — 서버는 from/to 를 반드시 요구한다.
|
|
1889
|
+
const today = new Date(Date.now() + 9 * 3600e3).toISOString().slice(0, 10);
|
|
1890
|
+
const from = args.from || today;
|
|
1891
|
+
const to = args.to || new Date(Date.parse(`${from}T00:00:00Z`) + 30 * 864e5).toISOString().slice(0, 10);
|
|
1892
|
+
const days = await client.bookingSlots(st.slug, { from, to, type: args.type });
|
|
1893
|
+
if (args.json) return console.log(JSON.stringify(days, null, 2));
|
|
1894
|
+
const entries = Object.entries(days.days || {});
|
|
1895
|
+
if (!entries.length) return console.log(tr('그 기간에는 비어 있는 시간이 없습니다.', `No open times in that range.`));
|
|
1896
|
+
const kind = days.type ? ` · ${days.type}` : '';
|
|
1897
|
+
console.log(
|
|
1898
|
+
tr(`빈 시간 (${days.durationMin}분 단위, ${days.timezone}${kind})`, `Open times (${days.durationMin} min, ${days.timezone}${kind})`),
|
|
1899
|
+
);
|
|
1900
|
+
for (const [date, times] of entries) console.log(` ${date} ${times.join(' ')}`);
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
if (sub !== 'set') throw new Error(usage('rootr booking <show|set|slots> <path|id>'));
|
|
1905
|
+
|
|
1906
|
+
const body = {};
|
|
1907
|
+
if (args.on) body.enabled = true;
|
|
1908
|
+
if (args.off) body.enabled = false;
|
|
1909
|
+
if (args.days) body.weekdays = String(args.days).split(',').map((d) => Number(d.trim())).filter((d) => Number.isInteger(d));
|
|
1910
|
+
if (args.hours) {
|
|
1911
|
+
const [start, end] = String(args.hours).split('-').map((x) => x.trim());
|
|
1912
|
+
if (!start || !end) throw new Error(tr('--hours 는 10:00-18:00 처럼 적어 주세요.', `--hours looks like 10:00-18:00`));
|
|
1913
|
+
body.startTime = start;
|
|
1914
|
+
body.endTime = end;
|
|
1915
|
+
}
|
|
1916
|
+
if (args.duration !== undefined) body.durationMin = Number(args.duration);
|
|
1917
|
+
if (args.buffer !== undefined) body.bufferMin = Number(args.buffer);
|
|
1918
|
+
if (args.notice !== undefined) body.minNoticeHours = Number(args.notice);
|
|
1919
|
+
if (args.ahead !== undefined) body.maxDaysAhead = Number(args.ahead);
|
|
1920
|
+
if (args['per-day'] !== undefined) body.maxPerDay = Number(args['per-day']);
|
|
1921
|
+
if (args.block !== undefined) body.blockedDates = String(args.block).split(',').map((x) => x.trim()).filter(Boolean);
|
|
1922
|
+
// 목록을 통째로 바꾼다 — 빈 값을 주면 다 지운다.
|
|
1923
|
+
if (args.meet !== undefined) body.google = { ...(body.google ?? {}), meet: args.meet !== 'off' && args.meet !== false };
|
|
1924
|
+
if (args['notify-visitor'] !== undefined) body.notifyVisitor = args['notify-visitor'] !== 'off' && args['notify-visitor'] !== false;
|
|
1925
|
+
if (args['self-cancel'] !== undefined) body.allowVisitorCancel = args['self-cancel'] !== 'off' && args['self-cancel'] !== false;
|
|
1926
|
+
if (args['self-move'] !== undefined) body.allowVisitorReschedule = args['self-move'] !== 'off' && args['self-move'] !== false;
|
|
1927
|
+
if (args['guests'] !== undefined) body.allowGuests = args['guests'] !== 'off' && args['guests'] !== false;
|
|
1928
|
+
if (args['max-guests'] !== undefined) body.maxGuests = Number(args['max-guests']);
|
|
1929
|
+
if (args['after'] !== undefined) body.redirectUrl = String(args['after'] === true ? '' : args['after']).trim();
|
|
1930
|
+
if (args['assign'] !== undefined) {
|
|
1931
|
+
const a = String(args['assign']).trim().toLowerCase();
|
|
1932
|
+
body.assign = a === 'roundrobin' || a === 'rr' ? 'roundRobin' : a === 'collective' || a === 'all' ? 'collective' : 'none';
|
|
1933
|
+
}
|
|
1934
|
+
// `--hosts "김상담=a@acme.com,이데모=b@acme.com"` → 목록을 통째로 바꾼다. 빈 값이면 없앤다.
|
|
1935
|
+
if (args['hosts'] !== undefined) {
|
|
1936
|
+
const raw = String(args['hosts'] === true ? '' : args['hosts']).trim();
|
|
1937
|
+
body.hosts = raw
|
|
1938
|
+
? raw
|
|
1939
|
+
.split(',')
|
|
1940
|
+
.map((part) => {
|
|
1941
|
+
const t = part.trim();
|
|
1942
|
+
const eq = t.lastIndexOf('=');
|
|
1943
|
+
if (eq < 0) return null;
|
|
1944
|
+
const name = t.slice(0, eq).trim();
|
|
1945
|
+
const email = t.slice(eq + 1).trim();
|
|
1946
|
+
return name && email.includes('@') ? { name, email } : null;
|
|
1947
|
+
})
|
|
1948
|
+
.filter(Boolean)
|
|
1949
|
+
: [];
|
|
1950
|
+
}
|
|
1951
|
+
// `--types "상담=30m,데모=60m|설명"` → 목록을 통째로 바꾼다. 빈 값을 주면 종류를 없앤다.
|
|
1952
|
+
if (args['types'] !== undefined) {
|
|
1953
|
+
const raw = String(args['types'] === true ? '' : args['types']).trim();
|
|
1954
|
+
body.types = raw
|
|
1955
|
+
? raw
|
|
1956
|
+
.split(',')
|
|
1957
|
+
.map((part) => parseType(part))
|
|
1958
|
+
.filter(Boolean)
|
|
1959
|
+
: [];
|
|
1960
|
+
}
|
|
1961
|
+
// `--remind 1d,1h` → [1440, 60]. `off`·빈 값이면 안 보낸다.
|
|
1962
|
+
if (args['remind'] !== undefined) {
|
|
1963
|
+
const raw = String(args['remind']).trim().toLowerCase();
|
|
1964
|
+
body.reminders =
|
|
1965
|
+
raw === '' || raw === 'off' || args['remind'] === false
|
|
1966
|
+
? []
|
|
1967
|
+
: raw
|
|
1968
|
+
.split(',')
|
|
1969
|
+
.map((x) => parseRemind(x))
|
|
1970
|
+
.filter((n) => n !== null);
|
|
1971
|
+
}
|
|
1972
|
+
if (args['notify-email'] !== undefined) {
|
|
1973
|
+
body.notifyEmails = String(args['notify-email']).split(',').map((x) => x.trim()).filter(Boolean);
|
|
1974
|
+
}
|
|
1975
|
+
if (args['block-time'] !== undefined) {
|
|
1976
|
+
body.blockedTimes = String(args['block-time'])
|
|
1977
|
+
.split(',')
|
|
1978
|
+
.map((x) => parseBlockTime(x))
|
|
1979
|
+
.filter(Boolean);
|
|
1980
|
+
}
|
|
1981
|
+
if (args.approval) body.approval = String(args.approval);
|
|
1982
|
+
if (args.tz) body.timezone = String(args.tz);
|
|
1983
|
+
if (args.message !== undefined) body.notice = String(args.message);
|
|
1984
|
+
if (args.ask !== undefined) {
|
|
1985
|
+
// "연락처:required,상담 종류" → [{propId:'연락처',required:true},{propId:'상담 종류',required:false}]
|
|
1986
|
+
// 칸 이름으로 적어도 받는다(id 를 외우고 있는 사람은 없다).
|
|
1987
|
+
body.askFields = String(args.ask)
|
|
1988
|
+
.split(',')
|
|
1989
|
+
.map((x) => x.trim())
|
|
1990
|
+
.filter(Boolean)
|
|
1991
|
+
.map((x) => {
|
|
1992
|
+
const required = /:required$/i.test(x);
|
|
1993
|
+
return { propId: x.replace(/:required$/i, '').trim(), required };
|
|
1994
|
+
});
|
|
1995
|
+
}
|
|
1996
|
+
if (args['gcal-connection'] || args['gcal-busy'] || args['gcal-write']) {
|
|
1997
|
+
const cur = (await client.getBooking(id)).booking?.google || {};
|
|
1998
|
+
const google = { ...cur };
|
|
1999
|
+
if (args['gcal-connection']) google.connectionId = String(args['gcal-connection']);
|
|
2000
|
+
if (args['gcal-busy']) google.busyCalendarIds = String(args['gcal-busy']).split(',').map((x) => x.trim()).filter(Boolean);
|
|
2001
|
+
if (args['gcal-write']) google.writeCalendarId = args['gcal-write'] === 'none' ? null : String(args['gcal-write']);
|
|
2002
|
+
if (!google.connectionId) {
|
|
2003
|
+
throw new Error(tr('먼저 --gcal-connection 으로 구글 연결을 고르세요(rootr gcal list).', `Pick a connection first with --gcal-connection (see rootr gcal list).`));
|
|
2004
|
+
}
|
|
2005
|
+
body.google = google;
|
|
2006
|
+
}
|
|
2007
|
+
if (!Object.keys(body).length) throw new Error(usage('rootr booking set <path|id> --on [...]'));
|
|
2008
|
+
|
|
2009
|
+
// 칸 이름으로 물어볼 칸을 골랐으면 id 로 바꿔 준다 — 서버는 id 만 받는다.
|
|
2010
|
+
if (body.askFields?.length) {
|
|
2011
|
+
const db = await client.getDatabase(id);
|
|
2012
|
+
const byName = new Map((db.properties || []).map((p) => [p.name, p.id]));
|
|
2013
|
+
const ids = new Set((db.properties || []).map((p) => p.id));
|
|
2014
|
+
body.askFields = body.askFields.map((a) => {
|
|
2015
|
+
if (ids.has(a.propId)) return a;
|
|
2016
|
+
const found = byName.get(a.propId);
|
|
2017
|
+
if (!found) throw new Error(tr(`"${a.propId}" 칸이 이 표에 없습니다.`, `No column named "${a.propId}" in this table.`));
|
|
2018
|
+
return { ...a, propId: found };
|
|
2019
|
+
});
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
const st = await client.setBooking(id, body);
|
|
2023
|
+
if (args.json) return console.log(JSON.stringify(st, null, 2));
|
|
2024
|
+
const pub = await client.nodePublicStatus(id);
|
|
2025
|
+
console.log(st.booking.enabled ? tr('방문자 일정 등록을 켰습니다.', `Visitor booking is on.`) : tr('방문자 일정 등록을 껐습니다.', `Visitor booking is off.`));
|
|
2026
|
+
if (st.booking.enabled && pub.slug) console.log(tr(` 예약 주소: /p/${pub.slug}`, ` booking link: /p/${pub.slug}`));
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
const DAY_NAMES = ['일', '월', '화', '수', '목', '금', '토'];
|
|
2030
|
+
|
|
2031
|
+
/**
|
|
2032
|
+
* BOOKING-GCAL — 구글 캘린더 연결. 연결은 **사람 한 명의 것**이라, 동의 화면은
|
|
2033
|
+
* 브라우저에서 한 번 거쳐야 한다(구글이 그렇게만 허락한다). 그 한 번을 빼면 다 여기서 된다.
|
|
2034
|
+
*/
|
|
2035
|
+
async function cmdGcal(args) {
|
|
2036
|
+
const sub = args._[0] || 'list';
|
|
2037
|
+
const client = makeClient();
|
|
2038
|
+
|
|
2039
|
+
if (sub === 'list') {
|
|
2040
|
+
const res = await client.googleCalendarConnections();
|
|
2041
|
+
if (args.json) return console.log(JSON.stringify(res, null, 2));
|
|
2042
|
+
if (!res.available) return console.log(tr('이 서버에는 구글 캘린더 연결이 아직 준비되지 않았습니다.', `Google Calendar isn't configured on this server yet.`));
|
|
2043
|
+
if (!res.connections.length) return console.log(tr('연결된 구글 계정이 없습니다. rootr gcal connect 로 시작하세요.', `No Google account connected. Run rootr gcal connect.`));
|
|
2044
|
+
for (const c of res.connections) {
|
|
2045
|
+
console.log(`${c.id} ${c.email}${c.broken ? tr(' (연결 끊김 — 다시 연결 필요)', ` (disconnected — reconnect)`) : ''}`);
|
|
2046
|
+
}
|
|
2047
|
+
return;
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
if (sub === 'connect') {
|
|
2051
|
+
const { url } = await client.googleCalendarAuthorizeUrl(args.return || '/');
|
|
2052
|
+
if (args.json) return console.log(JSON.stringify({ url }, null, 2));
|
|
2053
|
+
console.log(tr('아래 주소를 브라우저에서 열고 허용해 주세요(구글은 이 한 번을 꼭 사람 손으로 받습니다):', `Open this in a browser and approve (Google requires this one step by hand):`));
|
|
2054
|
+
console.log(url);
|
|
2055
|
+
console.log(tr('\n허용한 뒤 rootr gcal list 로 연결을 확인하세요.', `\nThen run rootr gcal list to see the connection.`));
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
if (sub === 'calendars') {
|
|
2060
|
+
const connId = args._[1];
|
|
2061
|
+
if (!connId) throw new Error(usage('rootr gcal calendars <connectionId>'));
|
|
2062
|
+
const res = await client.googleCalendars(connId);
|
|
2063
|
+
if (args.json) return console.log(JSON.stringify(res, null, 2));
|
|
2064
|
+
for (const c of res.calendars) {
|
|
2065
|
+
const marks = [c.primary ? tr('기본', `primary`) : '', c.writable ? tr('쓰기 가능', `writable`) : tr('읽기만', `read-only`)].filter(Boolean).join(', ');
|
|
2066
|
+
console.log(`${c.id} ${c.name} (${marks})`);
|
|
2067
|
+
}
|
|
2068
|
+
return;
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
if (sub === 'disconnect') {
|
|
2072
|
+
const connId = args._[1];
|
|
2073
|
+
if (!connId) throw new Error(usage('rootr gcal disconnect <connectionId>'));
|
|
2074
|
+
await client.disconnectGoogleCalendar(connId);
|
|
2075
|
+
console.log(tr('연결을 끊었습니다(구글 쪽 권한도 거뒀습니다).', `Disconnected (access revoked on Google too).`));
|
|
2076
|
+
return;
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
throw new Error(usage('rootr gcal <list|connect|calendars|disconnect>'));
|
|
2080
|
+
}
|
|
2081
|
+
|
|
1628
2082
|
async function cmdReadReq(args) {
|
|
1629
2083
|
const target = args._[0];
|
|
1630
2084
|
if (!target) {
|
|
@@ -1673,11 +2127,21 @@ async function cmdReadReq(args) {
|
|
|
1673
2127
|
tr(` · 마감: ${r.dueAt ?? '없음'}${r.overdue ? ' (지남)' : ''}`, ` · due: ${r.dueAt ?? 'none'}${r.overdue ? ' (overdue)' : ''}`) +
|
|
1674
2128
|
tr(`${r.requireExplicitAck ? ' · 확인 버튼 필요' : ''}`, `${r.requireExplicitAck ? ' · acknowledge button required' : ''}`),
|
|
1675
2129
|
);
|
|
2130
|
+
// 표·폴더에 걸었으면 사람마다 "자기가 열 수 있는 문서를 다 읽었나"(completed)가 완료이고, 진도는 그 사람의 readCount/readTotal 이다
|
|
2131
|
+
// — 읽을 수는 사람마다 다르다("본인이 만든 행만" 표). 남의 수를 볼 수 없는 사람에게는 서버가 수를 싣지 않는다(감사 ROUND-15 #4:
|
|
2132
|
+
// 예전엔 모두 같은 childTotal 로 나눠 다 읽은 사람을 "not yet 2/4"로 찍었다).
|
|
2133
|
+
const rollup = r.rollup ?? r.childTotal > 0;
|
|
1676
2134
|
for (const target of r.targets ?? []) {
|
|
1677
|
-
const mark =
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
2135
|
+
const mark = rollup
|
|
2136
|
+
? target.completed
|
|
2137
|
+
? tr('다 읽음', `done`)
|
|
2138
|
+
: tr('아직', `not yet`)
|
|
2139
|
+
: target.ackedAt
|
|
2140
|
+
? tr('확인', `acknowledged`)
|
|
2141
|
+
: target.firstViewedAt
|
|
2142
|
+
? tr('읽음', `read`)
|
|
2143
|
+
: tr('아직', `not yet`);
|
|
2144
|
+
const progress = rollup && target.readTotal !== undefined ? ` ${target.readCount ?? 0}/${target.readTotal}` : '';
|
|
1681
2145
|
console.log(` ${mark}${progress} ${target.name || target.email}`);
|
|
1682
2146
|
}
|
|
1683
2147
|
}
|
|
@@ -2449,6 +2913,8 @@ async function main() {
|
|
|
2449
2913
|
return cmdWrite(args);
|
|
2450
2914
|
case 'append':
|
|
2451
2915
|
return cmdAppend(args);
|
|
2916
|
+
case 'embed':
|
|
2917
|
+
return cmdEmbed(args);
|
|
2452
2918
|
case 'edit':
|
|
2453
2919
|
return cmdEdit(args);
|
|
2454
2920
|
case 'search':
|
|
@@ -2475,6 +2941,10 @@ async function main() {
|
|
|
2475
2941
|
return cmdForm(args);
|
|
2476
2942
|
case 'publish':
|
|
2477
2943
|
return cmdPublish(args);
|
|
2944
|
+
case 'booking':
|
|
2945
|
+
return cmdBooking(args);
|
|
2946
|
+
case 'gcal':
|
|
2947
|
+
return cmdGcal(args);
|
|
2478
2948
|
case 'readreq':
|
|
2479
2949
|
return cmdReadReq(args);
|
|
2480
2950
|
case 'access':
|