변경 목적: Agent Compare 아래 비교본의 개발 문서와 런처 소스를 기준으로 현재 AX Commander에 빠져 있던 신규 런처 기능을 동일한 흐름으로 옮겨, 비교본 수준의 기능 폭을 현재 제품에 반영했습니다. 핵심 수정사항: 비교본의 신규 런처 핸들러 다수를 src/AxCopilot/Handlers로 이식하고 App.xaml.cs 등록 흐름에 연결했습니다. 빠른 링크, 파일 태그, 알림 센터, 포모도로, 파일 브라우저, 핫키 관리, OCR, 세션/스케줄/매크로, Git/정규식/네트워크/압축/해시/UUID/JWT/QR 등 AX Commander 기능을 추가했습니다. 핵심 수정사항: 신규 기능이 실제 동작하도록 AppSettings 확장, SchedulerService/FileTagService/NotificationCenterService/IconCacheService/UrlTemplateEngine/PomodoroService 추가, 배치 이름변경/세션/스케줄/매크로 편집 창 추가, NotificationService와 Symbols 보강, QR/OCR용 csproj 의존성과 Windows 타겟 프레임워크를 반영했습니다. 문서 반영: README.md와 docs/DEVELOPMENT.md에 비교본 기반 런처 기능 이식 이력과 검증 결과를 업데이트했습니다. 검증 결과: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 실행 기준 경고 0개, 오류 0개를 확인했습니다.
312 lines
12 KiB
C#
312 lines
12 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using System.Windows;
|
|
using AxCopilot.SDK;
|
|
using AxCopilot.Services;
|
|
|
|
namespace AxCopilot.Handlers;
|
|
|
|
/// <summary>
|
|
/// L25-1: 로컬 연락처 관리. "contact" 프리픽스로 사용합니다.
|
|
///
|
|
/// 예: contact → 전체 연락처 목록
|
|
/// contact 홍길동 → 이름 검색
|
|
/// contact add 홍길동 개발팀 010-1234-5678 hong@company.com → 추가
|
|
/// contact del 홍길동 → 삭제
|
|
/// contact <부서명> → 부서 필터
|
|
/// Enter → 이메일 또는 전화번호 클립보드 복사
|
|
/// 저장: %APPDATA%\AxCopilot\contacts.json
|
|
/// </summary>
|
|
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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
|
{
|
|
var q = query.Trim();
|
|
var items = new List<LauncherItem>();
|
|
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<IEnumerable<LauncherItem>>(items);
|
|
}
|
|
|
|
foreach (var c in contacts)
|
|
items.Add(MakeContactItem(c));
|
|
|
|
return Task.FromResult<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<Contact> LoadContacts()
|
|
{
|
|
try
|
|
{
|
|
if (!System.IO.File.Exists(DataPath)) return new List<Contact>();
|
|
var json = System.IO.File.ReadAllText(DataPath, System.Text.Encoding.UTF8);
|
|
return JsonSerializer.Deserialize<List<Contact>>(json, JsonOpts) ?? new List<Contact>();
|
|
}
|
|
catch { return new List<Contact>(); }
|
|
}
|
|
|
|
private static void SaveContacts(List<Contact> 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<string>();
|
|
|
|
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 { }
|
|
}
|
|
}
|