@jordancoin/notioncli 1.0.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/.github/workflows/ci.yml +31 -0
- package/LICENSE +21 -0
- package/README.md +353 -0
- package/bin/notion.js +847 -0
- package/lib/helpers.js +291 -0
- package/package.json +44 -0
- package/skill/SKILL.md +262 -0
- package/skill/install.sh +4 -0
- package/test/integration.test.js +110 -0
- package/test/mock.test.js +378 -0
- package/test/unit.test.js +663 -0
package/bin/notion.js
ADDED
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { program } = require('commander');
|
|
4
|
+
const { Client } = require('@notionhq/client');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const helpers = require('../lib/helpers');
|
|
8
|
+
|
|
9
|
+
// ─── Config file system ────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
const { CONFIG_DIR, CONFIG_PATH } = helpers.getConfigPaths();
|
|
12
|
+
|
|
13
|
+
function loadConfig() {
|
|
14
|
+
return helpers.loadConfig(CONFIG_PATH);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function saveConfig(config) {
|
|
18
|
+
helpers.saveConfig(config, CONFIG_DIR, CONFIG_PATH);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolve API key: env var → config file → error with setup instructions
|
|
23
|
+
*/
|
|
24
|
+
function getApiKey() {
|
|
25
|
+
if (process.env.NOTION_API_KEY) return process.env.NOTION_API_KEY;
|
|
26
|
+
const config = loadConfig();
|
|
27
|
+
if (config.apiKey) return config.apiKey;
|
|
28
|
+
console.error('Error: No Notion API key found.');
|
|
29
|
+
console.error('');
|
|
30
|
+
console.error('Set it up with one of:');
|
|
31
|
+
console.error(' 1. notion init --key ntn_your_api_key');
|
|
32
|
+
console.error(' 2. export NOTION_API_KEY=ntn_your_api_key');
|
|
33
|
+
console.error('');
|
|
34
|
+
console.error('Get a key at: https://www.notion.so/profile/integrations');
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve a user-given alias or UUID to { database_id, data_source_id }.
|
|
40
|
+
* If given a raw UUID, we use it for both IDs (the SDK figures it out).
|
|
41
|
+
*/
|
|
42
|
+
function resolveDb(aliasOrId) {
|
|
43
|
+
const config = loadConfig();
|
|
44
|
+
if (config.aliases && config.aliases[aliasOrId]) {
|
|
45
|
+
return config.aliases[aliasOrId];
|
|
46
|
+
}
|
|
47
|
+
if (UUID_REGEX.test(aliasOrId)) {
|
|
48
|
+
return { database_id: aliasOrId, data_source_id: aliasOrId };
|
|
49
|
+
}
|
|
50
|
+
const aliasNames = config.aliases ? Object.keys(config.aliases) : [];
|
|
51
|
+
console.error(`Unknown database alias: "${aliasOrId}"`);
|
|
52
|
+
if (aliasNames.length > 0) {
|
|
53
|
+
console.error(`Available aliases: ${aliasNames.join(', ')}`);
|
|
54
|
+
} else {
|
|
55
|
+
console.error('No aliases configured. Add one with: notion alias add <name> <database-id>');
|
|
56
|
+
}
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve alias + filter → page ID, or pass through a raw UUID.
|
|
62
|
+
* Used by update, delete, get, blocks, comments, comment, append.
|
|
63
|
+
*
|
|
64
|
+
* Returns { pageId, dbIds } where dbIds is non-null when resolved via alias.
|
|
65
|
+
*/
|
|
66
|
+
async function resolvePageId(aliasOrId, filterStr) {
|
|
67
|
+
const config = loadConfig();
|
|
68
|
+
if (config.aliases && config.aliases[aliasOrId]) {
|
|
69
|
+
if (!filterStr) {
|
|
70
|
+
console.error('When using an alias, --filter is required to identify a specific page.');
|
|
71
|
+
console.error(`Example: notion update ${aliasOrId} --filter "Name=My Page" --prop "Status=Done"`);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
const dbIds = config.aliases[aliasOrId];
|
|
75
|
+
const notion = getNotion();
|
|
76
|
+
const filter = await buildFilter(dbIds, filterStr);
|
|
77
|
+
const res = await notion.dataSources.query({
|
|
78
|
+
data_source_id: dbIds.data_source_id,
|
|
79
|
+
filter,
|
|
80
|
+
page_size: 5,
|
|
81
|
+
});
|
|
82
|
+
if (res.results.length === 0) {
|
|
83
|
+
console.error('No matching page found.');
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
if (res.results.length > 1) {
|
|
87
|
+
console.error(`Multiple pages match (${res.results.length}). Use a more specific filter or pass a page ID directly.`);
|
|
88
|
+
const rows = pagesToRows(res.results);
|
|
89
|
+
const cols = Object.keys(rows[0]).slice(0, 4);
|
|
90
|
+
printTable(rows, cols);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
return { pageId: res.results[0].id, dbIds };
|
|
94
|
+
}
|
|
95
|
+
// Check if it looks like a UUID — if not, it's probably a typo'd alias
|
|
96
|
+
if (!UUID_REGEX.test(aliasOrId)) {
|
|
97
|
+
const aliasNames = config.aliases ? Object.keys(config.aliases) : [];
|
|
98
|
+
console.error(`Unknown alias: "${aliasOrId}"`);
|
|
99
|
+
if (aliasNames.length > 0) {
|
|
100
|
+
console.error(`Available aliases: ${aliasNames.join(', ')}`);
|
|
101
|
+
} else {
|
|
102
|
+
console.error('No aliases configured. Run: notion init --key <your-api-key>');
|
|
103
|
+
}
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
// Treat as raw page ID
|
|
107
|
+
return { pageId: aliasOrId, dbIds: null };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── Lazy Notion client ────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
let _notion = null;
|
|
113
|
+
function getNotion() {
|
|
114
|
+
if (!_notion) {
|
|
115
|
+
_notion = new Client({ auth: getApiKey() });
|
|
116
|
+
}
|
|
117
|
+
return _notion;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Helpers (imported from lib/helpers.js) ────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
const {
|
|
123
|
+
richTextToPlain,
|
|
124
|
+
propValue,
|
|
125
|
+
buildPropValue,
|
|
126
|
+
printTable,
|
|
127
|
+
pagesToRows,
|
|
128
|
+
formatCsv,
|
|
129
|
+
formatYaml,
|
|
130
|
+
outputFormatted,
|
|
131
|
+
buildFilterFromSchema,
|
|
132
|
+
UUID_REGEX,
|
|
133
|
+
} = helpers;
|
|
134
|
+
|
|
135
|
+
/** Check if --json flag is set anywhere in the command chain */
|
|
136
|
+
function getGlobalJson(cmd) {
|
|
137
|
+
let c = cmd;
|
|
138
|
+
while (c) {
|
|
139
|
+
if (c.opts().json) return true;
|
|
140
|
+
c = c.parent;
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Fetch data source schema — returns map of lowercase_name → { type, name }
|
|
147
|
+
* Uses dataSources.retrieve() which accepts the data_source_id.
|
|
148
|
+
*/
|
|
149
|
+
async function getDbSchema(dbIds) {
|
|
150
|
+
const notion = getNotion();
|
|
151
|
+
const dsId = dbIds.data_source_id;
|
|
152
|
+
const ds = await notion.dataSources.retrieve({ data_source_id: dsId });
|
|
153
|
+
const schema = {};
|
|
154
|
+
for (const [name, prop] of Object.entries(ds.properties)) {
|
|
155
|
+
schema[name.toLowerCase()] = { type: prop.type, name };
|
|
156
|
+
}
|
|
157
|
+
return schema;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Build properties object from --prop key=value pairs using schema */
|
|
161
|
+
async function buildProperties(dbIds, props) {
|
|
162
|
+
const schema = await getDbSchema(dbIds);
|
|
163
|
+
const properties = {};
|
|
164
|
+
|
|
165
|
+
for (const kv of props) {
|
|
166
|
+
const eqIdx = kv.indexOf('=');
|
|
167
|
+
if (eqIdx === -1) {
|
|
168
|
+
console.error(`Invalid property format: ${kv} (expected key=value)`);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
const key = kv.slice(0, eqIdx);
|
|
172
|
+
const value = kv.slice(eqIdx + 1);
|
|
173
|
+
|
|
174
|
+
const schemaEntry = schema[key.toLowerCase()];
|
|
175
|
+
if (!schemaEntry) {
|
|
176
|
+
console.error(`Property "${key}" not found in database schema.`);
|
|
177
|
+
console.error(`Available: ${Object.values(schema).map(s => s.name).join(', ')}`);
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
properties[schemaEntry.name] = buildPropValue(schemaEntry.type, value);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return properties;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Parse filter string key=value into a Notion filter object */
|
|
188
|
+
async function buildFilter(dbIds, filterStr) {
|
|
189
|
+
const schema = await getDbSchema(dbIds);
|
|
190
|
+
const result = buildFilterFromSchema(schema, filterStr);
|
|
191
|
+
if (result.error) {
|
|
192
|
+
console.error(result.error);
|
|
193
|
+
if (result.available) {
|
|
194
|
+
console.error(`Available: ${result.available.join(', ')}`);
|
|
195
|
+
}
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
return result.filter;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ─── Commands ──────────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
program
|
|
204
|
+
.name('notion')
|
|
205
|
+
.description('A powerful CLI for the Notion API — query databases, manage pages, and automate your workspace from the terminal.')
|
|
206
|
+
.version('1.0.0')
|
|
207
|
+
.option('--json', 'Output raw JSON instead of formatted tables');
|
|
208
|
+
|
|
209
|
+
// ─── init ──────────────────────────────────────────────────────────────────────
|
|
210
|
+
program
|
|
211
|
+
.command('init')
|
|
212
|
+
.description('Initialize notioncli with your API key and discover databases')
|
|
213
|
+
.option('--key <api-key>', 'Notion integration API key (starts with ntn_)')
|
|
214
|
+
.action(async (opts) => {
|
|
215
|
+
const config = loadConfig();
|
|
216
|
+
const apiKey = opts.key || process.env.NOTION_API_KEY;
|
|
217
|
+
|
|
218
|
+
if (!apiKey) {
|
|
219
|
+
console.error('Error: Provide an API key with --key or set NOTION_API_KEY env var.');
|
|
220
|
+
console.error('');
|
|
221
|
+
console.error('To create an integration:');
|
|
222
|
+
console.error(' 1. Go to https://www.notion.so/profile/integrations');
|
|
223
|
+
console.error(' 2. Click "New integration"');
|
|
224
|
+
console.error(' 3. Copy the API key (starts with ntn_)');
|
|
225
|
+
console.error(' 4. Share your databases with the integration');
|
|
226
|
+
console.error('');
|
|
227
|
+
console.error('Then run: notion init --key ntn_your_api_key');
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
config.apiKey = apiKey;
|
|
232
|
+
saveConfig(config);
|
|
233
|
+
console.log(`✅ API key saved to ${CONFIG_PATH}`);
|
|
234
|
+
console.log('');
|
|
235
|
+
|
|
236
|
+
// Discover databases
|
|
237
|
+
const notion = new Client({ auth: apiKey });
|
|
238
|
+
try {
|
|
239
|
+
const res = await notion.search({
|
|
240
|
+
filter: { value: 'data_source', property: 'object' },
|
|
241
|
+
page_size: 100,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
if (res.results.length === 0) {
|
|
245
|
+
console.log('No databases found. Make sure you\'ve shared databases with your integration.');
|
|
246
|
+
console.log('In Notion: open a database → ••• menu → Connections → Add your integration');
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!config.aliases) config.aliases = {};
|
|
251
|
+
|
|
252
|
+
console.log(`Found ${res.results.length} database${res.results.length !== 1 ? 's' : ''}:\n`);
|
|
253
|
+
|
|
254
|
+
const added = [];
|
|
255
|
+
for (const db of res.results) {
|
|
256
|
+
const title = richTextToPlain(db.title) || '';
|
|
257
|
+
const dsId = db.id;
|
|
258
|
+
const dbId = db.database_id || dsId;
|
|
259
|
+
|
|
260
|
+
// Auto-generate a slug from the title
|
|
261
|
+
let slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 30);
|
|
262
|
+
if (!slug) slug = `db-${dsId.slice(0, 8)}`;
|
|
263
|
+
|
|
264
|
+
// Avoid collisions — append a number if needed
|
|
265
|
+
let finalSlug = slug;
|
|
266
|
+
let counter = 2;
|
|
267
|
+
while (config.aliases[finalSlug] && config.aliases[finalSlug].data_source_id !== dsId) {
|
|
268
|
+
finalSlug = `${slug}-${counter}`;
|
|
269
|
+
counter++;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
config.aliases[finalSlug] = {
|
|
273
|
+
database_id: dbId,
|
|
274
|
+
data_source_id: dsId,
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
console.log(` ✅ ${finalSlug.padEnd(25)} → ${title || '(untitled)'}`);
|
|
278
|
+
added.push(finalSlug);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
saveConfig(config);
|
|
282
|
+
console.log('');
|
|
283
|
+
console.log(`${added.length} alias${added.length !== 1 ? 'es' : ''} saved automatically.`);
|
|
284
|
+
console.log('');
|
|
285
|
+
console.log('Ready! Try:');
|
|
286
|
+
if (added.length > 0) {
|
|
287
|
+
console.log(` notion query ${added[0]}`);
|
|
288
|
+
console.log(` notion add ${added[0]} --prop "Name=Hello World"`);
|
|
289
|
+
}
|
|
290
|
+
console.log('');
|
|
291
|
+
console.log('Manage aliases:');
|
|
292
|
+
console.log(' notion alias list — see all aliases');
|
|
293
|
+
console.log(' notion alias rename <old> <new> — rename an alias');
|
|
294
|
+
console.log(' notion alias remove <name> — remove an alias');
|
|
295
|
+
} catch (err) {
|
|
296
|
+
console.error(`Failed to discover databases: ${err.message}`);
|
|
297
|
+
console.error('Your API key was saved. You can add databases manually with: notion alias add <name> <id>');
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// ─── alias ─────────────────────────────────────────────────────────────────────
|
|
302
|
+
const alias = program
|
|
303
|
+
.command('alias')
|
|
304
|
+
.description('Manage database aliases for quick access');
|
|
305
|
+
|
|
306
|
+
alias
|
|
307
|
+
.command('add <name> <database-id>')
|
|
308
|
+
.description('Add a database alias (auto-discovers data_source_id)')
|
|
309
|
+
.action(async (name, databaseId) => {
|
|
310
|
+
const config = loadConfig();
|
|
311
|
+
if (!config.aliases) config.aliases = {};
|
|
312
|
+
|
|
313
|
+
// Try to discover the data_source_id by searching for this database
|
|
314
|
+
const notion = getNotion();
|
|
315
|
+
let dataSourceId = databaseId;
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
const res = await notion.search({
|
|
319
|
+
filter: { value: 'data_source', property: 'object' },
|
|
320
|
+
page_size: 100,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const match = res.results.find(db => {
|
|
324
|
+
// Match by data_source_id or database_id
|
|
325
|
+
return db.id === databaseId ||
|
|
326
|
+
db.id.replace(/-/g, '') === databaseId.replace(/-/g, '');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
if (match) {
|
|
330
|
+
dataSourceId = match.id;
|
|
331
|
+
// The database_id might differ from data_source_id
|
|
332
|
+
const dbId = match.database_id || databaseId;
|
|
333
|
+
config.aliases[name] = {
|
|
334
|
+
database_id: dbId,
|
|
335
|
+
data_source_id: dataSourceId,
|
|
336
|
+
};
|
|
337
|
+
const title = richTextToPlain(match.title) || '(untitled)';
|
|
338
|
+
console.log(`✅ Added alias "${name}" → ${title}`);
|
|
339
|
+
console.log(` database_id: ${dbId}`);
|
|
340
|
+
console.log(` data_source_id: ${dataSourceId}`);
|
|
341
|
+
} else {
|
|
342
|
+
// Couldn't find via search — use the ID for both
|
|
343
|
+
config.aliases[name] = {
|
|
344
|
+
database_id: databaseId,
|
|
345
|
+
data_source_id: databaseId,
|
|
346
|
+
};
|
|
347
|
+
console.log(`✅ Added alias "${name}" → ${databaseId}`);
|
|
348
|
+
console.log(' (Could not auto-discover data_source_id — using same ID for both)');
|
|
349
|
+
}
|
|
350
|
+
} catch (err) {
|
|
351
|
+
// Fallback: use same ID for both
|
|
352
|
+
config.aliases[name] = {
|
|
353
|
+
database_id: databaseId,
|
|
354
|
+
data_source_id: databaseId,
|
|
355
|
+
};
|
|
356
|
+
console.log(`✅ Added alias "${name}" → ${databaseId}`);
|
|
357
|
+
console.log(` (Auto-discovery failed: ${err.message})`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
saveConfig(config);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
alias
|
|
364
|
+
.command('list')
|
|
365
|
+
.description('Show all configured database aliases')
|
|
366
|
+
.action(() => {
|
|
367
|
+
const config = loadConfig();
|
|
368
|
+
const aliases = config.aliases || {};
|
|
369
|
+
const names = Object.keys(aliases);
|
|
370
|
+
|
|
371
|
+
if (names.length === 0) {
|
|
372
|
+
console.log('No aliases configured.');
|
|
373
|
+
console.log('Add one with: notion alias add <name> <database-id>');
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const rows = names.map(name => ({
|
|
378
|
+
alias: name,
|
|
379
|
+
database_id: aliases[name].database_id,
|
|
380
|
+
data_source_id: aliases[name].data_source_id,
|
|
381
|
+
}));
|
|
382
|
+
printTable(rows, ['alias', 'database_id', 'data_source_id']);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
alias
|
|
386
|
+
.command('remove <name>')
|
|
387
|
+
.description('Remove a database alias')
|
|
388
|
+
.action((name) => {
|
|
389
|
+
const config = loadConfig();
|
|
390
|
+
if (!config.aliases || !config.aliases[name]) {
|
|
391
|
+
console.error(`Alias "${name}" not found.`);
|
|
392
|
+
const names = config.aliases ? Object.keys(config.aliases) : [];
|
|
393
|
+
if (names.length > 0) {
|
|
394
|
+
console.error(`Available: ${names.join(', ')}`);
|
|
395
|
+
}
|
|
396
|
+
process.exit(1);
|
|
397
|
+
}
|
|
398
|
+
delete config.aliases[name];
|
|
399
|
+
saveConfig(config);
|
|
400
|
+
console.log(`✅ Removed alias "${name}"`);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
alias
|
|
404
|
+
.command('rename <old-name> <new-name>')
|
|
405
|
+
.description('Rename a database alias')
|
|
406
|
+
.action((oldName, newName) => {
|
|
407
|
+
const config = loadConfig();
|
|
408
|
+
if (!config.aliases || !config.aliases[oldName]) {
|
|
409
|
+
console.error(`Alias "${oldName}" not found.`);
|
|
410
|
+
const names = config.aliases ? Object.keys(config.aliases) : [];
|
|
411
|
+
if (names.length > 0) {
|
|
412
|
+
console.error(`Available: ${names.join(', ')}`);
|
|
413
|
+
}
|
|
414
|
+
process.exit(1);
|
|
415
|
+
}
|
|
416
|
+
if (config.aliases[newName]) {
|
|
417
|
+
console.error(`Alias "${newName}" already exists. Remove it first or pick a different name.`);
|
|
418
|
+
process.exit(1);
|
|
419
|
+
}
|
|
420
|
+
config.aliases[newName] = config.aliases[oldName];
|
|
421
|
+
delete config.aliases[oldName];
|
|
422
|
+
saveConfig(config);
|
|
423
|
+
console.log(`✅ Renamed "${oldName}" → "${newName}"`);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// ─── search ────────────────────────────────────────────────────────────────────
|
|
427
|
+
program
|
|
428
|
+
.command('search <query>')
|
|
429
|
+
.description('Search across all pages and databases shared with your integration')
|
|
430
|
+
.action(async (query, opts, cmd) => {
|
|
431
|
+
try {
|
|
432
|
+
const notion = getNotion();
|
|
433
|
+
const res = await notion.search({ query, page_size: 20 });
|
|
434
|
+
if (getGlobalJson(cmd)) {
|
|
435
|
+
console.log(JSON.stringify(res, null, 2));
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const rows = res.results.map(r => {
|
|
439
|
+
let title = '';
|
|
440
|
+
if (r.object === 'data_source' || r.object === 'database') {
|
|
441
|
+
title = richTextToPlain(r.title);
|
|
442
|
+
} else if (r.properties) {
|
|
443
|
+
for (const [, prop] of Object.entries(r.properties)) {
|
|
444
|
+
if (prop.type === 'title') {
|
|
445
|
+
title = propValue(prop);
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return {
|
|
451
|
+
id: r.id,
|
|
452
|
+
type: r.object,
|
|
453
|
+
title: title || '(untitled)',
|
|
454
|
+
url: r.url || '',
|
|
455
|
+
};
|
|
456
|
+
});
|
|
457
|
+
printTable(rows, ['id', 'type', 'title', 'url']);
|
|
458
|
+
} catch (err) {
|
|
459
|
+
console.error('Search failed:', err.message);
|
|
460
|
+
process.exit(1);
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
// ─── query ─────────────────────────────────────────────────────────────────────
|
|
465
|
+
program
|
|
466
|
+
.command('query <database>')
|
|
467
|
+
.description('Query a database by alias or ID (e.g. notion query projects --filter Status=Active)')
|
|
468
|
+
.option('--filter <key=value>', 'Filter by property (e.g. Status=Active)')
|
|
469
|
+
.option('--sort <key:direction>', 'Sort by property (e.g. Date:desc)')
|
|
470
|
+
.option('--limit <n>', 'Max results (default: 100, max: 100)', '100')
|
|
471
|
+
.option('--output <format>', 'Output format: table, csv, json, yaml (default: table)')
|
|
472
|
+
.action(async (db, opts, cmd) => {
|
|
473
|
+
try {
|
|
474
|
+
const notion = getNotion();
|
|
475
|
+
const dbIds = resolveDb(db);
|
|
476
|
+
const params = {
|
|
477
|
+
data_source_id: dbIds.data_source_id,
|
|
478
|
+
page_size: Math.min(parseInt(opts.limit), 100),
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
if (opts.filter) {
|
|
482
|
+
params.filter = await buildFilter(dbIds, opts.filter);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (opts.sort) {
|
|
486
|
+
const [key, dir] = opts.sort.split(':');
|
|
487
|
+
const schema = await getDbSchema(dbIds);
|
|
488
|
+
const entry = schema[key.toLowerCase()];
|
|
489
|
+
if (!entry) {
|
|
490
|
+
console.error(`Sort property "${key}" not found.`);
|
|
491
|
+
console.error(`Available: ${Object.values(schema).map(s => s.name).join(', ')}`);
|
|
492
|
+
process.exit(1);
|
|
493
|
+
}
|
|
494
|
+
params.sorts = [{ property: entry.name, direction: dir === 'desc' ? 'descending' : 'ascending' }];
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const res = await notion.dataSources.query(params);
|
|
498
|
+
|
|
499
|
+
// Determine output format: --output takes precedence, --json is shorthand
|
|
500
|
+
const format = opts.output || (getGlobalJson(cmd) ? 'json' : 'table');
|
|
501
|
+
|
|
502
|
+
if (format === 'json') {
|
|
503
|
+
console.log(JSON.stringify(res, null, 2));
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const rows = pagesToRows(res.results);
|
|
508
|
+
if (rows.length === 0) {
|
|
509
|
+
console.log('(no results)');
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
const columns = Object.keys(rows[0]);
|
|
513
|
+
outputFormatted(rows, columns, format);
|
|
514
|
+
} catch (err) {
|
|
515
|
+
console.error('Query failed:', err.message);
|
|
516
|
+
process.exit(1);
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// ─── add ───────────────────────────────────────────────────────────────────────
|
|
521
|
+
program
|
|
522
|
+
.command('add <database>')
|
|
523
|
+
.description('Add a new page to a database (e.g. notion add projects --prop "Name=New Task")')
|
|
524
|
+
.option('--prop <key=value...>', 'Property value — repeatable (e.g. --prop "Name=Hello" --prop "Status=Todo")', (v, prev) => prev.concat([v]), [])
|
|
525
|
+
.action(async (db, opts, cmd) => {
|
|
526
|
+
try {
|
|
527
|
+
const notion = getNotion();
|
|
528
|
+
const dbIds = resolveDb(db);
|
|
529
|
+
const properties = await buildProperties(dbIds, opts.prop);
|
|
530
|
+
const res = await notion.pages.create({
|
|
531
|
+
parent: { type: 'data_source_id', data_source_id: dbIds.data_source_id },
|
|
532
|
+
properties,
|
|
533
|
+
});
|
|
534
|
+
if (getGlobalJson(cmd)) {
|
|
535
|
+
console.log(JSON.stringify(res, null, 2));
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
console.log(`✅ Created page: ${res.id}`);
|
|
539
|
+
console.log(` URL: ${res.url}`);
|
|
540
|
+
} catch (err) {
|
|
541
|
+
console.error('Add failed:', err.message);
|
|
542
|
+
process.exit(1);
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
// ─── update ────────────────────────────────────────────────────────────────────
|
|
547
|
+
program
|
|
548
|
+
.command('update <page-or-alias>')
|
|
549
|
+
.description('Update a page\'s properties by ID or alias + filter')
|
|
550
|
+
.option('--filter <key=value>', 'Filter to find the page (required when using an alias)')
|
|
551
|
+
.option('--prop <key=value...>', 'Property value — repeatable', (v, prev) => prev.concat([v]), [])
|
|
552
|
+
.action(async (target, opts, cmd) => {
|
|
553
|
+
try {
|
|
554
|
+
const notion = getNotion();
|
|
555
|
+
const { pageId, dbIds: resolvedDbIds } = await resolvePageId(target, opts.filter);
|
|
556
|
+
let dbIds = resolvedDbIds;
|
|
557
|
+
if (!dbIds) {
|
|
558
|
+
const page = await notion.pages.retrieve({ page_id: pageId });
|
|
559
|
+
const dsId = page.parent?.data_source_id;
|
|
560
|
+
if (!dsId) {
|
|
561
|
+
console.error('Page is not in a database — cannot auto-detect property types.');
|
|
562
|
+
process.exit(1);
|
|
563
|
+
}
|
|
564
|
+
dbIds = { data_source_id: dsId, database_id: page.parent?.database_id || dsId };
|
|
565
|
+
}
|
|
566
|
+
const properties = await buildProperties(dbIds, opts.prop);
|
|
567
|
+
const res = await notion.pages.update({ page_id: pageId, properties });
|
|
568
|
+
if (getGlobalJson(cmd)) {
|
|
569
|
+
console.log(JSON.stringify(res, null, 2));
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
console.log(`✅ Updated page: ${res.id}`);
|
|
573
|
+
} catch (err) {
|
|
574
|
+
console.error('Update failed:', err.message);
|
|
575
|
+
process.exit(1);
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
// ─── delete (archive) ──────────────────────────────────────────────────────────
|
|
580
|
+
program
|
|
581
|
+
.command('delete <page-or-alias>')
|
|
582
|
+
.description('Delete (archive) a page by ID or alias + filter')
|
|
583
|
+
.option('--filter <key=value>', 'Filter to find the page (required when using an alias)')
|
|
584
|
+
.action(async (target, opts, cmd) => {
|
|
585
|
+
try {
|
|
586
|
+
const notion = getNotion();
|
|
587
|
+
const { pageId } = await resolvePageId(target, opts.filter);
|
|
588
|
+
const res = await notion.pages.update({ page_id: pageId, archived: true });
|
|
589
|
+
if (getGlobalJson(cmd)) {
|
|
590
|
+
console.log(JSON.stringify(res, null, 2));
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
console.log(`🗑️ Archived page: ${res.id}`);
|
|
594
|
+
console.log(' (Restore it from the trash in Notion if needed)');
|
|
595
|
+
} catch (err) {
|
|
596
|
+
console.error('Delete failed:', err.message);
|
|
597
|
+
process.exit(1);
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
// ─── get ───────────────────────────────────────────────────────────────────────
|
|
602
|
+
program
|
|
603
|
+
.command('get <page-or-alias>')
|
|
604
|
+
.description('Get a page\'s properties by ID or alias + filter')
|
|
605
|
+
.option('--filter <key=value>', 'Filter to find the page (required when using an alias)')
|
|
606
|
+
.action(async (target, opts, cmd) => {
|
|
607
|
+
try {
|
|
608
|
+
const notion = getNotion();
|
|
609
|
+
const { pageId } = await resolvePageId(target, opts.filter);
|
|
610
|
+
const page = await notion.pages.retrieve({ page_id: pageId });
|
|
611
|
+
if (getGlobalJson(cmd)) {
|
|
612
|
+
console.log(JSON.stringify(page, null, 2));
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
console.log(`Page: ${page.id}`);
|
|
616
|
+
console.log(`URL: ${page.url}`);
|
|
617
|
+
console.log(`Created: ${page.created_time}`);
|
|
618
|
+
console.log(`Updated: ${page.last_edited_time}`);
|
|
619
|
+
console.log('');
|
|
620
|
+
console.log('Properties:');
|
|
621
|
+
for (const [name, prop] of Object.entries(page.properties)) {
|
|
622
|
+
console.log(` ${name}: ${propValue(prop)}`);
|
|
623
|
+
}
|
|
624
|
+
} catch (err) {
|
|
625
|
+
console.error('Get failed:', err.message);
|
|
626
|
+
process.exit(1);
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
// ─── blocks ────────────────────────────────────────────────────────────────────
|
|
631
|
+
program
|
|
632
|
+
.command('blocks <page-or-alias>')
|
|
633
|
+
.description('Get page content as rendered blocks by ID or alias + filter')
|
|
634
|
+
.option('--filter <key=value>', 'Filter to find the page (required when using an alias)')
|
|
635
|
+
.action(async (target, opts, cmd) => {
|
|
636
|
+
try {
|
|
637
|
+
const notion = getNotion();
|
|
638
|
+
const { pageId } = await resolvePageId(target, opts.filter);
|
|
639
|
+
const res = await notion.blocks.children.list({ block_id: pageId, page_size: 100 });
|
|
640
|
+
if (getGlobalJson(cmd)) {
|
|
641
|
+
console.log(JSON.stringify(res, null, 2));
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
if (res.results.length === 0) {
|
|
645
|
+
console.log('(no blocks)');
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
for (const block of res.results) {
|
|
649
|
+
const type = block.type;
|
|
650
|
+
const content = block[type];
|
|
651
|
+
let text = '';
|
|
652
|
+
if (content?.rich_text) {
|
|
653
|
+
text = richTextToPlain(content.rich_text);
|
|
654
|
+
} else if (content?.text) {
|
|
655
|
+
text = richTextToPlain(content.text);
|
|
656
|
+
}
|
|
657
|
+
const prefix = type === 'heading_1' ? '# '
|
|
658
|
+
: type === 'heading_2' ? '## '
|
|
659
|
+
: type === 'heading_3' ? '### '
|
|
660
|
+
: type === 'bulleted_list_item' ? '• '
|
|
661
|
+
: type === 'numbered_list_item' ? ' 1. '
|
|
662
|
+
: type === 'to_do' ? (content?.checked ? '☑ ' : '☐ ')
|
|
663
|
+
: type === 'code' ? '```\n'
|
|
664
|
+
: '';
|
|
665
|
+
const suffix = type === 'code' ? '\n```' : '';
|
|
666
|
+
console.log(`${prefix}${text}${suffix}`);
|
|
667
|
+
}
|
|
668
|
+
} catch (err) {
|
|
669
|
+
console.error('Blocks failed:', err.message);
|
|
670
|
+
process.exit(1);
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
// ─── dbs ───────────────────────────────────────────────────────────────────────
|
|
675
|
+
program
|
|
676
|
+
.command('dbs')
|
|
677
|
+
.description('List all databases shared with your integration')
|
|
678
|
+
.action(async (opts, cmd) => {
|
|
679
|
+
try {
|
|
680
|
+
const notion = getNotion();
|
|
681
|
+
const res = await notion.search({
|
|
682
|
+
filter: { value: 'data_source', property: 'object' },
|
|
683
|
+
page_size: 100,
|
|
684
|
+
});
|
|
685
|
+
if (getGlobalJson(cmd)) {
|
|
686
|
+
console.log(JSON.stringify(res, null, 2));
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
const rows = res.results.map(db => ({
|
|
690
|
+
id: db.id,
|
|
691
|
+
title: richTextToPlain(db.title),
|
|
692
|
+
url: db.url || '',
|
|
693
|
+
}));
|
|
694
|
+
if (rows.length === 0) {
|
|
695
|
+
console.log('No databases found. Make sure you\'ve shared databases with your integration.');
|
|
696
|
+
console.log('In Notion: open a database → ••• menu → Connections → Add your integration');
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
printTable(rows, ['id', 'title', 'url']);
|
|
700
|
+
} catch (err) {
|
|
701
|
+
console.error('List databases failed:', err.message);
|
|
702
|
+
process.exit(1);
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
// ─── users ─────────────────────────────────────────────────────────────────────
|
|
707
|
+
program
|
|
708
|
+
.command('users')
|
|
709
|
+
.description('List all users in the workspace')
|
|
710
|
+
.action(async (opts, cmd) => {
|
|
711
|
+
try {
|
|
712
|
+
const notion = getNotion();
|
|
713
|
+
const res = await notion.users.list({});
|
|
714
|
+
if (getGlobalJson(cmd)) {
|
|
715
|
+
console.log(JSON.stringify(res, null, 2));
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const rows = res.results.map(u => ({
|
|
719
|
+
id: u.id,
|
|
720
|
+
name: u.name || '',
|
|
721
|
+
type: u.type || '',
|
|
722
|
+
email: (u.person && u.person.email) || '',
|
|
723
|
+
}));
|
|
724
|
+
printTable(rows, ['id', 'name', 'type', 'email']);
|
|
725
|
+
} catch (err) {
|
|
726
|
+
console.error('Users failed:', err.message);
|
|
727
|
+
process.exit(1);
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
// ─── user ──────────────────────────────────────────────────────────────────────
|
|
732
|
+
program
|
|
733
|
+
.command('user <user-id>')
|
|
734
|
+
.description('Get user details')
|
|
735
|
+
.action(async (userId, opts, cmd) => {
|
|
736
|
+
try {
|
|
737
|
+
const notion = getNotion();
|
|
738
|
+
const user = await notion.users.retrieve({ user_id: userId });
|
|
739
|
+
if (getGlobalJson(cmd)) {
|
|
740
|
+
console.log(JSON.stringify(user, null, 2));
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
console.log(`User: ${user.id}`);
|
|
744
|
+
console.log(`Name: ${user.name || '(unnamed)'}`);
|
|
745
|
+
console.log(`Type: ${user.type || ''}`);
|
|
746
|
+
if (user.person && user.person.email) {
|
|
747
|
+
console.log(`Email: ${user.person.email}`);
|
|
748
|
+
}
|
|
749
|
+
if (user.avatar_url) {
|
|
750
|
+
console.log(`Avatar: ${user.avatar_url}`);
|
|
751
|
+
}
|
|
752
|
+
if (user.bot) {
|
|
753
|
+
console.log(`Bot Owner: ${JSON.stringify(user.bot.owner || {})}`);
|
|
754
|
+
}
|
|
755
|
+
} catch (err) {
|
|
756
|
+
console.error('User failed:', err.message);
|
|
757
|
+
process.exit(1);
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
// ─── comments ──────────────────────────────────────────────────────────────────
|
|
762
|
+
program
|
|
763
|
+
.command('comments <page-or-alias>')
|
|
764
|
+
.description('List comments on a page by ID or alias + filter')
|
|
765
|
+
.option('--filter <key=value>', 'Filter to find the page (required when using an alias)')
|
|
766
|
+
.action(async (target, opts, cmd) => {
|
|
767
|
+
try {
|
|
768
|
+
const notion = getNotion();
|
|
769
|
+
const { pageId } = await resolvePageId(target, opts.filter);
|
|
770
|
+
const res = await notion.comments.list({ block_id: pageId });
|
|
771
|
+
if (getGlobalJson(cmd)) {
|
|
772
|
+
console.log(JSON.stringify(res, null, 2));
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
if (res.results.length === 0) {
|
|
776
|
+
console.log('(no comments)');
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
const rows = res.results.map(c => ({
|
|
780
|
+
id: c.id,
|
|
781
|
+
text: richTextToPlain(c.rich_text),
|
|
782
|
+
created: c.created_time || '',
|
|
783
|
+
author: c.created_by?.name || c.created_by?.id || '',
|
|
784
|
+
}));
|
|
785
|
+
printTable(rows, ['id', 'text', 'created', 'author']);
|
|
786
|
+
} catch (err) {
|
|
787
|
+
console.error('Comments failed:', err.message);
|
|
788
|
+
process.exit(1);
|
|
789
|
+
}
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
// ─── comment ───────────────────────────────────────────────────────────────────
|
|
793
|
+
program
|
|
794
|
+
.command('comment <page-or-alias> <text>')
|
|
795
|
+
.description('Add a comment to a page by ID or alias + filter')
|
|
796
|
+
.option('--filter <key=value>', 'Filter to find the page (required when using an alias)')
|
|
797
|
+
.action(async (target, text, opts, cmd) => {
|
|
798
|
+
try {
|
|
799
|
+
const notion = getNotion();
|
|
800
|
+
const { pageId } = await resolvePageId(target, opts.filter);
|
|
801
|
+
const res = await notion.comments.create({
|
|
802
|
+
parent: { page_id: pageId },
|
|
803
|
+
rich_text: [{ text: { content: text } }],
|
|
804
|
+
});
|
|
805
|
+
if (getGlobalJson(cmd)) {
|
|
806
|
+
console.log(JSON.stringify(res, null, 2));
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
console.log(`✅ Comment added: ${res.id}`);
|
|
810
|
+
} catch (err) {
|
|
811
|
+
console.error('Comment failed:', err.message);
|
|
812
|
+
process.exit(1);
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
// ─── append ────────────────────────────────────────────────────────────────────
|
|
817
|
+
program
|
|
818
|
+
.command('append <page-or-alias> <text>')
|
|
819
|
+
.description('Append a text block to a page by ID or alias + filter')
|
|
820
|
+
.option('--filter <key=value>', 'Filter to find the page (required when using an alias)')
|
|
821
|
+
.action(async (target, text, opts, cmd) => {
|
|
822
|
+
try {
|
|
823
|
+
const notion = getNotion();
|
|
824
|
+
const { pageId } = await resolvePageId(target, opts.filter);
|
|
825
|
+
const res = await notion.blocks.children.append({
|
|
826
|
+
block_id: pageId,
|
|
827
|
+
children: [{
|
|
828
|
+
object: 'block',
|
|
829
|
+
type: 'paragraph',
|
|
830
|
+
paragraph: {
|
|
831
|
+
rich_text: [{ text: { content: text } }],
|
|
832
|
+
},
|
|
833
|
+
}],
|
|
834
|
+
});
|
|
835
|
+
if (getGlobalJson(cmd)) {
|
|
836
|
+
console.log(JSON.stringify(res, null, 2));
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
console.log(`✅ Appended text block to page ${pageId}`);
|
|
840
|
+
} catch (err) {
|
|
841
|
+
console.error('Append failed:', err.message);
|
|
842
|
+
process.exit(1);
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
// ─── Run ───────────────────────────────────────────────────────────────────────
|
|
847
|
+
program.parse();
|