[Phase L25] 개인 생산성 강화 도구 4종 구현

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 19:03:05 +09:00
parent 98d63509f9
commit f1bc592507
5 changed files with 1074 additions and 0 deletions

View File

@@ -362,6 +362,16 @@ public partial class App : System.Windows.Application
// L24-3: 자주 틀리는 맞춤법 (prefix=spell) // L24-3: 자주 틀리는 맞춤법 (prefix=spell)
commandResolver.RegisterHandler(new SpellHandler()); 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); var pluginHost = new PluginHost(settings, commandResolver);
pluginHost.LoadAll(); pluginHost.LoadAll();

View File

@@ -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;
/// <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 { }
}
}

View File

@@ -0,0 +1,215 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
namespace AxCopilot.Handlers;
/// <summary>
/// L25-3: 자주 쓰는 업무 문구 모음. "phrase" 프리픽스로 사용합니다.
///
/// 예: phrase → 카테고리 목록
/// phrase 인사 → 인사 문구 목록
/// phrase 보고 → 보고/발표 문구
/// phrase 요청 → 요청/협조 문구
/// phrase 마무리 → 마무리/감사 문구
/// phrase <검색어> → 전체 검색
/// Enter → 문구 클립보드 복사
/// </summary>
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<PhraseEntry> _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<PhraseEntry> BuildPhrases()
{
var list = new List<PhraseEntry>();
// 인사 (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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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;
}
}

View File

@@ -0,0 +1,288 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
namespace AxCopilot.Handlers;
/// <summary>
/// L25-2: 오늘 특정 시각 알림. "remind" 프리픽스로 사용합니다.
///
/// 예: remind → 오늘 등록된 알림 목록
/// remind 15:00 보고서 제출 → 오후 3시에 알림
/// remind 오후3시 팀장보고 → 한국어 시각 파싱
/// remind del 1 → 1번 알림 취소
/// remind clear → 지난 알림 정리
/// </summary>
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<RemindEntry> _reminders = [];
private static readonly Dictionary<int, CancellationTokenSource> _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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
List<RemindEntry> 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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
}
// clear 명령
if (sub == "clear")
{
items.Add(new LauncherItem("지난 알림 정리",
"발화된 알림을 목록에서 제거합니다 · Enter 실행",
null, ("clear", ""), Symbol: "\uE787"));
return Task.FromResult<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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}시간 후";
}
}

View File

@@ -0,0 +1,250 @@
using System.Text.Json;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
namespace AxCopilot.Handlers;
/// <summary>
/// L25-4: 오늘 업무 통합 뷰. "today" 프리픽스로 사용합니다.
///
/// 예: today → 오늘 날짜/요일/공휴일 + 할일 + 알림 + 공휴일 현황
/// </summary>
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<DateOnly, string> 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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var items = new List<LauncherItem>();
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<IEnumerable<LauncherItem>>(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<string> 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<string>();
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;
}
}