diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md index 0a2f767..5c3ba9e 100644 --- a/docs/LAUNCHER_ROADMAP.md +++ b/docs/LAUNCHER_ROADMAP.md @@ -133,7 +133,7 @@ | L5-2 | **OCR 화면 텍스트 추출** ✅ | `ocr` 프리픽스 + F4 글로벌 단축키. RegionSelectWindow 재사용, Windows.Media.Ocr 로컬 엔진. 결과 → 클립보드 복사 + 런처 입력창 자동 채움 | 높음 | | L5-3 | **QuickLook 인라인 편집** ✅ | F3 미리보기 → Ctrl+E 편집 모드 토글. 텍스트/코드 전체 읽기(300줄 제한 없음). Ctrl+S 저장, ● 수정 마커, Esc 취소 확인, 저장 후 미리보기 새로고침 | 중간 | | L5-4 | **앱 세션 스냅** | 여러 앱을 지정 레이아웃으로 한번에 열기. `snap 세션이름` → 등록된 앱 목록을 각 레이아웃에 배치 | 중간 | -| L5-5 | **배치 파일 이름 변경** | 다중 선택 후 `rename {패턴}` → 넘버링·날짜·정규식 치환 미리보기 → 일괄 적용 | 중간 | +| L5-5 | **배치 파일 이름 변경** ✅ | `batchren` 프리픽스로 BatchRenameWindow 오픈. 변수 패턴(`{name}`, `{n:3}`, `{date:format}`, `{ext}`) + 정규식 모드(`/old/new/`). 드래그 앤 드롭·폴더/파일 추가, DataGrid 실시간 미리보기, 충돌 감지(배경 붉은 강조), 확장자 유지 토글, 시작 번호 지정, 적용 후 엔트리 갱신 | 중간 | | L5-6 | **자동화 스케줄러** | `sched` 프리픽스로 시간·앱 기반 트리거 등록. "매일 09:00 = 크롬 열기", "캐치 앱 실행 시 = 알림" | 낮음 | ### Phase L5 구현 순서 (권장) diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index 7e4e9a3..ddc1153 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -182,6 +182,8 @@ public partial class App : System.Windows.Application commandResolver.RegisterHandler(new HotkeyHandler(settings)); // Phase L5-2: OCR 화면 텍스트 추출 (prefix=ocr) commandResolver.RegisterHandler(new OcrHandler()); + // Phase L5-5: 배치 파일 이름변경 (prefix=batchren) + commandResolver.RegisterHandler(new BatchRenameHandler()); // ─── 플러그인 로드 ──────────────────────────────────────────────────── var pluginHost = new PluginHost(settings, commandResolver); diff --git a/src/AxCopilot/Handlers/BatchRenameHandler.cs b/src/AxCopilot/Handlers/BatchRenameHandler.cs new file mode 100644 index 0000000..20f28ca --- /dev/null +++ b/src/AxCopilot/Handlers/BatchRenameHandler.cs @@ -0,0 +1,86 @@ +using System.IO; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L5-5: 배치 파일 이름변경 핸들러. "batchren" 프리픽스로 사용합니다. +/// 예: batchren → 기능 소개 + 창 열기 +/// batchren C:\work\*.xlsx → 해당 패턴 파일을 창에 미리 로드 +/// +public class BatchRenameHandler : IActionHandler +{ + public string? Prefix => "batchren"; + + public PluginMetadata Metadata => new( + "BatchRename", + "배치 파일 이름변경 — batchren", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + + var items = new List + { + new LauncherItem( + "배치 파일 이름변경 창 열기", + "변수 패턴 또는 정규식으로 여러 파일을 한 번에 이름변경합니다", + null, + string.IsNullOrWhiteSpace(q) ? "__open__" : q, + Symbol: Symbols.Rename), + + new LauncherItem( + "변수: {name} 원본명 · {n} 순번 · {n:3} 세 자리 · {date} 날짜", + "예: 보고서_{n:3}_{date} → 보고서_001_2026-04-04.xlsx", + null, null, + Symbol: Symbols.Info), + + new LauncherItem( + "변수: {ext} 확장자 · {date:yyyyMMdd} 날짜 형식 지정", + "정규식 모드: /old_pattern/new_text/ → 패턴 일치 부분 치환", + null, null, + Symbol: Symbols.Info), + }; + + return Task.FromResult>(items); + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + var dataStr = item.Data as string; + if (dataStr == null) return Task.CompletedTask; + + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + var win = new Views.BatchRenameWindow(); + + // 초기 경로 패턴이 지정된 경우 파일 미리 로드 + if (dataStr != "__open__" && !string.IsNullOrWhiteSpace(dataStr)) + { + try + { + var dir = Path.GetDirectoryName(dataStr); + var glob = Path.GetFileName(dataStr); + if (!string.IsNullOrWhiteSpace(dir) && Directory.Exists(dir)) + { + var files = Directory.GetFiles(dir, glob ?? "*"); + Array.Sort(files); + win.AddFiles(files); + } + } + catch (Exception ex) + { + LogService.Warn($"BatchRenameHandler: 초기 로드 실패 — {ex.Message}"); + } + } + + win.Show(); + }); + + return Task.CompletedTask; + } +} diff --git a/src/AxCopilot/Views/BatchRenameWindow.xaml b/src/AxCopilot/Views/BatchRenameWindow.xaml new file mode 100644 index 0000000..4549b6e --- /dev/null +++ b/src/AxCopilot/Views/BatchRenameWindow.xaml @@ -0,0 +1,546 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AxCopilot/Views/BatchRenameWindow.xaml.cs b/src/AxCopilot/Views/BatchRenameWindow.xaml.cs new file mode 100644 index 0000000..8f403b5 --- /dev/null +++ b/src/AxCopilot/Views/BatchRenameWindow.xaml.cs @@ -0,0 +1,426 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.IO; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using System.Windows; +using System.Windows.Input; +using AxCopilot.Services; + +namespace AxCopilot.Views; + +/// +/// L5-5: 배치 파일 이름변경 창. +/// 여러 파일을 변수 패턴 또는 정규식으로 미리보기 후 일괄 적용합니다. +/// +public partial class BatchRenameWindow : Window +{ + private readonly ObservableCollection _entries = new(); + private bool _keepExt = true; + private bool _regexMode = false; + private int _startNumber = 1; + + // ────────────────────────────────────────────────────────────────────── + public BatchRenameWindow() + { + InitializeComponent(); + PreviewGrid.ItemsSource = _entries; + + // 항목 변경 → 카운트 배지 + 빈 상태 갱신 + _entries.CollectionChanged += (_, _) => RefreshUI(); + + // 드래그 앤 드롭 + Drop += Window_Drop; + DragOver += Window_DragOver; + DragLeave += (_, _) => DropHintOverlay.Visibility = Visibility.Collapsed; + } + + // ─── 외부에서 파일 목록 주입 ───────────────────────────────────────── + public void AddFiles(IEnumerable paths) + { + foreach (var p in paths) + { + if (!File.Exists(p)) continue; + if (_entries.Any(e => string.Equals(e.OriginalPath, p, StringComparison.OrdinalIgnoreCase))) continue; + _entries.Add(new RenameEntry + { + OriginalPath = p, + OriginalName = Path.GetFileName(p) + }); + } + UpdatePreviews(); + } + + // ─── 패턴 엔진 ─────────────────────────────────────────────────────── + private static string ApplyPattern( + string pattern, + string originalName, + string ext, + int index, + int startNum, + bool keepExt, + bool regexMode) + { + if (regexMode) + { + // /regex/replacement/ 형식 + var m = Regex.Match(pattern, @"^/(.+)/([^/]*)/?$"); + if (m.Success) + { + var rxPat = m.Groups[1].Value; + var repl = m.Groups[2].Value; + try + { + var result = Regex.Replace(originalName, rxPat, repl); + if (keepExt && !string.IsNullOrEmpty(ext) && + !result.EndsWith(ext, StringComparison.OrdinalIgnoreCase)) + result += ext; + return result; + } + catch { return "⚠ 정규식 오류"; } + } + // 패턴 불완전 → 원본 그대로 + return originalName + ext; + } + + // 변수 모드 + var n = index + startNum; + var newName = pattern; + + // {n:자릿수} — 자릿수 패딩 + newName = Regex.Replace(newName, @"\{n:(\d+)\}", rm => + { + if (int.TryParse(rm.Groups[1].Value, out var digits)) + return n.ToString($"D{digits}"); + return n.ToString(); + }); + + // {date:format} + newName = Regex.Replace(newName, @"\{date:([^}]+)\}", rm => + { + try { return DateTime.Today.ToString(rm.Groups[1].Value); } + catch { return DateTime.Today.ToString("yyyy-MM-dd"); } + }); + + newName = newName + .Replace("{n}", n.ToString()) + .Replace("{name}", originalName) + .Replace("{orig}", originalName) + .Replace("{date}", DateTime.Today.ToString("yyyy-MM-dd")) + .Replace("{ext}", ext.TrimStart('.')); + + // 확장자 유지 + if (keepExt && !string.IsNullOrEmpty(ext)) + { + if (!Path.HasExtension(newName)) + newName += ext; + } + + return newName; + } + + private void UpdatePreviews() + { + var pattern = PatternBox?.Text ?? "{n}_{name}"; + var startNum = _startNumber; + var keepExt = _keepExt; + var regexMode = _regexMode; + + // 새 이름 계산 + for (int i = 0; i < _entries.Count; i++) + { + var entry = _entries[i]; + var ext = Path.GetExtension(entry.OriginalPath); + var nameNoExt = Path.GetFileNameWithoutExtension(entry.OriginalPath); + entry.NewName = ApplyPattern(pattern, nameNoExt, ext, i, startNum, keepExt, regexMode); + } + + // 충돌 감지 (같은 폴더 내 같은 새 이름 OR 기존 파일) + var grouped = _entries + .GroupBy(e => Path.Combine( + Path.GetDirectoryName(e.OriginalPath) ?? "", + e.NewName), + StringComparer.OrdinalIgnoreCase); + + var conflictPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var g in grouped) + { + if (g.Count() > 1) + foreach (var e in g) conflictPaths.Add(e.OriginalPath); + } + + foreach (var entry in _entries) + { + var destPath = Path.Combine( + Path.GetDirectoryName(entry.OriginalPath) ?? "", + entry.NewName); + + // 충돌: 동명 중복 OR 목적지 파일이 이미 존재(원본 제외) + var nameConflict = conflictPaths.Contains(entry.OriginalPath); + var destExists = File.Exists(destPath) && + !string.Equals(destPath, entry.OriginalPath, StringComparison.OrdinalIgnoreCase); + entry.HasConflict = nameConflict || destExists; + } + + RefreshUI(); + } + + private void RefreshUI() + { + var count = _entries.Count; + var conflicts = _entries.Count(e => e.HasConflict); + + FileCountBadge.Text = count > 0 ? $"— {count}개 파일" : ""; + EmptyState.Visibility = count == 0 ? Visibility.Visible : Visibility.Collapsed; + PreviewGrid.Visibility = count == 0 ? Visibility.Collapsed : Visibility.Visible; + + ConflictLabel.Text = conflicts > 0 ? $"⚠ 충돌 {conflicts}개" : ""; + } + + // ─── 타이틀바 ──────────────────────────────────────────────────────── + private void TitleBar_MouseDown(object sender, MouseButtonEventArgs e) + { + if (e.LeftButton == MouseButtonState.Pressed) DragMove(); + } + + private void BtnClose_Click(object sender, MouseButtonEventArgs e) => Close(); + + // ─── 패턴 입력 ─────────────────────────────────────────────────────── + private void PatternBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e) + => UpdatePreviews(); + + private void StartNumberBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e) + { + if (int.TryParse(StartNumberBox.Text, out var n) && n >= 0) + _startNumber = n; + UpdatePreviews(); + } + + // ─── 모드 선택 ─────────────────────────────────────────────────────── + private void BtnModeVar_Click(object sender, MouseButtonEventArgs e) + { + _regexMode = false; + SetModeUI(); + UpdatePreviews(); + } + + private void BtnModeRegex_Click(object sender, MouseButtonEventArgs e) + { + _regexMode = true; + SetModeUI(); + if (string.IsNullOrWhiteSpace(PatternBox.Text) || !PatternBox.Text.StartsWith('/')) + PatternBox.Text = "/(old)/(new)/"; + UpdatePreviews(); + } + + private void SetModeUI() + { + var accent = TryFindResource("AccentColor") as System.Windows.Media.Brush + ?? System.Windows.Media.Brushes.DodgerBlue; + var sec = TryFindResource("SecondaryText") as System.Windows.Media.Brush + ?? System.Windows.Media.Brushes.Gray; + var dimBg = new System.Windows.Media.SolidColorBrush( + System.Windows.Media.Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); + + if (_regexMode) + { + BtnModeVar.Background = dimBg; + BtnModeVarText.Foreground = sec; + BtnModeRegex.Background = accent; + BtnModeRegexText.Foreground = System.Windows.Media.Brushes.White; + } + else + { + BtnModeVar.Background = accent; + BtnModeVarText.Foreground = System.Windows.Media.Brushes.White; + BtnModeRegex.Background = dimBg; + BtnModeRegexText.Foreground = sec; + } + } + + // ─── 확장자 유지 토글 ──────────────────────────────────────────────── + private void ExtToggle_Click(object sender, MouseButtonEventArgs e) + { + _keepExt = !_keepExt; + + var accent = TryFindResource("AccentColor") as System.Windows.Media.Brush + ?? System.Windows.Media.Brushes.DodgerBlue; + var gray = new System.Windows.Media.SolidColorBrush( + System.Windows.Media.Color.FromArgb(0x40, 0xFF, 0xFF, 0xFF)); + + ExtToggle.Background = _keepExt ? accent : gray; + ExtThumb.HorizontalAlignment = _keepExt ? HorizontalAlignment.Right : HorizontalAlignment.Left; + ExtThumb.Margin = _keepExt ? new Thickness(0, 0, 1, 0) : new Thickness(1, 0, 0, 0); + + UpdatePreviews(); + } + + // ─── 힌트 팝업 ─────────────────────────────────────────────────────── + private void BtnHint_Click(object sender, MouseButtonEventArgs e) + => HintPopup.IsOpen = !HintPopup.IsOpen; + + // ─── 파일/폴더 추가 ────────────────────────────────────────────────── + private void BtnAddFolder_Click(object sender, MouseButtonEventArgs e) + { + using var dlg = new System.Windows.Forms.FolderBrowserDialog + { + Description = "파일이 있는 폴더를 선택하세요", + ShowNewFolderButton = false + }; + if (dlg.ShowDialog() != System.Windows.Forms.DialogResult.OK) return; + + var files = Directory.GetFiles(dlg.SelectedPath); + Array.Sort(files); + AddFiles(files); + } + + private void BtnAddFiles_Click(object sender, MouseButtonEventArgs e) + { + using var dlg = new System.Windows.Forms.OpenFileDialog + { + Title = "이름변경할 파일 선택", + Multiselect = true, + Filter = "모든 파일 (*.*)|*.*" + }; + if (dlg.ShowDialog() != System.Windows.Forms.DialogResult.OK) return; + + var files = dlg.FileNames.OrderBy(f => f).ToArray(); + AddFiles(files); + } + + private void BtnRemoveSelected_Click(object sender, MouseButtonEventArgs e) + { + var selected = PreviewGrid.SelectedItems.OfType().ToList(); + foreach (var item in selected) _entries.Remove(item); + UpdatePreviews(); + } + + private void BtnClearAll_Click(object sender, MouseButtonEventArgs e) + { + _entries.Clear(); + RefreshUI(); + } + + // ─── 적용 ──────────────────────────────────────────────────────────── + private void BtnApply_Click(object sender, MouseButtonEventArgs e) + { + var toRename = _entries + .Where(en => en.NewName != en.OriginalName && !en.HasConflict) + .ToList(); + + if (toRename.Count == 0) + { + NotificationService.Notify("배치 이름변경", "변경할 파일이 없습니다. 패턴을 확인하거나 충돌을 해소하세요."); + return; + } + + int ok = 0, fail = 0; + foreach (var entry in toRename) + { + try + { + var destPath = Path.Combine( + Path.GetDirectoryName(entry.OriginalPath) ?? "", + entry.NewName); + + File.Move(entry.OriginalPath, destPath); + + // 성공 후 엔트리 갱신 (새 경로로 업데이트) + entry.OriginalPath = destPath; + entry.OriginalName = entry.NewName; + ok++; + } + catch (Exception ex) + { + LogService.Warn($"배치 이름변경 실패: {entry.OriginalPath} → {entry.NewName} — {ex.Message}"); + fail++; + } + } + + // 미리보기 갱신 (적용 후 남은 항목) + UpdatePreviews(); + + var msg = fail > 0 + ? $"{ok}개 이름변경 완료, {fail}개 실패" + : $"{ok}개 파일 이름변경 완료"; + NotificationService.Notify("AX Copilot", msg); + LogService.Info($"배치 이름변경: {msg}"); + } + + // ─── 드래그 앤 드롭 ────────────────────────────────────────────────── + private void Window_DragOver(object sender, DragEventArgs e) + { + if (e.Data.GetDataPresent(DataFormats.FileDrop)) + { + e.Effects = DragDropEffects.Copy; + DropHintOverlay.Visibility = Visibility.Visible; + } + else + { + e.Effects = DragDropEffects.None; + } + e.Handled = true; + } + + private void Window_Drop(object sender, DragEventArgs e) + { + DropHintOverlay.Visibility = Visibility.Collapsed; + + if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return; + + var dropped = (string[])e.Data.GetData(DataFormats.FileDrop); + var files = new List(); + + foreach (var p in dropped) + { + if (File.Exists(p)) + { + files.Add(p); + } + else if (Directory.Exists(p)) + { + files.AddRange(Directory.GetFiles(p).OrderBy(f => f)); + } + } + + AddFiles(files); + } +} + +// ─── RenameEntry 모델 ──────────────────────────────────────────────────────── +public class RenameEntry : INotifyPropertyChanged +{ + public string OriginalPath { get; set; } = ""; + + private string _originalName = ""; + public string OriginalName + { + get => _originalName; + set { _originalName = value; OnPropertyChanged(); OnPropertyChanged(nameof(StatusText)); } + } + + private string _newName = ""; + public string NewName + { + get => _newName; + set { _newName = value; OnPropertyChanged(); OnPropertyChanged(nameof(StatusText)); } + } + + private bool _hasConflict; + public bool HasConflict + { + get => _hasConflict; + set { _hasConflict = value; OnPropertyChanged(); OnPropertyChanged(nameof(StatusText)); } + } + + public string StatusText => + HasConflict + ? "⚠ 충돌" + : OriginalName == NewName + ? "─" + : "✓"; + + public event PropertyChangedEventHandler? PropertyChanged; + protected void OnPropertyChanged([CallerMemberName] string? name = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); +}