import chalk from 'chalk'; import type { Command } from 'commander'; import { getDb, schema } from '../../db'; import { feeds } from '../../db/schema'; import { listArticles } from '../../fetchers/article'; import { createFeedAddSession, FeedExistsError } from '../../fetchers/feed'; import { parseOpmlFile, parseOpmlFromUrl } from '../../parsers/opml'; import { MAX_ARTICLE_LIMIT } from '../../utils/constants'; import { escapeXml } from '../../utils/html'; import { formatOpmlImportResult } from '../../utils/opml-formatter'; import { sanitizeTerminalText } from '../../utils/terminal'; import { validateIntRange, validatePositiveInt, validateUrl } from '../../utils/validation'; import { assertValid, fail, requireValue } from '../error-handler'; import { publishExportFile } from '../export-file'; import { runCommand } from '../run-command'; export function registerImportExportCommands(program: Command): void { // Import command const importCmd = program.command('import').description('Import feeds from OPML'); importCmd .argument('', 'OPML file path or URL') .option('-c, --category ', 'Default category for imported feeds') .option('--dry-run', 'Preview import without actually importing') .option('--skip-errors', 'Skip feeds with errors instead of failing') .action(async (source: string, options: { category?: string; dryRun?: boolean; skipErrors?: boolean }) => { await runCommand(program, async (output) => { const isUrl = /^https?:\/\//i.test(source); // Validate URL if it looks like one if (isUrl) { assertValid(validateUrl(source)); } const parseSpinner = output.progress('Parsing OPML...'); // Parse OPML const result = isUrl ? await parseOpmlFromUrl(source) : await parseOpmlFile(source); if (result.errors.length > 0 && !options.skipErrors) { parseSpinner.fail(`OPML contains ${result.errors.length} invalid outline(s)`); throw new Error( `OPML contains ${result.errors.length} invalid outline(s); rerun with --skip-errors to continue`, ); } parseSpinner.succeed('OPML parsed'); // Build URL identity once for the whole import. This covers canonical // legacy rows and within-file duplicates without an O(existing) scan // for every outline. const addSession = await createFeedAddSession(); if (options.dryRun) { let imported = 0; let skipped = result.errors.length; for (const feed of result.feeds) { if (!addSession.claim(feed.xmlUrl)) { skipped++; } else { imported++; } } output.result({ ...result, imported, skipped, dryRun: true }, (data) => { const summary = formatOpmlImportResult(data); return `${summary}\n${chalk.gray('\nDry run - no feeds imported')}`; }); return; } // Import feeds const importSpinner = output.progress('Importing feeds...'); let imported = 0; let skipped = result.errors.length; for (const feed of result.feeds) { try { await addSession.addFeed(feed.xmlUrl, { title: feed.title, titleOverridden: false, category: feed.category || options.category, fetchNow: false, }); imported++; } catch (error) { if (error instanceof FeedExistsError) { skipped++; } else { skipped++; if (!options.skipErrors) { importSpinner.fail(`Failed to import: ${sanitizeTerminalText(feed.title)}`); throw error; } } } } importSpinner.stop(); // Record import const db = getDb(); await db.insert(schema.opmlImports).values({ filePath: source, fileName: source.split('/').pop(), feedsImported: imported, feedsSkipped: skipped, }); output.result( { source, imported, skipped, totalOutlines: result.totalOutlines, validFeeds: result.validFeeds, errors: result.errors, }, (data) => chalk.green(`Imported ${data.imported} feeds (${data.skipped} skipped)`), ); }); }); // Export command const exportCmd = program.command('export').description('Export data'); exportCmd .command('opml') .description('Export feeds to OPML format') .option('-o, --output ', 'Output file (default: stdout)') .option('-f, --force', 'Overwrite an existing output file') .action(async (options: { output?: string; force?: boolean }) => { await runCommand(program, async (output) => { if (options.output === '') { fail('Output file path cannot be empty'); } const db = getDb(); const feedsList = await db.select().from(feeds); const opml = ` RSS Feeds Export ${new Date().toISOString()} ${feedsList.map((f) => ` `).join('\n')} `; if (options.output !== undefined) { await publishExportFile(options.output, opml, options.force); output.result({ opmlPath: options.output, feedCount: feedsList.length }, (data) => chalk.green(`Exported ${data.feedCount} feeds to ${sanitizeTerminalText(data.opmlPath)}`), ); } else { // The exported document IS the essential output, so it must still // reach stdout under --quiet on the stdout path. output.result({ opml, feedCount: feedsList.length }, (data) => data.opml, { essential: true }); } }); }); exportCmd .command('json') .description('Export articles to JSON') .option('-o, --output ', 'Output file (default: stdout)') .option('--force', 'Overwrite an existing output file') .option('-f, --feed ', 'Export only articles from this feed') .option('-l, --limit ', 'Maximum articles to export', '1000') .action(async (options: { output?: string; force?: boolean; feed?: string; limit: string }) => { await runCommand(program, async (output) => { if (options.output === '') { fail('Output file path cannot be empty'); } const limit = requireValue(validateIntRange(options.limit, 'Limit', 1, MAX_ARTICLE_LIMIT)); let feedId: number | undefined; if (options.feed) { feedId = requireValue(validatePositiveInt(options.feed, 'Feed ID')); } const articlesList = await listArticles({ feedId, limit, }); if (options.output !== undefined) { const json = JSON.stringify(articlesList, null, 2); await publishExportFile(options.output, json, options.force); output.result({ jsonPath: options.output, articleCount: articlesList.length }, (data) => chalk.green(`Exported ${data.articleCount} articles to ${sanitizeTerminalText(data.jsonPath)}`), ); } else { // No `-o`: pass the article array as the result data so it round-trips // identically through both pretty mode (JSON.stringify pretty-printed) // and `--json` mode. The exported document IS the essential output, so // it must still reach stdout under --quiet on the stdout path. output.result(articlesList, (data) => JSON.stringify(data, null, 2), { essential: true }); } }); }); }