[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:
@@ -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();
|
||||
|
||||
311
src/AxCopilot/Handlers/ContactHandler.cs
Normal file
311
src/AxCopilot/Handlers/ContactHandler.cs
Normal 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 { }
|
||||
}
|
||||
}
|
||||
215
src/AxCopilot/Handlers/PhraseHandler.cs
Normal file
215
src/AxCopilot/Handlers/PhraseHandler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
288
src/AxCopilot/Handlers/RemindHandler.cs
Normal file
288
src/AxCopilot/Handlers/RemindHandler.cs
Normal 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}시간 후";
|
||||
}
|
||||
}
|
||||
250
src/AxCopilot/Handlers/TodayHandler.cs
Normal file
250
src/AxCopilot/Handlers/TodayHandler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user