AX Commander 비교본 런처 기능 대량 이식

변경 목적: Agent Compare 아래 비교본의 개발 문서와 런처 소스를 기준으로 현재 AX Commander에 빠져 있던 신규 런처 기능을 동일한 흐름으로 옮겨, 비교본 수준의 기능 폭을 현재 제품에 반영했습니다.

핵심 수정사항: 비교본의 신규 런처 핸들러 다수를 src/AxCopilot/Handlers로 이식하고 App.xaml.cs 등록 흐름에 연결했습니다. 빠른 링크, 파일 태그, 알림 센터, 포모도로, 파일 브라우저, 핫키 관리, OCR, 세션/스케줄/매크로, Git/정규식/네트워크/압축/해시/UUID/JWT/QR 등 AX Commander 기능을 추가했습니다.

핵심 수정사항: 신규 기능이 실제 동작하도록 AppSettings 확장, SchedulerService/FileTagService/NotificationCenterService/IconCacheService/UrlTemplateEngine/PomodoroService 추가, 배치 이름변경/세션/스케줄/매크로 편집 창 추가, NotificationService와 Symbols 보강, QR/OCR용 csproj 의존성과 Windows 타겟 프레임워크를 반영했습니다.

문서 반영: README.md와 docs/DEVELOPMENT.md에 비교본 기반 런처 기능 이식 이력과 검증 결과를 업데이트했습니다.

검증 결과: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 실행 기준 경고 0개, 오류 0개를 확인했습니다.
This commit is contained in:
2026-04-05 00:59:45 +09:00
parent 0929778ca7
commit 0336904258
115 changed files with 30749 additions and 1 deletions

View File

@@ -0,0 +1,242 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L14-3: 팁·할인·분할 계산기 핸들러. "tip" 프리픽스로 사용합니다.
///
/// 예: tip 50000 → 50,000원에 대한 팁 퍼센트별 계산
/// tip 50000 15 → 15% 팁 계산
/// tip 50000 / 4 → 4명 분할
/// tip 50000 15 4 → 15% 팁 포함 4명 분할
/// tip 50000 off 20 → 20% 할인가 계산
/// tip 50000 vat → 부가가치세 10% 계산
/// Enter → 결과를 클립보드에 복사.
/// </summary>
public class TipHandler : IActionHandler
{
public string? Prefix => "tip";
public PluginMetadata Metadata => new(
"Tip",
"팁·할인·분할 계산기 — 팁 % · 할인 · VAT · 인원 분할",
"1.0",
"AX");
private static readonly int[] DefaultTipRates = [10, 15, 18, 20, 25];
private static readonly int[] DefaultDiscountRates = [5, 10, 15, 20, 30, 50];
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("팁·할인·분할 계산기",
"예: tip 50000 / tip 50000 15 / tip 50000 off 20 / tip 50000 / 4",
null, null, Symbol: "\uE8F0"));
items.Add(new LauncherItem("tip 50000", "50,000원 팁 계산", null, null, Symbol: "\uE8F0"));
items.Add(new LauncherItem("tip 50000 15 4", "15% 팁 + 4명 분할", null, null, Symbol: "\uE8F0"));
items.Add(new LauncherItem("tip 50000 off 20", "20% 할인가", null, null, Symbol: "\uE8F0"));
items.Add(new LauncherItem("tip 50000 vat", "VAT 10% 계산", null, null, Symbol: "\uE8F0"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
// 금액 파싱 (쉼표 제거)
if (!TryParseAmount(parts[0], out var amount))
{
items.Add(new LauncherItem("금액 형식 오류",
"예: tip 50000 또는 tip 50,000", null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 서브커맨드 분기
if (parts.Length >= 2)
{
var sub2 = parts[1].ToLowerInvariant();
// 할인: tip 50000 off 20
if (sub2 is "off" or "discount" or "할인")
{
var rate = parts.Length >= 3 && TryParseAmount(parts[2], out var r) ? r : 10;
items.AddRange(BuildDiscountItems(amount, (double)rate));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// VAT: tip 50000 vat [rate]
if (sub2 is "vat" or "세금" or "tax")
{
var rate = parts.Length >= 3 && TryParseAmount(parts[2], out var r) ? (double)r : 10.0;
items.AddRange(BuildVatItems(amount, rate));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 분할: tip 50000 / 4
if (sub2 == "/" && parts.Length >= 3 && TryParseAmount(parts[2], out var people2))
{
items.AddRange(BuildSplitItems(amount, 0, (int)people2));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 팁%: tip 50000 15 또는 tip 50000 15 4
if (TryParseAmount(parts[1], out var tipRate))
{
var people = parts.Length >= 3 && TryParseAmount(parts[2], out var p) ? (int)p : 1;
items.AddRange(BuildTipItems(amount, (double)tipRate, people));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
}
// 기본: 팁 퍼센트별 목록
items.AddRange(BuildDefaultTipItems(amount));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Tip", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
return Task.CompletedTask;
}
// ── 계산 빌더 ─────────────────────────────────────────────────────────────
private static IEnumerable<LauncherItem> BuildDefaultTipItems(decimal amount)
{
yield return new LauncherItem(
$"원금 {FormatKrw(amount)}",
"팁 퍼센트별 합계",
null, ("copy", FormatKrw(amount)), Symbol: "\uE8F0");
foreach (var rate in DefaultTipRates)
{
var tip = amount * rate / 100;
var total = amount + tip;
yield return new LauncherItem(
$"{rate}% → {FormatKrw(total)}",
$"팁 {FormatKrw(tip)} · 합계 {FormatKrw(total)}",
null, ("copy", FormatKrw(total)), Symbol: "\uE8F0");
}
// 분할 미리보기
yield return new LauncherItem($"2명 분할", FormatKrw(amount / 2), null, ("copy", FormatKrw(amount / 2)), Symbol: "\uE8F0");
yield return new LauncherItem($"4명 분할", FormatKrw(amount / 4), null, ("copy", FormatKrw(amount / 4)), Symbol: "\uE8F0");
}
private static IEnumerable<LauncherItem> BuildTipItems(decimal amount, double tipPct, int people)
{
var tip = amount * (decimal)tipPct / 100;
var total = amount + tip;
var perPerson = people > 1 ? total / people : total;
yield return new LauncherItem(
$"합계 {FormatKrw(total)}",
$"원금 {FormatKrw(amount)} + 팁 {tipPct}% ({FormatKrw(tip)})",
null, ("copy", FormatKrw(total)), Symbol: "\uE8F0");
yield return new LauncherItem("원금", FormatKrw(amount), null, ("copy", FormatKrw(amount)), Symbol: "\uE8F0");
yield return new LauncherItem($"팁 {tipPct}%", FormatKrw(tip), null, ("copy", FormatKrw(tip)), Symbol: "\uE8F0");
yield return new LauncherItem("합계", FormatKrw(total), null, ("copy", FormatKrw(total)), Symbol: "\uE8F0");
if (people > 1)
{
yield return new LauncherItem(
$"{people}명 분할",
$"1인당 {FormatKrw(perPerson)}",
null, ("copy", FormatKrw(perPerson)), Symbol: "\uE8F0");
}
}
private static IEnumerable<LauncherItem> BuildDiscountItems(decimal amount, double discountPct)
{
var discount = amount * (decimal)discountPct / 100;
var discounted = amount - discount;
yield return new LauncherItem(
$"할인가 {FormatKrw(discounted)}",
$"{discountPct}% 할인 (할인액 {FormatKrw(discount)})",
null, ("copy", FormatKrw(discounted)), Symbol: "\uE8F0");
yield return new LauncherItem("원가", FormatKrw(amount), null, ("copy", FormatKrw(amount)), Symbol: "\uE8F0");
yield return new LauncherItem($"할인 {discountPct}%", FormatKrw(discount), null, ("copy", FormatKrw(discount)), Symbol: "\uE8F0");
yield return new LauncherItem("할인가", FormatKrw(discounted), null, ("copy", FormatKrw(discounted)), Symbol: "\uE8F0");
// 다른 할인율 비교
yield return new LauncherItem("── 할인율 비교 ──", "", null, null, Symbol: "\uE8F0");
foreach (var rate in DefaultDiscountRates.Where(r => r != (int)discountPct))
{
var d = amount * rate / 100;
yield return new LauncherItem($"{rate}% → {FormatKrw(amount - d)}",
$"할인 {FormatKrw(d)}", null, ("copy", FormatKrw(amount - d)), Symbol: "\uE8F0");
}
}
private static IEnumerable<LauncherItem> BuildVatItems(decimal amount, double vatRate)
{
var vat = amount * (decimal)vatRate / 100;
var withVat = amount + vat;
var exVat = amount / (1 + (decimal)vatRate / 100);
var vatOnly = amount - exVat;
yield return new LauncherItem(
$"VAT 포함 {FormatKrw(withVat)}",
$"VAT {vatRate}% ({FormatKrw(vat)})",
null, ("copy", FormatKrw(withVat)), Symbol: "\uE8F0");
yield return new LauncherItem($"입력액 (VAT 별도)", FormatKrw(amount), null, ("copy", FormatKrw(amount)), Symbol: "\uE8F0");
yield return new LauncherItem($"VAT {vatRate}%", FormatKrw(vat), null, ("copy", FormatKrw(vat)), Symbol: "\uE8F0");
yield return new LauncherItem("VAT 포함 합계", FormatKrw(withVat), null, ("copy", FormatKrw(withVat)), Symbol: "\uE8F0");
yield return new LauncherItem("── 역산 (VAT 포함가 입력 시) ──", "", null, null, Symbol: "\uE8F0");
yield return new LauncherItem("공급가액 (VAT 제외)", $"{FormatKrw(exVat)}", null, ("copy", FormatKrw(exVat)), Symbol: "\uE8F0");
yield return new LauncherItem("VAT 금액", $"{FormatKrw(vatOnly)}", null, ("copy", FormatKrw(vatOnly)), Symbol: "\uE8F0");
}
private static IEnumerable<LauncherItem> BuildSplitItems(decimal amount, double tipPct, int people)
{
if (people <= 0) people = 1;
var tip = tipPct > 0 ? amount * (decimal)tipPct / 100 : 0;
var total = amount + tip;
var perPerson = total / people;
var rounded = Math.Ceiling(perPerson / 100) * 100; // 100원 단위 올림
yield return new LauncherItem(
$"{people}명 분할 1인 {FormatKrw(perPerson)}",
$"합계 {FormatKrw(total)}",
null, ("copy", FormatKrw(perPerson)), Symbol: "\uE8F0");
yield return new LauncherItem("합계", FormatKrw(total), null, ("copy", FormatKrw(total)), Symbol: "\uE8F0");
yield return new LauncherItem($"1인 (정확)", FormatKrw(perPerson), null, ("copy", FormatKrw(perPerson)), Symbol: "\uE8F0");
yield return new LauncherItem($"1인 (100원↑)", FormatKrw(rounded), null, ("copy", FormatKrw(rounded)), Symbol: "\uE8F0");
}
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
private static bool TryParseAmount(string s, out decimal result)
{
result = 0;
s = s.Replace(",", "").Replace("원", "").Trim();
return decimal.TryParse(s, System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out result);
}
private static string FormatKrw(decimal amount)
{
if (amount == Math.Floor(amount))
return $"{amount:N0}원";
return $"{amount:N2}원";
}
}