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));
+}