diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md
index 35736d4..42bb0ff 100644
--- a/docs/LAUNCHER_ROADMAP.md
+++ b/docs/LAUNCHER_ROADMAP.md
@@ -134,7 +134,7 @@
| L5-3 | **QuickLook 인라인 편집** ✅ | F3 미리보기 → Ctrl+E 편집 모드 토글. 텍스트/코드 전체 읽기(300줄 제한 없음). Ctrl+S 저장, ● 수정 마커, Esc 취소 확인, 저장 후 미리보기 새로고침 | 중간 |
| L5-4 | **앱 세션 스냅** ✅ | `session` 프리픽스. `AppSession/SessionApp` 모델 추가 + `AppSettings.AppSessions` 저장. `SessionHandler`: 목록·실행·new/edit/del 서브커맨드. `SessionEditorWindow`: 세션 이름·설명·앱 행(경로+라벨+스냅 팝업 14종+삭제) 인라인 편집. 실행 시 Process.Start → 창 핸들 대기(6초) → ApplySnapToWindow(P/Invoke SetWindowPos+ShowWindow) | 중간 |
| L5-5 | **배치 파일 이름 변경** ✅ | `batchren` 프리픽스로 BatchRenameWindow 오픈. 변수 패턴(`{name}`, `{n:3}`, `{date:format}`, `{ext}`) + 정규식 모드(`/old/new/`). 드래그 앤 드롭·폴더/파일 추가, DataGrid 실시간 미리보기, 충돌 감지(배경 붉은 강조), 확장자 유지 토글, 시작 번호 지정, 적용 후 엔트리 갱신 | 중간 |
-| L5-6 | **자동화 스케줄러** | `sched` 프리픽스로 시간·앱 기반 트리거 등록. "매일 09:00 = 크롬 열기", "캐치 앱 실행 시 = 알림" | 낮음 |
+| L5-6 | **자동화 스케줄러** ✅ | `sched` 프리픽스. `ScheduleEntry` 모델(Id·Name·Enabled·TriggerType·TriggerTime·WeekDays·TriggerDate·ActionType·ActionTarget·ActionArgs·LastRun) + `AppSettings.Schedules` 저장. `SchedulerService`: 30초 간격 타이머, ±1분 트리거 윈도우, `LastRun.Date == today` 중복 방지, once 실행 후 자동 비활성화. `ScheduleHandler`: 목록(다음 실행 시각 표시)·new·edit·del·Enter 토글. `ScheduleEditorWindow`: 트리거 유형 4종(매일/주중/매주/한번)·요일 다중 선택·날짜 입력, 액션 2종(앱 실행/알림). `ComputeNextRun` + `TriggerLabel` 유틸 공유 | 낮음 |
### Phase L5 구현 순서 (권장)
diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs
index 60f5a86..39ad071 100644
--- a/src/AxCopilot/App.xaml.cs
+++ b/src/AxCopilot/App.xaml.cs
@@ -20,6 +20,7 @@ public partial class App : System.Windows.Application
private SettingsService? _settings;
private SettingsWindow? _settingsWindow;
private PluginHost? _pluginHost;
+ private SchedulerService? _schedulerService;
private ClipboardHistoryService? _clipboardHistory;
private DockBarWindow? _dockBar;
private FileDialogWatcher? _fileDialogWatcher;
@@ -186,6 +187,11 @@ public partial class App : System.Windows.Application
commandResolver.RegisterHandler(new SessionHandler(settings));
// Phase L5-5: 배치 파일 이름변경 (prefix=batchren)
commandResolver.RegisterHandler(new BatchRenameHandler());
+ // Phase L5-6: 자동화 스케줄러 (prefix=sched)
+ var schedulerService = new SchedulerService(settings);
+ schedulerService.Start();
+ _schedulerService = schedulerService;
+ commandResolver.RegisterHandler(new ScheduleHandler(settings));
// ─── 플러그인 로드 ────────────────────────────────────────────────────
var pluginHost = new PluginHost(settings, commandResolver);
diff --git a/src/AxCopilot/Handlers/ScheduleHandler.cs b/src/AxCopilot/Handlers/ScheduleHandler.cs
new file mode 100644
index 0000000..8aaf343
--- /dev/null
+++ b/src/AxCopilot/Handlers/ScheduleHandler.cs
@@ -0,0 +1,171 @@
+using System.IO;
+using AxCopilot.Models;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+using AxCopilot.Themes;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L5-6: 자동화 스케줄 핸들러. "sched" 프리픽스로 사용합니다.
+///
+/// 예: sched → 등록된 스케줄 목록
+/// sched 이름 → 이름으로 필터
+/// sched new → 새 스케줄 편집기 열기
+/// sched edit 이름 → 기존 스케줄 편집
+/// sched del 이름 → 스케줄 삭제
+/// sched toggle 이름 → 활성/비활성 전환 (Enter)
+///
+public class ScheduleHandler : IActionHandler
+{
+ private readonly SettingsService _settings;
+
+ public ScheduleHandler(SettingsService settings) { _settings = settings; }
+
+ public string? Prefix => "sched";
+
+ public PluginMetadata Metadata => new(
+ "Scheduler",
+ "자동화 스케줄 — sched",
+ "1.0",
+ "AX");
+
+ // ─── 항목 목록 ──────────────────────────────────────────────────────────
+ public Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim();
+ var parts = q.Split(' ', 2, StringSplitOptions.TrimEntries);
+ var cmd = parts.Length > 0 ? parts[0].ToLowerInvariant() : "";
+
+ // "new" — 새 스케줄
+ if (cmd == "new")
+ {
+ return Task.FromResult>(new[]
+ {
+ new LauncherItem("새 스케줄 만들기",
+ "편집기에서 트리거 시각과 실행 액션을 설정합니다",
+ null, "__new__", Symbol: "\uE710")
+ });
+ }
+
+ // "edit 이름"
+ if (cmd == "edit" && parts.Length > 1)
+ {
+ return Task.FromResult>(new[]
+ {
+ new LauncherItem($"'{parts[1]}' 스케줄 편집", "편집기 열기",
+ null, $"__edit__{parts[1]}", Symbol: "\uE70F")
+ });
+ }
+
+ // "del 이름" or "delete 이름"
+ if ((cmd == "del" || cmd == "delete") && parts.Length > 1)
+ {
+ return Task.FromResult>(new[]
+ {
+ new LauncherItem($"'{parts[1]}' 스케줄 삭제",
+ "Enter로 삭제 확인",
+ null, $"__del__{parts[1]}", Symbol: Symbols.Delete)
+ });
+ }
+
+ // 목록 표시
+ var schedules = _settings.Settings.Schedules;
+ var filter = q.ToLowerInvariant();
+ var items = new List();
+
+ foreach (var s in schedules)
+ {
+ if (!string.IsNullOrEmpty(filter) &&
+ !s.Name.Contains(filter, StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ var nextRun = SchedulerService.ComputeNextRun(s);
+ var nextStr = nextRun.HasValue ? nextRun.Value.ToString("MM/dd HH:mm") : "─";
+ var trigger = SchedulerService.TriggerLabel(s);
+ var symbol = s.Enabled ? "\uE916" : "\uE8D8"; // 타이머 / 멈춤
+
+ var actionIcon = s.ActionType == "notification" ? "🔔" : "▶";
+ var actionName = s.ActionType == "notification"
+ ? s.ActionTarget
+ : Path.GetFileNameWithoutExtension(s.ActionTarget);
+
+ var subtitle = s.Enabled
+ ? $"{trigger} {s.TriggerTime} · {actionIcon} {actionName} · 다음: {nextStr}"
+ : $"[비활성] {trigger} {s.TriggerTime} · {actionIcon} {actionName}";
+
+ items.Add(new LauncherItem(
+ s.Name, subtitle, null, s, Symbol: symbol));
+ }
+
+ if (items.Count == 0 && string.IsNullOrEmpty(filter))
+ {
+ items.Add(new LauncherItem(
+ "등록된 스케줄 없음",
+ "'sched new'로 자동화 스케줄을 추가하세요",
+ null, null, Symbol: Symbols.Info));
+ }
+
+ items.Add(new LauncherItem(
+ "새 스케줄 만들기",
+ "sched new · 시각·요일 기반 앱 실행 / 알림 자동화",
+ null, "__new__", Symbol: "\uE710"));
+
+ return Task.FromResult>(items);
+ }
+
+ // ─── 실행 ─────────────────────────────────────────────────────────────
+ public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
+ {
+ if (item.Data is string s)
+ {
+ if (s == "__new__")
+ {
+ System.Windows.Application.Current.Dispatcher.Invoke(() =>
+ {
+ var win = new Views.ScheduleEditorWindow(null, _settings);
+ win.Show();
+ });
+ return Task.CompletedTask;
+ }
+
+ if (s.StartsWith("__edit__"))
+ {
+ var name = s["__edit__".Length..];
+ var entry = _settings.Settings.Schedules
+ .FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
+ System.Windows.Application.Current.Dispatcher.Invoke(() =>
+ {
+ var win = new Views.ScheduleEditorWindow(entry, _settings);
+ win.Show();
+ });
+ return Task.CompletedTask;
+ }
+
+ if (s.StartsWith("__del__"))
+ {
+ var name = s["__del__".Length..];
+ var entry = _settings.Settings.Schedules
+ .FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
+ if (entry != null)
+ {
+ _settings.Settings.Schedules.Remove(entry);
+ _settings.Save();
+ NotificationService.Notify("AX Copilot", $"스케줄 '{name}' 삭제됨");
+ }
+ return Task.CompletedTask;
+ }
+ }
+
+ // 스케줄 항목 Enter → 활성/비활성 토글
+ if (item.Data is ScheduleEntry se)
+ {
+ se.Enabled = !se.Enabled;
+ _settings.Save();
+ var state = se.Enabled ? "활성화" : "비활성화";
+ NotificationService.Notify("AX Copilot", $"스케줄 '{se.Name}' {state}됨");
+ }
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/AxCopilot/Models/AppSettings.Models.cs b/src/AxCopilot/Models/AppSettings.Models.cs
index 919ea78..7fc3f20 100644
--- a/src/AxCopilot/Models/AppSettings.Models.cs
+++ b/src/AxCopilot/Models/AppSettings.Models.cs
@@ -310,6 +310,61 @@ public class SessionApp
public int DelayMs { get; set; } = 0;
}
+// ─── 자동화 스케줄러 ─────────────────────────────────────────────────────────────
+
+///
+/// 자동화 스케줄 항목.
+/// 지정 시각에 앱 실행 또는 알림을 자동으로 발생시킵니다.
+///
+public class ScheduleEntry
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8];
+
+ [JsonPropertyName("name")]
+ public string Name { get; set; } = "";
+
+ [JsonPropertyName("enabled")]
+ public bool Enabled { get; set; } = true;
+
+ // ─── 트리거 ───────────────────────────────────────────────────────────
+
+ /// 실행 주기. daily | weekdays | weekly | once
+ [JsonPropertyName("triggerType")]
+ public string TriggerType { get; set; } = "daily";
+
+ /// 실행 시각 (HH:mm 형식). 예: "09:00"
+ [JsonPropertyName("triggerTime")]
+ public string TriggerTime { get; set; } = "09:00";
+
+ /// weekly 트리거: 요일 목록. 0=일, 1=월 … 6=토
+ [JsonPropertyName("weekDays")]
+ public List WeekDays { get; set; } = new();
+
+ /// once 트리거: 실행 날짜 (yyyy-MM-dd). 한 번만 실행 후 비활성화.
+ [JsonPropertyName("triggerDate")]
+ public string? TriggerDate { get; set; }
+
+ // ─── 액션 ─────────────────────────────────────────────────────────────
+
+ /// 실행 동작 유형. app | notification
+ [JsonPropertyName("actionType")]
+ public string ActionType { get; set; } = "app";
+
+ /// 앱 경로 또는 알림 메시지 본문
+ [JsonPropertyName("actionTarget")]
+ public string ActionTarget { get; set; } = "";
+
+ /// 앱 실행 시 추가 인자
+ [JsonPropertyName("actionArgs")]
+ public string ActionArgs { get; set; } = "";
+
+ // ─── 상태 ─────────────────────────────────────────────────────────────
+
+ [JsonPropertyName("lastRun")]
+ public DateTime? LastRun { get; set; }
+}
+
// ─── 잠금 해제 알림 설정 ───────────────────────────────────────────────────────
public class ReminderSettings
diff --git a/src/AxCopilot/Models/AppSettings.cs b/src/AxCopilot/Models/AppSettings.cs
index ddc2dca..057b482 100644
--- a/src/AxCopilot/Models/AppSettings.cs
+++ b/src/AxCopilot/Models/AppSettings.cs
@@ -118,6 +118,12 @@ public class AppSettings
[JsonPropertyName("appSessions")]
public List AppSessions { get; set; } = new();
+ ///
+ /// 자동화 스케줄 목록. SchedulerService가 백그라운드에서 트리거를 확인·실행합니다.
+ ///
+ [JsonPropertyName("schedules")]
+ public List Schedules { get; set; } = new();
+
[JsonPropertyName("llm")]
public LlmSettings Llm { get; set; } = new();
}
diff --git a/src/AxCopilot/Services/SchedulerService.cs b/src/AxCopilot/Services/SchedulerService.cs
new file mode 100644
index 0000000..29e57ce
--- /dev/null
+++ b/src/AxCopilot/Services/SchedulerService.cs
@@ -0,0 +1,197 @@
+using System.Diagnostics;
+using System.IO;
+using System.Windows;
+using AxCopilot.Models;
+
+namespace AxCopilot.Services;
+
+///
+/// L5-6: 자동화 스케줄 백그라운드 서비스.
+/// 30초 간격으로 활성 스케줄을 검사하여 해당 시각에 액션을 실행합니다.
+///
+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;
+
+ return 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
+ };
+ }
+
+ // ─── 액션 실행 ────────────────────────────────────────────────────────
+ 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}");
+ }
+ }
+
+ // ─── 유틸리티 (핸들러·편집기에서 공유) ──────────────────────────────
+
+ /// 지정 스케줄의 다음 실행 예정 시각을 계산합니다.
+ 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 dayFilter)
+ {
+ for (int i = 0; i <= 7; i++)
+ {
+ var candidate = now.Date.AddDays(i) + t;
+ if (candidate > now && dayFilter(candidate))
+ return candidate;
+ }
+ return null;
+ }
+
+ /// 트리거 유형 표시 이름.
+ 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 days) =>
+ days.Count == 0 ? "매주(요일 미지정)" :
+ "매주 " + string.Join("·", days.OrderBy(d => d).Select(d => DayShort[d]));
+}
diff --git a/src/AxCopilot/Views/ScheduleEditorWindow.xaml b/src/AxCopilot/Views/ScheduleEditorWindow.xaml
new file mode 100644
index 0000000..175774b
--- /dev/null
+++ b/src/AxCopilot/Views/ScheduleEditorWindow.xaml
@@ -0,0 +1,307 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AxCopilot/Views/ScheduleEditorWindow.xaml.cs b/src/AxCopilot/Views/ScheduleEditorWindow.xaml.cs
new file mode 100644
index 0000000..a0c80af
--- /dev/null
+++ b/src/AxCopilot/Views/ScheduleEditorWindow.xaml.cs
@@ -0,0 +1,306 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Media.Animation;
+using AxCopilot.Models;
+using AxCopilot.Services;
+using Microsoft.Win32;
+
+namespace AxCopilot.Views;
+
+public partial class ScheduleEditorWindow : Window
+{
+ private readonly SettingsService _settings;
+ private readonly ScheduleEntry? _editing; // null = 새 스케줄
+
+ private string _triggerType = "daily";
+ private string _actionType = "app";
+ private bool _enabled = true;
+
+ // 요일 버튼 → Border 참조
+ private Border[] _dayBtns = null!;
+
+ public ScheduleEditorWindow(ScheduleEntry? entry, SettingsService settings)
+ {
+ InitializeComponent();
+ _settings = settings;
+ _editing = entry;
+
+ _dayBtns = new[] { BtnSun, BtnMon, BtnTue, BtnWed, BtnThu, BtnFri, BtnSat };
+
+ Loaded += OnLoaded;
+ }
+
+ // ─── 초기화 ─────────────────────────────────────────────────────────────
+ private void OnLoaded(object sender, RoutedEventArgs e)
+ {
+ // 다크 테마 색상
+ var dimBg = TryFindResource("ItemBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x25, 0x26, 0x37));
+ var accent = TryFindResource("AccentColor") as Brush ?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC));
+ var border = TryFindResource("BorderColor") as Brush ?? new SolidColorBrush(Color.FromRgb(0x2E, 0x2F, 0x4A));
+
+ // 요일 버튼 기본 색
+ foreach (var b in _dayBtns)
+ {
+ b.Background = dimBg;
+ b.BorderBrush = border;
+ b.BorderThickness = new Thickness(1);
+ if (b.Child is TextBlock tb)
+ tb.Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
+ }
+
+ if (_editing != null)
+ LoadFromEntry(_editing);
+ else
+ SetTriggerUi("daily");
+
+ SetActionUi(_actionType);
+ UpdateToggleUi(_enabled);
+ }
+
+ private void LoadFromEntry(ScheduleEntry e)
+ {
+ NameBox.Text = e.Name;
+ TimeBox.Text = e.TriggerTime;
+ _enabled = e.Enabled;
+ _triggerType = e.TriggerType;
+ _actionType = e.ActionType;
+
+ if (e.TriggerDate != null)
+ DateBox.Text = e.TriggerDate;
+
+ SetTriggerUi(e.TriggerType);
+
+ // 요일 복원
+ foreach (var b in _dayBtns)
+ {
+ if (int.TryParse(b.Tag?.ToString(), out var day) && e.WeekDays.Contains(day))
+ SetDaySelected(b, true);
+ }
+
+ if (e.ActionType == "app")
+ {
+ AppPathBox.Text = e.ActionTarget;
+ AppArgsBox.Text = e.ActionArgs ?? "";
+ }
+ else
+ {
+ NotifMsgBox.Text = e.ActionTarget;
+ }
+ }
+
+ // ─── 트리거 유형 ─────────────────────────────────────────────────────────
+ private void TriggerType_Click(object sender, MouseButtonEventArgs e)
+ {
+ if (sender is Border b && b.Tag is string tag)
+ SetTriggerUi(tag);
+ }
+
+ private void SetTriggerUi(string type)
+ {
+ _triggerType = type;
+
+ var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
+ var dimBg = TryFindResource("ItemBackground") as Brush ?? Brushes.DimGray;
+ var secFg = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
+ var white = Brushes.White;
+
+ // 버튼 배경·텍스트 색 초기화
+ void SetBtn(Border btn, TextBlock txt, bool active)
+ {
+ btn.Background = active ? accent : dimBg;
+ txt.Foreground = active ? white : secFg;
+ }
+
+ SetBtn(BtnDaily, TxtDaily, type == "daily");
+ SetBtn(BtnWeekdays, TxtWeekdays, type == "weekdays");
+ SetBtn(BtnWeekly, TxtWeekly, type == "weekly");
+ SetBtn(BtnOnce, TxtOnce, type == "once");
+
+ // 요일 패널 / 날짜 패널 표시
+ WeekDaysPanel.Visibility = type == "weekly" ? Visibility.Visible : Visibility.Collapsed;
+ DatePanel.Visibility = type == "once" ? Visibility.Visible : Visibility.Collapsed;
+
+ // once 기본값
+ if (type == "once" && string.IsNullOrWhiteSpace(DateBox.Text))
+ DateBox.Text = DateTime.Now.AddDays(1).ToString("yyyy-MM-dd");
+ }
+
+ // ─── 요일 선택 ──────────────────────────────────────────────────────────
+ private void WeekDay_Click(object sender, MouseButtonEventArgs e)
+ {
+ if (sender is not Border btn) return;
+ var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
+ var dimBg = TryFindResource("ItemBackground") as Brush ?? Brushes.DimGray;
+ bool current = btn.Background == accent;
+ SetDaySelected(btn, !current);
+ }
+
+ private void SetDaySelected(Border btn, bool selected)
+ {
+ var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
+ var dimBg = TryFindResource("ItemBackground") as Brush ?? Brushes.DimGray;
+ var secFg = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
+
+ btn.Background = selected ? accent : dimBg;
+ if (btn.Child is TextBlock tb)
+ tb.Foreground = selected ? Brushes.White : secFg;
+ }
+
+ private List GetSelectedDays()
+ {
+ var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
+ var list = new List();
+ foreach (var b in _dayBtns)
+ {
+ if (b.Background == accent && int.TryParse(b.Tag?.ToString(), out var day))
+ list.Add(day);
+ }
+ return list;
+ }
+
+ // ─── 액션 유형 ──────────────────────────────────────────────────────────
+ private void ActionType_Click(object sender, MouseButtonEventArgs e)
+ {
+ if (sender is Border b && b.Tag is string tag)
+ SetActionUi(tag);
+ }
+
+ private void SetActionUi(string type)
+ {
+ _actionType = type;
+
+ var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
+ var dimBg = TryFindResource("ItemBackground") as Brush ?? Brushes.DimGray;
+ var secFg = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
+ var white = Brushes.White;
+
+ bool isApp = type == "app";
+
+ BtnActionApp.Background = isApp ? accent : dimBg;
+ BtnActionNotif.Background = !isApp ? accent : dimBg;
+
+ TxtActionApp.Foreground = isApp ? white : secFg;
+ TxtActionNotif.Foreground = !isApp ? white : secFg;
+
+ // 아이콘 TextBlock은 StackPanel의 첫 번째 자식
+ if (BtnActionApp.Child is StackPanel spApp && spApp.Children.Count > 0)
+ ((TextBlock)spApp.Children[0]).Foreground = isApp ? white : secFg;
+ if (BtnActionNotif.Child is StackPanel spNotif && spNotif.Children.Count > 0)
+ ((TextBlock)spNotif.Children[0]).Foreground = !isApp ? white : secFg;
+
+ AppPathPanel.Visibility = isApp ? Visibility.Visible : Visibility.Collapsed;
+ NotifPanel.Visibility = !isApp ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ // ─── 앱 찾아보기 ─────────────────────────────────────────────────────────
+ private void BtnBrowseApp_Click(object sender, MouseButtonEventArgs e)
+ {
+ var dlg = new OpenFileDialog
+ {
+ Title = "실행 파일 선택",
+ Filter = "실행 파일|*.exe;*.bat;*.cmd;*.lnk;*.ps1|모든 파일|*.*"
+ };
+ if (dlg.ShowDialog(this) == true)
+ AppPathBox.Text = dlg.FileName;
+ }
+
+ // ─── 활성화 토글 ─────────────────────────────────────────────────────────
+ private void EnabledToggle_Click(object sender, MouseButtonEventArgs e)
+ {
+ _enabled = !_enabled;
+ UpdateToggleUi(_enabled);
+ }
+
+ private void UpdateToggleUi(bool enabled)
+ {
+ var accent = TryFindResource("AccentColor") as Brush
+ ?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC));
+ var off = new SolidColorBrush(Color.FromRgb(0x3A, 0x3B, 0x5A));
+
+ EnabledToggle.Background = enabled ? accent : off;
+
+ // 썸 위치 애니메이션
+ var da = new DoubleAnimation(
+ enabled ? 1.0 : -1.0, // 실제 HorizontalAlignment·Margin으로 처리
+ TimeSpan.FromMilliseconds(150));
+
+ EnabledThumb.HorizontalAlignment = enabled ? HorizontalAlignment.Right : HorizontalAlignment.Left;
+ EnabledThumb.Margin = enabled ? new Thickness(0, 0, 2, 0) : new Thickness(2, 0, 0, 0);
+ }
+
+ // ─── 저장 ────────────────────────────────────────────────────────────────
+ private void BtnSave_Click(object sender, MouseButtonEventArgs e)
+ {
+ var name = NameBox.Text.Trim();
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ MessageBox.Show("스케줄 이름을 입력하세요.", "저장 오류",
+ MessageBoxButton.OK, MessageBoxImage.Warning);
+ return;
+ }
+
+ var timeStr = TimeBox.Text.Trim();
+ if (!TimeSpan.TryParseExact(timeStr, new[] { @"hh\:mm", @"h\:mm" },
+ System.Globalization.CultureInfo.InvariantCulture, out _))
+ {
+ MessageBox.Show("실행 시각을 HH:mm 형식으로 입력하세요. (예: 09:00)",
+ "저장 오류", MessageBoxButton.OK, MessageBoxImage.Warning);
+ return;
+ }
+
+ if (_triggerType == "once")
+ {
+ var dateStr = DateBox.Text.Trim();
+ if (!DateTime.TryParse(dateStr, out _))
+ {
+ MessageBox.Show("실행 날짜를 yyyy-MM-dd 형식으로 입력하세요.",
+ "저장 오류", MessageBoxButton.OK, MessageBoxImage.Warning);
+ return;
+ }
+ }
+
+ if (_actionType == "app" && string.IsNullOrWhiteSpace(AppPathBox.Text))
+ {
+ MessageBox.Show("실행할 앱 경로를 입력하세요.", "저장 오류",
+ MessageBoxButton.OK, MessageBoxImage.Warning);
+ return;
+ }
+
+ // 기존 항목 편집 or 신규 생성
+ var entry = _editing ?? new ScheduleEntry();
+
+ entry.Name = name;
+ entry.Enabled = _enabled;
+ entry.TriggerType = _triggerType;
+ entry.TriggerTime = timeStr;
+ entry.WeekDays = _triggerType == "weekly" ? GetSelectedDays() : new List();
+ entry.TriggerDate = _triggerType == "once" ? DateBox.Text.Trim() : null;
+ entry.ActionType = _actionType;
+ entry.ActionTarget = _actionType == "app"
+ ? AppPathBox.Text.Trim()
+ : NotifMsgBox.Text.Trim();
+ entry.ActionArgs = _actionType == "app" ? AppArgsBox.Text.Trim() : "";
+
+ var schedules = _settings.Settings.Schedules;
+
+ if (_editing == null)
+ schedules.Add(entry);
+ // 편집 모드: 이미 리스트 내 참조이므로 별도 추가 불필요
+
+ _settings.Save();
+ NotificationService.Notify("AX Copilot", $"스케줄 '{entry.Name}' 저장됨");
+ Close();
+ }
+
+ // ─── 윈도우 컨트롤 ──────────────────────────────────────────────────────
+ 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 BtnCancel_Click(object sender, MouseButtonEventArgs e) => Close();
+}