using System.Globalization; using System.Windows; using AxCopilot.SDK; using AxCopilot.Services; namespace AxCopilot.Handlers; /// /// L23-1: 한국 공휴일·업무일 달력. "cal" 프리픽스로 사용합니다. /// /// 예: cal → 이번달 달력·공휴일 /// cal next → 다음 공휴일 D-day (5개) /// cal workdays → 이번달 업무일 수·잔여 업무일 /// cal today → 오늘 공휴일 여부 /// cal 2026-05 → 특정 월 조회 /// cal 2026-04-10 → 특정 날짜 조회 /// Enter → 복사 /// public class CalHandler : IActionHandler { public string? Prefix => "cal"; public PluginMetadata Metadata => new( "한국 달력", "공휴일·업무일 달력 — 이번달 · 다음 공휴일 · 업무일 계산", "1.0", "AX"); // ── 공휴일 데이터 ───────────────────────────────────────────────────────── private static readonly Dictionary Holidays = new() { // 2024 { new DateOnly(2024, 1, 1), "신정" }, { new DateOnly(2024, 2, 9), "설날연휴" }, { new DateOnly(2024, 2, 10), "설날" }, { new DateOnly(2024, 2, 11), "설날연휴" }, { new DateOnly(2024, 2, 12), "대체공휴일" }, { new DateOnly(2024, 3, 1), "삼일절" }, { new DateOnly(2024, 4, 10), "국회의원선거" }, { new DateOnly(2024, 5, 5), "어린이날" }, { new DateOnly(2024, 5, 6), "대체공휴일" }, { new DateOnly(2024, 5, 15), "부처님오신날" }, { new DateOnly(2024, 6, 6), "현충일" }, { new DateOnly(2024, 8, 15), "광복절" }, { new DateOnly(2024, 9, 16), "추석연휴" }, { new DateOnly(2024, 9, 17), "추석" }, { new DateOnly(2024, 9, 18), "추석연휴" }, { new DateOnly(2024, 10, 3), "개천절" }, { new DateOnly(2024, 10, 9), "한글날" }, { new DateOnly(2024, 12, 25), "크리스마스" }, // 2025 { new DateOnly(2025, 1, 1), "신정" }, { new DateOnly(2025, 1, 28), "설날연휴" }, { new DateOnly(2025, 1, 29), "설날" }, { new DateOnly(2025, 1, 30), "설날연휴" }, { new DateOnly(2025, 3, 1), "삼일절" }, { new DateOnly(2025, 3, 3), "대체공휴일" }, { new DateOnly(2025, 5, 5), "어린이날" }, { new DateOnly(2025, 5, 6), "부처님오신날" }, { new DateOnly(2025, 6, 6), "현충일" }, { new DateOnly(2025, 8, 15), "광복절" }, { new DateOnly(2025, 10, 3), "개천절" }, { new DateOnly(2025, 10, 5), "추석연휴" }, { new DateOnly(2025, 10, 6), "추석" }, { new DateOnly(2025, 10, 7), "추석연휴" }, { new DateOnly(2025, 10, 8), "대체공휴일" }, { new DateOnly(2025, 10, 9), "한글날" }, { new DateOnly(2025, 12, 25), "크리스마스" }, // 2026 { new DateOnly(2026, 1, 1), "신정" }, { new DateOnly(2026, 2, 17), "설날연휴" }, { new DateOnly(2026, 2, 18), "설날" }, { new DateOnly(2026, 2, 19), "설날연휴" }, { new DateOnly(2026, 3, 1), "삼일절" }, { new DateOnly(2026, 3, 2), "대체공휴일" }, { new DateOnly(2026, 5, 5), "어린이날" }, { new DateOnly(2026, 5, 24), "부처님오신날" }, { new DateOnly(2026, 5, 25), "대체공휴일" }, { new DateOnly(2026, 6, 6), "현충일" }, { new DateOnly(2026, 6, 8), "대체공휴일" }, { new DateOnly(2026, 8, 15), "광복절" }, { new DateOnly(2026, 8, 17), "대체공휴일" }, { new DateOnly(2026, 9, 24), "추석연휴" }, { new DateOnly(2026, 9, 25), "추석" }, { new DateOnly(2026, 9, 26), "추석연휴" }, { new DateOnly(2026, 10, 3), "개천절" }, { new DateOnly(2026, 10, 5), "대체공휴일" }, { new DateOnly(2026, 10, 9), "한글날" }, { new DateOnly(2026, 12, 25), "크리스마스" }, // 2027 { new DateOnly(2027, 1, 1), "신정" }, { new DateOnly(2027, 2, 7), "설날연휴" }, { new DateOnly(2027, 2, 8), "설날" }, { new DateOnly(2027, 2, 9), "설날연휴" }, { new DateOnly(2027, 3, 1), "삼일절" }, { new DateOnly(2027, 5, 5), "어린이날" }, { new DateOnly(2027, 5, 13), "부처님오신날" }, { new DateOnly(2027, 6, 6), "현충일" }, { new DateOnly(2027, 6, 7), "대체공휴일" }, { new DateOnly(2027, 8, 15), "광복절" }, { new DateOnly(2027, 8, 16), "대체공휴일" }, { new DateOnly(2027, 9, 13), "추석연휴" }, { new DateOnly(2027, 9, 14), "추석" }, { new DateOnly(2027, 9, 15), "추석연휴" }, { new DateOnly(2027, 10, 3), "개천절" }, { new DateOnly(2027, 10, 4), "대체공휴일" }, { new DateOnly(2027, 10, 9), "한글날" }, { new DateOnly(2027, 12, 25), "크리스마스" }, { new DateOnly(2027, 12, 27), "대체공휴일" }, }; private static readonly string[] DayNames = ["일", "월", "화", "수", "목", "금", "토"]; // ── 헬퍼 ───────────────────────────────────────────────────────────────── private static bool IsHoliday(DateOnly d) => Holidays.ContainsKey(d) || d.DayOfWeek == DayOfWeek.Saturday || d.DayOfWeek == DayOfWeek.Sunday; private static bool IsWorkday(DateOnly d) => !IsHoliday(d); private static int CountWorkdays(int year, int month) { var days = DateTime.DaysInMonth(year, month); var count = 0; for (var i = 1; i <= days; i++) if (IsWorkday(new DateOnly(year, month, i))) count++; return count; } private static int CountWorkdaysFrom(DateOnly from, DateOnly to) { var count = 0; var cur = from; while (cur <= to) { if (IsWorkday(cur)) count++; cur = cur.AddDays(1); } return count; } private static string DayOfWeekKor(DayOfWeek dow) => DayNames[(int)dow]; // ── GetItemsAsync ───────────────────────────────────────────────────────── public Task> GetItemsAsync(string query, CancellationToken ct) { var q = query.Trim(); var today = DateOnly.FromDateTime(DateTime.Today); var items = new List(); if (string.IsNullOrWhiteSpace(q)) { BuildMonthItems(today.Year, today.Month, today, items); return Task.FromResult>(items); } var kw = q.ToLowerInvariant(); // next → 다음 공휴일 5개 if (kw == "next") { items.Add(new LauncherItem("다음 공휴일 5개", "D-N일 표시 · Enter: 클립보드 복사", null, null, Symbol: "\uE787")); var upcoming = Holidays.Keys .Where(d => d > today) .OrderBy(d => d) .Take(5); foreach (var d in upcoming) { var diff = d.DayNumber - today.DayNumber; var name = Holidays[d]; var label = $"{d:yyyy-MM-dd} ({DayOfWeekKor(d.DayOfWeek)}) — {name} D-{diff}일"; items.Add(new LauncherItem(label, "Enter: 클립보드 복사", null, ("copy", $"{d:yyyy-MM-dd} {name}"), Symbol: "\uE787")); } return Task.FromResult>(items); } // workdays → 이번달 업무일 if (kw == "workdays") { var total = CountWorkdays(today.Year, today.Month); var remaining = CountWorkdaysFrom(today, new DateOnly(today.Year, today.Month, DateTime.DaysInMonth(today.Year, today.Month))); items.Add(new LauncherItem( $"{today.Year}년 {today.Month}월 업무일 — 총 {total}일", $"오늘 기준 잔여 업무일: {remaining}일", null, ("copy", $"{today.Year}년 {today.Month}월 업무일: 총 {total}일, 잔여 {remaining}일"), Symbol: "\uE787")); var monthHolidays = Holidays.Where(kv => kv.Key.Year == today.Year && kv.Key.Month == today.Month) .OrderBy(kv => kv.Key); foreach (var kv in monthHolidays) { var label = $"🎌 {kv.Key.Day}일 ({DayOfWeekKor(kv.Key.DayOfWeek)}) — {kv.Value}"; items.Add(new LauncherItem(label, "Enter: 클립보드 복사", null, ("copy", $"{kv.Key:yyyy-MM-dd} {kv.Value}"), Symbol: "\uE787")); } if (!monthHolidays.Any()) items.Add(new LauncherItem("이번 달 공휴일 없음", "", null, null, Symbol: "\uE787")); return Task.FromResult>(items); } // today → 오늘 정보 if (kw == "today") { var isHol = Holidays.TryGetValue(today, out var holName); var isWknd = today.DayOfWeek == DayOfWeek.Saturday || today.DayOfWeek == DayOfWeek.Sunday; string status; if (isHol) status = $"공휴일 ({holName})"; else if (isWknd) status = "주말"; else status = "평일 (업무일)"; items.Add(new LauncherItem( $"{today:yyyy-MM-dd} ({DayOfWeekKor(today.DayOfWeek)}) — {status}", "Enter: 클립보드 복사", null, ("copy", $"{today:yyyy-MM-dd} ({DayOfWeekKor(today.DayOfWeek)}) {status}"), Symbol: "\uE787")); var next = Holidays.Keys.Where(d => d > today).OrderBy(d => d).FirstOrDefault(); if (next != default) { var diff = next.DayNumber - today.DayNumber; items.Add(new LauncherItem( $"다음 공휴일: {next:yyyy-MM-dd} ({DayOfWeekKor(next.DayOfWeek)}) — {Holidays[next]} D-{diff}일", "", null, ("copy", $"{next:yyyy-MM-dd} {Holidays[next]}"), Symbol: "\uE787")); } return Task.FromResult>(items); } // yyyy-MM-dd 날짜 직접 조회 var dateFormats = new[] { "yyyy-MM-dd", "yyyy/MM/dd", "yyyyMMdd" }; if (DateOnly.TryParseExact(q, dateFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var specificDate)) { var isHol = Holidays.TryGetValue(specificDate, out var hName); var isWknd = specificDate.DayOfWeek == DayOfWeek.Saturday || specificDate.DayOfWeek == DayOfWeek.Sunday; string status; if (isHol) status = $"공휴일 ({hName})"; else if (isWknd) status = "주말"; else status = "평일 (업무일)"; items.Add(new LauncherItem( $"{specificDate:yyyy-MM-dd} ({DayOfWeekKor(specificDate.DayOfWeek)}) — {status}", "Enter: 클립보드 복사", null, ("copy", $"{specificDate:yyyy-MM-dd} ({DayOfWeekKor(specificDate.DayOfWeek)}) {status}"), Symbol: "\uE787")); return Task.FromResult>(items); } // yyyy-MM or yyyy/MM or yyyyMM 월 조회 var monthFormats = new[] { "yyyy-MM", "yyyy/MM", "yyyyMM" }; if (DateOnly.TryParseExact(q + "-01", new[] { "yyyy-MM-dd", "yyyy/MM/dd", "yyyyMM-dd" }, CultureInfo.InvariantCulture, DateTimeStyles.None, out var monthDate) || TryParseYearMonth(q, out monthDate)) { BuildMonthItems(monthDate.Year, monthDate.Month, today, items); return Task.FromResult>(items); } // 기본 — 이번달 BuildMonthItems(today.Year, today.Month, today, items); return Task.FromResult>(items); } private static bool TryParseYearMonth(string q, out DateOnly result) { result = default; // yyyy-MM, yyyy/MM, yyyyMM var formats = new[] { "yyyy-MM", "yyyy/MM" }; foreach (var fmt in formats) { if (DateTime.TryParseExact(q, fmt, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt)) { result = new DateOnly(dt.Year, dt.Month, 1); return true; } } // yyyyMM (6자리) if (q.Length == 6 && int.TryParse(q[..4], out var y) && int.TryParse(q[4..], out var m) && m >= 1 && m <= 12) { result = new DateOnly(y, m, 1); return true; } return false; } private static void BuildMonthItems(int year, int month, DateOnly today, List items) { var total = CountWorkdays(year, month); var lastDay = new DateOnly(year, month, DateTime.DaysInMonth(year, month)); int remaining; if (today.Year == year && today.Month == month) remaining = CountWorkdaysFrom(today, lastDay); else if (today < new DateOnly(year, month, 1)) remaining = total; else remaining = 0; var header = $"{year}년 {month}월 · 업무일 {total}일"; if (today.Year == year && today.Month == month) header += $" (잔여 {remaining}일)"; items.Add(new LauncherItem(header, "공휴일 목록 · Enter: 클립보드 복사", null, ("copy", header), Symbol: "\uE787")); var monthHolidays = Holidays .Where(kv => kv.Key.Year == year && kv.Key.Month == month) .OrderBy(kv => kv.Key) .ToList(); if (monthHolidays.Count == 0) { items.Add(new LauncherItem("공휴일 없음", "", null, null, Symbol: "\uE787")); } else { foreach (var kv in monthHolidays) { var label = $"🎌 {kv.Key.Day}일 ({DayOfWeekKor(kv.Key.DayOfWeek)}) — {kv.Value}"; items.Add(new LauncherItem(label, "Enter: 클립보드 복사", null, ("copy", $"{kv.Key:yyyy-MM-dd} {kv.Value}"), Symbol: "\uE787")); } } } // ── ExecuteAsync ────────────────────────────────────────────────────────── public Task ExecuteAsync(LauncherItem item, CancellationToken ct) { if (item.Data is ("copy", string text)) { try { Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text)); NotificationService.Notify("달력", "클립보드에 복사했습니다."); } catch { } } return Task.CompletedTask; } }