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