import chalk from 'chalk'; import Table from 'cli-table3'; import type { Article, Feed } from '../db/schema'; import { truncateUtf16 } from './bounded-text'; import { ARTICLE_TITLE_DISPLAY_LENGTH, FEED_TITLE_IN_LIST_LENGTH, FEED_TITLE_IN_STATS_LENGTH, MAX_CONTENT_SIZE, MAX_DESCRIPTION_SIZE, MAX_DISPLAY_CONTENT_LENGTH, } from './constants'; import { formatDateDisplay } from './date'; import { containsHtml, htmlToMarkdown } from './html'; import { sanitizeTerminalText } from './terminal'; /** * Render an Article list as a CLI table. */ export function createArticlesTable(articles: (Article & { feed?: Feed })[]): string { const table = new Table({ head: [chalk.cyan('ID'), chalk.cyan('Title'), chalk.cyan('Feed'), chalk.cyan('Published'), chalk.cyan('Status')], colWidths: [6, 50, 20, 20, 10], wordWrap: true, }); for (const article of articles) { let status = ''; if (article.isBroken) { status = chalk.red('Broken'); } else if (article.isRead) { status = article.isStarred ? chalk.yellow('★ Read') : chalk.gray('Read'); } else { status = article.isStarred ? chalk.yellow('★ New') : chalk.green('New'); } const published = article.publishedAt ? formatDateDisplay(article.publishedAt) : chalk.gray('Unknown'); table.push([ article.id.toString(), sanitizeTerminalText(article.title || 'Untitled').slice(0, ARTICLE_TITLE_DISPLAY_LENGTH), sanitizeTerminalText(article.feed?.title || 'Unknown').slice(0, FEED_TITLE_IN_LIST_LENGTH), published, status, ]); } return table.toString(); } /** * Render the detailed Article view shown by `article show`. */ export function formatArticleDetail(article: Article & { feed?: Feed }): string { const lines: string[] = []; lines.push(chalk.bold.cyan('═'.repeat(60))); lines.push(chalk.bold.white(sanitizeTerminalText(article.title || 'Untitled'))); lines.push(chalk.bold.cyan('═'.repeat(60))); lines.push(''); lines.push(`${chalk.gray('Feed:')} ${sanitizeTerminalText(article.feed?.title || 'Unknown')}`); lines.push(`${chalk.gray('Link:')} ${sanitizeTerminalText(article.link)}`); lines.push(`${chalk.gray('Author:')} ${sanitizeTerminalText(article.author || 'Unknown')}`); lines.push(`${chalk.gray('Published:')} ${formatDateDisplay(article.publishedAt)}`); lines.push(`${chalk.gray('Added:')} ${formatDateDisplay(article.createdAt)}`); const statusParts: string[] = []; if (article.isRead) statusParts.push(chalk.gray('Read')); else statusParts.push(chalk.green('Unread')); if (article.isStarred) statusParts.push(chalk.yellow('Starred')); if (article.isBroken) statusParts.push(chalk.red(`Broken: ${sanitizeTerminalText(article.brokenReason || 'Unknown')}`)); if (article.linkFixed) statusParts.push(chalk.blue(`Fixed: ${sanitizeTerminalText(article.linkFixed)}`)); lines.push(`${chalk.gray('Status:')} ${statusParts.join(' | ')}`); lines.push(''); lines.push(chalk.bold.cyan('─'.repeat(60))); lines.push(''); if (article.description) { lines.push(chalk.gray('Description:')); const boundedDescription = truncateUtf16(article.description ?? undefined, MAX_DESCRIPTION_SIZE) ?? ''; const rawDescription = containsHtml(boundedDescription) ? htmlToMarkdown(boundedDescription) : boundedDescription; const desc = sanitizeTerminalText(rawDescription, { preserveNewlines: true }); lines.push(desc.slice(0, MAX_DISPLAY_CONTENT_LENGTH)); if (desc.length > MAX_DISPLAY_CONTENT_LENGTH) { lines.push(chalk.gray(`... (${desc.length - MAX_DISPLAY_CONTENT_LENGTH} more characters)`)); } lines.push(''); } if (article.content) { lines.push(chalk.gray('Content:')); const boundedContent = truncateUtf16(article.content ?? undefined, MAX_CONTENT_SIZE) ?? ''; const rawContent = containsHtml(boundedContent) ? htmlToMarkdown(boundedContent) : boundedContent; const content = sanitizeTerminalText(rawContent, { preserveNewlines: true }); lines.push(content.slice(0, MAX_DISPLAY_CONTENT_LENGTH)); if (content.length > MAX_DISPLAY_CONTENT_LENGTH) { lines.push(chalk.gray(`... (${content.length - MAX_DISPLAY_CONTENT_LENGTH} more characters)`)); } } return lines.join('\n'); } /** * Render the Article statistics block (totals + per-Feed breakdown). */ export function formatStats(stats: { total: number; read: number; unread: number; starred: number; broken: number; byFeed: Array<{ feedId: number; feedTitle: string; count: number; unread: number }>; }): string { const lines: string[] = []; lines.push(''); lines.push(chalk.bold.cyan('Article Statistics')); lines.push(chalk.cyan('═'.repeat(40))); lines.push(` Total articles: ${chalk.white(stats.total.toString())}`); lines.push(` Read: ${chalk.gray(stats.read.toString())}`); lines.push(` Unread: ${chalk.green(stats.unread.toString())}`); lines.push(` Starred: ${chalk.yellow(stats.starred.toString())}`); lines.push(` Broken: ${chalk.red(stats.broken.toString())}`); lines.push(''); if (stats.byFeed.length > 0) { lines.push(chalk.bold('By Feed:')); const table = new Table({ head: [chalk.cyan('Feed'), chalk.cyan('Total'), chalk.cyan('Unread')], colWidths: [30, 10, 10], }); for (const f of stats.byFeed.slice(0, 10)) { table.push([ sanitizeTerminalText(f.feedTitle).slice(0, FEED_TITLE_IN_STATS_LENGTH), f.count.toString(), f.unread.toString(), ]); } lines.push(table.toString()); } return lines.join('\n'); }