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;
}
}