rootr-cli 0.20.0 → 0.23.3
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 +51 -2
- package/README.md +53 -7
- package/bin/rootr.js +186 -11
- package/lib/client.js +73 -2
- package/lib/mcp-tools/attachments.js +58 -1
- package/lib/mcp-tools/booking.js +21 -1
- package/lib/mcp-tools/databases.js +289 -4
- package/lib/mcp-tools/forms.js +6 -1
- package/lib/mcp-tools/pages.js +35 -8
- package/lib/mcp-tools/presentations.js +19 -0
- package/lib/mcp-tools/workspaces.js +5 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -757,6 +757,16 @@ export class RootrClient {
|
|
|
757
757
|
* 이름을 그대로 다시 적게 하는 것은 서버가 요구하는 안전장치다 — 지울 것을
|
|
758
758
|
* 정말 보고 있는지 확인한다.
|
|
759
759
|
*/
|
|
760
|
+
/**
|
|
761
|
+
* WS-EMBLEM — set (attachment id) or clear (null) the workspace emblem shown in the
|
|
762
|
+
* sidebar badge and workspace switcher. The server only accepts the relative raw path
|
|
763
|
+
* of an image attachment already uploaded to this same workspace.
|
|
764
|
+
*/
|
|
765
|
+
async setWorkspaceEmblem(workspaceId, attachmentId) {
|
|
766
|
+
const iconUrl = attachmentId ? `/api/v1/attachments/${encodeURIComponent(attachmentId)}/raw` : null;
|
|
767
|
+
return this.request(`/workspaces/${encodeURIComponent(workspaceId)}`, { method: 'PATCH', body: { iconUrl } });
|
|
768
|
+
}
|
|
769
|
+
|
|
760
770
|
async deleteWorkspace({ workspaceId, confirmName }) {
|
|
761
771
|
return this.request(`/workspaces/${workspaceId}`, {
|
|
762
772
|
method: 'DELETE',
|
|
@@ -1221,13 +1231,74 @@ export class RootrClient {
|
|
|
1221
1231
|
});
|
|
1222
1232
|
}
|
|
1223
1233
|
|
|
1224
|
-
async updateColumn(
|
|
1234
|
+
async updateColumn(
|
|
1235
|
+
databaseId,
|
|
1236
|
+
propertyId,
|
|
1237
|
+
{ name, options, optionRenames, formula, rollup, prefix, reverseName, searchExcluded, dedupe, dedupeShow, relationFilter } = {},
|
|
1238
|
+
) {
|
|
1225
1239
|
return this.request(
|
|
1226
1240
|
`/databases/${encodeURIComponent(databaseId)}/columns/${encodeURIComponent(propertyId)}`,
|
|
1227
|
-
{
|
|
1241
|
+
{
|
|
1242
|
+
method: 'PATCH',
|
|
1243
|
+
body: { name, options, optionRenames, formula, rollup, prefix, reverseName, searchExcluded, dedupe, dedupeShow, relationFilter },
|
|
1244
|
+
},
|
|
1228
1245
|
);
|
|
1229
1246
|
}
|
|
1230
1247
|
|
|
1248
|
+
/**
|
|
1249
|
+
* DEDUPE(F5) — 중복 감지 칸 값이 같은 기존 행. `rowId` 를 주면 그 행의 지금 값으로(그 행은 빠진다), 아니면 `values`
|
|
1250
|
+
* ({칸 id 또는 이름: 값})로 찾는다. 기존 행마다 보일 칸(preview)과 이어진 행 최근 5건(history)이 함께 온다.
|
|
1251
|
+
*/
|
|
1252
|
+
async findDuplicates(databaseId, { rowId, values } = {}) {
|
|
1253
|
+
return this.request(`/databases/${encodeURIComponent(databaseId)}/duplicates`, {
|
|
1254
|
+
method: 'POST',
|
|
1255
|
+
body: rowId ? { rowId } : { values: values ?? {} },
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/** DEDUPE — `rowId` 행을 `into` 행에 합친다(빈 칸만 채우고 "재지원" 댓글, `rowId` 행은 지운다). */
|
|
1260
|
+
async mergeRows(databaseId, rowId, into) {
|
|
1261
|
+
return this.request(`/databases/${encodeURIComponent(databaseId)}/rows/${encodeURIComponent(rowId)}/merge`, {
|
|
1262
|
+
method: 'POST',
|
|
1263
|
+
body: { into },
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
/** ROW-HISTORY(F6) — 표(또는 한 행)의 변경 이력, 최신부터. `before`+`beforeId` 로 이어 읽는다. */
|
|
1268
|
+
async databaseHistory(databaseId, { rowId, before, beforeId, limit } = {}) {
|
|
1269
|
+
const q = new URLSearchParams();
|
|
1270
|
+
if (rowId) q.set('rowId', rowId);
|
|
1271
|
+
if (before) q.set('before', before);
|
|
1272
|
+
if (beforeId) q.set('beforeId', beforeId);
|
|
1273
|
+
if (limit) q.set('limit', String(limit));
|
|
1274
|
+
return this.request(`/databases/${encodeURIComponent(databaseId)}/history${q.toString() ? `?${q}` : ''}`);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/** ROW-RESTORE(F7) — 행 하나를 그 변경의 전(`before`)/후(`after`) 모습으로. 지운 행도 되살린다. */
|
|
1278
|
+
async restoreRow(databaseId, rowId, changeId, side = 'after') {
|
|
1279
|
+
return this.request(`/databases/${encodeURIComponent(databaseId)}/rows/${encodeURIComponent(rowId)}/restore`, {
|
|
1280
|
+
method: 'POST',
|
|
1281
|
+
body: { changeId, side },
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/** ROW-RESTORE(F7·G3) — 표 전체(행 + 칸·보기·버튼·자동 규칙)를 그 시각으로. `dryRun` 이면 미리보기만. 큰 표는 `job` 이 온다. */
|
|
1286
|
+
async restoreDatabase(databaseId, at, { dryRun } = {}) {
|
|
1287
|
+
return this.request(`/databases/${encodeURIComponent(databaseId)}/restore`, { method: 'POST', body: { at, dryRun: dryRun === true } });
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
async restoreJob(databaseId, jobId) {
|
|
1291
|
+
return this.request(`/databases/${encodeURIComponent(databaseId)}/restore/${encodeURIComponent(jobId)}`);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/** DEDUPE-KEEP — 새 행으로 두되 같은 사람의 이전 행(`from`)과 서로 링크 댓글을 남긴다. */
|
|
1295
|
+
async keepDuplicate(databaseId, rowId, from) {
|
|
1296
|
+
return this.request(`/databases/${encodeURIComponent(databaseId)}/rows/${encodeURIComponent(rowId)}/keep-duplicate`, {
|
|
1297
|
+
method: 'POST',
|
|
1298
|
+
body: { from },
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1231
1302
|
// DB-VECTOR — 에이전트용 벡터 칸(칸 id 또는 이름).
|
|
1232
1303
|
async putVectors(databaseId, column, items) {
|
|
1233
1304
|
return this.request(`/databases/${encodeURIComponent(databaseId)}/vectors/${encodeURIComponent(column)}`, {
|
|
@@ -82,7 +82,10 @@ export function registerAttachmentTools(server) {
|
|
|
82
82
|
'`markdown` is ready to paste into a document body (). For the common case of inserting an ' +
|
|
83
83
|
'image straight into a document, prefer rootr_attach_image_to_document instead. For a large file (or ' +
|
|
84
84
|
'over the remote connector, where filePath is not available), use rootr_create_upload_url → PUT → ' +
|
|
85
|
-
'rootr_complete_upload instead of inlining the bytes as base64Data.'
|
|
85
|
+
'rootr_complete_upload instead of inlining the bytes as base64Data. ' +
|
|
86
|
+
'Video files (mp4/webm/mov, up to 75MB) are allowed; to put one on a slide, upload it with nodeId = the deck and add it to the slide\'s ' +
|
|
87
|
+
'`videos[]` (Team plan and above for decks). Videos play via signed URLs (`vt=`) that the server adds when it returns the slide — a bare /raw link ' +
|
|
88
|
+
'opened from another website is blocked.',
|
|
86
89
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
87
90
|
inputSchema: uploadInputSchema,
|
|
88
91
|
},
|
|
@@ -154,6 +157,60 @@ export function registerAttachmentTools(server) {
|
|
|
154
157
|
},
|
|
155
158
|
);
|
|
156
159
|
|
|
160
|
+
// 엠블럼은 워크스페이스에 붙는 것이라 노드 칸(nodeId)은 받지 않는다.
|
|
161
|
+
const { nodeId: _unusedNodeId, ...emblemUploadSchema } = uploadInputSchema;
|
|
162
|
+
// WS-EMBLEM — 화면(설정 → 일반 → 엠블럼)에서만 되던 일을 에이전트도 하게. 새 파일을 주면
|
|
163
|
+
// 올린 뒤 걸고, 이미 올린 첨부 id 를 주면 그것을 걸고, clear 면 이름 첫 글자 배지로 되돌린다.
|
|
164
|
+
server.registerTool(
|
|
165
|
+
'rootr_set_workspace_emblem',
|
|
166
|
+
{
|
|
167
|
+
title: 'Set or clear the workspace emblem (logo)',
|
|
168
|
+
description:
|
|
169
|
+
'Set the Rootr (루터) workspace emblem — the small square logo shown in the sidebar badge and the ' +
|
|
170
|
+
'workspace switcher for every member. Give ONE of: an image file (filePath or base64Data, with filename ' +
|
|
171
|
+
'+ mimeType — it is uploaded to this workspace first), `attachmentId` of an image already uploaded to ' +
|
|
172
|
+
'this workspace (e.g. from rootr_upload_attachment / rootr_complete_upload), or `clear: true` to go back ' +
|
|
173
|
+
'to the default initial-letter badge. Requires ADMIN on the workspace. A square PNG/SVG/WebP of 256px+ ' +
|
|
174
|
+
'looks best; it is shown cropped to a rounded square.',
|
|
175
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
176
|
+
inputSchema: {
|
|
177
|
+
...emblemUploadSchema,
|
|
178
|
+
filename: emblemUploadSchema.filename.optional(),
|
|
179
|
+
mimeType: emblemUploadSchema.mimeType.optional(),
|
|
180
|
+
attachmentId: z.string().optional().describe('Id of an image attachment already in this workspace'),
|
|
181
|
+
clear: z.boolean().optional().describe('true = remove the emblem (back to the initial-letter badge)'),
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
async ({ filename, mimeType, filePath, base64Data, attachmentId, clear, workspace }) => {
|
|
185
|
+
try {
|
|
186
|
+
const ws = requireWorkspace(workspace);
|
|
187
|
+
const client = makeClient(ws);
|
|
188
|
+
const given = [Boolean(filePath || base64Data), Boolean(attachmentId), Boolean(clear)].filter(Boolean).length;
|
|
189
|
+
if (given !== 1) {
|
|
190
|
+
throw new RootrApiError('파일(filePath/base64Data), attachmentId, clear 중 하나만 주세요.');
|
|
191
|
+
}
|
|
192
|
+
let id = null;
|
|
193
|
+
if (attachmentId) {
|
|
194
|
+
if (!ATTACHMENT_ID.test(attachmentId)) throw new RootrApiError(`첨부 id 모양이 아닙니다: ${attachmentId}`);
|
|
195
|
+
id = attachmentId;
|
|
196
|
+
} else if (!clear) {
|
|
197
|
+
if (!filename || !mimeType) throw new RootrApiError('파일을 올릴 때는 filename 과 mimeType 이 필요합니다.');
|
|
198
|
+
if (!mimeType.startsWith('image/')) throw new RootrApiError('엠블럼은 이미지(image/*)만 됩니다.');
|
|
199
|
+
const data = readAttachmentBytes({ filePath, base64Data });
|
|
200
|
+
({ id } = await client.uploadAttachment(ws, { filename, mimeType, data }));
|
|
201
|
+
}
|
|
202
|
+
const updated = await client.setWorkspaceEmblem(ws, id);
|
|
203
|
+
return jsonResult({
|
|
204
|
+
workspaceId: ws,
|
|
205
|
+
iconUrl: updated?.iconUrl ?? null,
|
|
206
|
+
emblemUrl: id ? client.attachmentRawUrl(id) : null,
|
|
207
|
+
});
|
|
208
|
+
} catch (err) {
|
|
209
|
+
return errorResult(err);
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
);
|
|
213
|
+
|
|
157
214
|
// ISSUE-222 — rootr_upload_attachment/rootr_attach_image_to_document require
|
|
158
215
|
// the full file as base64Data over the remote connector (it can't read
|
|
159
216
|
// filePath), which breaks down for larger images. This gives the caller a
|
package/lib/mcp-tools/booking.js
CHANGED
|
@@ -119,7 +119,18 @@ export function registerBookingTools(server) {
|
|
|
119
119
|
.number()
|
|
120
120
|
.optional()
|
|
121
121
|
.describe('Length of one booking in minutes: 15, 20, 30, 45, 60, 90, 120, 180 or 240.'),
|
|
122
|
-
bufferMin: z
|
|
122
|
+
bufferMin: z
|
|
123
|
+
.number()
|
|
124
|
+
.optional()
|
|
125
|
+
.describe('Gap kept before AND after each booking made through this table, in minutes (0-240).'),
|
|
126
|
+
calendarBufferMin: z
|
|
127
|
+
.number()
|
|
128
|
+
.optional()
|
|
129
|
+
.describe(
|
|
130
|
+
'Gap kept before AND after every event already on the connected Google Calendar (travel time etc.), in minutes (0-240). ' +
|
|
131
|
+
'Separate from bufferMin: e.g. 60 here and 0 in bufferMin blocks an hour around outside meetings while online bookings may sit back to back. ' +
|
|
132
|
+
'Bookings this table wrote to Google are not "existing events" — they get bufferMin. If never set, it follows bufferMin.',
|
|
133
|
+
),
|
|
123
134
|
minNoticeHours: z.number().optional().describe('No bookings closer than this many hours from now.'),
|
|
124
135
|
maxDaysAhead: z.number().optional().describe('How far ahead visitors may book, in days.'),
|
|
125
136
|
maxPerDay: z.number().optional().describe('Max bookings per day. 0 = unlimited.'),
|
|
@@ -209,6 +220,15 @@ export function registerBookingTools(server) {
|
|
|
209
220
|
.optional()
|
|
210
221
|
.describe('Let the visitor add other email addresses. Guests get the same emails and the same calendar invite.'),
|
|
211
222
|
maxGuests: z.number().int().optional().describe('How many guests a visitor may add (default 5). 0 turns it off.'),
|
|
223
|
+
trackSource: z
|
|
224
|
+
.boolean()
|
|
225
|
+
.optional()
|
|
226
|
+
.describe(
|
|
227
|
+
'Record where each booking came from. Adds a text column ("유입 경로" unless you pass sourceLabel) and fills it ' +
|
|
228
|
+
'from the address the visitor arrived on, e.g. "source=meta, medium=paid, campaign=ax, content=3-docs" ' +
|
|
229
|
+
'(utm_* first, else the ad click id, else the referring site). Left empty when nothing is known.',
|
|
230
|
+
),
|
|
231
|
+
sourceLabel: z.string().optional().describe('Name for the column trackSource creates (default "유입 경로").'),
|
|
212
232
|
redirectUrl: z
|
|
213
233
|
.string()
|
|
214
234
|
.optional()
|
|
@@ -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.';
|