From 37ab80c62ef3fb6060e423fb124e79d9d0bbec74 Mon Sep 17 00:00:00 2001 From: d1maash Date: Thu, 29 Jan 2026 17:41:15 +0500 Subject: [PATCH] feat: split CLI into modular commands, fix platform bugs, add new features (v1.2.0) Bug fixes: - Fix organize/rename "edit destination" overwrite bug - Fix rules edit to use notepad on Windows - Fix config paths to use APPDATA/LOCALAPPDATA on Windows - Fix trash path detection per platform - Fix expandPath to handle Windows %VAR% variables - Add db.close() in finally blocks across all commands Technical improvements: - Split cli.ts (1198 lines) into 12 modular command files in src/commands/ - Read VERSION from package.json instead of hardcoding - Add graceful shutdown handlers (SIGTERM, uncaughtException, unhandledRejection) - Add AI API key format validation - Add watcher change event handling New features: - Interactive TUI preview command (sortora preview) - Export/import rules (sortora rules export/import) - Batch undo (--last N, --all-recent) - Progress bar for organize command - Summary stats after organize (files moved, size, errors) Co-Authored-By: Claude Opus 4.5 --- package-lock.json | 10 + package.json | 2 +- src/cli.ts | 1181 +----------------------------------- src/commands/ai.ts | 230 +++++++ src/commands/duplicates.ts | 96 +++ src/commands/index.ts | 27 + src/commands/organize.ts | 189 ++++++ src/commands/preview.ts | 168 +++++ src/commands/rename.ts | 238 ++++++++ src/commands/rules.ts | 233 +++++++ src/commands/scan.ts | 133 ++++ src/commands/setup.ts | 79 +++ src/commands/stats.ts | 102 ++++ src/commands/undo.ts | 166 +++++ src/commands/watch.ts | 61 ++ src/config.ts | 120 +++- src/core/watcher.ts | 45 ++ src/index.ts | 4 +- 18 files changed, 1901 insertions(+), 1183 deletions(-) create mode 100644 src/commands/ai.ts create mode 100644 src/commands/duplicates.ts create mode 100644 src/commands/index.ts create mode 100644 src/commands/organize.ts create mode 100644 src/commands/preview.ts create mode 100644 src/commands/rename.ts create mode 100644 src/commands/rules.ts create mode 100644 src/commands/scan.ts create mode 100644 src/commands/setup.ts create mode 100644 src/commands/stats.ts create mode 100644 src/commands/undo.ts create mode 100644 src/commands/watch.ts diff --git a/package-lock.json b/package-lock.json index 88f43da..b23c35d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1879,6 +1879,7 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -2219,6 +2220,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3027,6 +3029,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -3082,6 +3085,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5322,6 +5326,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6490,6 +6495,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6677,6 +6683,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -6744,6 +6751,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7322,6 +7330,7 @@ "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "1.6.1", "@vitest/runner": "1.6.1", @@ -7550,6 +7559,7 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/package.json b/package.json index ddc4800..21de445 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sortora", - "version": "1.1.1", + "version": "1.2.0", "description": "Smart offline file organizer with AI-powered classification. Organize documents, code, and media files intelligently.", "type": "module", "bin": { diff --git a/src/cli.ts b/src/cli.ts index 47aee06..589780d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,35 +1,9 @@ #!/usr/bin/env node import { Command } from 'commander'; -import chalk from 'chalk'; -import ora from 'ora'; -import inquirer from 'inquirer'; -import { resolve } from 'path'; -import { existsSync } from 'fs'; - -import { VERSION, loadConfig, saveConfig, getAppPaths, ensureDirectories, expandPath, getAIProviderConfig, type AIProviderType } from './config.js'; -import { listProviders, type ProviderManagerConfig } from './ai/providers/index.js'; -import { Scanner } from './core/scanner.js'; -import { Analyzer } from './core/analyzer.js'; -import { RuleEngine } from './core/rule-engine.js'; -import { Suggester } from './core/suggester.js'; -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 { SmartRenamer } from './ai/smart-renamer.js'; -import { renameFile } from './actions/rename.js'; -import { - renderScanStats, - renderFileTable, - renderStatsOverview, - renderTopRules, - renderDuplicateStats, - renderOverallStats, - type StatsData, -} from './ui/table.js'; -import { logger } from './utils/logger.js'; +import { VERSION } from './config.js'; import { showBanner } from './ui/banner.js'; +import { registerAllCommands } from './commands/index.js'; const program = new Command(); @@ -38,1142 +12,22 @@ program .description('Offline AI file organizer') .version(VERSION); -// ═══════════════════════════════════════════════════════════════ -// SETUP command -// ═══════════════════════════════════════════════════════════════ -program - .command('setup') - .description('Initial setup - download AI models and create config') - .option('--minimal', 'Skip AI models (~50 MB)') - .option('--full', 'Include all languages for OCR (~120 MB)') - .action(async (options) => { - console.log(chalk.bold('\n Sortora Setup\n')); - - ensureDirectories(); - const paths = getAppPaths(); - - const spinner = ora('Initializing database...').start(); - try { - const db = new Database(paths.databaseFile); - await db.init(); - spinner.succeed('Database initialized'); - } catch (error) { - spinner.fail('Failed to initialize database'); - console.error(error); - process.exit(1); - } - - if (!options.minimal) { - const modelManager = new ModelManager(paths.modelsDir); - - console.log(chalk.dim('\n Downloading AI models...\n')); - - const modelSpinner = ora('Loading embedding model (MiniLM ~23 MB)...').start(); - try { - await modelManager.loadEmbeddings(); - modelSpinner.succeed('Embedding model loaded'); - } catch (error) { - modelSpinner.fail('Failed to load embedding model'); - logger.error('Embedding model error:', error); - } - - const classifierSpinner = ora('Loading classifier model (MobileBERT ~25 MB)...').start(); - try { - await modelManager.loadClassifier(); - classifierSpinner.succeed('Classifier model loaded'); - } catch (error) { - classifierSpinner.fail('Failed to load classifier model'); - logger.error('Classifier model error:', error); - } - - const ocrSpinner = ora('Loading OCR engine (Tesseract ~15 MB)...').start(); - try { - await modelManager.loadOCR(['eng']); - ocrSpinner.succeed('OCR engine loaded'); - } catch (error) { - ocrSpinner.fail('Failed to load OCR engine'); - logger.error('OCR engine error:', error); - } - } - - // Create default config - const config = loadConfig(); - saveConfig(config); - - console.log(chalk.green('\n Setup complete!\n')); - console.log(chalk.dim(` Config: ${paths.configFile}`)); - console.log(chalk.dim(` Database: ${paths.databaseFile}`)); - console.log(chalk.dim(` Models: ${paths.modelsDir}\n`)); - console.log(chalk.cyan(' Run: sortora scan ~/Downloads\n')); - }); - -// ═══════════════════════════════════════════════════════════════ -// SCAN command -// ═══════════════════════════════════════════════════════════════ -program - .command('scan ') - .description('Scan a directory and analyze files') - .option('-d, --deep', 'Scan recursively') - .option('--duplicates', 'Find duplicate files') - .option('--ai', 'Use AI for smart classification') - .option('--provider ', 'AI provider to use (local, openai, anthropic, gemini, ollama)') - .option('--json', 'Output as JSON') - .action(async (targetPath, options) => { - const fullPath = resolve(expandPath(targetPath)); - - if (!existsSync(fullPath)) { - console.error(chalk.red(`Path not found: ${fullPath}`)); - process.exit(1); - } - - console.log(chalk.bold(`\n Scanning ${chalk.cyan(fullPath)}...\n`)); - - const paths = getAppPaths(); - const db = new Database(paths.databaseFile); - await db.init(); - - const scanner = new Scanner(db); - const analyzer = new Analyzer(paths.modelsDir); - - // Enable AI if requested - if (options.ai) { - const config = loadConfig(); - const aiConfig = getAIProviderConfig(config); - - // Override provider if specified via CLI - const providerType = (options.provider as AIProviderType) || aiConfig.provider; - - const providerConfig: ProviderManagerConfig = { - provider: providerType, - openai: aiConfig.openai, - anthropic: aiConfig.anthropic, - gemini: aiConfig.gemini, - ollama: aiConfig.ollama, - local: { modelsDir: paths.modelsDir }, - }; - - const providerName = listProviders().find(p => p.type === providerType)?.name || providerType; - const aiSpinner = ora(`Loading AI provider (${providerName})...`).start(); - - try { - await analyzer.enableAIWithProvider(providerConfig); - aiSpinner.succeed(`AI provider loaded: ${analyzer.getActiveProviderName()}`); - } catch (error) { - aiSpinner.fail(`Failed to load AI provider. ${error instanceof Error ? error.message : ''}`); - logger.error('AI error:', error); - } - } - - const spinner = ora('Scanning files...').start(); - - try { - const files = await scanner.scan(fullPath, { - recursive: options.deep || false, - findDuplicates: options.duplicates || false, - }); - - spinner.succeed(`Found ${files.length} files`); - - if (files.length === 0) { - console.log(chalk.yellow('\n No files found.\n')); - return; - } - - const analyzeSpinner = ora('Analyzing files...').start(); - const analyzed = await analyzer.analyzeMany(files, { useAI: options.ai && analyzer.isAIEnabled() }); - analyzeSpinner.succeed('Analysis complete'); - - if (options.json) { - console.log(JSON.stringify(analyzed, null, 2)); - } else { - renderScanStats(analyzed); - - // Show file list with sizes - console.log(chalk.bold('\n Files:\n')); - renderFileTable(analyzed); - - // Show AI classifications if enabled - if (options.ai && analyzer.isAIEnabled()) { - console.log(chalk.bold('\n AI Classifications:\n')); - for (const file of analyzed.slice(0, 10)) { - if (file.aiCategory) { - const confidence = Math.round((file.aiConfidence || 0) * 100); - console.log(chalk.dim(` ${file.filename}`)); - console.log(chalk.green(` → ${file.aiCategory} (${confidence}%)\n`)); - } - } - if (analyzed.length > 10) { - console.log(chalk.dim(` ... and ${analyzed.length - 10} more files\n`)); - } - } - - if (options.duplicates) { - const duplicates = scanner.findDuplicates(analyzed); - if (duplicates.length > 0) { - console.log(chalk.yellow(`\n Found ${duplicates.length} duplicate groups\n`)); - } - } - - console.log(chalk.cyan(`\n Run: sortora organize ${targetPath}\n`)); - } - } catch (error) { - spinner.fail('Scan failed'); - console.error(error); - process.exit(1); - } - }); - -// ═══════════════════════════════════════════════════════════════ -// ORGANIZE command -// ═══════════════════════════════════════════════════════════════ -program - .command('organize ') - .description('Organize files based on rules') - .option('-d, --deep', 'Scan subdirectories recursively') - .option('--dry-run', 'Show what would be done without making changes') - .option('-i, --interactive', 'Confirm each action') - .option('--auto', 'Apply actions automatically') - .option('--global', 'Move files to global destinations (~/Documents, ~/Pictures, etc.)') - .option('--confidence ', 'Minimum confidence for auto mode (0-1)', '0.8') - .action(async (targetPath, options) => { - const fullPath = resolve(expandPath(targetPath)); - - if (!existsSync(fullPath)) { - console.error(chalk.red(`Path not found: ${fullPath}`)); - process.exit(1); - } - - const modeText = options.global - ? chalk.yellow('(global mode - files will be moved to ~/Documents, etc.)') - : chalk.green('(local mode - files will be organized within the directory)'); - - console.log(chalk.bold(`\n Organizing ${chalk.cyan(fullPath)}...`)); - console.log(` ${modeText}\n`); - - const config = loadConfig(); - const paths = getAppPaths(); - const db = new Database(paths.databaseFile); - await db.init(); - - const scanner = new Scanner(db); - const analyzer = new Analyzer(paths.modelsDir); - const ruleEngine = new RuleEngine(config); - const suggester = new Suggester(ruleEngine, config); - const executor = new Executor(db); - - const spinner = ora('Scanning files...').start(); - const files = await scanner.scan(fullPath, { recursive: options.deep || false }); - spinner.succeed(`Found ${files.length} files`); - - if (files.length === 0) { - console.log(chalk.yellow('\n No files to organize.\n')); - return; - } - - const analyzeSpinner = ora('Analyzing files...').start(); - const analyzed = await analyzer.analyzeMany(files); - analyzeSpinner.succeed('Analysis complete'); - - // Generate suggestions with local or global destinations - const suggestions = suggester.generateSuggestions(analyzed, { - baseDir: fullPath, - useGlobalDestinations: options.global || false, - }); - - if (suggestions.length === 0) { - console.log(chalk.yellow('\n No suggestions - files already organized.\n')); - return; - } - - console.log(chalk.bold(`\n ${suggestions.length} suggestions:\n`)); - - if (options.dryRun) { - for (const suggestion of suggestions) { - console.log(chalk.dim(` ${suggestion.file.filename}`)); - console.log(chalk.cyan(` -> ${suggestion.destination}`)); - console.log(chalk.dim(` Rule: ${suggestion.ruleName} (${Math.round(suggestion.confidence * 100)}%)\n`)); - } - return; - } - - const minConfidence = parseFloat(options.confidence); - - for (let i = 0; i < suggestions.length; i++) { - const suggestion = suggestions[i]; - const progress = `[${i + 1}/${suggestions.length}]`; - - console.log(chalk.bold(`\n ${progress} ${suggestion.file.filename}`)); - console.log(chalk.dim(` ${suggestion.file.path}`)); - console.log(chalk.cyan(` -> ${suggestion.destination}`)); - console.log(chalk.dim(` Rule: ${suggestion.ruleName} (${Math.round(suggestion.confidence * 100)}%)`)); - - let shouldExecute = false; - - if (options.auto && suggestion.confidence >= minConfidence) { - shouldExecute = true; - } else if (options.interactive || !options.auto) { - const { action } = await inquirer.prompt([{ - type: 'list', - name: 'action', - message: 'Action:', - choices: [ - { name: 'Accept', value: 'accept' }, - { name: 'Skip', value: 'skip' }, - { name: 'Edit destination', value: 'edit' }, - { name: 'Quit', value: 'quit' }, - ], - }]); - - if (action === 'quit') { - console.log(chalk.yellow('\n Stopped.\n')); - break; - } - - if (action === 'edit') { - const { newDest } = await inquirer.prompt([{ - type: 'input', - name: 'newDest', - message: 'New destination:', - default: suggestion.destination, - }]); - suggestion.destination = expandPath(newDest); - shouldExecute = true; - } - - shouldExecute = action === 'accept'; - } - - if (shouldExecute) { - try { - await executor.execute(suggestion); - console.log(chalk.green(' Done')); - } catch (error) { - console.log(chalk.red(` Error: ${error}`)); - } - } else { - console.log(chalk.dim(' Skipped')); - } - } - - console.log(chalk.green('\n Organization complete!\n')); - }); - -// ═══════════════════════════════════════════════════════════════ -// WATCH command -// ═══════════════════════════════════════════════════════════════ -program - .command('watch ') - .description('Monitor a directory for new files') - .option('--auto', 'Automatically organize new files') - .action(async (targetPath, options) => { - const fullPath = resolve(expandPath(targetPath)); - - if (!existsSync(fullPath)) { - console.error(chalk.red(`Path not found: ${fullPath}`)); - process.exit(1); - } - - console.log(chalk.bold(`\n Watching ${chalk.cyan(fullPath)}`)); - console.log(chalk.dim(' Press Ctrl+C to stop\n')); - - const config = loadConfig(); - const paths = getAppPaths(); - const db = new Database(paths.databaseFile); - await db.init(); - - const watcher = new Watcher(db, config, paths.modelsDir); - - watcher.on('file', (file) => { - const time = new Date().toLocaleTimeString(); - console.log(chalk.dim(`${time} |`) + chalk.cyan(` New: ${file.filename}`)); - }); - - watcher.on('organized', (_file, destination) => { - console.log(chalk.green(` -> ${destination}`)); - }); - - watcher.on('error', (error) => { - console.error(chalk.red(` Error: ${error.message}`)); - }); - - await watcher.start(fullPath, { auto: options.auto || false }); - - process.on('SIGINT', () => { - watcher.stop(); - console.log(chalk.yellow('\n Stopped watching.\n')); - process.exit(0); - }); - }); - -// ═══════════════════════════════════════════════════════════════ -// DUPLICATES command -// ═══════════════════════════════════════════════════════════════ -program - .command('duplicates ') - .description('Find and manage duplicate files') - .option('--clean', 'Remove duplicates interactively') - .action(async (targetPath, options) => { - const fullPath = resolve(expandPath(targetPath)); - - if (!existsSync(fullPath)) { - console.error(chalk.red(`Path not found: ${fullPath}`)); - process.exit(1); - } - - console.log(chalk.bold(`\n Finding duplicates in ${chalk.cyan(fullPath)}...\n`)); - - const paths = getAppPaths(); - const db = new Database(paths.databaseFile); - await db.init(); - - const scanner = new Scanner(db); - - const spinner = ora('Scanning files...').start(); - const files = await scanner.scan(fullPath, { recursive: true, findDuplicates: true }); - spinner.succeed(`Scanned ${files.length} files`); - - const hashSpinner = ora('Computing file hashes...').start(); - const analyzer = new Analyzer(paths.modelsDir); - const analyzed = await analyzer.analyzeMany(files); - hashSpinner.succeed('Hashes computed'); - - const duplicates = scanner.findDuplicates(analyzed); - - if (duplicates.length === 0) { - console.log(chalk.green('\n No duplicates found!\n')); - return; - } - - let totalSize = 0; - for (const group of duplicates) { - const wastedSize = group.files.slice(1).reduce((sum, f) => sum + f.size, 0); - totalSize += wastedSize; - } - - console.log(chalk.yellow(`\n Found ${duplicates.length} duplicate groups`)); - console.log(chalk.yellow(` ${(totalSize / 1024 / 1024).toFixed(1)} MB could be freed\n`)); - - for (const group of duplicates) { - console.log(chalk.bold(`\n Hash: ${group.hash.slice(0, 12)}...`)); - for (const file of group.files) { - console.log(chalk.dim(` - ${file.path}`)); - } - } - - if (options.clean) { - const { confirm } = await inquirer.prompt([{ - type: 'confirm', - name: 'confirm', - message: 'Remove duplicates (keep first of each)?', - default: false, - }]); - - if (confirm) { - const executor = new Executor(db); - for (const group of duplicates) { - for (const file of group.files.slice(1)) { - await executor.delete(file.path, true); - console.log(chalk.red(` Deleted: ${file.path}`)); - } - } - console.log(chalk.green('\n Duplicates removed!\n')); - } - } - }); - -// ═══════════════════════════════════════════════════════════════ -// UNDO command -// ═══════════════════════════════════════════════════════════════ -program - .command('undo') - .description('Undo recent operations') - .option('--all', 'Show all operations') - .option('--id ', 'Undo specific operation by ID') - .action(async (options) => { - const paths = getAppPaths(); - const db = new Database(paths.databaseFile); - await db.init(); - - const executor = new Executor(db); - - if (options.all) { - const operations = db.getOperations(50); - if (operations.length === 0) { - console.log(chalk.yellow('\n No operations to show.\n')); - return; - } - - console.log(chalk.bold('\n Recent operations:\n')); - for (const op of operations) { - const date = new Date(op.createdAt * 1000).toLocaleString(); - const undone = op.undoneAt ? chalk.dim(' (undone)') : ''; - console.log(chalk.dim(` #${op.id}`) + ` ${op.type}: ${op.source}${undone}`); - console.log(chalk.dim(` ${date}`)); - } - return; - } - - if (options.id) { - const id = parseInt(options.id, 10); - const success = await executor.undo(id); - if (success) { - console.log(chalk.green(`\n Operation #${id} undone.\n`)); - } else { - console.log(chalk.red(`\n Could not undo operation #${id}.\n`)); - } - return; - } - - // Undo last operation - const operations = db.getOperations(1); - if (operations.length === 0) { - console.log(chalk.yellow('\n No operations to undo.\n')); - return; - } +registerAllCommands(program); - const lastOp = operations[0]; - if (lastOp.undoneAt) { - console.log(chalk.yellow('\n Last operation already undone.\n')); - return; - } +// #8: Graceful shutdown handlers +process.on('SIGTERM', () => { + process.exit(0); +}); - const { confirm } = await inquirer.prompt([{ - type: 'confirm', - name: 'confirm', - message: `Undo ${lastOp.type}: ${lastOp.source}?`, - default: true, - }]); +process.on('uncaughtException', (error) => { + console.error('Uncaught exception:', error.message); + process.exit(1); +}); - if (confirm) { - const success = await executor.undo(lastOp.id); - if (success) { - console.log(chalk.green('\n Undone.\n')); - } else { - console.log(chalk.red('\n Could not undo.\n')); - } - } - }); - -// ═══════════════════════════════════════════════════════════════ -// RENAME command - Smart file renaming with AI -// ═══════════════════════════════════════════════════════════════ -program - .command('rename ') - .description('Smart rename files using EXIF data and AI analysis') - .option('-d, --deep', 'Scan subdirectories recursively') - .option('--ai', 'Use AI for content-based naming') - .option('--ocr', 'Use OCR to read text from images') - .option('--dry-run', 'Preview renames without making changes') - .option('--auto', 'Apply renames automatically without confirmation') - .option('--event ', 'Event or trip name to use in filenames') - .option('--lang ', 'Language for names (ru/en)', 'ru') - .option('--json', 'Output suggestions as JSON') - .action(async (targetPath, options) => { - const fullPath = resolve(expandPath(targetPath)); - - if (!existsSync(fullPath)) { - console.error(chalk.red(`Path not found: ${fullPath}`)); - process.exit(1); - } - - console.log(chalk.bold(`\n Smart Rename: ${chalk.cyan(fullPath)}\n`)); - - const paths = getAppPaths(); - const db = new Database(paths.databaseFile); - await db.init(); - - const renamer = new SmartRenamer(paths.modelsDir, { - language: options.lang === 'en' ? 'en' : 'ru', - }); - - // Enable AI if requested - if (options.ai) { - const aiSpinner = ora('Loading AI models...').start(); - try { - await renamer.enableAI(); - aiSpinner.succeed('AI models loaded'); - } catch (error) { - aiSpinner.fail('Failed to load AI models. Run "sortora setup" first.'); - logger.error('AI error:', error); - } - } - - // Enable OCR if requested - if (options.ocr) { - const ocrSpinner = ora('Loading OCR engine...').start(); - try { - await renamer.enableOCR(['eng', 'rus']); - ocrSpinner.succeed('OCR engine loaded'); - } catch (error) { - ocrSpinner.fail('Failed to load OCR. Run "sortora setup" first.'); - logger.error('OCR error:', error); - } - } - - // Scan for files - const scanner = new Scanner(db); - const spinner = ora('Scanning files...').start(); - - try { - const files = await scanner.scan(fullPath, { - recursive: options.deep || false, - }); - - spinner.succeed(`Found ${files.length} files`); - - if (files.length === 0) { - console.log(chalk.yellow('\n No files found.\n')); - return; - } - - // Filter files that need renaming - const filesToRename = files.filter(f => renamer.isUnreadable(f.filename)); - - if (filesToRename.length === 0) { - console.log(chalk.green('\n All files already have readable names!\n')); - return; - } - - console.log(chalk.dim(`\n ${filesToRename.length} files need renaming:\n`)); - - // Generate suggestions - const suggestSpinner = ora('Analyzing files and generating names...').start(); - const suggestions = await renamer.suggestBatch( - filesToRename.map(f => f.path), - { - useAI: options.ai && renamer.isAIEnabled(), - useOCR: options.ocr, - eventHint: options.event, - groupByDate: true, - groupByLocation: true, - } - ); - suggestSpinner.succeed('Suggestions generated'); - - // JSON output - if (options.json) { - console.log(JSON.stringify(suggestions, null, 2)); - return; - } - - // Dry run - just show suggestions - if (options.dryRun) { - console.log(chalk.bold('\n Suggested renames (dry run):\n')); - for (const suggestion of suggestions) { - const confidence = Math.round(suggestion.confidence * 100); - const confidenceColor = confidence >= 70 ? chalk.green : confidence >= 50 ? chalk.yellow : chalk.red; - - console.log(chalk.dim(` ${suggestion.original}`)); - console.log(chalk.cyan(` → ${suggestion.suggested}`)); - console.log(chalk.dim(` ${suggestion.reason} (${confidenceColor(confidence + '%')})\n`)); - } - return; - } - - // Process renames - let renamed = 0; - let skipped = 0; - - for (let i = 0; i < suggestions.length; i++) { - const suggestion = suggestions[i]; - const file = filesToRename[i]; - const progress = `[${i + 1}/${suggestions.length}]`; - - // Skip files with same name - if (suggestion.original === suggestion.suggested) { - continue; - } - - const confidence = Math.round(suggestion.confidence * 100); - const confidenceColor = confidence >= 70 ? chalk.green : confidence >= 50 ? chalk.yellow : chalk.red; - - console.log(chalk.bold(`\n ${progress} ${suggestion.original}`)); - console.log(chalk.cyan(` → ${suggestion.suggested}`)); - console.log(chalk.dim(` ${suggestion.reason} (${confidenceColor(confidence + '%')})`)); - - let shouldRename = false; - - if (options.auto) { - // Auto mode: rename if confidence >= 50% - shouldRename = suggestion.confidence >= 0.5; - if (!shouldRename) { - console.log(chalk.yellow(' Skipped (low confidence)')); - skipped++; - } - } else { - // Interactive mode - const { action } = await inquirer.prompt([{ - type: 'list', - name: 'action', - message: 'Action:', - choices: [ - { name: 'Accept', value: 'accept' }, - { name: 'Edit name', value: 'edit' }, - { name: 'Skip', value: 'skip' }, - { name: 'Skip all remaining', value: 'quit' }, - ], - }]); - - if (action === 'quit') { - console.log(chalk.yellow('\n Stopped.\n')); - break; - } - - if (action === 'edit') { - const { newName } = await inquirer.prompt([{ - type: 'input', - name: 'newName', - message: 'New name:', - default: suggestion.suggested, - }]); - suggestion.suggested = newName; - shouldRename = true; - } - - shouldRename = action === 'accept' || action === 'edit'; - - if (action === 'skip') { - skipped++; - } - } - - if (shouldRename) { - try { - const result = await renameFile(file.path, suggestion.suggested, { - preserveExtension: true, - }); - - if (result.success) { - // Log to database for undo support - db.insertOperation({ - type: 'rename', - source: file.path, - destination: result.destination, - ruleName: null, - confidence: suggestion.confidence, - }); - - console.log(chalk.green(' ✓ Renamed')); - renamed++; - } else { - console.log(chalk.red(` ✗ ${result.error}`)); - } - } catch (error) { - console.log(chalk.red(` ✗ Error: ${error}`)); - } - } - } - - console.log(chalk.bold('\n Summary:')); - console.log(chalk.green(` Renamed: ${renamed} files`)); - if (skipped > 0) { - console.log(chalk.yellow(` Skipped: ${skipped} files`)); - } - console.log(chalk.dim(`\n Run "sortora undo" to revert changes.\n`)); - - } catch (error) { - spinner.fail('Rename failed'); - console.error(error); - process.exit(1); - } - }); - -// ═══════════════════════════════════════════════════════════════ -// RULES command -// ═══════════════════════════════════════════════════════════════ -program - .command('rules') - .description('Manage organization rules') - .argument('[action]', 'list, add, test , edit') - .argument('[file]', 'File path for test action') - .action(async (action, file) => { - const config = loadConfig(); - const paths = getAppPaths(); - - if (!action || action === 'list') { - console.log(chalk.bold('\n Organization Rules:\n')); - - if (config.rules.length === 0) { - console.log(chalk.yellow(' No custom rules defined.')); - console.log(chalk.dim(' Using default presets.\n')); - return; - } - - for (const rule of config.rules.sort((a, b) => b.priority - a.priority)) { - console.log(chalk.cyan(` ${rule.name}`) + chalk.dim(` (priority: ${rule.priority})`)); - if (rule.match.extension) { - console.log(chalk.dim(` Extensions: ${rule.match.extension.join(', ')}`)); - } - if (rule.match.filename) { - console.log(chalk.dim(` Patterns: ${rule.match.filename.join(', ')}`)); - } - if (rule.action.moveTo) { - console.log(chalk.dim(` -> ${rule.action.moveTo}`)); - } - console.log(); - } - return; - } - - if (action === 'test' && file) { - const fullPath = resolve(expandPath(file)); - if (!existsSync(fullPath)) { - console.error(chalk.red(`File not found: ${fullPath}`)); - process.exit(1); - } - - const db = new Database(paths.databaseFile); - await db.init(); - - const analyzer = new Analyzer(paths.modelsDir); - const ruleEngine = new RuleEngine(config); - - const spinner = ora('Analyzing file...').start(); - const analysis = await analyzer.analyze(fullPath); - spinner.succeed('Analysis complete'); - - const matchedRule = ruleEngine.match(analysis); - - if (matchedRule) { - console.log(chalk.green(`\n Matched rule: ${matchedRule.rule.name}`)); - console.log(chalk.dim(` Priority: ${matchedRule.rule.priority}`)); - if (matchedRule.destination) { - console.log(chalk.cyan(` Destination: ${matchedRule.destination}\n`)); - } - } else { - console.log(chalk.yellow('\n No matching rule found.\n')); - } - return; - } - - if (action === 'add') { - const answers = await inquirer.prompt([ - { - type: 'input', - name: 'name', - message: 'Rule name:', - }, - { - type: 'input', - name: 'extensions', - message: 'File extensions (comma-separated, e.g., jpg,png):', - }, - { - type: 'input', - name: 'patterns', - message: 'Filename patterns (comma-separated, e.g., Screenshot*):', - }, - { - type: 'input', - name: 'destination', - message: 'Destination folder:', - }, - { - type: 'number', - name: 'priority', - message: 'Priority (1-100):', - default: 50, - }, - ]); - - const newRule = { - name: answers.name, - priority: answers.priority, - match: { - extension: answers.extensions ? answers.extensions.split(',').map((s: string) => s.trim()) : undefined, - filename: answers.patterns ? answers.patterns.split(',').map((s: string) => s.trim()) : undefined, - }, - action: { - moveTo: answers.destination, - }, - }; - - config.rules.push(newRule); - saveConfig(config); - - console.log(chalk.green(`\n Rule "${answers.name}" added.\n`)); - return; - } - - if (action === 'edit') { - const editor = process.env.EDITOR || 'nano'; - const { spawn } = await import('child_process'); - spawn(editor, [paths.configFile], { stdio: 'inherit' }); - return; - } - - 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); - } - }); - -// ═══════════════════════════════════════════════════════════════ -// AI command - manage AI providers -// ═══════════════════════════════════════════════════════════════ -program - .command('ai') - .description('Manage AI providers for classification') - .argument('[action]', 'list, set, test, info') - .argument('[provider]', 'Provider name for set action (openai, anthropic, gemini, ollama, local)') - .action(async (action, provider) => { - const config = loadConfig(); - const paths = getAppPaths(); - const aiConfig = getAIProviderConfig(config); - - if (!action || action === 'list') { - console.log(chalk.bold('\n Available AI Providers:\n')); - - const providers = listProviders(); - for (const p of providers) { - const isActive = p.type === aiConfig.provider; - const marker = isActive ? chalk.green('●') : chalk.dim('○'); - const name = isActive ? chalk.green(p.name) : p.name; - console.log(` ${marker} ${name}`); - console.log(chalk.dim(` ${p.description}`)); - - // Show configuration status - if (p.type === 'openai' && aiConfig.openai?.apiKey) { - console.log(chalk.dim(` API Key: ****${aiConfig.openai.apiKey.slice(-4)}`)); - } - if (p.type === 'anthropic' && aiConfig.anthropic?.apiKey) { - console.log(chalk.dim(` API Key: ****${aiConfig.anthropic.apiKey.slice(-4)}`)); - } - if (p.type === 'gemini' && aiConfig.gemini?.apiKey) { - console.log(chalk.dim(` API Key: ****${aiConfig.gemini.apiKey.slice(-4)}`)); - } - if (p.type === 'ollama') { - console.log(chalk.dim(` URL: ${aiConfig.ollama?.baseUrl || 'http://localhost:11434'}`)); - } - console.log(); - } - - console.log(chalk.dim(' Set provider: sortora ai set ')); - console.log(chalk.dim(' Test provider: sortora ai test\n')); - return; - } - - if (action === 'info') { - console.log(chalk.bold('\n Current AI Configuration:\n')); - console.log(chalk.cyan(` Provider: ${aiConfig.provider}`)); - - if (aiConfig.provider === 'openai') { - console.log(chalk.dim(` Model: ${aiConfig.openai?.model || 'gpt-4o-mini'}`)); - console.log(chalk.dim(` API Key: ${aiConfig.openai?.apiKey ? '****' + aiConfig.openai.apiKey.slice(-4) : 'not set'}`)); - } else if (aiConfig.provider === 'anthropic') { - console.log(chalk.dim(` Model: ${aiConfig.anthropic?.model || 'claude-3-haiku-20240307'}`)); - console.log(chalk.dim(` API Key: ${aiConfig.anthropic?.apiKey ? '****' + aiConfig.anthropic.apiKey.slice(-4) : 'not set'}`)); - } else if (aiConfig.provider === 'gemini') { - console.log(chalk.dim(` Model: ${aiConfig.gemini?.model || 'gemini-1.5-flash'}`)); - console.log(chalk.dim(` API Key: ${aiConfig.gemini?.apiKey ? '****' + aiConfig.gemini.apiKey.slice(-4) : 'not set'}`)); - } else if (aiConfig.provider === 'ollama') { - console.log(chalk.dim(` Model: ${aiConfig.ollama?.model || 'llama3.2'}`)); - console.log(chalk.dim(` URL: ${aiConfig.ollama?.baseUrl || 'http://localhost:11434'}`)); - } else if (aiConfig.provider === 'local') { - console.log(chalk.dim(` Models directory: ${paths.modelsDir}`)); - } - - console.log(chalk.dim('\n Environment variables:')); - console.log(chalk.dim(` SORTORA_AI_PROVIDER: ${process.env.SORTORA_AI_PROVIDER || '(not set)'}`)); - console.log(chalk.dim(` OPENAI_API_KEY: ${process.env.OPENAI_API_KEY ? 'set' : '(not set)'}`)); - console.log(chalk.dim(` ANTHROPIC_API_KEY: ${process.env.ANTHROPIC_API_KEY ? 'set' : '(not set)'}`)); - console.log(chalk.dim(` GEMINI_API_KEY: ${process.env.GEMINI_API_KEY ? 'set' : '(not set)'}`)); - console.log(chalk.dim(` OLLAMA_HOST: ${process.env.OLLAMA_HOST || '(not set)'}\n`)); - return; - } - - if (action === 'set') { - if (!provider) { - console.log(chalk.red('\n Please specify a provider: openai, anthropic, gemini, ollama, local\n')); - return; - } - - const validProviders = ['local', 'openai', 'anthropic', 'gemini', 'ollama']; - if (!validProviders.includes(provider)) { - console.log(chalk.red(`\n Unknown provider: ${provider}`)); - console.log(chalk.dim(` Available: ${validProviders.join(', ')}\n`)); - return; - } - - // Check if API key is required - if (provider === 'openai' && !aiConfig.openai?.apiKey) { - const { apiKey } = await inquirer.prompt([{ - type: 'password', - name: 'apiKey', - message: 'Enter your OpenAI API key:', - mask: '*', - }]); - config.ai.openai.apiKey = apiKey; - } - - if (provider === 'anthropic' && !aiConfig.anthropic?.apiKey) { - const { apiKey } = await inquirer.prompt([{ - type: 'password', - name: 'apiKey', - message: 'Enter your Anthropic API key:', - mask: '*', - }]); - config.ai.anthropic.apiKey = apiKey; - } - - if (provider === 'gemini' && !aiConfig.gemini?.apiKey) { - const { apiKey } = await inquirer.prompt([{ - type: 'password', - name: 'apiKey', - message: 'Enter your Gemini API key:', - mask: '*', - }]); - config.ai.gemini.apiKey = apiKey; - } - - config.ai.provider = provider as AIProviderType; - saveConfig(config); - - console.log(chalk.green(`\n AI provider set to: ${provider}\n`)); - return; - } - - if (action === 'test') { - const spinner = ora('Testing AI provider...').start(); - - try { - const analyzer = new Analyzer(paths.modelsDir); - - const providerConfig: ProviderManagerConfig = { - provider: aiConfig.provider, - openai: aiConfig.openai, - anthropic: aiConfig.anthropic, - gemini: aiConfig.gemini, - ollama: aiConfig.ollama, - local: { modelsDir: paths.modelsDir }, - }; - - await analyzer.enableAIWithProvider(providerConfig); - spinner.succeed(`AI provider initialized: ${analyzer.getActiveProviderName()}`); - - // Test classification - const testSpinner = ora('Testing classification...').start(); - const testResult = await analyzer.classifyWithAI({ - path: '/test/example-document.pdf', - filename: 'quarterly-report-2024.pdf', - extension: 'pdf', - size: 1024, - created: new Date(), - modified: new Date(), - accessed: new Date(), - mimeType: 'application/pdf', - category: 'document', - textContent: 'Q4 2024 Financial Summary. Revenue increased by 15%.', - }); - - testSpinner.succeed(`Classification test passed`); - console.log(chalk.dim(` Category: ${testResult.category}`)); - console.log(chalk.dim(` Confidence: ${Math.round(testResult.confidence * 100)}%\n`)); - } catch (error) { - spinner.fail(`AI provider test failed: ${error instanceof Error ? error.message : 'Unknown error'}`); - console.log(chalk.dim('\n Tips:')); - if (aiConfig.provider === 'openai') { - console.log(chalk.dim(' - Make sure OPENAI_API_KEY is set or configure via "sortora ai set openai"')); - } else if (aiConfig.provider === 'anthropic') { - console.log(chalk.dim(' - Make sure ANTHROPIC_API_KEY is set or configure via "sortora ai set anthropic"')); - } else if (aiConfig.provider === 'gemini') { - console.log(chalk.dim(' - Make sure GEMINI_API_KEY is set or configure via "sortora ai set gemini"')); - } else if (aiConfig.provider === 'ollama') { - console.log(chalk.dim(' - Make sure Ollama is running: ollama serve')); - console.log(chalk.dim(' - Pull a model: ollama pull llama3.2')); - } else if (aiConfig.provider === 'local') { - console.log(chalk.dim(' - Run "sortora setup" to download local models')); - } - console.log(); - } - return; - } - - console.log(chalk.yellow('\n Unknown action. Use: list, set, test, info\n')); - }); +process.on('unhandledRejection', (reason) => { + console.error('Unhandled rejection:', reason); + process.exit(1); +}); // Show animated banner when run without arguments async function main() { @@ -1186,11 +40,6 @@ async function main() { return; } - // Show static banner for commands (non-blocking) - if (!args.includes('--help') && !args.includes('-h') && !args.includes('--version') && !args.includes('-V')) { - // Silent for actual commands to keep output clean - } - program.parse(); } diff --git a/src/commands/ai.ts b/src/commands/ai.ts new file mode 100644 index 0000000..e0ad133 --- /dev/null +++ b/src/commands/ai.ts @@ -0,0 +1,230 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import ora from 'ora'; +import inquirer from 'inquirer'; + +import { loadConfig, saveConfig, getAppPaths, getAIProviderConfig, validateAPIKey, type AIProviderType } from '../config.js'; +import { listProviders, type ProviderManagerConfig } from '../ai/providers/index.js'; +import { Analyzer } from '../core/analyzer.js'; + +export function registerAICommand(program: Command): void { + program + .command('ai') + .description('Manage AI providers for classification') + .argument('[action]', 'list, set, test, info') + .argument('[provider]', 'Provider name for set action (openai, anthropic, gemini, ollama, local)') + .action(async (action, provider) => { + const config = loadConfig(); + const paths = getAppPaths(); + const aiConfig = getAIProviderConfig(config); + + if (!action || action === 'list') { + console.log(chalk.bold('\n Available AI Providers:\n')); + + const providers = listProviders(); + for (const p of providers) { + const isActive = p.type === aiConfig.provider; + const marker = isActive ? chalk.green('*') : chalk.dim('o'); + const name = isActive ? chalk.green(p.name) : p.name; + console.log(` ${marker} ${name}`); + console.log(chalk.dim(` ${p.description}`)); + + // Show configuration status + if (p.type === 'openai' && aiConfig.openai?.apiKey) { + console.log(chalk.dim(` API Key: ****${aiConfig.openai.apiKey.slice(-4)}`)); + } + if (p.type === 'anthropic' && aiConfig.anthropic?.apiKey) { + console.log(chalk.dim(` API Key: ****${aiConfig.anthropic.apiKey.slice(-4)}`)); + } + if (p.type === 'gemini' && aiConfig.gemini?.apiKey) { + console.log(chalk.dim(` API Key: ****${aiConfig.gemini.apiKey.slice(-4)}`)); + } + if (p.type === 'ollama') { + console.log(chalk.dim(` URL: ${aiConfig.ollama?.baseUrl || 'http://localhost:11434'}`)); + } + console.log(); + } + + console.log(chalk.dim(' Set provider: sortora ai set ')); + console.log(chalk.dim(' Test provider: sortora ai test\n')); + return; + } + + if (action === 'info') { + console.log(chalk.bold('\n Current AI Configuration:\n')); + console.log(chalk.cyan(` Provider: ${aiConfig.provider}`)); + + if (aiConfig.provider === 'openai') { + console.log(chalk.dim(` Model: ${aiConfig.openai?.model || 'gpt-4o-mini'}`)); + console.log(chalk.dim(` API Key: ${aiConfig.openai?.apiKey ? '****' + aiConfig.openai.apiKey.slice(-4) : 'not set'}`)); + } else if (aiConfig.provider === 'anthropic') { + console.log(chalk.dim(` Model: ${aiConfig.anthropic?.model || 'claude-3-haiku-20240307'}`)); + console.log(chalk.dim(` API Key: ${aiConfig.anthropic?.apiKey ? '****' + aiConfig.anthropic.apiKey.slice(-4) : 'not set'}`)); + } else if (aiConfig.provider === 'gemini') { + console.log(chalk.dim(` Model: ${aiConfig.gemini?.model || 'gemini-1.5-flash'}`)); + console.log(chalk.dim(` API Key: ${aiConfig.gemini?.apiKey ? '****' + aiConfig.gemini.apiKey.slice(-4) : 'not set'}`)); + } else if (aiConfig.provider === 'ollama') { + console.log(chalk.dim(` Model: ${aiConfig.ollama?.model || 'llama3.2'}`)); + console.log(chalk.dim(` URL: ${aiConfig.ollama?.baseUrl || 'http://localhost:11434'}`)); + } else if (aiConfig.provider === 'local') { + console.log(chalk.dim(` Models directory: ${paths.modelsDir}`)); + } + + console.log(chalk.dim('\n Environment variables:')); + console.log(chalk.dim(` SORTORA_AI_PROVIDER: ${process.env.SORTORA_AI_PROVIDER || '(not set)'}`)); + console.log(chalk.dim(` OPENAI_API_KEY: ${process.env.OPENAI_API_KEY ? 'set' : '(not set)'}`)); + console.log(chalk.dim(` ANTHROPIC_API_KEY: ${process.env.ANTHROPIC_API_KEY ? 'set' : '(not set)'}`)); + console.log(chalk.dim(` GEMINI_API_KEY: ${process.env.GEMINI_API_KEY ? 'set' : '(not set)'}`)); + console.log(chalk.dim(` OLLAMA_HOST: ${process.env.OLLAMA_HOST || '(not set)'}\n`)); + return; + } + + if (action === 'set') { + if (!provider) { + console.log(chalk.red('\n Please specify a provider: openai, anthropic, gemini, ollama, local\n')); + return; + } + + const validProviders = ['local', 'openai', 'anthropic', 'gemini', 'ollama']; + if (!validProviders.includes(provider)) { + console.log(chalk.red(`\n Unknown provider: ${provider}`)); + console.log(chalk.dim(` Available: ${validProviders.join(', ')}\n`)); + return; + } + + // #13: Validate API key format before saving + if (provider === 'openai' && !aiConfig.openai?.apiKey) { + const { apiKey } = await inquirer.prompt([{ + type: 'password', + name: 'apiKey', + message: 'Enter your OpenAI API key:', + mask: '*', + }]); + + const validation = validateAPIKey('openai', apiKey); + if (!validation.valid) { + console.log(chalk.yellow(`\n Warning: ${validation.message}`)); + const { proceed } = await inquirer.prompt([{ + type: 'confirm', + name: 'proceed', + message: 'Save anyway?', + default: false, + }]); + if (!proceed) return; + } + + config.ai.openai.apiKey = apiKey; + } + + if (provider === 'anthropic' && !aiConfig.anthropic?.apiKey) { + const { apiKey } = await inquirer.prompt([{ + type: 'password', + name: 'apiKey', + message: 'Enter your Anthropic API key:', + mask: '*', + }]); + + const validation = validateAPIKey('anthropic', apiKey); + if (!validation.valid) { + console.log(chalk.yellow(`\n Warning: ${validation.message}`)); + const { proceed } = await inquirer.prompt([{ + type: 'confirm', + name: 'proceed', + message: 'Save anyway?', + default: false, + }]); + if (!proceed) return; + } + + config.ai.anthropic.apiKey = apiKey; + } + + if (provider === 'gemini' && !aiConfig.gemini?.apiKey) { + const { apiKey } = await inquirer.prompt([{ + type: 'password', + name: 'apiKey', + message: 'Enter your Gemini API key:', + mask: '*', + }]); + + const validation = validateAPIKey('gemini', apiKey); + if (!validation.valid) { + console.log(chalk.yellow(`\n Warning: ${validation.message}`)); + const { proceed } = await inquirer.prompt([{ + type: 'confirm', + name: 'proceed', + message: 'Save anyway?', + default: false, + }]); + if (!proceed) return; + } + + config.ai.gemini.apiKey = apiKey; + } + + config.ai.provider = provider as AIProviderType; + saveConfig(config); + + console.log(chalk.green(`\n AI provider set to: ${provider}\n`)); + return; + } + + if (action === 'test') { + const spinner = ora('Testing AI provider...').start(); + + try { + const analyzer = new Analyzer(paths.modelsDir); + + const providerConfig: ProviderManagerConfig = { + provider: aiConfig.provider, + openai: aiConfig.openai, + anthropic: aiConfig.anthropic, + gemini: aiConfig.gemini, + ollama: aiConfig.ollama, + local: { modelsDir: paths.modelsDir }, + }; + + await analyzer.enableAIWithProvider(providerConfig); + spinner.succeed(`AI provider initialized: ${analyzer.getActiveProviderName()}`); + + // Test classification + const testSpinner = ora('Testing classification...').start(); + const testResult = await analyzer.classifyWithAI({ + path: '/test/example-document.pdf', + filename: 'quarterly-report-2024.pdf', + extension: 'pdf', + size: 1024, + created: new Date(), + modified: new Date(), + accessed: new Date(), + mimeType: 'application/pdf', + category: 'document', + textContent: 'Q4 2024 Financial Summary. Revenue increased by 15%.', + }); + + testSpinner.succeed(`Classification test passed`); + console.log(chalk.dim(` Category: ${testResult.category}`)); + console.log(chalk.dim(` Confidence: ${Math.round(testResult.confidence * 100)}%\n`)); + } catch (error) { + spinner.fail(`AI provider test failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + console.log(chalk.dim('\n Tips:')); + if (aiConfig.provider === 'openai') { + console.log(chalk.dim(' - Make sure OPENAI_API_KEY is set or configure via "sortora ai set openai"')); + } else if (aiConfig.provider === 'anthropic') { + console.log(chalk.dim(' - Make sure ANTHROPIC_API_KEY is set or configure via "sortora ai set anthropic"')); + } else if (aiConfig.provider === 'gemini') { + console.log(chalk.dim(' - Make sure GEMINI_API_KEY is set or configure via "sortora ai set gemini"')); + } else if (aiConfig.provider === 'ollama') { + console.log(chalk.dim(' - Make sure Ollama is running: ollama serve')); + console.log(chalk.dim(' - Pull a model: ollama pull llama3.2')); + } else if (aiConfig.provider === 'local') { + console.log(chalk.dim(' - Run "sortora setup" to download local models')); + } + console.log(); + } + return; + } + + console.log(chalk.yellow('\n Unknown action. Use: list, set, test, info\n')); + }); +} diff --git a/src/commands/duplicates.ts b/src/commands/duplicates.ts new file mode 100644 index 0000000..7338ae8 --- /dev/null +++ b/src/commands/duplicates.ts @@ -0,0 +1,96 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import ora from 'ora'; +import inquirer from 'inquirer'; +import { resolve } from 'path'; +import { existsSync } from 'fs'; + +import { getAppPaths, expandPath } from '../config.js'; +import { Scanner } from '../core/scanner.js'; +import { Analyzer } from '../core/analyzer.js'; +import { Executor } from '../core/executor.js'; +import { Database } from '../storage/database.js'; + +export function registerDuplicatesCommand(program: Command): void { + program + .command('duplicates ') + .description('Find and manage duplicate files') + .option('--clean', 'Remove duplicates interactively') + .action(async (targetPath, options) => { + const fullPath = resolve(expandPath(targetPath)); + + if (!existsSync(fullPath)) { + console.error(chalk.red(`Path not found: ${fullPath}`)); + process.exit(1); + } + + console.log(chalk.bold(`\n Finding duplicates in ${chalk.cyan(fullPath)}...\n`)); + + const paths = getAppPaths(); + const db = new Database(paths.databaseFile); + + try { + await db.init(); + + const scanner = new Scanner(db); + + const spinner = ora('Scanning files...').start(); + const files = await scanner.scan(fullPath, { recursive: true, findDuplicates: true }); + spinner.succeed(`Scanned ${files.length} files`); + + const hashSpinner = ora('Computing file hashes...').start(); + const analyzer = new Analyzer(paths.modelsDir); + const analyzed = await analyzer.analyzeMany(files); + hashSpinner.succeed('Hashes computed'); + + const duplicates = scanner.findDuplicates(analyzed); + + if (duplicates.length === 0) { + console.log(chalk.green('\n No duplicates found!\n')); + return; + } + + let totalSize = 0; + for (const group of duplicates) { + const wastedSize = group.files.slice(1).reduce((sum, f) => sum + f.size, 0); + totalSize += wastedSize; + } + + console.log(chalk.yellow(`\n Found ${duplicates.length} duplicate groups`)); + console.log(chalk.yellow(` ${(totalSize / 1024 / 1024).toFixed(1)} MB could be freed\n`)); + + for (const group of duplicates) { + console.log(chalk.bold(`\n Hash: ${group.hash.slice(0, 12)}...`)); + for (const file of group.files) { + console.log(chalk.dim(` - ${file.path}`)); + } + } + + if (options.clean) { + const { confirm } = await inquirer.prompt([{ + type: 'confirm', + name: 'confirm', + message: 'Remove duplicates (keep first of each)?', + default: false, + }]); + + if (confirm) { + const executor = new Executor(db); + for (const group of duplicates) { + for (const file of group.files.slice(1)) { + await executor.delete(file.path, true); + console.log(chalk.red(` Deleted: ${file.path}`)); + } + } + console.log(chalk.green('\n Duplicates removed!\n')); + } + } + } catch (error) { + console.error(chalk.red('Duplicate scan failed')); + console.error(error); + process.exit(1); + } finally { + db.close(); + } + }); +} diff --git a/src/commands/index.ts b/src/commands/index.ts new file mode 100644 index 0000000..adc9e64 --- /dev/null +++ b/src/commands/index.ts @@ -0,0 +1,27 @@ +import { Command } from 'commander'; + +import { registerSetupCommand } from './setup.js'; +import { registerScanCommand } from './scan.js'; +import { registerOrganizeCommand } from './organize.js'; +import { registerWatchCommand } from './watch.js'; +import { registerDuplicatesCommand } from './duplicates.js'; +import { registerUndoCommand } from './undo.js'; +import { registerRenameCommand } from './rename.js'; +import { registerRulesCommand } from './rules.js'; +import { registerStatsCommand } from './stats.js'; +import { registerAICommand } from './ai.js'; +import { registerPreviewCommand } from './preview.js'; + +export function registerAllCommands(program: Command): void { + registerSetupCommand(program); + registerScanCommand(program); + registerOrganizeCommand(program); + registerWatchCommand(program); + registerDuplicatesCommand(program); + registerUndoCommand(program); + registerRenameCommand(program); + registerRulesCommand(program); + registerStatsCommand(program); + registerAICommand(program); + registerPreviewCommand(program); +} diff --git a/src/commands/organize.ts b/src/commands/organize.ts new file mode 100644 index 0000000..0ab1f34 --- /dev/null +++ b/src/commands/organize.ts @@ -0,0 +1,189 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import ora from 'ora'; +import inquirer from 'inquirer'; +import { resolve } from 'path'; +import { existsSync } from 'fs'; + +import { loadConfig, getAppPaths, expandPath } from '../config.js'; +import { Scanner } from '../core/scanner.js'; +import { Analyzer } from '../core/analyzer.js'; +import { RuleEngine } from '../core/rule-engine.js'; +import { Suggester } from '../core/suggester.js'; +import { Executor } from '../core/executor.js'; +import { Database } from '../storage/database.js'; +import { createProgressBar } from '../ui/progress.js'; +import { formatSize } from '../ui/colors.js'; + +export function registerOrganizeCommand(program: Command): void { + program + .command('organize ') + .description('Organize files based on rules') + .option('-d, --deep', 'Scan subdirectories recursively') + .option('--dry-run', 'Show what would be done without making changes') + .option('-i, --interactive', 'Confirm each action') + .option('--auto', 'Apply actions automatically') + .option('--global', 'Move files to global destinations (~/Documents, ~/Pictures, etc.)') + .option('--confidence ', 'Minimum confidence for auto mode (0-1)', '0.8') + .action(async (targetPath, options) => { + const fullPath = resolve(expandPath(targetPath)); + + if (!existsSync(fullPath)) { + console.error(chalk.red(`Path not found: ${fullPath}`)); + process.exit(1); + } + + const modeText = options.global + ? chalk.yellow('(global mode - files will be moved to ~/Documents, etc.)') + : chalk.green('(local mode - files will be organized within the directory)'); + + console.log(chalk.bold(`\n Organizing ${chalk.cyan(fullPath)}...`)); + console.log(` ${modeText}\n`); + + const config = loadConfig(); + const paths = getAppPaths(); + const db = new Database(paths.databaseFile); + + try { + await db.init(); + + const scanner = new Scanner(db); + const analyzer = new Analyzer(paths.modelsDir); + const ruleEngine = new RuleEngine(config); + const suggester = new Suggester(ruleEngine, config); + const executor = new Executor(db); + + const spinner = ora('Scanning files...').start(); + const files = await scanner.scan(fullPath, { recursive: options.deep || false }); + spinner.succeed(`Found ${files.length} files`); + + if (files.length === 0) { + console.log(chalk.yellow('\n No files to organize.\n')); + return; + } + + const analyzeSpinner = ora('Analyzing files...').start(); + const analyzed = await analyzer.analyzeMany(files); + analyzeSpinner.succeed('Analysis complete'); + + // Generate suggestions with local or global destinations + const suggestions = suggester.generateSuggestions(analyzed, { + baseDir: fullPath, + useGlobalDestinations: options.global || false, + }); + + if (suggestions.length === 0) { + console.log(chalk.yellow('\n No suggestions - files already organized.\n')); + return; + } + + console.log(chalk.bold(`\n ${suggestions.length} suggestions:\n`)); + + if (options.dryRun) { + for (const suggestion of suggestions) { + console.log(chalk.dim(` ${suggestion.file.filename}`)); + console.log(chalk.cyan(` -> ${suggestion.destination}`)); + console.log(chalk.dim(` Rule: ${suggestion.ruleName} (${Math.round(suggestion.confidence * 100)}%)\n`)); + } + return; + } + + const minConfidence = parseFloat(options.confidence); + + // #23: Track summary stats + let filesMoved = 0; + let totalSizeMoved = 0; + let filesSkipped = 0; + let errors = 0; + + // #17: Progress bar for organize + const progressBar = createProgressBar(suggestions.length, 'Organizing'); + + for (let i = 0; i < suggestions.length; i++) { + const suggestion = suggestions[i]; + const progress = `[${i + 1}/${suggestions.length}]`; + + console.log(chalk.bold(`\n ${progress} ${suggestion.file.filename}`)); + console.log(chalk.dim(` ${suggestion.file.path}`)); + console.log(chalk.cyan(` -> ${suggestion.destination}`)); + console.log(chalk.dim(` Rule: ${suggestion.ruleName} (${Math.round(suggestion.confidence * 100)}%)`)); + + let shouldExecute = false; + + if (options.auto && suggestion.confidence >= minConfidence) { + shouldExecute = true; + } else if (options.interactive || !options.auto) { + const { action } = await inquirer.prompt([{ + type: 'list', + name: 'action', + message: 'Action:', + choices: [ + { name: 'Accept', value: 'accept' }, + { name: 'Skip', value: 'skip' }, + { name: 'Edit destination', value: 'edit' }, + { name: 'Quit', value: 'quit' }, + ], + }]); + + if (action === 'quit') { + console.log(chalk.yellow('\n Stopped.\n')); + progressBar.clear(); + break; + } + + // #1: Fix "Edit destination" bug - use else if chain + if (action === 'edit') { + const { newDest } = await inquirer.prompt([{ + type: 'input', + name: 'newDest', + message: 'New destination:', + default: suggestion.destination, + }]); + suggestion.destination = expandPath(newDest); + shouldExecute = true; + } else if (action === 'accept') { + shouldExecute = true; + } + // action === 'skip' leaves shouldExecute as false + } + + if (shouldExecute) { + try { + await executor.execute(suggestion); + console.log(chalk.green(' Done')); + filesMoved++; + totalSizeMoved += suggestion.file.size; + } catch (error) { + console.log(chalk.red(` Error: ${error}`)); + errors++; + } + } else { + console.log(chalk.dim(' Skipped')); + filesSkipped++; + } + + progressBar.update(i + 1); + } + + progressBar.finish(); + + // #23: Show summary after organize + console.log(chalk.bold('\n Summary:')); + console.log(chalk.green(` Files organized: ${filesMoved}`)); + console.log(chalk.dim(` Total size: ${formatSize(totalSizeMoved)}`)); + if (filesSkipped > 0) { + console.log(chalk.yellow(` Skipped: ${filesSkipped}`)); + } + if (errors > 0) { + console.log(chalk.red(` Errors: ${errors}`)); + } + console.log(chalk.dim(`\n Run "sortora undo" to revert changes.\n`)); + } catch (error) { + console.error(chalk.red('Organization failed')); + console.error(error); + process.exit(1); + } finally { + db.close(); + } + }); +} diff --git a/src/commands/preview.ts b/src/commands/preview.ts new file mode 100644 index 0000000..209805a --- /dev/null +++ b/src/commands/preview.ts @@ -0,0 +1,168 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import ora from 'ora'; +import inquirer from 'inquirer'; +import { resolve } from 'path'; +import { existsSync } from 'fs'; + +import { loadConfig, getAppPaths, expandPath } from '../config.js'; +import { Scanner } from '../core/scanner.js'; +import { Analyzer, type FileAnalysis } from '../core/analyzer.js'; +import { RuleEngine } from '../core/rule-engine.js'; +import { Suggester } from '../core/suggester.js'; +import { Executor } from '../core/executor.js'; +import { Database } from '../storage/database.js'; +import { formatSize } from '../ui/colors.js'; +import { getCategoryIcon } from '../utils/mime.js'; + +export function registerPreviewCommand(program: Command): void { + program + .command('preview ') + .description('Interactive file browser with rule matching preview') + .option('-d, --deep', 'Scan subdirectories recursively') + .action(async (targetPath, options) => { + const fullPath = resolve(expandPath(targetPath)); + + if (!existsSync(fullPath)) { + console.error(chalk.red(`Path not found: ${fullPath}`)); + process.exit(1); + } + + console.log(chalk.bold(`\n Preview: ${chalk.cyan(fullPath)}\n`)); + + const config = loadConfig(); + const paths = getAppPaths(); + const db = new Database(paths.databaseFile); + + try { + await db.init(); + + const scanner = new Scanner(db); + const analyzer = new Analyzer(paths.modelsDir); + const ruleEngine = new RuleEngine(config); + const suggester = new Suggester(ruleEngine, config); + const executor = new Executor(db); + + const spinner = ora('Scanning and analyzing files...').start(); + const files = await scanner.scan(fullPath, { recursive: options.deep || false }); + + if (files.length === 0) { + spinner.fail('No files found'); + return; + } + + const analyzed = await analyzer.analyzeMany(files); + spinner.succeed(`Found ${analyzed.length} files`); + + // Generate suggestions for all files + const suggestions = suggester.generateSuggestions(analyzed, { + baseDir: fullPath, + }); + + // Build a map of file path -> suggestion + const suggestionMap = new Map(suggestions.map(s => [s.file.path, s])); + + // Interactive browsing loop + let running = true; + while (running) { + // Build file list for selection + const choices = analyzed.map(file => { + const suggestion = suggestionMap.get(file.path); + const icon = getCategoryIcon(file.category as Parameters[0]); + const size = formatSize(file.size); + const dest = suggestion + ? chalk.dim(` -> ${suggestion.ruleName}`) + : chalk.dim(' (no rule)'); + + return { + name: `${icon} ${file.filename} ${size}${dest}`, + value: file.path, + short: file.filename, + }; + }); + + choices.push({ + name: chalk.yellow(' Exit preview'), + value: '__exit__', + short: 'Exit', + }); + + const { selectedPath } = await inquirer.prompt([{ + type: 'list', + name: 'selectedPath', + message: 'Select a file to preview:', + choices, + pageSize: 15, + }]); + + if (selectedPath === '__exit__') { + running = false; + continue; + } + + // Show file details + const file = analyzed.find(f => f.path === selectedPath); + if (!file) continue; + + const suggestion = suggestionMap.get(file.path); + + showFileDetails(file, suggestion); + + // File actions + const { fileAction } = await inquirer.prompt([{ + type: 'list', + name: 'fileAction', + message: 'Action:', + choices: [ + ...(suggestion ? [ + { name: chalk.green(`Move to ${suggestion.destination}`), value: 'move' }, + ] : []), + { name: 'Back to list', value: 'back' }, + { name: chalk.yellow('Exit preview'), value: 'exit' }, + ], + }]); + + if (fileAction === 'exit') { + running = false; + } else if (fileAction === 'move' && suggestion) { + try { + await executor.execute(suggestion); + console.log(chalk.green(`\n Moved to: ${suggestion.destination}\n`)); + // Remove from analyzed list + const idx = analyzed.indexOf(file); + if (idx >= 0) analyzed.splice(idx, 1); + suggestionMap.delete(file.path); + } catch (error) { + console.log(chalk.red(`\n Error: ${error}\n`)); + } + } + } + + console.log(chalk.dim('\n Preview closed.\n')); + } catch (error) { + console.error(chalk.red('Preview failed')); + console.error(error); + process.exit(1); + } finally { + db.close(); + } + }); +} + +function showFileDetails(file: FileAnalysis, suggestion?: { destination: string; ruleName: string; confidence: number } | null): void { + console.log(chalk.bold(`\n File: ${file.filename}`)); + console.log(chalk.dim(` Path: ${file.path}`)); + console.log(chalk.dim(` Size: ${formatSize(file.size)}`)); + console.log(chalk.dim(` Type: ${file.mimeType || 'unknown'}`)); + console.log(chalk.dim(` Category: ${file.category || 'unknown'}`)); + console.log(chalk.dim(` Modified: ${file.modified.toLocaleDateString()}`)); + + if (suggestion) { + console.log(chalk.cyan(`\n Rule: ${suggestion.ruleName}`)); + console.log(chalk.cyan(` Confidence: ${Math.round(suggestion.confidence * 100)}%`)); + console.log(chalk.cyan(` Destination: ${suggestion.destination}`)); + } else { + console.log(chalk.yellow('\n No matching rule found')); + } + console.log(); +} diff --git a/src/commands/rename.ts b/src/commands/rename.ts new file mode 100644 index 0000000..b244863 --- /dev/null +++ b/src/commands/rename.ts @@ -0,0 +1,238 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import ora from 'ora'; +import inquirer from 'inquirer'; +import { resolve } from 'path'; +import { existsSync } from 'fs'; + +import { getAppPaths, expandPath } from '../config.js'; +import { Scanner } from '../core/scanner.js'; +import { Database } from '../storage/database.js'; +import { SmartRenamer } from '../ai/smart-renamer.js'; +import { renameFile } from '../actions/rename.js'; +import { logger } from '../utils/logger.js'; + +export function registerRenameCommand(program: Command): void { + program + .command('rename ') + .description('Smart rename files using EXIF data and AI analysis') + .option('-d, --deep', 'Scan subdirectories recursively') + .option('--ai', 'Use AI for content-based naming') + .option('--ocr', 'Use OCR to read text from images') + .option('--dry-run', 'Preview renames without making changes') + .option('--auto', 'Apply renames automatically without confirmation') + .option('--event ', 'Event or trip name to use in filenames') + .option('--lang ', 'Language for names (ru/en)', 'ru') + .option('--json', 'Output suggestions as JSON') + .action(async (targetPath, options) => { + const fullPath = resolve(expandPath(targetPath)); + + if (!existsSync(fullPath)) { + console.error(chalk.red(`Path not found: ${fullPath}`)); + process.exit(1); + } + + console.log(chalk.bold(`\n Smart Rename: ${chalk.cyan(fullPath)}\n`)); + + const paths = getAppPaths(); + const db = new Database(paths.databaseFile); + + try { + await db.init(); + + const renamer = new SmartRenamer(paths.modelsDir, { + language: options.lang === 'en' ? 'en' : 'ru', + }); + + // Enable AI if requested + if (options.ai) { + const aiSpinner = ora('Loading AI models...').start(); + try { + await renamer.enableAI(); + aiSpinner.succeed('AI models loaded'); + } catch (error) { + aiSpinner.fail('Failed to load AI models. Run "sortora setup" first.'); + logger.error('AI error:', error); + } + } + + // Enable OCR if requested + if (options.ocr) { + const ocrSpinner = ora('Loading OCR engine...').start(); + try { + await renamer.enableOCR(['eng', 'rus']); + ocrSpinner.succeed('OCR engine loaded'); + } catch (error) { + ocrSpinner.fail('Failed to load OCR. Run "sortora setup" first.'); + logger.error('OCR error:', error); + } + } + + // Scan for files + const scanner = new Scanner(db); + const spinner = ora('Scanning files...').start(); + + const files = await scanner.scan(fullPath, { + recursive: options.deep || false, + }); + + spinner.succeed(`Found ${files.length} files`); + + if (files.length === 0) { + console.log(chalk.yellow('\n No files found.\n')); + return; + } + + // Filter files that need renaming + const filesToRename = files.filter(f => renamer.isUnreadable(f.filename)); + + if (filesToRename.length === 0) { + console.log(chalk.green('\n All files already have readable names!\n')); + return; + } + + console.log(chalk.dim(`\n ${filesToRename.length} files need renaming:\n`)); + + // Generate suggestions + const suggestSpinner = ora('Analyzing files and generating names...').start(); + const suggestions = await renamer.suggestBatch( + filesToRename.map(f => f.path), + { + useAI: options.ai && renamer.isAIEnabled(), + useOCR: options.ocr, + eventHint: options.event, + groupByDate: true, + groupByLocation: true, + } + ); + suggestSpinner.succeed('Suggestions generated'); + + // JSON output + if (options.json) { + console.log(JSON.stringify(suggestions, null, 2)); + return; + } + + // Dry run - just show suggestions + if (options.dryRun) { + console.log(chalk.bold('\n Suggested renames (dry run):\n')); + for (const suggestion of suggestions) { + const confidence = Math.round(suggestion.confidence * 100); + const confidenceColor = confidence >= 70 ? chalk.green : confidence >= 50 ? chalk.yellow : chalk.red; + + console.log(chalk.dim(` ${suggestion.original}`)); + console.log(chalk.cyan(` -> ${suggestion.suggested}`)); + console.log(chalk.dim(` ${suggestion.reason} (${confidenceColor(confidence + '%')})\n`)); + } + return; + } + + // Process renames + let renamed = 0; + let skipped = 0; + + for (let i = 0; i < suggestions.length; i++) { + const suggestion = suggestions[i]; + const file = filesToRename[i]; + const progress = `[${i + 1}/${suggestions.length}]`; + + // Skip files with same name + if (suggestion.original === suggestion.suggested) { + continue; + } + + const confidence = Math.round(suggestion.confidence * 100); + const confidenceColor = confidence >= 70 ? chalk.green : confidence >= 50 ? chalk.yellow : chalk.red; + + console.log(chalk.bold(`\n ${progress} ${suggestion.original}`)); + console.log(chalk.cyan(` -> ${suggestion.suggested}`)); + console.log(chalk.dim(` ${suggestion.reason} (${confidenceColor(confidence + '%')})`)); + + let shouldRename = false; + + if (options.auto) { + // Auto mode: rename if confidence >= 50% + shouldRename = suggestion.confidence >= 0.5; + if (!shouldRename) { + console.log(chalk.yellow(' Skipped (low confidence)')); + skipped++; + } + } else { + // Interactive mode + const { action } = await inquirer.prompt([{ + type: 'list', + name: 'action', + message: 'Action:', + choices: [ + { name: 'Accept', value: 'accept' }, + { name: 'Edit name', value: 'edit' }, + { name: 'Skip', value: 'skip' }, + { name: 'Skip all remaining', value: 'quit' }, + ], + }]); + + if (action === 'quit') { + console.log(chalk.yellow('\n Stopped.\n')); + break; + } + + // Fix: same edit bug as organize - use else if chain + if (action === 'edit') { + const { newName } = await inquirer.prompt([{ + type: 'input', + name: 'newName', + message: 'New name:', + default: suggestion.suggested, + }]); + suggestion.suggested = newName; + shouldRename = true; + } else if (action === 'accept') { + shouldRename = true; + } else { + // skip + skipped++; + } + } + + if (shouldRename) { + try { + const result = await renameFile(file.path, suggestion.suggested, { + preserveExtension: true, + }); + + if (result.success) { + // Log to database for undo support + db.insertOperation({ + type: 'rename', + source: file.path, + destination: result.destination, + ruleName: null, + confidence: suggestion.confidence, + }); + + console.log(chalk.green(' Renamed')); + renamed++; + } else { + console.log(chalk.red(` ${result.error}`)); + } + } catch (error) { + console.log(chalk.red(` Error: ${error}`)); + } + } + } + + console.log(chalk.bold('\n Summary:')); + console.log(chalk.green(` Renamed: ${renamed} files`)); + if (skipped > 0) { + console.log(chalk.yellow(` Skipped: ${skipped} files`)); + } + console.log(chalk.dim(`\n Run "sortora undo" to revert changes.\n`)); + } catch (error) { + console.error(chalk.red('Rename failed')); + console.error(error); + process.exit(1); + } finally { + db.close(); + } + }); +} diff --git a/src/commands/rules.ts b/src/commands/rules.ts new file mode 100644 index 0000000..7978394 --- /dev/null +++ b/src/commands/rules.ts @@ -0,0 +1,233 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import ora from 'ora'; +import inquirer from 'inquirer'; +import { resolve } from 'path'; +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { platform } from 'os'; +import YAML from 'yaml'; + +import { loadConfig, saveConfig, getAppPaths, expandPath } from '../config.js'; +import { Analyzer } from '../core/analyzer.js'; +import { RuleEngine } from '../core/rule-engine.js'; +import { Database } from '../storage/database.js'; + +export function registerRulesCommand(program: Command): void { + program + .command('rules') + .description('Manage organization rules') + .argument('[action]', 'list, add, test , edit, export [file], import ') + .argument('[file]', 'File path for test/export/import action') + .action(async (action, file) => { + const config = loadConfig(); + const paths = getAppPaths(); + + if (!action || action === 'list') { + console.log(chalk.bold('\n Organization Rules:\n')); + + if (config.rules.length === 0) { + console.log(chalk.yellow(' No custom rules defined.')); + console.log(chalk.dim(' Using default presets.\n')); + return; + } + + for (const rule of config.rules.sort((a, b) => b.priority - a.priority)) { + console.log(chalk.cyan(` ${rule.name}`) + chalk.dim(` (priority: ${rule.priority})`)); + if (rule.match.extension) { + console.log(chalk.dim(` Extensions: ${rule.match.extension.join(', ')}`)); + } + if (rule.match.filename) { + console.log(chalk.dim(` Patterns: ${rule.match.filename.join(', ')}`)); + } + if (rule.action.moveTo) { + console.log(chalk.dim(` -> ${rule.action.moveTo}`)); + } + console.log(); + } + return; + } + + if (action === 'test' && file) { + const fullPath = resolve(expandPath(file)); + if (!existsSync(fullPath)) { + console.error(chalk.red(`File not found: ${fullPath}`)); + process.exit(1); + } + + const db = new Database(paths.databaseFile); + try { + await db.init(); + + const analyzer = new Analyzer(paths.modelsDir); + const ruleEngine = new RuleEngine(config); + + const spinner = ora('Analyzing file...').start(); + const analysis = await analyzer.analyze(fullPath); + spinner.succeed('Analysis complete'); + + const matchedRule = ruleEngine.match(analysis); + + if (matchedRule) { + console.log(chalk.green(`\n Matched rule: ${matchedRule.rule.name}`)); + console.log(chalk.dim(` Priority: ${matchedRule.rule.priority}`)); + if (matchedRule.destination) { + console.log(chalk.cyan(` Destination: ${matchedRule.destination}\n`)); + } + } else { + console.log(chalk.yellow('\n No matching rule found.\n')); + } + } finally { + db.close(); + } + return; + } + + if (action === 'add') { + const answers = await inquirer.prompt([ + { + type: 'input', + name: 'name', + message: 'Rule name:', + }, + { + type: 'input', + name: 'extensions', + message: 'File extensions (comma-separated, e.g., jpg,png):', + }, + { + type: 'input', + name: 'patterns', + message: 'Filename patterns (comma-separated, e.g., Screenshot*):', + }, + { + type: 'input', + name: 'destination', + message: 'Destination folder:', + }, + { + type: 'number', + name: 'priority', + message: 'Priority (1-100):', + default: 50, + }, + ]); + + const newRule = { + name: answers.name, + priority: answers.priority, + match: { + extension: answers.extensions ? answers.extensions.split(',').map((s: string) => s.trim()) : undefined, + filename: answers.patterns ? answers.patterns.split(',').map((s: string) => s.trim()) : undefined, + }, + action: { + moveTo: answers.destination, + }, + }; + + config.rules.push(newRule); + saveConfig(config); + + console.log(chalk.green(`\n Rule "${answers.name}" added.\n`)); + return; + } + + if (action === 'edit') { + // #2: Fix editor fallback for Windows + let editor: string; + if (process.env.EDITOR) { + editor = process.env.EDITOR; + } else if (platform() === 'win32') { + editor = 'notepad'; + } else { + editor = 'nano'; + } + + const { spawn } = await import('child_process'); + spawn(editor, [paths.configFile], { stdio: 'inherit' }); + return; + } + + // #20: Export rules to a YAML file + if (action === 'export') { + const exportPath = file ? resolve(expandPath(file)) : resolve('sortora-rules.yaml'); + + const rulesData = { + version: 1, + rules: config.rules, + }; + + const yamlContent = YAML.stringify(rulesData); + writeFileSync(exportPath, yamlContent, 'utf-8'); + + console.log(chalk.green(`\n Rules exported to: ${exportPath}`)); + console.log(chalk.dim(` ${config.rules.length} rule(s) exported.\n`)); + return; + } + + // #20: Import rules from a YAML file + if (action === 'import') { + if (!file) { + console.log(chalk.red('\n Please specify a file to import: sortora rules import \n')); + return; + } + + const importPath = resolve(expandPath(file)); + + if (!existsSync(importPath)) { + console.error(chalk.red(`File not found: ${importPath}`)); + process.exit(1); + } + + try { + const content = readFileSync(importPath, 'utf-8'); + const parsed = YAML.parse(content); + + if (!parsed || !Array.isArray(parsed.rules)) { + console.log(chalk.red('\n Invalid rules file format. Expected { rules: [...] }\n')); + return; + } + + const importedRules = parsed.rules; + + const { mode } = await inquirer.prompt([{ + type: 'list', + name: 'mode', + message: `Import ${importedRules.length} rule(s). How?`, + choices: [ + { name: 'Merge with existing rules', value: 'merge' }, + { name: 'Replace all existing rules', value: 'replace' }, + { name: 'Cancel', value: 'cancel' }, + ], + }]); + + if (mode === 'cancel') { + console.log(chalk.yellow('\n Import cancelled.\n')); + return; + } + + if (mode === 'replace') { + config.rules = importedRules; + } else { + // Merge: add rules that don't exist by name + const existingNames = new Set(config.rules.map(r => r.name)); + let added = 0; + for (const rule of importedRules) { + if (!existingNames.has(rule.name)) { + config.rules.push(rule); + added++; + } + } + console.log(chalk.dim(` ${added} new rule(s) added, ${importedRules.length - added} skipped (duplicate names).`)); + } + + saveConfig(config); + console.log(chalk.green(`\n Rules imported successfully. Total: ${config.rules.length} rule(s).\n`)); + } catch (error) { + console.error(chalk.red(`\n Failed to import rules: ${error instanceof Error ? error.message : error}\n`)); + } + return; + } + + console.log(chalk.yellow('\n Unknown action. Use: list, add, test , edit, export [file], import \n')); + }); +} diff --git a/src/commands/scan.ts b/src/commands/scan.ts new file mode 100644 index 0000000..f546301 --- /dev/null +++ b/src/commands/scan.ts @@ -0,0 +1,133 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import ora from 'ora'; +import { resolve } from 'path'; +import { existsSync } from 'fs'; + +import { loadConfig, getAppPaths, expandPath, getAIProviderConfig, type AIProviderType } from '../config.js'; +import { listProviders, type ProviderManagerConfig } from '../ai/providers/index.js'; +import { Scanner } from '../core/scanner.js'; +import { Analyzer } from '../core/analyzer.js'; +import { Database } from '../storage/database.js'; +import { renderScanStats, renderFileTable } from '../ui/table.js'; +import { logger } from '../utils/logger.js'; + +export function registerScanCommand(program: Command): void { + program + .command('scan ') + .description('Scan a directory and analyze files') + .option('-d, --deep', 'Scan recursively') + .option('--duplicates', 'Find duplicate files') + .option('--ai', 'Use AI for smart classification') + .option('--provider ', 'AI provider to use (local, openai, anthropic, gemini, ollama)') + .option('--json', 'Output as JSON') + .action(async (targetPath, options) => { + const fullPath = resolve(expandPath(targetPath)); + + if (!existsSync(fullPath)) { + console.error(chalk.red(`Path not found: ${fullPath}`)); + process.exit(1); + } + + console.log(chalk.bold(`\n Scanning ${chalk.cyan(fullPath)}...\n`)); + + const paths = getAppPaths(); + const db = new Database(paths.databaseFile); + + try { + await db.init(); + + const scanner = new Scanner(db); + const analyzer = new Analyzer(paths.modelsDir); + + // Enable AI if requested + if (options.ai) { + const config = loadConfig(); + const aiConfig = getAIProviderConfig(config); + + // Override provider if specified via CLI + const providerType = (options.provider as AIProviderType) || aiConfig.provider; + + const providerConfig: ProviderManagerConfig = { + provider: providerType, + openai: aiConfig.openai, + anthropic: aiConfig.anthropic, + gemini: aiConfig.gemini, + ollama: aiConfig.ollama, + local: { modelsDir: paths.modelsDir }, + }; + + const providerName = listProviders().find(p => p.type === providerType)?.name || providerType; + const aiSpinner = ora(`Loading AI provider (${providerName})...`).start(); + + try { + await analyzer.enableAIWithProvider(providerConfig); + aiSpinner.succeed(`AI provider loaded: ${analyzer.getActiveProviderName()}`); + } catch (error) { + aiSpinner.fail(`Failed to load AI provider. ${error instanceof Error ? error.message : ''}`); + logger.error('AI error:', error); + } + } + + const spinner = ora('Scanning files...').start(); + + const files = await scanner.scan(fullPath, { + recursive: options.deep || false, + findDuplicates: options.duplicates || false, + }); + + spinner.succeed(`Found ${files.length} files`); + + if (files.length === 0) { + console.log(chalk.yellow('\n No files found.\n')); + return; + } + + const analyzeSpinner = ora('Analyzing files...').start(); + const analyzed = await analyzer.analyzeMany(files, { + useAI: options.ai && analyzer.isAIEnabled(), + }); + analyzeSpinner.succeed(`Analysis complete (${analyzed.length} files)`); + + if (options.json) { + console.log(JSON.stringify(analyzed, null, 2)); + } else { + renderScanStats(analyzed); + + // Show file list with sizes + console.log(chalk.bold('\n Files:\n')); + renderFileTable(analyzed); + + // Show AI classifications if enabled + if (options.ai && analyzer.isAIEnabled()) { + console.log(chalk.bold('\n AI Classifications:\n')); + for (const file of analyzed.slice(0, 10)) { + if (file.aiCategory) { + const confidence = Math.round((file.aiConfidence || 0) * 100); + console.log(chalk.dim(` ${file.filename}`)); + console.log(chalk.green(` -> ${file.aiCategory} (${confidence}%)\n`)); + } + } + if (analyzed.length > 10) { + console.log(chalk.dim(` ... and ${analyzed.length - 10} more files\n`)); + } + } + + if (options.duplicates) { + const duplicates = scanner.findDuplicates(analyzed); + if (duplicates.length > 0) { + console.log(chalk.yellow(`\n Found ${duplicates.length} duplicate groups\n`)); + } + } + + console.log(chalk.cyan(`\n Run: sortora organize ${targetPath}\n`)); + } + } catch (error) { + console.error(chalk.red('Scan failed')); + console.error(error); + process.exit(1); + } finally { + db.close(); + } + }); +} diff --git a/src/commands/setup.ts b/src/commands/setup.ts new file mode 100644 index 0000000..d4c3dec --- /dev/null +++ b/src/commands/setup.ts @@ -0,0 +1,79 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import ora from 'ora'; + +import { loadConfig, saveConfig, getAppPaths, ensureDirectories } from '../config.js'; +import { Database } from '../storage/database.js'; +import { ModelManager } from '../ai/model-manager.js'; +import { logger } from '../utils/logger.js'; + +export function registerSetupCommand(program: Command): void { + program + .command('setup') + .description('Initial setup - download AI models and create config') + .option('--minimal', 'Skip AI models (~50 MB)') + .option('--full', 'Include all languages for OCR (~120 MB)') + .action(async (options) => { + console.log(chalk.bold('\n Sortora Setup\n')); + + ensureDirectories(); + const paths = getAppPaths(); + + const spinner = ora('Initializing database...').start(); + let db: Database | undefined; + try { + db = new Database(paths.databaseFile); + await db.init(); + spinner.succeed('Database initialized'); + } catch (error) { + spinner.fail('Failed to initialize database'); + console.error(error); + process.exit(1); + } finally { + db?.close(); + } + + if (!options.minimal) { + const modelManager = new ModelManager(paths.modelsDir); + + console.log(chalk.dim('\n Downloading AI models...\n')); + + const modelSpinner = ora('Loading embedding model (MiniLM ~23 MB)...').start(); + try { + await modelManager.loadEmbeddings(); + modelSpinner.succeed('Embedding model loaded'); + } catch (error) { + modelSpinner.fail('Failed to load embedding model'); + logger.error('Embedding model error:', error); + } + + const classifierSpinner = ora('Loading classifier model (MobileBERT ~25 MB)...').start(); + try { + await modelManager.loadClassifier(); + classifierSpinner.succeed('Classifier model loaded'); + } catch (error) { + classifierSpinner.fail('Failed to load classifier model'); + logger.error('Classifier model error:', error); + } + + const ocrSpinner = ora('Loading OCR engine (Tesseract ~15 MB)...').start(); + try { + await modelManager.loadOCR(['eng']); + ocrSpinner.succeed('OCR engine loaded'); + } catch (error) { + ocrSpinner.fail('Failed to load OCR engine'); + logger.error('OCR engine error:', error); + } + } + + // Create default config + const config = loadConfig(); + saveConfig(config); + + console.log(chalk.green('\n Setup complete!\n')); + console.log(chalk.dim(` Config: ${paths.configFile}`)); + console.log(chalk.dim(` Database: ${paths.databaseFile}`)); + console.log(chalk.dim(` Models: ${paths.modelsDir}\n`)); + console.log(chalk.cyan(' Run: sortora scan ~/Downloads\n')); + }); +} diff --git a/src/commands/stats.ts b/src/commands/stats.ts new file mode 100644 index 0000000..f05e868 --- /dev/null +++ b/src/commands/stats.ts @@ -0,0 +1,102 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import ora from 'ora'; + +import { getAppPaths } from '../config.js'; +import { Database } from '../storage/database.js'; +import { + renderStatsOverview, + renderTopRules, + renderDuplicateStats, + renderOverallStats, + type StatsData, +} from '../ui/table.js'; +import { formatNumber } from '../ui/colors.js'; + +export function registerStatsCommand(program: Command): void { + 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); + + try { + await db.init(); + + const spinner = ora('Gathering statistics...').start(); + + // 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)}: ${formatNumber(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)}: ${formatNumber(count)}`); + } + } + console.log(); + } else { + // Show all statistics + renderStatsOverview(statsData); + renderTopRules(statsData.topRules); + renderDuplicateStats(statsData); + renderOverallStats(statsData); + console.log(); + } + } catch (error) { + console.error(chalk.red('Failed to gather statistics')); + console.error(error); + process.exit(1); + } finally { + db.close(); + } + }); +} diff --git a/src/commands/undo.ts b/src/commands/undo.ts new file mode 100644 index 0000000..95251ac --- /dev/null +++ b/src/commands/undo.ts @@ -0,0 +1,166 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import inquirer from 'inquirer'; + +import { getAppPaths } from '../config.js'; +import { Executor } from '../core/executor.js'; +import { Database } from '../storage/database.js'; + +export function registerUndoCommand(program: Command): void { + program + .command('undo') + .description('Undo recent operations') + .option('--all', 'Show all operations') + .option('--id ', 'Undo specific operation by ID') + .option('--last ', 'Undo last N operations') + .option('--all-recent', 'Undo all recent undoable operations') + .action(async (options) => { + const paths = getAppPaths(); + const db = new Database(paths.databaseFile); + + try { + await db.init(); + + const executor = new Executor(db); + + if (options.all) { + const operations = db.getOperations(50); + if (operations.length === 0) { + console.log(chalk.yellow('\n No operations to show.\n')); + return; + } + + console.log(chalk.bold('\n Recent operations:\n')); + for (const op of operations) { + const date = new Date(op.createdAt * 1000).toLocaleString(); + const undone = op.undoneAt ? chalk.dim(' (undone)') : ''; + console.log(chalk.dim(` #${op.id}`) + ` ${op.type}: ${op.source}${undone}`); + console.log(chalk.dim(` ${date}`)); + } + return; + } + + if (options.id) { + const id = parseInt(options.id, 10); + const success = await executor.undo(id); + if (success) { + console.log(chalk.green(`\n Operation #${id} undone.\n`)); + } else { + console.log(chalk.red(`\n Could not undo operation #${id}.\n`)); + } + return; + } + + // #22: Batch undo - undo last N operations + if (options.last) { + const count = parseInt(options.last, 10); + if (isNaN(count) || count < 1) { + console.log(chalk.red('\n Invalid count. Use a positive number.\n')); + return; + } + + const operations = db.getOperations(count); + const undoable = operations.filter(op => !op.undoneAt); + + if (undoable.length === 0) { + console.log(chalk.yellow('\n No undoable operations found.\n')); + return; + } + + console.log(chalk.bold(`\n Undoing ${undoable.length} operation(s):\n`)); + + let undoneCount = 0; + let failedCount = 0; + + for (const op of undoable) { + const success = await executor.undo(op.id); + if (success) { + console.log(chalk.green(` #${op.id} ${op.type}: ${op.source} - undone`)); + undoneCount++; + } else { + console.log(chalk.red(` #${op.id} ${op.type}: ${op.source} - failed`)); + failedCount++; + } + } + + console.log(chalk.bold(`\n Summary: ${undoneCount} undone, ${failedCount} failed.\n`)); + return; + } + + // #22: Batch undo - undo all recent undoable operations + if (options.allRecent) { + const operations = db.getOperations(100); + const undoable = operations.filter(op => !op.undoneAt); + + if (undoable.length === 0) { + console.log(chalk.yellow('\n No undoable operations found.\n')); + return; + } + + const { confirm } = await inquirer.prompt([{ + type: 'confirm', + name: 'confirm', + message: `Undo ${undoable.length} recent operations?`, + default: false, + }]); + + if (!confirm) { + console.log(chalk.yellow('\n Cancelled.\n')); + return; + } + + let undoneCount = 0; + let failedCount = 0; + + for (const op of undoable) { + const success = await executor.undo(op.id); + if (success) { + console.log(chalk.green(` #${op.id} ${op.type}: ${op.source} - undone`)); + undoneCount++; + } else { + console.log(chalk.red(` #${op.id} ${op.type}: ${op.source} - failed`)); + failedCount++; + } + } + + console.log(chalk.bold(`\n Summary: ${undoneCount} undone, ${failedCount} failed.\n`)); + return; + } + + // Default: Undo last operation + const operations = db.getOperations(1); + if (operations.length === 0) { + console.log(chalk.yellow('\n No operations to undo.\n')); + return; + } + + const lastOp = operations[0]; + if (lastOp.undoneAt) { + console.log(chalk.yellow('\n Last operation already undone.\n')); + return; + } + + const { confirm } = await inquirer.prompt([{ + type: 'confirm', + name: 'confirm', + message: `Undo ${lastOp.type}: ${lastOp.source}?`, + default: true, + }]); + + if (confirm) { + const success = await executor.undo(lastOp.id); + if (success) { + console.log(chalk.green('\n Undone.\n')); + } else { + console.log(chalk.red('\n Could not undo.\n')); + } + } + } catch (error) { + console.error(chalk.red('Undo failed')); + console.error(error); + process.exit(1); + } finally { + db.close(); + } + }); +} diff --git a/src/commands/watch.ts b/src/commands/watch.ts new file mode 100644 index 0000000..68821c0 --- /dev/null +++ b/src/commands/watch.ts @@ -0,0 +1,61 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { resolve } from 'path'; +import { existsSync } from 'fs'; + +import { loadConfig, getAppPaths, expandPath } from '../config.js'; +import { Watcher } from '../core/watcher.js'; +import { Database } from '../storage/database.js'; + +export function registerWatchCommand(program: Command): void { + program + .command('watch ') + .description('Monitor a directory for new files') + .option('--auto', 'Automatically organize new files') + .action(async (targetPath, options) => { + const fullPath = resolve(expandPath(targetPath)); + + if (!existsSync(fullPath)) { + console.error(chalk.red(`Path not found: ${fullPath}`)); + process.exit(1); + } + + console.log(chalk.bold(`\n Watching ${chalk.cyan(fullPath)}`)); + console.log(chalk.dim(' Press Ctrl+C to stop\n')); + + const config = loadConfig(); + const paths = getAppPaths(); + const db = new Database(paths.databaseFile); + await db.init(); + + const watcher = new Watcher(db, config, paths.modelsDir); + + watcher.on('file', (file) => { + const time = new Date().toLocaleTimeString(); + console.log(chalk.dim(`${time} |`) + chalk.cyan(` New: ${file.filename}`)); + }); + + watcher.on('changed', (file) => { + const time = new Date().toLocaleTimeString(); + console.log(chalk.dim(`${time} |`) + chalk.yellow(` Changed: ${file.filename}`)); + }); + + watcher.on('organized', (_file, destination) => { + console.log(chalk.green(` -> ${destination}`)); + }); + + watcher.on('error', (error) => { + console.error(chalk.red(` Error: ${error.message}`)); + }); + + await watcher.start(fullPath, { auto: options.auto || false }); + + // #6: Close DB on exit + process.on('SIGINT', () => { + watcher.stop(); + db.close(); + console.log(chalk.yellow('\n Stopped watching.\n')); + process.exit(0); + }); + }); +} diff --git a/src/config.ts b/src/config.ts index 8d917bc..c0e1f26 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,10 +1,36 @@ -import { homedir } from 'os'; -import { join } from 'path'; +import { homedir, platform } from 'os'; +import { join, dirname } from 'path'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { fileURLToPath } from 'url'; import YAML from 'yaml'; import { z } from 'zod'; -export const VERSION = '1.1.1'; +// #9: Read version from package.json instead of hardcoding +function getPackageVersion(): string { + try { + const __filename = fileURLToPath(import.meta.url); + const __dirname = dirname(__filename); + const pkgPath = join(__dirname, '..', 'package.json'); + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); + return pkg.version || '0.0.0'; + } catch { + return '0.0.0'; + } +} + +export const VERSION = getPackageVersion(); + +// #4: Platform-specific default trash path +function getDefaultTrashPath(): string { + switch (platform()) { + case 'win32': + return '~/sortora-trash'; + case 'darwin': + return '~/.Trash'; + default: + return '~/.local/share/Trash/files'; + } +} // AI Provider configuration schema const AIProviderSchema = z.object({ @@ -44,7 +70,7 @@ const ConfigSchema = z.object({ ]), }).default({}), ai: AIProviderSchema, - destinations: z.record(z.string()).default({ + destinations: z.record(z.string()).default(() => ({ photos: '~/Pictures/Sorted', screenshots: '~/Pictures/Screenshots', documents: '~/Documents/Sorted', @@ -54,8 +80,8 @@ const ConfigSchema = z.object({ music: '~/Music/Sorted', video: '~/Videos/Sorted', archives: '~/Archives', - trash: '~/.Trash', - }), + trash: getDefaultTrashPath(), + })), rules: z.array(z.object({ name: z.string(), priority: z.number().default(50), @@ -92,9 +118,22 @@ export interface AppPaths { rulesFile: string; } +// #3: Platform-specific config paths export function getAppPaths(): AppPaths { - const configDir = join(homedir(), '.config', 'sortora'); - const dataDir = join(homedir(), '.local', 'share', 'sortora'); + const isWindows = platform() === 'win32'; + + let configDir: string; + let dataDir: string; + + if (isWindows) { + const appData = process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'); + const localAppData = process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'); + configDir = join(appData, 'sortora'); + dataDir = join(localAppData, 'sortora'); + } else { + configDir = join(homedir(), '.config', 'sortora'); + dataDir = join(homedir(), '.local', 'share', 'sortora'); + } return { configDir, @@ -148,14 +187,25 @@ export function saveConfig(config: Config): void { writeFileSync(paths.configFile, content, 'utf-8'); } -export function expandPath(path: string): string { - if (path.startsWith('~/')) { - return join(homedir(), path.slice(2)); +// #5: Handle Windows environment variables (%USERPROFILE%, %APPDATA%, etc.) +export function expandPath(inputPath: string): string { + let result = inputPath; + + if (result.startsWith('~/') || result === '~') { + return join(homedir(), result.slice(result === '~' ? 1 : 2)); + } + if (result.startsWith('$HOME/') || result === '$HOME') { + return join(homedir(), result.slice(result === '$HOME' ? 5 : 6)); } - if (path.startsWith('$HOME/')) { - return join(homedir(), path.slice(6)); + + // Handle Windows environment variables like %USERPROFILE%, %APPDATA%, etc. + if (platform() === 'win32') { + result = result.replace(/%([^%]+)%/g, (match, varName: string) => { + return process.env[varName] || match; + }); } - return path; + + return result; } export function getDestination(config: Config, key: string): string { @@ -170,6 +220,42 @@ export const DEFAULT_CONFIG: Config = ConfigSchema.parse({}); export type AIProviderType = 'local' | 'openai' | 'anthropic' | 'gemini' | 'ollama'; +// #13: Validate AI API key format +export function validateAPIKey(provider: AIProviderType, key: string): { valid: boolean; message?: string } { + if (!key || key.trim().length === 0) { + return { valid: false, message: 'API key cannot be empty' }; + } + + switch (provider) { + case 'openai': + if (!key.startsWith('sk-')) { + return { valid: false, message: 'OpenAI API keys should start with "sk-"' }; + } + if (key.length < 20) { + return { valid: false, message: 'OpenAI API key seems too short' }; + } + return { valid: true }; + + case 'anthropic': + if (!key.startsWith('sk-ant-')) { + return { valid: false, message: 'Anthropic API keys should start with "sk-ant-"' }; + } + if (key.length < 20) { + return { valid: false, message: 'Anthropic API key seems too short' }; + } + return { valid: true }; + + case 'gemini': + if (key.length < 10) { + return { valid: false, message: 'Gemini API key seems too short' }; + } + return { valid: true }; + + default: + return { valid: true }; + } +} + /** * Get the AI provider configuration, with environment variable overrides */ @@ -209,6 +295,12 @@ export function getAIProviderConfig(config: Config): Config['ai'] { apiKey: process.env.ANTHROPIC_API_KEY, }; } + if (process.env.ANTHROPIC_BASE_URL) { + aiConfig.anthropic = { + ...aiConfig.anthropic, + baseUrl: process.env.ANTHROPIC_BASE_URL, + }; + } if (process.env.ANTHROPIC_MODEL) { aiConfig.anthropic = { ...aiConfig.anthropic, diff --git a/src/core/watcher.ts b/src/core/watcher.ts index c103dcb..de91161 100644 --- a/src/core/watcher.ts +++ b/src/core/watcher.ts @@ -16,6 +16,7 @@ export interface WatcherOptions { export interface WatcherEvents { file: (file: FileAnalysis) => void; + changed: (file: FileAnalysis) => void; organized: (file: FileAnalysis, destination: string) => void; skipped: (file: FileAnalysis, reason: string) => void; error: (error: Error) => void; @@ -68,6 +69,11 @@ export class Watcher extends EventEmitter { this.handleFileAdded(filePath); }); + // #15: Handle change events + this.watcher.on('change', (filePath) => { + this.handleFileChanged(filePath); + }); + this.watcher.on('error', (error) => { this.emit('error', error); }); @@ -101,6 +107,21 @@ export class Watcher extends EventEmitter { this.pendingFiles.set(filePath, timeout); } + // #15: Handle file change events + private handleFileChanged(filePath: string): void { + const existing = this.pendingFiles.get(filePath); + if (existing) { + clearTimeout(existing); + } + + const timeout = setTimeout(async () => { + this.pendingFiles.delete(filePath); + await this.processChangedFile(filePath); + }, this.options.debounceMs); + + this.pendingFiles.set(filePath, timeout); + } + private async processFile(filePath: string): Promise { try { // Analyze file @@ -140,6 +161,30 @@ export class Watcher extends EventEmitter { } } + // #15: Process changed files - re-analyze and re-suggest + private async processChangedFile(filePath: string): Promise { + try { + const analysis = await this.analyzer.analyze(filePath); + this.emit('changed', analysis); + + // Re-evaluate rules for changed file + if (this.options.auto) { + const suggestion = this.suggester.generateSuggestion(analysis); + + if (suggestion && suggestion.confidence >= this.options.minConfidence! && !suggestion.requiresConfirmation) { + const result = await this.executor.execute(suggestion); + if (result.success) { + this.emit('organized', analysis, suggestion.destination); + } else { + this.emit('error', new Error(result.error || 'Execution failed')); + } + } + } + } catch (error) { + this.emit('error', error instanceof Error ? error : new Error('Unknown error')); + } + } + isWatching(): boolean { return this.watcher !== null; } diff --git a/src/index.ts b/src/index.ts index 9b98783..151b5a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,5 +43,5 @@ export { type AppPaths, } from './config.js'; -// Version -export const VERSION = '0.1.0'; +// Version - re-export from config to avoid duplication (#9) +export { VERSION } from './config.js';