diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index 879c4ca..e68db80 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -350,6 +350,16 @@ public partial class App : System.Windows.Application // L22-4: 업무 양식·문서 구조 템플릿 (prefix=form) commandResolver.RegisterHandler(new FormHandler()); + // ─── L23: 한국 사무 환경 특화 ───────────────────────────────────── + // L23-1: 한국 공휴일·업무일 달력 (prefix=cal) + commandResolver.RegisterHandler(new CalHandler()); + // L23-2: 연차·휴가 관리 (prefix=leave) + commandResolver.RegisterHandler(new LeaveHandler()); + // L23-3: 근무 시간·급여 계산 (prefix=work) + commandResolver.RegisterHandler(new WorkTimeHandler()); + // L23-4: 한/영 타이핑 오류 교정 (prefix=fix) + commandResolver.RegisterHandler(new FixHandler()); + // ─── 플러그인 로드 ──────────────────────────────────────────────────── var pluginHost = new PluginHost(settings, commandResolver); pluginHost.LoadAll(); diff --git a/src/AxCopilot/Handlers/CalHandler.cs b/src/AxCopilot/Handlers/CalHandler.cs new file mode 100644 index 0000000..1d7d938 --- /dev/null +++ b/src/AxCopilot/Handlers/CalHandler.cs @@ -0,0 +1,349 @@ +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; + } +} diff --git a/src/AxCopilot/Handlers/FixHandler.cs b/src/AxCopilot/Handlers/FixHandler.cs new file mode 100644 index 0000000..f202fc9 --- /dev/null +++ b/src/AxCopilot/Handlers/FixHandler.cs @@ -0,0 +1,539 @@ +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; + +namespace AxCopilot.Handlers; + +/// +/// L23-4: 한/영 타이핑 오류 교정. "fix" 프리픽스로 사용합니다. +/// +/// 예: fix gksrmf → 안녕 (영타→한글 변환) +/// fix → 클립보드 텍스트 자동 교정 +/// Enter → 교정 결과 클립보드 복사 +/// +public class FixHandler : IActionHandler +{ + public string? Prefix => "fix"; + + public PluginMetadata Metadata => new( + "타이핑 교정", + "영타→한글 변환 — 잘못 입력된 영문 타이핑을 한글로 교정", + "1.0", + "AX"); + + // ── 두벌식 영→자모 매핑 ────────────────────────────────────────────────── + + // 소문자 + private static readonly Dictionary EngToJamo = new() + { + {'q', 'ㅂ'}, {'w', 'ㅈ'}, {'e', 'ㄷ'}, {'r', 'ㄱ'}, {'t', 'ㅅ'}, + {'y', 'ㅛ'}, {'u', 'ㅕ'}, {'i', 'ㅑ'}, {'o', 'ㅐ'}, {'p', 'ㅔ'}, + {'a', 'ㅁ'}, {'s', 'ㄴ'}, {'d', 'ㅇ'}, {'f', 'ㄹ'}, {'g', 'ㅎ'}, + {'h', 'ㅗ'}, {'j', 'ㅓ'}, {'k', 'ㅏ'}, {'l', 'ㅣ'}, + {'z', 'ㅋ'}, {'x', 'ㅌ'}, {'c', 'ㅊ'}, {'v', 'ㅍ'}, + {'b', 'ㅠ'}, {'n', 'ㅜ'}, {'m', 'ㅡ'}, + // 대문자 = 소문자와 동일 기본 (Shift 된소리/쌍모음 별도) + {'Q', 'ㅃ'}, {'W', 'ㅉ'}, {'E', 'ㄸ'}, {'R', 'ㄲ'}, {'T', 'ㅆ'}, + {'Y', 'ㅛ'}, {'U', 'ㅕ'}, {'I', 'ㅑ'}, {'O', 'ㅒ'}, {'P', 'ㅖ'}, + {'A', 'ㅁ'}, {'S', 'ㄴ'}, {'D', 'ㅇ'}, {'F', 'ㄹ'}, {'G', 'ㅎ'}, + {'H', 'ㅗ'}, {'J', 'ㅓ'}, {'K', 'ㅏ'}, {'L', 'ㅣ'}, + {'Z', 'ㅋ'}, {'X', 'ㅌ'}, {'C', 'ㅊ'}, {'V', 'ㅍ'}, + {'B', 'ㅠ'}, {'N', 'ㅜ'}, {'M', 'ㅡ'}, + }; + + // ── 초성 배열 (19개) ────────────────────────────────────────────────────── + private static readonly char[] Choseong = + ['ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ','ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ']; + + // ── 중성 배열 (21개) ────────────────────────────────────────────────────── + private static readonly char[] Jungseong = + ['ㅏ','ㅐ','ㅑ','ㅒ','ㅓ','ㅔ','ㅕ','ㅖ','ㅗ','ㅘ','ㅙ','ㅚ','ㅛ','ㅜ','ㅝ','ㅞ','ㅟ','ㅠ','ㅡ','ㅢ','ㅣ']; + + // ── 종성 배열 (28개, 0=없음) ───────────────────────────────────────────── + private static readonly char[] Jongseong = + ['\0','ㄱ','ㄲ','ㄳ','ㄴ','ㄵ','ㄶ','ㄷ','ㄹ','ㄺ','ㄻ','ㄼ','ㄽ','ㄾ','ㄿ','ㅀ','ㅁ','ㅂ','ㅄ','ㅅ','ㅆ','ㅇ','ㅈ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ']; + + // ── 복합 모음 ───────────────────────────────────────────────────────────── + private static readonly Dictionary<(char, char), char> CompoundVowel = new() + { + {('ㅗ','ㅏ'), 'ㅘ'}, {('ㅗ','ㅐ'), 'ㅙ'}, {('ㅗ','ㅣ'), 'ㅚ'}, + {('ㅜ','ㅓ'), 'ㅝ'}, {('ㅜ','ㅔ'), 'ㅞ'}, {('ㅜ','ㅣ'), 'ㅟ'}, + {('ㅡ','ㅣ'), 'ㅢ'}, + }; + + // ── 복합 종성 ───────────────────────────────────────────────────────────── + private static readonly Dictionary<(char, char), char> CompoundJong = new() + { + {('ㄱ','ㅅ'), 'ㄳ'}, {('ㄴ','ㅈ'), 'ㄵ'}, {('ㄴ','ㅎ'), 'ㄶ'}, + {('ㄹ','ㄱ'), 'ㄺ'}, {('ㄹ','ㅁ'), 'ㄻ'}, {('ㄹ','ㅂ'), 'ㄼ'}, + {('ㄹ','ㅅ'), 'ㄽ'}, {('ㄹ','ㅌ'), 'ㄾ'}, {('ㄹ','ㅍ'), 'ㄿ'}, + {('ㄹ','ㅎ'), 'ㅀ'}, {('ㅂ','ㅅ'), 'ㅄ'}, + }; + + // 복합 종성 분리 (모음이 올 때 jong → cho + remain) + private static readonly Dictionary SplitJong = new() + { + {'ㄳ', ('ㄱ','ㅅ')}, {'ㄵ', ('ㄴ','ㅈ')}, {'ㄶ', ('ㄴ','ㅎ')}, + {'ㄺ', ('ㄹ','ㄱ')}, {'ㄻ', ('ㄹ','ㅁ')}, {'ㄼ', ('ㄹ','ㅂ')}, + {'ㄽ', ('ㄹ','ㅅ')}, {'ㄾ', ('ㄹ','ㅌ')}, {'ㄿ', ('ㄹ','ㅍ')}, + {'ㅀ', ('ㄹ','ㅎ')}, {'ㅄ', ('ㅂ','ㅅ')}, + }; + + // ── 인덱스 헬퍼 ────────────────────────────────────────────────────────── + + private static int ChoIdx(char c) => Array.IndexOf(Choseong, c); + private static int JungIdx(char c) => Array.IndexOf(Jungseong, c); + private static int JongIdx(char c) => Array.IndexOf(Jongseong, c); + + private static bool IsVowel(char jamo) => JungIdx(jamo) >= 0; + private static bool IsConsonant(char jamo) => ChoIdx(jamo) >= 0 || JongIdx(jamo) > 0; + + private static char MakeSyllable(int cho, int jung, int jong) => + (char)(0xAC00 + cho * 21 * 28 + jung * 28 + jong); + + // ── 한글 조합기 (두벌식 상태 기계) ─────────────────────────────────────── + + private sealed class HangulComposer + { + private int _cho = -1; + private int _jung = -1; + private int _jong = -1; + private readonly StringBuilder _sb = new(); + + public string Result => _sb.ToString(); + + public void Flush() + { + if (_cho < 0) return; + if (_jung < 0) + { + // 초성만 + _sb.Append(Choseong[_cho]); + } + else + { + _sb.Append(MakeSyllable(_cho, _jung, _jong < 0 ? 0 : _jong)); + } + _cho = _jung = _jong = -1; + } + + public void Feed(char jamo) + { + if (IsVowel(jamo)) + { + FeedVowel(jamo); + } + else + { + FeedConsonant(jamo); + } + } + + private void FeedVowel(char v) + { + var vi = JungIdx(v); + + if (_cho < 0) + { + // 초성 없음 → ㅇ + 모음 + _sb.Append(MakeSyllable(ChoIdx('ㅇ'), vi, 0)); + return; + } + + if (_jung < 0) + { + // 초성만 있음 → 중성 결합 + _jung = vi; + return; + } + + // 초성+중성 있음 + if (_jong < 0) + { + // 복합 모음 시도 + var curVowel = Jungseong[_jung]; + if (CompoundVowel.TryGetValue((curVowel, v), out var compound)) + { + _jung = JungIdx(compound); + } + else + { + // 현재 음절 확정, 새 음절 시작 (ㅇ + 모음) + Flush(); + _cho = ChoIdx('ㅇ'); + _jung = vi; + } + return; + } + + // 초성+중성+종성 있음 → 종성을 새 음절의 초성으로 + var jongChar = Jongseong[_jong]; + + if (SplitJong.TryGetValue(jongChar, out var split)) + { + // 복합 종성: 앞 자음은 종성, 뒷 자음은 새 초성 + var newCho = ChoIdx(split.Second); + if (newCho < 0) newCho = 0; + var remainJong = JongIdx(split.First); + // 현재 음절 (jong=split.First) + _sb.Append(MakeSyllable(_cho, _jung, remainJong)); + _cho = newCho; + _jung = vi; + _jong = -1; + } + else + { + // 단일 종성 → 새 초성으로 + var newCho = ChoIdx(jongChar); + if (newCho < 0) newCho = 0; + _sb.Append(MakeSyllable(_cho, _jung, 0)); + _cho = newCho; + _jung = vi; + _jong = -1; + } + } + + private void FeedConsonant(char c) + { + var ci = ChoIdx(c); + + if (_cho < 0) + { + // 처음 자음 + _cho = ci >= 0 ? ci : 0; + return; + } + + if (_jung < 0) + { + // 초성만 있음 → 이전 초성 출력, 새 초성 + _sb.Append(Choseong[_cho]); + _cho = ci >= 0 ? ci : 0; + _jung = -1; + _jong = -1; + return; + } + + if (_jong < 0) + { + // 초성+중성 → 종성 후보 + _jong = JongIdx(c); + if (_jong < 0) _jong = 0; // 종성에 없는 자음은 그냥 처리 + if (_jong == 0) + { + // 종성에 들어갈 수 없는 자음(ㄸ, ㅃ, ㅉ) + Flush(); + _cho = ci >= 0 ? ci : 0; + _jung = -1; + _jong = -1; + } + return; + } + + // 초성+중성+종성 있음 + var curJongChar = Jongseong[_jong]; + if (CompoundJong.TryGetValue((curJongChar, c), out var compJ)) + { + // 복합 종성 가능 + var cji = JongIdx(compJ); + if (cji > 0) + { + _jong = cji; + return; + } + } + + // 복합 종성 불가 → 현재 음절 확정, 새 초성 + Flush(); + _cho = ci >= 0 ? ci : 0; + _jung = -1; + _jong = -1; + } + } + + // ── 영타→한글 변환 ──────────────────────────────────────────────────────── + + private static string EngToKorean(string input) + { + var composer = new HangulComposer(); + var sb = new StringBuilder(); + + foreach (var ch in input) + { + if (EngToJamo.TryGetValue(ch, out var jamo)) + { + if (composer.Result.Length > 0 || jamo != '\0') + { + // 현재 변환 중인 조합기에 피드 + } + composer.Feed(jamo); + } + else + { + // 변환 불가 문자 → 조합기 플러시 후 그대로 + var cur = composer.Result; + // 지금까지 쌓인 결과 덤프 + composer.Feed('\0'); // 플러시 트리거 안 됨 → 직접 Flush + // 아래 로직: 조합기는 Flush()로만 비워짐 + sb.Append(ch); + } + } + + // 위 로직을 단순화: 문자별 처리 + return ConvertEngToKor(input); + } + + private static string ConvertEngToKor(string input) + { + // 먼저 자모 문자열로 변환 + var jamoSeq = new List(); + foreach (var ch in input) + { + if (EngToJamo.TryGetValue(ch, out var jamo)) + jamoSeq.Add(jamo); + else + jamoSeq.Add(ch); // 변환 불가 문자는 그대로 + } + + // 자모 시퀀스를 한글 음절로 조합 + var result = new StringBuilder(); + var i = 0; + while (i < jamoSeq.Count) + { + var ch = jamoSeq[i]; + + // 변환 불가 문자 (공백, 숫자, 특수문자 등) + if (!IsKorJamo(ch)) + { + result.Append(ch); + i++; + continue; + } + + // 자모 덩어리 추출 + var jamoBlock = new List(); + var j = i; + while (j < jamoSeq.Count && IsKorJamo(jamoSeq[j])) + jamoBlock.Add(jamoSeq[j++]); + + // 자모 블록을 한글로 조합 + result.Append(ComposeHangul(jamoBlock)); + i = j; + } + + return result.ToString(); + } + + private static bool IsKorJamo(char c) + { + return (c >= 'ㄱ' && c <= 'ㅎ') || (c >= 'ㅏ' && c <= 'ㅣ'); + } + + private static string ComposeHangul(List jamos) + { + var sb = new StringBuilder(); + var idx = 0; + + while (idx < jamos.Count) + { + var c = jamos[idx]; + + if (IsVowel(c)) + { + // 단독 모음 → ㅇ + 모음 + sb.Append(MakeSyllable(ChoIdx('ㅇ'), JungIdx(c), 0)); + idx++; + continue; + } + + // 자음: 초성 후보 + var cho = c; + var choI = ChoIdx(cho); + if (choI < 0) { sb.Append(c); idx++; continue; } + idx++; + + if (idx >= jamos.Count || IsConsonantOnly(jamos[idx])) + { + // 단독 초성 + sb.Append(cho); + continue; + } + + // 중성 + var v1 = jamos[idx]; + var jungI = JungIdx(v1); + if (jungI < 0) { sb.Append(cho); continue; } + idx++; + + // 복합 모음 시도 + if (idx < jamos.Count && IsVowel(jamos[idx])) + { + if (CompoundVowel.TryGetValue((v1, jamos[idx]), out var cv)) + { + jungI = JungIdx(cv); + idx++; + } + } + + // 종성 후보 + if (idx >= jamos.Count) + { + sb.Append(MakeSyllable(choI, jungI, 0)); + continue; + } + + var next = jamos[idx]; + if (IsVowel(next)) + { + sb.Append(MakeSyllable(choI, jungI, 0)); + continue; + } + + // 종성 자음 + var jongI = JongIdx(next); + if (jongI <= 0) + { + sb.Append(MakeSyllable(choI, jungI, 0)); + continue; + } + + idx++; + + // 다음 모음 있으면 종성→초성 이동 + if (idx < jamos.Count && IsVowel(jamos[idx])) + { + // 복합 종성 분리 확인 + if (idx + 1 < jamos.Count && IsVowel(jamos[idx])) + { + // 분리 없음: next 자음 → 다음 음절 초성 + } + sb.Append(MakeSyllable(choI, jungI, 0)); + idx--; // next 자음을 다음 루프에서 초성으로 사용 + continue; + } + + // 복합 종성 시도 + if (idx < jamos.Count && !IsVowel(jamos[idx])) + { + var next2 = jamos[idx]; + var jongChar = Jongseong[jongI]; + if (CompoundJong.TryGetValue((jongChar, next2), out var cj)) + { + var cji = JongIdx(cj); + if (cji > 0) + { + // 다음에 모음이 있으면 복합 종성 분리 + if (idx + 1 < jamos.Count && IsVowel(jamos[idx + 1])) + { + sb.Append(MakeSyllable(choI, jungI, jongI)); + // next2는 다음 음절 초성으로 + idx--; // next2를 다시 처리 + idx++; + continue; + } + jongI = cji; + idx++; + } + } + + // 다음에 모음이 있으면 종성→초성 + if (idx < jamos.Count && IsVowel(jamos[idx])) + { + sb.Append(MakeSyllable(choI, jungI, 0)); + idx -= 2; + idx++; + continue; + } + } + + sb.Append(MakeSyllable(choI, jungI, jongI)); + } + + return sb.ToString(); + } + + // 초성으로만 사용 가능한 자음인지 (된소리 = 종성 불가) + private static bool IsConsonantOnly(char c) + { + if (!IsKorJamo(c)) return false; + if (IsVowel(c)) return false; + return JongIdx(c) <= 0; + } + + // ── GetItemsAsync ───────────────────────────────────────────────────────── + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + string inputText; + + if (string.IsNullOrWhiteSpace(q)) + { + // 클립보드에서 읽기 + string? clipText = null; + try + { + Application.Current.Dispatcher.Invoke(() => + { + if (Clipboard.ContainsText()) + clipText = Clipboard.GetText(); + }); + } + catch { } + + if (string.IsNullOrWhiteSpace(clipText)) + { + items.Add(new LauncherItem("한/영 타이핑 교정", + "fix <영타 텍스트> 또는 클립보드에 텍스트 복사 후 fix 입력", + null, null, Symbol: "\uE8AC")); + items.Add(new LauncherItem("예: fix gksrmf", "→ 안녕 (영타→한글)", null, null, Symbol: "\uE8AC")); + return Task.FromResult>(items); + } + + inputText = clipText; + } + else + { + inputText = q; + } + + var converted = ConvertEngToKor(inputText); + + if (converted == inputText) + { + items.Add(new LauncherItem("변환할 영타 오류가 없습니다", + $"입력: {inputText}", null, null, Symbol: "\uE8AC")); + } + else + { + items.Add(new LauncherItem( + converted, + $"영타 교정 결과 · Enter: 클립보드 복사", + null, ("copy", converted), Symbol: "\uE8AC")); + items.Add(new LauncherItem( + $"원본: {(inputText.Length > 50 ? inputText[..50] + "…" : inputText)}", + "", null, null, Symbol: "\uE8AC")); + } + + return Task.FromResult>(items); + } + + // ── 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; + } +} diff --git a/src/AxCopilot/Handlers/LeaveHandler.cs b/src/AxCopilot/Handlers/LeaveHandler.cs new file mode 100644 index 0000000..fde3a21 --- /dev/null +++ b/src/AxCopilot/Handlers/LeaveHandler.cs @@ -0,0 +1,323 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; + +namespace AxCopilot.Handlers; + +/// +/// L23-2: 연차·휴가 관리. "leave" 프리픽스로 사용합니다. +/// +/// 예: leave → 잔여 연차 현황 +/// leave set 15 → 연간 연차 일수 설정 +/// leave use 2026-04-10 → 1일 연차 기록 +/// leave use 2026-04-10 0.5 → 반차 기록 +/// leave use 2026-04-10 0.5 반차메모 → 메모 포함 +/// leave del 2026-04-10 → 기록 삭제 +/// leave remaining → 잔여 연차 +/// leave list → 올해 사용 이력 +/// leave clear → 올해 기록 초기화 +/// Enter → 저장 실행 또는 복사 +/// 저장: %APPDATA%\AxCopilot\leave.json +/// +public class LeaveHandler : IActionHandler +{ + public string? Prefix => "leave"; + + public PluginMetadata Metadata => new( + "연차 관리", + "연차·휴가 기록 — 설정 · 사용 · 잔여 조회 · 이력", + "1.0", + "AX"); + + private static readonly string DataPath = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "AxCopilot", "leave.json"); + + private static readonly JsonSerializerOptions JsonOpts = new() + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + private sealed class LeaveData + { + [JsonPropertyName("annualDays")] public double AnnualDays { get; set; } = 15; + [JsonPropertyName("records")] public List Records { get; set; } = []; + } + + private sealed class LeaveRecord + { + [JsonPropertyName("date")] public string Date { get; set; } = ""; + [JsonPropertyName("days")] public double Days { get; set; } = 1; + [JsonPropertyName("note")] public string Note { get; set; } = ""; + } + + // ── GetItemsAsync ───────────────────────────────────────────────────────── + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + var data = LoadData(); + var year = DateTime.Today.Year; + var used = data.Records.Where(r => r.Date.StartsWith(year.ToString())).Sum(r => r.Days); + var left = data.AnnualDays - used; + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem( + $"연차 현황 — 연간 {data.AnnualDays}일 사용 {used}일 잔여 {left}일", + "leave set N · leave use 날짜 [일수] [메모] · leave list · leave remaining", + null, null, Symbol: "\uE716")); + items.Add(new LauncherItem("leave set <일수>", "연간 연차 총일수 설정", null, null, Symbol: "\uE716")); + items.Add(new LauncherItem("leave use <날짜>", "연차 사용 기록 (기본 1일)", null, null, Symbol: "\uE716")); + items.Add(new LauncherItem("leave list", "올해 사용 이력 전체 조회", null, null, Symbol: "\uE716")); + items.Add(new LauncherItem("leave remaining", "잔여 연차 확인", null, null, Symbol: "\uE716")); + return Task.FromResult>(items); + } + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + // set N + if (sub == "set") + { + if (parts.Length >= 2 && double.TryParse(parts[1], out var n) && n > 0) + { + items.Add(new LauncherItem( + $"연간 연차를 {n}일로 설정", + $"현재: {data.AnnualDays}일 → {n}일 · Enter: 저장", + null, ("set", n.ToString(System.Globalization.CultureInfo.InvariantCulture)), + Symbol: "\uE716")); + } + else + { + items.Add(new LauncherItem("일수를 입력하세요", "예: leave set 15", null, null, Symbol: "\uE783")); + } + return Task.FromResult>(items); + } + + // use 날짜 [일수] [메모] + if (sub == "use") + { + if (parts.Length < 2) + { + items.Add(new LauncherItem("날짜를 입력하세요", "예: leave use 2026-04-10", null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + var dateStr = parts[1]; + if (!DateOnly.TryParseExact(dateStr, new[] { "yyyy-MM-dd", "yyyy/MM/dd" }, + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, out _)) + { + items.Add(new LauncherItem("날짜 형식 오류", "yyyy-MM-dd 형식으로 입력하세요", null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + var useDays = 1.0; + var note = ""; + if (parts.Length >= 3 && double.TryParse(parts[2], + System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var pd)) + { + useDays = pd; + if (parts.Length >= 4) + note = string.Join(" ", parts[3..]); + } + else if (parts.Length >= 3) + { + note = string.Join(" ", parts[2..]); + } + var newLeft = left - useDays; + var label = note.Length > 0 + ? $"{dateStr} 연차 {useDays}일 기록 [{note}]" + : $"{dateStr} 연차 {useDays}일 기록"; + items.Add(new LauncherItem( + label, + $"잔여: {newLeft}일 · Enter: 저장", + null, ("use", $"{dateStr}|{useDays.ToString(System.Globalization.CultureInfo.InvariantCulture)}|{note}"), + Symbol: "\uE716")); + return Task.FromResult>(items); + } + + // del 날짜 + if (sub == "del") + { + if (parts.Length < 2) + { + items.Add(new LauncherItem("날짜를 입력하세요", "예: leave del 2026-04-10", null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + var delDate = parts[1]; + var target = data.Records.FirstOrDefault(r => r.Date == delDate); + if (target == null) + { + items.Add(new LauncherItem($"{delDate} 기록 없음", "해당 날짜의 연차 기록이 없습니다", null, null, Symbol: "\uE783")); + } + else + { + items.Add(new LauncherItem( + $"{delDate} 기록 삭제 ({target.Days}일{(target.Note.Length > 0 ? " / " + target.Note : "")})", + "Enter: 삭제", + null, ("del", delDate), Symbol: "\uE716")); + } + return Task.FromResult>(items); + } + + // remaining + if (sub == "remaining") + { + items.Add(new LauncherItem( + $"잔여 연차: {left}일 (연간 {data.AnnualDays}일 - 사용 {used}일)", + "Enter: 클립보드 복사", + null, ("copy", $"잔여 연차: {left}일"), Symbol: "\uE716")); + return Task.FromResult>(items); + } + + // list + if (sub == "list") + { + var yearRecords = data.Records + .Where(r => r.Date.StartsWith(year.ToString())) + .OrderBy(r => r.Date) + .ToList(); + items.Add(new LauncherItem( + $"{year}년 연차 사용 이력 — {yearRecords.Count}건 총 {used}일", + $"잔여: {left}일", null, null, Symbol: "\uE716")); + if (yearRecords.Count == 0) + { + items.Add(new LauncherItem("사용 이력 없음", "", null, null, Symbol: "\uE716")); + } + else + { + foreach (var r in yearRecords) + { + var noteStr = r.Note.Length > 0 ? $" [{r.Note}]" : ""; + items.Add(new LauncherItem( + $"{r.Date} {r.Days}일{noteStr}", + "Enter: 삭제", + null, ("del", r.Date), Symbol: "\uE716")); + } + } + return Task.FromResult>(items); + } + + // clear + if (sub == "clear") + { + var cnt = data.Records.Count(r => r.Date.StartsWith(year.ToString())); + items.Add(new LauncherItem( + $"{year}년 연차 기록 초기화 ({cnt}건)", + "Enter: 확인", + null, ("clear", year.ToString()), Symbol: "\uE716")); + return Task.FromResult>(items); + } + + // 기본 — 안내 + items.Add(new LauncherItem($"'{q}' — 알 수 없는 명령", + "leave set · use · del · remaining · list · clear", + null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + // ── ExecuteAsync ────────────────────────────────────────────────────────── + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + var data = LoadData(); + + switch (item.Data) + { + case ("set", string nStr) when double.TryParse(nStr, + System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var n): + data.AnnualDays = n; + SaveData(data); + NotificationService.Notify("연차 관리", $"연간 연차를 {n}일로 설정했습니다."); + break; + + case ("use", string payload): + { + var parts = payload.Split('|'); + if (parts.Length >= 2 && + double.TryParse(parts[1], + System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var days)) + { + var dateStr = parts[0]; + var note = parts.Length >= 3 ? parts[2] : ""; + // 중복 날짜 처리 — 누적 + var existing = data.Records.FirstOrDefault(r => r.Date == dateStr); + if (existing != null) + { + existing.Days += days; + if (note.Length > 0) existing.Note = note; + } + else + { + data.Records.Add(new LeaveRecord { Date = dateStr, Days = days, Note = note }); + } + data.Records.Sort((a, b) => string.Compare(a.Date, b.Date, StringComparison.Ordinal)); + SaveData(data); + NotificationService.Notify("연차 관리", $"{dateStr} 연차 {days}일 기록됐습니다."); + } + break; + } + + case ("del", string delDate): + { + var removed = data.Records.RemoveAll(r => r.Date == delDate); + if (removed > 0) + { + SaveData(data); + NotificationService.Notify("연차 관리", $"{delDate} 기록이 삭제됐습니다."); + } + break; + } + + case ("clear", string yearStr) when int.TryParse(yearStr, out var y): + { + var cnt = data.Records.RemoveAll(r => r.Date.StartsWith(y.ToString())); + SaveData(data); + NotificationService.Notify("연차 관리", $"{y}년 연차 기록 {cnt}건 초기화됐습니다."); + break; + } + + case ("copy", string text): + try + { + Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text)); + NotificationService.Notify("연차 관리", "클립보드에 복사했습니다."); + } + catch { } + break; + } + return Task.CompletedTask; + } + + // ── 저장/불러오기 ───────────────────────────────────────────────────────── + + private static LeaveData LoadData() + { + try + { + if (!System.IO.File.Exists(DataPath)) return new LeaveData(); + var json = System.IO.File.ReadAllText(DataPath, System.Text.Encoding.UTF8); + return JsonSerializer.Deserialize(json, JsonOpts) ?? new LeaveData(); + } + catch { return new LeaveData(); } + } + + private static void SaveData(LeaveData data) + { + try + { + System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(DataPath)!); + System.IO.File.WriteAllText(DataPath, + JsonSerializer.Serialize(data, JsonOpts), + System.Text.Encoding.UTF8); + } + catch { } + } +} diff --git a/src/AxCopilot/Handlers/WorkTimeHandler.cs b/src/AxCopilot/Handlers/WorkTimeHandler.cs new file mode 100644 index 0000000..cf4d64a --- /dev/null +++ b/src/AxCopilot/Handlers/WorkTimeHandler.cs @@ -0,0 +1,241 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; + +namespace AxCopilot.Handlers; + +/// +/// L23-3: 근무 시간·급여 계산. "work" 프리픽스로 사용합니다. +/// +/// 예: work 09:00 18:30 → 근무시간·초과근무 (점심 1시간 자동 제외) +/// work 09:00 18:30 -30 → 점심 30분 제외 +/// work 09:00 18:30 -0 → 점심 제외 없음 +/// work 09:00 18:30 pay 15000 → 급여 함께 계산 +/// work pay 15000 → 이전 계산 재활용 급여 산출 +/// work week 45.5 → 주간 근무시간 입력 → 초과 계산 +/// Enter → 결과 복사 +/// +public class WorkTimeHandler : IActionHandler +{ + public string? Prefix => "work"; + + public PluginMetadata Metadata => new( + "근무시간 계산", + "출퇴근 시간 입력 → 근무시간·초과근무·급여 계산", + "1.0", + "AX"); + + // 마지막 계산 캐시 (pay 재활용용) + private static double _lastWorkedHours; + + // ── 파서 ────────────────────────────────────────────────────────────────── + + private static bool TryParseTime(string s, out TimeSpan result) + { + result = default; + s = s.Trim(); + // HH:mm or H:mm + if (s.Contains(':')) + { + var parts = s.Split(':'); + if (parts.Length == 2 && + int.TryParse(parts[0], out var h) && + int.TryParse(parts[1], out var m) && + h >= 0 && h <= 47 && m >= 0 && m < 60) + { + result = new TimeSpan(h, m, 0); + return true; + } + return false; + } + // HHmm (4자리) + if (s.Length == 4 && + int.TryParse(s[..2], out var hh) && + int.TryParse(s[2..], out var mm) && + hh >= 0 && hh <= 23 && mm >= 0 && mm < 60) + { + result = new TimeSpan(hh, mm, 0); + return true; + } + return false; + } + + private static bool TryParseWorkTime(string q, + out TimeSpan start, out TimeSpan end, + out double lunchMinutes, out double payWage) + { + start = default; + end = default; + lunchMinutes = 60; + payWage = 0; + + var tokens = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (tokens.Length < 2) return false; + if (!TryParseTime(tokens[0], out start)) return false; + if (!TryParseTime(tokens[1], out end)) return false; + + for (var i = 2; i < tokens.Length; i++) + { + var t = tokens[i]; + // -N → 점심 제외 분 + if (t.StartsWith('-') && double.TryParse(t[1..], + System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var lm)) + { + lunchMinutes = lm; + } + // pay N + else if (t.Equals("pay", StringComparison.OrdinalIgnoreCase) && i + 1 < tokens.Length) + { + if (double.TryParse(tokens[i + 1], + System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var pw)) + { + payWage = pw; + i++; + } + } + } + return true; + } + + private static (double stdPay, double otPay, double total) CalcPay(double wage, double workedHours) + { + var stdHours = Math.Min(workedHours, 8.0); + var otHours = Math.Max(0, workedHours - 8.0); + var stdPay = stdHours * wage; + var otPay = otHours * wage * 1.5; + return (stdPay, otPay, stdPay + otPay); + } + + // ── GetItemsAsync ───────────────────────────────────────────────────────── + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem("근무시간 계산기", + "work 09:00 18:30 → 근무시간 / work 09:00 18:30 -30 → 점심 30분 / work 09:00 18:30 pay 15000 → 급여", + null, null, Symbol: "\uE916")); + items.Add(new LauncherItem("work 09:00 18:30", "점심 1시간 자동 제외", null, null, Symbol: "\uE916")); + items.Add(new LauncherItem("work 09:00 18:30 -30", "점심 30분 제외", null, null, Symbol: "\uE916")); + items.Add(new LauncherItem("work pay 15000", "이전 계산 재활용 급여 산출", null, null, Symbol: "\uE916")); + return Task.FromResult>(items); + } + + var tokens = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var sub = tokens[0].ToLowerInvariant(); + + // pay N → 이전 계산 재활용 + if (sub == "pay") + { + if (tokens.Length >= 2 && double.TryParse(tokens[1], + System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var wage)) + { + if (_lastWorkedHours <= 0) + { + items.Add(new LauncherItem("이전 계산 없음", "먼저 시간을 계산하세요: work HH:mm HH:mm", + null, null, Symbol: "\uE783")); + } + else + { + var (stdPay, otPay, total) = CalcPay(wage, _lastWorkedHours); + var result = $"근무 {_lastWorkedHours:F1}시간 | 시급 {wage:#,0}원 | 기본 {stdPay:#,0}원 | 초과 {otPay:#,0}원 | 합계 {total:#,0}원"; + items.Add(new LauncherItem( + $"급여: {total:#,0}원", + $"기본 {stdPay:#,0}원 + 초과(1.5배) {otPay:#,0}원 · Enter: 복사", + null, ("copy", result), Symbol: "\uE916")); + } + } + else + { + items.Add(new LauncherItem("시급을 입력하세요", "예: work pay 15000", null, null, Symbol: "\uE783")); + } + return Task.FromResult>(items); + } + + // week N → 주간 근무시간 + if (sub == "week") + { + if (tokens.Length >= 2 && double.TryParse(tokens[1], + System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var weekHours)) + { + var std = 40.0; + var ot = Math.Max(0, weekHours - std); + var label = $"주간 근무 {weekHours:F1}시간 초과근무 {ot:F1}시간 (기준 {std}h)"; + items.Add(new LauncherItem(label, "Enter: 복사", + null, ("copy", label), Symbol: "\uE916")); + } + else + { + items.Add(new LauncherItem("시간을 입력하세요", "예: work week 45.5", null, null, Symbol: "\uE783")); + } + return Task.FromResult>(items); + } + + // 시각 파싱 시도 + if (!TryParseWorkTime(q, out var start, out var end, out var lunch, out var pay)) + { + items.Add(new LauncherItem("시간 형식 오류", + "예: work 09:00 18:30 또는 work 09:00 18:30 -30 pay 15000", + null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + // 야간 처리 + if (end <= start) end = end.Add(TimeSpan.FromHours(24)); + + var totalSpan = end - start - TimeSpan.FromMinutes(lunch); + var workedHours = totalSpan.TotalHours; + if (workedHours < 0) workedHours = 0; + + _lastWorkedHours = workedHours; + + var wh = (int)workedHours; + var wm = (int)Math.Round((workedHours - wh) * 60); + var otHours = Math.Max(0, workedHours - 8.0); + + var summaryLine = $"근무 {workedHours:F1}시간 ({wh}시간 {wm}분) 초과 {otHours:F1}시간"; + items.Add(new LauncherItem( + $"근무시간: {workedHours:F1}시간 ({wh}시간 {wm}분)", + $"초과근무: {otHours:F1}시간 · Enter: 복사", + null, ("copy", summaryLine), Symbol: "\uE916")); + items.Add(new LauncherItem( + $"초과근무: {otHours:F1}시간", + $"기준 8시간 초과분 / 점심 제외: {lunch}분", + null, ("copy", $"초과근무: {otHours:F1}시간"), Symbol: "\uE916")); + + if (pay > 0) + { + var (stdP, otP, totalP) = CalcPay(pay, workedHours); + var payLine = $"급여: {totalP:#,0}원 (기본 {stdP:#,0}원 + 초과 {otP:#,0}원) | 시급 {pay:#,0}원"; + items.Add(new LauncherItem( + $"급여: {totalP:#,0}원", + $"기본 {stdP:#,0}원 + 초과(1.5배) {otP:#,0}원 · Enter: 복사", + null, ("copy", payLine), Symbol: "\uE916")); + } + + return Task.FromResult>(items); + } + + // ── 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; + } +}