@jordancoin/notioncli 1.2.1 → 1.3.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/.github/workflows/publish.yml +74 -0
- package/README.md +118 -420
- package/TECHNICAL.md +185 -0
- package/bin/notion.js +18 -1611
- package/commands/blocks.js +142 -0
- package/commands/comments.js +59 -0
- package/commands/config.js +328 -0
- package/commands/crud.js +196 -0
- package/commands/database.js +241 -0
- package/commands/import-export.js +162 -0
- package/commands/pages.js +203 -0
- package/commands/query.js +73 -0
- package/commands/search.js +45 -0
- package/commands/upload.js +84 -0
- package/commands/users.js +73 -0
- package/lib/config.js +68 -0
- package/lib/context.js +359 -0
- package/lib/filters.js +257 -0
- package/lib/format.js +258 -0
- package/lib/helpers.js +13 -334
- package/lib/markdown.js +488 -0
- package/lib/paginate.js +74 -0
- package/lib/retry.js +76 -0
- package/package.json +1 -1
- package/skill/SKILL.md +51 -10
- package/skill/marketplace.json +2 -2
- package/test/unit.test.js +662 -0
- package/test/debug-parent.js +0 -32
- package/test/live-relations-test.js +0 -309
package/commands/crud.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
module.exports = {
|
|
5
|
+
register(program, ctx) {
|
|
6
|
+
const {
|
|
7
|
+
getNotion,
|
|
8
|
+
resolveDb,
|
|
9
|
+
resolvePageId,
|
|
10
|
+
getDbSchema,
|
|
11
|
+
buildProperties,
|
|
12
|
+
jsonOutput,
|
|
13
|
+
richTextToPlain,
|
|
14
|
+
propValue,
|
|
15
|
+
extractDynamicProps,
|
|
16
|
+
markdownToBlocks,
|
|
17
|
+
runCommand,
|
|
18
|
+
} = ctx;
|
|
19
|
+
|
|
20
|
+
// ─── add ──────────────────────────────────────────────────────────────────
|
|
21
|
+
program
|
|
22
|
+
.command('add <database>')
|
|
23
|
+
.description('Add a new page to a database (e.g. notion add tasks --name "Ship it" --status "Done")')
|
|
24
|
+
.option('--prop <key=value...>', 'Property value — repeatable (e.g. --prop "Name=Hello")', (v, prev) => prev.concat([v]), [])
|
|
25
|
+
.option('--from <file>', 'Import content from a .md file as page body')
|
|
26
|
+
.allowUnknownOption()
|
|
27
|
+
.allowExcessArguments()
|
|
28
|
+
.action(async (db, opts, cmd) => runCommand('Add', async () => {
|
|
29
|
+
const notion = getNotion();
|
|
30
|
+
const dbIds = resolveDb(db);
|
|
31
|
+
|
|
32
|
+
// Merge --prop flags with dynamic property flags (--name, --status, etc.)
|
|
33
|
+
const schema = await getDbSchema(dbIds);
|
|
34
|
+
const knownFlags = ['prop', 'from', 'json', 'workspace', 'w', 'filter', 'limit', 'sort', 'output'];
|
|
35
|
+
const dynamicProps = extractDynamicProps(process.argv, knownFlags, schema);
|
|
36
|
+
const allProps = [...(opts.prop || []), ...dynamicProps];
|
|
37
|
+
|
|
38
|
+
if (allProps.length === 0) {
|
|
39
|
+
console.error('No properties provided. Use property flags or --prop:');
|
|
40
|
+
console.error(` notion add ${db} --name "My Page" --status "Active"`);
|
|
41
|
+
console.error(` notion add ${db} --prop "Name=My Page" --prop "Status=Active"`);
|
|
42
|
+
const propNames = Object.values(schema).map(s => `--${s.name.toLowerCase().replace(/\s+/g, '-')}`);
|
|
43
|
+
console.error(`\nAvailable: ${propNames.join(', ')}`);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const properties = await buildProperties(dbIds, allProps);
|
|
48
|
+
const res = await notion.pages.create({
|
|
49
|
+
parent: { type: 'data_source_id', data_source_id: dbIds.data_source_id },
|
|
50
|
+
properties,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// If --from file, parse and append blocks
|
|
54
|
+
if (opts.from) {
|
|
55
|
+
const filePath = path.resolve(opts.from);
|
|
56
|
+
if (!fs.existsSync(filePath)) {
|
|
57
|
+
console.error(`File not found: ${filePath}`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
61
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
62
|
+
|
|
63
|
+
let blocks;
|
|
64
|
+
if (ext === '.md' || ext === '.markdown') {
|
|
65
|
+
blocks = markdownToBlocks(content);
|
|
66
|
+
} else {
|
|
67
|
+
// Treat as plain text
|
|
68
|
+
blocks = [{ object: 'block', type: 'paragraph', paragraph: { rich_text: [{ type: 'text', text: { content } }] } }];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Notion API limits to 100 blocks per append call
|
|
72
|
+
for (let i = 0; i < blocks.length; i += 100) {
|
|
73
|
+
await notion.blocks.children.append({
|
|
74
|
+
block_id: res.id,
|
|
75
|
+
children: blocks.slice(i, i + 100),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (jsonOutput(cmd, res)) return;
|
|
81
|
+
console.log(`✅ Created page: ${res.id}`);
|
|
82
|
+
console.log(` URL: ${res.url}`);
|
|
83
|
+
if (opts.from) console.log(` Content imported from: ${opts.from}`);
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
// ─── update ────────────────────────────────────────────────────────────────
|
|
87
|
+
program
|
|
88
|
+
.command('update <page-or-alias>')
|
|
89
|
+
.description('Update a page\'s properties by ID or alias + filter (e.g. notion update tasks --filter "Name=Ship it" --status "Done")')
|
|
90
|
+
.option('--filter <key=value...>', 'Filter to find the page — repeatable for AND (required with alias)', (v, prev) => prev.concat([v]), [])
|
|
91
|
+
.option('--prop <key=value...>', 'Property value — repeatable', (v, prev) => prev.concat([v]), [])
|
|
92
|
+
.allowUnknownOption()
|
|
93
|
+
.allowExcessArguments()
|
|
94
|
+
.action(async (target, opts, cmd) => runCommand('Update', async () => {
|
|
95
|
+
const notion = getNotion();
|
|
96
|
+
const { pageId, dbIds: resolvedDbIds } = await resolvePageId(target, opts.filter);
|
|
97
|
+
let dbIds = resolvedDbIds;
|
|
98
|
+
if (!dbIds) {
|
|
99
|
+
const page = await notion.pages.retrieve({ page_id: pageId });
|
|
100
|
+
const dsId = page.parent?.data_source_id;
|
|
101
|
+
if (!dsId) {
|
|
102
|
+
console.error('Page is not in a database — cannot auto-detect property types.');
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
dbIds = { data_source_id: dsId, database_id: page.parent?.database_id || dsId };
|
|
106
|
+
}
|
|
107
|
+
// Merge --prop flags with dynamic property flags
|
|
108
|
+
const schema = await getDbSchema(dbIds);
|
|
109
|
+
const knownFlags = ['prop', 'filter', 'json', 'workspace', 'w', 'limit', 'sort', 'output'];
|
|
110
|
+
const dynamicProps = extractDynamicProps(process.argv, knownFlags, schema);
|
|
111
|
+
const allProps = [...(opts.prop || []), ...dynamicProps];
|
|
112
|
+
|
|
113
|
+
if (allProps.length === 0) {
|
|
114
|
+
console.error('No properties to update. Use property flags or --prop:');
|
|
115
|
+
console.error(` notion update ${target} --filter "Name=..." --status "Done"`);
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const properties = await buildProperties(dbIds, allProps);
|
|
120
|
+
const res = await notion.pages.update({ page_id: pageId, properties });
|
|
121
|
+
if (jsonOutput(cmd, res)) return;
|
|
122
|
+
console.log(`✅ Updated page: ${res.id}`);
|
|
123
|
+
}));
|
|
124
|
+
|
|
125
|
+
// ─── delete (archive) ──────────────────────────────────────────────────────
|
|
126
|
+
program
|
|
127
|
+
.command('delete <page-or-alias>')
|
|
128
|
+
.description('Delete (archive) a page by ID or alias + filter')
|
|
129
|
+
.option('--filter <key=value>', 'Filter to find the page (required when using an alias)')
|
|
130
|
+
.action(async (target, opts, cmd) => runCommand('Delete', async () => {
|
|
131
|
+
const notion = getNotion();
|
|
132
|
+
const { pageId } = await resolvePageId(target, opts.filter);
|
|
133
|
+
const res = await notion.pages.update({ page_id: pageId, archived: true });
|
|
134
|
+
if (jsonOutput(cmd, res)) return;
|
|
135
|
+
console.log('🗑️ Archived page: ' + res.id);
|
|
136
|
+
console.log(' (Restore it from the trash in Notion if needed)');
|
|
137
|
+
}));
|
|
138
|
+
|
|
139
|
+
// ─── get ──────────────────────────────────────────────────────────────────
|
|
140
|
+
program
|
|
141
|
+
.command('get <page-or-alias>')
|
|
142
|
+
.description('Get a page\'s properties by ID or alias + filter')
|
|
143
|
+
.option('--filter <key=value>', 'Filter to find the page (required when using an alias)')
|
|
144
|
+
.action(async (target, opts, cmd) => runCommand('Get', async () => {
|
|
145
|
+
const notion = getNotion();
|
|
146
|
+
const { pageId } = await resolvePageId(target, opts.filter);
|
|
147
|
+
const page = await notion.pages.retrieve({ page_id: pageId });
|
|
148
|
+
if (jsonOutput(cmd, page)) return;
|
|
149
|
+
console.log(`Page: ${page.id}`);
|
|
150
|
+
console.log(`URL: ${page.url}`);
|
|
151
|
+
console.log(`Created: ${page.created_time}`);
|
|
152
|
+
console.log(`Updated: ${page.last_edited_time}`);
|
|
153
|
+
console.log('');
|
|
154
|
+
console.log('Properties:');
|
|
155
|
+
for (const [name, prop] of Object.entries(page.properties)) {
|
|
156
|
+
if (prop.type === 'relation') {
|
|
157
|
+
const rels = prop.relation || [];
|
|
158
|
+
if (rels.length === 0) {
|
|
159
|
+
console.log(` ${name}: (none)`);
|
|
160
|
+
} else {
|
|
161
|
+
// Resolve relation titles
|
|
162
|
+
const titles = [];
|
|
163
|
+
for (const rel of rels) {
|
|
164
|
+
try {
|
|
165
|
+
const linked = await notion.pages.retrieve({ page_id: rel.id });
|
|
166
|
+
let t = '';
|
|
167
|
+
for (const [, p] of Object.entries(linked.properties)) {
|
|
168
|
+
if (p.type === 'title') { t = propValue(p); break; }
|
|
169
|
+
}
|
|
170
|
+
titles.push(t || rel.id.slice(0, 8) + '…');
|
|
171
|
+
} catch {
|
|
172
|
+
titles.push(rel.id.slice(0, 8) + '…');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
console.log(` ${name}: ${titles.join(', ')}`);
|
|
176
|
+
}
|
|
177
|
+
} else if (prop.type === 'rollup') {
|
|
178
|
+
const r = prop.rollup;
|
|
179
|
+
if (!r) {
|
|
180
|
+
console.log(` ${name}: (empty)`);
|
|
181
|
+
} else if (r.type === 'number') {
|
|
182
|
+
console.log(` ${name}: ${r.number != null ? r.number : '(empty)'}`);
|
|
183
|
+
} else if (r.type === 'date') {
|
|
184
|
+
console.log(` ${name}: ${r.date ? r.date.start : '(empty)'}`);
|
|
185
|
+
} else if (r.type === 'array' && r.array) {
|
|
186
|
+
console.log(` ${name}: ${r.array.map(item => propValue(item)).join(', ')}`);
|
|
187
|
+
} else {
|
|
188
|
+
console.log(` ${name}: ${JSON.stringify(r)}`);
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
console.log(` ${name}: ${propValue(prop)}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}));
|
|
195
|
+
},
|
|
196
|
+
};
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
register(program, ctx) {
|
|
3
|
+
const {
|
|
4
|
+
getNotion,
|
|
5
|
+
resolveDb,
|
|
6
|
+
loadConfig,
|
|
7
|
+
saveConfig,
|
|
8
|
+
getWorkspaceName,
|
|
9
|
+
jsonOutput,
|
|
10
|
+
richTextToPlain,
|
|
11
|
+
propValue,
|
|
12
|
+
printTable,
|
|
13
|
+
paginate,
|
|
14
|
+
runCommand,
|
|
15
|
+
} = ctx;
|
|
16
|
+
|
|
17
|
+
// ─── dbs ────────────────────────────────────────────────────────────────
|
|
18
|
+
program
|
|
19
|
+
.command('dbs')
|
|
20
|
+
.description('List all databases shared with your integration')
|
|
21
|
+
.action(async (opts, cmd) => runCommand('List databases', async () => {
|
|
22
|
+
const notion = getNotion();
|
|
23
|
+
const { results, response } = await paginate(
|
|
24
|
+
({ start_cursor, page_size }) => notion.search({
|
|
25
|
+
filter: { value: 'data_source', property: 'object' },
|
|
26
|
+
start_cursor,
|
|
27
|
+
page_size,
|
|
28
|
+
}),
|
|
29
|
+
{ pageSizeLimit: 100 },
|
|
30
|
+
);
|
|
31
|
+
if (jsonOutput(cmd, response)) return;
|
|
32
|
+
const rows = results.map(db => ({
|
|
33
|
+
id: db.id,
|
|
34
|
+
title: richTextToPlain(db.title),
|
|
35
|
+
url: db.url || '',
|
|
36
|
+
}));
|
|
37
|
+
if (rows.length === 0) {
|
|
38
|
+
console.log('No databases found. Make sure you\'ve shared databases with your integration.');
|
|
39
|
+
console.log('In Notion: open a database → ••• menu → Connections → Add your integration');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
printTable(rows, ['id', 'title', 'url']);
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
// ─── templates ───────────────────────────────────────────────────────────
|
|
46
|
+
program
|
|
47
|
+
.command('templates <database>')
|
|
48
|
+
.description('List page templates available for a database')
|
|
49
|
+
.action(async (db, opts, cmd) => runCommand('Templates', async () => {
|
|
50
|
+
const notion = getNotion();
|
|
51
|
+
const dbIds = resolveDb(db);
|
|
52
|
+
const res = await notion.dataSources.listTemplates({
|
|
53
|
+
data_source_id: dbIds.data_source_id,
|
|
54
|
+
});
|
|
55
|
+
if (jsonOutput(cmd, res)) return;
|
|
56
|
+
if (!res.results || res.results.length === 0) {
|
|
57
|
+
console.log('No templates found for this database.');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const rows = res.results.map(t => {
|
|
61
|
+
let title = '';
|
|
62
|
+
if (t.properties) {
|
|
63
|
+
for (const [, prop] of Object.entries(t.properties)) {
|
|
64
|
+
if (prop.type === 'title') {
|
|
65
|
+
title = propValue(prop);
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
id: t.id,
|
|
72
|
+
title: title || '(untitled)',
|
|
73
|
+
url: t.url || '',
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
printTable(rows, ['id', 'title', 'url']);
|
|
77
|
+
}));
|
|
78
|
+
|
|
79
|
+
// ─── db-create ───────────────────────────────────────────────────────────
|
|
80
|
+
program
|
|
81
|
+
.command('db-create <parent-page-id> <title>')
|
|
82
|
+
.description('Create a new database under a page')
|
|
83
|
+
.option('--prop <name:type...>', 'Property definition — repeatable (e.g. --prop "Status:select" --prop "Priority:number")', (v, prev) => prev.concat([v]), [])
|
|
84
|
+
.option('--alias <name>', 'Auto-create an alias for the new database')
|
|
85
|
+
.action(async (parentPageId, title, opts, cmd) => runCommand('Database create', async () => {
|
|
86
|
+
const notion = getNotion();
|
|
87
|
+
|
|
88
|
+
// Build properties — always include a title property
|
|
89
|
+
const properties = {};
|
|
90
|
+
let hasTitleProp = false;
|
|
91
|
+
|
|
92
|
+
for (const kv of opts.prop) {
|
|
93
|
+
const colonIdx = kv.indexOf(':');
|
|
94
|
+
if (colonIdx === -1) {
|
|
95
|
+
console.error(`Invalid property format: ${kv} (expected name:type)`);
|
|
96
|
+
console.error('Supported types: title, rich_text, number, select, multi_select, date, checkbox, url, email, phone_number, status');
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
const name = kv.slice(0, colonIdx);
|
|
100
|
+
const type = kv.slice(colonIdx + 1).toLowerCase();
|
|
101
|
+
if (type === 'title') hasTitleProp = true;
|
|
102
|
+
properties[name] = { [type]: {} };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Ensure there's a title property
|
|
106
|
+
if (!hasTitleProp) {
|
|
107
|
+
properties['Name'] = { title: {} };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 2025 API: databases.create() only handles title property reliably.
|
|
111
|
+
// Non-title properties must be added via dataSources.update() after creation.
|
|
112
|
+
const titleProps = {};
|
|
113
|
+
const extraProps = {};
|
|
114
|
+
for (const [name, prop] of Object.entries(properties)) {
|
|
115
|
+
if (prop.title) {
|
|
116
|
+
titleProps[name] = prop;
|
|
117
|
+
} else {
|
|
118
|
+
extraProps[name] = prop;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Ensure title property exists in create call
|
|
122
|
+
if (Object.keys(titleProps).length === 0) {
|
|
123
|
+
titleProps['Name'] = { title: {} };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const res = await notion.databases.create({
|
|
127
|
+
parent: { type: 'page_id', page_id: parentPageId },
|
|
128
|
+
title: [{ text: { content: title } }],
|
|
129
|
+
properties: titleProps,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Extract correct dual IDs from response
|
|
133
|
+
const databaseId = res.id;
|
|
134
|
+
const dataSourceId = (res.data_sources && res.data_sources[0])
|
|
135
|
+
? res.data_sources[0].id
|
|
136
|
+
: res.id;
|
|
137
|
+
|
|
138
|
+
// Add non-title properties via dataSources.update()
|
|
139
|
+
if (Object.keys(extraProps).length > 0) {
|
|
140
|
+
await notion.dataSources.update({
|
|
141
|
+
data_source_id: dataSourceId,
|
|
142
|
+
properties: extraProps,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (jsonOutput(cmd, res)) return;
|
|
147
|
+
|
|
148
|
+
console.log(`✅ Created database: ${databaseId.slice(0, 8)}…`);
|
|
149
|
+
console.log(` Title: ${title}`);
|
|
150
|
+
console.log(` Properties: ${Object.keys(properties).join(', ')}`);
|
|
151
|
+
|
|
152
|
+
// Auto-create alias if requested
|
|
153
|
+
if (opts.alias) {
|
|
154
|
+
const config = loadConfig();
|
|
155
|
+
const wsName = getWorkspaceName() || config.activeWorkspace || 'default';
|
|
156
|
+
if (!config.workspaces[wsName]) config.workspaces[wsName] = { aliases: {} };
|
|
157
|
+
if (!config.workspaces[wsName].aliases) config.workspaces[wsName].aliases = {};
|
|
158
|
+
config.workspaces[wsName].aliases[opts.alias] = {
|
|
159
|
+
database_id: databaseId,
|
|
160
|
+
data_source_id: dataSourceId,
|
|
161
|
+
};
|
|
162
|
+
saveConfig(config);
|
|
163
|
+
console.log(` Alias: ${opts.alias}`);
|
|
164
|
+
}
|
|
165
|
+
}));
|
|
166
|
+
|
|
167
|
+
// ─── db-update ───────────────────────────────────────────────────────────
|
|
168
|
+
program
|
|
169
|
+
.command('db-update <database>')
|
|
170
|
+
.description('Update a database title or add properties')
|
|
171
|
+
.option('--title <text>', 'New database title')
|
|
172
|
+
.option('--add-prop <name:type...>', 'Add a property (e.g. --add-prop "Priority:number")', (v, prev) => prev.concat([v]), [])
|
|
173
|
+
.option('--remove-prop <name...>', 'Remove a property by name', (v, prev) => prev.concat([v]), [])
|
|
174
|
+
.action(async (db, opts, cmd) => runCommand('Database update', async () => {
|
|
175
|
+
const notion = getNotion();
|
|
176
|
+
const dbIds = resolveDb(db);
|
|
177
|
+
|
|
178
|
+
// 2025 API: property changes go through dataSources.update(), NOT databases.update().
|
|
179
|
+
// databases.update() silently ignores property modifications.
|
|
180
|
+
// Title changes still go through databases.update().
|
|
181
|
+
let canonicalId = dbIds.database_id;
|
|
182
|
+
const dataSourceId = dbIds.data_source_id;
|
|
183
|
+
|
|
184
|
+
// Resolve canonical database_id if both IDs are the same
|
|
185
|
+
if (canonicalId === dataSourceId) {
|
|
186
|
+
try {
|
|
187
|
+
const ds = await notion.dataSources.retrieve({ data_source_id: canonicalId });
|
|
188
|
+
if (ds.parent && ds.parent.type === 'database_id') {
|
|
189
|
+
canonicalId = ds.parent.database_id;
|
|
190
|
+
}
|
|
191
|
+
} catch (_) { /* fall through with what we have */ }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Build property changes for dataSources.update()
|
|
195
|
+
let propChanges = null;
|
|
196
|
+
if (opts.addProp.length > 0 || opts.removeProp.length > 0) {
|
|
197
|
+
propChanges = {};
|
|
198
|
+
|
|
199
|
+
for (const kv of opts.addProp) {
|
|
200
|
+
const colonIdx = kv.indexOf(':');
|
|
201
|
+
if (colonIdx === -1) {
|
|
202
|
+
console.error(`Invalid property format: ${kv} (expected name:type)`);
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
const name = kv.slice(0, colonIdx);
|
|
206
|
+
const type = kv.slice(colonIdx + 1).toLowerCase();
|
|
207
|
+
propChanges[name] = { [type]: {} };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
for (const name of opts.removeProp) {
|
|
211
|
+
propChanges[name] = null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
let res;
|
|
216
|
+
|
|
217
|
+
// Title changes go through databases.update()
|
|
218
|
+
if (opts.title) {
|
|
219
|
+
res = await notion.databases.update({
|
|
220
|
+
database_id: canonicalId,
|
|
221
|
+
title: [{ text: { content: opts.title } }],
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Property changes go through dataSources.update()
|
|
226
|
+
if (propChanges) {
|
|
227
|
+
res = await notion.dataSources.update({
|
|
228
|
+
data_source_id: dataSourceId,
|
|
229
|
+
properties: propChanges,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (jsonOutput(cmd, res)) return;
|
|
234
|
+
|
|
235
|
+
console.log(`✅ Updated database: ${(dbIds.database_id || dbIds.data_source_id).slice(0, 8)}…`);
|
|
236
|
+
if (opts.title) console.log(` Title: ${opts.title}`);
|
|
237
|
+
if (opts.addProp.length > 0) console.log(` Added: ${opts.addProp.join(', ')}`);
|
|
238
|
+
if (opts.removeProp.length > 0) console.log(` Removed: ${opts.removeProp.join(', ')}`);
|
|
239
|
+
}));
|
|
240
|
+
},
|
|
241
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
module.exports = {
|
|
5
|
+
register(program, ctx) {
|
|
6
|
+
const {
|
|
7
|
+
getNotion,
|
|
8
|
+
resolveDb,
|
|
9
|
+
getDbSchema,
|
|
10
|
+
buildProperties,
|
|
11
|
+
parseCsv,
|
|
12
|
+
markdownToBlocks,
|
|
13
|
+
blocksToMarkdown,
|
|
14
|
+
runCommand,
|
|
15
|
+
} = ctx;
|
|
16
|
+
|
|
17
|
+
// ─── import ──────────────────────────────────────────────────────────────
|
|
18
|
+
program
|
|
19
|
+
.command('import <file>')
|
|
20
|
+
.description('Import data from a file (.csv/.json → database pages, .md → page content)')
|
|
21
|
+
.option('--to <database>', 'Target database alias for CSV/JSON import')
|
|
22
|
+
.option('--parent <page-id>', 'Parent page for markdown import')
|
|
23
|
+
.option('--title <text>', 'Page title for markdown import')
|
|
24
|
+
.action(async (file, opts, cmd) => runCommand('Import', async () => {
|
|
25
|
+
const filePath = path.resolve(file);
|
|
26
|
+
if (!fs.existsSync(filePath)) {
|
|
27
|
+
console.error(`File not found: ${filePath}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
32
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
33
|
+
|
|
34
|
+
if (ext === '.csv' || ext === '.json') {
|
|
35
|
+
// Database import: CSV/JSON → pages
|
|
36
|
+
if (!opts.to) {
|
|
37
|
+
console.error('--to <database> is required for CSV/JSON import.');
|
|
38
|
+
console.error('Example: notion import data.csv --to tasks');
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const notion = getNotion();
|
|
43
|
+
const dbIds = resolveDb(opts.to);
|
|
44
|
+
const schema = await getDbSchema(dbIds);
|
|
45
|
+
|
|
46
|
+
let rows;
|
|
47
|
+
if (ext === '.csv') {
|
|
48
|
+
rows = parseCsv(content);
|
|
49
|
+
} else {
|
|
50
|
+
const parsed = JSON.parse(content);
|
|
51
|
+
rows = Array.isArray(parsed) ? parsed : [parsed];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (rows.length === 0) {
|
|
55
|
+
console.error('No data found in file.');
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.log(`Importing ${rows.length} row${rows.length !== 1 ? 's' : ''} to ${opts.to}...`);
|
|
60
|
+
|
|
61
|
+
let created = 0;
|
|
62
|
+
let failed = 0;
|
|
63
|
+
for (const row of rows) {
|
|
64
|
+
try {
|
|
65
|
+
// Map row keys to schema properties
|
|
66
|
+
const propStrs = [];
|
|
67
|
+
for (const [key, value] of Object.entries(row)) {
|
|
68
|
+
if (value === '' || value === null || value === undefined) continue;
|
|
69
|
+
const schemaEntry = schema[key.toLowerCase()];
|
|
70
|
+
if (schemaEntry) {
|
|
71
|
+
propStrs.push(`${schemaEntry.name}=${value}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (propStrs.length === 0) continue;
|
|
75
|
+
|
|
76
|
+
const properties = await buildProperties(dbIds, propStrs);
|
|
77
|
+
await notion.pages.create({
|
|
78
|
+
parent: { type: 'data_source_id', data_source_id: dbIds.data_source_id },
|
|
79
|
+
properties,
|
|
80
|
+
});
|
|
81
|
+
created++;
|
|
82
|
+
} catch (err) {
|
|
83
|
+
failed++;
|
|
84
|
+
if (failed <= 3) console.error(` Row failed: ${err.message}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
console.log(`✅ Imported ${created} page${created !== 1 ? 's' : ''}${failed > 0 ? ` (${failed} failed)` : ''}`);
|
|
89
|
+
|
|
90
|
+
} else if (ext === '.md' || ext === '.markdown') {
|
|
91
|
+
// Page import: Markdown → page with blocks
|
|
92
|
+
const notion = getNotion();
|
|
93
|
+
const title = opts.title || path.basename(filePath, ext);
|
|
94
|
+
|
|
95
|
+
let parentId = opts.parent;
|
|
96
|
+
if (!parentId && opts.to) {
|
|
97
|
+
// If --to is an alias, create as a database page
|
|
98
|
+
const dbIds = resolveDb(opts.to);
|
|
99
|
+
const properties = await buildProperties(dbIds, [`Name=${title}`]);
|
|
100
|
+
const res = await notion.pages.create({
|
|
101
|
+
parent: { type: 'data_source_id', data_source_id: dbIds.data_source_id },
|
|
102
|
+
properties,
|
|
103
|
+
});
|
|
104
|
+
parentId = res.id;
|
|
105
|
+
console.log(`✅ Created page: ${res.id}`);
|
|
106
|
+
} else if (parentId) {
|
|
107
|
+
// Create as a child page
|
|
108
|
+
const res = await notion.pages.create({
|
|
109
|
+
parent: { type: 'page_id', page_id: parentId },
|
|
110
|
+
properties: { title: { title: [{ text: { content: title } }] } },
|
|
111
|
+
});
|
|
112
|
+
parentId = res.id;
|
|
113
|
+
console.log(`✅ Created page: ${res.id}`);
|
|
114
|
+
} else {
|
|
115
|
+
console.error('Specify --to <database> or --parent <page-id> for markdown import.');
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Parse markdown and append blocks
|
|
120
|
+
const blocks = markdownToBlocks(content);
|
|
121
|
+
for (let i = 0; i < blocks.length; i += 100) {
|
|
122
|
+
await notion.blocks.children.append({
|
|
123
|
+
block_id: parentId,
|
|
124
|
+
children: blocks.slice(i, i + 100),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
console.log(` Imported ${blocks.length} block${blocks.length !== 1 ? 's' : ''} from ${path.basename(filePath)}`);
|
|
129
|
+
} else {
|
|
130
|
+
console.error(`Unsupported file type: ${ext}`);
|
|
131
|
+
console.error('Supported: .csv, .json (→ database), .md (→ page)');
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
}));
|
|
135
|
+
|
|
136
|
+
// ─── export ──────────────────────────────────────────────────────────────
|
|
137
|
+
program
|
|
138
|
+
.command('export <page-or-alias>')
|
|
139
|
+
.description('Export page content as markdown')
|
|
140
|
+
.option('--filter <key=value>', 'Filter to find the page (required when using an alias)')
|
|
141
|
+
.action(async (target, opts, cmd) => runCommand('Export', async () => {
|
|
142
|
+
const notion = getNotion();
|
|
143
|
+
const { pageId } = await ctx.resolvePageId(target, opts.filter);
|
|
144
|
+
|
|
145
|
+
// Fetch all blocks
|
|
146
|
+
let blocks = [];
|
|
147
|
+
let cursor;
|
|
148
|
+
do {
|
|
149
|
+
const res = await notion.blocks.children.list({
|
|
150
|
+
block_id: pageId,
|
|
151
|
+
start_cursor: cursor,
|
|
152
|
+
page_size: 100,
|
|
153
|
+
});
|
|
154
|
+
blocks = blocks.concat(res.results);
|
|
155
|
+
cursor = res.has_more ? res.next_cursor : null;
|
|
156
|
+
} while (cursor);
|
|
157
|
+
|
|
158
|
+
const md = blocksToMarkdown(blocks);
|
|
159
|
+
console.log(md);
|
|
160
|
+
}));
|
|
161
|
+
},
|
|
162
|
+
};
|