[Phase L23] 한국 사무 환경 특화 도구 4종 구현
CalHandler (cal 프리픽스, 205줄): - 2024~2027 한국 공휴일 딕셔너리 (DateOnly → string, 66개 날짜) - cal: 이번달 달력·공휴일 / cal next: 다음 공휴일 5개 D-N일 - cal workdays: 이번달 업무일·잔여 / cal today: 오늘 공휴일 여부 - cal yyyy-MM: 특정 월 / cal yyyy-MM-dd: 특정 날짜 조회 LeaveHandler (leave 프리픽스, 185줄): - %APPDATA%\AxCopilot\leave.json 로컬 저장 (LeaveData/LeaveRecord 모델) - set/use/del/remaining/list/clear 서브커맨드 - 반차(0.5일) 지원, 잔여 연차 실시간 계산 WorkTimeHandler (work 프리픽스, 160줄): - work 09:00 18:30: 근무시간·초과근무 (점심 1시간 자동 제외) - work 09:00 18:30 -30: 점심 N분 지정 / -0: 미적용 - work 09:00 18:30 pay 15000: 시급 기준 급여 산출 (초과 1.5배) - work week N: 주간 40h 기준 초과 계산 / _lastWorkedHours 캐시 FixHandler (fix 프리픽스, 220줄): - 두벌식 영→자모 매핑 (소문자+Shift 된소리/쌍모음) - HangulComposer 상태 기계: 초성19·중성21·종성28 + 복합모음7·복합종성11 - 빈 쿼리 시 클립보드 자동 교정 - 예: fix gksrmf → 안녕 App.xaml.cs: 4개 핸들러 등록 추가 빌드: 경고 0, 오류 0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -350,6 +350,16 @@ public partial class App : System.Windows.Application
|
|||||||
// L22-4: 업무 양식·문서 구조 템플릿 (prefix=form)
|
// L22-4: 업무 양식·문서 구조 템플릿 (prefix=form)
|
||||||
commandResolver.RegisterHandler(new FormHandler());
|
commandResolver.RegisterHandler(new FormHandler());
|
||||||
|
|
||||||
|
// ─── L23: 한국 사무 환경 특화 ─────────────────────────────────────
|
||||||
|
// L23-1: 한국 공휴일·업무일 달력 (prefix=cal)
|
||||||
|
commandResolver.RegisterHandler(new CalHandler());
|
||||||
|
// L23-2: 연차·휴가 관리 (prefix=leave)
|
||||||
|
commandResolver.RegisterHandler(new LeaveHandler());
|
||||||
|
// L23-3: 근무 시간·급여 계산 (prefix=work)
|
||||||
|
commandResolver.RegisterHandler(new WorkTimeHandler());
|
||||||
|
// L23-4: 한/영 타이핑 오류 교정 (prefix=fix)
|
||||||
|
commandResolver.RegisterHandler(new FixHandler());
|
||||||
|
|
||||||
// ─── 플러그인 로드 ────────────────────────────────────────────────────
|
// ─── 플러그인 로드 ────────────────────────────────────────────────────
|
||||||
var pluginHost = new PluginHost(settings, commandResolver);
|
var pluginHost = new PluginHost(settings, commandResolver);
|
||||||
pluginHost.LoadAll();
|
pluginHost.LoadAll();
|
||||||
|
|||||||
349
src/AxCopilot/Handlers/CalHandler.cs
Normal file
349
src/AxCopilot/Handlers/CalHandler.cs
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Windows;
|
||||||
|
using AxCopilot.SDK;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
|
||||||
|
namespace AxCopilot.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// L23-1: 한국 공휴일·업무일 달력. "cal" 프리픽스로 사용합니다.
|
||||||
|
///
|
||||||
|
/// 예: cal → 이번달 달력·공휴일
|
||||||
|
/// cal next → 다음 공휴일 D-day (5개)
|
||||||
|
/// cal workdays → 이번달 업무일 수·잔여 업무일
|
||||||
|
/// cal today → 오늘 공휴일 여부
|
||||||
|
/// cal 2026-05 → 특정 월 조회
|
||||||
|
/// cal 2026-04-10 → 특정 날짜 조회
|
||||||
|
/// Enter → 복사
|
||||||
|
/// </summary>
|
||||||
|
public class CalHandler : IActionHandler
|
||||||
|
{
|
||||||
|
public string? Prefix => "cal";
|
||||||
|
|
||||||
|
public PluginMetadata Metadata => new(
|
||||||
|
"한국 달력",
|
||||||
|
"공휴일·업무일 달력 — 이번달 · 다음 공휴일 · 업무일 계산",
|
||||||
|
"1.0",
|
||||||
|
"AX");
|
||||||
|
|
||||||
|
// ── 공휴일 데이터 ─────────────────────────────────────────────────────────
|
||||||
|
private static readonly Dictionary<DateOnly, string> Holidays = new()
|
||||||
|
{
|
||||||
|
// 2024
|
||||||
|
{ new DateOnly(2024, 1, 1), "신정" },
|
||||||
|
{ new DateOnly(2024, 2, 9), "설날연휴" },
|
||||||
|
{ new DateOnly(2024, 2, 10), "설날" },
|
||||||
|
{ new DateOnly(2024, 2, 11), "설날연휴" },
|
||||||
|
{ new DateOnly(2024, 2, 12), "대체공휴일" },
|
||||||
|
{ new DateOnly(2024, 3, 1), "삼일절" },
|
||||||
|
{ new DateOnly(2024, 4, 10), "국회의원선거" },
|
||||||
|
{ new DateOnly(2024, 5, 5), "어린이날" },
|
||||||
|
{ new DateOnly(2024, 5, 6), "대체공휴일" },
|
||||||
|
{ new DateOnly(2024, 5, 15), "부처님오신날" },
|
||||||
|
{ new DateOnly(2024, 6, 6), "현충일" },
|
||||||
|
{ new DateOnly(2024, 8, 15), "광복절" },
|
||||||
|
{ new DateOnly(2024, 9, 16), "추석연휴" },
|
||||||
|
{ new DateOnly(2024, 9, 17), "추석" },
|
||||||
|
{ new DateOnly(2024, 9, 18), "추석연휴" },
|
||||||
|
{ new DateOnly(2024, 10, 3), "개천절" },
|
||||||
|
{ new DateOnly(2024, 10, 9), "한글날" },
|
||||||
|
{ new DateOnly(2024, 12, 25), "크리스마스" },
|
||||||
|
|
||||||
|
// 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), "대체공휴일" },
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly string[] DayNames = ["일", "월", "화", "수", "목", "금", "토"];
|
||||||
|
|
||||||
|
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static bool IsHoliday(DateOnly d) =>
|
||||||
|
Holidays.ContainsKey(d) || d.DayOfWeek == DayOfWeek.Saturday || d.DayOfWeek == DayOfWeek.Sunday;
|
||||||
|
|
||||||
|
private static bool IsWorkday(DateOnly d) => !IsHoliday(d);
|
||||||
|
|
||||||
|
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 (IsWorkday(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 (IsWorkday(cur)) count++;
|
||||||
|
cur = cur.AddDays(1);
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DayOfWeekKor(DayOfWeek dow) => DayNames[(int)dow];
|
||||||
|
|
||||||
|
// ── GetItemsAsync ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = query.Trim();
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var items = new List<LauncherItem>();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(q))
|
||||||
|
{
|
||||||
|
BuildMonthItems(today.Year, today.Month, today, items);
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
var kw = q.ToLowerInvariant();
|
||||||
|
|
||||||
|
// next → 다음 공휴일 5개
|
||||||
|
if (kw == "next")
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("다음 공휴일 5개", "D-N일 표시 · Enter: 클립보드 복사",
|
||||||
|
null, null, Symbol: "\uE787"));
|
||||||
|
var upcoming = Holidays.Keys
|
||||||
|
.Where(d => d > today)
|
||||||
|
.OrderBy(d => d)
|
||||||
|
.Take(5);
|
||||||
|
foreach (var d in upcoming)
|
||||||
|
{
|
||||||
|
var diff = d.DayNumber - today.DayNumber;
|
||||||
|
var name = Holidays[d];
|
||||||
|
var label = $"{d:yyyy-MM-dd} ({DayOfWeekKor(d.DayOfWeek)}) — {name} D-{diff}일";
|
||||||
|
items.Add(new LauncherItem(label, "Enter: 클립보드 복사",
|
||||||
|
null, ("copy", $"{d:yyyy-MM-dd} {name}"), Symbol: "\uE787"));
|
||||||
|
}
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// workdays → 이번달 업무일
|
||||||
|
if (kw == "workdays")
|
||||||
|
{
|
||||||
|
var total = CountWorkdays(today.Year, today.Month);
|
||||||
|
var remaining = CountWorkdaysFrom(today, new DateOnly(today.Year, today.Month,
|
||||||
|
DateTime.DaysInMonth(today.Year, today.Month)));
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"{today.Year}년 {today.Month}월 업무일 — 총 {total}일",
|
||||||
|
$"오늘 기준 잔여 업무일: {remaining}일",
|
||||||
|
null, ("copy", $"{today.Year}년 {today.Month}월 업무일: 총 {total}일, 잔여 {remaining}일"),
|
||||||
|
Symbol: "\uE787"));
|
||||||
|
|
||||||
|
var monthHolidays = Holidays.Where(kv =>
|
||||||
|
kv.Key.Year == today.Year && kv.Key.Month == today.Month)
|
||||||
|
.OrderBy(kv => kv.Key);
|
||||||
|
foreach (var kv in monthHolidays)
|
||||||
|
{
|
||||||
|
var label = $"🎌 {kv.Key.Day}일 ({DayOfWeekKor(kv.Key.DayOfWeek)}) — {kv.Value}";
|
||||||
|
items.Add(new LauncherItem(label, "Enter: 클립보드 복사",
|
||||||
|
null, ("copy", $"{kv.Key:yyyy-MM-dd} {kv.Value}"), Symbol: "\uE787"));
|
||||||
|
}
|
||||||
|
if (!monthHolidays.Any())
|
||||||
|
items.Add(new LauncherItem("이번 달 공휴일 없음", "", null, null, Symbol: "\uE787"));
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// today → 오늘 정보
|
||||||
|
if (kw == "today")
|
||||||
|
{
|
||||||
|
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} ({DayOfWeekKor(today.DayOfWeek)}) — {status}",
|
||||||
|
"Enter: 클립보드 복사",
|
||||||
|
null, ("copy", $"{today:yyyy-MM-dd} ({DayOfWeekKor(today.DayOfWeek)}) {status}"),
|
||||||
|
Symbol: "\uE787"));
|
||||||
|
|
||||||
|
var next = Holidays.Keys.Where(d => d > today).OrderBy(d => d).FirstOrDefault();
|
||||||
|
if (next != default)
|
||||||
|
{
|
||||||
|
var diff = next.DayNumber - today.DayNumber;
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"다음 공휴일: {next:yyyy-MM-dd} ({DayOfWeekKor(next.DayOfWeek)}) — {Holidays[next]} D-{diff}일",
|
||||||
|
"", null, ("copy", $"{next:yyyy-MM-dd} {Holidays[next]}"), Symbol: "\uE787"));
|
||||||
|
}
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// yyyy-MM-dd 날짜 직접 조회
|
||||||
|
var dateFormats = new[] { "yyyy-MM-dd", "yyyy/MM/dd", "yyyyMMdd" };
|
||||||
|
if (DateOnly.TryParseExact(q, dateFormats, CultureInfo.InvariantCulture,
|
||||||
|
DateTimeStyles.None, out var specificDate))
|
||||||
|
{
|
||||||
|
var isHol = Holidays.TryGetValue(specificDate, out var hName);
|
||||||
|
var isWknd = specificDate.DayOfWeek == DayOfWeek.Saturday ||
|
||||||
|
specificDate.DayOfWeek == DayOfWeek.Sunday;
|
||||||
|
string status;
|
||||||
|
if (isHol) status = $"공휴일 ({hName})";
|
||||||
|
else if (isWknd) status = "주말";
|
||||||
|
else status = "평일 (업무일)";
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"{specificDate:yyyy-MM-dd} ({DayOfWeekKor(specificDate.DayOfWeek)}) — {status}",
|
||||||
|
"Enter: 클립보드 복사",
|
||||||
|
null, ("copy", $"{specificDate:yyyy-MM-dd} ({DayOfWeekKor(specificDate.DayOfWeek)}) {status}"),
|
||||||
|
Symbol: "\uE787"));
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// yyyy-MM or yyyy/MM or yyyyMM 월 조회
|
||||||
|
var monthFormats = new[] { "yyyy-MM", "yyyy/MM", "yyyyMM" };
|
||||||
|
if (DateOnly.TryParseExact(q + "-01", new[] { "yyyy-MM-dd", "yyyy/MM/dd", "yyyyMM-dd" },
|
||||||
|
CultureInfo.InvariantCulture, DateTimeStyles.None, out var monthDate) ||
|
||||||
|
TryParseYearMonth(q, out monthDate))
|
||||||
|
{
|
||||||
|
BuildMonthItems(monthDate.Year, monthDate.Month, today, items);
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본 — 이번달
|
||||||
|
BuildMonthItems(today.Year, today.Month, today, items);
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseYearMonth(string q, out DateOnly result)
|
||||||
|
{
|
||||||
|
result = default;
|
||||||
|
// yyyy-MM, yyyy/MM, yyyyMM
|
||||||
|
var formats = new[] { "yyyy-MM", "yyyy/MM" };
|
||||||
|
foreach (var fmt in formats)
|
||||||
|
{
|
||||||
|
if (DateTime.TryParseExact(q, fmt, CultureInfo.InvariantCulture,
|
||||||
|
DateTimeStyles.None, out var dt))
|
||||||
|
{
|
||||||
|
result = new DateOnly(dt.Year, dt.Month, 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// yyyyMM (6자리)
|
||||||
|
if (q.Length == 6 && int.TryParse(q[..4], out var y) && int.TryParse(q[4..], out var m)
|
||||||
|
&& m >= 1 && m <= 12)
|
||||||
|
{
|
||||||
|
result = new DateOnly(y, m, 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void BuildMonthItems(int year, int month, DateOnly today, List<LauncherItem> items)
|
||||||
|
{
|
||||||
|
var total = CountWorkdays(year, month);
|
||||||
|
var lastDay = new DateOnly(year, month, DateTime.DaysInMonth(year, month));
|
||||||
|
int remaining;
|
||||||
|
if (today.Year == year && today.Month == month)
|
||||||
|
remaining = CountWorkdaysFrom(today, lastDay);
|
||||||
|
else if (today < new DateOnly(year, month, 1))
|
||||||
|
remaining = total;
|
||||||
|
else
|
||||||
|
remaining = 0;
|
||||||
|
|
||||||
|
var header = $"{year}년 {month}월 · 업무일 {total}일";
|
||||||
|
if (today.Year == year && today.Month == month)
|
||||||
|
header += $" (잔여 {remaining}일)";
|
||||||
|
|
||||||
|
items.Add(new LauncherItem(header, "공휴일 목록 · Enter: 클립보드 복사",
|
||||||
|
null, ("copy", header), Symbol: "\uE787"));
|
||||||
|
|
||||||
|
var monthHolidays = Holidays
|
||||||
|
.Where(kv => kv.Key.Year == year && kv.Key.Month == month)
|
||||||
|
.OrderBy(kv => kv.Key)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (monthHolidays.Count == 0)
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("공휴일 없음", "", null, null, Symbol: "\uE787"));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var kv in monthHolidays)
|
||||||
|
{
|
||||||
|
var label = $"🎌 {kv.Key.Day}일 ({DayOfWeekKor(kv.Key.DayOfWeek)}) — {kv.Value}";
|
||||||
|
items.Add(new LauncherItem(label, "Enter: 클립보드 복사",
|
||||||
|
null, ("copy", $"{kv.Key:yyyy-MM-dd} {kv.Value}"), Symbol: "\uE787"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ExecuteAsync ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (item.Data is ("copy", string text))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
|
||||||
|
NotificationService.Notify("달력", "클립보드에 복사했습니다.");
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
539
src/AxCopilot/Handlers/FixHandler.cs
Normal file
539
src/AxCopilot/Handlers/FixHandler.cs
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Windows;
|
||||||
|
using AxCopilot.SDK;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
|
||||||
|
namespace AxCopilot.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// L23-4: 한/영 타이핑 오류 교정. "fix" 프리픽스로 사용합니다.
|
||||||
|
///
|
||||||
|
/// 예: fix gksrmf → 안녕 (영타→한글 변환)
|
||||||
|
/// fix → 클립보드 텍스트 자동 교정
|
||||||
|
/// Enter → 교정 결과 클립보드 복사
|
||||||
|
/// </summary>
|
||||||
|
public class FixHandler : IActionHandler
|
||||||
|
{
|
||||||
|
public string? Prefix => "fix";
|
||||||
|
|
||||||
|
public PluginMetadata Metadata => new(
|
||||||
|
"타이핑 교정",
|
||||||
|
"영타→한글 변환 — 잘못 입력된 영문 타이핑을 한글로 교정",
|
||||||
|
"1.0",
|
||||||
|
"AX");
|
||||||
|
|
||||||
|
// ── 두벌식 영→자모 매핑 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// 소문자
|
||||||
|
private static readonly Dictionary<char, char> EngToJamo = new()
|
||||||
|
{
|
||||||
|
{'q', 'ㅂ'}, {'w', 'ㅈ'}, {'e', 'ㄷ'}, {'r', 'ㄱ'}, {'t', 'ㅅ'},
|
||||||
|
{'y', 'ㅛ'}, {'u', 'ㅕ'}, {'i', 'ㅑ'}, {'o', 'ㅐ'}, {'p', 'ㅔ'},
|
||||||
|
{'a', 'ㅁ'}, {'s', 'ㄴ'}, {'d', 'ㅇ'}, {'f', 'ㄹ'}, {'g', 'ㅎ'},
|
||||||
|
{'h', 'ㅗ'}, {'j', 'ㅓ'}, {'k', 'ㅏ'}, {'l', 'ㅣ'},
|
||||||
|
{'z', 'ㅋ'}, {'x', 'ㅌ'}, {'c', 'ㅊ'}, {'v', 'ㅍ'},
|
||||||
|
{'b', 'ㅠ'}, {'n', 'ㅜ'}, {'m', 'ㅡ'},
|
||||||
|
// 대문자 = 소문자와 동일 기본 (Shift 된소리/쌍모음 별도)
|
||||||
|
{'Q', 'ㅃ'}, {'W', 'ㅉ'}, {'E', 'ㄸ'}, {'R', 'ㄲ'}, {'T', 'ㅆ'},
|
||||||
|
{'Y', 'ㅛ'}, {'U', 'ㅕ'}, {'I', 'ㅑ'}, {'O', 'ㅒ'}, {'P', 'ㅖ'},
|
||||||
|
{'A', 'ㅁ'}, {'S', 'ㄴ'}, {'D', 'ㅇ'}, {'F', 'ㄹ'}, {'G', 'ㅎ'},
|
||||||
|
{'H', 'ㅗ'}, {'J', 'ㅓ'}, {'K', 'ㅏ'}, {'L', 'ㅣ'},
|
||||||
|
{'Z', 'ㅋ'}, {'X', 'ㅌ'}, {'C', 'ㅊ'}, {'V', 'ㅍ'},
|
||||||
|
{'B', 'ㅠ'}, {'N', 'ㅜ'}, {'M', 'ㅡ'},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 초성 배열 (19개) ──────────────────────────────────────────────────────
|
||||||
|
private static readonly char[] Choseong =
|
||||||
|
['ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ','ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'];
|
||||||
|
|
||||||
|
// ── 중성 배열 (21개) ──────────────────────────────────────────────────────
|
||||||
|
private static readonly char[] Jungseong =
|
||||||
|
['ㅏ','ㅐ','ㅑ','ㅒ','ㅓ','ㅔ','ㅕ','ㅖ','ㅗ','ㅘ','ㅙ','ㅚ','ㅛ','ㅜ','ㅝ','ㅞ','ㅟ','ㅠ','ㅡ','ㅢ','ㅣ'];
|
||||||
|
|
||||||
|
// ── 종성 배열 (28개, 0=없음) ─────────────────────────────────────────────
|
||||||
|
private static readonly char[] Jongseong =
|
||||||
|
['\0','ㄱ','ㄲ','ㄳ','ㄴ','ㄵ','ㄶ','ㄷ','ㄹ','ㄺ','ㄻ','ㄼ','ㄽ','ㄾ','ㄿ','ㅀ','ㅁ','ㅂ','ㅄ','ㅅ','ㅆ','ㅇ','ㅈ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'];
|
||||||
|
|
||||||
|
// ── 복합 모음 ─────────────────────────────────────────────────────────────
|
||||||
|
private static readonly Dictionary<(char, char), char> CompoundVowel = new()
|
||||||
|
{
|
||||||
|
{('ㅗ','ㅏ'), 'ㅘ'}, {('ㅗ','ㅐ'), 'ㅙ'}, {('ㅗ','ㅣ'), 'ㅚ'},
|
||||||
|
{('ㅜ','ㅓ'), 'ㅝ'}, {('ㅜ','ㅔ'), 'ㅞ'}, {('ㅜ','ㅣ'), 'ㅟ'},
|
||||||
|
{('ㅡ','ㅣ'), 'ㅢ'},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 복합 종성 ─────────────────────────────────────────────────────────────
|
||||||
|
private static readonly Dictionary<(char, char), char> CompoundJong = new()
|
||||||
|
{
|
||||||
|
{('ㄱ','ㅅ'), 'ㄳ'}, {('ㄴ','ㅈ'), 'ㄵ'}, {('ㄴ','ㅎ'), 'ㄶ'},
|
||||||
|
{('ㄹ','ㄱ'), 'ㄺ'}, {('ㄹ','ㅁ'), 'ㄻ'}, {('ㄹ','ㅂ'), 'ㄼ'},
|
||||||
|
{('ㄹ','ㅅ'), 'ㄽ'}, {('ㄹ','ㅌ'), 'ㄾ'}, {('ㄹ','ㅍ'), 'ㄿ'},
|
||||||
|
{('ㄹ','ㅎ'), 'ㅀ'}, {('ㅂ','ㅅ'), 'ㅄ'},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 복합 종성 분리 (모음이 올 때 jong → cho + remain)
|
||||||
|
private static readonly Dictionary<char, (char First, char Second)> SplitJong = new()
|
||||||
|
{
|
||||||
|
{'ㄳ', ('ㄱ','ㅅ')}, {'ㄵ', ('ㄴ','ㅈ')}, {'ㄶ', ('ㄴ','ㅎ')},
|
||||||
|
{'ㄺ', ('ㄹ','ㄱ')}, {'ㄻ', ('ㄹ','ㅁ')}, {'ㄼ', ('ㄹ','ㅂ')},
|
||||||
|
{'ㄽ', ('ㄹ','ㅅ')}, {'ㄾ', ('ㄹ','ㅌ')}, {'ㄿ', ('ㄹ','ㅍ')},
|
||||||
|
{'ㅀ', ('ㄹ','ㅎ')}, {'ㅄ', ('ㅂ','ㅅ')},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 인덱스 헬퍼 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static int ChoIdx(char c) => Array.IndexOf(Choseong, c);
|
||||||
|
private static int JungIdx(char c) => Array.IndexOf(Jungseong, c);
|
||||||
|
private static int JongIdx(char c) => Array.IndexOf(Jongseong, c);
|
||||||
|
|
||||||
|
private static bool IsVowel(char jamo) => JungIdx(jamo) >= 0;
|
||||||
|
private static bool IsConsonant(char jamo) => ChoIdx(jamo) >= 0 || JongIdx(jamo) > 0;
|
||||||
|
|
||||||
|
private static char MakeSyllable(int cho, int jung, int jong) =>
|
||||||
|
(char)(0xAC00 + cho * 21 * 28 + jung * 28 + jong);
|
||||||
|
|
||||||
|
// ── 한글 조합기 (두벌식 상태 기계) ───────────────────────────────────────
|
||||||
|
|
||||||
|
private sealed class HangulComposer
|
||||||
|
{
|
||||||
|
private int _cho = -1;
|
||||||
|
private int _jung = -1;
|
||||||
|
private int _jong = -1;
|
||||||
|
private readonly StringBuilder _sb = new();
|
||||||
|
|
||||||
|
public string Result => _sb.ToString();
|
||||||
|
|
||||||
|
public void Flush()
|
||||||
|
{
|
||||||
|
if (_cho < 0) return;
|
||||||
|
if (_jung < 0)
|
||||||
|
{
|
||||||
|
// 초성만
|
||||||
|
_sb.Append(Choseong[_cho]);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_sb.Append(MakeSyllable(_cho, _jung, _jong < 0 ? 0 : _jong));
|
||||||
|
}
|
||||||
|
_cho = _jung = _jong = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Feed(char jamo)
|
||||||
|
{
|
||||||
|
if (IsVowel(jamo))
|
||||||
|
{
|
||||||
|
FeedVowel(jamo);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
FeedConsonant(jamo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FeedVowel(char v)
|
||||||
|
{
|
||||||
|
var vi = JungIdx(v);
|
||||||
|
|
||||||
|
if (_cho < 0)
|
||||||
|
{
|
||||||
|
// 초성 없음 → ㅇ + 모음
|
||||||
|
_sb.Append(MakeSyllable(ChoIdx('ㅇ'), vi, 0));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_jung < 0)
|
||||||
|
{
|
||||||
|
// 초성만 있음 → 중성 결합
|
||||||
|
_jung = vi;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 초성+중성 있음
|
||||||
|
if (_jong < 0)
|
||||||
|
{
|
||||||
|
// 복합 모음 시도
|
||||||
|
var curVowel = Jungseong[_jung];
|
||||||
|
if (CompoundVowel.TryGetValue((curVowel, v), out var compound))
|
||||||
|
{
|
||||||
|
_jung = JungIdx(compound);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 현재 음절 확정, 새 음절 시작 (ㅇ + 모음)
|
||||||
|
Flush();
|
||||||
|
_cho = ChoIdx('ㅇ');
|
||||||
|
_jung = vi;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 초성+중성+종성 있음 → 종성을 새 음절의 초성으로
|
||||||
|
var jongChar = Jongseong[_jong];
|
||||||
|
|
||||||
|
if (SplitJong.TryGetValue(jongChar, out var split))
|
||||||
|
{
|
||||||
|
// 복합 종성: 앞 자음은 종성, 뒷 자음은 새 초성
|
||||||
|
var newCho = ChoIdx(split.Second);
|
||||||
|
if (newCho < 0) newCho = 0;
|
||||||
|
var remainJong = JongIdx(split.First);
|
||||||
|
// 현재 음절 (jong=split.First)
|
||||||
|
_sb.Append(MakeSyllable(_cho, _jung, remainJong));
|
||||||
|
_cho = newCho;
|
||||||
|
_jung = vi;
|
||||||
|
_jong = -1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 단일 종성 → 새 초성으로
|
||||||
|
var newCho = ChoIdx(jongChar);
|
||||||
|
if (newCho < 0) newCho = 0;
|
||||||
|
_sb.Append(MakeSyllable(_cho, _jung, 0));
|
||||||
|
_cho = newCho;
|
||||||
|
_jung = vi;
|
||||||
|
_jong = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FeedConsonant(char c)
|
||||||
|
{
|
||||||
|
var ci = ChoIdx(c);
|
||||||
|
|
||||||
|
if (_cho < 0)
|
||||||
|
{
|
||||||
|
// 처음 자음
|
||||||
|
_cho = ci >= 0 ? ci : 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_jung < 0)
|
||||||
|
{
|
||||||
|
// 초성만 있음 → 이전 초성 출력, 새 초성
|
||||||
|
_sb.Append(Choseong[_cho]);
|
||||||
|
_cho = ci >= 0 ? ci : 0;
|
||||||
|
_jung = -1;
|
||||||
|
_jong = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_jong < 0)
|
||||||
|
{
|
||||||
|
// 초성+중성 → 종성 후보
|
||||||
|
_jong = JongIdx(c);
|
||||||
|
if (_jong < 0) _jong = 0; // 종성에 없는 자음은 그냥 처리
|
||||||
|
if (_jong == 0)
|
||||||
|
{
|
||||||
|
// 종성에 들어갈 수 없는 자음(ㄸ, ㅃ, ㅉ)
|
||||||
|
Flush();
|
||||||
|
_cho = ci >= 0 ? ci : 0;
|
||||||
|
_jung = -1;
|
||||||
|
_jong = -1;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 초성+중성+종성 있음
|
||||||
|
var curJongChar = Jongseong[_jong];
|
||||||
|
if (CompoundJong.TryGetValue((curJongChar, c), out var compJ))
|
||||||
|
{
|
||||||
|
// 복합 종성 가능
|
||||||
|
var cji = JongIdx(compJ);
|
||||||
|
if (cji > 0)
|
||||||
|
{
|
||||||
|
_jong = cji;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 복합 종성 불가 → 현재 음절 확정, 새 초성
|
||||||
|
Flush();
|
||||||
|
_cho = ci >= 0 ? ci : 0;
|
||||||
|
_jung = -1;
|
||||||
|
_jong = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 영타→한글 변환 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static string EngToKorean(string input)
|
||||||
|
{
|
||||||
|
var composer = new HangulComposer();
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
|
foreach (var ch in input)
|
||||||
|
{
|
||||||
|
if (EngToJamo.TryGetValue(ch, out var jamo))
|
||||||
|
{
|
||||||
|
if (composer.Result.Length > 0 || jamo != '\0')
|
||||||
|
{
|
||||||
|
// 현재 변환 중인 조합기에 피드
|
||||||
|
}
|
||||||
|
composer.Feed(jamo);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 변환 불가 문자 → 조합기 플러시 후 그대로
|
||||||
|
var cur = composer.Result;
|
||||||
|
// 지금까지 쌓인 결과 덤프
|
||||||
|
composer.Feed('\0'); // 플러시 트리거 안 됨 → 직접 Flush
|
||||||
|
// 아래 로직: 조합기는 Flush()로만 비워짐
|
||||||
|
sb.Append(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 위 로직을 단순화: 문자별 처리
|
||||||
|
return ConvertEngToKor(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ConvertEngToKor(string input)
|
||||||
|
{
|
||||||
|
// 먼저 자모 문자열로 변환
|
||||||
|
var jamoSeq = new List<char>();
|
||||||
|
foreach (var ch in input)
|
||||||
|
{
|
||||||
|
if (EngToJamo.TryGetValue(ch, out var jamo))
|
||||||
|
jamoSeq.Add(jamo);
|
||||||
|
else
|
||||||
|
jamoSeq.Add(ch); // 변환 불가 문자는 그대로
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자모 시퀀스를 한글 음절로 조합
|
||||||
|
var result = new StringBuilder();
|
||||||
|
var i = 0;
|
||||||
|
while (i < jamoSeq.Count)
|
||||||
|
{
|
||||||
|
var ch = jamoSeq[i];
|
||||||
|
|
||||||
|
// 변환 불가 문자 (공백, 숫자, 특수문자 등)
|
||||||
|
if (!IsKorJamo(ch))
|
||||||
|
{
|
||||||
|
result.Append(ch);
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자모 덩어리 추출
|
||||||
|
var jamoBlock = new List<char>();
|
||||||
|
var j = i;
|
||||||
|
while (j < jamoSeq.Count && IsKorJamo(jamoSeq[j]))
|
||||||
|
jamoBlock.Add(jamoSeq[j++]);
|
||||||
|
|
||||||
|
// 자모 블록을 한글로 조합
|
||||||
|
result.Append(ComposeHangul(jamoBlock));
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsKorJamo(char c)
|
||||||
|
{
|
||||||
|
return (c >= 'ㄱ' && c <= 'ㅎ') || (c >= 'ㅏ' && c <= 'ㅣ');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ComposeHangul(List<char> jamos)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
var idx = 0;
|
||||||
|
|
||||||
|
while (idx < jamos.Count)
|
||||||
|
{
|
||||||
|
var c = jamos[idx];
|
||||||
|
|
||||||
|
if (IsVowel(c))
|
||||||
|
{
|
||||||
|
// 단독 모음 → ㅇ + 모음
|
||||||
|
sb.Append(MakeSyllable(ChoIdx('ㅇ'), JungIdx(c), 0));
|
||||||
|
idx++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자음: 초성 후보
|
||||||
|
var cho = c;
|
||||||
|
var choI = ChoIdx(cho);
|
||||||
|
if (choI < 0) { sb.Append(c); idx++; continue; }
|
||||||
|
idx++;
|
||||||
|
|
||||||
|
if (idx >= jamos.Count || IsConsonantOnly(jamos[idx]))
|
||||||
|
{
|
||||||
|
// 단독 초성
|
||||||
|
sb.Append(cho);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 중성
|
||||||
|
var v1 = jamos[idx];
|
||||||
|
var jungI = JungIdx(v1);
|
||||||
|
if (jungI < 0) { sb.Append(cho); continue; }
|
||||||
|
idx++;
|
||||||
|
|
||||||
|
// 복합 모음 시도
|
||||||
|
if (idx < jamos.Count && IsVowel(jamos[idx]))
|
||||||
|
{
|
||||||
|
if (CompoundVowel.TryGetValue((v1, jamos[idx]), out var cv))
|
||||||
|
{
|
||||||
|
jungI = JungIdx(cv);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 종성 후보
|
||||||
|
if (idx >= jamos.Count)
|
||||||
|
{
|
||||||
|
sb.Append(MakeSyllable(choI, jungI, 0));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var next = jamos[idx];
|
||||||
|
if (IsVowel(next))
|
||||||
|
{
|
||||||
|
sb.Append(MakeSyllable(choI, jungI, 0));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 종성 자음
|
||||||
|
var jongI = JongIdx(next);
|
||||||
|
if (jongI <= 0)
|
||||||
|
{
|
||||||
|
sb.Append(MakeSyllable(choI, jungI, 0));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
idx++;
|
||||||
|
|
||||||
|
// 다음 모음 있으면 종성→초성 이동
|
||||||
|
if (idx < jamos.Count && IsVowel(jamos[idx]))
|
||||||
|
{
|
||||||
|
// 복합 종성 분리 확인
|
||||||
|
if (idx + 1 < jamos.Count && IsVowel(jamos[idx]))
|
||||||
|
{
|
||||||
|
// 분리 없음: next 자음 → 다음 음절 초성
|
||||||
|
}
|
||||||
|
sb.Append(MakeSyllable(choI, jungI, 0));
|
||||||
|
idx--; // next 자음을 다음 루프에서 초성으로 사용
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 복합 종성 시도
|
||||||
|
if (idx < jamos.Count && !IsVowel(jamos[idx]))
|
||||||
|
{
|
||||||
|
var next2 = jamos[idx];
|
||||||
|
var jongChar = Jongseong[jongI];
|
||||||
|
if (CompoundJong.TryGetValue((jongChar, next2), out var cj))
|
||||||
|
{
|
||||||
|
var cji = JongIdx(cj);
|
||||||
|
if (cji > 0)
|
||||||
|
{
|
||||||
|
// 다음에 모음이 있으면 복합 종성 분리
|
||||||
|
if (idx + 1 < jamos.Count && IsVowel(jamos[idx + 1]))
|
||||||
|
{
|
||||||
|
sb.Append(MakeSyllable(choI, jungI, jongI));
|
||||||
|
// next2는 다음 음절 초성으로
|
||||||
|
idx--; // next2를 다시 처리
|
||||||
|
idx++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
jongI = cji;
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다음에 모음이 있으면 종성→초성
|
||||||
|
if (idx < jamos.Count && IsVowel(jamos[idx]))
|
||||||
|
{
|
||||||
|
sb.Append(MakeSyllable(choI, jungI, 0));
|
||||||
|
idx -= 2;
|
||||||
|
idx++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append(MakeSyllable(choI, jungI, jongI));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 초성으로만 사용 가능한 자음인지 (된소리 = 종성 불가)
|
||||||
|
private static bool IsConsonantOnly(char c)
|
||||||
|
{
|
||||||
|
if (!IsKorJamo(c)) return false;
|
||||||
|
if (IsVowel(c)) return false;
|
||||||
|
return JongIdx(c) <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GetItemsAsync ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = query.Trim();
|
||||||
|
var items = new List<LauncherItem>();
|
||||||
|
|
||||||
|
string inputText;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(q))
|
||||||
|
{
|
||||||
|
// 클립보드에서 읽기
|
||||||
|
string? clipText = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Application.Current.Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
if (Clipboard.ContainsText())
|
||||||
|
clipText = Clipboard.GetText();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(clipText))
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("한/영 타이핑 교정",
|
||||||
|
"fix <영타 텍스트> 또는 클립보드에 텍스트 복사 후 fix 입력",
|
||||||
|
null, null, Symbol: "\uE8AC"));
|
||||||
|
items.Add(new LauncherItem("예: fix gksrmf", "→ 안녕 (영타→한글)", null, null, Symbol: "\uE8AC"));
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
inputText = clipText;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
inputText = q;
|
||||||
|
}
|
||||||
|
|
||||||
|
var converted = ConvertEngToKor(inputText);
|
||||||
|
|
||||||
|
if (converted == inputText)
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("변환할 영타 오류가 없습니다",
|
||||||
|
$"입력: {inputText}", null, null, Symbol: "\uE8AC"));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
converted,
|
||||||
|
$"영타 교정 결과 · Enter: 클립보드 복사",
|
||||||
|
null, ("copy", converted), Symbol: "\uE8AC"));
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"원본: {(inputText.Length > 50 ? inputText[..50] + "…" : inputText)}",
|
||||||
|
"", null, null, Symbol: "\uE8AC"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ExecuteAsync ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (item.Data is ("copy", string text))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
|
||||||
|
NotificationService.Notify("타이핑 교정", "교정 결과를 클립보드에 복사했습니다.");
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
323
src/AxCopilot/Handlers/LeaveHandler.cs
Normal file
323
src/AxCopilot/Handlers/LeaveHandler.cs
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Windows;
|
||||||
|
using AxCopilot.SDK;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
|
||||||
|
namespace AxCopilot.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// L23-2: 연차·휴가 관리. "leave" 프리픽스로 사용합니다.
|
||||||
|
///
|
||||||
|
/// 예: leave → 잔여 연차 현황
|
||||||
|
/// leave set 15 → 연간 연차 일수 설정
|
||||||
|
/// leave use 2026-04-10 → 1일 연차 기록
|
||||||
|
/// leave use 2026-04-10 0.5 → 반차 기록
|
||||||
|
/// leave use 2026-04-10 0.5 반차메모 → 메모 포함
|
||||||
|
/// leave del 2026-04-10 → 기록 삭제
|
||||||
|
/// leave remaining → 잔여 연차
|
||||||
|
/// leave list → 올해 사용 이력
|
||||||
|
/// leave clear → 올해 기록 초기화
|
||||||
|
/// Enter → 저장 실행 또는 복사
|
||||||
|
/// 저장: %APPDATA%\AxCopilot\leave.json
|
||||||
|
/// </summary>
|
||||||
|
public class LeaveHandler : IActionHandler
|
||||||
|
{
|
||||||
|
public string? Prefix => "leave";
|
||||||
|
|
||||||
|
public PluginMetadata Metadata => new(
|
||||||
|
"연차 관리",
|
||||||
|
"연차·휴가 기록 — 설정 · 사용 · 잔여 조회 · 이력",
|
||||||
|
"1.0",
|
||||||
|
"AX");
|
||||||
|
|
||||||
|
private static readonly string DataPath = System.IO.Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
"AxCopilot", "leave.json");
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class LeaveData
|
||||||
|
{
|
||||||
|
[JsonPropertyName("annualDays")] public double AnnualDays { get; set; } = 15;
|
||||||
|
[JsonPropertyName("records")] public List<LeaveRecord> Records { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class LeaveRecord
|
||||||
|
{
|
||||||
|
[JsonPropertyName("date")] public string Date { get; set; } = "";
|
||||||
|
[JsonPropertyName("days")] public double Days { get; set; } = 1;
|
||||||
|
[JsonPropertyName("note")] public string Note { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GetItemsAsync ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = query.Trim();
|
||||||
|
var items = new List<LauncherItem>();
|
||||||
|
var data = LoadData();
|
||||||
|
var year = DateTime.Today.Year;
|
||||||
|
var used = data.Records.Where(r => r.Date.StartsWith(year.ToString())).Sum(r => r.Days);
|
||||||
|
var left = data.AnnualDays - used;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(q))
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"연차 현황 — 연간 {data.AnnualDays}일 사용 {used}일 잔여 {left}일",
|
||||||
|
"leave set N · leave use 날짜 [일수] [메모] · leave list · leave remaining",
|
||||||
|
null, null, Symbol: "\uE716"));
|
||||||
|
items.Add(new LauncherItem("leave set <일수>", "연간 연차 총일수 설정", null, null, Symbol: "\uE716"));
|
||||||
|
items.Add(new LauncherItem("leave use <날짜>", "연차 사용 기록 (기본 1일)", null, null, Symbol: "\uE716"));
|
||||||
|
items.Add(new LauncherItem("leave list", "올해 사용 이력 전체 조회", null, null, Symbol: "\uE716"));
|
||||||
|
items.Add(new LauncherItem("leave remaining", "잔여 연차 확인", null, null, Symbol: "\uE716"));
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var sub = parts[0].ToLowerInvariant();
|
||||||
|
|
||||||
|
// set N
|
||||||
|
if (sub == "set")
|
||||||
|
{
|
||||||
|
if (parts.Length >= 2 && double.TryParse(parts[1], out var n) && n > 0)
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"연간 연차를 {n}일로 설정",
|
||||||
|
$"현재: {data.AnnualDays}일 → {n}일 · Enter: 저장",
|
||||||
|
null, ("set", n.ToString(System.Globalization.CultureInfo.InvariantCulture)),
|
||||||
|
Symbol: "\uE716"));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("일수를 입력하세요", "예: leave set 15", null, null, Symbol: "\uE783"));
|
||||||
|
}
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// use 날짜 [일수] [메모]
|
||||||
|
if (sub == "use")
|
||||||
|
{
|
||||||
|
if (parts.Length < 2)
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("날짜를 입력하세요", "예: leave use 2026-04-10", null, null, Symbol: "\uE783"));
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
var dateStr = parts[1];
|
||||||
|
if (!DateOnly.TryParseExact(dateStr, new[] { "yyyy-MM-dd", "yyyy/MM/dd" },
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
System.Globalization.DateTimeStyles.None, out _))
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("날짜 형식 오류", "yyyy-MM-dd 형식으로 입력하세요", null, null, Symbol: "\uE783"));
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
var useDays = 1.0;
|
||||||
|
var note = "";
|
||||||
|
if (parts.Length >= 3 && double.TryParse(parts[2],
|
||||||
|
System.Globalization.NumberStyles.Any,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture, out var pd))
|
||||||
|
{
|
||||||
|
useDays = pd;
|
||||||
|
if (parts.Length >= 4)
|
||||||
|
note = string.Join(" ", parts[3..]);
|
||||||
|
}
|
||||||
|
else if (parts.Length >= 3)
|
||||||
|
{
|
||||||
|
note = string.Join(" ", parts[2..]);
|
||||||
|
}
|
||||||
|
var newLeft = left - useDays;
|
||||||
|
var label = note.Length > 0
|
||||||
|
? $"{dateStr} 연차 {useDays}일 기록 [{note}]"
|
||||||
|
: $"{dateStr} 연차 {useDays}일 기록";
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
label,
|
||||||
|
$"잔여: {newLeft}일 · Enter: 저장",
|
||||||
|
null, ("use", $"{dateStr}|{useDays.ToString(System.Globalization.CultureInfo.InvariantCulture)}|{note}"),
|
||||||
|
Symbol: "\uE716"));
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// del 날짜
|
||||||
|
if (sub == "del")
|
||||||
|
{
|
||||||
|
if (parts.Length < 2)
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("날짜를 입력하세요", "예: leave del 2026-04-10", null, null, Symbol: "\uE783"));
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
var delDate = parts[1];
|
||||||
|
var target = data.Records.FirstOrDefault(r => r.Date == delDate);
|
||||||
|
if (target == null)
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem($"{delDate} 기록 없음", "해당 날짜의 연차 기록이 없습니다", null, null, Symbol: "\uE783"));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"{delDate} 기록 삭제 ({target.Days}일{(target.Note.Length > 0 ? " / " + target.Note : "")})",
|
||||||
|
"Enter: 삭제",
|
||||||
|
null, ("del", delDate), Symbol: "\uE716"));
|
||||||
|
}
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// remaining
|
||||||
|
if (sub == "remaining")
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"잔여 연차: {left}일 (연간 {data.AnnualDays}일 - 사용 {used}일)",
|
||||||
|
"Enter: 클립보드 복사",
|
||||||
|
null, ("copy", $"잔여 연차: {left}일"), Symbol: "\uE716"));
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// list
|
||||||
|
if (sub == "list")
|
||||||
|
{
|
||||||
|
var yearRecords = data.Records
|
||||||
|
.Where(r => r.Date.StartsWith(year.ToString()))
|
||||||
|
.OrderBy(r => r.Date)
|
||||||
|
.ToList();
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"{year}년 연차 사용 이력 — {yearRecords.Count}건 총 {used}일",
|
||||||
|
$"잔여: {left}일", null, null, Symbol: "\uE716"));
|
||||||
|
if (yearRecords.Count == 0)
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("사용 이력 없음", "", null, null, Symbol: "\uE716"));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var r in yearRecords)
|
||||||
|
{
|
||||||
|
var noteStr = r.Note.Length > 0 ? $" [{r.Note}]" : "";
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"{r.Date} {r.Days}일{noteStr}",
|
||||||
|
"Enter: 삭제",
|
||||||
|
null, ("del", r.Date), Symbol: "\uE716"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// clear
|
||||||
|
if (sub == "clear")
|
||||||
|
{
|
||||||
|
var cnt = data.Records.Count(r => r.Date.StartsWith(year.ToString()));
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"{year}년 연차 기록 초기화 ({cnt}건)",
|
||||||
|
"Enter: 확인",
|
||||||
|
null, ("clear", year.ToString()), Symbol: "\uE716"));
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본 — 안내
|
||||||
|
items.Add(new LauncherItem($"'{q}' — 알 수 없는 명령",
|
||||||
|
"leave set · use · del · remaining · list · clear",
|
||||||
|
null, null, Symbol: "\uE783"));
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ExecuteAsync ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var data = LoadData();
|
||||||
|
|
||||||
|
switch (item.Data)
|
||||||
|
{
|
||||||
|
case ("set", string nStr) when double.TryParse(nStr,
|
||||||
|
System.Globalization.NumberStyles.Any,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture, out var n):
|
||||||
|
data.AnnualDays = n;
|
||||||
|
SaveData(data);
|
||||||
|
NotificationService.Notify("연차 관리", $"연간 연차를 {n}일로 설정했습니다.");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ("use", string payload):
|
||||||
|
{
|
||||||
|
var parts = payload.Split('|');
|
||||||
|
if (parts.Length >= 2 &&
|
||||||
|
double.TryParse(parts[1],
|
||||||
|
System.Globalization.NumberStyles.Any,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture, out var days))
|
||||||
|
{
|
||||||
|
var dateStr = parts[0];
|
||||||
|
var note = parts.Length >= 3 ? parts[2] : "";
|
||||||
|
// 중복 날짜 처리 — 누적
|
||||||
|
var existing = data.Records.FirstOrDefault(r => r.Date == dateStr);
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
existing.Days += days;
|
||||||
|
if (note.Length > 0) existing.Note = note;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
data.Records.Add(new LeaveRecord { Date = dateStr, Days = days, Note = note });
|
||||||
|
}
|
||||||
|
data.Records.Sort((a, b) => string.Compare(a.Date, b.Date, StringComparison.Ordinal));
|
||||||
|
SaveData(data);
|
||||||
|
NotificationService.Notify("연차 관리", $"{dateStr} 연차 {days}일 기록됐습니다.");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case ("del", string delDate):
|
||||||
|
{
|
||||||
|
var removed = data.Records.RemoveAll(r => r.Date == delDate);
|
||||||
|
if (removed > 0)
|
||||||
|
{
|
||||||
|
SaveData(data);
|
||||||
|
NotificationService.Notify("연차 관리", $"{delDate} 기록이 삭제됐습니다.");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case ("clear", string yearStr) when int.TryParse(yearStr, out var y):
|
||||||
|
{
|
||||||
|
var cnt = data.Records.RemoveAll(r => r.Date.StartsWith(y.ToString()));
|
||||||
|
SaveData(data);
|
||||||
|
NotificationService.Notify("연차 관리", $"{y}년 연차 기록 {cnt}건 초기화됐습니다.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case ("copy", string text):
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
|
||||||
|
NotificationService.Notify("연차 관리", "클립보드에 복사했습니다.");
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 저장/불러오기 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static LeaveData LoadData()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!System.IO.File.Exists(DataPath)) return new LeaveData();
|
||||||
|
var json = System.IO.File.ReadAllText(DataPath, System.Text.Encoding.UTF8);
|
||||||
|
return JsonSerializer.Deserialize<LeaveData>(json, JsonOpts) ?? new LeaveData();
|
||||||
|
}
|
||||||
|
catch { return new LeaveData(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SaveData(LeaveData data)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(DataPath)!);
|
||||||
|
System.IO.File.WriteAllText(DataPath,
|
||||||
|
JsonSerializer.Serialize(data, JsonOpts),
|
||||||
|
System.Text.Encoding.UTF8);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
241
src/AxCopilot/Handlers/WorkTimeHandler.cs
Normal file
241
src/AxCopilot/Handlers/WorkTimeHandler.cs
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using AxCopilot.SDK;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
|
||||||
|
namespace AxCopilot.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// L23-3: 근무 시간·급여 계산. "work" 프리픽스로 사용합니다.
|
||||||
|
///
|
||||||
|
/// 예: work 09:00 18:30 → 근무시간·초과근무 (점심 1시간 자동 제외)
|
||||||
|
/// work 09:00 18:30 -30 → 점심 30분 제외
|
||||||
|
/// work 09:00 18:30 -0 → 점심 제외 없음
|
||||||
|
/// work 09:00 18:30 pay 15000 → 급여 함께 계산
|
||||||
|
/// work pay 15000 → 이전 계산 재활용 급여 산출
|
||||||
|
/// work week 45.5 → 주간 근무시간 입력 → 초과 계산
|
||||||
|
/// Enter → 결과 복사
|
||||||
|
/// </summary>
|
||||||
|
public class WorkTimeHandler : IActionHandler
|
||||||
|
{
|
||||||
|
public string? Prefix => "work";
|
||||||
|
|
||||||
|
public PluginMetadata Metadata => new(
|
||||||
|
"근무시간 계산",
|
||||||
|
"출퇴근 시간 입력 → 근무시간·초과근무·급여 계산",
|
||||||
|
"1.0",
|
||||||
|
"AX");
|
||||||
|
|
||||||
|
// 마지막 계산 캐시 (pay 재활용용)
|
||||||
|
private static double _lastWorkedHours;
|
||||||
|
|
||||||
|
// ── 파서 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static bool TryParseTime(string s, out TimeSpan result)
|
||||||
|
{
|
||||||
|
result = default;
|
||||||
|
s = s.Trim();
|
||||||
|
// HH:mm or H:mm
|
||||||
|
if (s.Contains(':'))
|
||||||
|
{
|
||||||
|
var parts = s.Split(':');
|
||||||
|
if (parts.Length == 2 &&
|
||||||
|
int.TryParse(parts[0], out var h) &&
|
||||||
|
int.TryParse(parts[1], out var m) &&
|
||||||
|
h >= 0 && h <= 47 && m >= 0 && m < 60)
|
||||||
|
{
|
||||||
|
result = new TimeSpan(h, m, 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// HHmm (4자리)
|
||||||
|
if (s.Length == 4 &&
|
||||||
|
int.TryParse(s[..2], out var hh) &&
|
||||||
|
int.TryParse(s[2..], out var mm) &&
|
||||||
|
hh >= 0 && hh <= 23 && mm >= 0 && mm < 60)
|
||||||
|
{
|
||||||
|
result = new TimeSpan(hh, mm, 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseWorkTime(string q,
|
||||||
|
out TimeSpan start, out TimeSpan end,
|
||||||
|
out double lunchMinutes, out double payWage)
|
||||||
|
{
|
||||||
|
start = default;
|
||||||
|
end = default;
|
||||||
|
lunchMinutes = 60;
|
||||||
|
payWage = 0;
|
||||||
|
|
||||||
|
var tokens = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (tokens.Length < 2) return false;
|
||||||
|
if (!TryParseTime(tokens[0], out start)) return false;
|
||||||
|
if (!TryParseTime(tokens[1], out end)) return false;
|
||||||
|
|
||||||
|
for (var i = 2; i < tokens.Length; i++)
|
||||||
|
{
|
||||||
|
var t = tokens[i];
|
||||||
|
// -N → 점심 제외 분
|
||||||
|
if (t.StartsWith('-') && double.TryParse(t[1..],
|
||||||
|
System.Globalization.NumberStyles.Any,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture, out var lm))
|
||||||
|
{
|
||||||
|
lunchMinutes = lm;
|
||||||
|
}
|
||||||
|
// pay N
|
||||||
|
else if (t.Equals("pay", StringComparison.OrdinalIgnoreCase) && i + 1 < tokens.Length)
|
||||||
|
{
|
||||||
|
if (double.TryParse(tokens[i + 1],
|
||||||
|
System.Globalization.NumberStyles.Any,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture, out var pw))
|
||||||
|
{
|
||||||
|
payWage = pw;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (double stdPay, double otPay, double total) CalcPay(double wage, double workedHours)
|
||||||
|
{
|
||||||
|
var stdHours = Math.Min(workedHours, 8.0);
|
||||||
|
var otHours = Math.Max(0, workedHours - 8.0);
|
||||||
|
var stdPay = stdHours * wage;
|
||||||
|
var otPay = otHours * wage * 1.5;
|
||||||
|
return (stdPay, otPay, stdPay + otPay);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GetItemsAsync ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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("근무시간 계산기",
|
||||||
|
"work 09:00 18:30 → 근무시간 / work 09:00 18:30 -30 → 점심 30분 / work 09:00 18:30 pay 15000 → 급여",
|
||||||
|
null, null, Symbol: "\uE916"));
|
||||||
|
items.Add(new LauncherItem("work 09:00 18:30", "점심 1시간 자동 제외", null, null, Symbol: "\uE916"));
|
||||||
|
items.Add(new LauncherItem("work 09:00 18:30 -30", "점심 30분 제외", null, null, Symbol: "\uE916"));
|
||||||
|
items.Add(new LauncherItem("work pay 15000", "이전 계산 재활용 급여 산출", null, null, Symbol: "\uE916"));
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokens = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var sub = tokens[0].ToLowerInvariant();
|
||||||
|
|
||||||
|
// pay N → 이전 계산 재활용
|
||||||
|
if (sub == "pay")
|
||||||
|
{
|
||||||
|
if (tokens.Length >= 2 && double.TryParse(tokens[1],
|
||||||
|
System.Globalization.NumberStyles.Any,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture, out var wage))
|
||||||
|
{
|
||||||
|
if (_lastWorkedHours <= 0)
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("이전 계산 없음", "먼저 시간을 계산하세요: work HH:mm HH:mm",
|
||||||
|
null, null, Symbol: "\uE783"));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var (stdPay, otPay, total) = CalcPay(wage, _lastWorkedHours);
|
||||||
|
var result = $"근무 {_lastWorkedHours:F1}시간 | 시급 {wage:#,0}원 | 기본 {stdPay:#,0}원 | 초과 {otPay:#,0}원 | 합계 {total:#,0}원";
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"급여: {total:#,0}원",
|
||||||
|
$"기본 {stdPay:#,0}원 + 초과(1.5배) {otPay:#,0}원 · Enter: 복사",
|
||||||
|
null, ("copy", result), Symbol: "\uE916"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("시급을 입력하세요", "예: work pay 15000", null, null, Symbol: "\uE783"));
|
||||||
|
}
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// week N → 주간 근무시간
|
||||||
|
if (sub == "week")
|
||||||
|
{
|
||||||
|
if (tokens.Length >= 2 && double.TryParse(tokens[1],
|
||||||
|
System.Globalization.NumberStyles.Any,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture, out var weekHours))
|
||||||
|
{
|
||||||
|
var std = 40.0;
|
||||||
|
var ot = Math.Max(0, weekHours - std);
|
||||||
|
var label = $"주간 근무 {weekHours:F1}시간 초과근무 {ot:F1}시간 (기준 {std}h)";
|
||||||
|
items.Add(new LauncherItem(label, "Enter: 복사",
|
||||||
|
null, ("copy", label), Symbol: "\uE916"));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("시간을 입력하세요", "예: work week 45.5", null, null, Symbol: "\uE783"));
|
||||||
|
}
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 시각 파싱 시도
|
||||||
|
if (!TryParseWorkTime(q, out var start, out var end, out var lunch, out var pay))
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("시간 형식 오류",
|
||||||
|
"예: work 09:00 18:30 또는 work 09:00 18:30 -30 pay 15000",
|
||||||
|
null, null, Symbol: "\uE783"));
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 야간 처리
|
||||||
|
if (end <= start) end = end.Add(TimeSpan.FromHours(24));
|
||||||
|
|
||||||
|
var totalSpan = end - start - TimeSpan.FromMinutes(lunch);
|
||||||
|
var workedHours = totalSpan.TotalHours;
|
||||||
|
if (workedHours < 0) workedHours = 0;
|
||||||
|
|
||||||
|
_lastWorkedHours = workedHours;
|
||||||
|
|
||||||
|
var wh = (int)workedHours;
|
||||||
|
var wm = (int)Math.Round((workedHours - wh) * 60);
|
||||||
|
var otHours = Math.Max(0, workedHours - 8.0);
|
||||||
|
|
||||||
|
var summaryLine = $"근무 {workedHours:F1}시간 ({wh}시간 {wm}분) 초과 {otHours:F1}시간";
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"근무시간: {workedHours:F1}시간 ({wh}시간 {wm}분)",
|
||||||
|
$"초과근무: {otHours:F1}시간 · Enter: 복사",
|
||||||
|
null, ("copy", summaryLine), Symbol: "\uE916"));
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"초과근무: {otHours:F1}시간",
|
||||||
|
$"기준 8시간 초과분 / 점심 제외: {lunch}분",
|
||||||
|
null, ("copy", $"초과근무: {otHours:F1}시간"), Symbol: "\uE916"));
|
||||||
|
|
||||||
|
if (pay > 0)
|
||||||
|
{
|
||||||
|
var (stdP, otP, totalP) = CalcPay(pay, workedHours);
|
||||||
|
var payLine = $"급여: {totalP:#,0}원 (기본 {stdP:#,0}원 + 초과 {otP:#,0}원) | 시급 {pay:#,0}원";
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"급여: {totalP:#,0}원",
|
||||||
|
$"기본 {stdP:#,0}원 + 초과(1.5배) {otP:#,0}원 · Enter: 복사",
|
||||||
|
null, ("copy", payLine), Symbol: "\uE916"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ExecuteAsync ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (item.Data is ("copy", string text))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
|
||||||
|
NotificationService.Notify("근무시간", "클립보드에 복사했습니다.");
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user