From f1bc5925079c4b4fb80a2ccf87d845a47e4c3fb3 Mon Sep 17 00:00:00 2001 From: lacvet Date: Sat, 4 Apr 2026 19:03:05 +0900 Subject: [PATCH] =?UTF-8?q?[Phase=20L25]=20=EA=B0=9C=EC=9D=B8=20=EC=83=9D?= =?UTF-8?q?=EC=82=B0=EC=84=B1=20=EA=B0=95=ED=99=94=20=EB=8F=84=EA=B5=AC=20?= =?UTF-8?q?4=EC=A2=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ContactHandler (신규): - prefix=contact, 로컬 연락처 관리 - Contact { Name, Dept, Phone, Email, Memo } - 저장: %APPDATA%\AxCopilot\contacts.json - 서브명령: add, del, 이름/부서/메모 검색 - 복사 우선순위: 이메일 > 전화 > 이름 RemindHandler (신규): - prefix=remind, 특정 시각 알림 - RemindEntry { Id, Time, Message, Fired, Cts } - 한국어 시각 파싱: 오전/오후N시M분, HH:mm - 시각 경과 시 자동 내일로 설정 - GetTodayReminders(): TodayHandler 연동용 - 서브명령: HH:mm 메시지, del N, clear PhraseHandler (신규): - prefix=phrase, 업무 문구 모음 62종 - 7개 카테고리: 인사/보고/요청/마무리/승인결재/회의/사과지연 - PhraseEntry(Text, Category, Tags) - 카테고리 키워드 매칭 + 텍스트 검색 폴백 TodayHandler (신규): - prefix=today, 오늘 업무 통합 뷰 - 5개 항목: 날짜/공휴일, 미완료 할일, 오늘 알림, 다음 공휴일 D-day, 이번달 잔여 업무일 - todos.json JsonDocument 파싱 - RemindHandler.GetTodayReminders() 연동 - 자체 공휴일 사전 (2025–2027) 내장 App.xaml.cs: - 핸들러 4개 등록 (ContactHandler, RemindHandler, PhraseHandler, TodayHandler) - 빌드: 경고 0, 오류 0 Co-Authored-By: Claude Sonnet 4.6 --- src/AxCopilot/App.xaml.cs | 10 + src/AxCopilot/Handlers/ContactHandler.cs | 311 +++++++++++++++++++++++ src/AxCopilot/Handlers/PhraseHandler.cs | 215 ++++++++++++++++ src/AxCopilot/Handlers/RemindHandler.cs | 288 +++++++++++++++++++++ src/AxCopilot/Handlers/TodayHandler.cs | 250 ++++++++++++++++++ 5 files changed, 1074 insertions(+) create mode 100644 src/AxCopilot/Handlers/ContactHandler.cs create mode 100644 src/AxCopilot/Handlers/PhraseHandler.cs create mode 100644 src/AxCopilot/Handlers/RemindHandler.cs create mode 100644 src/AxCopilot/Handlers/TodayHandler.cs diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index 267adbb..60a878d 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -362,6 +362,16 @@ public partial class App : System.Windows.Application // L24-3: 자주 틀리는 맞춤법 (prefix=spell) commandResolver.RegisterHandler(new SpellHandler()); + // ─── L25: 개인 생산성 강화 ───────────────────────────────────────────── + // L25-1: 로컬 연락처 관리 (prefix=contact) + commandResolver.RegisterHandler(new ContactHandler()); + // L25-2: 오늘 특정 시각 알림 (prefix=remind) + commandResolver.RegisterHandler(new RemindHandler()); + // L25-3: 자주 쓰는 업무 문구 (prefix=phrase) + commandResolver.RegisterHandler(new PhraseHandler()); + // L25-4: 오늘 업무 통합 뷰 (prefix=today) + commandResolver.RegisterHandler(new TodayHandler()); + // ─── 플러그인 로드 ──────────────────────────────────────────────────── var pluginHost = new PluginHost(settings, commandResolver); pluginHost.LoadAll(); diff --git a/src/AxCopilot/Handlers/ContactHandler.cs b/src/AxCopilot/Handlers/ContactHandler.cs new file mode 100644 index 0000000..124260a --- /dev/null +++ b/src/AxCopilot/Handlers/ContactHandler.cs @@ -0,0 +1,311 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; + +namespace AxCopilot.Handlers; + +/// +/// L25-1: 로컬 연락처 관리. "contact" 프리픽스로 사용합니다. +/// +/// 예: contact → 전체 연락처 목록 +/// contact 홍길동 → 이름 검색 +/// contact add 홍길동 개발팀 010-1234-5678 hong@company.com → 추가 +/// contact del 홍길동 → 삭제 +/// contact <부서명> → 부서 필터 +/// Enter → 이메일 또는 전화번호 클립보드 복사 +/// 저장: %APPDATA%\AxCopilot\contacts.json +/// +public class ContactHandler : IActionHandler +{ + public string? Prefix => "contact"; + + public PluginMetadata Metadata => new( + "연락처", + "로컬 연락처 관리 — 추가 · 검색 · 삭제 · 복사", + "1.0", + "AX"); + + private static readonly string DataPath = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "AxCopilot", "contacts.json"); + + private static readonly JsonSerializerOptions JsonOpts = new() + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + private sealed class Contact + { + [JsonPropertyName("name")] public string Name { get; set; } = ""; + [JsonPropertyName("dept")] public string Dept { get; set; } = ""; + [JsonPropertyName("phone")] public string Phone { get; set; } = ""; + [JsonPropertyName("email")] public string Email { get; set; } = ""; + [JsonPropertyName("memo")] public string Memo { get; set; } = ""; + } + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + var contacts = LoadContacts(); + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem( + $"연락처 {contacts.Count}개", + "contact add 이름 부서 010-xxxx-xxxx email@company.com", + null, null, Symbol: "\uE8D4")); + + if (contacts.Count == 0) + { + items.Add(new LauncherItem("저장된 연락처가 없습니다", + "contact add 이름 부서 전화 이메일 로 추가하세요", + null, null, Symbol: "\uE946")); + return Task.FromResult>(items); + } + + foreach (var c in contacts) + items.Add(MakeContactItem(c)); + + return Task.FromResult>(items); + } + + var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + // add 명령 + if (sub == "add") + { + var addPart = parts.Length > 1 ? parts[1].Trim() : ""; + if (string.IsNullOrWhiteSpace(addPart)) + { + items.Add(new LauncherItem("이름을 입력하세요", + "예: contact add 홍길동 개발팀 010-1234-5678 hong@company.com", + null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + var (name, dept, phone, email, memo) = ParseAddArgs(addPart); + if (string.IsNullOrWhiteSpace(name)) + { + items.Add(new LauncherItem("이름을 입력하세요", + "예: contact add 홍길동 개발팀 010-1234-5678 hong@company.com", + null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + var encoded = $"{name}|{dept}|{phone}|{email}|{memo}"; + items.Add(new LauncherItem( + $"연락처 추가: {name}", + $"{dept} {phone} {email} · Enter로 추가", + null, ("add", encoded), Symbol: "\uE710")); + return Task.FromResult>(items); + } + + // del 명령 + if (sub is "del" or "delete" or "remove") + { + var delName = parts.Length > 1 ? parts[1].Trim() : ""; + if (string.IsNullOrWhiteSpace(delName)) + { + items.Add(new LauncherItem("삭제할 이름을 입력하세요", + "예: contact del 홍길동", + null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + var matches = contacts.Where(c => + c.Name.Contains(delName, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (matches.Count == 0) + { + items.Add(new LauncherItem($"'{delName}' 연락처를 찾을 수 없습니다", "", + null, null, Symbol: "\uE783")); + } + else + { + foreach (var c in matches) + items.Add(new LauncherItem( + $"{c.Name} 삭제", + $"{c.Dept} {c.Phone} {c.Email} · Enter로 삭제", + null, ("del", c.Name), Symbol: "\uE8D4")); + } + return Task.FromResult>(items); + } + + // 검색: 이름, 부서, 메모 포함 + var byStartsWith = contacts + .Where(c => c.Name.StartsWith(q, StringComparison.OrdinalIgnoreCase) || + c.Dept.StartsWith(q, StringComparison.OrdinalIgnoreCase)) + .ToList(); + var byContains = contacts + .Where(c => !byStartsWith.Contains(c) && + (c.Name.Contains(q, StringComparison.OrdinalIgnoreCase) || + c.Dept.Contains(q, StringComparison.OrdinalIgnoreCase) || + c.Memo.Contains(q, StringComparison.OrdinalIgnoreCase))) + .ToList(); + + var results = byStartsWith.Concat(byContains).ToList(); + + if (results.Count > 0) + { + items.Add(new LauncherItem($"'{q}' 검색 결과 {results.Count}개", "", + null, null, Symbol: "\uE8D4")); + foreach (var c in results) + items.Add(MakeContactItem(c)); + } + else + { + items.Add(new LauncherItem($"'{q}' 검색 결과 없음", + "contact add 로 새 연락처를 추가하세요", + null, null, Symbol: "\uE783")); + } + + return Task.FromResult>(items); + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + switch (item.Data) + { + case ("copy_email", string email) when !string.IsNullOrWhiteSpace(email): + CopyToClipboard(email); + NotificationService.Notify("연락처", $"{email} 복사"); + break; + + case ("copy_phone", string phone) when !string.IsNullOrWhiteSpace(phone): + CopyToClipboard(phone); + NotificationService.Notify("연락처", $"{phone} 복사"); + break; + + case ("copy", string text) when !string.IsNullOrWhiteSpace(text): + CopyToClipboard(text); + NotificationService.Notify("연락처", $"{text} 복사"); + break; + + case ("add", string encoded): + var addParts = encoded.Split('|'); + if (addParts.Length >= 5) + { + var contact = new Contact + { + Name = addParts[0], + Dept = addParts[1], + Phone = addParts[2], + Email = addParts[3], + Memo = addParts[4], + }; + var list = LoadContacts(); + list.RemoveAll(c => c.Name == contact.Name); + list.Add(contact); + SaveContacts(list); + NotificationService.Notify("연락처", $"{contact.Name} 추가됨"); + } + break; + + case ("del", string name): + var contacts = LoadContacts(); + var removed = contacts.RemoveAll(c => c.Name == name); + if (removed > 0) + { + SaveContacts(contacts); + NotificationService.Notify("연락처", $"{name} 삭제됨"); + } + break; + } + return Task.CompletedTask; + } + + // ── 저장/불러오기 ───────────────────────────────────────────────────────── + + private static List LoadContacts() + { + try + { + if (!System.IO.File.Exists(DataPath)) return new List(); + var json = System.IO.File.ReadAllText(DataPath, System.Text.Encoding.UTF8); + return JsonSerializer.Deserialize>(json, JsonOpts) ?? new List(); + } + catch { return new List(); } + } + + private static void SaveContacts(List contacts) + { + try + { + System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(DataPath)!); + System.IO.File.WriteAllText(DataPath, + JsonSerializer.Serialize(contacts, JsonOpts), + System.Text.Encoding.UTF8); + } + catch { /* 비핵심 */ } + } + + // ── 헬퍼 ───────────────────────────────────────────────────────────────── + + private static LauncherItem MakeContactItem(Contact c) + { + var title = $"{c.Name}"; + if (!string.IsNullOrWhiteSpace(c.Dept)) title += $" · {c.Dept}"; + var sub = ""; + if (!string.IsNullOrWhiteSpace(c.Phone)) sub += c.Phone; + if (!string.IsNullOrWhiteSpace(c.Email)) sub += (sub.Length > 0 ? " | " : "") + c.Email; + + // 복사 우선순위: email > phone > name + (string action, string value) data; + if (!string.IsNullOrWhiteSpace(c.Email)) + data = ("copy_email", c.Email); + else if (!string.IsNullOrWhiteSpace(c.Phone)) + data = ("copy_phone", c.Phone); + else + data = ("copy", c.Name); + + return new LauncherItem(title, sub, null, data, Symbol: "\uE8D4"); + } + + private static (string name, string dept, string phone, string email, string memo) + ParseAddArgs(string addPart) + { + var tokens = addPart.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (tokens.Length == 0) return ("", "", "", "", ""); + + var name = tokens[0]; + var dept = ""; + var phone = ""; + var email = ""; + var memoTokens = new List(); + + for (var i = 1; i < tokens.Length; i++) + { + var t = tokens[i]; + if (string.IsNullOrWhiteSpace(phone) && IsPhoneLike(t)) + phone = t; + else if (string.IsNullOrWhiteSpace(email) && t.Contains('@')) + email = t; + else if (string.IsNullOrWhiteSpace(dept) && i == 1) + dept = t; + else + memoTokens.Add(t); + } + + return (name, dept, phone, email, string.Join(" ", memoTokens)); + } + + private static bool IsPhoneLike(string s) + { + var stripped = s.Replace("-", "").Replace(" ", ""); + return stripped.Length >= 9 && stripped.All(c => char.IsDigit(c)); + } + + private static void CopyToClipboard(string text) + { + try + { + Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text)); + } + catch { } + } +} diff --git a/src/AxCopilot/Handlers/PhraseHandler.cs b/src/AxCopilot/Handlers/PhraseHandler.cs new file mode 100644 index 0000000..d2b6de7 --- /dev/null +++ b/src/AxCopilot/Handlers/PhraseHandler.cs @@ -0,0 +1,215 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; + +namespace AxCopilot.Handlers; + +/// +/// L25-3: 자주 쓰는 업무 문구 모음. "phrase" 프리픽스로 사용합니다. +/// +/// 예: phrase → 카테고리 목록 +/// phrase 인사 → 인사 문구 목록 +/// phrase 보고 → 보고/발표 문구 +/// phrase 요청 → 요청/협조 문구 +/// phrase 마무리 → 마무리/감사 문구 +/// phrase <검색어> → 전체 검색 +/// Enter → 문구 클립보드 복사 +/// +public class PhraseHandler : IActionHandler +{ + public string? Prefix => "phrase"; + + public PluginMetadata Metadata => new( + "업무 문구", + "자주 쓰는 업무 문구 — 인사·보고·요청·마무리·승인·회의·사과", + "1.0", + "AX"); + + private sealed record PhraseEntry(string Text, string Category, string CategoryKey); + + private static readonly List _phrases = BuildPhrases(); + + private static readonly (string Key, string Display, string[] Aliases)[] _categories = + [ + ("greeting", "인사", ["인사", "greeting"]), + ("report", "보고", ["보고", "보고서", "report"]), + ("request", "요청", ["요청", "협조", "request"]), + ("closing", "마무리", ["마무리", "감사", "closing"]), + ("approval", "승인결재", ["승인", "결재", "approval"]), + ("meeting", "회의", ["회의", "미팅", "meeting"]), + ("apology", "사과지연", ["사과", "지연", "사죄", "apology"]), + ]; + + private static List BuildPhrases() + { + var list = new List(); + + // 인사 (greeting) + void G(string t) => list.Add(new PhraseEntry(t, "인사", "greeting")); + G("안녕하세요. [이름]입니다."); + G("수고 많으십니다."); + G("오랜만에 연락드립니다."); + G("처음 뵙겠습니다. 잘 부탁드립니다."); + G("바쁘신 와중에 연락드려 죄송합니다."); + G("항상 감사드립니다."); + G("늦은 시간에 연락드려 죄송합니다."); + G("좋은 하루 보내세요."); + G("주말 잘 보내세요."); + G("휴가 잘 다녀오세요."); + + // 보고 (report) + void R(string t) => list.Add(new PhraseEntry(t, "보고", "report")); + R("말씀하신 대로 처리하겠습니다."); + R("현재 검토 중에 있습니다."); + R("확인 후 말씀드리겠습니다."); + R("금일 중으로 처리하겠습니다."); + R("해당 건은 [날짜]까지 완료 예정입니다."); + R("진행 상황을 공유드립니다."); + R("결과 보고드립니다."); + R("관련하여 추가 검토가 필요합니다."); + R("이슈 사항이 발생하여 공유드립니다."); + R("문제없이 완료되었습니다."); + + // 요청 (request) + void Req(string t) => list.Add(new PhraseEntry(t, "요청", "request")); + Req("검토 부탁드립니다."); + Req("회신 부탁드립니다."); + Req("확인 부탁드립니다."); + Req("협조 부탁드립니다."); + Req("아래 내용 확인 후 승인 부탁드립니다."); + Req("첨부 파일 확인 부탁드립니다."); + Req("가능한 빠른 시일 내 처리 부탁드립니다."); + Req("[날짜]까지 회신 주시면 감사하겠습니다."); + Req("필요한 사항 있으시면 알려주세요."); + Req("문의 사항은 언제든지 연락 주세요."); + + // 마무리 (closing) + void C(string t) => list.Add(new PhraseEntry(t, "마무리", "closing")); + C("감사합니다."); + C("수고하셨습니다."); + C("도움 주셔서 감사합니다."); + C("빠른 처리 감사드립니다."); + C("앞으로도 잘 부탁드립니다."); + C("이상입니다."); + C("이상으로 보고를 마치겠습니다."); + C("궁금하신 점은 언제든 문의 주세요."); + C("좋은 결과 있기를 바랍니다."); + C("다시 한번 감사드립니다."); + + // 승인결재 (approval) + void A(string t) => list.Add(new PhraseEntry(t, "승인결재", "approval")); + A("검토 후 승인 부탁드립니다."); + A("위 내용으로 진행해도 될까요?"); + A("담당자 확인 후 피드백 주세요."); + A("위와 같이 결정되었음을 알려드립니다."); + A("아래와 같이 변경합니다."); + A("이의 없으시면 그대로 진행하겠습니다."); + A("검토 의견 부탁드립니다."); + + // 회의 (meeting) + void M(string t) => list.Add(new PhraseEntry(t, "회의", "meeting")); + M("오늘 회의 내용을 공유드립니다."); + M("다음 회의는 [날짜]에 진행 예정입니다."); + M("회의 참석 부탁드립니다."); + M("회의 장소 및 일정을 안내드립니다."); + M("회의록 공유드립니다."); + M("다음 주 [요일] [시간]에 미팅 가능하신가요?"); + M("일정 조율 부탁드립니다."); + M("화상 회의 링크 공유드립니다."); + M("회의 아젠다 공유드립니다."); + + // 사과/지연 (apology) + void Ap(string t) => list.Add(new PhraseEntry(t, "사과지연", "apology")); + Ap("처리가 늦어져 죄송합니다."); + Ap("불편을 드려 죄송합니다."); + Ap("확인이 늦었습니다. 죄송합니다."); + Ap("오류가 발생하여 사과드립니다."); + Ap("빠른 처리가 어려운 점 양해 부탁드립니다."); + Ap("답변이 늦어 죄송합니다."); + + return list; + } + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + // 카테고리 목록 표시 + items.Add(new LauncherItem( + "업무 문구 카테고리", + "phrase <카테고리> 또는 phrase <검색어>", + null, null, Symbol: "\uE8A0")); + + foreach (var (key, display, _) in _categories) + { + var count = _phrases.Count(p => p.CategoryKey == key); + items.Add(new LauncherItem( + $"{display} ({count}개)", + $"phrase {display} → 문구 목록", + null, ("category", key), Symbol: "\uE8A0")); + } + return Task.FromResult>(items); + } + + var kw = q.ToLowerInvariant(); + + // 카테고리 키워드 매칭 + foreach (var (key, display, aliases) in _categories) + { + if (aliases.Any(a => a.Equals(q, StringComparison.OrdinalIgnoreCase) || + a.Contains(q, StringComparison.OrdinalIgnoreCase))) + { + var catPhrases = _phrases.Where(p => p.CategoryKey == key).ToList(); + items.Add(new LauncherItem( + $"{display} ({catPhrases.Count}개)", + "Enter → 클립보드 복사", + null, null, Symbol: "\uE8A0")); + foreach (var p in catPhrases) + items.Add(new LauncherItem(p.Text, p.Category, + null, ("copy", p.Text), Symbol: "\uE8A0")); + return Task.FromResult>(items); + } + } + + // 전체 검색 + var results = _phrases + .Where(p => p.Text.Contains(q, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (results.Count > 0) + { + items.Add(new LauncherItem( + $"'{q}' 검색 결과 {results.Count}개", + "Enter → 클립보드 복사", + null, null, Symbol: "\uE8A0")); + foreach (var p in results) + items.Add(new LauncherItem(p.Text, p.Category, + null, ("copy", p.Text), Symbol: "\uE8A0")); + } + else + { + items.Add(new LauncherItem($"'{q}' 검색 결과 없음", + "카테고리: 인사 / 보고 / 요청 / 마무리 / 승인결재 / 회의 / 사과지연", + null, null, Symbol: "\uE783")); + } + + return Task.FromResult>(items); + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + if (item.Data is ("copy", string text) && !string.IsNullOrWhiteSpace(text)) + { + try + { + Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text)); + NotificationService.Notify("문구", "클립보드에 복사했습니다."); + } + catch { } + } + return Task.CompletedTask; + } +} diff --git a/src/AxCopilot/Handlers/RemindHandler.cs b/src/AxCopilot/Handlers/RemindHandler.cs new file mode 100644 index 0000000..1e1af86 --- /dev/null +++ b/src/AxCopilot/Handlers/RemindHandler.cs @@ -0,0 +1,288 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; + +namespace AxCopilot.Handlers; + +/// +/// L25-2: 오늘 특정 시각 알림. "remind" 프리픽스로 사용합니다. +/// +/// 예: remind → 오늘 등록된 알림 목록 +/// remind 15:00 보고서 제출 → 오후 3시에 알림 +/// remind 오후3시 팀장보고 → 한국어 시각 파싱 +/// remind del 1 → 1번 알림 취소 +/// remind clear → 지난 알림 정리 +/// +public class RemindHandler : IActionHandler +{ + public string? Prefix => "remind"; + + public PluginMetadata Metadata => new( + "알림", + "오늘 특정 시각 알림 — 시간 설정 · 취소 · 목록", + "1.0", + "AX"); + + private sealed class RemindEntry + { + public int Id { get; set; } + public DateTime Time { get; set; } + public string Message { get; set; } = ""; + public bool Fired { get; set; } + public CancellationTokenSource? Cts { get; set; } + } + + private static readonly List _reminders = []; + private static readonly Dictionary _ctsDic = []; + private static readonly object _lock = new(); + private static int _nextId = 1; + + // 외부(TodayHandler)에서 오늘 알림 목록 조회용 + internal static List<(DateTime Time, string Message)> GetTodayReminders() + { + var today = DateTime.Today; + lock (_lock) + { + return _reminders + .Where(r => !r.Fired && r.Time.Date == today) + .Select(r => (r.Time, r.Message)) + .OrderBy(r => r.Time) + .ToList(); + } + } + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + List pending; + lock (_lock) + pending = _reminders.Where(r => !r.Fired).OrderBy(r => r.Time).ToList(); + + if (pending.Count == 0) + { + items.Add(new LauncherItem("등록된 알림 없음", + "remind HH:mm 메시지 또는 remind 오후3시 메시지", + null, null, Symbol: "\uE787")); + } + else + { + items.Add(new LauncherItem($"알림 {pending.Count}개", + "remind <시각> <메시지> / remind del <번호>", + null, null, Symbol: "\uE787")); + foreach (var r in pending) + { + var left = r.Time > DateTime.Now + ? FormatLeft(r.Time - DateTime.Now) + : "시간 지남"; + items.Add(new LauncherItem( + $"#{r.Id} {r.Time:HH:mm} — {r.Message}", + $"남은 시간: {left} · Enter로 취소", + null, ("del", r.Id.ToString()), Symbol: "\uE787")); + } + } + return Task.FromResult>(items); + } + + var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + // del 명령 + if (sub is "del" or "cancel" or "취소") + { + var idStr = parts.Length > 1 ? parts[1].Trim() : ""; + if (!int.TryParse(idStr, out var delId)) + { + items.Add(new LauncherItem("번호를 입력하세요", + "예: remind del 1", + null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + RemindEntry? target; + lock (_lock) target = _reminders.FirstOrDefault(r => r.Id == delId); + if (target == null) + items.Add(new LauncherItem($"#{delId} 알림을 찾을 수 없습니다", "", + null, null, Symbol: "\uE783")); + else + items.Add(new LauncherItem( + $"#{delId} 알림 취소: {target.Time:HH:mm} {target.Message}", + "Enter로 취소합니다", + null, ("del", delId.ToString()), Symbol: "\uE787")); + return Task.FromResult>(items); + } + + // clear 명령 + if (sub == "clear") + { + items.Add(new LauncherItem("지난 알림 정리", + "발화된 알림을 목록에서 제거합니다 · Enter 실행", + null, ("clear", ""), Symbol: "\uE787")); + return Task.FromResult>(items); + } + + // 시각 파싱 시도 + var (timeStr, msgStr) = SplitTimeAndMessage(q); + if (!string.IsNullOrWhiteSpace(timeStr) && TryParseTime(timeStr, out var alarmTime)) + { + var leftText = alarmTime > DateTime.Now + ? FormatLeft(alarmTime - DateTime.Now) + : "이미 지난 시각 (내일로 설정됩니다)"; + var message = string.IsNullOrWhiteSpace(msgStr) ? "(메시지 없음)" : msgStr; + var encoded = $"{alarmTime:O}|{message}"; + items.Add(new LauncherItem( + $"알림 설정: {alarmTime:HH:mm} — {message}", + $"{leftText} · Enter로 설정", + null, ("set", encoded), Symbol: "\uE787")); + } + else + { + items.Add(new LauncherItem($"시각 파싱 실패: '{q}'", + "예: remind 15:00 보고서 제출 / remind 오후3시 팀장보고", + null, null, Symbol: "\uE783")); + } + + return Task.FromResult>(items); + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + switch (item.Data) + { + case ("set", string encoded): + { + var idx = encoded.IndexOf('|'); + if (idx < 0) break; + var timeIso = encoded[..idx]; + var message = encoded[(idx + 1)..]; + if (!DateTime.TryParse(timeIso, null, + System.Globalization.DateTimeStyles.RoundtripKind, out var alarmTime)) + break; + + var cts = new CancellationTokenSource(); + int id; + lock (_lock) + { + id = _nextId++; + var entry = new RemindEntry { Id = id, Time = alarmTime, Message = message, Cts = cts }; + _reminders.Add(entry); + _ctsDic[id] = cts; + } + NotificationService.Notify("알림", $"#{id} {alarmTime:HH:mm} {message} 설정됨"); + _ = RunReminderAsync(id, alarmTime, message, cts.Token); + break; + } + + case ("del", string idStr) when int.TryParse(idStr, out var delId): + { + RemindEntry? entry; + lock (_lock) + { + entry = _reminders.FirstOrDefault(r => r.Id == delId); + if (entry != null) _reminders.Remove(entry); + if (_ctsDic.TryGetValue(delId, out var c)) { c.Cancel(); _ctsDic.Remove(delId); } + } + if (entry != null) + NotificationService.Notify("알림", $"#{delId} 알림 취소됨"); + break; + } + + case ("clear", _): + { + int cleared; + lock (_lock) + { + cleared = _reminders.RemoveAll(r => r.Fired); + } + NotificationService.Notify("알림", $"지난 알림 {cleared}개 정리됨"); + break; + } + } + return Task.CompletedTask; + } + + // ── 알림 실행 ──────────────────────────────────────────────────────────── + + private static async Task RunReminderAsync(int id, DateTime at, string message, CancellationToken token) + { + try + { + var delay = at - DateTime.Now; + if (delay > TimeSpan.Zero) + await Task.Delay(delay, token); + + lock (_lock) + { + var entry = _reminders.FirstOrDefault(r => r.Id == id); + if (entry != null) entry.Fired = true; + _ctsDic.Remove(id); + } + NotificationService.Notify("⏰ 알림", $"{at:HH:mm} {message}"); + } + catch (OperationCanceledException) { } + } + + // ── 파싱 헬퍼 ──────────────────────────────────────────────────────────── + + private static (string timeStr, string message) SplitTimeAndMessage(string q) + { + var sp = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + if (sp.Length == 0) return ("", ""); + return (sp[0], sp.Length > 1 ? sp[1] : ""); + } + + private static bool TryParseTime(string s, out DateTime result) + { + result = DateTime.MinValue; + s = s.Trim(); + + // HH:mm 또는 H:mm + if (System.Text.RegularExpressions.Regex.IsMatch(s, @"^\d{1,2}:\d{2}$")) + { + var colonParts = s.Split(':'); + if (int.TryParse(colonParts[0], out var h) && + int.TryParse(colonParts[1], out var m) && + h is >= 0 and <= 23 && m is >= 0 and <= 59) + { + result = DateTime.Today.AddHours(h).AddMinutes(m); + if (result <= DateTime.Now) result = result.AddDays(1); + return true; + } + } + + // 한국어 시각: 오전N시M분, 오후N시M분, N시M분, 오전N시, 오후N시, N시 + var korMatch = System.Text.RegularExpressions.Regex.Match(s, + @"^(오전|오후)?(\d{1,2})시((\d{1,2})분)?$"); + if (korMatch.Success) + { + var meridiem = korMatch.Groups[1].Value; + if (!int.TryParse(korMatch.Groups[2].Value, out var h)) return false; + var minStr = korMatch.Groups[4].Value; + var m = string.IsNullOrEmpty(minStr) ? 0 : int.TryParse(minStr, out var mp) ? mp : 0; + + if (meridiem == "오후" && h < 12) h += 12; + if (meridiem == "오전" && h == 12) h = 0; + + if (h is < 0 or > 23 || m is < 0 or > 59) return false; + + result = DateTime.Today.AddHours(h).AddMinutes(m); + if (result <= DateTime.Now) result = result.AddDays(1); + return true; + } + + return false; + } + + private static string FormatLeft(TimeSpan ts) + { + if (ts.TotalSeconds < 60) + return $"{(int)ts.TotalSeconds}초 후"; + if (ts.TotalMinutes < 60) + return $"{(int)ts.TotalMinutes}분 후"; + var h = (int)ts.TotalHours; + var m = (int)(ts.TotalMinutes - h * 60); + return m > 0 ? $"{h}시간 {m}분 후" : $"{h}시간 후"; + } +} diff --git a/src/AxCopilot/Handlers/TodayHandler.cs b/src/AxCopilot/Handlers/TodayHandler.cs new file mode 100644 index 0000000..68a6579 --- /dev/null +++ b/src/AxCopilot/Handlers/TodayHandler.cs @@ -0,0 +1,250 @@ +using System.Text.Json; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; + +namespace AxCopilot.Handlers; + +/// +/// L25-4: 오늘 업무 통합 뷰. "today" 프리픽스로 사용합니다. +/// +/// 예: today → 오늘 날짜/요일/공휴일 + 할일 + 알림 + 공휴일 현황 +/// +public class TodayHandler : IActionHandler +{ + public string? Prefix => "today"; + + public PluginMetadata Metadata => new( + "오늘", + "오늘 업무 통합 뷰 — 날짜·할일·알림·공휴일", + "1.0", + "AX"); + + private static readonly string TodoPath = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "AxCopilot", "todos.json"); + + private static readonly string[] DayNames = ["일", "월", "화", "수", "목", "금", "토"]; + + // 2025~2027 주요 공휴일 (CalHandler에 직접 접근 불가하므로 독립 정의) + private static readonly Dictionary Holidays = new() + { + // 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), "대체공휴일" }, + }; + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var items = new List(); + var today = DateOnly.FromDateTime(DateTime.Today); + var now = DateTime.Now; + + // ─── 항목1: 날짜 헤더 ──────────────────────────────────────────────── + var dow = DayNames[(int)today.DayOfWeek]; + 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일} ({dow}요일)", + status, + null, ("copy", $"{today:yyyy-MM-dd} ({dow}) {status}"), + Symbol: "\uE8BF")); + + // ─── 항목2: 할일 ───────────────────────────────────────────────────── + var (pendingCount, recentTitles) = LoadPendingTodos(); + var todoSub = pendingCount == 0 + ? "미완료 할일 없음" + : string.Join(" / ", recentTitles.Take(3)); + items.Add(new LauncherItem( + $"미완료 할일 {pendingCount}건", + todoSub, + null, null, Symbol: "\uE762")); + + // ─── 항목3: 알림 ───────────────────────────────────────────────────── + var todayReminders = RemindHandler.GetTodayReminders(); + if (todayReminders.Count == 0) + { + items.Add(new LauncherItem("오늘 알림 없음", + "remind HH:mm 메시지 로 알림을 설정하세요", + null, null, Symbol: "\uE787")); + } + else + { + var remindSub = string.Join(" / ", + todayReminders.Take(3).Select(r => $"{r.Time:HH:mm} {r.Message}")); + items.Add(new LauncherItem( + $"오늘 알림 {todayReminders.Count}건", + remindSub, + null, null, Symbol: "\uE787")); + } + + // ─── 항목4: 다음 공휴일 ────────────────────────────────────────────── + var nextHol = Holidays.Keys + .Where(d => d > today) + .OrderBy(d => d) + .FirstOrDefault(); + + if (nextHol != default) + { + var diff = nextHol.DayNumber - today.DayNumber; + var nextDow = DayNames[(int)nextHol.DayOfWeek]; + items.Add(new LauncherItem( + $"다음 공휴일: {nextHol:MM/dd} ({nextDow}) {Holidays[nextHol]}", + $"D-{diff}일", + null, ("copy", $"{nextHol:yyyy-MM-dd} {Holidays[nextHol]} D-{diff}"), + Symbol: "\uE787")); + } + else + { + items.Add(new LauncherItem("다음 공휴일 정보 없음", "", + null, null, Symbol: "\uE787")); + } + + // ─── 항목5: 이번달 잔여 업무일 ─────────────────────────────────────── + var lastDay = new DateOnly(today.Year, today.Month, + DateTime.DaysInMonth(today.Year, today.Month)); + var remaining = CountWorkdaysFrom(today, lastDay); + var total = CountWorkdays(today.Year, today.Month); + items.Add(new LauncherItem( + $"이번달 잔여 업무일 {remaining}일", + $"{today.Year}년 {today.Month}월 총 업무일: {total}일", + null, null, Symbol: "\uE8BF")); + + return Task.FromResult>(items); + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + if (item.Data is ("copy", string text) && !string.IsNullOrWhiteSpace(text)) + { + try + { + Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text)); + NotificationService.Notify("오늘", "클립보드에 복사했습니다."); + } + catch { } + } + return Task.CompletedTask; + } + + // ── 할일 파싱 ──────────────────────────────────────────────────────────── + + private static (int pending, List titles) LoadPendingTodos() + { + try + { + if (!System.IO.File.Exists(TodoPath)) return (0, []); + var json = System.IO.File.ReadAllText(TodoPath, System.Text.Encoding.UTF8); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Array) return (0, []); + + var pending = 0; + var titles = new List(); + foreach (var el in root.EnumerateArray()) + { + var done = false; + if (el.TryGetProperty("done", out var doneProp)) + done = doneProp.GetBoolean(); + if (done) continue; + + pending++; + if (titles.Count < 3 && el.TryGetProperty("text", out var textProp)) + titles.Add(textProp.GetString() ?? ""); + } + return (pending, titles); + } + catch { return (0, []); } + } + + // ── 업무일 계산 ────────────────────────────────────────────────────────── + + private static bool IsHoliday(DateOnly d) => + Holidays.ContainsKey(d) || + d.DayOfWeek == DayOfWeek.Saturday || + d.DayOfWeek == DayOfWeek.Sunday; + + 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 (!IsHoliday(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 (!IsHoliday(cur)) count++; + cur = cur.AddDays(1); + } + return count; + } +}