From a5010cddabd6c26ec4ca23e313235be6a01c61a7 Mon Sep 17 00:00:00 2001 From: Dimash Date: Sun, 25 Jan 2026 04:51:42 +0500 Subject: [PATCH] feat: add stats command for organization statistics and reports - Add period-based statistics (day/week/month) showing files organized - Add top rules analysis with trigger counts and last usage time - Add duplicate files statistics with potential space savings - Add overall statistics with category breakdown - Support --json, --period, --rules, --duplicates options Co-Authored-By: Claude Opus 4.5 --- src/cli.ts | 96 +++++++++++++++- src/storage/database.ts | 150 +++++++++++++++++++++++++ src/ui/table.ts | 240 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 485 insertions(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index 78ea3a5..94d7e45 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,7 +16,15 @@ import { Executor } from './core/executor.js'; import { Watcher } from './core/watcher.js'; import { Database } from './storage/database.js'; import { ModelManager } from './ai/model-manager.js'; -import { renderScanStats, renderFileTable } from './ui/table.js'; +import { + renderScanStats, + renderFileTable, + renderStatsOverview, + renderTopRules, + renderDuplicateStats, + renderOverallStats, + type StatsData, +} from './ui/table.js'; import { logger } from './utils/logger.js'; import { showBanner } from './ui/banner.js'; @@ -652,6 +660,92 @@ program console.log(chalk.yellow('\n Unknown action. Use: list, add, test , edit\n')); }); +// ═══════════════════════════════════════════════════════════════ +// STATS command +// ═══════════════════════════════════════════════════════════════ +program + .command('stats') + .description('Show organization statistics and reports') + .option('--json', 'Output as JSON') + .option('--period ', 'Show stats for specific period (day, week, month)', 'all') + .option('--rules', 'Show only top rules statistics') + .option('--duplicates', 'Show only duplicate statistics') + .action(async (options) => { + const paths = getAppPaths(); + const db = new Database(paths.databaseFile); + await db.init(); + + const spinner = ora('Gathering statistics...').start(); + + try { + // Gather all statistics + const statsData: StatsData = { + day: db.getOperationsByPeriod('day'), + week: db.getOperationsByPeriod('week'), + month: db.getOperationsByPeriod('month'), + topRules: db.getTopRules(10), + duplicates: db.getDuplicateStats(), + deletedDuplicates: db.getDeletedDuplicatesStats(), + overall: db.getStats(), + }; + + spinner.succeed('Statistics gathered'); + + if (options.json) { + console.log(JSON.stringify(statsData, null, 2)); + return; + } + + // Render specific sections or all + if (options.rules) { + renderTopRules(statsData.topRules); + } else if (options.duplicates) { + renderDuplicateStats(statsData); + } else if (options.period !== 'all') { + // Show specific period + const period = options.period as 'day' | 'week' | 'month'; + const periodData = statsData[period]; + + if (!periodData) { + console.log(chalk.red(`\n Invalid period: ${options.period}. Use day, week, or month.\n`)); + return; + } + + const periodNames = { day: 'Today', week: 'This Week', month: 'This Month' }; + console.log(chalk.bold(`\n Statistics for ${periodNames[period]}\n`)); + console.log(` Files organized: ${chalk.cyan(periodData.total.toString())}`); + + if (Object.keys(periodData.byType).length > 0) { + console.log(chalk.bold('\n Operations by type:')); + for (const [type, count] of Object.entries(periodData.byType)) { + const typeColor = type === 'delete' ? chalk.red : chalk.cyan; + console.log(` ${typeColor(type)}: ${count}`); + } + } + + if (Object.keys(periodData.byRule).length > 0) { + console.log(chalk.bold('\n Operations by rule:')); + const sortedRules = Object.entries(periodData.byRule).sort((a, b) => b[1] - a[1]); + for (const [rule, count] of sortedRules.slice(0, 10)) { + console.log(` ${chalk.cyan(rule)}: ${count}`); + } + } + console.log(); + } else { + // Show all statistics + renderStatsOverview(statsData); + renderTopRules(statsData.topRules); + renderDuplicateStats(statsData); + renderOverallStats(statsData); + console.log(); + } + } catch (error) { + spinner.fail('Failed to gather statistics'); + console.error(error); + process.exit(1); + } + }); + // Show animated banner when run without arguments async function main() { const args = process.argv.slice(2); diff --git a/src/storage/database.ts b/src/storage/database.ts index c3f6843..a689863 100644 --- a/src/storage/database.ts +++ b/src/storage/database.ts @@ -420,4 +420,154 @@ export class Database { categories, }; } + + // ═══════════════════════════════════════════════════════════════ + // Extended Stats + // ═══════════════════════════════════════════════════════════════ + + getOperationsByPeriod(period: 'day' | 'week' | 'month'): { + total: number; + byType: Record; + byRule: Record; + } { + const now = Math.floor(Date.now() / 1000); + let since: number; + + switch (period) { + case 'day': + since = now - 24 * 60 * 60; + break; + case 'week': + since = now - 7 * 24 * 60 * 60; + break; + case 'month': + since = now - 30 * 24 * 60 * 60; + break; + } + + const total = this.queryOne<{ count: number }>( + 'SELECT COUNT(*) as count FROM operations WHERE created_at >= ? AND undone_at IS NULL', + [since] + ) || { count: 0 }; + + const byTypeRows = this.queryAll<{ type: string; count: number }>(` + SELECT type, COUNT(*) as count + FROM operations + WHERE created_at >= ? AND undone_at IS NULL + GROUP BY type + ORDER BY count DESC + `, [since]); + + const byType: Record = {}; + for (const row of byTypeRows) { + byType[row.type] = row.count; + } + + const byRuleRows = this.queryAll<{ rule_name: string; count: number }>(` + SELECT rule_name, COUNT(*) as count + FROM operations + WHERE created_at >= ? AND undone_at IS NULL AND rule_name IS NOT NULL + GROUP BY rule_name + ORDER BY count DESC + `, [since]); + + const byRule: Record = {}; + for (const row of byRuleRows) { + byRule[row.rule_name] = row.count; + } + + return { + total: total.count, + byType, + byRule, + }; + } + + getTopRules(limit = 10): { ruleName: string; count: number; lastUsed: number }[] { + const rows = this.queryAll<{ rule_name: string; count: number; last_used: number }>(` + SELECT rule_name, COUNT(*) as count, MAX(created_at) as last_used + FROM operations + WHERE rule_name IS NOT NULL AND undone_at IS NULL + GROUP BY rule_name + ORDER BY count DESC + LIMIT ? + `, [limit]); + + return rows.map(row => ({ + ruleName: row.rule_name, + count: row.count, + lastUsed: row.last_used, + })); + } + + getDuplicateStats(): { + totalGroups: number; + totalDuplicateFiles: number; + potentialSavings: number; + } { + const duplicateHashes = this.queryAll<{ hash: string; count: number; size: number }>(` + SELECT hash, COUNT(*) as count, size + FROM files + WHERE hash IS NOT NULL + GROUP BY hash + HAVING count > 1 + `); + + let totalGroups = 0; + let totalDuplicateFiles = 0; + let potentialSavings = 0; + + for (const row of duplicateHashes) { + totalGroups++; + totalDuplicateFiles += row.count - 1; + potentialSavings += row.size * (row.count - 1); + } + + return { + totalGroups, + totalDuplicateFiles, + potentialSavings, + }; + } + + getDeletedDuplicatesStats(): { + totalDeleted: number; + totalSaved: number; + } { + const result = this.queryOne<{ count: number }>(` + SELECT COUNT(*) as count + FROM operations + WHERE type = 'delete' AND undone_at IS NULL + `) || { count: 0 }; + + // Get sizes of deleted files from operation source paths + // Note: We can't get exact size from deleted files, so we estimate + // based on average file size from duplicates that were deleted + const avgSize = this.queryOne<{ avg_size: number }>(` + SELECT AVG(f.size) as avg_size + FROM files f + JOIN operations o ON f.path = o.source + WHERE o.type = 'delete' + `) || { avg_size: 0 }; + + return { + totalDeleted: result.count, + totalSaved: Math.floor(result.count * (avgSize.avg_size || 0)), + }; + } + + getActivityTimeline(days = 30): { date: string; count: number }[] { + const now = Math.floor(Date.now() / 1000); + const since = now - days * 24 * 60 * 60; + + const rows = this.queryAll<{ date: string; count: number }>(` + SELECT date(created_at, 'unixepoch') as date, COUNT(*) as count + FROM operations + WHERE created_at >= ? AND undone_at IS NULL + GROUP BY date + ORDER BY date ASC + `, [since]); + + return rows; + } } diff --git a/src/ui/table.ts b/src/ui/table.ts index 222b5fb..1064f30 100644 --- a/src/ui/table.ts +++ b/src/ui/table.ts @@ -4,6 +4,34 @@ import { formatSize, formatNumber, colorByCategory } from './colors.js'; import type { FileAnalysis } from '../core/analyzer.js'; import { getCategoryIcon } from '../utils/mime.js'; +export interface PeriodStats { + total: number; + byType: Record; + byRule: Record; +} + +export interface StatsData { + day: PeriodStats; + week: PeriodStats; + month: PeriodStats; + topRules: { ruleName: string; count: number; lastUsed: number }[]; + duplicates: { + totalGroups: number; + totalDuplicateFiles: number; + potentialSavings: number; + }; + deletedDuplicates: { + totalDeleted: number; + totalSaved: number; + }; + overall: { + totalFiles: number; + totalSize: number; + totalOperations: number; + categories: Record; + }; +} + export interface TableOptions { head?: string[]; colWidths?: number[]; @@ -237,3 +265,215 @@ export function renderRulesTable( console.log(table.toString().split('\n').map(line => ' ' + line).join('\n')); } + +export function renderStatsOverview(stats: StatsData): void { + console.log(chalk.bold('\n Organization Statistics\n')); + + // Period stats table + const periodTable = new Table({ + head: [ + chalk.bold('Period'), + chalk.bold('Files Organized'), + chalk.bold('Moves'), + chalk.bold('Copies'), + chalk.bold('Deletes'), + ], + colWidths: [12, 18, 10, 10, 10], + style: { + head: [], + border: ['gray'], + }, + }); + + const periods: Array<{ name: string; data: PeriodStats }> = [ + { name: 'Today', data: stats.day }, + { name: 'This Week', data: stats.week }, + { name: 'This Month', data: stats.month }, + ]; + + for (const period of periods) { + periodTable.push([ + chalk.cyan(period.name), + formatNumber(period.data.total), + formatNumber(period.data.byType['move'] || 0), + formatNumber(period.data.byType['copy'] || 0), + chalk.red(formatNumber(period.data.byType['delete'] || 0)), + ]); + } + + console.log(periodTable.toString().split('\n').map(line => ' ' + line).join('\n')); +} + +export function renderTopRules( + topRules: { ruleName: string; count: number; lastUsed: number }[] +): void { + if (topRules.length === 0) { + console.log(chalk.dim('\n No rules have been triggered yet.\n')); + return; + } + + console.log(chalk.bold('\n Most Active Rules\n')); + + const table = new Table({ + head: [ + chalk.bold('Rule'), + chalk.bold('Triggers'), + chalk.bold('Last Used'), + ], + colWidths: [35, 12, 20], + style: { + head: [], + border: ['gray'], + }, + }); + + for (const rule of topRules) { + const lastUsed = new Date(rule.lastUsed * 1000); + const lastUsedStr = formatRelativeTime(lastUsed); + + table.push([ + chalk.cyan(rule.ruleName), + formatNumber(rule.count), + chalk.dim(lastUsedStr), + ]); + } + + console.log(table.toString().split('\n').map(line => ' ' + line).join('\n')); +} + +export function renderDuplicateStats(stats: StatsData): void { + console.log(chalk.bold('\n Duplicate Files\n')); + + const { duplicates, deletedDuplicates } = stats; + + if (duplicates.totalGroups === 0 && deletedDuplicates.totalDeleted === 0) { + console.log(chalk.dim(' No duplicates found or removed.\n')); + return; + } + + const table = new Table({ + head: [ + chalk.bold('Metric'), + chalk.bold('Value'), + ], + colWidths: [35, 20], + style: { + head: [], + border: ['gray'], + }, + }); + + if (duplicates.totalGroups > 0) { + table.push([ + 'Current duplicate groups', + chalk.yellow(formatNumber(duplicates.totalGroups)), + ]); + table.push([ + 'Duplicate files (excluding originals)', + chalk.yellow(formatNumber(duplicates.totalDuplicateFiles)), + ]); + table.push([ + 'Potential space savings', + chalk.yellow(formatSize(duplicates.potentialSavings)), + ]); + } + + if (deletedDuplicates.totalDeleted > 0) { + table.push([ + 'Duplicates removed', + chalk.green(formatNumber(deletedDuplicates.totalDeleted)), + ]); + if (deletedDuplicates.totalSaved > 0) { + table.push([ + 'Space saved', + chalk.green(formatSize(deletedDuplicates.totalSaved)), + ]); + } + } + + console.log(table.toString().split('\n').map(line => ' ' + line).join('\n')); +} + +export function renderOverallStats(stats: StatsData): void { + console.log(chalk.bold('\n Overall Statistics\n')); + + const { overall } = stats; + + const table = new Table({ + head: [ + chalk.bold('Metric'), + chalk.bold('Value'), + ], + colWidths: [35, 20], + style: { + head: [], + border: ['gray'], + }, + }); + + table.push([ + 'Total files analyzed', + formatNumber(overall.totalFiles), + ]); + table.push([ + 'Total size tracked', + formatSize(overall.totalSize), + ]); + table.push([ + 'Total operations performed', + formatNumber(overall.totalOperations), + ]); + + console.log(table.toString().split('\n').map(line => ' ' + line).join('\n')); + + // Categories breakdown + const categories = Object.entries(overall.categories); + if (categories.length > 0) { + console.log(chalk.bold('\n Files by Category\n')); + + const catTable = new Table({ + head: [ + chalk.bold('Category'), + chalk.bold('Count'), + ], + colWidths: [25, 15], + style: { + head: [], + border: ['gray'], + }, + }); + + const sortedCats = categories.sort((a, b) => b[1] - a[1]); + for (const [category, count] of sortedCats) { + const icon = getCategoryIcon(category as Parameters[0]); + const colorFn = colorByCategory(category); + catTable.push([ + `${icon} ${colorFn(category.charAt(0).toUpperCase() + category.slice(1))}`, + formatNumber(count), + ]); + } + + console.log(catTable.toString().split('\n').map(line => ' ' + line).join('\n')); + } +} + +function formatRelativeTime(date: Date): string { + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSec = Math.floor(diffMs / 1000); + const diffMin = Math.floor(diffSec / 60); + const diffHour = Math.floor(diffMin / 60); + const diffDay = Math.floor(diffHour / 24); + + if (diffSec < 60) { + return 'just now'; + } else if (diffMin < 60) { + return `${diffMin} min ago`; + } else if (diffHour < 24) { + return `${diffHour} hour${diffHour > 1 ? 's' : ''} ago`; + } else if (diffDay < 7) { + return `${diffDay} day${diffDay > 1 ? 's' : ''} ago`; + } else { + return date.toLocaleDateString(); + } +}