rootr-cli 0.20.0 → 0.22.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 +37 -2
- package/README.md +35 -5
- package/bin/rootr.js +149 -6
- package/lib/client.js +63 -2
- package/lib/mcp-tools/databases.js +289 -4
- package/lib/mcp-tools/forms.js +6 -1
- package/lib/mcp-tools/pages.js +29 -4
- package/lib/mcp-tools/workspaces.js +5 -0
- package/package.json +1 -1
package/README.ko.md
CHANGED
|
@@ -542,6 +542,26 @@ rootr db view <표> <뷰> --scale year # 달력 보기를 주·달·해
|
|
|
542
542
|
"steps": [{ "kind": "update_rows", "target": "selected", "values": { "오늘 할 일": true } }] }]
|
|
543
543
|
```
|
|
544
544
|
|
|
545
|
+
**같은 행이 이미 있으면 잇기.** 마스터 표에 등록하는 규칙(투입확정 → 사업장 마스터)은 기존 고객이면
|
|
546
|
+
이미 행이 있습니다. `add_row` 에 `match` 를 주면 만들기 전에 찾습니다 — 같은 이름이 **하나**면 새로
|
|
547
|
+
만들지 않고 그 행에 잇고, **없으면** 만들고, **여럿**이면 아무것도 안 만들고 요청 행에 "후보 여러 곳"
|
|
548
|
+
댓글을 남깁니다. 먼저 공백만 무시하고 똑같은 이름을 찾으므로 지점명까지 같으면 그 지점에 잇습니다.
|
|
549
|
+
관계 칸은 `"{배정 매니저}"` 처럼 칸 하나만 적으면(받는 칸이 같은 표를 가리킬 때) 행 자체가 옮겨집니다.
|
|
550
|
+
|
|
551
|
+
```json
|
|
552
|
+
[{ "label": "투입확정 시 사업장 등록",
|
|
553
|
+
"when": [{ "property": "상태", "op": "changed_to", "value": "투입확정" },
|
|
554
|
+
{ "property": "사업장", "op": "is_empty" }],
|
|
555
|
+
"steps": [{ "kind": "add_row", "database": "/사업장 마스터",
|
|
556
|
+
"values": { "업체명": "{요청 건}", "담당 매니저": "{배정 매니저}" },
|
|
557
|
+
"link": { "relation": "채용 요청", "from": "selected" },
|
|
558
|
+
"match": { "key": "{요청 건}", "on": "업체명",
|
|
559
|
+
"normalize": ["spaces", "parens", "suffix:의원", "trailingDigits"], "fill": "empty" } }] }]
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
`rootr db automations <표>` 는 규칙마다 "같은 행이 있으면 잇기"와 마지막 사유(후보 여러 곳·조건에 걸려
|
|
563
|
+
비운 칸)를 함께 보여 줍니다.
|
|
564
|
+
|
|
545
565
|
스캐폴드로 표를 지을 때도 `database.buttons` · `database.automations` 에 같은 모양을 넣으면
|
|
546
566
|
**표와 함께** 달립니다.
|
|
547
567
|
|
|
@@ -789,9 +809,24 @@ rootr db view <표> <뷰> --scale year # 달력 보기를 주·달·해
|
|
|
789
809
|
`rootr_append_page_blocks`, `rootr_update_page`, `rootr_update_page_block`,
|
|
790
810
|
`rootr_read_page_live_data`)로 저작합니다.
|
|
791
811
|
|
|
792
|
-
HTML을 짜는 것이 아니라 **정해진 블록
|
|
812
|
+
HTML을 짜는 것이 아니라 **정해진 블록 15종을 골라 쌓습니다** — `hero` `text`
|
|
793
813
|
`features` `gallery` `pricing` `testimonials` `faq` `timeline` `cta` `footer` +
|
|
794
|
-
표에서 값을 끌어오는 `liveStats` `liveTable` `liveChart` + 신청을 받는 `form` +
|
|
814
|
+
표에서 값을 끌어오는 `liveStats` `liveTable` `liveChart` + 신청을 받는 `form` +
|
|
815
|
+
방문자가 시간을 잡는 `booking`(`{bookingNodeId, headline?, type?}` — 예약을 켜고 공개한 표의 노드 id) +
|
|
816
|
+
자유 블록 `custom`.
|
|
817
|
+
|
|
818
|
+
**랜딩 페이지**용으로 두 블록은 가운데 본문 기둥을 벗어나 화면 폭 전체를 쓸 수 있습니다.
|
|
819
|
+
`hero`에 `size: "screen"`을 주면 화면 한 장을 채우는 큰 제목(화면 폭 전체·화면 높이·큰 글자)이 되고,
|
|
820
|
+
`custom`에 `bleed: true`를 주면 좌우·위아래 여백 없이 화면 폭 전체를 써서 연달아 놓은 블록이
|
|
821
|
+
틈 없는 색 띠로 이어집니다. `custom`에 `minHeight: "screen"`을 주면 어느 기기에서나 틀 높이가
|
|
822
|
+
화면 한 장이 되고(숫자 `height`를 같이 주면 그 값이 하한), `bleed: true`와 함께 쓰면 화면을 꽉 채우는
|
|
823
|
+
섹션이 됩니다. 맨 위 이름 줄은 `config.navEnabled: false`로 끕니다.
|
|
824
|
+
|
|
825
|
+
**스크롤 등장 효과** — 어느 블록이나 `reveal: "fade"`(서서히) 또는 `"fade-up"`(아래에서 위로)을 주면
|
|
826
|
+
(기본 `"none"`), 발행한 페이지에서 그 블록이 화면에 들어올 때 한 번 나타납니다(큰 제목은 제목 → 부제 →
|
|
827
|
+
버튼 순서로). `custom`에 `reveal`을 주면 **틀 안의 CSS 애니메이션도 그 순간에 시작**합니다 — 틀을 블록이
|
|
828
|
+
화면에 들어올 때 만들기 때문입니다. `@keyframes`는 `@media (prefers-reduced-motion: no-preference)` 안에
|
|
829
|
+
적으세요. 움직임 줄이기를 켠 방문자에게는 효과 없이 바로 보입니다.
|
|
795
830
|
|
|
796
831
|
`custom`은 **격리된 방**입니다: HTML/CSS를 마음대로 쓰되 `sandbox` 아이프레임 안에서만
|
|
797
832
|
그려집니다(스크립트 없음·같은 출처 아님·바깥 통신 차단). 그래서 대담한 CSS도 바깥 화면을
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ citations back to the exact paragraph.
|
|
|
11
11
|
*(한국어 문서: [README.ko.md](https://github.com/gwmage/rootr-cli/blob/main/README.ko.md))*
|
|
12
12
|
|
|
13
13
|
- `rootr` CLI — documents, databases, forms, signed documents, publishing, permissions, memory
|
|
14
|
-
- `rootr mcp` — stdio MCP server, **
|
|
14
|
+
- `rootr mcp` — stdio MCP server, **192 tools** (see [MCP tools](#mcp-tools))
|
|
15
15
|
|
|
16
16
|
Plain Node.js ESM, no build step. Node 18+ (uses the built-in `fetch`).
|
|
17
17
|
|
|
@@ -197,7 +197,13 @@ rootr db rows-new /projects/tasks --file rows.json # many at once — all or
|
|
|
197
197
|
rootr db rows-set /projects/tasks --ids r1,r2 --values '{"Status":"Done"}'
|
|
198
198
|
rootr db rows-each /projects/tasks --json '[{"rowId":"r1","values":{"Score":3}}]'
|
|
199
199
|
rootr db rows-rm /projects/tasks --ids r1,r2 --yes
|
|
200
|
+
rootr db duplicates /hr/applicants --row r9 # same person (dedupe column) + latest linked history
|
|
201
|
+
rootr db merge /hr/applicants r9 --into r1 # merge a duplicate into the existing row
|
|
202
|
+
rootr db keep-duplicate /hr/applicants r9 --from r1 # keep as a new row, link both
|
|
203
|
+
rootr db history /hr/applicants --row r1 # who changed what, when, how
|
|
204
|
+
rootr db restore /hr/applicants --at 2026-10-15T09:00:00+09:00 --yes # rows + columns/views back to that time
|
|
200
205
|
rootr db view /projects/tasks Calendar --scale month # views: table, board, calendar, timeline
|
|
206
|
+
rootr db view /hr/applicants Board --cards "Interview date,Phone" # fields shown on board cards
|
|
201
207
|
rootr db lock /projects/tasks --schema managers --rows own --locked "Approved,Score"
|
|
202
208
|
rootr db notify /projects/tasks --file rules.json # notification rules
|
|
203
209
|
rootr db buttons /projects/tasks --file buttons.json # buttons that do work when pressed
|
|
@@ -215,6 +221,13 @@ add `on: ["schedule"]` with `schedule: { at: "09:00" }` for a daily job (Korean
|
|
|
215
221
|
Prefer `changed_to` over `is`: `is` stays true on every later edit, so a rule that creates
|
|
216
222
|
rows would create them again each time the row is saved.
|
|
217
223
|
|
|
224
|
+
A rule that registers into a master table should not duplicate customers who are already
|
|
225
|
+
there: give the `add_row` a `match: { key: "{Request}", on: "Company", normalize: ["spaces",
|
|
226
|
+
"parens", "suffix:Clinic", "trailingDigits"], fill: "empty" }`. One matching row → linked
|
|
227
|
+
instead of created; none → created; several → nothing is created and the request row gets a
|
|
228
|
+
"several candidates" comment. A value that is exactly `"{Assigned manager}"` going into a
|
|
229
|
+
relation to the same table copies the linked rows, not their titles.
|
|
230
|
+
|
|
218
231
|
`--upsert-key` is what makes a migration or a sync safe to retry — without it, retrying
|
|
219
232
|
after a partial failure silently double-loads (a real incident: 903 duplicate rows).
|
|
220
233
|
|
|
@@ -394,12 +407,12 @@ is refused and says when the grace was used.
|
|
|
394
407
|
|
|
395
408
|
## MCP tools
|
|
396
409
|
|
|
397
|
-
`rootr mcp` speaks MCP over stdio.
|
|
410
|
+
`rootr mcp` speaks MCP over stdio. 192 tools:
|
|
398
411
|
|
|
399
412
|
| Group | Count | Tools |
|
|
400
413
|
|---|---|---|
|
|
401
414
|
| Documents | 22 | list, read, write, append, edit, search, comment, version history, commits & merges, publish, trash & restore |
|
|
402
|
-
| Databases |
|
|
415
|
+
| Databases | 32 | create, columns, views, rows (single & bulk), row pages, permissions, notifications, buttons, duplicates (find / merge / keep & link), history & point-in-time restore (row / whole table) |
|
|
403
416
|
| CRM | 18 | companies, contacts, deals (incl. pipeline moves), activities, tasks, CSV import |
|
|
404
417
|
| Signed documents & research notes | 14 | create, sign, revise, reject, reopen, approval lines, signing codes, ledger verification, research-note preset |
|
|
405
418
|
| Forms | 10 | create, read, update, responses, share links, collectors |
|
|
@@ -451,9 +464,26 @@ signed-document, issue, CRM and LOG tools need `docs:read` / `docs:write`; `root
|
|
|
451
464
|
### Pages
|
|
452
465
|
|
|
453
466
|
A PAGE is **a screen for people outside the workspace** — an event announcement, a product page,
|
|
454
|
-
a sign-up. You do not write HTML: you stack **
|
|
467
|
+
a sign-up. You do not write HTML: you stack **15 predefined block types** — `hero`, `text`,
|
|
455
468
|
`features`, `gallery`, `pricing`, `testimonials`, `faq`, `timeline`, `cta`, `footer`, the
|
|
456
|
-
table-backed `liveStats`, `liveTable`, `liveChart`, the `form` block,
|
|
469
|
+
table-backed `liveStats`, `liveTable`, `liveChart`, the `form` block, the `booking` block
|
|
470
|
+
(a visitor scheduling calendar: `{bookingNodeId, headline?, type?}` — the node id of a published
|
|
471
|
+
table with visitor booking on) — plus the free-form `custom`.
|
|
472
|
+
|
|
473
|
+
For a **landing page**, two options let a block use the whole page width instead of the centred
|
|
474
|
+
text column: `hero` with `size: "screen"` is a full-screen hero (edge to edge, one viewport tall,
|
|
475
|
+
large headline), and `custom` with `bleed: true` spans the full width with no gap around it, so
|
|
476
|
+
consecutive bleed blocks read as seamless colour bands. `custom` with `minHeight: "screen"` is one
|
|
477
|
+
viewport tall on every device (a numeric `height` given with it is the floor), so
|
|
478
|
+
`bleed: true` + `minHeight: "screen"` is a section that fills the screen. Pair them with
|
|
479
|
+
`config.navEnabled: false`.
|
|
480
|
+
|
|
481
|
+
For **scroll-in animation**, any block takes `reveal: "fade"` or `"fade-up"` (default `"none"`): on
|
|
482
|
+
the published page it appears once, when it scrolls into view (a hero staggers title → subtitle →
|
|
483
|
+
button). On a `custom` block `reveal` also **starts the CSS animations inside the frame at that
|
|
484
|
+
moment** — the frame is created only when the block scrolls in — so put your `@keyframes` there and
|
|
485
|
+
wrap them in `@media (prefers-reduced-motion: no-preference)`. Visitors who ask for reduced motion
|
|
486
|
+
see everything at once.
|
|
457
487
|
|
|
458
488
|
`custom` is an **isolated room**: write any HTML/CSS you like, but it renders inside a `sandbox`
|
|
459
489
|
iframe (no scripts, not same-origin, no outbound calls), so even bold CSS cannot leak onto the
|
package/bin/rootr.js
CHANGED
|
@@ -38,13 +38,18 @@ const USAGE_KO = `Rootr CLI v${pkg.version}
|
|
|
38
38
|
rootr db button-run <path|id> <buttonId> [--rows id,id] 버튼 한 번 누르기 (행 만들기·값 채우기·AI 채우기)
|
|
39
39
|
rootr db automations <path|id> [--file rules.json] [--clear] 자동 규칙 — "값이 이렇게 되면 이 일을 해라"·정해진 시각
|
|
40
40
|
rootr db import-sheets <파일.xlsx> --ws <id> [--dry] 시트 수백 장짜리 엑셀을 표 하나로 접어 가져오기
|
|
41
|
-
rootr db view <path|id> [뷰이름] [--scale week|month|year] [--name 새이름] 뷰 목록·달력 주/달/해
|
|
41
|
+
rootr db view <path|id> [뷰이름] [--scale week|month|year] [--name 새이름] [--cards "칸,칸"] 뷰 목록·달력 주/달/해 단위·보드 카드에 보일 칸
|
|
42
42
|
rootr db row-add <path|id> --values '<json>' [--body <md>|--body-file <f>] 행 추가(상세 내용 포함)
|
|
43
43
|
rootr db row-body <path|id> <rowId> [--file <f>] 행의 상세 내용(본문 메모) 쓰기 — 없으면 표준입력
|
|
44
44
|
rootr db rows-new <path|id> --json '[{…},…]' | --file <파일> 여러 행 한 번에 넣기 (하나라도 틀리면 전부 취소)
|
|
45
45
|
rootr db rows-set <path|id> --ids <id,id> --values '<json>' 고른 행 전부에 같은 값 (날짜 일괄 변경 등)
|
|
46
46
|
rootr db rows-each <path|id> --json '[{"rowId":…,"values":{…}},…]' 행마다 다른 값으로 한 번에 수정
|
|
47
47
|
rootr db rows-rm <path|id> --ids <id,id> [--yes] 고른 행 한 번에 삭제 — 하나라도 막히면 통째로 거절
|
|
48
|
+
rootr db duplicates <path|id> (--row <rowId> | --values '<json>') [--json] 같은 사람(중복 감지 칸 값이 같은 행)과 그 이력
|
|
49
|
+
rootr db merge <path|id> <rowId> --into <기존 rowId> 중복 행을 기존 행에 합치기(빈 칸만 채움, rowId 행은 지움)
|
|
50
|
+
rootr db keep-duplicate <path|id> <rowId> --from <이전 rowId> 새 행으로 두되 이전 행과 서로 링크
|
|
51
|
+
rootr db history <path|id> [--row <rowId>] [--limit N] [--json] 행·표 변경 이력(누가·언제·어떻게·전후 값)
|
|
52
|
+
rootr db restore <path|id> --at <ISO 시각> [--yes] 표 전체(행+칸·보기·버튼·규칙)를 그 시각으로 — --yes 없으면 미리보기만
|
|
48
53
|
rootr db row-page <rowId> [--type RECORDING] 이 행만 원하는 타입으로 상세 붙이기(녹음·스프레드시트·서명문서…)
|
|
49
54
|
rootr db row-page <rowId> --node <path|id> 이미 있는 문서를 이 행에 잇기 (옮기지 않고 가리킴)
|
|
50
55
|
rootr db row-page <rowId> --unlink 연결만 끊기 — 문서는 그대로 남음
|
|
@@ -146,6 +151,11 @@ usage:
|
|
|
146
151
|
rootr db rows-set <path|id> --ids <id,id> --values '<json>' same value on every chosen row (bulk date changes, …)
|
|
147
152
|
rootr db rows-each <path|id> --json '[{"rowId":…,"values":{…}},…]' a different value per row, in one call
|
|
148
153
|
rootr db rows-rm <path|id> --ids <id,id> [--yes] delete chosen rows at once — one blocked row rejects all
|
|
154
|
+
rootr db duplicates <path|id> (--row <rowId> | --values '<json>') [--json] rows of the same person (same dedupe value) with history
|
|
155
|
+
rootr db merge <path|id> <rowId> --into <existing rowId> merge a duplicate into the existing row (fills blanks, deletes rowId)
|
|
156
|
+
rootr db keep-duplicate <path|id> <rowId> --from <earlier rowId> keep as a new row but link both rows
|
|
157
|
+
rootr db history <path|id> [--row <rowId>] [--limit N] [--json] change history (who, when, how, before/after)
|
|
158
|
+
rootr db restore <path|id> --at <ISO time> [--yes] restore the whole table (rows + columns/views/buttons/rules) — preview without --yes
|
|
149
159
|
rootr db row-page <rowId> [--type RECORDING] give this row a typed detail page (recording, spreadsheet, signed doc…)
|
|
150
160
|
rootr db row-page <rowId> --node <path|id> attach an existing document to this row (points at it, does not move it)
|
|
151
161
|
rootr db row-page <rowId> --unlink unlink only — the document stays
|
|
@@ -1361,11 +1371,24 @@ async function cmdDbAutomations(args) {
|
|
|
1361
1371
|
if (a.when) console.log(` ${tr('조건', `if`)}: ${a.when.property} ${a.when.op} ${a.when.value ?? ''}`);
|
|
1362
1372
|
for (const [i, st] of (a.steps ?? []).entries()) {
|
|
1363
1373
|
const where = st.database ? ` → ${st.database}` : '';
|
|
1364
|
-
console.log(` ${i + 1}. ${st.kind}${where}`);
|
|
1374
|
+
console.log(` ${i + 1}. ${st.kind}${where}${matchNote(st)}`);
|
|
1365
1375
|
}
|
|
1376
|
+
// AUTO-MATCH · AUTO-REL-COPY — 후보 여러 곳·조건에 걸려 비운 칸도 여기(사유 자리)에 남는다.
|
|
1377
|
+
if (a.lastError?.message) console.log(` ${tr('사유', `note`)}: ${a.lastError.message}`);
|
|
1366
1378
|
}
|
|
1367
1379
|
}
|
|
1368
1380
|
|
|
1381
|
+
/** AUTO-MATCH — `add_row.match` 가 있으면 "같은 행이 있으면 잇기"를 한 줄로. */
|
|
1382
|
+
function matchNote(st) {
|
|
1383
|
+
if (st.kind !== 'add_row' || !st.match) return '';
|
|
1384
|
+
const norm = st.match.normalize?.length ? ` [${st.match.normalize.join(', ')}]` : '';
|
|
1385
|
+
const fill = st.match.fill === 'empty' ? tr(', 빈 칸 채움', `, fill blanks`) : '';
|
|
1386
|
+
return tr(
|
|
1387
|
+
` · 같은 행이 있으면 잇기(${st.match.on} = ${st.match.key}${norm}${fill})`,
|
|
1388
|
+
` · link to an existing row if one matches (${st.match.on} = ${st.match.key}${norm}${fill})`,
|
|
1389
|
+
);
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1369
1392
|
/**
|
|
1370
1393
|
* XLSX-FOLD — 시트가 수백 장인 엑셀을 **표 하나로 접어** 가져온다.
|
|
1371
1394
|
*
|
|
@@ -1544,6 +1567,111 @@ async function cmdDbRowsRm(args) {
|
|
|
1544
1567
|
console.log(tr(`${res.deleted}개 행을 지웠습니다.`, `${res.deleted} rows deleted.`));
|
|
1545
1568
|
}
|
|
1546
1569
|
|
|
1570
|
+
/**
|
|
1571
|
+
* DEDUPE(F5) — `rootr db duplicates <표> --row <rowId>` 또는 `--values '{"연락처":"010-…"}'`.
|
|
1572
|
+
* 같은 사람(중복 감지 칸 값이 같은 행)마다 보일 칸과 이어진 행 최근 5건(연락 이력)을 보인다.
|
|
1573
|
+
*/
|
|
1574
|
+
async function cmdDbDuplicates(args) {
|
|
1575
|
+
const target = args._[1];
|
|
1576
|
+
if (!target || (typeof args.row !== 'string' && typeof args.values !== 'string')) {
|
|
1577
|
+
throw new Error(usage(`rootr db duplicates <path|id> (--row <rowId> | --values '<json>') [--json]`));
|
|
1578
|
+
}
|
|
1579
|
+
let values;
|
|
1580
|
+
if (typeof args.values === 'string') {
|
|
1581
|
+
try {
|
|
1582
|
+
values = JSON.parse(args.values);
|
|
1583
|
+
} catch {
|
|
1584
|
+
throw new Error(tr('--values 를 JSON 으로 읽을 수 없습니다.', 'Could not read --values as JSON.'));
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
const client = makeClient();
|
|
1588
|
+
const id = await resolveDatabaseId(client, target);
|
|
1589
|
+
const res = await client.findDuplicates(id, { rowId: typeof args.row === 'string' ? args.row : undefined, values });
|
|
1590
|
+
if (args.json) return console.log(JSON.stringify(res, null, 2));
|
|
1591
|
+
if (!res.matches.length) return console.log(tr('같은 값의 기존 행이 없습니다.', 'No existing row has the same value.'));
|
|
1592
|
+
for (const m of res.matches) {
|
|
1593
|
+
console.log(tr(`「${m.propName}」 ${m.value} 같은 행:`, `Rows with ${m.propName} = ${m.value}:`));
|
|
1594
|
+
for (const r of m.rows) {
|
|
1595
|
+
console.log(`- ${r.title || '(제목 없음)'}\t${r.id}`);
|
|
1596
|
+
for (const p of r.preview) console.log(` ${p.name}: ${p.value}`);
|
|
1597
|
+
for (const h of r.history ?? []) {
|
|
1598
|
+
console.log(` ${h.propName} ${h.total}${tr('건', '')}`);
|
|
1599
|
+
for (const x of h.rows) console.log(` · ${[x.title, ...x.fields.map((f) => f.value)].filter(Boolean).join(' · ')}`);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
/** DEDUPE — `rootr db merge <표> <rowId> --into <기존 rowId>` */
|
|
1606
|
+
async function cmdDbMerge(args) {
|
|
1607
|
+
const [, target, rowId] = args._;
|
|
1608
|
+
if (!target || !rowId || typeof args.into !== 'string') throw new Error(usage(`rootr db merge <path|id> <rowId> --into <rowId>`));
|
|
1609
|
+
const client = makeClient();
|
|
1610
|
+
const id = await resolveDatabaseId(client, target);
|
|
1611
|
+
const res = await client.mergeRows(id, rowId, args.into);
|
|
1612
|
+
console.log(
|
|
1613
|
+
tr(
|
|
1614
|
+
`합쳤습니다 → ${res.into} (빈 칸 채움: ${res.filled.join(', ') || '없음'}). ${rowId} 행은 지웠습니다.`,
|
|
1615
|
+
`Merged into ${res.into} (filled: ${res.filled.join(', ') || 'none'}). Row ${rowId} was deleted.`,
|
|
1616
|
+
),
|
|
1617
|
+
);
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
/** ROW-HISTORY — `rootr db history <표> [--row <rowId>] [--limit N] [--json]` */
|
|
1621
|
+
async function cmdDbHistory(args) {
|
|
1622
|
+
const target = args._[1];
|
|
1623
|
+
if (!target) throw new Error(usage(`rootr db history <path|id> [--row <rowId>] [--limit N] [--json]`));
|
|
1624
|
+
const client = makeClient();
|
|
1625
|
+
const id = await resolveDatabaseId(client, target);
|
|
1626
|
+
const res = await client.databaseHistory(id, { rowId: typeof args.row === 'string' ? args.row : undefined, limit: args.limit ? Number(args.limit) : 50 });
|
|
1627
|
+
if (args.json) return console.log(JSON.stringify(res, null, 2));
|
|
1628
|
+
for (const c of res.changes) {
|
|
1629
|
+
const cols = [...new Set([...Object.keys(c.before ?? {}), ...Object.keys(c.after ?? {})])];
|
|
1630
|
+
console.log(`${c.at}\t${c.kind}\t${c.rowId}\t${c.actor?.name ?? '-'}${c.via ? ` (${c.via})` : ''}\t${cols.join(',')}\t${c.id}`);
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
/** ROW-RESTORE — `rootr db restore <표> --at <ISO> [--yes]` (--yes 없으면 미리보기) */
|
|
1635
|
+
async function cmdDbRestore(args) {
|
|
1636
|
+
const target = args._[1];
|
|
1637
|
+
if (!target || typeof args.at !== 'string') throw new Error(usage(`rootr db restore <path|id> --at <ISO time> [--yes]`));
|
|
1638
|
+
const client = makeClient();
|
|
1639
|
+
const id = await resolveDatabaseId(client, target);
|
|
1640
|
+
const preview = await client.restoreDatabase(id, args.at, { dryRun: true });
|
|
1641
|
+
const sc = preview.schema ?? {};
|
|
1642
|
+
console.log(
|
|
1643
|
+
tr(
|
|
1644
|
+
`고칠 행 ${preview.updates} · 되살릴 행 ${preview.creates} · 지울 행 ${preview.deletes}` +
|
|
1645
|
+
(sc.restorable ? (sc.differs ? ` · 표 정의: 되살릴 칸 ${sc.propsAdded}, 뺄 칸 ${sc.propsRemoved}, 바뀌는 칸 ${sc.propsChanged}${sc.views ? ', 보기' : ''}${sc.buttons ? ', 버튼' : ''}${sc.automations ? ', 자동 규칙' : ''}` : ' · 표 정의는 같음') : ' · 그 시각의 표 정의 기록 없음(행만)'),
|
|
1646
|
+
`rows to edit ${preview.updates} · revive ${preview.creates} · delete ${preview.deletes}` +
|
|
1647
|
+
(sc.restorable ? (sc.differs ? ` · definition: ${sc.propsAdded} column(s) back, ${sc.propsRemoved} removed, ${sc.propsChanged} changed${sc.views ? ', views' : ''}${sc.buttons ? ', buttons' : ''}${sc.automations ? ', automations' : ''}` : ' · definition unchanged') : ' · no definition record at that time (rows only)'),
|
|
1648
|
+
),
|
|
1649
|
+
);
|
|
1650
|
+
if (!args.yes) return console.log(tr('미리보기입니다. 되돌리려면 --yes 를 붙이세요.', 'Preview only. Add --yes to restore.'));
|
|
1651
|
+
const res = await client.restoreDatabase(id, args.at);
|
|
1652
|
+
if (res.job) {
|
|
1653
|
+
let job = { done: 0, total: res.job.total, state: 'running' };
|
|
1654
|
+
while (job.state !== 'done') {
|
|
1655
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1656
|
+
job = await client.restoreJob(id, res.job.id);
|
|
1657
|
+
process.stderr.write(`\r${job.done}/${job.total}`);
|
|
1658
|
+
}
|
|
1659
|
+
process.stderr.write('\n');
|
|
1660
|
+
if (job.failed) console.log(tr(`되돌리지 못한 행 ${job.failed}개 — 같은 시각으로 다시 실행하세요.`, `${job.failed} row(s) failed — run the same restore again.`));
|
|
1661
|
+
}
|
|
1662
|
+
console.log(tr('되돌렸습니다.', 'Restored.'));
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
/** DEDUPE-KEEP — `rootr db keep-duplicate <표> <rowId> --from <이전 rowId>` */
|
|
1666
|
+
async function cmdDbKeepDuplicate(args) {
|
|
1667
|
+
const [, target, rowId] = args._;
|
|
1668
|
+
if (!target || !rowId || typeof args.from !== 'string') throw new Error(usage(`rootr db keep-duplicate <path|id> <rowId> --from <rowId>`));
|
|
1669
|
+
const client = makeClient();
|
|
1670
|
+
const id = await resolveDatabaseId(client, target);
|
|
1671
|
+
await client.keepDuplicate(id, rowId, args.from);
|
|
1672
|
+
console.log(tr(`새 행으로 두고 ${args.from} 행과 서로 링크를 남겼습니다.`, `Kept as a new row and linked with ${args.from}.`));
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1547
1675
|
/**
|
|
1548
1676
|
* PUBLIC-ANY — `rootr publish <노드> [--children|--off|--status]`
|
|
1549
1677
|
*
|
|
@@ -2161,6 +2289,11 @@ async function cmdDb(args) {
|
|
|
2161
2289
|
if (sub === 'rows-each') return cmdDbRowsEach(args);
|
|
2162
2290
|
if (sub === 'rows-set') return cmdDbRowsSet(args);
|
|
2163
2291
|
if (sub === 'rows-rm') return cmdDbRowsRm(args);
|
|
2292
|
+
if (sub === 'duplicates') return cmdDbDuplicates(args);
|
|
2293
|
+
if (sub === 'merge') return cmdDbMerge(args);
|
|
2294
|
+
if (sub === 'keep-duplicate') return cmdDbKeepDuplicate(args);
|
|
2295
|
+
if (sub === 'history') return cmdDbHistory(args);
|
|
2296
|
+
if (sub === 'restore') return cmdDbRestore(args);
|
|
2164
2297
|
if (sub === 'row-page') return cmdDbRowPage(args);
|
|
2165
2298
|
if (sub === 'row-page-type') return cmdDbRowPageType(args);
|
|
2166
2299
|
if (sub === 'lock') return cmdDbLock(args);
|
|
@@ -2172,7 +2305,7 @@ async function cmdDb(args) {
|
|
|
2172
2305
|
if (sub === 'view') return cmdDbView(args);
|
|
2173
2306
|
if (sub !== 'create') {
|
|
2174
2307
|
throw new Error(
|
|
2175
|
-
usage(tr(`rootr db create|rows|row-add|rows-new|row-body|rows-set|rows-each|rows-rm|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|row-page|row-page-type|lock|notify|buttons|button-run|view … (see rootr --help)`)),
|
|
2308
|
+
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)`)),
|
|
2176
2309
|
);
|
|
2177
2310
|
}
|
|
2178
2311
|
const raw = typeof args.file === 'string' ? readFileSync(args.file, 'utf8') : await readStdin();
|
|
@@ -2295,7 +2428,7 @@ async function cmdDbNotify(args) {
|
|
|
2295
2428
|
*/
|
|
2296
2429
|
async function cmdDbView(args) {
|
|
2297
2430
|
const target = args._[1];
|
|
2298
|
-
if (!target) throw new Error(usage(tr(`rootr db view <path|id> [뷰이름] [--scale week|month|year] [--name 새이름] [--json]`, `rootr db view <path|id> [view name] [--scale week|month|year] [--name <new name>] [--json]`)));
|
|
2431
|
+
if (!target) throw new Error(usage(tr(`rootr db view <path|id> [뷰이름] [--scale week|month|year] [--name 새이름] [--cards "칸,칸"] [--json]`, `rootr db view <path|id> [view name] [--scale week|month|year] [--name <new name>] [--cards "field,field"] [--json]`)));
|
|
2299
2432
|
|
|
2300
2433
|
const client = makeClient();
|
|
2301
2434
|
const id = await resolveDatabaseId(client, target);
|
|
@@ -2329,8 +2462,13 @@ async function cmdDbView(args) {
|
|
|
2329
2462
|
patch.calendarScale = scale;
|
|
2330
2463
|
}
|
|
2331
2464
|
if (typeof args.name === 'string') patch.name = args.name;
|
|
2465
|
+
// CARD-PROPS — 보드 카드에 보일 칸(이름 또는 id, 쉼표로). "" 이면 기본으로.
|
|
2466
|
+
if (typeof args.cards === 'string') {
|
|
2467
|
+
const cards = args.cards.split(',').map((c) => c.trim()).filter(Boolean);
|
|
2468
|
+
patch.cardProps = cards.length ? cards : null;
|
|
2469
|
+
}
|
|
2332
2470
|
if (!Object.keys(patch).length) {
|
|
2333
|
-
throw new Error(tr('바꿀 것을 주세요: --scale week|month|year 또는 --
|
|
2471
|
+
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"`));
|
|
2334
2472
|
}
|
|
2335
2473
|
|
|
2336
2474
|
const view = await client.updateDatabaseView(id, viewRef, patch);
|
|
@@ -2410,7 +2548,7 @@ async function cmdDbButtons(args) {
|
|
|
2410
2548
|
: st.values
|
|
2411
2549
|
? ' · ' + Object.entries(st.values).map(([k, v]) => `${k}=${v}`).join(', ')
|
|
2412
2550
|
: '';
|
|
2413
|
-
console.log(` ${i + 1}. ${what}${where}${detail}`);
|
|
2551
|
+
console.log(` ${i + 1}. ${what}${where}${detail}${matchNote(st)}`);
|
|
2414
2552
|
}
|
|
2415
2553
|
}
|
|
2416
2554
|
if (myLevel && myLevel !== 'MANAGE' && myLevel !== 'FULL') {
|
|
@@ -2448,6 +2586,9 @@ async function cmdDbButtonRun(args) {
|
|
|
2448
2586
|
if (madeCount) parts.push(tr(`행 ${madeCount}개 만듦`, `${madeCount} rows created`));
|
|
2449
2587
|
if (r.updated) parts.push(tr(`행 ${r.updated}개 고침`, `${r.updated} rows changed`));
|
|
2450
2588
|
if (r.filled) parts.push(tr(`AI로 칸 ${r.filled}개 채움`, `${r.filled} columns filled by AI`));
|
|
2589
|
+
// AUTO-MATCH — 새로 만들지 않고 있던 행에 이은 것 / 후보가 여럿이라 멈춘 것.
|
|
2590
|
+
if (r.matched?.linked) parts.push(tr(`있던 행 ${r.matched.linked}개에 이음`, `${r.matched.linked} linked to existing rows`));
|
|
2591
|
+
if (r.matched?.ambiguous) parts.push(tr(`후보 여러 곳이라 멈춤 ${r.matched.ambiguous}건(행 댓글 확인)`, `${r.matched.ambiguous} stopped — several candidates (see the row comment)`));
|
|
2451
2592
|
console.log(parts.length ? parts.join(' · ') : tr('바뀐 것이 없습니다.', `Nothing changed.`));
|
|
2452
2593
|
if (r.openRowId) console.log(tr(`열 행: ${r.openRowId}`, `Row to open: ${r.openRowId}`));
|
|
2453
2594
|
// AI가 못 채워도 버튼은 계속 돈다 — 조용히 넘어가면 사람이 왜 빈칸인지 모른다.
|
|
@@ -2458,6 +2599,8 @@ async function cmdDbButtonRun(args) {
|
|
|
2458
2599
|
`${r.aiFailed} columns the AI could not fill${r.aiError ? ` — ${r.aiError}` : ''}`,
|
|
2459
2600
|
),
|
|
2460
2601
|
);
|
|
2602
|
+
for (const n of r.notes ?? []) console.log(`· ${n}`);
|
|
2603
|
+
if (r.linkWarning) console.log(`· ${r.linkWarning}`);
|
|
2461
2604
|
}
|
|
2462
2605
|
|
|
2463
2606
|
|
package/lib/client.js
CHANGED
|
@@ -1221,13 +1221,74 @@ export class RootrClient {
|
|
|
1221
1221
|
});
|
|
1222
1222
|
}
|
|
1223
1223
|
|
|
1224
|
-
async updateColumn(
|
|
1224
|
+
async updateColumn(
|
|
1225
|
+
databaseId,
|
|
1226
|
+
propertyId,
|
|
1227
|
+
{ name, options, optionRenames, formula, rollup, prefix, reverseName, searchExcluded, dedupe, dedupeShow, relationFilter } = {},
|
|
1228
|
+
) {
|
|
1225
1229
|
return this.request(
|
|
1226
1230
|
`/databases/${encodeURIComponent(databaseId)}/columns/${encodeURIComponent(propertyId)}`,
|
|
1227
|
-
{
|
|
1231
|
+
{
|
|
1232
|
+
method: 'PATCH',
|
|
1233
|
+
body: { name, options, optionRenames, formula, rollup, prefix, reverseName, searchExcluded, dedupe, dedupeShow, relationFilter },
|
|
1234
|
+
},
|
|
1228
1235
|
);
|
|
1229
1236
|
}
|
|
1230
1237
|
|
|
1238
|
+
/**
|
|
1239
|
+
* DEDUPE(F5) — 중복 감지 칸 값이 같은 기존 행. `rowId` 를 주면 그 행의 지금 값으로(그 행은 빠진다), 아니면 `values`
|
|
1240
|
+
* ({칸 id 또는 이름: 값})로 찾는다. 기존 행마다 보일 칸(preview)과 이어진 행 최근 5건(history)이 함께 온다.
|
|
1241
|
+
*/
|
|
1242
|
+
async findDuplicates(databaseId, { rowId, values } = {}) {
|
|
1243
|
+
return this.request(`/databases/${encodeURIComponent(databaseId)}/duplicates`, {
|
|
1244
|
+
method: 'POST',
|
|
1245
|
+
body: rowId ? { rowId } : { values: values ?? {} },
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/** DEDUPE — `rowId` 행을 `into` 행에 합친다(빈 칸만 채우고 "재지원" 댓글, `rowId` 행은 지운다). */
|
|
1250
|
+
async mergeRows(databaseId, rowId, into) {
|
|
1251
|
+
return this.request(`/databases/${encodeURIComponent(databaseId)}/rows/${encodeURIComponent(rowId)}/merge`, {
|
|
1252
|
+
method: 'POST',
|
|
1253
|
+
body: { into },
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/** ROW-HISTORY(F6) — 표(또는 한 행)의 변경 이력, 최신부터. `before`+`beforeId` 로 이어 읽는다. */
|
|
1258
|
+
async databaseHistory(databaseId, { rowId, before, beforeId, limit } = {}) {
|
|
1259
|
+
const q = new URLSearchParams();
|
|
1260
|
+
if (rowId) q.set('rowId', rowId);
|
|
1261
|
+
if (before) q.set('before', before);
|
|
1262
|
+
if (beforeId) q.set('beforeId', beforeId);
|
|
1263
|
+
if (limit) q.set('limit', String(limit));
|
|
1264
|
+
return this.request(`/databases/${encodeURIComponent(databaseId)}/history${q.toString() ? `?${q}` : ''}`);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
/** ROW-RESTORE(F7) — 행 하나를 그 변경의 전(`before`)/후(`after`) 모습으로. 지운 행도 되살린다. */
|
|
1268
|
+
async restoreRow(databaseId, rowId, changeId, side = 'after') {
|
|
1269
|
+
return this.request(`/databases/${encodeURIComponent(databaseId)}/rows/${encodeURIComponent(rowId)}/restore`, {
|
|
1270
|
+
method: 'POST',
|
|
1271
|
+
body: { changeId, side },
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/** ROW-RESTORE(F7·G3) — 표 전체(행 + 칸·보기·버튼·자동 규칙)를 그 시각으로. `dryRun` 이면 미리보기만. 큰 표는 `job` 이 온다. */
|
|
1276
|
+
async restoreDatabase(databaseId, at, { dryRun } = {}) {
|
|
1277
|
+
return this.request(`/databases/${encodeURIComponent(databaseId)}/restore`, { method: 'POST', body: { at, dryRun: dryRun === true } });
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
async restoreJob(databaseId, jobId) {
|
|
1281
|
+
return this.request(`/databases/${encodeURIComponent(databaseId)}/restore/${encodeURIComponent(jobId)}`);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/** DEDUPE-KEEP — 새 행으로 두되 같은 사람의 이전 행(`from`)과 서로 링크 댓글을 남긴다. */
|
|
1285
|
+
async keepDuplicate(databaseId, rowId, from) {
|
|
1286
|
+
return this.request(`/databases/${encodeURIComponent(databaseId)}/rows/${encodeURIComponent(rowId)}/keep-duplicate`, {
|
|
1287
|
+
method: 'POST',
|
|
1288
|
+
body: { from },
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1231
1292
|
// DB-VECTOR — 에이전트용 벡터 칸(칸 id 또는 이름).
|
|
1232
1293
|
async putVectors(databaseId, column, items) {
|
|
1233
1294
|
return this.request(`/databases/${encodeURIComponent(databaseId)}/vectors/${encodeURIComponent(column)}`, {
|
|
@@ -412,6 +412,30 @@ export function registerDatabaseTools(server) {
|
|
|
412
412
|
'Default month. The year view shows twelve mini-months at once (darker = more rows that day). ' +
|
|
413
413
|
'Saved on the view, so everyone who opens it sees that span.',
|
|
414
414
|
),
|
|
415
|
+
cardProps: z
|
|
416
|
+
.array(z.string())
|
|
417
|
+
.nullable()
|
|
418
|
+
.optional()
|
|
419
|
+
.describe(
|
|
420
|
+
'board view only: the fields shown on each card, in this order (names or ids, up to 12). Rollups and formulas ' +
|
|
421
|
+
'work here (e.g. an interview date rolled up from a linked table) — only the picked ones are computed. ' +
|
|
422
|
+
'null or [] goes back to the default (the first 3 stored fields that have a value).',
|
|
423
|
+
),
|
|
424
|
+
rowColors: z
|
|
425
|
+
.array(
|
|
426
|
+
z.object({
|
|
427
|
+
propId: z.string(),
|
|
428
|
+
op: z.enum(['is', 'is_not_empty']),
|
|
429
|
+
values: z.array(z.string()).optional(),
|
|
430
|
+
color: z.string(),
|
|
431
|
+
}),
|
|
432
|
+
)
|
|
433
|
+
.nullable()
|
|
434
|
+
.optional()
|
|
435
|
+
.describe(
|
|
436
|
+
'Tint rows/cards by value: the first matching rule wins. e.g. [{propId:"진행상태",op:"is",values:["채용불가"],color:"red"}, ' +
|
|
437
|
+
'{propId:"면접예정일",op:"is_not_empty",color:"yellow"}]. Colors: gray, brown, orange, yellow, green, blue, purple, pink, red. null or [] turns it off.',
|
|
438
|
+
),
|
|
415
439
|
},
|
|
416
440
|
},
|
|
417
441
|
async ({
|
|
@@ -426,6 +450,8 @@ export function registerDatabaseTools(server) {
|
|
|
426
450
|
sort,
|
|
427
451
|
colWidths,
|
|
428
452
|
calendarScale,
|
|
453
|
+
cardProps,
|
|
454
|
+
rowColors,
|
|
429
455
|
}) => {
|
|
430
456
|
try {
|
|
431
457
|
const client = makeClient();
|
|
@@ -440,6 +466,8 @@ export function registerDatabaseTools(server) {
|
|
|
440
466
|
sort,
|
|
441
467
|
colWidths,
|
|
442
468
|
calendarScale,
|
|
469
|
+
cardProps,
|
|
470
|
+
rowColors,
|
|
443
471
|
});
|
|
444
472
|
return jsonResult({ view });
|
|
445
473
|
} catch (err) {
|
|
@@ -1055,9 +1083,57 @@ export function registerDatabaseTools(server) {
|
|
|
1055
1083
|
'chat, button AI-fill steps, formula/rollup columns and MCP reads still see the values; to hide values from ' +
|
|
1056
1084
|
'people, use an "own rows only" table or a private node. Table managers only. false puts the column back into the index.',
|
|
1057
1085
|
),
|
|
1086
|
+
optionRenames: z
|
|
1087
|
+
.array(z.object({ from: z.string(), to: z.string() }))
|
|
1088
|
+
.optional()
|
|
1089
|
+
.describe(
|
|
1090
|
+
'select/multi_select/status with `options`: rename choices and move every row that used the old name ' +
|
|
1091
|
+
'(e.g. [{from:"신규접수",to:"접수 완료"}]). A choice left out of `options` and not renamed is DELETED and the rows using it are emptied.',
|
|
1092
|
+
),
|
|
1093
|
+
dedupe: z
|
|
1094
|
+
.boolean()
|
|
1095
|
+
.optional()
|
|
1096
|
+
.describe(
|
|
1097
|
+
'title/text/phone/email/url only: warn when another row has the same value (phone numbers compare digits only, ' +
|
|
1098
|
+
'other text ignores case and spaces). People get a choose-merge-or-keep dialog; forms filled inside Rootr warn before submit; ' +
|
|
1099
|
+
'public forms leave a "possible duplicate" comment. See rootr_find_duplicates.',
|
|
1100
|
+
),
|
|
1101
|
+
dedupeShow: z
|
|
1102
|
+
.array(z.string())
|
|
1103
|
+
.nullable()
|
|
1104
|
+
.optional()
|
|
1105
|
+
.describe(
|
|
1106
|
+
'With dedupe on: which fields of the EXISTING row the duplicate dialog shows, in order (column ids). A relation column ' +
|
|
1107
|
+
'shows the latest 5 linked rows (contact history) and the total. Names or ids; an unknown name is refused (400), not dropped. ' +
|
|
1108
|
+
'null/[] = automatic (status, notes, dates, rollups; relations as history).',
|
|
1109
|
+
),
|
|
1110
|
+
relationFilter: z
|
|
1111
|
+
.array(z.object({ propId: z.string(), op: z.enum(['is', 'is_not_empty']), values: z.array(z.string()).optional() }))
|
|
1112
|
+
.nullable()
|
|
1113
|
+
.optional()
|
|
1114
|
+
.describe(
|
|
1115
|
+
'relation only: which rows of the linked table may be linked (all rules must match; propId is a column of the LINKED table). ' +
|
|
1116
|
+
'e.g. [{propId:"진행상태",op:"is",values:["채용"]}]. The picker hides others and the server refuses new links that fail. null turns it off.',
|
|
1117
|
+
),
|
|
1058
1118
|
},
|
|
1059
1119
|
},
|
|
1060
|
-
async ({
|
|
1120
|
+
async ({
|
|
1121
|
+
databaseId,
|
|
1122
|
+
propertyId,
|
|
1123
|
+
name,
|
|
1124
|
+
options,
|
|
1125
|
+
formula,
|
|
1126
|
+
rollup,
|
|
1127
|
+
prefix,
|
|
1128
|
+
reverseName,
|
|
1129
|
+
searchExcluded,
|
|
1130
|
+
optionRenames,
|
|
1131
|
+
dedupe,
|
|
1132
|
+
dedupeShow,
|
|
1133
|
+
relationFilter,
|
|
1134
|
+
type,
|
|
1135
|
+
dimensions,
|
|
1136
|
+
}) => {
|
|
1061
1137
|
// 조용히 무시하지 않는다 — 못 바꾸는 것을 넘기면 까닭과 할 일을 말한다(벡터 차원을 바꾸면 저장된 벡터가 전부 틀려진다).
|
|
1062
1138
|
if (type !== undefined || dimensions !== undefined) {
|
|
1063
1139
|
return errorResult(
|
|
@@ -1078,6 +1154,10 @@ export function registerDatabaseTools(server) {
|
|
|
1078
1154
|
prefix,
|
|
1079
1155
|
reverseName,
|
|
1080
1156
|
searchExcluded,
|
|
1157
|
+
optionRenames,
|
|
1158
|
+
dedupe,
|
|
1159
|
+
dedupeShow,
|
|
1160
|
+
relationFilter,
|
|
1081
1161
|
});
|
|
1082
1162
|
return jsonResult({ property });
|
|
1083
1163
|
} catch (err) {
|
|
@@ -1086,6 +1166,190 @@ export function registerDatabaseTools(server) {
|
|
|
1086
1166
|
},
|
|
1087
1167
|
);
|
|
1088
1168
|
|
|
1169
|
+
// ── DEDUPE(F5) — 같은 사람 찾기·합치기·새 행으로 두기 ─────────────────────
|
|
1170
|
+
//
|
|
1171
|
+
// 화면의 중복 창과 같은 일을 에이전트도 할 수 있어야 한다. 그렇지 않으면 "지원자 붙여 넣으면서 이미 있는 사람은 합쳐 줘"
|
|
1172
|
+
// 같은 부탁에서 에이전트가 표 전체를 읽어 전화번호를 손으로 견주게 된다(9천 행이면 수 MB).
|
|
1173
|
+
|
|
1174
|
+
server.registerTool(
|
|
1175
|
+
'rootr_find_duplicates',
|
|
1176
|
+
{
|
|
1177
|
+
title: 'Find existing rows that duplicate a row (same phone/email/name)',
|
|
1178
|
+
description:
|
|
1179
|
+
'Find rows of a Rootr (루터) DATABASE whose duplicate-detection columns (rootr_update_column dedupe:true — e.g. a phone ' +
|
|
1180
|
+
'column) hold the same value. Phone numbers compare digits only (010-1234-5678 = 01012345678); other text ignores case ' +
|
|
1181
|
+
'and spaces. Give `rowId` to check an existing row (it is excluded from the result) or `values` ({column name or id: value}) ' +
|
|
1182
|
+
'to check before adding. Each match comes with the fields the duplicate dialog shows (`preview`: status, notes, dates, rollups ' +
|
|
1183
|
+
'— or the column\'s dedupeShow list) and `history`: the latest 5 linked rows per relation column (e.g. call log) with the total. ' +
|
|
1184
|
+
'Only rows the caller can see are returned. Then use rootr_merge_rows or rootr_keep_duplicate.',
|
|
1185
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
1186
|
+
inputSchema: {
|
|
1187
|
+
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
1188
|
+
rowId: z.string().optional().describe('Check this existing row (its current values).'),
|
|
1189
|
+
values: z.record(z.any()).optional().describe('Or check these values before adding a row: { "연락처": "010-1234-5678" }'),
|
|
1190
|
+
},
|
|
1191
|
+
},
|
|
1192
|
+
async ({ databaseId, rowId, values }) => {
|
|
1193
|
+
try {
|
|
1194
|
+
if (!rowId && !values) return errorResult(new Error('rowId 나 values 중 하나를 주세요.'));
|
|
1195
|
+
const client = makeClient();
|
|
1196
|
+
const id = await resolveNodeId(client, databaseId);
|
|
1197
|
+
return jsonResult(await client.findDuplicates(id, { rowId, values }));
|
|
1198
|
+
} catch (err) {
|
|
1199
|
+
return errorResult(err);
|
|
1200
|
+
}
|
|
1201
|
+
},
|
|
1202
|
+
);
|
|
1203
|
+
|
|
1204
|
+
server.registerTool(
|
|
1205
|
+
'rootr_merge_rows',
|
|
1206
|
+
{
|
|
1207
|
+
title: 'Merge a duplicate row into an existing row',
|
|
1208
|
+
description:
|
|
1209
|
+
'Merge row `rowId` into row `into` of the same Rootr (루터) DATABASE — the "same person applied again" case. Empty cells of ' +
|
|
1210
|
+
'`into` are filled from `rowId` (existing values are kept), relation/multi-select/people/files are unioned (so a call log linked ' +
|
|
1211
|
+
'to the new row moves to the old one), a "🔁 재지원" comment listing the new values is left on `into`, and `rowId` is DELETED ' +
|
|
1212
|
+
'(row history records it as merged into `into`). Both rows must be editable by the caller. Not reversible except by the table history.',
|
|
1213
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
1214
|
+
inputSchema: {
|
|
1215
|
+
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
1216
|
+
rowId: z.string().describe('The newer/duplicate row — it is deleted after merging'),
|
|
1217
|
+
into: z.string().describe('The existing row to keep'),
|
|
1218
|
+
},
|
|
1219
|
+
},
|
|
1220
|
+
async ({ databaseId, rowId, into }) => {
|
|
1221
|
+
try {
|
|
1222
|
+
const client = makeClient();
|
|
1223
|
+
const id = await resolveNodeId(client, databaseId);
|
|
1224
|
+
return jsonResult(await client.mergeRows(id, rowId, into));
|
|
1225
|
+
} catch (err) {
|
|
1226
|
+
return errorResult(err);
|
|
1227
|
+
}
|
|
1228
|
+
},
|
|
1229
|
+
);
|
|
1230
|
+
|
|
1231
|
+
server.registerTool(
|
|
1232
|
+
'rootr_keep_duplicate',
|
|
1233
|
+
{
|
|
1234
|
+
title: 'Keep a duplicate as a new row but link it to the earlier row',
|
|
1235
|
+
description:
|
|
1236
|
+
'The "keep as new row" choice: leaves row `rowId` as it is and writes a 🔗 comment on BOTH rows pointing at each other, so the ' +
|
|
1237
|
+
'earlier history follows the new row. Use when the same person really should have a separate row (e.g. a new application period).',
|
|
1238
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
1239
|
+
inputSchema: {
|
|
1240
|
+
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
1241
|
+
rowId: z.string().describe('The new row to keep'),
|
|
1242
|
+
from: z.string().describe('The earlier row of the same person'),
|
|
1243
|
+
},
|
|
1244
|
+
},
|
|
1245
|
+
async ({ databaseId, rowId, from }) => {
|
|
1246
|
+
try {
|
|
1247
|
+
const client = makeClient();
|
|
1248
|
+
const id = await resolveNodeId(client, databaseId);
|
|
1249
|
+
return jsonResult(await client.keepDuplicate(id, rowId, from));
|
|
1250
|
+
} catch (err) {
|
|
1251
|
+
return errorResult(err);
|
|
1252
|
+
}
|
|
1253
|
+
},
|
|
1254
|
+
);
|
|
1255
|
+
|
|
1256
|
+
// ── ROW-HISTORY / RESTORE(F6·F7·G3) — 행·표 이력과 되돌리기 ─────────────────
|
|
1257
|
+
|
|
1258
|
+
server.registerTool(
|
|
1259
|
+
'rootr_database_history',
|
|
1260
|
+
{
|
|
1261
|
+
title: 'Read the change history of a database (or one row)',
|
|
1262
|
+
description:
|
|
1263
|
+
'Newest-first change history of a Rootr (루터) DATABASE — every create/update/delete with who, when, how (via: button, ' +
|
|
1264
|
+
'automation, form, restore, option-edit, merge:<rowId>, link…) and the before/after values of the changed columns. Give `rowId` ' +
|
|
1265
|
+
'for one row. Page with `before` + `beforeId` (the `at` and `id` of the last item you got). Use the change `id`s with ' +
|
|
1266
|
+
'rootr_restore_row, or an `at` with rootr_restore_database.',
|
|
1267
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
1268
|
+
inputSchema: {
|
|
1269
|
+
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
1270
|
+
rowId: z.string().optional(),
|
|
1271
|
+
before: z.string().optional().describe('ISO time — only changes before it (the `at` of the last item of the previous page)'),
|
|
1272
|
+
beforeId: z.string().optional().describe('The `id` of the last item of the previous page (needed when many changes share a time)'),
|
|
1273
|
+
limit: z.number().int().min(1).max(500).optional(),
|
|
1274
|
+
},
|
|
1275
|
+
},
|
|
1276
|
+
async ({ databaseId, rowId, before, beforeId, limit }) => {
|
|
1277
|
+
try {
|
|
1278
|
+
const client = makeClient();
|
|
1279
|
+
const id = await resolveNodeId(client, databaseId);
|
|
1280
|
+
return jsonResult(await client.databaseHistory(id, { rowId, before, beforeId, limit }));
|
|
1281
|
+
} catch (err) {
|
|
1282
|
+
return errorResult(err);
|
|
1283
|
+
}
|
|
1284
|
+
},
|
|
1285
|
+
);
|
|
1286
|
+
|
|
1287
|
+
server.registerTool(
|
|
1288
|
+
'rootr_restore_row',
|
|
1289
|
+
{
|
|
1290
|
+
title: 'Restore one row to how it was at a change',
|
|
1291
|
+
description:
|
|
1292
|
+
'Put ONE row of a Rootr (루터) DATABASE back to its state right after (`side: "after"`, default) or right before (`"before"`) ' +
|
|
1293
|
+
'the change `changeId` from rootr_database_history. A deleted row comes back with the same id (links on the other side are ' +
|
|
1294
|
+
'restored too). The restore itself is recorded in the history, so it can be undone the same way.',
|
|
1295
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
1296
|
+
inputSchema: {
|
|
1297
|
+
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
1298
|
+
rowId: z.string(),
|
|
1299
|
+
changeId: z.string().describe('The history item id'),
|
|
1300
|
+
side: z.enum(['after', 'before']).optional(),
|
|
1301
|
+
},
|
|
1302
|
+
},
|
|
1303
|
+
async ({ databaseId, rowId, changeId, side }) => {
|
|
1304
|
+
try {
|
|
1305
|
+
const client = makeClient();
|
|
1306
|
+
const id = await resolveNodeId(client, databaseId);
|
|
1307
|
+
return jsonResult(await client.restoreRow(id, rowId, changeId, side ?? 'after'));
|
|
1308
|
+
} catch (err) {
|
|
1309
|
+
return errorResult(err);
|
|
1310
|
+
}
|
|
1311
|
+
},
|
|
1312
|
+
);
|
|
1313
|
+
|
|
1314
|
+
server.registerTool(
|
|
1315
|
+
'rootr_restore_database',
|
|
1316
|
+
{
|
|
1317
|
+
title: 'Restore a whole database to a point in time',
|
|
1318
|
+
description:
|
|
1319
|
+
'Put a whole Rootr (루터) DATABASE back to how it was at `at` (ISO time): rows created after it are deleted, rows deleted after ' +
|
|
1320
|
+
'it come back, edited values go back — and the table definition too (columns incl. deleted ones and their values, select ' +
|
|
1321
|
+
'choices, views, buttons, automations) when it was recorded at that time. ALWAYS call with `dryRun: true` first and show ' +
|
|
1322
|
+
'the person the counts (`updates/creates/deletes`, `schema`). Table managers only. Big restores (over 300 rows) run in the ' +
|
|
1323
|
+
'background: the reply has `job`, and this tool waits up to ~2 minutes for it, then returns the progress — call ' +
|
|
1324
|
+
'rootr_restore_database again with `jobId` to keep checking. Automations do not fire while restoring.',
|
|
1325
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
1326
|
+
inputSchema: {
|
|
1327
|
+
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
|
1328
|
+
at: z.string().optional().describe('ISO time to go back to'),
|
|
1329
|
+
dryRun: z.boolean().optional().describe('true = only count what would change'),
|
|
1330
|
+
jobId: z.string().optional().describe('Check a running background restore instead of starting one'),
|
|
1331
|
+
},
|
|
1332
|
+
},
|
|
1333
|
+
async ({ databaseId, at, dryRun, jobId }) => {
|
|
1334
|
+
try {
|
|
1335
|
+
const client = makeClient();
|
|
1336
|
+
const id = await resolveNodeId(client, databaseId);
|
|
1337
|
+
if (jobId) return jsonResult({ job: await client.restoreJob(id, jobId) });
|
|
1338
|
+
if (!at) return errorResult(new Error('at(ISO 시각)을 주세요.'));
|
|
1339
|
+
const res = await client.restoreDatabase(id, at, { dryRun });
|
|
1340
|
+
if (!res.job) return jsonResult(res);
|
|
1341
|
+
let job = { ...res.job, done: 0, state: 'running' };
|
|
1342
|
+
for (let i = 0; i < 60 && job.state !== 'done'; i++) {
|
|
1343
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1344
|
+
job = await client.restoreJob(id, res.job.id);
|
|
1345
|
+
}
|
|
1346
|
+
return jsonResult({ ...res, job });
|
|
1347
|
+
} catch (err) {
|
|
1348
|
+
return errorResult(err);
|
|
1349
|
+
}
|
|
1350
|
+
},
|
|
1351
|
+
);
|
|
1352
|
+
|
|
1089
1353
|
// ── DB-PERM-001 / DB-WATCH — 노션에 없는 두 가지: 자물쇠와 알림 ────────────
|
|
1090
1354
|
//
|
|
1091
1355
|
// 표를 만들 수 있으면 잠글 수도, 누구에게 알릴지 정할 수도 있어야 한다. 이게 없으면
|
|
@@ -1286,12 +1550,27 @@ export function registerDatabaseTools(server) {
|
|
|
1286
1550
|
'row the person pressed on, where `relation` is the relation column IN THE TARGET table that points back ' +
|
|
1287
1551
|
'here. That pair is how "press 전화 on an applicant → a call row already linked to them" becomes one ' +
|
|
1288
1552
|
'press instead of switching tables and searching the person again.\n' +
|
|
1553
|
+
' add_row also takes `match: { key: "{요청 건}", on: "업체명", normalize?: ["spaces","parens","suffix:의원",' +
|
|
1554
|
+
'"trailingDigits","case"], fill?: "empty" }` — FIND-OR-LINK instead of always creating: `key` (filled from the ' +
|
|
1555
|
+
'pressed row) is compared with the target column `on` — first exactly (spaces ignored, so "다라치과(홍대점)" ' +
|
|
1556
|
+
'picks that branch), then after `normalize` (parens drops "(긴급)"·"(소독)", suffix:X drops a trailing X, ' +
|
|
1557
|
+
'trailingDigits drops a trailing number). ONE match → no new row, the pressed row is ADDED to that row\'s `link` ' +
|
|
1558
|
+
'relation (fill:"empty" also fills its blank cells from `values`); NONE → created as usual; TWO OR MORE → ' +
|
|
1559
|
+
'nothing is created and the pressed row gets a "후보 여러 곳: A, B" comment so a person picks. The result ' +
|
|
1560
|
+
'says `matched: { linked, created, ambiguous }`.\n' +
|
|
1289
1561
|
'· {kind:"update_rows", target:"created"|"selected", values} — set values on the rows this button just ' +
|
|
1290
1562
|
'made, or on the rows the person ticked in the table.\n' +
|
|
1291
1563
|
'· {kind:"ai_fill", target, propId, prompt} — let AI write ONE cell. The prompt may quote the row with ' +
|
|
1292
1564
|
'{칸이름}, e.g. "{제목}과 {메모}를 한 문장으로 요약해". If the AI fails the button keeps going.\n' +
|
|
1293
1565
|
'· {kind:"open_row"} — open the row this button made (UI only; over the API it just reports the id).\n' +
|
|
1294
|
-
'Values accept @today, @today+7 and @me — the server fills them in at press time, not now
|
|
1566
|
+
'Values accept @today, @today+7 and @me — the server fills them in at press time, not now. A text value ' +
|
|
1567
|
+
'may also quote the pressed row with {칸이름}, e.g. "{이름} {통화 건수+1}차": computed columns (rollup, ' +
|
|
1568
|
+
'formula) are read too, {칸이름+N} / {칸이름-N} adds to a numeric value, and when several rows are ' +
|
|
1569
|
+
'selected each new row is filled from its own source row. A relation column resolves to the linked row: ' +
|
|
1570
|
+
'{지원자} is its TITLE (not the id) and {지원자.통화 건수} reads one of its columns — EXCEPT when the value ' +
|
|
1571
|
+
'is exactly "{관계 칸}" and the column it goes into is a relation to the SAME table: then the row ids are ' +
|
|
1572
|
+
'copied (e.g. 담당 매니저: "{배정 매니저}"). Ids the receiving column\'s relationFilter rejects are left out ' +
|
|
1573
|
+
'of that cell only (the rest still runs), commented on the row and reported in `notes`.\n' +
|
|
1295
1574
|
'A button never grants permission: it runs as whoever presses it — writing into another table needs ' +
|
|
1296
1575
|
'write access there too.',
|
|
1297
1576
|
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
@@ -1333,7 +1612,9 @@ export function registerDatabaseTools(server) {
|
|
|
1333
1612
|
'Returns `created` (the ARRAY of row ids it made, not a count), `updated` and `filled` counts, the ' +
|
|
1334
1613
|
'`openRowId` a final "open_row" step points at (with `openDatabaseId` when that row is in another ' +
|
|
1335
1614
|
'table), `createdIn` telling which table each new row landed in, and — if the AI could not write a cell — `aiFailed` ' +
|
|
1336
|
-
'with the reason (the rest of the button still ran). ' +
|
|
1615
|
+
'with the reason (the rest of the button still ran). `aiSkipped` counts rows where the AI answered but the ' +
|
|
1616
|
+
'answer could not go in the cell (e.g. not one of a select\'s options), with one sample in `aiSkippedAnswer`; ' +
|
|
1617
|
+
'`aiUnchanged` counts rows whose AI answer equals the current value (left as is, not counted in `filled`). ' +
|
|
1337
1618
|
'It runs with YOUR permissions: a button that writes a locked column stops there if you cannot edit it.',
|
|
1338
1619
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
1339
1620
|
inputSchema: {
|
|
@@ -1400,7 +1681,11 @@ export function registerDatabaseTools(server) {
|
|
|
1400
1681
|
'부재 3회 초과 → set the applicant to 보류; every morning at 09:00 → flag rows whose 재통화 예정일 is today.\n' +
|
|
1401
1682
|
'Rules run as the person who SAVED them, never with more permission than that. A rule that a computed ' +
|
|
1402
1683
|
'column (rollup/formula) triggers must use a value comparison, not changed/changed_to — the server ' +
|
|
1403
|
-
'refuses those instead of quietly never firing
|
|
1684
|
+
'refuses those instead of quietly never firing.\n' +
|
|
1685
|
+
'add_row `match` (find-or-link, see rootr_set_database_buttons) is how "투입확정 → 사업장 마스터에 등록" ' +
|
|
1686
|
+
'stops making duplicates for existing customers: one matching 사업장 → linked, none → created, several → a ' +
|
|
1687
|
+
'"후보 여러 곳" comment on the request row. Those notes, and relation ids skipped by a relationFilter, are ' +
|
|
1688
|
+
'written to the rule\'s lastError (with the row) — read them with rootr_list_database_automations.',
|
|
1404
1689
|
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
1405
1690
|
inputSchema: {
|
|
1406
1691
|
databaseId: z.string().describe(DATABASE_TARGET_DESC),
|
package/lib/mcp-tools/forms.js
CHANGED
|
@@ -31,6 +31,10 @@ const fieldShape = z
|
|
|
31
31
|
.boolean()
|
|
32
32
|
.optional()
|
|
33
33
|
.describe('Not shown; filled from the URL (?이름=값). Use to record where a signup came from.'),
|
|
34
|
+
range: z
|
|
35
|
+
.boolean()
|
|
36
|
+
.optional()
|
|
37
|
+
.describe('date only: false = a single date (no end-date input). Omitted/true = start + optional end.'),
|
|
34
38
|
showIf: z
|
|
35
39
|
.object({ fieldId: z.string(), equals: z.string() })
|
|
36
40
|
.optional()
|
|
@@ -45,7 +49,8 @@ const FIELDS_GUIDE =
|
|
|
45
49
|
'Also: time (HH:MM) and file (uploaded on the published page only). ' +
|
|
46
50
|
'select/multi_select/matrix/ranking need `options` ({name,color}[]); matrix also needs `rows`. ' +
|
|
47
51
|
'Per-field extras: `help` (a hint line), `placeholder`, `page` (split into multiple pages), `hidden` + URL ' +
|
|
48
|
-
'params (record the traffic source), `showIf` (show only when an earlier answer matches)
|
|
52
|
+
'params (record the traffic source), `showIf` (show only when an earlier answer matches), `range: false` on a ' +
|
|
53
|
+
'date field (ask for ONE date — by default a date field also shows an optional end date). ' +
|
|
49
54
|
'Read the form first (rootr_read_form) before updating so ' +
|
|
50
55
|
'you keep each field\'s `id` stable — response values are keyed by field id, so renaming/dropping an id orphans ' +
|
|
51
56
|
'past responses\' answers for that field.';
|
package/lib/mcp-tools/pages.js
CHANGED
|
@@ -26,7 +26,9 @@ import {
|
|
|
26
26
|
const BLOCK_GUIDE =
|
|
27
27
|
'A page is `blocks[]` (ordered, max 200) + `config`. Each block is {id?, type, props}. ' +
|
|
28
28
|
'You assemble a page from these block types:\n' +
|
|
29
|
-
'· hero {title, subtitle?, align?:"left"|"center", background?:{kind:"color",color}|{kind:"image",attachmentId}, button?:{label,target}} — the opening banner
|
|
29
|
+
'· hero {title, subtitle?, align?:"left"|"center", size?:"normal"|"screen", background?:{kind:"color",color}|{kind:"image",attachmentId}, button?:{label,target}} — the opening banner. ' +
|
|
30
|
+
'size:"screen" makes it a FULL-SCREEN landing hero: edge to edge and one viewport tall (minus the top name bar, capped at 1200px), big headline, content centred vertically ' +
|
|
31
|
+
'(default "normal" = a compact banner inside the text column)\n' +
|
|
30
32
|
'· text {body (markdown), title?} — prose section; markdown is sanitized when rendered\n' +
|
|
31
33
|
'· features {columns?:1|2|3, items:[{title, body, icon?(emoji)}]} — "what it does" grid\n' +
|
|
32
34
|
'· gallery {images:[{attachmentId, caption?}]} — images must be workspace attachments (no external image URLs)\n' +
|
|
@@ -40,12 +42,35 @@ const BLOCK_GUIDE =
|
|
|
40
42
|
'· liveTable {source:{nodeId}, columns?:[name], rowLimit?(1-200), orderBy?:{column, dir:"asc"|"desc"}}\n' +
|
|
41
43
|
'· liveChart {source:{nodeId, sheet?, range?}, chart, labelColumn, valueColumn?, agg?, …} — a LIVE chart (46 kinds, see CHARTS below)\n' +
|
|
42
44
|
'· form {formNodeId(FORM node), headline?, submitLabel?} — visitors submit right on the page\n' +
|
|
43
|
-
'·
|
|
45
|
+
'· booking {bookingNodeId(DATABASE node id — a table that is published with visitor booking turned on, see rootr_set_booking), headline?, type?} — ' +
|
|
46
|
+
'a visitor scheduling calendar right on the page; `type` shows only one of the table\'s booking types. ' +
|
|
47
|
+
'bookingNodeId must be the node ID (names/paths are not resolved here); a table that is not published or has booking off renders as "not taking bookings"\n' +
|
|
48
|
+
'· custom {html, height?(40-4000, default 260), minHeight?:"none"|"screen", bleed?:boolean} — free HTML/CSS, rendered inside a locked-down sandboxed iframe ' +
|
|
44
49
|
'(no scripts, no same-origin, no network). Use it for a look the other blocks cannot make. Page theme colors are ' +
|
|
45
50
|
'available inside as var(--pg-bg|text|muted|faint|card|line|accent|on-accent) and var(--pg-font). Scripts and ' +
|
|
46
|
-
'on*= handlers are stripped on save — do not put behaviour here, only markup and CSS
|
|
51
|
+
'on*= handlers are stripped on save — do not put behaviour here, only markup and CSS. ' +
|
|
52
|
+
'bleed:true makes the frame span the FULL page width with no gap or padding around it, so consecutive bleed blocks ' +
|
|
53
|
+
'stack into seamless edge-to-edge colour bands (the sandbox is unchanged). Inside a bleed block paint your own ' +
|
|
54
|
+
'full-width background and keep the content in a centred max-width wrapper; still set `height` (the frame does not auto-size). ' +
|
|
55
|
+
'minHeight:"screen" makes the frame ONE VIEWPORT TALL on every device (same rule as hero size:"screen": minus the top name bar, capped at 1200px); ' +
|
|
56
|
+
'a numeric `height` given with it becomes the FLOOR — frame height = max(one screen, height) — so give the height your content ' +
|
|
57
|
+
'needs on a short phone and nothing is clipped. Inside, take the frame height with `height:100%` (html and body already are 100%) ' +
|
|
58
|
+
'and centre the content with flexbox. bleed:true + minHeight:"screen" = a full-screen section; stack several for a ' +
|
|
59
|
+
'section-per-screen landing page.\n' +
|
|
47
60
|
'LAYOUT: any block takes `width:"full"|"twoThirds"|"half"|"third"` — neighbouring blocks sit side by side (phones stack them). ' +
|
|
48
|
-
'That is how a page becomes a DASHBOARD: a row of `chart:"kpi"` blocks at width "third", then charts at width "half"
|
|
61
|
+
'That is how a page becomes a DASHBOARD: a row of `chart:"kpi"` blocks at width "third", then charts at width "half". ' +
|
|
62
|
+
'A hero with size:"screen" and a custom block with bleed:true always take the whole row — `width` is ignored (not stored) on them.\n' +
|
|
63
|
+
'REVEAL (scroll-in animation): any block takes `reveal:"fade"|"fade-up"` (default "none" = always visible). On the published page ' +
|
|
64
|
+
'the block appears ONCE when it scrolls into view (its top passes ~85% of the viewport height; blocks already on the first screen ' +
|
|
65
|
+
'play right after load): "fade" fades in, "fade-up" also rises a little. A hero staggers title → subtitle → button while its ' +
|
|
66
|
+
'background stays put. On a custom block, reveal ALSO starts the CSS animations inside the frame at that moment — the sandboxed ' +
|
|
67
|
+
'frame is created only when the block scrolls in, so `@keyframes`/`animation` in your html run from 0s exactly when the visitor ' +
|
|
68
|
+
'sees the section (without reveal they start at page load and are over before anyone scrolls down). Write those animations so ' +
|
|
69
|
+
'the content is still fine without them and wrap them in `@media (prefers-reduced-motion: no-preference)`. Visitors who ask ' +
|
|
70
|
+
'for reduced motion, and browsers without IntersectionObserver, simply see everything at once; nothing shifts layout. The editor ' +
|
|
71
|
+
'always shows blocks (the effect plays in its preview mode and on the published page).\n' +
|
|
72
|
+
'LANDING PAGE: start with hero size:"screen", follow with custom bleed:true bands (add minHeight:"screen" where a section should fill the screen), and set config.navEnabled:false so the hero owns the first screen. ' +
|
|
73
|
+
'All other blocks stay in the centred text column.\n' +
|
|
49
74
|
'CHARTS (liveChart): chart ∈ bar|barH|stackedBar|stackedBarH|percentBar|lollipop|dumbbell|bullet|waterfall|pareto|radar|polarBar|line|step|area|stackedArea|percentArea|combo|slope|stream|calendar|pie|donut|halfDonut|rose|funnel|treemap|sunburst|waffle|histogram|boxplot|heatmap|scatter|bubble|sankey|chord|network|tree|kpi|gauge|progress|ring|candlestick|gantt|wordCloud|pivot. ' +
|
|
50
75
|
'source = a DATABASE, SPREADSHEET or LOG node (sheet: {nodeId, sheet?(name), range?:"A1:F200"} — first row = column names, formula results are read). ' +
|
|
51
76
|
'Category charts: labelColumn (dates are bucketed: dateBucket day|week|month|quarter|year|weekday|hour, empty periods filled with 0) + ' +
|
|
@@ -41,6 +41,11 @@ const ACCESS_HINT =
|
|
|
41
41
|
'lock) and `database.notifications` (who to notify on row changes). A database property may carry ' +
|
|
42
42
|
'`searchExcluded: true` (values stay out of the search index behind ask / AI explore / similar rows — not a read permission) and a `type: "vector"` ' +
|
|
43
43
|
'property needs `dimensions` (an embedding column agents fill with rootr_set_row_vectors). ' +
|
|
44
|
+
'A phone/email/name property may carry `dedupe: true` (warn when the same person is entered again) with ' +
|
|
45
|
+
'`dedupeShow: ["진행상태","특이사항","콜 이력"]` (fields of the existing row the warning shows — a relation shows its latest 5 rows); ' +
|
|
46
|
+
'a relation property may carry `relationFilter: [{ propId, op: "is"|"is_not_empty", values? }]` (which linked rows may be picked). ' +
|
|
47
|
+
'A board view config may carry `cardProps: ["면접예정일","연락처"]` (fields on each card, rollups too) and any view `rowColors` ' +
|
|
48
|
+
'(`[{ propId, op: "is"|"is_not_empty", values?, color }]`). ' +
|
|
44
49
|
'A DOCUMENT\'s `content` may embed a live ' +
|
|
45
50
|
'table/sheet/deck with a line `[[embed:<name of a node in this same tree>]]` or `[[embed:/absolute/path]]` — it is ' +
|
|
46
51
|
'linked to the real node after the whole tree is built (an unknown name rolls the whole tree back).';
|