nconv-cli 1.0.0 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,535 @@
1
+ import { input, confirm } from '@inquirer/prompts';
2
+ import * as logger from '../utils/logger.js';
3
+ import { mdCommand } from '../commands/md.js';
4
+ import { htmlCommand } from '../commands/html.js';
5
+ import { pdfCommand } from '../commands/pdf.js';
6
+ import { validateConfig } from '../config.js';
7
+ import {
8
+ loadConfig,
9
+ saveConfig,
10
+ promptInitConfig,
11
+ promptEditConfig,
12
+ } from './prompts.js';
13
+
14
+ /**
15
+ * Handle /init command - Initialize configuration
16
+ */
17
+ export async function handleInit(): Promise<void> {
18
+ const existing = loadConfig();
19
+ const isFirstTime = !existing || (!existing.TOKEN_V2 && !existing.FILE_TOKEN);
20
+
21
+ // If config already exists, ask for confirmation
22
+ if (!isFirstTime) {
23
+ logger.warn('Configuration already exists.');
24
+
25
+ try {
26
+ const overwrite = await confirm({
27
+ message: 'Do you want to overwrite the existing configuration?',
28
+ default: false,
29
+ });
30
+
31
+ if (!overwrite) {
32
+ logger.info('Configuration unchanged. Use /config to view or edit your settings.');
33
+ return;
34
+ }
35
+ } catch (error) {
36
+ if (error instanceof Error && error.message === 'User force closed the prompt') {
37
+ logger.warn('\nCancelled.');
38
+ }
39
+ return;
40
+ }
41
+ }
42
+
43
+ // Show detailed instructions for token setup
44
+ logger.info('\nšŸ“ How to find your Notion tokens\n');
45
+ logger.info('1. Log in to https://notion.so in your browser');
46
+ logger.info('2. Open browser developer tools (press F12)');
47
+ logger.info('3. Go to the "Application" tab');
48
+ logger.info('4. Find "Cookies" section and select https://www.notion.so');
49
+ logger.info('5. Copy the value of "token_v2" cookie → TOKEN_V2');
50
+ logger.info('6. Copy the value of "file_token" cookie → FILE_TOKEN\n');
51
+
52
+ try {
53
+ const config = await promptInitConfig();
54
+ saveConfig(config);
55
+ } catch (error) {
56
+ if (error instanceof Error && error.message === 'User force closed the prompt') {
57
+ logger.warn('\nConfiguration cancelled.');
58
+ } else {
59
+ throw error;
60
+ }
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Handle /config command - View and edit configuration
66
+ */
67
+ export async function handleConfig(): Promise<void> {
68
+ const existing = loadConfig();
69
+
70
+ if (!existing || (!existing.TOKEN_V2 && !existing.FILE_TOKEN)) {
71
+ logger.warn('No configuration found.');
72
+ logger.info('Run /init to create initial configuration.');
73
+ return;
74
+ }
75
+
76
+ logger.info('Current configuration:');
77
+ logger.info(`TOKEN_V2: ${existing.TOKEN_V2 ? '***' + existing.TOKEN_V2.slice(-8) : '(not set)'}`);
78
+ logger.info(`FILE_TOKEN: ${existing.FILE_TOKEN ? '***' + existing.FILE_TOKEN.slice(-8) : '(not set)'}\n`);
79
+
80
+ try {
81
+ const edit = await input({
82
+ message: 'Edit configuration? (y/n)',
83
+ default: 'n',
84
+ });
85
+
86
+ if (edit.toLowerCase() === 'y' || edit.toLowerCase() === 'yes') {
87
+ const newConfig = await promptEditConfig(existing);
88
+ saveConfig(newConfig);
89
+ }
90
+ } catch (error) {
91
+ if (error instanceof Error && error.message === 'User force closed the prompt') {
92
+ logger.warn('\nEdit cancelled.');
93
+ } else {
94
+ throw error;
95
+ }
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Handle /md command - Convert Notion page to markdown
101
+ */
102
+ export async function handleMd(args: string[]): Promise<void> {
103
+ let url = '';
104
+ const options: any = {
105
+ output: './nconv-output',
106
+ imageDir: 'images',
107
+ verbose: false,
108
+ };
109
+
110
+ // Interactive mode (TUI) - step by step prompts
111
+ if (args.length === 0) {
112
+ try {
113
+ // Step 1: URL
114
+ url = await input({
115
+ message: 'Notion URL',
116
+ validate: (value) => {
117
+ if (!value.trim()) return 'URL is required';
118
+ if (!value.includes('notion.so') && !value.includes('notion.site')) {
119
+ return 'Please enter a valid Notion URL';
120
+ }
121
+ return true;
122
+ },
123
+ });
124
+
125
+ // Step 2: Output directory
126
+ const outputDir = await input({
127
+ message: 'Output directory [default: ./nconv-output]',
128
+ default: './nconv-output',
129
+ });
130
+ options.output = outputDir;
131
+
132
+ // Step 3: Filename (optional, auto-generated if empty)
133
+ const filename = await input({
134
+ message: 'Filename [leave empty for auto-generated]',
135
+ default: '',
136
+ });
137
+ if (filename.trim()) {
138
+ options.filename = filename;
139
+ }
140
+
141
+ // Step 4: Verbose logging
142
+ options.verbose = await confirm({
143
+ message: 'Enable verbose logging?',
144
+ default: false,
145
+ });
146
+ } catch (error) {
147
+ if (error instanceof Error && error.message === 'User force closed the prompt') {
148
+ logger.warn('\nConversion cancelled.');
149
+ }
150
+ return;
151
+ }
152
+ } else {
153
+ // CLI mode - parse arguments
154
+ url = args[0];
155
+ const additionalArgs = args.slice(1);
156
+
157
+ // Parse options
158
+ for (let i = 0; i < additionalArgs.length; i++) {
159
+ const arg = additionalArgs[i];
160
+ if ((arg === '-o' || arg === '--output') && i + 1 < additionalArgs.length) {
161
+ options.output = additionalArgs[++i];
162
+ } else if ((arg === '-i' || arg === '--image-dir') && i + 1 < additionalArgs.length) {
163
+ options.imageDir = additionalArgs[++i];
164
+ } else if ((arg === '-f' || arg === '--filename') && i + 1 < additionalArgs.length) {
165
+ options.filename = additionalArgs[++i];
166
+ } else if (arg === '-v' || arg === '--verbose') {
167
+ options.verbose = true;
168
+ }
169
+ }
170
+ }
171
+
172
+ // Check if config is valid
173
+ const configCheck = validateConfig();
174
+ if (!configCheck.valid) {
175
+ logger.error('Cannot convert Notion page:');
176
+ logger.error(configCheck.message || 'Configuration is invalid.');
177
+ return;
178
+ }
179
+
180
+ await mdCommand(url, options);
181
+ }
182
+
183
+ /**
184
+ * Handle /html command - Convert Notion page to HTML
185
+ */
186
+ export async function handleHtml(args: string[]): Promise<void> {
187
+ let url = '';
188
+ const options: any = {
189
+ output: './nconv-output',
190
+ imageDir: 'images',
191
+ verbose: false,
192
+ };
193
+
194
+ // Interactive mode (TUI) - step by step prompts
195
+ if (args.length === 0) {
196
+ try {
197
+ // Step 1: URL
198
+ url = await input({
199
+ message: 'Notion URL',
200
+ validate: (value) => {
201
+ if (!value.trim()) return 'URL is required';
202
+ if (!value.includes('notion.so') && !value.includes('notion.site')) {
203
+ return 'Please enter a valid Notion URL';
204
+ }
205
+ return true;
206
+ },
207
+ });
208
+
209
+ // Step 2: Output directory
210
+ const outputDir = await input({
211
+ message: 'Output directory [default: ./nconv-output]',
212
+ default: './nconv-output',
213
+ });
214
+ options.output = outputDir;
215
+
216
+ // Step 3: Filename (optional, auto-generated if empty)
217
+ const filename = await input({
218
+ message: 'Filename [leave empty for auto-generated]',
219
+ default: '',
220
+ });
221
+ if (filename.trim()) {
222
+ options.filename = filename;
223
+ }
224
+
225
+ // Step 4: Verbose logging
226
+ options.verbose = await confirm({
227
+ message: 'Enable verbose logging?',
228
+ default: false,
229
+ });
230
+ } catch (error) {
231
+ if (error instanceof Error && error.message === 'User force closed the prompt') {
232
+ logger.warn('\nConversion cancelled.');
233
+ }
234
+ return;
235
+ }
236
+ } else {
237
+ // CLI mode - parse arguments
238
+ url = args[0];
239
+ const additionalArgs = args.slice(1);
240
+
241
+ // Parse options
242
+ for (let i = 0; i < additionalArgs.length; i++) {
243
+ const arg = additionalArgs[i];
244
+ if ((arg === '-o' || arg === '--output') && i + 1 < additionalArgs.length) {
245
+ options.output = additionalArgs[++i];
246
+ } else if ((arg === '-i' || arg === '--image-dir') && i + 1 < additionalArgs.length) {
247
+ options.imageDir = additionalArgs[++i];
248
+ } else if ((arg === '-f' || arg === '--filename') && i + 1 < additionalArgs.length) {
249
+ options.filename = additionalArgs[++i];
250
+ } else if (arg === '-v' || arg === '--verbose') {
251
+ options.verbose = true;
252
+ }
253
+ }
254
+ }
255
+
256
+ // Check if config is valid
257
+ const configCheck = validateConfig();
258
+ if (!configCheck.valid) {
259
+ logger.error('Cannot convert Notion page:');
260
+ logger.error(configCheck.message || 'Configuration is invalid.');
261
+ return;
262
+ }
263
+
264
+ await htmlCommand(url, options);
265
+ }
266
+
267
+ /**
268
+ * Handle /pdf command - Convert Notion page to PDF
269
+ */
270
+ export async function handlePdf(args: string[]): Promise<void> {
271
+ let url = '';
272
+ const options: any = {
273
+ output: './nconv-output',
274
+ imageDir: 'images',
275
+ verbose: false,
276
+ };
277
+
278
+ // Interactive mode (TUI) - step by step prompts
279
+ if (args.length === 0) {
280
+ try {
281
+ // Step 1: URL
282
+ url = await input({
283
+ message: 'Notion URL',
284
+ validate: (value) => {
285
+ if (!value.trim()) return 'URL is required';
286
+ if (!value.includes('notion.so') && !value.includes('notion.site')) {
287
+ return 'Please enter a valid Notion URL';
288
+ }
289
+ return true;
290
+ },
291
+ });
292
+
293
+ // Step 2: Output directory
294
+ const outputDir = await input({
295
+ message: 'Output directory [default: ./nconv-output]',
296
+ default: './nconv-output',
297
+ });
298
+ options.output = outputDir;
299
+
300
+ // Step 3: Filename (optional, auto-generated if empty)
301
+ const filename = await input({
302
+ message: 'Filename [leave empty for auto-generated]',
303
+ default: '',
304
+ });
305
+ if (filename.trim()) {
306
+ options.filename = filename;
307
+ }
308
+
309
+ // Step 4: Verbose logging
310
+ options.verbose = await confirm({
311
+ message: 'Enable verbose logging?',
312
+ default: false,
313
+ });
314
+ } catch (error) {
315
+ if (error instanceof Error && error.message === 'User force closed the prompt') {
316
+ logger.warn('\nConversion cancelled.');
317
+ }
318
+ return;
319
+ }
320
+ } else {
321
+ // CLI mode - parse arguments
322
+ url = args[0];
323
+ const additionalArgs = args.slice(1);
324
+
325
+ // Parse options
326
+ for (let i = 0; i < additionalArgs.length; i++) {
327
+ const arg = additionalArgs[i];
328
+ if ((arg === '-o' || arg === '--output') && i + 1 < additionalArgs.length) {
329
+ options.output = additionalArgs[++i];
330
+ } else if ((arg === '-f' || arg === '--filename') && i + 1 < additionalArgs.length) {
331
+ options.filename = additionalArgs[++i];
332
+ } else if (arg === '-v' || arg === '--verbose') {
333
+ options.verbose = true;
334
+ }
335
+ }
336
+ }
337
+
338
+ // Check if config is valid
339
+ const configCheck = validateConfig();
340
+ if (!configCheck.valid) {
341
+ logger.error('Cannot convert Notion page:');
342
+ logger.error(configCheck.message || 'Configuration is invalid.');
343
+ return;
344
+ }
345
+
346
+ await pdfCommand(url, options);
347
+ }
348
+
349
+ /**
350
+ * Available commands list
351
+ */
352
+ const AVAILABLE_COMMANDS = [
353
+ {
354
+ name: 'init',
355
+ description: 'Initialize configuration (set Notion tokens)',
356
+ examples: ['/init'],
357
+ },
358
+ {
359
+ name: 'config',
360
+ description: 'View and edit current configuration',
361
+ examples: ['/config'],
362
+ },
363
+ {
364
+ name: 'md',
365
+ description: 'Convert Notion page to markdown',
366
+ examples: [
367
+ '/md https://notion.so/page-id',
368
+ '/md https://notion.so/page-id -o ./blog',
369
+ '/md https://notion.so/page-id -o ./blog -f my-post -v',
370
+ ],
371
+ },
372
+ {
373
+ name: 'html',
374
+ description: 'Convert Notion page to HTML',
375
+ examples: [
376
+ '/html https://notion.so/page-id',
377
+ '/html https://notion.so/page-id -o ./blog',
378
+ '/html https://notion.so/page-id -o ./blog -f my-post -v',
379
+ ],
380
+ },
381
+ {
382
+ name: 'pdf',
383
+ description: 'Convert Notion page to PDF (renders actual page)',
384
+ examples: [
385
+ '/pdf https://notion.so/page-id',
386
+ '/pdf https://notion.so/page-id -o ./blog',
387
+ '/pdf https://notion.so/page-id -o ./blog -f my-post -v',
388
+ ],
389
+ },
390
+ {
391
+ name: 'help',
392
+ description: 'Show this help message',
393
+ examples: ['/help'],
394
+ },
395
+ {
396
+ name: 'exit',
397
+ description: 'Exit the REPL',
398
+ examples: ['/exit'],
399
+ },
400
+ ];
401
+
402
+ /**
403
+ * Get command suggestions based on input
404
+ */
405
+ export function getCommandSuggestions(input: string): string[] {
406
+ const cleanInput = input.toLowerCase().replace(/^\//, '');
407
+ return AVAILABLE_COMMANDS
408
+ .filter((cmd) => cmd.name.startsWith(cleanInput))
409
+ .map((cmd) => `/${cmd.name}`);
410
+ }
411
+
412
+ /**
413
+ * Get command choices for autocomplete
414
+ */
415
+ export function getCommandChoices() {
416
+ return AVAILABLE_COMMANDS.map((cmd) => ({
417
+ title: `/${cmd.name}`,
418
+ value: `/${cmd.name}`,
419
+ description: cmd.description,
420
+ }));
421
+ }
422
+
423
+ /**
424
+ * Find similar commands using simple string distance
425
+ */
426
+ function findSimilarCommand(input: string): string | null {
427
+ const cleanInput = input.toLowerCase();
428
+ for (const cmd of AVAILABLE_COMMANDS) {
429
+ if (cmd.name.includes(cleanInput) || cleanInput.includes(cmd.name)) {
430
+ return cmd.name;
431
+ }
432
+ }
433
+ return null;
434
+ }
435
+
436
+ /**
437
+ * Handle /help command - Show help information
438
+ */
439
+ export function handleHelp(): void {
440
+ logger.info('Available commands:\n');
441
+
442
+ AVAILABLE_COMMANDS.forEach((cmd) => {
443
+ logger.info(` /${cmd.name.padEnd(20)} ${cmd.description}`);
444
+ });
445
+
446
+ console.log('');
447
+ logger.info('Conversion options (for /md, /html, and /pdf):');
448
+ logger.info(' -o, --output <dir> Output directory (default: ./nconv-output)');
449
+ logger.info(' -i, --image-dir <dir> Image folder name (default: images) [md/html only]');
450
+ logger.info(' -f, --filename <name> Output filename');
451
+ logger.info(' -v, --verbose Enable verbose logging\n');
452
+
453
+ logger.info('Examples:');
454
+ AVAILABLE_COMMANDS.forEach((cmd) => {
455
+ cmd.examples.forEach((example) => {
456
+ logger.info(` ${example}`);
457
+ });
458
+ });
459
+ console.log('');
460
+ }
461
+
462
+ /**
463
+ * Parse and execute a slash command
464
+ */
465
+ export async function executeCommand(input: string): Promise<boolean> {
466
+ const trimmed = input.trim();
467
+
468
+ if (!trimmed) {
469
+ return false;
470
+ }
471
+
472
+ if (!trimmed.startsWith('/')) {
473
+ logger.error('Commands must start with /');
474
+ logger.info('Type /help for available commands');
475
+ logger.info('Example: /init, /md <url>, /config\n');
476
+ return false;
477
+ }
478
+
479
+ const parts = trimmed.slice(1).split(/\s+/);
480
+ const command = parts[0].toLowerCase();
481
+ const args = parts.slice(1);
482
+
483
+ switch (command) {
484
+ case 'init':
485
+ await handleInit();
486
+ break;
487
+
488
+ case 'config':
489
+ await handleConfig();
490
+ break;
491
+
492
+ case 'md':
493
+ await handleMd(args);
494
+ break;
495
+
496
+ case 'html':
497
+ await handleHtml(args);
498
+ break;
499
+
500
+ case 'pdf':
501
+ await handlePdf(args);
502
+ break;
503
+
504
+ case 'help':
505
+ case 'h':
506
+ handleHelp();
507
+ break;
508
+
509
+ case 'exit':
510
+ case 'quit':
511
+ case 'q':
512
+ return true;
513
+
514
+ default:
515
+ logger.error(`Unknown command: /${command}`);
516
+
517
+ // Try to suggest a similar command
518
+ const similar = findSimilarCommand(command);
519
+ if (similar) {
520
+ logger.info(`Did you mean /${similar}?`);
521
+ }
522
+
523
+ logger.info('Type /help to see all available commands\n');
524
+
525
+ // Show available commands
526
+ const suggestions = getCommandSuggestions(command);
527
+ if (suggestions.length > 0 && suggestions.length < 5) {
528
+ logger.info('Available commands:');
529
+ suggestions.forEach((cmd) => logger.info(` ${cmd}`));
530
+ console.log('');
531
+ }
532
+ }
533
+
534
+ return false;
535
+ }
@@ -0,0 +1,87 @@
1
+ import prompts from 'prompts';
2
+ import chalk from 'chalk';
3
+ import * as logger from '../utils/logger.js';
4
+ import { executeCommand, getCommandChoices } from './commands.js';
5
+
6
+ /**
7
+ * Display a centered banner with dynamic box sizing
8
+ */
9
+ function showBanner(): void {
10
+ const title = 'NCONV CLI (Notion Converter CLI)';
11
+ const padding = 2;
12
+ const totalWidth = title.length + (padding * 2);
13
+
14
+ const topBorder = 'ā•”' + '═'.repeat(totalWidth) + 'ā•—';
15
+ const bottomBorder = 'ā•š' + '═'.repeat(totalWidth) + 'ā•';
16
+
17
+ console.log(chalk.cyan('\n' + topBorder));
18
+ console.log(chalk.cyan('ā•‘') + chalk.bold(' '.repeat(padding) + title + ' '.repeat(padding)) + chalk.cyan('ā•‘'));
19
+ console.log(chalk.cyan(bottomBorder + '\n'));
20
+
21
+ logger.info('Welcome to nconv interactive mode!');
22
+ logger.info('Type /help to see available commands');
23
+ logger.info('Type /exit to quit\n');
24
+
25
+ // Show quick examples
26
+ console.log(chalk.dim('Quick examples:'));
27
+ console.log(chalk.dim(' /init - Set up Notion tokens'));
28
+ console.log(chalk.dim(' /md <url> - Convert Notion page'));
29
+ console.log(chalk.dim(' /md <url> -o ./blog -f my-post - Convert with options\n'));
30
+ }
31
+
32
+ /**
33
+ * Start the REPL loop
34
+ */
35
+ export async function startRepl(): Promise<void> {
36
+ showBanner();
37
+
38
+ let shouldExit = false;
39
+
40
+ while (!shouldExit) {
41
+ try {
42
+ const response = await prompts({
43
+ type: 'autocomplete',
44
+ name: 'command',
45
+ message: chalk.cyan('nconv'),
46
+ choices: getCommandChoices(),
47
+ suggest: async (input: string, choices: any[]) => {
48
+ // If input doesn't start with /, add it for search
49
+ const searchInput = input.startsWith('/') ? input : `/${input}`;
50
+
51
+ // Filter choices based on input
52
+ const filtered = choices.filter((choice: any) =>
53
+ choice.title.toLowerCase().startsWith(searchInput.toLowerCase())
54
+ );
55
+
56
+ // If user typed a full command or URL, allow it
57
+ if (filtered.length === 0 && input.trim()) {
58
+ return Promise.resolve([{ title: input, value: input }]);
59
+ }
60
+
61
+ return Promise.resolve(filtered);
62
+ },
63
+ limit: 5,
64
+ });
65
+
66
+ if (response.command === undefined) {
67
+ // User cancelled (Ctrl+C)
68
+ console.log('\n');
69
+ logger.info('Goodbye! šŸ‘‹');
70
+ break;
71
+ }
72
+
73
+ shouldExit = await executeCommand(response.command);
74
+
75
+ if (!shouldExit) {
76
+ console.log(); // Empty line for readability
77
+ }
78
+ } catch (error) {
79
+ if (error instanceof Error) {
80
+ logger.error(`Error: ${error.message}`);
81
+ console.log(); // Empty line for readability
82
+ }
83
+ }
84
+ }
85
+
86
+ logger.info('Exiting nconv...');
87
+ }