import chalk from 'chalk'; import type { Command } from 'commander'; import { eq } from 'drizzle-orm'; import { getDb } from '../../db'; import { articles, feeds } from '../../db/schema'; import { cleanupArticles, getArticleStats, listArticles, markAllRead, markArticleRead, removeBrokenArticles, starArticle, } from '../../fetchers/article'; import { checkUrl, tryFixBrokenUrl } from '../../fetchers/broken-link-repair'; import { createArticlesTable, formatArticleDetail, formatStats } from '../../utils/article-formatter'; import { DEFAULT_ARTICLE_LIMIT, MAX_ARTICLE_LIMIT } from '../../utils/constants'; import { parseDateInput, parseDateRange } from '../../utils/date'; import { sanitizeTerminalText } from '../../utils/terminal'; import { parseHttpUrl, validateIntRange, validatePositiveInt, validateUrl } from '../../utils/validation'; import { assertValid, fail, requireForceForBulkDelete, requireValue } from '../error-handler'; import { runCommand } from '../run-command'; function parseDateWithError(input: string, label: string): Date { const result = parseDateInput(input); if (!result) { fail( `Invalid date for --${label}: "${input}". Use ISO dates (2024-01-01), relative dates (today, yesterday, week, month), or relative expressions (3 days ago).`, ); } return result; } function isDateOnlyInput(input: string): boolean { return /^\d{4}-\d{2}-\d{2}$/.test(input.trim()); } function parseSortField(value: string): 'publishedAt' | 'createdAt' | 'title' { if (value === 'publishedAt' || value === 'createdAt' || value === 'title') { return value; } fail(`Invalid sort field: "${value}". Use publishedAt, createdAt, or title.`); } export function registerArticleCommands(program: Command): void { const articleCmd = program.command('article').alias('articles').description('Manage articles'); // article list articleCmd .command('list') .alias('ls') .description('List articles') .option('-f, --feed ', 'Filter by feed ID') .option('-u, --feed-url ', 'Filter by feed URL') .option('-c, --category ', 'Filter by category') .option('--unread', 'Show only unread articles') .option('--read', 'Show only read articles') .option('--starred', 'Show only starred articles') .option('--broken', 'Show only broken articles') .option('--author ', 'Filter by author') .option('-s, --search ', 'Search in title and content') .option('--from ', 'Articles from date (e.g., "2024-01-01", "today", "week")') .option('--to ', 'Articles to date') .option('--after ', 'Articles after date') .option('--before ', 'Articles before date') .option('--yesterday', 'Articles from yesterday') .option('--today', 'Articles from today') .option('--this-week', 'Articles from this week') .option('--this-month', 'Articles from this month') .option('-l, --limit ', 'Maximum articles to show', String(DEFAULT_ARTICLE_LIMIT)) .option('-o, --offset ', 'Offset for pagination', '0') .option('--sort ', 'Sort by field (publishedAt, createdAt, title)', 'publishedAt') .option('--asc', 'Sort ascending') .option('--desc', 'Sort descending (default)') .action( async (options: { feed?: string; feedUrl?: string; category?: string; unread?: boolean; read?: boolean; starred?: boolean; broken?: boolean; author?: string; search?: string; from?: string; to?: string; after?: string; before?: string; yesterday?: boolean; today?: boolean; thisWeek?: boolean; thisMonth?: boolean; limit: string; offset: string; sort: string; asc?: boolean; desc?: boolean; }) => { await runCommand(program, async (output) => { const limit = requireValue(validateIntRange(options.limit, 'Limit', 1, MAX_ARTICLE_LIMIT)); const offset = requireValue(validateIntRange(options.offset, 'Offset', 0, Number.MAX_SAFE_INTEGER)); const sortBy = parseSortField(options.sort); if (options.feed && options.feedUrl) { fail('Use either --feed or --feed-url, not both'); } if (options.read && options.unread) { fail('Use either --read or --unread, not both'); } if (options.asc && options.desc) { fail('Use either --asc or --desc, not both'); } // Validate feed ID if provided let feedId: number | undefined; if (options.feed) { feedId = requireValue(validatePositiveInt(options.feed, 'Feed ID')); } const feedUrl = options.feedUrl ? requireValue(parseHttpUrl(options.feedUrl)) : undefined; // Parse date filters let startDate: Date | undefined; let endDate: Date | undefined; // Preset date flags and explicit --from/--to flags are mutually // exclusive: silently letting one override the other is surprising. const hasPreset = Boolean(options.today || options.yesterday || options.thisWeek || options.thisMonth); const hasExplicit = Boolean(options.from || options.after || options.to || options.before); const presetCount = [options.today, options.yesterday, options.thisWeek, options.thisMonth].filter( Boolean, ).length; if (presetCount > 1) { fail('Use only one preset date flag (--today, --yesterday, --this-week, or --this-month)'); } if (hasPreset && hasExplicit) { fail( 'Use either a preset date flag (--today, --yesterday, --this-week, --this-month) or --from/--to, not both', ); } if (options.from && options.after) { fail('Use either --from or --after, not both'); } if (options.to && options.before) { fail('Use either --to or --before, not both'); } // Handle preset date filters if (options.today) { startDate = parseDateWithError('today', 'today'); } else if (options.yesterday) { startDate = parseDateWithError('yesterday', 'yesterday'); const today = new Date(); today.setHours(0, 0, 0, 0); endDate = today; } else if (options.thisWeek) { startDate = parseDateWithError('week', 'this-week'); } else if (options.thisMonth) { startDate = parseDateWithError('month', 'this-month'); } // Handle explicit date filters if (options.from || options.after) { const dateInput = options.from || options.after; // Check for date range syntax before treating as single date. // parseDateRange matches "to" case-insensitively, so the gate must too. if (dateInput && (dateInput.includes('..') || /\s+to\s+/i.test(dateInput))) { const range = parseDateRange(dateInput); if (range) { startDate = range.start; endDate = range.end; } else { fail(`Invalid date range: "${dateInput}"`); } } else if (dateInput) { startDate = parseDateWithError(dateInput, 'from/after'); } } if (options.to || options.before) { const dateInput = options.to || options.before; if (dateInput) { const parsed = parseDateWithError(dateInput, 'to/before'); // Only a literal calendar date denotes a whole day. An explicit // timestamp at midnight is still a precise bound and must not be // widened to the end of that day. if (isDateOnlyInput(dateInput)) { parsed.setHours(23, 59, 59, 999); } endDate = parsed; } } const articlesList = await listArticles({ feedId, feedUrl, category: options.category, read: options.read ? true : options.unread ? false : undefined, starred: options.starred, broken: options.broken, author: options.author, search: options.search, startDate, endDate, limit, offset, sortBy, sortOrder: options.asc ? 'asc' : 'desc', }); output.result(articlesList, (list) => list.length === 0 ? chalk.gray('No articles found') : `${createArticlesTable(list)}\n${chalk.gray(`\nShowing ${list.length} articles`)}`, ); }); }, ); // article show articleCmd .command('show ') .description('Show article details') .option('-w, --web', 'Open in browser') .action(async (id: string, options: { web?: boolean }) => { await runCommand(program, async (output) => { const articleId = requireValue(validatePositiveInt(id, 'Article ID')); const db = getDb(); const [article] = await db.select().from(articles).where(eq(articles.id, articleId)).limit(1); if (!article) { fail('Article not found'); } // Get feed info const [feed] = await db.select().from(feeds).where(eq(feeds.id, article.feedId)).limit(1); if (options.web) { // Prefer the repaired link when present: linkFixed is stored precisely // because the original link failed. const targetUrl = article.linkFixed ?? article.link; const urlCheck = validateUrl(targetUrl); if (!urlCheck.valid) { fail(`Refusing to open unsafe URL: ${urlCheck.error}`); } const { default: open } = await import('open'); await open(targetUrl); output.result({ opened: targetUrl }, (data) => chalk.green(`Opened: ${sanitizeTerminalText(data.opened)}`)); return; } const detail = { ...article, feed }; output.result(detail, (data) => formatArticleDetail(data)); }); }); // article read articleCmd .command('read ') .description('Mark article as read') .option('-u, --unread', 'Mark as unread') .action(async (id: string, options: { unread?: boolean }) => { await runCommand(program, async (output) => { const articleId = requireValue(validatePositiveInt(id, 'Article ID')); const article = await markArticleRead(articleId, !options.unread); if (!article) { fail('Article not found'); } const action = options.unread ? 'unread' : 'read'; output.result(article, () => chalk.green(`Article marked as ${action}`)); }); }); // article star articleCmd .command('star ') .description('Star/unstar article') .option('-u, --unstar', 'Remove star') .action(async (id: string, options: { unstar?: boolean }) => { await runCommand(program, async (output) => { const articleId = requireValue(validatePositiveInt(id, 'Article ID')); const result = await starArticle(articleId, !options.unstar); if (!result) { fail('Article not found'); } const action = options.unstar ? 'unstarred' : 'starred'; output.result(result, (data) => chalk.green(data.changed ? `Article ${action}` : `Article was already ${action}`), ); }); }); // article read-all articleCmd .command('read-all') .description('Mark all articles as read') .option('-f, --feed ', 'Only articles from this feed') .option('--older-than ', 'Only articles older than date') .action(async (options: { feed?: string; olderThan?: string }) => { await runCommand(program, async (output) => { let olderThan: Date | undefined; if (options.olderThan) { olderThan = parseDateWithError(options.olderThan, 'older-than'); } let feedId: number | undefined; if (options.feed) { feedId = requireValue(validatePositiveInt(options.feed, 'Feed ID')); } const count = await markAllRead({ feedId, olderThan, }); output.result({ updated: count }, (data) => chalk.green(`Marked ${data.updated} articles as read`)); }); }); // article stats articleCmd .command('stats') .description('Show article statistics') .action(async () => { await runCommand(program, async (output) => { const stats = await getArticleStats(); output.result(stats, (data) => formatStats(data)); }); }); // article check articleCmd .command('check ') .description('Check if a URL is accessible') .option('--fix', 'Try to fix broken URL') .action(async (url: string, options: { fix?: boolean }) => { await runCommand(program, async (output) => { assertValid(validateUrl(url)); const checkSpinner = output.progress('Checking URL...'); const checkResult = await checkUrl(url); checkSpinner.stop(); let fixResult: Awaited> | undefined; if (!checkResult.accessible && options.fix) { const fixSpinner = output.progress('Trying to fix URL...'); fixResult = await tryFixBrokenUrl(url); fixSpinner.stop(); } output.result({ ...checkResult, fix: fixResult }, (data) => { const lines: string[] = []; if (data.accessible) { lines.push(chalk.green(`URL is accessible (Status: ${data.statusCode})`)); if (data.redirectUrl) { lines.push(chalk.gray(` Redirected to: ${sanitizeTerminalText(data.redirectUrl)}`)); } } else { lines.push(chalk.red(`URL is not accessible: ${sanitizeTerminalText(data.error ?? 'Unknown error')}`)); } if (data.fix) { if (data.fix.success && data.fix.fixed) { lines.push(chalk.green(`Fixed URL: ${sanitizeTerminalText(data.fix.fixed)}`)); if (data.fix.method) { lines.push(chalk.gray(` Method: ${sanitizeTerminalText(data.fix.method)}`)); } } else { lines.push(chalk.red('Could not fix URL')); } } return lines.join('\n'); }); }); }); // article cleanup articleCmd .command('cleanup') .description('Remove articles matching criteria') .option('--broken', 'Remove articles whose links are definitively missing (404/410)') .option('--read', 'Remove read articles that are not starred') .option('--older-than ', 'Remove articles older than date') .option('-f, --feed ', 'Only remove from specific feed') .option('--dry-run', 'Show what would be removed without deleting') .option('--force', 'Confirm deletion after previewing with --dry-run') .action( async (options: { broken?: boolean; read?: boolean; olderThan?: string; feed?: string; dryRun?: boolean; force?: boolean; }) => { await runCommand(program, async (output) => { let olderThan: Date | undefined; if (options.olderThan) { olderThan = parseDateWithError(options.olderThan, 'older-than'); } let feedId: number | undefined; if (options.feed) { feedId = requireValue(validatePositiveInt(options.feed, 'Feed ID')); } requireForceForBulkDelete({ command: 'article cleanup', force: options.force, dryRun: options.dryRun }); const spinner = output.progress('Finding articles to remove...'); const result = await cleanupArticles({ broken: options.broken, read: options.read, olderThan, feedId, dryRun: options.dryRun, }); if (options.dryRun) { spinner.warn('Dry run - no articles removed'); const preview = result.preview ?? []; output.result({ dryRun: true, matching: result.matching ?? 0, preview }, (data) => data.preview.length > 0 ? `${createArticlesTable(data.preview)}\n${chalk.yellow(`\nWould remove ${data.matching} articles`)}` : chalk.gray('No articles match the criteria'), ); return; } spinner.stop(); output.result({ removed: result.removed }, (data) => chalk.green(`Removed ${data.removed} articles`)); }); }, ); // article remove-broken articleCmd .command('remove-broken') .description('Remove articles whose links are definitively missing (404/410)') .option('-f, --feed ', 'Only remove from specific feed') .option('--dry-run', 'Show what would be removed without deleting') .option('--force', 'Confirm deletion after previewing with --dry-run') .action(async (options: { feed?: string; dryRun?: boolean; force?: boolean }) => { await runCommand(program, async (output) => { let feedId: number | undefined; if (options.feed) { feedId = requireValue(validatePositiveInt(options.feed, 'Feed ID')); } requireForceForBulkDelete({ command: 'article remove-broken', force: options.force, dryRun: options.dryRun }); const spinner = output.progress('Finding broken articles...'); const result = await removeBrokenArticles({ feedId, dryRun: options.dryRun, }); if (options.dryRun) { spinner.warn('Dry run - no articles removed'); const preview = result.preview ?? []; output.result({ dryRun: true, matching: result.matching ?? 0, preview }, (data) => data.preview.length > 0 ? chalk.gray(`Would remove ${data.matching} broken articles`) : chalk.gray('No broken articles found'), ); return; } spinner.stop(); output.result({ removed: result.removed }, (data) => chalk.green(`Removed ${data.removed} broken articles`)); }); }); }