변경 목적: 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개를 확인했습니다.
427 lines
16 KiB
C#
427 lines
16 KiB
C#
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));
|
|
}
|