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:
2026-04-05 00:59:45 +09:00
parent 0929778ca7
commit 0336904258
115 changed files with 30749 additions and 1 deletions

View File

@@ -0,0 +1,155 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AxCopilot.Services;
/// <summary>
/// Phase L3-5: 파일 태그 시스템 서비스.
/// 파일·폴더에 사용자 정의 태그를 부여하고 태그 기반 검색을 지원합니다.
/// 데이터는 %APPDATA%\AxCopilot\file_tags.json에 저장됩니다.
/// </summary>
public class FileTagService
{
private static readonly string TagFile = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "file_tags.json");
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = true,
PropertyNameCaseInsensitive = true,
};
/// <summary>key = 정규화된 파일 경로, value = 태그 집합</summary>
private Dictionary<string, HashSet<string>> _data =
new(StringComparer.OrdinalIgnoreCase);
private bool _loaded;
// ─── 싱글턴 ─────────────────────────────────────────────────────────────
private static FileTagService? _instance;
public static FileTagService Instance => _instance ??= new FileTagService();
private FileTagService() { }
// ─── 공개 API ────────────────────────────────────────────────────────────
/// <summary>파일에 태그를 추가합니다.</summary>
public void AddTag(string path, string tag)
{
EnsureLoaded();
path = NormalizePath(path);
tag = NormalizeTag(tag);
if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(tag)) return;
if (!_data.TryGetValue(path, out var tags))
{
tags = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
_data[path] = tags;
}
tags.Add(tag);
Save();
}
/// <summary>파일에서 태그를 제거합니다.</summary>
public void RemoveTag(string path, string tag)
{
EnsureLoaded();
path = NormalizePath(path);
tag = NormalizeTag(tag);
if (!_data.TryGetValue(path, out var tags)) return;
tags.Remove(tag);
if (tags.Count == 0) _data.Remove(path);
Save();
}
/// <summary>파일의 모든 태그를 제거합니다.</summary>
public void ClearTags(string path)
{
EnsureLoaded();
path = NormalizePath(path);
_data.Remove(path);
Save();
}
/// <summary>파일의 태그 목록을 반환합니다.</summary>
public IReadOnlyList<string> GetTags(string path)
{
EnsureLoaded();
path = NormalizePath(path);
return _data.TryGetValue(path, out var tags)
? tags.OrderBy(t => t).ToList()
: Array.Empty<string>();
}
/// <summary>특정 태그가 부여된 파일 경로 목록을 반환합니다.</summary>
public IReadOnlyList<string> GetFilesByTag(string tag)
{
EnsureLoaded();
tag = NormalizeTag(tag);
return _data
.Where(kv => kv.Value.Contains(tag))
.Select(kv => kv.Key)
.OrderBy(p => p)
.ToList();
}
/// <summary>등록된 모든 태그와 각 파일 수를 반환합니다.</summary>
public IReadOnlyDictionary<string, int> GetAllTags()
{
EnsureLoaded();
return _data
.SelectMany(kv => kv.Value)
.GroupBy(t => t, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Count());
}
/// <summary>경로에 태그가 하나 이상 있는지 확인합니다.</summary>
public bool HasTags(string path)
{
EnsureLoaded();
path = NormalizePath(path);
return _data.TryGetValue(path, out var tags) && tags.Count > 0;
}
// ─── 내부 헬퍼 ──────────────────────────────────────────────────────────
private void EnsureLoaded()
{
if (_loaded) return;
_loaded = true;
try
{
if (!File.Exists(TagFile)) return;
var raw = JsonSerializer.Deserialize<Dictionary<string, List<string>>>(
File.ReadAllText(TagFile), JsonOpts);
if (raw != null)
{
_data = raw.ToDictionary(
kv => kv.Key,
kv => new HashSet<string>(kv.Value, StringComparer.OrdinalIgnoreCase),
StringComparer.OrdinalIgnoreCase);
}
}
catch (Exception ex) { LogService.Warn($"[FileTagService] 로드 실패: {ex.Message}"); }
}
private void Save()
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(TagFile)!);
var raw = _data.ToDictionary(
kv => kv.Key,
kv => kv.Value.OrderBy(t => t).ToList());
File.WriteAllText(TagFile, JsonSerializer.Serialize(raw, JsonOpts));
}
catch (Exception ex) { LogService.Warn($"[FileTagService] 저장 실패: {ex.Message}"); }
}
private static string NormalizePath(string path)
=> path.Trim().TrimEnd('\\', '/');
private static string NormalizeTag(string tag)
=> tag.Trim().ToLowerInvariant().Replace(" ", "-");
}

View File

@@ -0,0 +1,192 @@
using System.Collections.Concurrent;
using System.IO;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Media.Imaging;
namespace AxCopilot.Services;
/// <summary>
/// Shell32 SHGetFileInfo를 사용하여 파일/폴더의 Windows 아이콘을 추출·캐시합니다.
/// 캐시 위치: %LOCALAPPDATA%\AxCopilot\IconCache\{확장자}.png
/// GetIconPath()는 캐시 미스 시 null을 반환하고 백그라운드에서 추출을 시작합니다.
/// WarmUp()을 앱 시작 시 호출하면 자주 쓰는 확장자를 미리 준비합니다.
/// </summary>
internal static class IconCacheService
{
private static readonly string _cacheDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"AxCopilot", "IconCache");
/// <summary>key(확장자 또는 "folder") → PNG 캐시 파일 경로 (null = 추출 실패)</summary>
private static readonly ConcurrentDictionary<string, string?> _cache =
new(StringComparer.OrdinalIgnoreCase);
private static volatile bool _warmupDone;
// ─── Win32 ──────────────────────────────────────────────────────────────
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
private struct SHFILEINFO
{
public IntPtr hIcon;
public int iIcon;
public uint dwAttributes;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string szDisplayName;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 80)] public string szTypeName;
}
[DllImport("shell32.dll", CharSet = CharSet.Auto)]
private static extern IntPtr SHGetFileInfo(
string pszPath, uint dwFileAttributes,
ref SHFILEINFO psfi, uint cbFileInfo, uint uFlags);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool DestroyIcon(IntPtr hIcon);
private const uint SHGFI_ICON = 0x000000100;
private const uint SHGFI_LARGEICON = 0x000000000;
private const uint SHGFI_USEFILEATTRIBUTES = 0x000000010;
private const uint FILE_ATTRIBUTE_NORMAL = 0x00000080;
private const uint FILE_ATTRIBUTE_DIRECTORY = 0x00000010;
// ─── 공개 API ───────────────────────────────────────────────────────────
/// <summary>
/// 파일 확장자 기반 아이콘 PNG 경로를 반환합니다.
/// 캐시가 없으면 백그라운드에서 추출을 시작하고 null을 반환합니다.
/// </summary>
public static string? GetIconPath(string filePath, bool isDirectory = false)
{
var key = isDirectory ? "folder" : GetExtKey(filePath);
if (_cache.TryGetValue(key, out var cached)) return cached;
_ = ExtractAsync(filePath, key, isDirectory);
return null;
}
/// <summary>
/// 앱 시작 시 자주 쓰는 파일 형식의 아이콘을 미리 추출합니다.
/// 중복 실행은 자동으로 건너뜁니다.
/// </summary>
public static void WarmUp()
{
if (_warmupDone) return;
_warmupDone = true;
// 캐시 디렉터리에 이미 저장된 PNG 로드 (재시작 시 빠른 복원)
_ = Task.Run(() =>
{
try
{
Directory.CreateDirectory(_cacheDir);
foreach (var file in Directory.GetFiles(_cacheDir, "*.png"))
{
var key = Path.GetFileNameWithoutExtension(file);
_cache.TryAdd(key, file);
}
}
catch { }
});
// 자주 쓰는 확장자 사전 추출 (UI 스레드 부하를 분산하기 위해 순차 실행)
_ = Task.Run(async () =>
{
await Task.Delay(2000); // 앱 초기화 완료 후 추출 시작
var common = new (string Path, string Key, bool IsDir)[]
{
("dummy.exe", "exe", false),
("dummy.lnk", "lnk", false),
("dummy.pdf", "pdf", false),
("dummy.docx", "docx", false),
("dummy.xlsx", "xlsx", false),
("dummy.pptx", "pptx", false),
("dummy.txt", "txt", false),
("dummy.png", "png", false),
("dummy.zip", "zip", false),
("dummy.mp4", "mp4", false),
("C:\\", "folder", true),
};
foreach (var (path, key, isDir) in common)
{
if (_cache.ContainsKey(key)) continue;
await ExtractAsync(path, key, isDir);
await Task.Delay(80); // UI 스레드 부하 분산
}
});
}
// ─── 내부 ───────────────────────────────────────────────────────────────
private static string GetExtKey(string filePath)
{
var ext = Path.GetExtension(filePath).ToLowerInvariant();
return string.IsNullOrEmpty(ext) ? "noext" : ext.TrimStart('.');
}
private static async Task ExtractAsync(string filePath, string key, bool isDirectory)
{
if (_cache.ContainsKey(key)) return;
try
{
Directory.CreateDirectory(_cacheDir);
var cachePath = Path.Combine(_cacheDir, key + ".png");
// 파일이 이미 캐시에 있으면 메모리 캐시에 등록만
if (File.Exists(cachePath))
{
_cache.TryAdd(key, cachePath);
return;
}
// SHGetFileInfo + HICON → PNG 변환은 반드시 STA(Dispatcher) 스레드에서
var dispatcher = Application.Current?.Dispatcher;
if (dispatcher == null) return;
var saved = await dispatcher.InvokeAsync(() =>
TryExtractAndSave(filePath, cachePath, isDirectory));
_cache.TryAdd(key, saved ? cachePath : null);
}
catch (Exception ex)
{
LogService.Warn($"[IconCache] '{key}' 추출 실패: {ex.Message}");
_cache.TryAdd(key, null);
}
}
/// <summary>Dispatcher(STA) 스레드에서 HICON을 PNG로 변환하여 저장합니다.</summary>
private static bool TryExtractAndSave(string filePath, string cachePath, bool isDirectory)
{
var info = new SHFILEINFO();
uint flags = SHGFI_ICON | SHGFI_LARGEICON | SHGFI_USEFILEATTRIBUTES;
uint attrs = isDirectory ? FILE_ATTRIBUTE_DIRECTORY : FILE_ATTRIBUTE_NORMAL;
SHGetFileInfo(filePath, attrs, ref info, (uint)Marshal.SizeOf(info), flags);
if (info.hIcon == IntPtr.Zero) return false;
try
{
var bitmap = Imaging.CreateBitmapSourceFromHIcon(
info.hIcon, Int32Rect.Empty,
BitmapSizeOptions.FromEmptyOptions());
bitmap.Freeze();
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(bitmap));
using var stream = File.OpenWrite(cachePath);
encoder.Save(stream);
return true;
}
catch
{
return false;
}
finally
{
DestroyIcon(info.hIcon);
}
}
}

View File

@@ -0,0 +1,86 @@
using System.Collections.Concurrent;
namespace AxCopilot.Services;
/// <summary>알림 타입.</summary>
public enum NotificationType { Info, Success, Warning, Error }
/// <summary>알림 항목.</summary>
public record NotificationEntry(
string Title,
string Message,
NotificationType Type,
DateTime Timestamp);
/// <summary>
/// Phase L3-7: 알림 센터 — 앱 전체에서 알림을 발행하고 이력을 관리합니다.
/// 트레이 BalloonTip + 인앱 알림 큐를 결합합니다.
/// </summary>
public static class NotificationCenterService
{
private const int MaxHistory = 50;
private static readonly ConcurrentQueue<NotificationEntry> _history = new();
/// <summary>새 알림 발행 이벤트. UI 계층에서 구독하여 인앱 알림 표시에 활용합니다.</summary>
public static event EventHandler<NotificationEntry>? NotificationRaised;
/// <summary>최근 알림 이력 (최대 50건, 최신 순).</summary>
public static IReadOnlyList<NotificationEntry> History
{
get
{
var list = _history.ToArray();
Array.Reverse(list);
return list;
}
}
/// <summary>알림을 발행합니다. 트레이 BalloonTip + 이벤트를 동시에 발화합니다.</summary>
public static void Show(string title, string message, NotificationType type = NotificationType.Info)
{
var entry = Record(title, message, type);
NotificationService.NotifyBalloonOnly(title, message);
TryRaise(entry);
}
/// <summary>성공 알림.</summary>
public static void ShowSuccess(string title, string message)
=> Show(title, message, NotificationType.Success);
/// <summary>경고 알림.</summary>
public static void ShowWarning(string title, string message)
=> Show(title, message, NotificationType.Warning);
/// <summary>오류 알림.</summary>
public static void ShowError(string title, string message)
=> Show(title, message, NotificationType.Error);
/// <summary>에이전트 작업 완료 알림 (BackgroundAgentService 전용 편의 메서드).</summary>
public static void NotifyAgentCompleted(string agentType, string? result)
{
var preview = result?.Length > 80 ? result[..77] + "…" : result ?? "완료";
Show($"{agentType} 에이전트 완료", preview, NotificationType.Success);
}
/// <summary>이력을 초기화합니다.</summary>
public static void ClearHistory()
{
while (_history.TryDequeue(out _)) { }
}
internal static NotificationEntry Record(string title, string message, NotificationType type)
{
var entry = new NotificationEntry(title, message, type, DateTime.Now);
_history.Enqueue(entry);
while (_history.Count > MaxHistory && _history.TryDequeue(out _)) { }
TryRaise(entry);
return entry;
}
private static void TryRaise(NotificationEntry entry)
{
try { NotificationRaised?.Invoke(null, entry); }
catch (Exception) { }
}
}

View File

@@ -14,5 +14,14 @@ internal static class NotificationService
/// <summary>트레이 풍선 알림을 표시합니다.</summary>
public static void Notify(string title, string message)
{
NotifyBalloonOnly(title, message);
NotificationCenterService.Record(title, message, NotificationType.Info);
}
internal static void NotifyBalloonOnly(string title, string message)
=> _showBalloon?.Invoke(title, message);
public static void LogOnly(string title, string message)
=> NotificationCenterService.Record(title, message, NotificationType.Info);
}

View File

@@ -0,0 +1,179 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AxCopilot.Services;
public enum PomodoroMode { Idle, Focus, Break }
/// <summary>
/// Phase L3-9: 뽀모도로 타이머 서비스.
/// 집중(기본 25분) / 휴식(기본 5분) 모드를 관리하며 AppData에 상태를 저장합니다.
/// </summary>
internal sealed class PomodoroService
{
public static readonly PomodoroService Instance = new();
private static readonly string StateFile = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "pomodoro.json");
// ─── 설정 ────────────────────────────────────────────────────────────────
public int FocusMinutes { get; set; } = 25;
public int BreakMinutes { get; set; } = 5;
// ─── 상태 ────────────────────────────────────────────────────────────────
public PomodoroMode Mode { get; private set; } = PomodoroMode.Idle;
public bool IsRunning { get; private set; }
public TimeSpan Remaining { get; private set; }
public event EventHandler? StateChanged;
// ─── 내부 ────────────────────────────────────────────────────────────────
private System.Threading.Timer? _ticker;
private PomodoroService()
{
LoadState();
// 앱 재시작 후 진행 중이던 타이머가 있으면 재개
if (IsRunning && Remaining > TimeSpan.Zero)
StartTicker();
else
IsRunning = false;
}
// ─── 공개 API ────────────────────────────────────────────────────────────
public void StartFocus() => StartMode(PomodoroMode.Focus);
public void StartBreak() => StartMode(PomodoroMode.Break);
public void Stop()
{
StopTicker();
IsRunning = false;
SaveState();
StateChanged?.Invoke(this, EventArgs.Empty);
}
public void Reset()
{
StopTicker();
Mode = PomodoroMode.Idle;
IsRunning = false;
Remaining = TimeSpan.FromMinutes(FocusMinutes);
SaveState();
StateChanged?.Invoke(this, EventArgs.Empty);
}
public void Toggle()
{
if (IsRunning) Stop();
else if (Mode == PomodoroMode.Break) StartBreak();
else StartFocus();
}
// ─── 내부 ────────────────────────────────────────────────────────────────
private void StartMode(PomodoroMode mode)
{
StopTicker();
Mode = mode;
IsRunning = true;
if (Remaining <= TimeSpan.Zero || Mode != mode)
Remaining = TimeSpan.FromMinutes(mode == PomodoroMode.Focus ? FocusMinutes : BreakMinutes);
StartTicker();
SaveState();
StateChanged?.Invoke(this, EventArgs.Empty);
}
private void StartTicker()
{
_ticker = new System.Threading.Timer(_ => Tick(), null, 1000, 1000);
}
private void StopTicker()
{
_ticker?.Dispose();
_ticker = null;
}
private void Tick()
{
if (Remaining <= TimeSpan.Zero)
{
// 타이머 종료
Stop();
var modeLabel = Mode == PomodoroMode.Focus ? "집중" : "휴식";
NotificationCenterService.Show("뽀모도로 타이머", $"{modeLabel} 시간이 종료되었습니다.", NotificationType.Info);
return;
}
Remaining -= TimeSpan.FromSeconds(1);
StateChanged?.Invoke(this, EventArgs.Empty);
}
// ─── 영속성 ──────────────────────────────────────────────────────────────
private void SaveState()
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(StateFile)!);
var dto = new PomodoroStateDto
{
Mode = Mode.ToString(),
IsRunning = IsRunning,
RemainingSeconds = (int)Remaining.TotalSeconds,
FocusMinutes = FocusMinutes,
BreakMinutes = BreakMinutes,
SavedAt = DateTime.Now,
};
File.WriteAllText(StateFile, JsonSerializer.Serialize(dto));
}
catch { /* 무시 */ }
}
private void LoadState()
{
try
{
if (!File.Exists(StateFile)) goto defaults;
var json = File.ReadAllText(StateFile);
var dto = JsonSerializer.Deserialize<PomodoroStateDto>(json);
if (dto == null) goto defaults;
FocusMinutes = dto.FocusMinutes > 0 ? dto.FocusMinutes : 25;
BreakMinutes = dto.BreakMinutes > 0 ? dto.BreakMinutes : 5;
Mode = Enum.TryParse<PomodoroMode>(dto.Mode, out var m) ? m : PomodoroMode.Idle;
IsRunning = dto.IsRunning;
// 앱이 꺼진 동안 경과 시간 보정
var elapsed = DateTime.Now - dto.SavedAt;
var saved = TimeSpan.FromSeconds(dto.RemainingSeconds);
Remaining = saved - elapsed;
if (Remaining < TimeSpan.Zero) { Remaining = TimeSpan.Zero; IsRunning = false; }
return;
}
catch { /* fall through */ }
defaults:
FocusMinutes = 25;
BreakMinutes = 5;
Mode = PomodoroMode.Idle;
IsRunning = false;
Remaining = TimeSpan.FromMinutes(FocusMinutes);
}
private class PomodoroStateDto
{
[JsonPropertyName("mode")] public string Mode { get; set; } = "Idle";
[JsonPropertyName("isRunning")] public bool IsRunning { get; set; }
[JsonPropertyName("remainingSeconds")] public int RemainingSeconds { get; set; }
[JsonPropertyName("focusMinutes")] public int FocusMinutes { get; set; } = 25;
[JsonPropertyName("breakMinutes")] public int BreakMinutes { get; set; } = 5;
[JsonPropertyName("savedAt")] public DateTime SavedAt { get; set; } = DateTime.Now;
}
}

View File

@@ -0,0 +1,214 @@
using System.Diagnostics;
using System.IO;
using System.Windows;
using AxCopilot.Models;
using System.Linq;
namespace AxCopilot.Services;
/// <summary>
/// L5-6: 자동화 스케줄 백그라운드 서비스.
/// 30초 간격으로 활성 스케줄을 검사하여 해당 시각에 액션을 실행합니다.
/// </summary>
public sealed class SchedulerService : IDisposable
{
private readonly SettingsService _settings;
private Timer? _timer;
private bool _disposed;
public SchedulerService(SettingsService settings)
{
_settings = settings;
}
// ─── 시작 / 중지 ─────────────────────────────────────────────────────
public void Start()
{
// 30초 간격 체크 (즉시 1회 실행 후)
_timer = new Timer(OnTick, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30));
LogService.Info("SchedulerService 시작");
}
public void Stop()
{
_timer?.Change(Timeout.Infinite, Timeout.Infinite);
LogService.Info("SchedulerService 중지");
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_timer?.Dispose();
_timer = null;
}
// ─── 트리거 검사 ─────────────────────────────────────────────────────
private void OnTick(object? _)
{
try
{
var now = DateTime.Now;
var schedules = _settings.Settings.Schedules;
bool dirty = false;
foreach (var entry in schedules.ToList()) // ToList: 반복 중 수정 방지
{
if (!ShouldFire(entry, now)) continue;
LogService.Info($"스케줄 실행: '{entry.Name}' ({entry.TriggerType} {entry.TriggerTime})");
ExecuteAction(entry);
entry.LastRun = now;
dirty = true;
// once 트리거는 실행 후 비활성화
if (entry.TriggerType == "once")
entry.Enabled = false;
}
if (dirty) _settings.Save();
}
catch (Exception ex)
{
LogService.Error($"SchedulerService 오류: {ex.Message}");
}
}
// ─── 트리거 조건 검사 ─────────────────────────────────────────────────
private static bool ShouldFire(ScheduleEntry entry, DateTime now)
{
if (!entry.Enabled) return false;
if (!TimeSpan.TryParse(entry.TriggerTime, out var triggerTime)) return false;
// 트리거 시각과 ±1분 이내인지 확인
var targetDt = now.Date + triggerTime;
if (Math.Abs((now - targetDt).TotalMinutes) > 1.0) return false;
// 오늘 이미 실행했는지 확인 (once 제외)
if (entry.TriggerType != "once" &&
entry.LastRun.HasValue &&
entry.LastRun.Value.Date == now.Date)
return false;
bool typeMatch = entry.TriggerType switch
{
"daily" => true,
"weekdays" => now.DayOfWeek >= DayOfWeek.Monday &&
now.DayOfWeek <= DayOfWeek.Friday,
"weekly" => entry.WeekDays.Count > 0 &&
entry.WeekDays.Contains((int)now.DayOfWeek),
"once" => !entry.LastRun.HasValue &&
entry.TriggerDate != null &&
DateTime.TryParse(entry.TriggerDate, out var d) &&
now.Date == d.Date,
_ => false
};
if (!typeMatch) return false;
// ─── L6-4: 프로세스 조건 검사 ─────────────────────────────────────
if (!string.IsNullOrWhiteSpace(entry.ConditionProcess))
{
var procName = entry.ConditionProcess.Trim()
.Replace(".exe", "", StringComparison.OrdinalIgnoreCase);
bool isRunning = Process.GetProcessesByName(procName).Length > 0;
if (entry.ConditionProcessMustRun && !isRunning) return false;
if (!entry.ConditionProcessMustRun && isRunning) return false;
}
return true;
}
// ─── 액션 실행 ────────────────────────────────────────────────────────
private static void ExecuteAction(ScheduleEntry entry)
{
try
{
switch (entry.ActionType)
{
case "app":
if (!string.IsNullOrWhiteSpace(entry.ActionTarget))
Process.Start(new ProcessStartInfo
{
FileName = entry.ActionTarget,
Arguments = entry.ActionArgs ?? "",
UseShellExecute = true
});
break;
case "notification":
var msg = string.IsNullOrWhiteSpace(entry.ActionTarget)
? entry.Name
: entry.ActionTarget;
Application.Current?.Dispatcher.Invoke(() =>
NotificationService.Notify($"[스케줄] {entry.Name}", msg));
break;
}
}
catch (Exception ex)
{
LogService.Warn($"스케줄 액션 실행 실패 '{entry.Name}': {ex.Message}");
}
}
// ─── 유틸리티 (핸들러·편집기에서 공유) ──────────────────────────────
/// <summary>지정 스케줄의 다음 실행 예정 시각을 계산합니다.</summary>
public static DateTime? ComputeNextRun(ScheduleEntry entry)
{
if (!entry.Enabled) return null;
if (!TimeSpan.TryParse(entry.TriggerTime, out var t)) return null;
var now = DateTime.Now;
return entry.TriggerType switch
{
"once" when
!entry.LastRun.HasValue &&
entry.TriggerDate != null &&
DateTime.TryParse(entry.TriggerDate, out var d) &&
(now.Date + t) >= now =>
d.Date + t,
"daily" => NextOccurrence(now, t, _ => true),
"weekdays" => NextOccurrence(now, t, dt =>
dt.DayOfWeek >= DayOfWeek.Monday && dt.DayOfWeek <= DayOfWeek.Friday),
"weekly" when entry.WeekDays.Count > 0 => NextOccurrence(now, t, dt =>
entry.WeekDays.Contains((int)dt.DayOfWeek)),
_ => null
};
}
private static DateTime? NextOccurrence(DateTime now, TimeSpan t, Func<DateTime, bool> dayFilter)
{
for (int i = 0; i <= 7; i++)
{
var candidate = now.Date.AddDays(i) + t;
if (candidate > now && dayFilter(candidate))
return candidate;
}
return null;
}
/// <summary>트리거 유형 표시 이름.</summary>
public static string TriggerLabel(ScheduleEntry e) => e.TriggerType switch
{
"daily" => "매일",
"weekdays" => "주중(월~금)",
"weekly" => WeekDayLabel(e.WeekDays),
"once" => $"한번({e.TriggerDate})",
_ => e.TriggerType
};
private static readonly string[] DayShort = ["일", "월", "화", "수", "목", "금", "토"];
private static string WeekDayLabel(List<int> days) =>
days.Count == 0 ? "매주(요일 미지정)" :
"매주 " + string.Join("·", days.OrderBy(d => d).Select(d => DayShort[d]));
}

View File

@@ -0,0 +1,56 @@
using System.Text.RegularExpressions;
namespace AxCopilot.Services;
/// <summary>
/// Phase L3-4: URL 템플릿 엔진.
/// {0}, {1}, {query}, {param} 등의 플레이스홀더를 인자로 치환합니다.
/// </summary>
public static class UrlTemplateEngine
{
private static readonly Regex NamedPlaceholder = new(@"\{(\w+)\}", RegexOptions.Compiled);
private static readonly Regex IndexedPlaceholder = new(@"\{(\d+)\}", RegexOptions.Compiled);
/// <summary>
/// URL 템플릿의 플레이스홀더를 args 배열로 치환합니다.
/// {0}, {1} — 순서 기반 | {query}, {param} 등 — 첫 번째 인자로 치환
/// </summary>
public static string Expand(string urlTemplate, params string[] args)
{
if (string.IsNullOrEmpty(urlTemplate)) return urlTemplate;
var encoded = args.Select(Uri.EscapeDataString).ToArray();
// 인덱스 기반: {0}, {1}…
var result = IndexedPlaceholder.Replace(urlTemplate, m =>
{
if (int.TryParse(m.Groups[1].Value, out var idx) && idx < encoded.Length)
return encoded[idx];
return m.Value;
});
// 이름 기반: {query}, {param}… → 첫 번째 인자로 치환
result = NamedPlaceholder.Replace(result, m =>
{
return encoded.Length > 0 ? encoded[0] : m.Value;
});
return result;
}
/// <summary>공백으로 구분된 쿼리 문자열을 파싱하여 템플릿에 적용합니다.</summary>
public static string ExpandFromQuery(string urlTemplate, string query)
{
var parts = query.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
return Expand(urlTemplate, parts);
}
/// <summary>템플릿에 포함된 플레이스홀더 목록을 반환합니다.</summary>
public static IReadOnlyList<string> GetPlaceholders(string urlTemplate)
{
var result = new List<string>();
foreach (Match m in NamedPlaceholder.Matches(urlTemplate))
result.Add(m.Groups[1].Value);
return result;
}
}