AX Commander 비교본 런처 기능 대량 이식
변경 목적: Agent Compare 아래 비교본의 개발 문서와 런처 소스를 기준으로 현재 AX Commander에 빠져 있던 신규 런처 기능을 동일한 흐름으로 옮겨, 비교본 수준의 기능 폭을 현재 제품에 반영했습니다. 핵심 수정사항: 비교본의 신규 런처 핸들러 다수를 src/AxCopilot/Handlers로 이식하고 App.xaml.cs 등록 흐름에 연결했습니다. 빠른 링크, 파일 태그, 알림 센터, 포모도로, 파일 브라우저, 핫키 관리, OCR, 세션/스케줄/매크로, Git/정규식/네트워크/압축/해시/UUID/JWT/QR 등 AX Commander 기능을 추가했습니다. 핵심 수정사항: 신규 기능이 실제 동작하도록 AppSettings 확장, SchedulerService/FileTagService/NotificationCenterService/IconCacheService/UrlTemplateEngine/PomodoroService 추가, 배치 이름변경/세션/스케줄/매크로 편집 창 추가, NotificationService와 Symbols 보강, QR/OCR용 csproj 의존성과 Windows 타겟 프레임워크를 반영했습니다. 문서 반영: README.md와 docs/DEVELOPMENT.md에 비교본 기반 런처 기능 이식 이력과 검증 결과를 업데이트했습니다. 검증 결과: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 실행 기준 경고 0개, 오류 0개를 확인했습니다.
This commit is contained in:
426
src/AxCopilot/Views/BatchRenameWindow.xaml.cs
Normal file
426
src/AxCopilot/Views/BatchRenameWindow.xaml.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// L5-5: 배치 파일 이름변경 창.
|
||||
/// 여러 파일을 변수 패턴 또는 정규식으로 미리보기 후 일괄 적용합니다.
|
||||
/// </summary>
|
||||
public partial class BatchRenameWindow : Window
|
||||
{
|
||||
private readonly ObservableCollection<RenameEntry> _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<string> 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<string>(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<RenameEntry>().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<string>();
|
||||
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user