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,288 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L19-1: 공학 계산기 핸들러. "calc" 프리픽스로 사용합니다.
///
/// 예: calc → 사용법 목록
/// calc sin 45 → sin(45°) = 0.7071
/// calc cos 60 → cos(60°) = 0.5
/// calc tan 45 → tan(45°) = 1.0
/// calc sqrt 144 → √144 = 12
/// calc log 1000 → log₁₀(1000) = 3
/// calc ln 2.718 → ln(2.718) ≈ 1.0
/// calc pow 2 10 → 2¹⁰ = 1024
/// calc factorial 10 → 10! = 3628800
/// calc gcd 12 18 → GCD(12,18) = 6
/// calc lcm 4 6 → LCM(4,6) = 12
/// calc pi → π = 3.14159265358979
/// calc e → e = 2.71828182845905
/// calc deg 1.5707 → 라디안 → 도 변환
/// calc rad 90 → 도 → 라디안 변환
/// calc abs -42 → 절댓값
/// calc ceil 3.2 → 올림
/// calc floor 3.8 → 내림
/// calc round 3.567 2 → 반올림 (소수점 자리)
/// Enter → 결과 복사.
/// </summary>
public class CalcHandler : IActionHandler
{
public string? Prefix => "calc";
public PluginMetadata Metadata => new(
"Calc",
"공학 계산기 — sin·cos·log·sqrt·factorial·GCD·LCM 등",
"1.0",
"AX");
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("공학 계산기",
"calc sin/cos/tan/sqrt/log/ln/pow/factorial/gcd/lcm/pi/e/deg/rad/abs/ceil/floor/round",
null, null, Symbol: "\uE8EF"));
items.Add(BuildUsage("삼각함수", "calc sin 45 / calc cos 60 / calc tan 30"));
items.Add(BuildUsage("제곱근·거듭제곱", "calc sqrt 144 / calc pow 2 10"));
items.Add(BuildUsage("로그", "calc log 1000 / calc ln 2.718"));
items.Add(BuildUsage("팩토리얼", "calc factorial 12"));
items.Add(BuildUsage("GCD · LCM", "calc gcd 12 18 / calc lcm 4 6"));
items.Add(BuildUsage("상수", "calc pi / calc e"));
items.Add(BuildUsage("단위 변환", "calc deg 1.5707 / calc rad 90"));
items.Add(BuildUsage("기타", "calc abs -42 / calc ceil 3.2 / calc floor 3.8 / calc round 3.567 2"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var fn = parts[0].ToLowerInvariant();
// 단순 상수
if (fn is "pi") { var v = Math.PI; return Result(items, "π (Pi)", v, "π"); }
if (fn is "e") { var v = Math.E; return Result(items, "e (자연상수)", v, "e"); }
if (fn is "phi") { var v = 1.6180339887; return Result(items, "φ (황금비)", v, "φ"); }
// 단일 인수 함수
if (parts.Length >= 2 && double.TryParse(parts[1],
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var a))
{
switch (fn)
{
case "sin":
return Result(items, $"sin({a}°)", Math.Sin(DegToRad(a)), "sin");
case "cos":
return Result(items, $"cos({a}°)", Math.Cos(DegToRad(a)), "cos");
case "tan":
if (Math.Abs(a % 180 - 90) < 1e-9)
{
items.Add(ErrorItem("tan(90°±n·180°)는 정의되지 않습니다 (무한대)"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
return Result(items, $"tan({a}°)", Math.Tan(DegToRad(a)), "tan");
case "asin":
if (a < -1 || a > 1) { items.Add(ErrorItem("asin 입력값은 -1 ~ 1 범위여야 합니다")); break; }
return Result(items, $"asin({a}) °", RadToDeg(Math.Asin(a)), "asin", isAngle: true);
case "acos":
if (a < -1 || a > 1) { items.Add(ErrorItem("acos 입력값은 -1 ~ 1 범위여야 합니다")); break; }
return Result(items, $"acos({a}) °", RadToDeg(Math.Acos(a)), "acos", isAngle: true);
case "atan":
return Result(items, $"atan({a}) °", RadToDeg(Math.Atan(a)), "atan", isAngle: true);
case "sqrt":
if (a < 0) { items.Add(ErrorItem("음수의 실수 제곱근은 정의되지 않습니다")); break; }
return Result(items, $"√{a}", Math.Sqrt(a), "sqrt");
case "cbrt":
return Result(items, $"∛{a}", Math.Cbrt(a), "cbrt");
case "log":
if (a <= 0) { items.Add(ErrorItem("log 입력값은 양수여야 합니다")); break; }
return Result(items, $"log₁₀({a})", Math.Log10(a), "log10");
case "log2":
if (a <= 0) { items.Add(ErrorItem("log2 입력값은 양수여야 합니다")); break; }
return Result(items, $"log₂({a})", Math.Log2(a), "log2");
case "ln":
if (a <= 0) { items.Add(ErrorItem("ln 입력값은 양수여야 합니다")); break; }
return Result(items, $"ln({a})", Math.Log(a), "ln");
case "exp":
return Result(items, $"e^{a}", Math.Exp(a), "exp");
case "factorial":
{
var n = (long)Math.Round(a);
if (n < 0 || n > 20) { items.Add(ErrorItem("팩토리얼은 0~20 범위만 지원합니다")); break; }
var fac = Factorial(n);
items.Add(new LauncherItem($"{n}! = {fac:N0}", $"팩토리얼 · Enter 복사",
null, ("copy", $"{fac}"), Symbol: "\uE8EF"));
items.Add(new LauncherItem("결과", $"{fac}", null, ("copy", $"{fac}"), Symbol: "\uE8EF"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
case "abs":
return Result(items, $"|{a}|", Math.Abs(a), "abs");
case "ceil":
return Result(items, $"⌈{a}⌉", Math.Ceiling(a), "ceil");
case "floor":
return Result(items, $"⌊{a}⌋", Math.Floor(a), "floor");
case "round":
{
int decimals = 0;
if (parts.Length >= 3 && int.TryParse(parts[2], out var d)) decimals = Math.Clamp(d, 0, 15);
var rounded = Math.Round(a, decimals, MidpointRounding.AwayFromZero);
return Result(items, $"round({a}, {decimals})", rounded, "round");
}
case "deg":
return Result(items, $"{a} rad → °", RadToDeg(a), "deg", isAngle: true);
case "rad":
return Result(items, $"{a}° → rad", DegToRad(a), "rad");
case "sign":
items.Add(new LauncherItem($"sign({a}) = {Math.Sign(a)}",
a > 0 ? "양수" : a < 0 ? "음수" : "0",
null, ("copy", $"{Math.Sign(a)}"), Symbol: "\uE8EF"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
}
// 이인수 함수
if (parts.Length >= 3 &&
double.TryParse(parts[1], System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var x) &&
double.TryParse(parts[2], System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var y))
{
switch (fn)
{
case "pow":
return Result(items, $"{x}^{y}", Math.Pow(x, y), "pow");
case "log":
if (x <= 0 || y <= 0 || y == 1) { items.Add(ErrorItem("밑과 진수는 양수, 밑 ≠ 1 이어야 합니다")); break; }
return Result(items, $"log_({x}) {y}", Math.Log(y, x), "log");
case "atan2":
return Result(items, $"atan2({x},{y}) °", RadToDeg(Math.Atan2(x, y)), "atan2", isAngle: true);
case "hypot":
return Result(items, $"hypot({x},{y})", Math.Sqrt(x * x + y * y), "hypot");
case "gcd":
{
var ga = (long)Math.Abs(Math.Round(x));
var gb = (long)Math.Abs(Math.Round(y));
var gv = GcdLong(ga, gb);
items.Add(new LauncherItem($"GCD({ga}, {gb}) = {gv}", "최대공약수 · Enter 복사",
null, ("copy", $"{gv}"), Symbol: "\uE8EF"));
items.Add(new LauncherItem("결과", $"{gv}", null, ("copy", $"{gv}"), Symbol: "\uE8EF"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
case "lcm":
{
var la = (long)Math.Abs(Math.Round(x));
var lb = (long)Math.Abs(Math.Round(y));
var g = GcdLong(la, lb);
var lv = g == 0 ? 0 : la / g * lb;
items.Add(new LauncherItem($"LCM({la}, {lb}) = {lv}", "최소공배수 · Enter 복사",
null, ("copy", $"{lv}"), Symbol: "\uE8EF"));
items.Add(new LauncherItem("결과", $"{lv}", null, ("copy", $"{lv}"), Symbol: "\uE8EF"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
case "mod":
if (y == 0) { items.Add(ErrorItem("0으로 나눌 수 없습니다")); break; }
return Result(items, $"{x} mod {y}", x % y, "mod");
}
}
// 파싱 실패 안내
items.Add(new LauncherItem($"알 수 없는 함수: '{fn}'",
"calc sin/cos/tan/sqrt/log/ln/pow/factorial/gcd/lcm/abs/ceil/floor/round/deg/rad",
null, null, Symbol: "\uE783"));
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("Calc", "클립보드에 복사했습니다.");
}
catch { }
}
return Task.CompletedTask;
}
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
private static Task<IEnumerable<LauncherItem>> Result(
List<LauncherItem> items, string label, double value, string fn, bool isAngle = false)
{
var formatted = FormatDouble(value);
var extra = isAngle ? " °" : "";
items.Add(new LauncherItem($"{label} = {formatted}{extra}",
$"· Enter 복사", null, ("copy", formatted), Symbol: "\uE8EF"));
items.Add(new LauncherItem("결과", $"{formatted}{extra}", null, ("copy", formatted), Symbol: "\uE8EF"));
// 추가 정보
if (!isAngle && value != 0)
{
items.Add(new LauncherItem("과학적 표기법", $"{value:E6}", null, ("copy", $"{value:E6}"), Symbol: "\uE8EF"));
if (value > 0)
items.Add(new LauncherItem("log₁₀ 값", $"{Math.Log10(value):F6}", null, null, Symbol: "\uE8EF"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
private static string FormatDouble(double v)
{
if (double.IsNaN(v)) return "NaN";
if (double.IsPositiveInfinity(v)) return "+∞";
if (double.IsNegativeInfinity(v)) return "-∞";
// 정수라면 소수점 없이
if (v == Math.Floor(v) && Math.Abs(v) < 1e15)
return $"{(long)v}";
return $"{v:G15}";
}
private static LauncherItem BuildUsage(string title, string example) =>
new(title, example, null, null, Symbol: "\uE8EF");
private static LauncherItem ErrorItem(string msg) =>
new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783");
private static double DegToRad(double deg) => deg * Math.PI / 180.0;
private static double RadToDeg(double rad) => rad * 180.0 / Math.PI;
private static long Factorial(long n)
{
long r = 1;
for (long i = 2; i <= n; i++) r *= i;
return r;
}
private static long GcdLong(long a, long b) => b == 0 ? a : GcdLong(b, a % b);
}