diff --git a/src/ai/smart-renamer.ts b/src/ai/smart-renamer.ts new file mode 100644 index 0000000..27817c2 --- /dev/null +++ b/src/ai/smart-renamer.ts @@ -0,0 +1,694 @@ +/** + * Smart Renamer - AI-powered file renaming service + * + * Converts unreadable filenames like "IMG_20240315_123456.jpg" into meaningful names + * like "Отпуск Турция - Март 2024.jpg" using EXIF data, OCR, and AI analysis. + */ + +import { basename, extname, dirname, join } from 'path'; +import { format } from 'date-fns'; +import { ru, enUS } from 'date-fns/locale'; +import { analyzeImage, type ImageMetadata } from '../analyzers/image.js'; +import { ClassifierService } from './classifier.js'; +import { OCRService } from './ocr.js'; +import { EmbeddingService } from './embeddings.js'; +import { analyzeFilename } from '../utils/filename-analyzer.js'; +import { getMimeType, getFileCategory } from '../utils/mime.js'; +import { reverseGeocode, type LocationInfo } from '../utils/geocoding.js'; + +export interface RenameContext { + // File info + filePath: string; + filename: string; + extension: string; + mimeType: string | null; + category: string; + + // Image metadata (from EXIF) + imageMetadata?: ImageMetadata; + + // Location info (from GPS) + location?: LocationInfo; + + // OCR text (from image/document) + ocrText?: string; + + // AI classification + aiCategory?: string; + aiConfidence?: number; + + // User hints + eventName?: string; + trip?: string; + people?: string[]; +} + +export interface RenameSuggestion { + original: string; + suggested: string; + confidence: number; + reason: string; + components: { + event?: string; + location?: string; + date?: string; + sequence?: number; + description?: string; + }; +} + +// Patterns for unreadable filenames that need renaming +const UNREADABLE_PATTERNS = [ + // Camera patterns + /^IMG_\d{8}_\d+/i, // IMG_20240315_123456 + /^DSC_?\d+/i, // DSC_1234 or DSC1234 + /^DSCN?\d+/i, // DSCN1234 + /^P\d{7,}/i, // P1234567 + /^DCIM\d+/i, // DCIM1234 + /^Photo_\d+/i, // Photo_001 + + // Phone patterns + /^IMG-\d{8}-WA\d+/i, // WhatsApp: IMG-20240315-WA0001 + /^VID-\d{8}-WA\d+/i, // WhatsApp video + /^PXL_\d{8}_\d+/i, // Google Pixel + /^Screenshot_\d{8}/i, // Screenshot_20240315 + /^\d{8}_\d{6}/, // 20240315_123456 + + // Download patterns + /^image\s*\(\d+\)/i, // image (1) + /^photo\s*\(\d+\)/i, // photo (1) + /^download\s*\(\d+\)/i, // download (1) + /^Untitled/i, // Untitled + /^[a-f0-9]{24,}/i, // Long hash-like names + + // Generic + /^temp/i, + /^new\s*file/i, + /^copy\s*of/i, + /^копия/i, +]; + +// Month names for formatting +const MONTH_NAMES_RU = [ + 'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', + 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь', +]; + +const MONTH_NAMES_EN = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', +]; + +// AI categories that suggest specific naming patterns +const CATEGORY_TEMPLATES: Record string | null> = { + 'photo or image': buildPhotoName, + 'screenshot': buildScreenshotName, + 'meme or funny image': buildMemeName, + 'design or artwork': buildDesignName, + 'video': buildVideoName, + 'work document': buildDocumentName, + 'personal document': buildDocumentName, + 'financial document': buildFinancialDocName, + 'invoice or receipt': buildInvoiceName, +}; + +export class SmartRenamer { + private modelsDir: string; + private classifier: ClassifierService | null = null; + private ocr: OCRService | null = null; + private embeddings: EmbeddingService | null = null; + private aiEnabled = false; + private language: 'ru' | 'en' = 'ru'; + + constructor(modelsDir: string, options?: { language?: 'ru' | 'en' }) { + this.modelsDir = modelsDir; + this.language = options?.language ?? 'ru'; + } + + async enableAI(): Promise { + if (this.aiEnabled) return; + + this.classifier = new ClassifierService(this.modelsDir); + this.embeddings = new EmbeddingService(this.modelsDir); + + await Promise.all([ + this.classifier.init(), + this.embeddings.init(), + ]); + + this.aiEnabled = true; + } + + async enableOCR(languages: string[] = ['eng', 'rus']): Promise { + if (this.ocr) return; + + this.ocr = new OCRService(this.modelsDir); + await this.ocr.init(languages); + } + + isAIEnabled(): boolean { + return this.aiEnabled; + } + + /** + * Check if a filename looks unreadable and needs renaming + */ + isUnreadable(filename: string): boolean { + const nameWithoutExt = filename.replace(/\.[^.]+$/, ''); + + // Check against unreadable patterns + if (UNREADABLE_PATTERNS.some(p => p.test(nameWithoutExt))) { + return true; + } + + // If name is just numbers or mostly numbers/underscores + const alphaCount = (nameWithoutExt.match(/[a-zA-Zа-яА-ЯёЁ]/g) || []).length; + const totalCount = nameWithoutExt.length; + if (totalCount > 5 && alphaCount / totalCount < 0.3) { + return true; + } + + return false; + } + + /** + * Generate a smart rename suggestion for a file + */ + async suggest( + filePath: string, + options: { + useAI?: boolean; + useOCR?: boolean; + eventHint?: string; + tripHint?: string; + peopleHint?: string[]; + } = {} + ): Promise { + const filename = basename(filePath); + const ext = extname(filename).toLowerCase(); + const mimeType = getMimeType(filename); + const category = getFileCategory(filename, mimeType || undefined); + + // Build context for naming + const context: RenameContext = { + filePath, + filename, + extension: ext, + mimeType, + category, + eventName: options.eventHint, + trip: options.tripHint, + people: options.peopleHint, + }; + + // If filename is already readable, return it with low confidence + if (!this.isUnreadable(filename)) { + return { + original: filename, + suggested: filename, + confidence: 0.3, + reason: 'Имя файла уже читаемое', + components: {}, + }; + } + + // Analyze based on file type + if (category === 'image' || mimeType?.startsWith('image/')) { + await this.analyzeImage(context); + } + + // Use AI classification if enabled + if (options.useAI && this.aiEnabled && this.classifier) { + try { + const result = await this.classifier.classifyFile({ + filename, + content: context.ocrText, + }); + context.aiCategory = result.category; + context.aiConfidence = result.confidence; + } catch { + // Classification failed + } + } + + // Use OCR if enabled and available + if (options.useOCR && this.ocr && (category === 'image' || category === 'document')) { + try { + const result = await this.ocr.recognize(filePath); + if (result.text && result.text.length > 10) { + context.ocrText = result.text; + } + } catch { + // OCR failed + } + } + + // Generate name based on context + return this.generateName(context); + } + + /** + * Generate suggestions for multiple files (batch mode) + */ + async suggestBatch( + files: string[], + options: { + useAI?: boolean; + useOCR?: boolean; + eventHint?: string; + tripHint?: string; + groupByDate?: boolean; + groupByLocation?: boolean; + } = {} + ): Promise { + const suggestions: RenameSuggestion[] = []; + + // First pass: analyze all files + const contexts: RenameContext[] = []; + for (const filePath of files) { + const filename = basename(filePath); + const ext = extname(filename).toLowerCase(); + const mimeType = getMimeType(filename); + const category = getFileCategory(filename, mimeType || undefined); + + const context: RenameContext = { + filePath, + filename, + extension: ext, + mimeType, + category, + eventName: options.eventHint, + trip: options.tripHint, + }; + + if (category === 'image' || mimeType?.startsWith('image/')) { + await this.analyzeImage(context); + } + + contexts.push(context); + } + + // Group files if requested + if (options.groupByDate || options.groupByLocation) { + this.groupContexts(contexts, options); + } + + // Generate names with sequence numbers for groups + const dateGroups = new Map(); + const locationGroups = new Map(); + + for (const context of contexts) { + // Track sequence numbers + const dateKey = context.imageMetadata?.dateTaken?.toISOString().slice(0, 10) || 'unknown'; + const locationKey = context.location?.city || context.location?.country || 'unknown'; + + const dateSeq = (dateGroups.get(dateKey) || 0) + 1; + dateGroups.set(dateKey, dateSeq); + + const locationSeq = (locationGroups.get(locationKey) || 0) + 1; + locationGroups.set(locationKey, locationSeq); + + // Add sequence info to context + const sequence = options.groupByLocation ? locationSeq : dateSeq; + + const suggestion = this.generateName(context, sequence > 1 ? sequence : undefined); + suggestions.push(suggestion); + } + + return suggestions; + } + + /** + * Analyze image and extract metadata + */ + private async analyzeImage(context: RenameContext): Promise { + try { + const metadata = await analyzeImage(context.filePath); + context.imageMetadata = metadata; + + // Get location from GPS if available + if (metadata.gps) { + try { + const location = await reverseGeocode( + metadata.gps.latitude, + metadata.gps.longitude + ); + context.location = location; + } catch { + // Geocoding failed + } + } + } catch { + // Image analysis failed + } + } + + /** + * Group contexts by date or location for consistent naming + */ + private groupContexts( + contexts: RenameContext[], + options: { groupByDate?: boolean; groupByLocation?: boolean } + ): void { + // Find common event/trip for files on same date or location + const dateMap = new Map(); + const locationMap = new Map(); + + for (const ctx of contexts) { + if (options.groupByDate && ctx.imageMetadata?.dateTaken) { + const dateKey = ctx.imageMetadata.dateTaken.toISOString().slice(0, 10); + const group = dateMap.get(dateKey) || []; + group.push(ctx); + dateMap.set(dateKey, group); + } + + if (options.groupByLocation && ctx.location?.city) { + const locationKey = ctx.location.city; + const group = locationMap.get(locationKey) || []; + group.push(ctx); + locationMap.set(locationKey, group); + } + } + + // If a group has multiple files with same location, set trip hint + for (const [location, group] of locationMap) { + if (group.length >= 3 && !group[0].trip) { + for (const ctx of group) { + ctx.trip = location; + } + } + } + } + + /** + * Generate a smart name based on context + */ + private generateName(context: RenameContext, sequence?: number): RenameSuggestion { + const components: RenameSuggestion['components'] = {}; + const parts: string[] = []; + let confidence = 0.5; + const reasons: string[] = []; + + // 1. Event or trip name (highest priority) + if (context.eventName) { + parts.push(context.eventName); + components.event = context.eventName; + confidence += 0.2; + reasons.push('событие задано пользователем'); + } else if (context.trip) { + parts.push(context.trip); + components.event = context.trip; + confidence += 0.15; + reasons.push('поездка определена по геолокации'); + } + + // 2. Location (if no event/trip) + if (!parts.length && context.location) { + const locationStr = formatLocation(context.location, this.language); + if (locationStr) { + parts.push(locationStr); + components.location = locationStr; + confidence += 0.15; + reasons.push('место определено по GPS'); + } + } + + // 3. Date + if (context.imageMetadata?.dateTaken) { + const dateStr = formatDateSmart(context.imageMetadata.dateTaken, this.language); + components.date = dateStr; + confidence += 0.1; + reasons.push('дата из EXIF'); + + // Add date to name if no event specified + if (!context.eventName) { + parts.push(dateStr); + } + } + + // 4. AI-based description + if (context.aiCategory && context.aiConfidence && context.aiConfidence > 0.7) { + const template = CATEGORY_TEMPLATES[context.aiCategory]; + if (template) { + const aiName = template(context); + if (aiName && !parts.includes(aiName)) { + components.description = aiName; + confidence += 0.1; + reasons.push(`AI: ${context.aiCategory}`); + } + } + } + + // 5. OCR-based keywords + if (context.ocrText && !components.description) { + const keywords = extractKeywords(context.ocrText); + if (keywords.length > 0) { + components.description = keywords.slice(0, 2).join(' '); + confidence += 0.05; + reasons.push('ключевые слова из OCR'); + } + } + + // 6. Sequence number + if (sequence && sequence > 1) { + components.sequence = sequence; + } + + // Build final name + let suggested: string; + + if (parts.length === 0) { + // Fallback: use date or keep original with cleanup + if (components.date) { + suggested = components.date; + } else { + // Clean up original name + suggested = cleanupFilename(context.filename, context.extension); + confidence = 0.3; + reasons.push('очистка оригинального имени'); + } + } else { + suggested = parts.join(' - '); + } + + // Add description if available and not already included + if (components.description && !suggested.includes(components.description)) { + suggested += ` - ${components.description}`; + } + + // Add sequence number + if (components.sequence) { + suggested += ` (${components.sequence})`; + } + + // Ensure extension + if (context.extension && !suggested.toLowerCase().endsWith(context.extension)) { + suggested += context.extension; + } + + // Sanitize + suggested = sanitizeFilename(suggested); + + return { + original: context.filename, + suggested, + confidence: Math.min(confidence, 1), + reason: reasons.join(', ') || 'базовое переименование', + components, + }; + } + + setLanguage(lang: 'ru' | 'en'): void { + this.language = lang; + } +} + +// ═══════════════════════════════════════════════════════════════ +// Template functions for different file types +// ═══════════════════════════════════════════════════════════════ + +function buildPhotoName(ctx: RenameContext): string | null { + const parts: string[] = []; + + if (ctx.location) { + parts.push(ctx.location.city || ctx.location.country || ''); + } + + if (ctx.imageMetadata?.dateTaken) { + const month = MONTH_NAMES_RU[ctx.imageMetadata.dateTaken.getMonth()]; + const year = ctx.imageMetadata.dateTaken.getFullYear(); + parts.push(`${month} ${year}`); + } + + return parts.filter(Boolean).join(' - ') || null; +} + +function buildScreenshotName(ctx: RenameContext): string | null { + const prefix = 'Снимок экрана'; + + if (ctx.imageMetadata?.dateTaken) { + return `${prefix} ${format(ctx.imageMetadata.dateTaken, 'yyyy-MM-dd HH-mm')}`; + } + + return prefix; +} + +function buildMemeName(_ctx: RenameContext): string | null { + return 'Мем'; +} + +function buildDesignName(ctx: RenameContext): string | null { + if (ctx.ocrText) { + const keywords = extractKeywords(ctx.ocrText); + if (keywords.length > 0) { + return `Дизайн - ${keywords[0]}`; + } + } + return 'Дизайн'; +} + +function buildVideoName(ctx: RenameContext): string | null { + const parts: string[] = ['Видео']; + + if (ctx.location) { + parts.push(ctx.location.city || ctx.location.country || ''); + } + + if (ctx.imageMetadata?.dateTaken) { + parts.push(format(ctx.imageMetadata.dateTaken, 'MMMM yyyy')); + } + + return parts.filter(Boolean).join(' - '); +} + +function buildDocumentName(ctx: RenameContext): string | null { + const analysis = analyzeFilename(ctx.filename); + + if (analysis.documentType && analysis.entity) { + return `${analysis.documentType} - ${analysis.entity}`; + } + + return null; +} + +function buildFinancialDocName(ctx: RenameContext): string | null { + const analysis = analyzeFilename(ctx.filename); + + if (analysis.entity && analysis.year) { + return `Финансы ${analysis.entity} ${analysis.year}`; + } + + return null; +} + +function buildInvoiceName(ctx: RenameContext): string | null { + const analysis = analyzeFilename(ctx.filename); + + if (analysis.entity) { + const date = analysis.year ? ` ${analysis.year}` : ''; + return `Счёт ${analysis.entity}${date}`; + } + + return null; +} + +// ═══════════════════════════════════════════════════════════════ +// Helper functions +// ═══════════════════════════════════════════════════════════════ + +function formatLocation(location: LocationInfo, lang: 'ru' | 'en'): string { + const parts: string[] = []; + + if (location.city) { + parts.push(location.city); + } else if (location.region) { + parts.push(location.region); + } + + if (location.country && parts.length === 0) { + parts.push(location.country); + } + + return parts.join(', '); +} + +function formatDateSmart(date: Date, lang: 'ru' | 'en'): string { + const monthNames = lang === 'ru' ? MONTH_NAMES_RU : MONTH_NAMES_EN; + const month = monthNames[date.getMonth()]; + const year = date.getFullYear(); + + return `${month} ${year}`; +} + +function extractKeywords(text: string): string[] { + // Remove common words and extract meaningful keywords + const stopWords = new Set([ + 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', + 'и', 'в', 'на', 'с', 'по', 'из', 'за', 'от', 'до', 'это', 'как', + ]); + + const words = text + .toLowerCase() + .replace(/[^\w\sа-яёА-ЯЁ]/g, ' ') + .split(/\s+/) + .filter(w => w.length > 3 && !stopWords.has(w)) + .slice(0, 10); + + // Count word frequency + const freq = new Map(); + for (const word of words) { + freq.set(word, (freq.get(word) || 0) + 1); + } + + // Return most frequent words + return [...freq.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 3) + .map(([word]) => word.charAt(0).toUpperCase() + word.slice(1)); +} + +function cleanupFilename(filename: string, ext: string): string { + // Remove extension first + let name = filename.replace(new RegExp(`\\${ext}$`, 'i'), ''); + + // Remove common prefixes + name = name.replace(/^(IMG|DSC|PXL|Photo|Screenshot|VID)[-_]?/i, ''); + + // Replace underscores and dashes with spaces + name = name.replace(/[_-]+/g, ' '); + + // Remove long number sequences + name = name.replace(/\d{6,}/g, ''); + + // Clean up spaces + name = name.replace(/\s+/g, ' ').trim(); + + // If nothing left, use generic name + if (!name || name.length < 3) { + name = 'Файл'; + } + + return name; +} + +function sanitizeFilename(filename: string): string { + // Remove invalid characters + let sanitized = filename.replace(/[<>:"/\\|?*]/g, ''); + + // Replace multiple spaces with single space + sanitized = sanitized.replace(/\s+/g, ' '); + + // Trim + sanitized = sanitized.trim(); + + // Limit length (preserving extension) + const ext = extname(sanitized); + const base = basename(sanitized, ext); + if (base.length > 100) { + sanitized = base.slice(0, 100).trim() + ext; + } + + return sanitized; +} + +export default SmartRenamer; diff --git a/src/cli.ts b/src/cli.ts index 94d7e45..f9cee78 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,6 +16,9 @@ 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 } from './ui/table.js'; import { renderScanStats, renderFileTable, @@ -533,6 +536,230 @@ program } }); +// ═══════════════════════════════════════════════════════════════ +// 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 // ═══════════════════════════════════════════════════════════════ diff --git a/src/utils/geocoding.ts b/src/utils/geocoding.ts new file mode 100644 index 0000000..8dc78f6 --- /dev/null +++ b/src/utils/geocoding.ts @@ -0,0 +1,322 @@ +/** + * Offline reverse geocoding utility + * + * Converts GPS coordinates to location names without external API calls. + * Uses a built-in database of major cities and regions. + */ + +export interface LocationInfo { + country: string; + countryCode: string; + region?: string; + city?: string; + timezone?: string; +} + +// Major cities database with approximate coordinates +// Format: [lat, lon, city, region, country, countryCode, timezone] +const CITIES_DATABASE: [number, number, string, string, string, string, string][] = [ + // Russia + [55.7558, 37.6173, 'Москва', 'Москва', 'Россия', 'RU', 'Europe/Moscow'], + [59.9343, 30.3351, 'Санкт-Петербург', 'Санкт-Петербург', 'Россия', 'RU', 'Europe/Moscow'], + [56.8389, 60.6057, 'Екатеринбург', 'Свердловская область', 'Россия', 'RU', 'Asia/Yekaterinburg'], + [55.0084, 82.9357, 'Новосибирск', 'Новосибирская область', 'Россия', 'RU', 'Asia/Novosibirsk'], + [55.7887, 49.1221, 'Казань', 'Татарстан', 'Россия', 'RU', 'Europe/Moscow'], + [54.9885, 73.3242, 'Омск', 'Омская область', 'Россия', 'RU', 'Asia/Omsk'], + [53.2001, 50.1500, 'Самара', 'Самарская область', 'Россия', 'RU', 'Europe/Samara'], + [47.2357, 39.7015, 'Ростов-на-Дону', 'Ростовская область', 'Россия', 'RU', 'Europe/Moscow'], + [54.7388, 55.9721, 'Уфа', 'Башкортостан', 'Россия', 'RU', 'Asia/Yekaterinburg'], + [56.3287, 44.0020, 'Нижний Новгород', 'Нижегородская область', 'Россия', 'RU', 'Europe/Moscow'], + [43.1056, 131.8735, 'Владивосток', 'Приморский край', 'Россия', 'RU', 'Asia/Vladivostok'], + [44.9572, 34.1108, 'Симферополь', 'Крым', 'Россия', 'RU', 'Europe/Simferopol'], + [43.5855, 39.7231, 'Сочи', 'Краснодарский край', 'Россия', 'RU', 'Europe/Moscow'], + [45.0448, 38.9760, 'Краснодар', 'Краснодарский край', 'Россия', 'RU', 'Europe/Moscow'], + [48.7194, 44.5018, 'Волгоград', 'Волгоградская область', 'Россия', 'RU', 'Europe/Volgograd'], + + // Turkey + [41.0082, 28.9784, 'Стамбул', 'Стамбул', 'Турция', 'TR', 'Europe/Istanbul'], + [39.9334, 32.8597, 'Анкара', 'Анкара', 'Турция', 'TR', 'Europe/Istanbul'], + [38.4192, 27.1287, 'Измир', 'Измир', 'Турция', 'TR', 'Europe/Istanbul'], + [36.8969, 30.7133, 'Анталья', 'Анталья', 'Турция', 'TR', 'Europe/Istanbul'], + [37.0000, 35.3213, 'Адана', 'Адана', 'Турция', 'TR', 'Europe/Istanbul'], + [36.5500, 32.0000, 'Аланья', 'Анталья', 'Турция', 'TR', 'Europe/Istanbul'], + [36.8500, 28.2667, 'Мармарис', 'Мугла', 'Турция', 'TR', 'Europe/Istanbul'], + [37.0344, 27.4305, 'Бодрум', 'Мугла', 'Турция', 'TR', 'Europe/Istanbul'], + [36.8841, 30.7056, 'Кемер', 'Анталья', 'Турция', 'TR', 'Europe/Istanbul'], + + // Egypt + [30.0444, 31.2357, 'Каир', 'Каир', 'Египет', 'EG', 'Africa/Cairo'], + [31.2001, 29.9187, 'Александрия', 'Александрия', 'Египет', 'EG', 'Africa/Cairo'], + [27.2579, 33.8116, 'Хургада', 'Красное море', 'Египет', 'EG', 'Africa/Cairo'], + [27.9158, 34.3300, 'Шарм-эш-Шейх', 'Южный Синай', 'Египет', 'EG', 'Africa/Cairo'], + + // UAE + [25.2048, 55.2708, 'Дубай', 'Дубай', 'ОАЭ', 'AE', 'Asia/Dubai'], + [24.4539, 54.3773, 'Абу-Даби', 'Абу-Даби', 'ОАЭ', 'AE', 'Asia/Dubai'], + [25.3463, 55.4209, 'Шарджа', 'Шарджа', 'ОАЭ', 'AE', 'Asia/Dubai'], + + // Thailand + [13.7563, 100.5018, 'Бангкок', 'Бангкок', 'Таиланд', 'TH', 'Asia/Bangkok'], + [7.8804, 98.3923, 'Пхукет', 'Пхукет', 'Таиланд', 'TH', 'Asia/Bangkok'], + [9.1382, 99.3219, 'Самуи', 'Сураттхани', 'Таиланд', 'TH', 'Asia/Bangkok'], + [12.9236, 100.8825, 'Паттайя', 'Чонбури', 'Таиланд', 'TH', 'Asia/Bangkok'], + [18.7883, 98.9853, 'Чиангмай', 'Чиангмай', 'Таиланд', 'TH', 'Asia/Bangkok'], + + // Europe + [48.8566, 2.3522, 'Париж', 'Иль-де-Франс', 'Франция', 'FR', 'Europe/Paris'], + [51.5074, -0.1278, 'Лондон', 'Англия', 'Великобритания', 'GB', 'Europe/London'], + [52.5200, 13.4050, 'Берлин', 'Берлин', 'Германия', 'DE', 'Europe/Berlin'], + [41.9028, 12.4964, 'Рим', 'Лацио', 'Италия', 'IT', 'Europe/Rome'], + [40.4168, -3.7038, 'Мадрид', 'Мадрид', 'Испания', 'ES', 'Europe/Madrid'], + [41.3851, 2.1734, 'Барселона', 'Каталония', 'Испания', 'ES', 'Europe/Madrid'], + [48.2082, 16.3738, 'Вена', 'Вена', 'Австрия', 'AT', 'Europe/Vienna'], + [50.0755, 14.4378, 'Прага', 'Прага', 'Чехия', 'CZ', 'Europe/Prague'], + [52.3676, 4.9041, 'Амстердам', 'Северная Голландия', 'Нидерланды', 'NL', 'Europe/Amsterdam'], + [47.4979, 19.0402, 'Будапешт', 'Будапешт', 'Венгрия', 'HU', 'Europe/Budapest'], + [44.4268, 26.1025, 'Бухарест', 'Бухарест', 'Румыния', 'RO', 'Europe/Bucharest'], + [42.6977, 23.3219, 'София', 'София', 'Болгария', 'BG', 'Europe/Sofia'], + [37.9838, 23.7275, 'Афины', 'Аттика', 'Греция', 'GR', 'Europe/Athens'], + [45.4642, 9.1900, 'Милан', 'Ломбардия', 'Италия', 'IT', 'Europe/Rome'], + [43.7102, 7.2620, 'Ницца', 'Прованс', 'Франция', 'FR', 'Europe/Paris'], + [43.2965, 5.3698, 'Марсель', 'Прованс', 'Франция', 'FR', 'Europe/Paris'], + + // USA + [40.7128, -74.0060, 'Нью-Йорк', 'Нью-Йорк', 'США', 'US', 'America/New_York'], + [34.0522, -118.2437, 'Лос-Анджелес', 'Калифорния', 'США', 'US', 'America/Los_Angeles'], + [41.8781, -87.6298, 'Чикаго', 'Иллинойс', 'США', 'US', 'America/Chicago'], + [29.7604, -95.3698, 'Хьюстон', 'Техас', 'США', 'US', 'America/Chicago'], + [33.4484, -112.0740, 'Финикс', 'Аризона', 'США', 'US', 'America/Phoenix'], + [37.7749, -122.4194, 'Сан-Франциско', 'Калифорния', 'США', 'US', 'America/Los_Angeles'], + [25.7617, -80.1918, 'Майами', 'Флорида', 'США', 'US', 'America/New_York'], + [36.1699, -115.1398, 'Лас-Вегас', 'Невада', 'США', 'US', 'America/Los_Angeles'], + [47.6062, -122.3321, 'Сиэтл', 'Вашингтон', 'США', 'US', 'America/Los_Angeles'], + [38.9072, -77.0369, 'Вашингтон', 'Округ Колумбия', 'США', 'US', 'America/New_York'], + + // Asia + [35.6762, 139.6503, 'Токио', 'Токио', 'Япония', 'JP', 'Asia/Tokyo'], + [37.5665, 126.9780, 'Сеул', 'Сеул', 'Южная Корея', 'KR', 'Asia/Seoul'], + [31.2304, 121.4737, 'Шанхай', 'Шанхай', 'Китай', 'CN', 'Asia/Shanghai'], + [39.9042, 116.4074, 'Пекин', 'Пекин', 'Китай', 'CN', 'Asia/Shanghai'], + [22.3193, 114.1694, 'Гонконг', 'Гонконг', 'Китай', 'CN', 'Asia/Hong_Kong'], + [1.3521, 103.8198, 'Сингапур', 'Сингапур', 'Сингапур', 'SG', 'Asia/Singapore'], + [28.6139, 77.2090, 'Дели', 'Дели', 'Индия', 'IN', 'Asia/Kolkata'], + [19.0760, 72.8777, 'Мумбаи', 'Махараштра', 'Индия', 'IN', 'Asia/Kolkata'], + [3.1390, 101.6869, 'Куала-Лумпур', 'Куала-Лумпур', 'Малайзия', 'MY', 'Asia/Kuala_Lumpur'], + [-6.2088, 106.8456, 'Джакарта', 'Джакарта', 'Индонезия', 'ID', 'Asia/Jakarta'], + [-8.3405, 115.0920, 'Бали', 'Бали', 'Индонезия', 'ID', 'Asia/Makassar'], + [14.5995, 120.9842, 'Манила', 'Метро Манила', 'Филиппины', 'PH', 'Asia/Manila'], + [21.0278, 105.8342, 'Ханой', 'Ханой', 'Вьетнам', 'VN', 'Asia/Ho_Chi_Minh'], + [10.8231, 106.6297, 'Хошимин', 'Хошимин', 'Вьетнам', 'VN', 'Asia/Ho_Chi_Minh'], + + // CIS + [50.4501, 30.5234, 'Киев', 'Киев', 'Украина', 'UA', 'Europe/Kiev'], + [49.9935, 36.2304, 'Харьков', 'Харьковская область', 'Украина', 'UA', 'Europe/Kiev'], + [46.4825, 30.7233, 'Одесса', 'Одесская область', 'Украина', 'UA', 'Europe/Kiev'], + [53.9045, 27.5615, 'Минск', 'Минск', 'Беларусь', 'BY', 'Europe/Minsk'], + [43.2220, 76.8512, 'Алматы', 'Алматы', 'Казахстан', 'KZ', 'Asia/Almaty'], + [51.1801, 71.4460, 'Нур-Султан', 'Нур-Султан', 'Казахстан', 'KZ', 'Asia/Almaty'], + [41.2995, 69.2401, 'Ташкент', 'Ташкент', 'Узбекистан', 'UZ', 'Asia/Tashkent'], + [41.3111, 36.2894, 'Самарканд', 'Самарканд', 'Узбекистан', 'UZ', 'Asia/Samarkand'], + [42.8746, 74.5698, 'Бишкек', 'Бишкек', 'Кыргызстан', 'KG', 'Asia/Bishkek'], + [40.4093, 49.8671, 'Баку', 'Баку', 'Азербайджан', 'AZ', 'Asia/Baku'], + [41.7151, 44.8271, 'Тбилиси', 'Тбилиси', 'Грузия', 'GE', 'Asia/Tbilisi'], + [40.1872, 44.5152, 'Ереван', 'Ереван', 'Армения', 'AM', 'Asia/Yerevan'], + + // Popular beach destinations + [36.4618, 28.2176, 'Родос', 'Южные Эгейские острова', 'Греция', 'GR', 'Europe/Athens'], + [35.5138, 24.0180, 'Крит', 'Крит', 'Греция', 'GR', 'Europe/Athens'], + [25.0330, -77.3963, 'Нассау', 'Нью-Провиденс', 'Багамы', 'BS', 'America/Nassau'], + [21.1619, -86.8515, 'Канкун', 'Кинтана-Роо', 'Мексика', 'MX', 'America/Cancun'], + [20.2114, -87.4654, 'Тулум', 'Кинтана-Роо', 'Мексика', 'MX', 'America/Cancun'], + [-4.0383, 39.6682, 'Момбаса', 'Момбаса', 'Кения', 'KE', 'Africa/Nairobi'], + [-6.1659, 39.2026, 'Занзибар', 'Занзибар', 'Танзания', 'TZ', 'Africa/Dar_es_Salaam'], + [-20.1609, 57.5012, 'Маврикий', 'Маврикий', 'Маврикий', 'MU', 'Indian/Mauritius'], + [-4.6796, 55.4920, 'Сейшелы', 'Маэ', 'Сейшелы', 'SC', 'Indian/Mahe'], + [4.1755, 73.5093, 'Мальдивы', 'Мале', 'Мальдивы', 'MV', 'Indian/Maldives'], + [21.4735, 39.8148, 'Джидда', 'Мекка', 'Саудовская Аравия', 'SA', 'Asia/Riyadh'], +]; + +// Country boundaries (rough approximations for fallback) +const COUNTRY_BOUNDS: Record = { + RU: { name: 'Россия', latMin: 41, latMax: 82, lonMin: 19, lonMax: 180 }, + TR: { name: 'Турция', latMin: 36, latMax: 42, lonMin: 26, lonMax: 45 }, + EG: { name: 'Египет', latMin: 22, latMax: 32, lonMin: 25, lonMax: 37 }, + AE: { name: 'ОАЭ', latMin: 22, latMax: 26, lonMin: 51, lonMax: 57 }, + TH: { name: 'Таиланд', latMin: 5, latMax: 21, lonMin: 97, lonMax: 106 }, + FR: { name: 'Франция', latMin: 41, latMax: 51, lonMin: -5, lonMax: 10 }, + GB: { name: 'Великобритания', latMin: 49, latMax: 61, lonMin: -8, lonMax: 2 }, + DE: { name: 'Германия', latMin: 47, latMax: 55, lonMin: 6, lonMax: 15 }, + IT: { name: 'Италия', latMin: 36, latMax: 47, lonMin: 6, lonMax: 19 }, + ES: { name: 'Испания', latMin: 36, latMax: 44, lonMin: -10, lonMax: 5 }, + US: { name: 'США', latMin: 24, latMax: 50, lonMin: -125, lonMax: -66 }, + JP: { name: 'Япония', latMin: 24, latMax: 46, lonMin: 123, lonMax: 146 }, + KR: { name: 'Южная Корея', latMin: 33, latMax: 39, lonMin: 124, lonMax: 132 }, + CN: { name: 'Китай', latMin: 18, latMax: 54, lonMin: 73, lonMax: 135 }, + IN: { name: 'Индия', latMin: 8, latMax: 37, lonMin: 68, lonMax: 98 }, + AU: { name: 'Австралия', latMin: -44, latMax: -10, lonMin: 113, lonMax: 154 }, + BR: { name: 'Бразилия', latMin: -34, latMax: 6, lonMin: -74, lonMax: -34 }, + UA: { name: 'Украина', latMin: 44, latMax: 53, lonMin: 22, lonMax: 41 }, + BY: { name: 'Беларусь', latMin: 51, latMax: 56, lonMin: 23, lonMax: 33 }, + KZ: { name: 'Казахстан', latMin: 40, latMax: 56, lonMin: 46, lonMax: 88 }, + GR: { name: 'Греция', latMin: 35, latMax: 42, lonMin: 19, lonMax: 30 }, + ID: { name: 'Индонезия', latMin: -11, latMax: 6, lonMin: 95, lonMax: 141 }, + VN: { name: 'Вьетнам', latMin: 8, latMax: 24, lonMin: 102, lonMax: 110 }, + MX: { name: 'Мексика', latMin: 14, latMax: 33, lonMin: -118, lonMax: -86 }, + MV: { name: 'Мальдивы', latMin: -1, latMax: 8, lonMin: 72, lonMax: 74 }, +}; + +/** + * Calculate distance between two GPS points in kilometers (Haversine formula) + */ +function haversineDistance(lat1: number, lon1: number, lat2: number, lon2: number): number { + const R = 6371; // Earth's radius in km + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * + Math.sin(dLon / 2) * Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; +} + +function toRad(deg: number): number { + return deg * (Math.PI / 180); +} + +/** + * Find the nearest city to the given coordinates + */ +function findNearestCity(lat: number, lon: number): { + city: string; + region: string; + country: string; + countryCode: string; + timezone: string; + distance: number; +} | null { + let nearest: typeof CITIES_DATABASE[0] | null = null; + let minDistance = Infinity; + + for (const city of CITIES_DATABASE) { + const [cityLat, cityLon] = city; + const distance = haversineDistance(lat, lon, cityLat, cityLon); + + if (distance < minDistance) { + minDistance = distance; + nearest = city; + } + } + + // Only return if within 100km of a known city + if (nearest && minDistance <= 100) { + return { + city: nearest[2], + region: nearest[3], + country: nearest[4], + countryCode: nearest[5], + timezone: nearest[6], + distance: minDistance, + }; + } + + return null; +} + +/** + * Find country by coordinates using bounding boxes + */ +function findCountry(lat: number, lon: number): { name: string; code: string } | null { + for (const [code, bounds] of Object.entries(COUNTRY_BOUNDS)) { + if ( + lat >= bounds.latMin && + lat <= bounds.latMax && + lon >= bounds.lonMin && + lon <= bounds.lonMax + ) { + return { name: bounds.name, code }; + } + } + return null; +} + +/** + * Reverse geocode GPS coordinates to a location name + * + * @param latitude - GPS latitude + * @param longitude - GPS longitude + * @returns Location information + */ +export async function reverseGeocode(latitude: number, longitude: number): Promise { + // Validate coordinates + if ( + typeof latitude !== 'number' || + typeof longitude !== 'number' || + isNaN(latitude) || + isNaN(longitude) || + latitude < -90 || + latitude > 90 || + longitude < -180 || + longitude > 180 + ) { + throw new Error('Invalid GPS coordinates'); + } + + // Try to find nearest city first + const nearestCity = findNearestCity(latitude, longitude); + + if (nearestCity) { + return { + country: nearestCity.country, + countryCode: nearestCity.countryCode, + region: nearestCity.region, + city: nearestCity.city, + timezone: nearestCity.timezone, + }; + } + + // Fallback to country detection + const country = findCountry(latitude, longitude); + + if (country) { + return { + country: country.name, + countryCode: country.code, + }; + } + + // Unknown location + return { + country: 'Неизвестно', + countryCode: 'XX', + }; +} + +/** + * Format location for display + */ +export function formatLocation(location: LocationInfo, style: 'full' | 'short' = 'short'): string { + if (style === 'full') { + const parts = [location.city, location.region, location.country].filter(Boolean); + return parts.join(', '); + } + + // Short style: city or country + return location.city || location.country; +} + +/** + * Get city suggestions for a partial name + */ +export function suggestCities(query: string, limit = 10): string[] { + const lowerQuery = query.toLowerCase(); + + const matches = CITIES_DATABASE + .filter(([, , city]) => city.toLowerCase().includes(lowerQuery)) + .map(([, , city, region, country]) => `${city}, ${country}`) + .slice(0, limit); + + return matches; +} + +export default reverseGeocode;