@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/lib/markdown.js
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
// lib/markdown.js — Markdown/CSV parsing and conversion
|
|
2
|
+
|
|
3
|
+
const { richTextToPlain } = require('./format');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse markdown text into Notion block objects.
|
|
7
|
+
* Handles: headings, paragraphs, bullet lists, numbered lists, code blocks, quotes, dividers.
|
|
8
|
+
*/
|
|
9
|
+
function markdownToBlocks(md) {
|
|
10
|
+
const lines = md.split('\n');
|
|
11
|
+
const blocks = [];
|
|
12
|
+
const listStack = [];
|
|
13
|
+
let i = 0;
|
|
14
|
+
|
|
15
|
+
while (i < lines.length) {
|
|
16
|
+
const line = lines[i];
|
|
17
|
+
|
|
18
|
+
// Code block (fenced)
|
|
19
|
+
if (line.startsWith('```')) {
|
|
20
|
+
const lang = line.slice(3).trim() || 'plain text';
|
|
21
|
+
const codeLines = [];
|
|
22
|
+
i++;
|
|
23
|
+
while (i < lines.length && !lines[i].startsWith('```')) {
|
|
24
|
+
codeLines.push(lines[i]);
|
|
25
|
+
i++;
|
|
26
|
+
}
|
|
27
|
+
i++; // skip closing ```
|
|
28
|
+
blocks.push({
|
|
29
|
+
object: 'block',
|
|
30
|
+
type: 'code',
|
|
31
|
+
code: {
|
|
32
|
+
rich_text: [{ type: 'text', text: { content: codeLines.join('\n') } }],
|
|
33
|
+
language: lang,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
listStack.length = 0;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Todo/checkbox (must check BEFORE bullet list since both start with -)
|
|
41
|
+
if (line.startsWith('- [ ] ') || line.startsWith('- [x] ')) {
|
|
42
|
+
const checked = line.startsWith('- [x] ');
|
|
43
|
+
blocks.push({
|
|
44
|
+
object: 'block', type: 'to_do',
|
|
45
|
+
to_do: {
|
|
46
|
+
rich_text: parseInlineFormatting(line.slice(6)),
|
|
47
|
+
checked,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
i++;
|
|
51
|
+
listStack.length = 0;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const bulletMatch = line.match(/^(\s*)([-*])\s+(.*)$/);
|
|
56
|
+
if (bulletMatch) {
|
|
57
|
+
const indent = bulletMatch[1].length;
|
|
58
|
+
const level = Math.floor(indent / 2);
|
|
59
|
+
const content = bulletMatch[3];
|
|
60
|
+
const itemBlock = {
|
|
61
|
+
object: 'block', type: 'bulleted_list_item',
|
|
62
|
+
bulleted_list_item: { rich_text: parseInlineFormatting(content) },
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
if (level <= 0) {
|
|
66
|
+
blocks.push(itemBlock);
|
|
67
|
+
listStack.length = 0;
|
|
68
|
+
listStack.push({ level: 0, block: itemBlock });
|
|
69
|
+
} else {
|
|
70
|
+
while (listStack.length && listStack[listStack.length - 1].level >= level) {
|
|
71
|
+
listStack.pop();
|
|
72
|
+
}
|
|
73
|
+
const parent = listStack[listStack.length - 1];
|
|
74
|
+
if (parent) {
|
|
75
|
+
if (!parent.block.bulleted_list_item.children) {
|
|
76
|
+
parent.block.bulleted_list_item.children = [];
|
|
77
|
+
}
|
|
78
|
+
parent.block.bulleted_list_item.children.push(itemBlock);
|
|
79
|
+
} else {
|
|
80
|
+
blocks.push(itemBlock);
|
|
81
|
+
}
|
|
82
|
+
listStack.push({ level, block: itemBlock });
|
|
83
|
+
}
|
|
84
|
+
i++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
listStack.length = 0;
|
|
89
|
+
|
|
90
|
+
// Divider
|
|
91
|
+
if (/^---+\s*$/.test(line) || /^\*\*\*+\s*$/.test(line)) {
|
|
92
|
+
blocks.push({ object: 'block', type: 'divider', divider: {} });
|
|
93
|
+
listStack.length = 0;
|
|
94
|
+
i++;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Headings
|
|
99
|
+
if (line.startsWith('### ')) {
|
|
100
|
+
blocks.push({
|
|
101
|
+
object: 'block', type: 'heading_3',
|
|
102
|
+
heading_3: { rich_text: parseInlineFormatting(line.slice(4)) },
|
|
103
|
+
});
|
|
104
|
+
listStack.length = 0;
|
|
105
|
+
i++;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (line.startsWith('## ')) {
|
|
109
|
+
blocks.push({
|
|
110
|
+
object: 'block', type: 'heading_2',
|
|
111
|
+
heading_2: { rich_text: parseInlineFormatting(line.slice(3)) },
|
|
112
|
+
});
|
|
113
|
+
listStack.length = 0;
|
|
114
|
+
i++;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (line.startsWith('# ')) {
|
|
118
|
+
blocks.push({
|
|
119
|
+
object: 'block', type: 'heading_1',
|
|
120
|
+
heading_1: { rich_text: parseInlineFormatting(line.slice(2)) },
|
|
121
|
+
});
|
|
122
|
+
listStack.length = 0;
|
|
123
|
+
i++;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Quote
|
|
128
|
+
if (line.startsWith('> ')) {
|
|
129
|
+
blocks.push({
|
|
130
|
+
object: 'block', type: 'quote',
|
|
131
|
+
quote: { rich_text: parseInlineFormatting(line.slice(2)) },
|
|
132
|
+
});
|
|
133
|
+
listStack.length = 0;
|
|
134
|
+
i++;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Numbered list
|
|
139
|
+
if (/^\d+\.\s/.test(line)) {
|
|
140
|
+
blocks.push({
|
|
141
|
+
object: 'block', type: 'numbered_list_item',
|
|
142
|
+
numbered_list_item: { rich_text: parseInlineFormatting(line.replace(/^\d+\.\s/, '')) },
|
|
143
|
+
});
|
|
144
|
+
listStack.length = 0;
|
|
145
|
+
i++;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Empty line — skip
|
|
150
|
+
if (line.trim() === '') {
|
|
151
|
+
listStack.length = 0;
|
|
152
|
+
i++;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Default: paragraph
|
|
157
|
+
blocks.push({
|
|
158
|
+
object: 'block', type: 'paragraph',
|
|
159
|
+
paragraph: { rich_text: parseInlineFormatting(line) },
|
|
160
|
+
});
|
|
161
|
+
listStack.length = 0;
|
|
162
|
+
i++;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return blocks;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Parse inline markdown formatting (bold, italic, code, links) into rich_text array.
|
|
170
|
+
*/
|
|
171
|
+
function parseInlineFormatting(text) {
|
|
172
|
+
const segments = [];
|
|
173
|
+
// Simple regex-based parser for **bold**, *italic*, `code`, [text](url)
|
|
174
|
+
const regex = /(\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`|\[(.+?)\]\((.+?)\))/g;
|
|
175
|
+
let lastIdx = 0;
|
|
176
|
+
let match;
|
|
177
|
+
|
|
178
|
+
while ((match = regex.exec(text)) !== null) {
|
|
179
|
+
// Add plain text before match
|
|
180
|
+
if (match.index > lastIdx) {
|
|
181
|
+
segments.push({ type: 'text', text: { content: text.slice(lastIdx, match.index) } });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (match[2]) {
|
|
185
|
+
// **bold**
|
|
186
|
+
segments.push({ type: 'text', text: { content: match[2] }, annotations: { bold: true } });
|
|
187
|
+
} else if (match[3]) {
|
|
188
|
+
// *italic*
|
|
189
|
+
segments.push({ type: 'text', text: { content: match[3] }, annotations: { italic: true } });
|
|
190
|
+
} else if (match[4]) {
|
|
191
|
+
// `code`
|
|
192
|
+
segments.push({ type: 'text', text: { content: match[4] }, annotations: { code: true } });
|
|
193
|
+
} else if (match[5] && match[6]) {
|
|
194
|
+
// [text](url)
|
|
195
|
+
segments.push({ type: 'text', text: { content: match[5], link: { url: match[6] } } });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
lastIdx = match.index + match[0].length;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Add remaining plain text
|
|
202
|
+
if (lastIdx < text.length) {
|
|
203
|
+
segments.push({ type: 'text', text: { content: text.slice(lastIdx) } });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// If no formatting found, return simple text
|
|
207
|
+
if (segments.length === 0) {
|
|
208
|
+
return [{ type: 'text', text: { content: text } }];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return segments;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Convert Notion rich_text array into markdown with annotations.
|
|
216
|
+
*/
|
|
217
|
+
function richTextToMarkdown(richText) {
|
|
218
|
+
if (!Array.isArray(richText)) return '';
|
|
219
|
+
return richText.map((item) => {
|
|
220
|
+
const plainText = item.plain_text ?? item.text?.content ?? '';
|
|
221
|
+
const linkUrl = item.text?.link?.url;
|
|
222
|
+
const annotations = item.annotations || {};
|
|
223
|
+
let formatted = plainText;
|
|
224
|
+
|
|
225
|
+
if (annotations.code) {
|
|
226
|
+
formatted = `\`${plainText}\``;
|
|
227
|
+
} else {
|
|
228
|
+
if (annotations.bold) formatted = `**${formatted}**`;
|
|
229
|
+
if (annotations.italic) formatted = `*${formatted}*`;
|
|
230
|
+
if (annotations.strikethrough) formatted = `~~${formatted}~~`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (linkUrl) {
|
|
234
|
+
formatted = `[${formatted}](${linkUrl})`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return formatted;
|
|
238
|
+
}).join('');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Parse Notion blocks into markdown text.
|
|
243
|
+
*/
|
|
244
|
+
function blocksToMarkdown(blocks) {
|
|
245
|
+
const lines = [];
|
|
246
|
+
for (const block of blocks) {
|
|
247
|
+
const type = block.type;
|
|
248
|
+
switch (type) {
|
|
249
|
+
case 'heading_1':
|
|
250
|
+
lines.push(`# ${richTextToMarkdown(block.heading_1?.rich_text)}`);
|
|
251
|
+
break;
|
|
252
|
+
case 'heading_2':
|
|
253
|
+
lines.push(`## ${richTextToMarkdown(block.heading_2?.rich_text)}`);
|
|
254
|
+
break;
|
|
255
|
+
case 'heading_3':
|
|
256
|
+
lines.push(`### ${richTextToMarkdown(block.heading_3?.rich_text)}`);
|
|
257
|
+
break;
|
|
258
|
+
case 'paragraph':
|
|
259
|
+
lines.push(richTextToMarkdown(block.paragraph?.rich_text));
|
|
260
|
+
break;
|
|
261
|
+
case 'bulleted_list_item':
|
|
262
|
+
lines.push(`- ${richTextToMarkdown(block.bulleted_list_item?.rich_text)}`);
|
|
263
|
+
break;
|
|
264
|
+
case 'numbered_list_item':
|
|
265
|
+
lines.push(`1. ${richTextToMarkdown(block.numbered_list_item?.rich_text)}`);
|
|
266
|
+
break;
|
|
267
|
+
case 'to_do': {
|
|
268
|
+
const check = block.to_do?.checked ? 'x' : ' ';
|
|
269
|
+
lines.push(`- [${check}] ${richTextToMarkdown(block.to_do?.rich_text)}`);
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
case 'quote':
|
|
273
|
+
lines.push(`> ${richTextToMarkdown(block.quote?.rich_text)}`);
|
|
274
|
+
break;
|
|
275
|
+
case 'code':
|
|
276
|
+
lines.push(`\`\`\`${block.code?.language || ''}`);
|
|
277
|
+
lines.push(richTextToPlain(block.code?.rich_text));
|
|
278
|
+
lines.push('```');
|
|
279
|
+
break;
|
|
280
|
+
case 'divider':
|
|
281
|
+
lines.push('---');
|
|
282
|
+
break;
|
|
283
|
+
default:
|
|
284
|
+
// Attempt generic rich_text extraction
|
|
285
|
+
if (block[type]?.rich_text) {
|
|
286
|
+
lines.push(richTextToMarkdown(block[type].rich_text));
|
|
287
|
+
}
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return lines.join('\n');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Parse CSV text into array of objects (rows).
|
|
296
|
+
* First line is headers.
|
|
297
|
+
*/
|
|
298
|
+
function parseCsv(text) {
|
|
299
|
+
const lines = [];
|
|
300
|
+
let current = '';
|
|
301
|
+
let inQuotes = false;
|
|
302
|
+
|
|
303
|
+
for (let i = 0; i < text.length; i++) {
|
|
304
|
+
const ch = text[i];
|
|
305
|
+
if (ch === '"') {
|
|
306
|
+
if (inQuotes && text[i + 1] === '"') {
|
|
307
|
+
current += '""';
|
|
308
|
+
i++;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
inQuotes = !inQuotes;
|
|
312
|
+
current += ch;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (ch === '\r' && text[i + 1] === '\n') {
|
|
317
|
+
if (inQuotes) {
|
|
318
|
+
current += '\n';
|
|
319
|
+
} else if (current.trim()) {
|
|
320
|
+
lines.push(current);
|
|
321
|
+
}
|
|
322
|
+
current = '';
|
|
323
|
+
i++;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (ch === '\n') {
|
|
328
|
+
if (inQuotes) {
|
|
329
|
+
current += '\n';
|
|
330
|
+
} else if (current.trim()) {
|
|
331
|
+
lines.push(current);
|
|
332
|
+
current = '';
|
|
333
|
+
} else {
|
|
334
|
+
current = '';
|
|
335
|
+
}
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
current += ch;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (current.trim()) {
|
|
343
|
+
lines.push(current);
|
|
344
|
+
}
|
|
345
|
+
if (lines.length < 2) return [];
|
|
346
|
+
|
|
347
|
+
const headers = parseCsvLine(lines[0]);
|
|
348
|
+
const rows = [];
|
|
349
|
+
for (let i = 1; i < lines.length; i++) {
|
|
350
|
+
const values = parseCsvLine(lines[i]);
|
|
351
|
+
const row = {};
|
|
352
|
+
for (let j = 0; j < headers.length; j++) {
|
|
353
|
+
row[headers[j]] = values[j] || '';
|
|
354
|
+
}
|
|
355
|
+
rows.push(row);
|
|
356
|
+
}
|
|
357
|
+
return rows;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Parse a single CSV line, handling quoted fields */
|
|
361
|
+
function parseCsvLine(line) {
|
|
362
|
+
const fields = [];
|
|
363
|
+
let current = '';
|
|
364
|
+
let inQuotes = false;
|
|
365
|
+
for (let i = 0; i < line.length; i++) {
|
|
366
|
+
const ch = line[i];
|
|
367
|
+
if (inQuotes) {
|
|
368
|
+
if (ch === '"' && line[i + 1] === '"') {
|
|
369
|
+
current += '"';
|
|
370
|
+
i++;
|
|
371
|
+
} else if (ch === '"') {
|
|
372
|
+
inQuotes = false;
|
|
373
|
+
} else {
|
|
374
|
+
current += ch;
|
|
375
|
+
}
|
|
376
|
+
} else {
|
|
377
|
+
if (ch === '"') {
|
|
378
|
+
inQuotes = true;
|
|
379
|
+
} else if (ch === ',') {
|
|
380
|
+
fields.push(current);
|
|
381
|
+
current = '';
|
|
382
|
+
} else {
|
|
383
|
+
current += ch;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
fields.push(current);
|
|
388
|
+
return fields;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Convert kebab-case flag name to a schema property match.
|
|
393
|
+
* --due-date → "Due Date", --name → "Name", --status → "Status"
|
|
394
|
+
*/
|
|
395
|
+
function kebabToProperty(kebab, schema) {
|
|
396
|
+
// Remove leading dashes
|
|
397
|
+
const clean = kebab.replace(/^-+/, '');
|
|
398
|
+
|
|
399
|
+
// Try exact lowercase match first
|
|
400
|
+
if (schema[clean.toLowerCase()]) {
|
|
401
|
+
return schema[clean.toLowerCase()];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Try kebab → space conversion: "due-date" → "due date"
|
|
405
|
+
const spaced = clean.replace(/-/g, ' ');
|
|
406
|
+
if (schema[spaced.toLowerCase()]) {
|
|
407
|
+
return schema[spaced.toLowerCase()];
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Try kebab → title case: "due-date" → "Due Date"
|
|
411
|
+
const titled = clean.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
412
|
+
if (schema[titled.toLowerCase()]) {
|
|
413
|
+
return schema[titled.toLowerCase()];
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Extract dynamic property flags from raw argv.
|
|
421
|
+
* Returns array of "Key=Value" strings compatible with buildProperties.
|
|
422
|
+
*/
|
|
423
|
+
function extractDynamicProps(argv, knownFlags, schema) {
|
|
424
|
+
const props = [];
|
|
425
|
+
const known = new Set(knownFlags);
|
|
426
|
+
const isKnownFlagName = (flagName) => known.has(flagName) || known.has(`--${flagName}`);
|
|
427
|
+
const isKnownFlagToken = (token) => {
|
|
428
|
+
if (!token.startsWith('--') || token === '--') return false;
|
|
429
|
+
const eqIdx = token.indexOf('=');
|
|
430
|
+
const tokenName = eqIdx === -1 ? token.slice(2) : token.slice(2, eqIdx);
|
|
431
|
+
return isKnownFlagName(tokenName) || !!kebabToProperty(tokenName, schema);
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
for (let i = 0; i < argv.length; i++) {
|
|
435
|
+
const arg = argv[i];
|
|
436
|
+
if (!arg.startsWith('--') || arg === '--') continue;
|
|
437
|
+
|
|
438
|
+
const eqIdx = arg.indexOf('=');
|
|
439
|
+
const flagName = eqIdx === -1 ? arg.slice(2) : arg.slice(2, eqIdx);
|
|
440
|
+
const explicitValue = eqIdx === -1 ? null : arg.slice(eqIdx + 1);
|
|
441
|
+
|
|
442
|
+
// Skip known commander flags
|
|
443
|
+
if (isKnownFlagName(flagName) || known.has(arg)) continue;
|
|
444
|
+
|
|
445
|
+
// Try to match against schema
|
|
446
|
+
const schemaEntry = kebabToProperty(flagName, schema);
|
|
447
|
+
if (!schemaEntry) continue;
|
|
448
|
+
|
|
449
|
+
if (schemaEntry.type === 'checkbox') {
|
|
450
|
+
const value = explicitValue !== null ? explicitValue : 'true';
|
|
451
|
+
props.push(`${schemaEntry.name}=${value}`);
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (explicitValue !== null) {
|
|
456
|
+
props.push(`${schemaEntry.name}=${explicitValue}`);
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (i + 1 >= argv.length) continue;
|
|
461
|
+
|
|
462
|
+
const valueCandidate = argv[i + 1];
|
|
463
|
+
if (!valueCandidate.startsWith('--') || valueCandidate === '--') {
|
|
464
|
+
props.push(`${schemaEntry.name}=${valueCandidate}`);
|
|
465
|
+
i++; // skip value
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const isLastArg = i + 1 === argv.length - 1;
|
|
470
|
+
if (isLastArg || !isKnownFlagToken(valueCandidate)) {
|
|
471
|
+
props.push(`${schemaEntry.name}=${valueCandidate}`);
|
|
472
|
+
i++; // skip value
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return props;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
module.exports = {
|
|
480
|
+
markdownToBlocks,
|
|
481
|
+
parseInlineFormatting,
|
|
482
|
+
richTextToMarkdown,
|
|
483
|
+
blocksToMarkdown,
|
|
484
|
+
parseCsv,
|
|
485
|
+
parseCsvLine,
|
|
486
|
+
kebabToProperty,
|
|
487
|
+
extractDynamicProps,
|
|
488
|
+
};
|
package/lib/paginate.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// lib/paginate.js — Generic Notion pagination helper
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Paginate Notion list/query/search endpoints that return { results, has_more, next_cursor }.
|
|
5
|
+
* fetchPage({ start_cursor, page_size }) should return the raw API response.
|
|
6
|
+
*/
|
|
7
|
+
async function paginate(fetchPage, options = {}) {
|
|
8
|
+
const limit = options.limit == null ? null : Number(options.limit);
|
|
9
|
+
const pageSizeLimit = options.pageSizeLimit || 100;
|
|
10
|
+
|
|
11
|
+
if (limit != null && (!Number.isFinite(limit) || limit < 0)) {
|
|
12
|
+
throw new Error(`Invalid limit: ${options.limit}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const results = [];
|
|
16
|
+
let cursor = undefined;
|
|
17
|
+
let hasMore = true;
|
|
18
|
+
let truncated = false;
|
|
19
|
+
let responseBase = null;
|
|
20
|
+
|
|
21
|
+
while (hasMore) {
|
|
22
|
+
const remaining = limit == null ? null : limit - results.length;
|
|
23
|
+
if (remaining != null && remaining <= 0) {
|
|
24
|
+
truncated = true;
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const pageSize = remaining == null ? pageSizeLimit : Math.min(pageSizeLimit, remaining);
|
|
29
|
+
const res = await fetchPage({ start_cursor: cursor, page_size: pageSize });
|
|
30
|
+
if (!responseBase) responseBase = res;
|
|
31
|
+
|
|
32
|
+
const pageResults = res.results || [];
|
|
33
|
+
if (limit != null && pageResults.length > 0) {
|
|
34
|
+
const remaining = limit - results.length;
|
|
35
|
+
if (remaining <= 0) {
|
|
36
|
+
truncated = true;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
if (pageResults.length > remaining) {
|
|
40
|
+
results.push(...pageResults.slice(0, remaining));
|
|
41
|
+
truncated = true;
|
|
42
|
+
cursor = res.next_cursor || null;
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
results.push(...pageResults);
|
|
47
|
+
|
|
48
|
+
hasMore = Boolean(res.has_more);
|
|
49
|
+
cursor = res.next_cursor || null;
|
|
50
|
+
|
|
51
|
+
if (limit != null && results.length >= limit && hasMore) {
|
|
52
|
+
truncated = true;
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const finalHasMore = truncated ? true : false;
|
|
58
|
+
const finalCursor = truncated ? cursor : null;
|
|
59
|
+
const response = responseBase
|
|
60
|
+
? { ...responseBase, results, has_more: finalHasMore, next_cursor: finalCursor }
|
|
61
|
+
: { object: 'list', results, has_more: finalHasMore, next_cursor: finalCursor };
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
results,
|
|
65
|
+
has_more: finalHasMore,
|
|
66
|
+
next_cursor: finalCursor,
|
|
67
|
+
truncated,
|
|
68
|
+
response,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = {
|
|
73
|
+
paginate,
|
|
74
|
+
};
|
package/lib/retry.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// lib/retry.js — Retry helpers for Notion API calls
|
|
2
|
+
|
|
3
|
+
function sleep(ms) {
|
|
4
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function isRateLimitError(err) {
|
|
8
|
+
if (!err || typeof err !== 'object') return false;
|
|
9
|
+
return err.status === 429 || err.code === 'rate_limited';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isNotionApiError(err) {
|
|
13
|
+
if (!err || typeof err !== 'object') return false;
|
|
14
|
+
if (err.name === 'APIResponseError') return true;
|
|
15
|
+
return typeof err.status === 'number' && err.body && typeof err.body === 'object';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getNotionApiErrorDetails(err) {
|
|
19
|
+
if (!isNotionApiError(err)) return null;
|
|
20
|
+
const details = {
|
|
21
|
+
status: err.status,
|
|
22
|
+
code: err.code,
|
|
23
|
+
body: err.body,
|
|
24
|
+
};
|
|
25
|
+
if (err.message && (!err.body || err.body.message !== err.message)) {
|
|
26
|
+
details.message = err.message;
|
|
27
|
+
}
|
|
28
|
+
return details;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function calculateDelayMs(baseDelayMs, attempt, jitter, randomFn) {
|
|
32
|
+
const delayMs = baseDelayMs * (2 ** (attempt - 1));
|
|
33
|
+
if (!jitter) return delayMs;
|
|
34
|
+
const rand = typeof randomFn === 'function' ? randomFn() : Math.random();
|
|
35
|
+
const jittered = delayMs * (0.5 + rand);
|
|
36
|
+
return Math.max(0, Math.floor(jittered));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function withRetry(fn, options = {}) {
|
|
40
|
+
const {
|
|
41
|
+
maxAttempts = 3,
|
|
42
|
+
baseDelayMs = 1000,
|
|
43
|
+
jitter = true,
|
|
44
|
+
random = Math.random,
|
|
45
|
+
sleep: sleepFn = sleep,
|
|
46
|
+
onRetry,
|
|
47
|
+
} = options;
|
|
48
|
+
|
|
49
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
50
|
+
try {
|
|
51
|
+
return await fn();
|
|
52
|
+
} catch (err) {
|
|
53
|
+
if (!isRateLimitError(err) || attempt === maxAttempts) {
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
const delayMs = calculateDelayMs(baseDelayMs, attempt, jitter, random);
|
|
57
|
+
if (typeof onRetry === 'function') {
|
|
58
|
+
onRetry({ attempt, maxAttempts, delayMs, error: err });
|
|
59
|
+
} else {
|
|
60
|
+
const delaySec = Math.max(0.1, Math.round(delayMs / 100) / 10);
|
|
61
|
+
console.error(`Rate limited, retrying in ${delaySec}s...`);
|
|
62
|
+
}
|
|
63
|
+
await sleepFn(delayMs);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = {
|
|
71
|
+
isRateLimitError,
|
|
72
|
+
isNotionApiError,
|
|
73
|
+
getNotionApiErrorDetails,
|
|
74
|
+
withRetry,
|
|
75
|
+
calculateDelayMs,
|
|
76
|
+
};
|
package/package.json
CHANGED