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