@jordancoin/notioncli 1.1.0 → 1.2.1
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.md +126 -2
- package/bin/notion.js +618 -41
- package/lib/helpers.js +33 -2
- package/package.json +1 -1
- package/skill/SKILL.md +78 -1
- package/test/mock.test.js +39 -18
package/README.md
CHANGED
|
@@ -34,9 +34,21 @@ Zero UUIDs. One command. You're ready to go.
|
|
|
34
34
|
|
|
35
35
|
---
|
|
36
36
|
|
|
37
|
-
## What's New
|
|
37
|
+
## What's New
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
### v1.2 — Multi-Workspace, Database Management & File Uploads
|
|
40
|
+
|
|
41
|
+
- 🏢 **Multi-workspace** — Named profiles for multiple Notion accounts (`workspace add/use/list/remove`, `--workspace` flag)
|
|
42
|
+
- 🤖 **`me`** — Show integration/bot identity and owner
|
|
43
|
+
- 📦 **`move`** — Move pages between databases by alias
|
|
44
|
+
- 📋 **`templates`** — List page templates available for a database
|
|
45
|
+
- 🏗️ **`db-create`** — Create new databases with custom property schemas
|
|
46
|
+
- ✏️ **`db-update`** — Add/remove columns, rename databases
|
|
47
|
+
- 📎 **`upload`** — Upload files to pages (MIME-aware, supports images/docs/text)
|
|
48
|
+
- 🔍 **`props`** — Quick page property inspector (cleaner than `get` for debugging)
|
|
49
|
+
- 🐛 **Fixed** canonical `database_id` resolution for the 2025 dual-ID system
|
|
50
|
+
|
|
51
|
+
### v1.1 — Relations, Rollups & Blocks
|
|
40
52
|
|
|
41
53
|
- 🔗 **Relations** — `get` resolves linked page titles automatically. New `notion relations` command for exploring connected pages.
|
|
42
54
|
- 📊 **Rollups** — Numbers, dates, and arrays are parsed into readable values. No more raw JSON blobs.
|
|
@@ -267,6 +279,86 @@ $ notion comments tasks --filter "Name=Ship feature"
|
|
|
267
279
|
$ notion comment tasks "Shipped! 🚀" --filter "Name=Ship feature"
|
|
268
280
|
```
|
|
269
281
|
|
|
282
|
+
### `notion me` — Integration Identity
|
|
283
|
+
|
|
284
|
+
```
|
|
285
|
+
$ notion me
|
|
286
|
+
Bot: Stargazer
|
|
287
|
+
ID: 8fd93059-5e54-44a5-8efd-800069da9497
|
|
288
|
+
Type: bot
|
|
289
|
+
Owner: Workspace
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### `notion props` — Quick Property Inspector
|
|
293
|
+
|
|
294
|
+
A fast way to inspect a single page's properties:
|
|
295
|
+
|
|
296
|
+
```
|
|
297
|
+
$ notion props tasks --filter "Name=Ship v1.1"
|
|
298
|
+
Page: a1b2c3d4-5678-90ab-cdef-1234567890ab
|
|
299
|
+
URL: https://www.notion.so/...
|
|
300
|
+
Name: Ship v1.1
|
|
301
|
+
Status: Done
|
|
302
|
+
Priority: High
|
|
303
|
+
Date: 2026-02-09
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### `notion move` — Move Pages Between Databases
|
|
307
|
+
|
|
308
|
+
```bash
|
|
309
|
+
$ notion move tasks --filter "Name=Archived task" --to archive
|
|
310
|
+
✅ Moved page: a1b2c3d4…
|
|
311
|
+
URL: https://notion.so/...
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
Accepts alias + filter for the source page, and an alias or page ID for `--to`.
|
|
315
|
+
|
|
316
|
+
### `notion templates` — List Database Templates
|
|
317
|
+
|
|
318
|
+
```bash
|
|
319
|
+
$ notion templates projects
|
|
320
|
+
id │ title │ url
|
|
321
|
+
──────────┼────────────────────┼──────────────────
|
|
322
|
+
a1b2c3d4… │ Project Template │ https://notion.so/...
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
### `notion db-create` — Create a Database
|
|
326
|
+
|
|
327
|
+
```bash
|
|
328
|
+
$ notion db-create <parent-page-id> "My New DB" --prop "Name:title" --prop "Status:select" --prop "Priority:number"
|
|
329
|
+
✅ Created database: a1b2c3d4…
|
|
330
|
+
Title: My New DB
|
|
331
|
+
Properties: Name, Status, Priority
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
### `notion db-update` — Update Database Schema
|
|
335
|
+
|
|
336
|
+
Add or remove columns, rename databases:
|
|
337
|
+
|
|
338
|
+
```bash
|
|
339
|
+
$ notion db-update projects --add-prop "Rating:number" --add-prop "Category:select"
|
|
340
|
+
✅ Updated database: a1b2c3d4…
|
|
341
|
+
Added: Rating:number, Category:select
|
|
342
|
+
|
|
343
|
+
$ notion db-update projects --remove-prop "Old Column"
|
|
344
|
+
✅ Updated database: a1b2c3d4…
|
|
345
|
+
Removed: Old Column
|
|
346
|
+
|
|
347
|
+
$ notion db-update projects --title "Renamed Projects"
|
|
348
|
+
✅ Updated database: a1b2c3d4…
|
|
349
|
+
Title: Renamed Projects
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
### `notion upload` — Upload Files to Pages
|
|
353
|
+
|
|
354
|
+
```bash
|
|
355
|
+
$ notion upload tasks --filter "Name=Ship feature" ./screenshot.png
|
|
356
|
+
✅ Uploaded: screenshot.png (142.3 KB)
|
|
357
|
+
Page: a1b2c3d4…
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
Supports images, PDFs, text files, documents, and more. MIME types are detected automatically from file extensions.
|
|
361
|
+
|
|
270
362
|
### `notion alias` — Manage Aliases
|
|
271
363
|
|
|
272
364
|
Aliases are created automatically by `notion init`, but you can manage them:
|
|
@@ -278,6 +370,38 @@ notion alias add tasks <database-id> # Add manually
|
|
|
278
370
|
notion alias remove tasks # Remove one
|
|
279
371
|
```
|
|
280
372
|
|
|
373
|
+
### `notion workspace` — Multi-Workspace Profiles
|
|
374
|
+
|
|
375
|
+
Manage multiple Notion accounts (work, personal, client projects):
|
|
376
|
+
|
|
377
|
+
```bash
|
|
378
|
+
# Add a workspace
|
|
379
|
+
notion workspace add work --key ntn_your_work_key
|
|
380
|
+
notion workspace add personal --key ntn_your_personal_key
|
|
381
|
+
|
|
382
|
+
# List workspaces
|
|
383
|
+
notion workspace list
|
|
384
|
+
# default ← active
|
|
385
|
+
# Key: ntn_3871... | Aliases: 5
|
|
386
|
+
# work
|
|
387
|
+
# Key: ntn_ab12... | Aliases: 0
|
|
388
|
+
|
|
389
|
+
# Switch active workspace
|
|
390
|
+
notion workspace use work
|
|
391
|
+
|
|
392
|
+
# Discover databases for a workspace
|
|
393
|
+
notion init --workspace work
|
|
394
|
+
|
|
395
|
+
# Per-command override (no switching needed)
|
|
396
|
+
notion query tasks --workspace personal
|
|
397
|
+
notion -w work add projects --prop "Name=Q2 Plan"
|
|
398
|
+
|
|
399
|
+
# Remove a workspace
|
|
400
|
+
notion workspace remove old-client
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
Aliases are scoped per workspace — same alias name in different workspaces won't collide. Old single-key configs are auto-migrated to a "default" workspace.
|
|
404
|
+
|
|
281
405
|
### `--json` — Raw JSON Output
|
|
282
406
|
|
|
283
407
|
Add `--json` to any command for the raw Notion API response:
|
package/bin/notion.js
CHANGED
|
@@ -19,12 +19,35 @@ function saveConfig(config) {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
*
|
|
22
|
+
* Get the active workspace name from --workspace flag or config.
|
|
23
|
+
*/
|
|
24
|
+
function getWorkspaceName() {
|
|
25
|
+
return program.opts().workspace || undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get the active workspace config { apiKey, aliases, name }.
|
|
30
|
+
*/
|
|
31
|
+
function getWorkspaceConfig() {
|
|
32
|
+
const config = loadConfig();
|
|
33
|
+
const ws = helpers.resolveWorkspace(config, getWorkspaceName());
|
|
34
|
+
if (ws.error) {
|
|
35
|
+
console.error(`Error: ${ws.error}`);
|
|
36
|
+
if (ws.available && ws.available.length > 0) {
|
|
37
|
+
console.error(`Available workspaces: ${ws.available.join(', ')}`);
|
|
38
|
+
}
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
return ws;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve API key: env var → workspace config → error with setup instructions
|
|
23
46
|
*/
|
|
24
47
|
function getApiKey() {
|
|
25
48
|
if (process.env.NOTION_API_KEY) return process.env.NOTION_API_KEY;
|
|
26
|
-
const
|
|
27
|
-
if (
|
|
49
|
+
const ws = getWorkspaceConfig();
|
|
50
|
+
if (ws.apiKey) return ws.apiKey;
|
|
28
51
|
console.error('Error: No Notion API key found.');
|
|
29
52
|
console.error('');
|
|
30
53
|
console.error('Set it up with one of:');
|
|
@@ -40,14 +63,14 @@ function getApiKey() {
|
|
|
40
63
|
* If given a raw UUID, we use it for both IDs (the SDK figures it out).
|
|
41
64
|
*/
|
|
42
65
|
function resolveDb(aliasOrId) {
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
45
|
-
return
|
|
66
|
+
const ws = getWorkspaceConfig();
|
|
67
|
+
if (ws.aliases && ws.aliases[aliasOrId]) {
|
|
68
|
+
return ws.aliases[aliasOrId];
|
|
46
69
|
}
|
|
47
70
|
if (UUID_REGEX.test(aliasOrId)) {
|
|
48
71
|
return { database_id: aliasOrId, data_source_id: aliasOrId };
|
|
49
72
|
}
|
|
50
|
-
const aliasNames =
|
|
73
|
+
const aliasNames = ws.aliases ? Object.keys(ws.aliases) : [];
|
|
51
74
|
console.error(`Unknown database alias: "${aliasOrId}"`);
|
|
52
75
|
if (aliasNames.length > 0) {
|
|
53
76
|
console.error(`Available aliases: ${aliasNames.join(', ')}`);
|
|
@@ -64,14 +87,14 @@ function resolveDb(aliasOrId) {
|
|
|
64
87
|
* Returns { pageId, dbIds } where dbIds is non-null when resolved via alias.
|
|
65
88
|
*/
|
|
66
89
|
async function resolvePageId(aliasOrId, filterStr) {
|
|
67
|
-
const
|
|
68
|
-
if (
|
|
90
|
+
const ws = getWorkspaceConfig();
|
|
91
|
+
if (ws.aliases && ws.aliases[aliasOrId]) {
|
|
69
92
|
if (!filterStr) {
|
|
70
93
|
console.error('When using an alias, --filter is required to identify a specific page.');
|
|
71
94
|
console.error(`Example: notion update ${aliasOrId} --filter "Name=My Page" --prop "Status=Done"`);
|
|
72
95
|
process.exit(1);
|
|
73
96
|
}
|
|
74
|
-
const dbIds =
|
|
97
|
+
const dbIds = ws.aliases[aliasOrId];
|
|
75
98
|
const notion = getNotion();
|
|
76
99
|
const filter = await buildFilter(dbIds, filterStr);
|
|
77
100
|
const res = await notion.dataSources.query({
|
|
@@ -94,7 +117,7 @@ async function resolvePageId(aliasOrId, filterStr) {
|
|
|
94
117
|
}
|
|
95
118
|
// Check if it looks like a UUID — if not, it's probably a typo'd alias
|
|
96
119
|
if (!UUID_REGEX.test(aliasOrId)) {
|
|
97
|
-
const aliasNames =
|
|
120
|
+
const aliasNames = ws.aliases ? Object.keys(ws.aliases) : [];
|
|
98
121
|
console.error(`Unknown alias: "${aliasOrId}"`);
|
|
99
122
|
if (aliasNames.length > 0) {
|
|
100
123
|
console.error(`Available aliases: ${aliasNames.join(', ')}`);
|
|
@@ -203,8 +226,9 @@ async function buildFilter(dbIds, filterStr) {
|
|
|
203
226
|
program
|
|
204
227
|
.name('notion')
|
|
205
228
|
.description('A powerful CLI for the Notion API — query databases, manage pages, and automate your workspace from the terminal.')
|
|
206
|
-
.version('1.
|
|
207
|
-
.option('--json', 'Output raw JSON instead of formatted tables')
|
|
229
|
+
.version('1.2.0')
|
|
230
|
+
.option('--json', 'Output raw JSON instead of formatted tables')
|
|
231
|
+
.option('-w, --workspace <name>', 'Use a specific workspace profile');
|
|
208
232
|
|
|
209
233
|
// ─── init ──────────────────────────────────────────────────────────────────────
|
|
210
234
|
program
|
|
@@ -213,6 +237,7 @@ program
|
|
|
213
237
|
.option('--key <api-key>', 'Notion integration API key (starts with ntn_)')
|
|
214
238
|
.action(async (opts) => {
|
|
215
239
|
const config = loadConfig();
|
|
240
|
+
const wsName = getWorkspaceName() || config.activeWorkspace || 'default';
|
|
216
241
|
const apiKey = opts.key || process.env.NOTION_API_KEY;
|
|
217
242
|
|
|
218
243
|
if (!apiKey) {
|
|
@@ -225,12 +250,16 @@ program
|
|
|
225
250
|
console.error(' 4. Share your databases with the integration');
|
|
226
251
|
console.error('');
|
|
227
252
|
console.error('Then run: notion init --key ntn_your_api_key');
|
|
253
|
+
console.error(' Or with workspace: notion init --workspace work --key ntn_your_api_key');
|
|
228
254
|
process.exit(1);
|
|
229
255
|
}
|
|
230
256
|
|
|
231
|
-
config.
|
|
257
|
+
if (!config.workspaces) config.workspaces = {};
|
|
258
|
+
if (!config.workspaces[wsName]) config.workspaces[wsName] = { aliases: {} };
|
|
259
|
+
config.workspaces[wsName].apiKey = apiKey;
|
|
260
|
+
config.activeWorkspace = wsName;
|
|
232
261
|
saveConfig(config);
|
|
233
|
-
console.log(`✅ API key saved to ${CONFIG_PATH}`);
|
|
262
|
+
console.log(`✅ API key saved to workspace "${wsName}" in ${CONFIG_PATH}`);
|
|
234
263
|
console.log('');
|
|
235
264
|
|
|
236
265
|
// Discover databases
|
|
@@ -247,7 +276,7 @@ program
|
|
|
247
276
|
return;
|
|
248
277
|
}
|
|
249
278
|
|
|
250
|
-
|
|
279
|
+
const aliases = config.workspaces[wsName].aliases || {};
|
|
251
280
|
|
|
252
281
|
console.log(`Found ${res.results.length} database${res.results.length !== 1 ? 's' : ''}:\n`);
|
|
253
282
|
|
|
@@ -255,7 +284,7 @@ program
|
|
|
255
284
|
for (const db of res.results) {
|
|
256
285
|
const title = richTextToPlain(db.title) || '';
|
|
257
286
|
const dsId = db.id;
|
|
258
|
-
const dbId = db.database_id || dsId;
|
|
287
|
+
const dbId = (db.parent && db.parent.type === 'database_id' && db.parent.database_id) || db.database_id || dsId;
|
|
259
288
|
|
|
260
289
|
// Auto-generate a slug from the title
|
|
261
290
|
let slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 30);
|
|
@@ -264,12 +293,12 @@ program
|
|
|
264
293
|
// Avoid collisions — append a number if needed
|
|
265
294
|
let finalSlug = slug;
|
|
266
295
|
let counter = 2;
|
|
267
|
-
while (
|
|
296
|
+
while (aliases[finalSlug] && aliases[finalSlug].data_source_id !== dsId) {
|
|
268
297
|
finalSlug = `${slug}-${counter}`;
|
|
269
298
|
counter++;
|
|
270
299
|
}
|
|
271
300
|
|
|
272
|
-
|
|
301
|
+
aliases[finalSlug] = {
|
|
273
302
|
database_id: dbId,
|
|
274
303
|
data_source_id: dsId,
|
|
275
304
|
};
|
|
@@ -278,9 +307,10 @@ program
|
|
|
278
307
|
added.push(finalSlug);
|
|
279
308
|
}
|
|
280
309
|
|
|
310
|
+
config.workspaces[wsName].aliases = aliases;
|
|
281
311
|
saveConfig(config);
|
|
282
312
|
console.log('');
|
|
283
|
-
console.log(`${added.length} alias${added.length !== 1 ? 'es' : ''} saved
|
|
313
|
+
console.log(`${added.length} alias${added.length !== 1 ? 'es' : ''} saved to workspace "${wsName}".`);
|
|
284
314
|
console.log('');
|
|
285
315
|
console.log('Ready! Try:');
|
|
286
316
|
if (added.length > 0) {
|
|
@@ -308,7 +338,9 @@ alias
|
|
|
308
338
|
.description('Add a database alias (auto-discovers data_source_id)')
|
|
309
339
|
.action(async (name, databaseId) => {
|
|
310
340
|
const config = loadConfig();
|
|
311
|
-
|
|
341
|
+
const wsName = getWorkspaceName() || config.activeWorkspace || 'default';
|
|
342
|
+
if (!config.workspaces[wsName]) config.workspaces[wsName] = { aliases: {} };
|
|
343
|
+
const aliases = config.workspaces[wsName].aliases || {};
|
|
312
344
|
|
|
313
345
|
// Try to discover the data_source_id by searching for this database
|
|
314
346
|
const notion = getNotion();
|
|
@@ -328,9 +360,9 @@ alias
|
|
|
328
360
|
|
|
329
361
|
if (match) {
|
|
330
362
|
dataSourceId = match.id;
|
|
331
|
-
// The database_id might differ from data_source_id
|
|
332
|
-
const dbId = match.database_id || databaseId;
|
|
333
|
-
|
|
363
|
+
// The database_id might differ from data_source_id — check parent
|
|
364
|
+
const dbId = (match.parent && match.parent.type === 'database_id' && match.parent.database_id) || match.database_id || databaseId;
|
|
365
|
+
aliases[name] = {
|
|
334
366
|
database_id: dbId,
|
|
335
367
|
data_source_id: dataSourceId,
|
|
336
368
|
};
|
|
@@ -340,7 +372,7 @@ alias
|
|
|
340
372
|
console.log(` data_source_id: ${dataSourceId}`);
|
|
341
373
|
} else {
|
|
342
374
|
// Couldn't find via search — use the ID for both
|
|
343
|
-
|
|
375
|
+
aliases[name] = {
|
|
344
376
|
database_id: databaseId,
|
|
345
377
|
data_source_id: databaseId,
|
|
346
378
|
};
|
|
@@ -349,7 +381,7 @@ alias
|
|
|
349
381
|
}
|
|
350
382
|
} catch (err) {
|
|
351
383
|
// Fallback: use same ID for both
|
|
352
|
-
|
|
384
|
+
aliases[name] = {
|
|
353
385
|
database_id: databaseId,
|
|
354
386
|
data_source_id: databaseId,
|
|
355
387
|
};
|
|
@@ -357,6 +389,7 @@ alias
|
|
|
357
389
|
console.log(` (Auto-discovery failed: ${err.message})`);
|
|
358
390
|
}
|
|
359
391
|
|
|
392
|
+
config.workspaces[wsName].aliases = aliases;
|
|
360
393
|
saveConfig(config);
|
|
361
394
|
});
|
|
362
395
|
|
|
@@ -364,16 +397,17 @@ alias
|
|
|
364
397
|
.command('list')
|
|
365
398
|
.description('Show all configured database aliases')
|
|
366
399
|
.action(() => {
|
|
367
|
-
const
|
|
368
|
-
const aliases =
|
|
400
|
+
const ws = getWorkspaceConfig();
|
|
401
|
+
const aliases = ws.aliases || {};
|
|
369
402
|
const names = Object.keys(aliases);
|
|
370
403
|
|
|
371
404
|
if (names.length === 0) {
|
|
372
|
-
console.log(
|
|
405
|
+
console.log(`No aliases in workspace "${ws.name}".`);
|
|
373
406
|
console.log('Add one with: notion alias add <name> <database-id>');
|
|
374
407
|
return;
|
|
375
408
|
}
|
|
376
409
|
|
|
410
|
+
console.log(`Workspace: ${ws.name}\n`);
|
|
377
411
|
const rows = names.map(name => ({
|
|
378
412
|
alias: name,
|
|
379
413
|
database_id: aliases[name].database_id,
|
|
@@ -387,17 +421,20 @@ alias
|
|
|
387
421
|
.description('Remove a database alias')
|
|
388
422
|
.action((name) => {
|
|
389
423
|
const config = loadConfig();
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
424
|
+
const wsName = getWorkspaceName() || config.activeWorkspace || 'default';
|
|
425
|
+
const aliases = config.workspaces[wsName]?.aliases || {};
|
|
426
|
+
if (!aliases[name]) {
|
|
427
|
+
console.error(`Alias "${name}" not found in workspace "${wsName}".`);
|
|
428
|
+
const names = Object.keys(aliases);
|
|
393
429
|
if (names.length > 0) {
|
|
394
430
|
console.error(`Available: ${names.join(', ')}`);
|
|
395
431
|
}
|
|
396
432
|
process.exit(1);
|
|
397
433
|
}
|
|
398
|
-
delete
|
|
434
|
+
delete aliases[name];
|
|
435
|
+
config.workspaces[wsName].aliases = aliases;
|
|
399
436
|
saveConfig(config);
|
|
400
|
-
console.log(`✅ Removed alias "${name}"`);
|
|
437
|
+
console.log(`✅ Removed alias "${name}" from workspace "${wsName}"`);
|
|
401
438
|
});
|
|
402
439
|
|
|
403
440
|
alias
|
|
@@ -405,22 +442,104 @@ alias
|
|
|
405
442
|
.description('Rename a database alias')
|
|
406
443
|
.action((oldName, newName) => {
|
|
407
444
|
const config = loadConfig();
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
445
|
+
const wsName = getWorkspaceName() || config.activeWorkspace || 'default';
|
|
446
|
+
const aliases = config.workspaces[wsName]?.aliases || {};
|
|
447
|
+
if (!aliases[oldName]) {
|
|
448
|
+
console.error(`Alias "${oldName}" not found in workspace "${wsName}".`);
|
|
449
|
+
const names = Object.keys(aliases);
|
|
411
450
|
if (names.length > 0) {
|
|
412
451
|
console.error(`Available: ${names.join(', ')}`);
|
|
413
452
|
}
|
|
414
453
|
process.exit(1);
|
|
415
454
|
}
|
|
416
|
-
if (
|
|
455
|
+
if (aliases[newName]) {
|
|
417
456
|
console.error(`Alias "${newName}" already exists. Remove it first or pick a different name.`);
|
|
418
457
|
process.exit(1);
|
|
419
458
|
}
|
|
420
|
-
|
|
421
|
-
delete
|
|
459
|
+
aliases[newName] = aliases[oldName];
|
|
460
|
+
delete aliases[oldName];
|
|
461
|
+
config.workspaces[wsName].aliases = aliases;
|
|
422
462
|
saveConfig(config);
|
|
423
|
-
console.log(`✅ Renamed "${oldName}" → "${newName}"`);
|
|
463
|
+
console.log(`✅ Renamed "${oldName}" → "${newName}" in workspace "${wsName}"`);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// ─── workspace ─────────────────────────────────────────────────────────────────
|
|
467
|
+
const workspace = program
|
|
468
|
+
.command('workspace')
|
|
469
|
+
.description('Manage workspace profiles (multiple Notion accounts)');
|
|
470
|
+
|
|
471
|
+
workspace
|
|
472
|
+
.command('add <name>')
|
|
473
|
+
.description('Add a new workspace profile')
|
|
474
|
+
.requiredOption('--key <api-key>', 'Notion API key for this workspace')
|
|
475
|
+
.action(async (name, opts) => {
|
|
476
|
+
const config = loadConfig();
|
|
477
|
+
if (config.workspaces[name]) {
|
|
478
|
+
console.error(`Workspace "${name}" already exists. Use "notion init --workspace ${name} --key ..." to update it.`);
|
|
479
|
+
process.exit(1);
|
|
480
|
+
}
|
|
481
|
+
config.workspaces[name] = { apiKey: opts.key, aliases: {} };
|
|
482
|
+
saveConfig(config);
|
|
483
|
+
console.log(`✅ Added workspace "${name}"`);
|
|
484
|
+
console.log('');
|
|
485
|
+
console.log(`Discover databases: notion init --workspace ${name}`);
|
|
486
|
+
console.log(`Or set as active: notion workspace use ${name}`);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
workspace
|
|
490
|
+
.command('list')
|
|
491
|
+
.description('List all workspace profiles')
|
|
492
|
+
.action(() => {
|
|
493
|
+
const config = loadConfig();
|
|
494
|
+
const names = Object.keys(config.workspaces || {});
|
|
495
|
+
if (names.length === 0) {
|
|
496
|
+
console.log('No workspaces configured. Run: notion init --key ntn_...');
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
for (const name of names) {
|
|
500
|
+
const ws = config.workspaces[name];
|
|
501
|
+
const active = name === config.activeWorkspace ? ' ← active' : '';
|
|
502
|
+
const aliasCount = Object.keys(ws.aliases || {}).length;
|
|
503
|
+
const keyPreview = ws.apiKey ? `${ws.apiKey.slice(0, 8)}...` : '(no key)';
|
|
504
|
+
console.log(` ${name}${active}`);
|
|
505
|
+
console.log(` Key: ${keyPreview} | Aliases: ${aliasCount}`);
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
workspace
|
|
510
|
+
.command('use <name>')
|
|
511
|
+
.description('Set the active workspace')
|
|
512
|
+
.action((name) => {
|
|
513
|
+
const config = loadConfig();
|
|
514
|
+
if (!config.workspaces[name]) {
|
|
515
|
+
console.error(`Workspace "${name}" not found.`);
|
|
516
|
+
const names = Object.keys(config.workspaces || {});
|
|
517
|
+
if (names.length > 0) {
|
|
518
|
+
console.error(`Available: ${names.join(', ')}`);
|
|
519
|
+
}
|
|
520
|
+
process.exit(1);
|
|
521
|
+
}
|
|
522
|
+
config.activeWorkspace = name;
|
|
523
|
+
saveConfig(config);
|
|
524
|
+
console.log(`✅ Active workspace: ${name}`);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
workspace
|
|
528
|
+
.command('remove <name>')
|
|
529
|
+
.description('Remove a workspace profile')
|
|
530
|
+
.action((name) => {
|
|
531
|
+
const config = loadConfig();
|
|
532
|
+
if (!config.workspaces[name]) {
|
|
533
|
+
console.error(`Workspace "${name}" not found.`);
|
|
534
|
+
process.exit(1);
|
|
535
|
+
}
|
|
536
|
+
if (name === config.activeWorkspace) {
|
|
537
|
+
console.error(`Cannot remove the active workspace. Switch first: notion workspace use <other>`);
|
|
538
|
+
process.exit(1);
|
|
539
|
+
}
|
|
540
|
+
delete config.workspaces[name];
|
|
541
|
+
saveConfig(config);
|
|
542
|
+
console.log(`✅ Removed workspace "${name}"`);
|
|
424
543
|
});
|
|
425
544
|
|
|
426
545
|
// ─── search ────────────────────────────────────────────────────────────────────
|
|
@@ -1040,5 +1159,463 @@ program
|
|
|
1040
1159
|
}
|
|
1041
1160
|
});
|
|
1042
1161
|
|
|
1162
|
+
// ─── me ────────────────────────────────────────────────────────────────────────
|
|
1163
|
+
program
|
|
1164
|
+
.command('me')
|
|
1165
|
+
.description('Show details about the current integration/bot')
|
|
1166
|
+
.action(async (opts, cmd) => {
|
|
1167
|
+
try {
|
|
1168
|
+
const notion = getNotion();
|
|
1169
|
+
const me = await notion.users.me({});
|
|
1170
|
+
if (getGlobalJson(cmd)) {
|
|
1171
|
+
console.log(JSON.stringify(me, null, 2));
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
console.log(`Bot: ${me.name || '(unnamed)'}`);
|
|
1175
|
+
console.log(`ID: ${me.id}`);
|
|
1176
|
+
console.log(`Type: ${me.type}`);
|
|
1177
|
+
if (me.bot?.owner) {
|
|
1178
|
+
const owner = me.bot.owner;
|
|
1179
|
+
console.log(`Owner: ${owner.type === 'workspace' ? 'Workspace' : owner.user?.name || owner.type}`);
|
|
1180
|
+
}
|
|
1181
|
+
if (me.avatar_url) {
|
|
1182
|
+
console.log(`Avatar: ${me.avatar_url}`);
|
|
1183
|
+
}
|
|
1184
|
+
} catch (err) {
|
|
1185
|
+
console.error('Me failed:', err.message);
|
|
1186
|
+
process.exit(1);
|
|
1187
|
+
}
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
// ─── move ──────────────────────────────────────────────────────────────────────
|
|
1191
|
+
program
|
|
1192
|
+
.command('move <page-or-alias>')
|
|
1193
|
+
.description('Move a page to a new parent (page or database)')
|
|
1194
|
+
.option('--filter <key=value>', 'Filter to find the page (required when using an alias)')
|
|
1195
|
+
.option('--to <parent-id-or-alias>', 'Destination parent (page ID, database alias, or database ID)')
|
|
1196
|
+
.action(async (target, opts, cmd) => {
|
|
1197
|
+
try {
|
|
1198
|
+
if (!opts.to) {
|
|
1199
|
+
console.error('--to is required. Specify a parent page ID or database alias.');
|
|
1200
|
+
process.exit(1);
|
|
1201
|
+
}
|
|
1202
|
+
const notion = getNotion();
|
|
1203
|
+
const { pageId } = await resolvePageId(target, opts.filter);
|
|
1204
|
+
|
|
1205
|
+
// Resolve --to target
|
|
1206
|
+
let parent;
|
|
1207
|
+
const ws = getWorkspaceConfig();
|
|
1208
|
+
if (ws.aliases && ws.aliases[opts.to]) {
|
|
1209
|
+
const db = ws.aliases[opts.to];
|
|
1210
|
+
// pages.move() requires data_source_id parent, not database_id
|
|
1211
|
+
parent = { type: 'data_source_id', data_source_id: db.data_source_id };
|
|
1212
|
+
} else if (UUID_REGEX.test(opts.to)) {
|
|
1213
|
+
// Assume page ID — user can also pass a database_id
|
|
1214
|
+
parent = { type: 'page_id', page_id: opts.to };
|
|
1215
|
+
} else {
|
|
1216
|
+
console.error(`Unknown destination: "${opts.to}". Use a page ID or database alias.`);
|
|
1217
|
+
const aliasNames = ws.aliases ? Object.keys(ws.aliases) : [];
|
|
1218
|
+
if (aliasNames.length > 0) {
|
|
1219
|
+
console.error(`Available aliases: ${aliasNames.join(', ')}`);
|
|
1220
|
+
}
|
|
1221
|
+
process.exit(1);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
const res = await notion.pages.move({ page_id: pageId, parent });
|
|
1225
|
+
if (getGlobalJson(cmd)) {
|
|
1226
|
+
console.log(JSON.stringify(res, null, 2));
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
console.log(`✅ Moved page: ${pageId.slice(0, 8)}…`);
|
|
1230
|
+
if (res.url) console.log(` URL: ${res.url}`);
|
|
1231
|
+
} catch (err) {
|
|
1232
|
+
console.error('Move failed:', err.message);
|
|
1233
|
+
process.exit(1);
|
|
1234
|
+
}
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
// ─── templates ─────────────────────────────────────────────────────────────────
|
|
1238
|
+
program
|
|
1239
|
+
.command('templates <database>')
|
|
1240
|
+
.description('List page templates available for a database')
|
|
1241
|
+
.action(async (db, opts, cmd) => {
|
|
1242
|
+
try {
|
|
1243
|
+
const notion = getNotion();
|
|
1244
|
+
const dbIds = resolveDb(db);
|
|
1245
|
+
const res = await notion.dataSources.listTemplates({
|
|
1246
|
+
data_source_id: dbIds.data_source_id,
|
|
1247
|
+
});
|
|
1248
|
+
if (getGlobalJson(cmd)) {
|
|
1249
|
+
console.log(JSON.stringify(res, null, 2));
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
if (!res.results || res.results.length === 0) {
|
|
1253
|
+
console.log('No templates found for this database.');
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
const rows = res.results.map(t => {
|
|
1257
|
+
let title = '';
|
|
1258
|
+
if (t.properties) {
|
|
1259
|
+
for (const [, prop] of Object.entries(t.properties)) {
|
|
1260
|
+
if (prop.type === 'title') {
|
|
1261
|
+
title = propValue(prop);
|
|
1262
|
+
break;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
return {
|
|
1267
|
+
id: t.id,
|
|
1268
|
+
title: title || '(untitled)',
|
|
1269
|
+
url: t.url || '',
|
|
1270
|
+
};
|
|
1271
|
+
});
|
|
1272
|
+
printTable(rows, ['id', 'title', 'url']);
|
|
1273
|
+
} catch (err) {
|
|
1274
|
+
console.error('Templates failed:', err.message);
|
|
1275
|
+
process.exit(1);
|
|
1276
|
+
}
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
// ─── db-create ─────────────────────────────────────────────────────────────────
|
|
1280
|
+
program
|
|
1281
|
+
.command('db-create <parent-page-id> <title>')
|
|
1282
|
+
.description('Create a new database under a page')
|
|
1283
|
+
.option('--prop <name:type...>', 'Property definition — repeatable (e.g. --prop "Status:select" --prop "Priority:number")', (v, prev) => prev.concat([v]), [])
|
|
1284
|
+
.option('--alias <name>', 'Auto-create an alias for the new database')
|
|
1285
|
+
.action(async (parentPageId, title, opts, cmd) => {
|
|
1286
|
+
try {
|
|
1287
|
+
const notion = getNotion();
|
|
1288
|
+
|
|
1289
|
+
// Build properties — always include a title property
|
|
1290
|
+
const properties = {};
|
|
1291
|
+
let hasTitleProp = false;
|
|
1292
|
+
|
|
1293
|
+
for (const kv of opts.prop) {
|
|
1294
|
+
const colonIdx = kv.indexOf(':');
|
|
1295
|
+
if (colonIdx === -1) {
|
|
1296
|
+
console.error(`Invalid property format: ${kv} (expected name:type)`);
|
|
1297
|
+
console.error('Supported types: title, rich_text, number, select, multi_select, date, checkbox, url, email, phone_number, status');
|
|
1298
|
+
process.exit(1);
|
|
1299
|
+
}
|
|
1300
|
+
const name = kv.slice(0, colonIdx);
|
|
1301
|
+
const type = kv.slice(colonIdx + 1).toLowerCase();
|
|
1302
|
+
if (type === 'title') hasTitleProp = true;
|
|
1303
|
+
properties[name] = { [type]: {} };
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// Ensure there's a title property
|
|
1307
|
+
if (!hasTitleProp) {
|
|
1308
|
+
properties['Name'] = { title: {} };
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// 2025 API: databases.create() only handles title property reliably.
|
|
1312
|
+
// Non-title properties must be added via dataSources.update() after creation.
|
|
1313
|
+
const titleProps = {};
|
|
1314
|
+
const extraProps = {};
|
|
1315
|
+
for (const [name, prop] of Object.entries(properties)) {
|
|
1316
|
+
if (prop.title) {
|
|
1317
|
+
titleProps[name] = prop;
|
|
1318
|
+
} else {
|
|
1319
|
+
extraProps[name] = prop;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
// Ensure title property exists in create call
|
|
1323
|
+
if (Object.keys(titleProps).length === 0) {
|
|
1324
|
+
titleProps['Name'] = { title: {} };
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
const res = await notion.databases.create({
|
|
1328
|
+
parent: { type: 'page_id', page_id: parentPageId },
|
|
1329
|
+
title: [{ text: { content: title } }],
|
|
1330
|
+
properties: titleProps,
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
// Extract correct dual IDs from response
|
|
1334
|
+
const databaseId = res.id;
|
|
1335
|
+
const dataSourceId = (res.data_sources && res.data_sources[0])
|
|
1336
|
+
? res.data_sources[0].id
|
|
1337
|
+
: res.id;
|
|
1338
|
+
|
|
1339
|
+
// Add non-title properties via dataSources.update()
|
|
1340
|
+
if (Object.keys(extraProps).length > 0) {
|
|
1341
|
+
await notion.dataSources.update({
|
|
1342
|
+
data_source_id: dataSourceId,
|
|
1343
|
+
properties: extraProps,
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
if (getGlobalJson(cmd)) {
|
|
1348
|
+
console.log(JSON.stringify(res, null, 2));
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
console.log(`✅ Created database: ${databaseId.slice(0, 8)}…`);
|
|
1353
|
+
console.log(` Title: ${title}`);
|
|
1354
|
+
console.log(` Properties: ${Object.keys(properties).join(', ')}`);
|
|
1355
|
+
|
|
1356
|
+
// Auto-create alias if requested
|
|
1357
|
+
if (opts.alias) {
|
|
1358
|
+
const config = loadConfig();
|
|
1359
|
+
const wsName = getWorkspaceName() || config.activeWorkspace || 'default';
|
|
1360
|
+
if (!config.workspaces[wsName]) config.workspaces[wsName] = { aliases: {} };
|
|
1361
|
+
if (!config.workspaces[wsName].aliases) config.workspaces[wsName].aliases = {};
|
|
1362
|
+
config.workspaces[wsName].aliases[opts.alias] = {
|
|
1363
|
+
database_id: databaseId,
|
|
1364
|
+
data_source_id: dataSourceId,
|
|
1365
|
+
};
|
|
1366
|
+
saveConfig(config);
|
|
1367
|
+
console.log(` Alias: ${opts.alias}`);
|
|
1368
|
+
}
|
|
1369
|
+
} catch (err) {
|
|
1370
|
+
console.error('Database create failed:', err.message);
|
|
1371
|
+
process.exit(1);
|
|
1372
|
+
}
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
// ─── db-update ─────────────────────────────────────────────────────────────────
|
|
1376
|
+
program
|
|
1377
|
+
.command('db-update <database>')
|
|
1378
|
+
.description('Update a database title or add properties')
|
|
1379
|
+
.option('--title <text>', 'New database title')
|
|
1380
|
+
.option('--add-prop <name:type...>', 'Add a property (e.g. --add-prop "Priority:number")', (v, prev) => prev.concat([v]), [])
|
|
1381
|
+
.option('--remove-prop <name...>', 'Remove a property by name', (v, prev) => prev.concat([v]), [])
|
|
1382
|
+
.action(async (db, opts, cmd) => {
|
|
1383
|
+
try {
|
|
1384
|
+
const notion = getNotion();
|
|
1385
|
+
const dbIds = resolveDb(db);
|
|
1386
|
+
|
|
1387
|
+
// 2025 API: property changes go through dataSources.update(), NOT databases.update().
|
|
1388
|
+
// databases.update() silently ignores property modifications.
|
|
1389
|
+
// Title changes still go through databases.update().
|
|
1390
|
+
let canonicalId = dbIds.database_id;
|
|
1391
|
+
const dataSourceId = dbIds.data_source_id;
|
|
1392
|
+
|
|
1393
|
+
// Resolve canonical database_id if both IDs are the same
|
|
1394
|
+
if (canonicalId === dataSourceId) {
|
|
1395
|
+
try {
|
|
1396
|
+
const ds = await notion.dataSources.retrieve({ data_source_id: canonicalId });
|
|
1397
|
+
if (ds.parent && ds.parent.type === 'database_id') {
|
|
1398
|
+
canonicalId = ds.parent.database_id;
|
|
1399
|
+
}
|
|
1400
|
+
} catch (_) { /* fall through with what we have */ }
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// Build property changes for dataSources.update()
|
|
1404
|
+
let propChanges = null;
|
|
1405
|
+
if (opts.addProp.length > 0 || opts.removeProp.length > 0) {
|
|
1406
|
+
propChanges = {};
|
|
1407
|
+
|
|
1408
|
+
for (const kv of opts.addProp) {
|
|
1409
|
+
const colonIdx = kv.indexOf(':');
|
|
1410
|
+
if (colonIdx === -1) {
|
|
1411
|
+
console.error(`Invalid property format: ${kv} (expected name:type)`);
|
|
1412
|
+
process.exit(1);
|
|
1413
|
+
}
|
|
1414
|
+
const name = kv.slice(0, colonIdx);
|
|
1415
|
+
const type = kv.slice(colonIdx + 1).toLowerCase();
|
|
1416
|
+
propChanges[name] = { [type]: {} };
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
for (const name of opts.removeProp) {
|
|
1420
|
+
propChanges[name] = null;
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
let res;
|
|
1425
|
+
|
|
1426
|
+
// Title changes go through databases.update()
|
|
1427
|
+
if (opts.title) {
|
|
1428
|
+
res = await notion.databases.update({
|
|
1429
|
+
database_id: canonicalId,
|
|
1430
|
+
title: [{ text: { content: opts.title } }],
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// Property changes go through dataSources.update()
|
|
1435
|
+
if (propChanges) {
|
|
1436
|
+
res = await notion.dataSources.update({
|
|
1437
|
+
data_source_id: dataSourceId,
|
|
1438
|
+
properties: propChanges,
|
|
1439
|
+
});
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
if (getGlobalJson(cmd)) {
|
|
1443
|
+
console.log(JSON.stringify(res, null, 2));
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
console.log(`✅ Updated database: ${(dbIds.database_id || dbIds.data_source_id).slice(0, 8)}…`);
|
|
1448
|
+
if (opts.title) console.log(` Title: ${opts.title}`);
|
|
1449
|
+
if (opts.addProp.length > 0) console.log(` Added: ${opts.addProp.join(', ')}`);
|
|
1450
|
+
if (opts.removeProp.length > 0) console.log(` Removed: ${opts.removeProp.join(', ')}`);
|
|
1451
|
+
} catch (err) {
|
|
1452
|
+
console.error('Database update failed:', err.message);
|
|
1453
|
+
process.exit(1);
|
|
1454
|
+
}
|
|
1455
|
+
});
|
|
1456
|
+
|
|
1457
|
+
// ─── upload ────────────────────────────────────────────────────────────────────
|
|
1458
|
+
program
|
|
1459
|
+
.command('upload <page-or-alias> <file-path>')
|
|
1460
|
+
.description('Upload a file to a page')
|
|
1461
|
+
.option('--filter <key=value>', 'Filter to find the page (required when using an alias)')
|
|
1462
|
+
.action(async (target, filePath, opts, cmd) => {
|
|
1463
|
+
try {
|
|
1464
|
+
const notion = getNotion();
|
|
1465
|
+
const { pageId } = await resolvePageId(target, opts.filter);
|
|
1466
|
+
|
|
1467
|
+
// Resolve file path
|
|
1468
|
+
const absPath = path.resolve(filePath);
|
|
1469
|
+
if (!fs.existsSync(absPath)) {
|
|
1470
|
+
console.error(`File not found: ${absPath}`);
|
|
1471
|
+
process.exit(1);
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
const filename = path.basename(absPath);
|
|
1475
|
+
const fileData = fs.readFileSync(absPath);
|
|
1476
|
+
const fileSize = fileData.length;
|
|
1477
|
+
|
|
1478
|
+
// Detect MIME type from extension
|
|
1479
|
+
const MIME_MAP = {
|
|
1480
|
+
'.txt': 'text/plain', '.csv': 'text/csv', '.html': 'text/html',
|
|
1481
|
+
'.json': 'application/json', '.pdf': 'application/pdf',
|
|
1482
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
1483
|
+
'.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
|
|
1484
|
+
'.mp4': 'video/mp4', '.mp3': 'audio/mpeg', '.wav': 'audio/wav',
|
|
1485
|
+
'.zip': 'application/zip', '.doc': 'application/msword',
|
|
1486
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
1487
|
+
'.xls': 'application/vnd.ms-excel',
|
|
1488
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
1489
|
+
};
|
|
1490
|
+
const ext = path.extname(filename).toLowerCase();
|
|
1491
|
+
const mimeType = MIME_MAP[ext] || 'application/octet-stream';
|
|
1492
|
+
|
|
1493
|
+
// Step 1: Create file upload
|
|
1494
|
+
const upload = await notion.fileUploads.create({
|
|
1495
|
+
parent: { type: 'page_id', page_id: pageId },
|
|
1496
|
+
filename,
|
|
1497
|
+
});
|
|
1498
|
+
const uploadId = upload.id;
|
|
1499
|
+
|
|
1500
|
+
// Step 2: Send file data with correct content type
|
|
1501
|
+
await notion.fileUploads.send({
|
|
1502
|
+
file_upload_id: uploadId,
|
|
1503
|
+
file: { data: new Blob([fileData], { type: mimeType }), filename },
|
|
1504
|
+
part_number: '1',
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
// Step 3: Append file block to page (no complete() needed — attach directly)
|
|
1508
|
+
await notion.blocks.children.append({
|
|
1509
|
+
block_id: pageId,
|
|
1510
|
+
children: [{
|
|
1511
|
+
object: 'block',
|
|
1512
|
+
type: 'file',
|
|
1513
|
+
file: {
|
|
1514
|
+
type: 'file_upload',
|
|
1515
|
+
file_upload: { id: uploadId },
|
|
1516
|
+
},
|
|
1517
|
+
}],
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1520
|
+
if (getGlobalJson(cmd)) {
|
|
1521
|
+
console.log(JSON.stringify({ upload_id: uploadId, filename, size: fileSize, page_id: pageId }, null, 2));
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
const sizeStr = fileSize > 1024 * 1024
|
|
1526
|
+
? `${(fileSize / (1024 * 1024)).toFixed(1)} MB`
|
|
1527
|
+
: `${(fileSize / 1024).toFixed(1)} KB`;
|
|
1528
|
+
|
|
1529
|
+
console.log(`✅ Uploaded: ${filename} (${sizeStr})`);
|
|
1530
|
+
console.log(` Page: ${pageId.slice(0, 8)}…`);
|
|
1531
|
+
} catch (err) {
|
|
1532
|
+
console.error('Upload failed:', err.message);
|
|
1533
|
+
process.exit(1);
|
|
1534
|
+
}
|
|
1535
|
+
});
|
|
1536
|
+
|
|
1537
|
+
// ─── props ─────────────────────────────────────────────────────────────────────
|
|
1538
|
+
program
|
|
1539
|
+
.command('props <page-or-alias>')
|
|
1540
|
+
.description('List all properties with full paginated values')
|
|
1541
|
+
.option('--filter <key=value>', 'Filter to find the page (required when using an alias)')
|
|
1542
|
+
.action(async (target, opts, cmd) => {
|
|
1543
|
+
try {
|
|
1544
|
+
const notion = getNotion();
|
|
1545
|
+
const { pageId } = await resolvePageId(target, opts.filter);
|
|
1546
|
+
const page = await notion.pages.retrieve({ page_id: pageId });
|
|
1547
|
+
|
|
1548
|
+
if (getGlobalJson(cmd)) {
|
|
1549
|
+
console.log(JSON.stringify(page, null, 2));
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
console.log(`Page: ${page.id}`);
|
|
1554
|
+
console.log(`URL: ${page.url}\n`);
|
|
1555
|
+
|
|
1556
|
+
for (const [name, prop] of Object.entries(page.properties)) {
|
|
1557
|
+
// For paginated properties (relation, rollup, rich_text, title, people),
|
|
1558
|
+
// use the property retrieval endpoint to get full values
|
|
1559
|
+
const needsPagination = ['relation', 'rollup', 'rich_text', 'title', 'people'].includes(prop.type);
|
|
1560
|
+
|
|
1561
|
+
if (needsPagination && prop.id) {
|
|
1562
|
+
try {
|
|
1563
|
+
const fullProp = await notion.pages.properties.retrieve({
|
|
1564
|
+
page_id: pageId,
|
|
1565
|
+
property_id: prop.id,
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
if (fullProp.results) {
|
|
1569
|
+
// Paginated property — collect all results
|
|
1570
|
+
const items = fullProp.results;
|
|
1571
|
+
if (prop.type === 'relation') {
|
|
1572
|
+
if (items.length === 0) {
|
|
1573
|
+
console.log(` ${name}: (none)`);
|
|
1574
|
+
} else {
|
|
1575
|
+
const titles = [];
|
|
1576
|
+
for (const item of items) {
|
|
1577
|
+
const relId = item.relation?.id;
|
|
1578
|
+
if (relId) {
|
|
1579
|
+
try {
|
|
1580
|
+
const linked = await notion.pages.retrieve({ page_id: relId });
|
|
1581
|
+
let t = '';
|
|
1582
|
+
for (const [, p] of Object.entries(linked.properties)) {
|
|
1583
|
+
if (p.type === 'title') { t = propValue(p); break; }
|
|
1584
|
+
}
|
|
1585
|
+
titles.push(t || relId.slice(0, 8) + '…');
|
|
1586
|
+
} catch {
|
|
1587
|
+
titles.push(relId.slice(0, 8) + '…');
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
console.log(` ${name}: ${titles.join(', ')}`);
|
|
1592
|
+
}
|
|
1593
|
+
} else if (prop.type === 'rich_text' || prop.type === 'title') {
|
|
1594
|
+
const text = items.map(i => i[prop.type]?.plain_text || '').join('');
|
|
1595
|
+
console.log(` ${name}: ${text}`);
|
|
1596
|
+
} else if (prop.type === 'people') {
|
|
1597
|
+
const people = items.map(i => i.people?.name || i.people?.id || '').join(', ');
|
|
1598
|
+
console.log(` ${name}: ${people}`);
|
|
1599
|
+
} else {
|
|
1600
|
+
console.log(` ${name}: ${JSON.stringify(items)}`);
|
|
1601
|
+
}
|
|
1602
|
+
} else {
|
|
1603
|
+
// Non-paginated response
|
|
1604
|
+
console.log(` ${name}: ${propValue(fullProp)}`);
|
|
1605
|
+
}
|
|
1606
|
+
} catch {
|
|
1607
|
+
// Fallback to basic propValue
|
|
1608
|
+
console.log(` ${name}: ${propValue(prop)}`);
|
|
1609
|
+
}
|
|
1610
|
+
} else {
|
|
1611
|
+
console.log(` ${name}: ${propValue(prop)}`);
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
} catch (err) {
|
|
1615
|
+
console.error('Props failed:', err.message);
|
|
1616
|
+
process.exit(1);
|
|
1617
|
+
}
|
|
1618
|
+
});
|
|
1619
|
+
|
|
1043
1620
|
// ─── Run ───────────────────────────────────────────────────────────────────────
|
|
1044
1621
|
program.parse();
|
package/lib/helpers.js
CHANGED
|
@@ -17,14 +17,43 @@ function getConfigPaths(overrideDir) {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
function loadConfig(configPath) {
|
|
20
|
+
let config;
|
|
20
21
|
try {
|
|
21
22
|
if (fs.existsSync(configPath)) {
|
|
22
|
-
|
|
23
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
23
24
|
}
|
|
24
25
|
} catch (err) {
|
|
25
26
|
// Corrupted config — start fresh
|
|
26
27
|
}
|
|
27
|
-
|
|
28
|
+
if (!config) config = { activeWorkspace: 'default', workspaces: { default: { aliases: {} } } };
|
|
29
|
+
return migrateConfig(config);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Migrate old flat config → multi-workspace format.
|
|
34
|
+
* Old: { apiKey, aliases }
|
|
35
|
+
* New: { activeWorkspace, workspaces: { name: { apiKey, aliases } } }
|
|
36
|
+
*/
|
|
37
|
+
function migrateConfig(config) {
|
|
38
|
+
if (config.workspaces) return config; // already migrated
|
|
39
|
+
// Old format detected — wrap in "default" workspace
|
|
40
|
+
const ws = { aliases: config.aliases || {} };
|
|
41
|
+
if (config.apiKey) ws.apiKey = config.apiKey;
|
|
42
|
+
return { activeWorkspace: 'default', workspaces: { default: ws } };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get the config for a specific workspace (or the active one).
|
|
47
|
+
* Returns { apiKey, aliases } for that workspace.
|
|
48
|
+
*/
|
|
49
|
+
function resolveWorkspace(config, workspaceName) {
|
|
50
|
+
const name = workspaceName || config.activeWorkspace || 'default';
|
|
51
|
+
const ws = config.workspaces && config.workspaces[name];
|
|
52
|
+
if (!ws) {
|
|
53
|
+
const available = config.workspaces ? Object.keys(config.workspaces) : [];
|
|
54
|
+
return { error: `Unknown workspace: "${name}"`, available, name };
|
|
55
|
+
}
|
|
56
|
+
return { apiKey: ws.apiKey, aliases: ws.aliases || {}, name };
|
|
28
57
|
}
|
|
29
58
|
|
|
30
59
|
function saveConfig(config, configDir, configPath) {
|
|
@@ -292,6 +321,8 @@ function buildFilterFromSchema(schema, filterStr) {
|
|
|
292
321
|
module.exports = {
|
|
293
322
|
getConfigPaths,
|
|
294
323
|
loadConfig,
|
|
324
|
+
migrateConfig,
|
|
325
|
+
resolveWorkspace,
|
|
295
326
|
saveConfig,
|
|
296
327
|
richTextToPlain,
|
|
297
328
|
propValue,
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: notion
|
|
3
|
-
description: Notion API for creating and managing pages, databases, blocks, relations, and
|
|
3
|
+
description: Notion API for creating and managing pages, databases, blocks, relations, rollups, and multi-workspace profiles via the notioncli CLI tool.
|
|
4
4
|
homepage: https://github.com/JordanCoin/notioncli
|
|
5
5
|
metadata:
|
|
6
6
|
openclaw:
|
|
@@ -166,6 +166,39 @@ notion comment <page-id> "Looks good, shipping this!" # By page ID
|
|
|
166
166
|
notion comment tasks "AI review complete ✅" --filter "Name=Ship feature" # By alias + filter
|
|
167
167
|
```
|
|
168
168
|
|
|
169
|
+
### Page Inspector
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
notion props tasks --filter "Name=Ship feature" # Quick property dump
|
|
173
|
+
notion me # Show bot identity and owner
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Database Management
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
notion db-create <parent-page-id> "New DB" --prop "Name:title" --prop "Status:select"
|
|
180
|
+
notion db-update tasks --add-prop "Rating:number" # Add a column
|
|
181
|
+
notion db-update tasks --remove-prop "Old Column" # Remove a column
|
|
182
|
+
notion db-update tasks --title "Renamed DB" # Rename database
|
|
183
|
+
notion templates tasks # List page templates
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Moving Pages
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
notion move tasks --filter "Name=Done task" --to archive # Move by alias
|
|
190
|
+
notion move tasks --filter "Name=Done task" --to <page-id> # Move to page
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### File Uploads
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
notion upload tasks --filter "Name=Ship feature" ./report.pdf
|
|
197
|
+
notion upload <page-id> ./screenshot.png
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Supports images, PDFs, text files, documents. MIME types auto-detected from extension.
|
|
201
|
+
|
|
169
202
|
### Search
|
|
170
203
|
|
|
171
204
|
```bash
|
|
@@ -253,6 +286,29 @@ notion delete tasks --filter "Name=Old task"
|
|
|
253
286
|
notion delete workouts --filter "Date=2026-02-09"
|
|
254
287
|
```
|
|
255
288
|
|
|
289
|
+
### 9. Manage database schema
|
|
290
|
+
|
|
291
|
+
```bash
|
|
292
|
+
notion db-update tasks --add-prop "Priority:select" # Add column
|
|
293
|
+
notion db-update tasks --remove-prop "Old Field" # Remove column
|
|
294
|
+
notion db-create <parent-page-id> "New DB" --prop "Name:title" --prop "Status:select"
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
### 10. Move pages and upload files
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
notion move tasks --filter "Name=Done" --to archive
|
|
301
|
+
notion upload tasks --filter "Name=Ship feature" ./report.pdf
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### 11. Inspect and debug
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
notion me # Check integration identity
|
|
308
|
+
notion props tasks --filter "Name=Ship feature" # Quick property dump
|
|
309
|
+
notion templates tasks # List available templates
|
|
310
|
+
```
|
|
311
|
+
|
|
256
312
|
## Property Type Reference
|
|
257
313
|
|
|
258
314
|
When using `--prop key=value`, the CLI auto-detects the property type from the database schema:
|
|
@@ -271,6 +327,27 @@ When using `--prop key=value`, the CLI auto-detects the property type from the d
|
|
|
271
327
|
| `phone_number` | `Phone=+1234567890` | Phone number string |
|
|
272
328
|
| `status` | `Status=In Progress` | Status property |
|
|
273
329
|
|
|
330
|
+
## Multi-Workspace Profiles
|
|
331
|
+
|
|
332
|
+
Manage multiple Notion accounts from one CLI:
|
|
333
|
+
|
|
334
|
+
```bash
|
|
335
|
+
notion workspace add work --key ntn_work_key # Add workspace
|
|
336
|
+
notion workspace add personal --key ntn_personal # Add another
|
|
337
|
+
notion workspace list # Show all
|
|
338
|
+
notion workspace use work # Switch active
|
|
339
|
+
notion workspace remove old # Remove one
|
|
340
|
+
|
|
341
|
+
# Per-command override
|
|
342
|
+
notion query tasks --workspace personal
|
|
343
|
+
notion -w work add projects --prop "Name=Q2 Plan"
|
|
344
|
+
|
|
345
|
+
# Init with workspace
|
|
346
|
+
notion init --workspace work --key ntn_work_key
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Aliases are scoped per workspace. Old single-key configs auto-migrate to a "default" workspace.
|
|
350
|
+
|
|
274
351
|
## Notion API 2025 — Dual IDs
|
|
275
352
|
|
|
276
353
|
The Notion API (2025-09-03) uses dual IDs for databases: a `database_id` and a `data_source_id`. notioncli handles this automatically — when you run `notion init` or `notion alias add`, both IDs are discovered and stored. You never need to think about it.
|
package/test/mock.test.js
CHANGED
|
@@ -34,44 +34,61 @@ describe('Config management', () => {
|
|
|
34
34
|
|
|
35
35
|
it('loadConfig returns empty config for missing file', () => {
|
|
36
36
|
const config = loadConfig(configPath);
|
|
37
|
-
assert.
|
|
37
|
+
assert.equal(config.activeWorkspace, 'default');
|
|
38
|
+
assert.ok(config.workspaces.default);
|
|
39
|
+
assert.deepEqual(config.workspaces.default.aliases, {});
|
|
38
40
|
});
|
|
39
41
|
|
|
40
42
|
it('loadConfig returns empty config for corrupted file', () => {
|
|
41
43
|
fs.mkdirSync(configDir, { recursive: true });
|
|
42
44
|
fs.writeFileSync(configPath, 'not json at all!!!');
|
|
43
45
|
const config = loadConfig(configPath);
|
|
44
|
-
assert.
|
|
46
|
+
assert.equal(config.activeWorkspace, 'default');
|
|
47
|
+
assert.ok(config.workspaces.default);
|
|
45
48
|
});
|
|
46
49
|
|
|
47
50
|
it('saveConfig creates directory and file', () => {
|
|
48
|
-
const config = { aliases: { test: { database_id: 'db1', data_source_id: 'ds1' } } };
|
|
51
|
+
const config = { activeWorkspace: 'default', workspaces: { default: { aliases: { test: { database_id: 'db1', data_source_id: 'ds1' } } } } };
|
|
49
52
|
saveConfig(config, configDir, configPath);
|
|
50
53
|
assert.ok(fs.existsSync(configPath));
|
|
51
54
|
});
|
|
52
55
|
|
|
53
56
|
it('loadConfig reads back saved config', () => {
|
|
54
57
|
const config = {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
+
activeWorkspace: 'default',
|
|
59
|
+
workspaces: {
|
|
60
|
+
default: {
|
|
61
|
+
apiKey: 'ntn_test_key',
|
|
62
|
+
aliases: {
|
|
63
|
+
projects: { database_id: 'db-123', data_source_id: 'ds-456' },
|
|
64
|
+
},
|
|
65
|
+
},
|
|
58
66
|
},
|
|
59
67
|
};
|
|
60
68
|
saveConfig(config, configDir, configPath);
|
|
61
69
|
const loaded = loadConfig(configPath);
|
|
62
|
-
assert.equal(loaded.apiKey, 'ntn_test_key');
|
|
63
|
-
assert.deepEqual(loaded.aliases.projects, {
|
|
70
|
+
assert.equal(loaded.workspaces.default.apiKey, 'ntn_test_key');
|
|
71
|
+
assert.deepEqual(loaded.workspaces.default.aliases.projects, {
|
|
64
72
|
database_id: 'db-123',
|
|
65
73
|
data_source_id: 'ds-456',
|
|
66
74
|
});
|
|
67
75
|
});
|
|
68
76
|
|
|
69
77
|
it('saveConfig overwrites existing config', () => {
|
|
70
|
-
saveConfig({ aliases: { a: { database_id: '1', data_source_id: '1' } } }, configDir, configPath);
|
|
71
|
-
saveConfig({ aliases: { b: { database_id: '2', data_source_id: '2' } } }, configDir, configPath);
|
|
78
|
+
saveConfig({ activeWorkspace: 'default', workspaces: { default: { aliases: { a: { database_id: '1', data_source_id: '1' } } } } }, configDir, configPath);
|
|
79
|
+
saveConfig({ activeWorkspace: 'default', workspaces: { default: { aliases: { b: { database_id: '2', data_source_id: '2' } } } } }, configDir, configPath);
|
|
72
80
|
const loaded = loadConfig(configPath);
|
|
73
|
-
assert.ok(!loaded.aliases.a);
|
|
74
|
-
assert.ok(loaded.aliases.b);
|
|
81
|
+
assert.ok(!loaded.workspaces.default.aliases.a);
|
|
82
|
+
assert.ok(loaded.workspaces.default.aliases.b);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('loadConfig auto-migrates old flat format', () => {
|
|
86
|
+
const oldConfig = { apiKey: 'ntn_old', aliases: { tasks: { database_id: 'db1', data_source_id: 'ds1' } } };
|
|
87
|
+
saveConfig(oldConfig, configDir, configPath);
|
|
88
|
+
const loaded = loadConfig(configPath);
|
|
89
|
+
assert.equal(loaded.activeWorkspace, 'default');
|
|
90
|
+
assert.equal(loaded.workspaces.default.apiKey, 'ntn_old');
|
|
91
|
+
assert.deepEqual(loaded.workspaces.default.aliases.tasks, { database_id: 'db1', data_source_id: 'ds1' });
|
|
75
92
|
});
|
|
76
93
|
});
|
|
77
94
|
|
|
@@ -108,14 +125,18 @@ describe('resolveDb logic (pure)', () => {
|
|
|
108
125
|
|
|
109
126
|
it('known alias resolves to database_id + data_source_id', () => {
|
|
110
127
|
const config = {
|
|
111
|
-
|
|
112
|
-
|
|
128
|
+
activeWorkspace: 'default',
|
|
129
|
+
workspaces: {
|
|
130
|
+
default: {
|
|
131
|
+
aliases: {
|
|
132
|
+
projects: { database_id: 'db-aaa', data_source_id: 'ds-bbb' },
|
|
133
|
+
},
|
|
134
|
+
},
|
|
113
135
|
},
|
|
114
136
|
};
|
|
115
137
|
saveConfig(config, configDir, configPath);
|
|
116
138
|
const loaded = loadConfig(configPath);
|
|
117
|
-
const
|
|
118
|
-
const result = loaded.aliases[alias];
|
|
139
|
+
const result = loaded.workspaces.default.aliases.projects;
|
|
119
140
|
assert.ok(result);
|
|
120
141
|
assert.equal(result.database_id, 'db-aaa');
|
|
121
142
|
assert.equal(result.data_source_id, 'ds-bbb');
|
|
@@ -132,9 +153,9 @@ describe('resolveDb logic (pure)', () => {
|
|
|
132
153
|
});
|
|
133
154
|
|
|
134
155
|
it('config with no aliases returns empty aliases object', () => {
|
|
135
|
-
saveConfig({ aliases: {} }, configDir, configPath);
|
|
156
|
+
saveConfig({ activeWorkspace: 'default', workspaces: { default: { aliases: {} } } }, configDir, configPath);
|
|
136
157
|
const loaded = loadConfig(configPath);
|
|
137
|
-
assert.deepEqual(Object.keys(loaded.aliases), []);
|
|
158
|
+
assert.deepEqual(Object.keys(loaded.workspaces.default.aliases), []);
|
|
138
159
|
});
|
|
139
160
|
});
|
|
140
161
|
|