diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md index 9c5a385..3b9f962 100644 --- a/docs/LAUNCHER_ROADMAP.md +++ b/docs/LAUNCHER_ROADMAP.md @@ -373,3 +373,16 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label | L18-2 | **텍스트 케이스 변환기** ✅ | `text` 프리픽스. 클립보드 자동 읽기. 13가지 케이스 일괄 표시: camelCase·PascalCase·snake_case·SCREAMING_SNAKE·kebab-case·URL slug·dot.case·UPPER·lower·Title·Sentence·뒤집기·trim. 인라인 입력(`text camel hello world`) 지원. Tokenize(): camelCase 경계 분리 + 구분자 정규화. ToSlug(): 유니코드 정규화(NFC→ASCII). [GeneratedRegex] 소스 생성기 | 높음 | | L18-3 | **화면 비율·해상도 계산기** ✅ | `aspect` 프리픽스. 9개 비율 프리셋 내장(16:9·4:3·21:9·1:1·9:16·3:2·2:1·5:4·2.35:1). `aspect 1920 1080` → GCD 약분 비율 계산·MP 표시. `aspect 16:9 1280` 너비 기준 높이 계산. `aspect 16:9 h 720` 높이 기준 너비 계산. `aspect crop 1920 1080 4:3` 크롭 영역+FFmpeg crop 파라미터. 소수 비율(2.35:1) 지원 | 높음 | | L18-4 | **IT·개발 약어 사전** ✅ | `abbr` 프리픽스. 150개+ 내장 약어(웹/네트워크·개발·DB·보안·클라우드·AI·데이터형식·협업 8개 카테고리). 정확 일치 → 약어/원문/설명/카테고리 상세 표시. 부분 일치 → 목록 표시. `abbr 클라우드` 카테고리별 필터. `abbr jwt` → JWT 상세. API/CRUD/REST/JWT/MCP/SOLID/CAP/ACID/OWASP 등 포함 | 높음 | + +--- + +## Phase L19 — Calc·Timer·IP·NPM 도구 (v2.1.0) ✅ 완료 + +> **방향**: 개발자·네트워크 실용 계산 도구 강화 — 공학 계산기, 백그라운드 타이머, IP 분석, 패키지 매니저 명령어 생성. + +| # | 기능 | 설명 | 우선순위 | +|---|------|------|----------| +| L19-1 | **공학 계산기** ✅ | `calc` 프리픽스. 삼각함수(sin·cos·tan·asin·acos·atan), 역삼각, 제곱근(sqrt·cbrt), 로그(log·log2·ln·exp), 거듭제곱(pow), 팩토리얼(0~20), GCD·LCM, 절댓값·올림·내림·반올림, 라디안↔도 변환, 상수(π·e·φ). 결과에 과학적 표기법·log₁₀ 부가정보 동시 표시. `calc sin 45` → 0.7071 형식 | 높음 | +| L19-2 | **타이머·알람** ✅ | `timer` 프리픽스. `timer 30`(초)·`timer 5m`(분)·`timer 1h30m`(시간+분) 형식 파싱. `timer stop`·`timer stop ` 특정 타이머 취소. 정적 타이머 레지스트리로 복수 타이머 동시 운영. `Task.Delay` 백그라운드 실행 + `NotificationService.Notify`로 완료 알림. 실행 중 목록 및 남은 시간 실시간 표시 | 높음 | +| L19-3 | **IP 주소 유틸리티** ✅ | `ip` 프리픽스. `ip my` → NetworkInterface 전 어댑터 IPv4·마스크·게이트웨이. `ip 192.168.1.1` → 분류(사설/공인/루프백/APIPA/멀티캐스트)·클래스(A~E)·이진·16진·정수 변환. `ip 10.0.0.0/8` CIDR → 네트워크·브로드캐스트·와일드카드·호스트 범위·수. `ip range ` IP 범위 계산. `ip bin/hex/int` 표현 변환. `ip from ` 정수→IP | 높음 | +| L19-4 | **npm/yarn/pnpm 명령어 생성기** ✅ | `npm` 프리픽스. `npm install ` → npm·yarn·pnpm 3종 설치 명령 동시 표시(일반/devDependencies/전역). init·uninstall·run·build·test·update·list·audit·publish·scripts·global·clean·ci·lock 서브커맨드 지원. `npm run dev` → yarn dev / pnpm run dev 동등 명령 비교. Enter로 클립보드 복사 | 높음 | diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index c98f70c..e96b821 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -316,6 +316,14 @@ public partial class App : System.Windows.Application commandResolver.RegisterHandler(new AspectHandler()); // L18-4: IT·개발 약어 사전 (prefix=abbr) commandResolver.RegisterHandler(new AbbrHandler()); + // L19-1: 공학 계산기 (prefix=calc) + commandResolver.RegisterHandler(new CalcHandler()); + // L19-2: 타이머·알람 (prefix=timer) + commandResolver.RegisterHandler(new TimerHandler()); + // L19-3: IP 주소 유틸리티 (prefix=ip) + commandResolver.RegisterHandler(new IpInfoHandler()); + // L19-4: npm/yarn/pnpm 명령어 생성기 (prefix=npm) + commandResolver.RegisterHandler(new NpmHandler()); // ─── 플러그인 로드 ──────────────────────────────────────────────────── var pluginHost = new PluginHost(settings, commandResolver); diff --git a/src/AxCopilot/Handlers/CalcHandler.cs b/src/AxCopilot/Handlers/CalcHandler.cs new file mode 100644 index 0000000..72c1bff --- /dev/null +++ b/src/AxCopilot/Handlers/CalcHandler.cs @@ -0,0 +1,288 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// 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 → 결과 복사. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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>(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>(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>(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>(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>(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> Result( + List 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>(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); +} diff --git a/src/AxCopilot/Handlers/IpInfoHandler.cs b/src/AxCopilot/Handlers/IpInfoHandler.cs new file mode 100644 index 0000000..ed77b7f --- /dev/null +++ b/src/AxCopilot/Handlers/IpInfoHandler.cs @@ -0,0 +1,367 @@ +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L19-3: IP 주소 유틸리티 핸들러. "ip" 프리픽스로 사용합니다. +/// +/// 예: ip → 로컬 IP 주소 목록 +/// ip my → 전체 어댑터 IP 목록 +/// ip 192.168.1.100 → IP 분류·타입·이진 표현 +/// ip 10.0.0.0/8 → CIDR 네트워크 정보 +/// ip 192.168.1.0/24 → 네트워크 주소·브로드캐스트·호스트 범위·수 +/// ip range 192.168.1.1 192.168.1.100 → 범위 내 IP 수 +/// ip bin 192.168.1.1 → 이진 표현 +/// ip hex 192.168.1.1 → 16진수 표현 +/// ip int 192.168.1.1 → 정수(uint32) 표현 +/// ip from 3232235777 → 정수 → IP 주소 +/// Enter → 값 복사. +/// +public class IpInfoHandler : IActionHandler +{ + public string? Prefix => "ip"; + + public PluginMetadata Metadata => new( + "IP", + "IP 주소 유틸리티 — 분류·CIDR·이진·16진·정수 변환", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem("IP 주소 유틸리티", + "ip my / ip 192.168.1.1 / ip 10.0.0.0/8 / ip bin/hex/int ", + null, null, Symbol: "\uE968")); + BuildLocalIpItems(items, brief: true); + return Task.FromResult>(items); + } + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + // ip my + if (sub is "my" or "local" or "내" or "로컬") + { + items.Add(new LauncherItem("로컬 네트워크 어댑터 IP 목록", "", null, null, Symbol: "\uE968")); + BuildLocalIpItems(items, brief: false); + return Task.FromResult>(items); + } + + // ip range + if (sub == "range" && parts.Length >= 3) + { + if (IPAddress.TryParse(parts[1], out var start) && + IPAddress.TryParse(parts[2], out var end) && + start.AddressFamily == AddressFamily.InterNetwork && + end.AddressFamily == AddressFamily.InterNetwork) + { + var s = IpToUint(start); + var e = IpToUint(end); + if (s > e) (s, e) = (e, s); + var count = e - s + 1; + items.Add(new LauncherItem($"{UintToIp(s)} ~ {UintToIp(e)}", + $"IP 수: {count:N0}개", null, null, Symbol: "\uE968")); + items.Add(CopyItem("시작 IP", UintToIp(s))); + items.Add(CopyItem("끝 IP", UintToIp(e))); + items.Add(CopyItem("IP 개수", count.ToString("N0"))); + } + else + { + items.Add(ErrorItem("올바른 IPv4 주소를 입력하세요")); + } + return Task.FromResult>(items); + } + + // ip from + if (sub == "from" && parts.Length >= 2) + { + if (uint.TryParse(parts[1], out var uval)) + { + var ip = UintToIp(uval); + items.Add(new LauncherItem($"{uval} → {ip}", "정수 → IPv4 변환", null, ("copy", ip), Symbol: "\uE968")); + items.Add(CopyItem("IP 주소", ip)); + items.Add(CopyItem("이진", ToBinary(uval))); + items.Add(CopyItem("16진수", $"0x{uval:X8}")); + } + else items.Add(ErrorItem("올바른 32비트 정수를 입력하세요")); + return Task.FromResult>(items); + } + + // ip bin/hex/int + if (sub is "bin" or "binary" or "이진" or "hex" or "int" or "integer") + { + if (parts.Length >= 2 && IPAddress.TryParse(parts[1], out var ip4) && + ip4.AddressFamily == AddressFamily.InterNetwork) + { + BuildConversionItems(items, ip4); + } + else items.Add(ErrorItem("올바른 IPv4 주소를 입력하세요")); + return Task.FromResult>(items); + } + + // CIDR: 10.0.0.0/8 + if (parts[0].Contains('/')) + { + BuildCidrItems(items, parts[0]); + return Task.FromResult>(items); + } + + // 단순 IP 주소 + if (IPAddress.TryParse(parts[0], out var addr)) + { + if (addr.AddressFamily == AddressFamily.InterNetwork) + BuildIpInfoItems(items, addr); + else if (addr.AddressFamily == AddressFamily.InterNetworkV6) + BuildIpv6Items(items, addr); + else + items.Add(ErrorItem("IPv4 또는 IPv6 주소를 입력하세요")); + } + else + { + items.Add(new LauncherItem($"인식할 수 없는 입력: '{parts[0]}'", + "ip 192.168.1.1 / ip 10.0.0.0/8 / ip my / ip bin 1.2.3.4", + null, null, Symbol: "\uE783")); + } + + return Task.FromResult>(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("IP", "클립보드에 복사했습니다."); + } + catch { } + } + return Task.CompletedTask; + } + + // ── 빌더 ──────────────────────────────────────────────────────────────── + + private static void BuildLocalIpItems(List items, bool brief) + { + try + { + var ifaces = NetworkInterface.GetAllNetworkInterfaces() + .Where(n => n.OperationalStatus == OperationalStatus.Up && + n.NetworkInterfaceType != NetworkInterfaceType.Loopback) + .ToList(); + + if (ifaces.Count == 0) + { + items.Add(new LauncherItem("활성 네트워크 어댑터 없음", "", null, null, Symbol: "\uE968")); + return; + } + + foreach (var nic in ifaces) + { + var ipProps = nic.GetIPProperties(); + var ipv4s = ipProps.UnicastAddresses + .Where(u => u.Address.AddressFamily == AddressFamily.InterNetwork) + .ToList(); + + if (ipv4s.Count == 0) continue; + + if (!brief) + items.Add(new LauncherItem($"── {nic.Name} ──", + $"{nic.Description} ({nic.Speed / 1_000_000} Mbps)", null, null, Symbol: "\uE968")); + + foreach (var uni in ipv4s) + { + var mask = uni.IPv4Mask?.ToString() ?? ""; + var cidr = MaskToCidr(uni.IPv4Mask); + var label = brief ? uni.Address.ToString() : $"{uni.Address}/{cidr}"; + items.Add(new LauncherItem(label, + brief ? nic.Name : $"마스크: {mask} CIDR: /{cidr} ({ClassifyIp(uni.Address)})", + null, ("copy", uni.Address.ToString()), Symbol: "\uE968")); + } + + if (!brief) + { + var gateways = ipProps.GatewayAddresses + .Where(g => g.Address.AddressFamily == AddressFamily.InterNetwork) + .Select(g => g.Address.ToString()).ToList(); + if (gateways.Count > 0) + items.Add(new LauncherItem("게이트웨이", + string.Join(", ", gateways), null, ("copy", gateways[0]), Symbol: "\uE968")); + } + } + } + catch (Exception ex) + { + items.Add(ErrorItem($"네트워크 정보 조회 오류: {ex.Message}")); + } + } + + private static void BuildIpInfoItems(List items, IPAddress ip) + { + var type = ClassifyIp(ip); + var uval = IpToUint(ip); + items.Add(new LauncherItem($"{ip} ({type})", + $"클래스: {GetClass(uval)} · Enter 복사", null, ("copy", ip.ToString()), Symbol: "\uE968")); + items.Add(CopyItem("IP 주소", ip.ToString())); + items.Add(CopyItem("분류", type)); + items.Add(CopyItem("클래스", GetClass(uval))); + items.Add(CopyItem("이진", ToBinary(uval))); + items.Add(CopyItem("16진수", $"0x{uval:X8}")); + items.Add(CopyItem("정수", uval.ToString())); + BuildConversionItems(items, ip); + } + + private static void BuildConversionItems(List items, IPAddress ip) + { + var uval = IpToUint(ip); + var bin = ToBinary(uval); + items.Add(new LauncherItem("── 변환 ──", "", null, null, Symbol: "\uE968")); + items.Add(CopyItem("이진 (32bit)", bin)); + items.Add(CopyItem("이진 (점구분)", ToBinaryDotted(uval))); + items.Add(CopyItem("16진수", $"0x{uval:X8}")); + items.Add(CopyItem("정수 (uint32)", uval.ToString())); + } + + private static void BuildIpv6Items(List items, IPAddress ip) + { + items.Add(new LauncherItem($"{ip}", "IPv6 주소", null, ("copy", ip.ToString()), Symbol: "\uE968")); + items.Add(CopyItem("전체 표기", ip.ToString())); + var expanded = ExpandIpv6(ip); + if (expanded != ip.ToString()) + items.Add(CopyItem("확장 표기", expanded)); + } + + private static void BuildCidrItems(List items, string cidr) + { + var slash = cidr.IndexOf('/'); + if (slash < 0) { items.Add(ErrorItem("올바른 CIDR 형식: 10.0.0.0/8")); return; } + + var ipStr = cidr[..slash]; + var lenStr = cidr[(slash + 1)..]; + + if (!IPAddress.TryParse(ipStr, out var addr) || addr.AddressFamily != AddressFamily.InterNetwork || + !int.TryParse(lenStr, out var prefix) || prefix < 0 || prefix > 32) + { + items.Add(ErrorItem("올바른 IPv4 CIDR을 입력하세요 (예: 192.168.1.0/24)")); + return; + } + + var mask = CidrToMask(prefix); + var maskIp = UintToIp(mask); + var wildcard = ~mask; + var network = IpToUint(addr) & mask; + var broadcast = network | wildcard; + var hostCount = prefix < 31 ? (broadcast - network - 1) : (broadcast - network + 1); + var firstHost = prefix < 31 ? network + 1 : network; + var lastHost = prefix < 31 ? broadcast - 1 : broadcast; + + items.Add(new LauncherItem($"{cidr} → {IpToHostCount(prefix)}", + $"네트워크: {UintToIp(network)} 브로드캐스트: {UintToIp(broadcast)}", null, null, Symbol: "\uE968")); + items.Add(CopyItem("네트워크 주소", UintToIp(network))); + items.Add(CopyItem("브로드캐스트 주소", UintToIp(broadcast))); + items.Add(CopyItem("서브넷 마스크", maskIp.ToString())); + items.Add(CopyItem("와일드카드 마스크", UintToIp(wildcard))); + items.Add(CopyItem("첫 번째 호스트", UintToIp(firstHost))); + items.Add(CopyItem("마지막 호스트", UintToIp(lastHost))); + items.Add(CopyItem("호스트 수", $"{hostCount:N0}")); + items.Add(CopyItem("CIDR 표기", $"{UintToIp(network)}/{prefix}")); + items.Add(CopyItem("이진 마스크", ToBinaryDotted(mask))); + } + + // ── 헬퍼 ──────────────────────────────────────────────────────────────── + + private static string ClassifyIp(IPAddress ip) + { + var u = IpToUint(ip); + if (u == 0x7F000001u) return "루프백 (localhost)"; + if ((u & 0xFF000000u) == 0x7F000000u) return "루프백"; + if ((u & 0xFF000000u) == 0x0A000000u) return "사설 (Class A: 10.x.x.x)"; + if ((u & 0xFFF00000u) == 0xAC100000u) return "사설 (Class B: 172.16-31.x.x)"; + if ((u & 0xFFFF0000u) == 0xC0A80000u) return "사설 (Class C: 192.168.x.x)"; + if ((u & 0xFFFF0000u) == 0xA9FE0000u) return "링크-로컬 (APIPA: 169.254.x.x)"; + if ((u & 0xF0000000u) == 0xE0000000u) return "멀티캐스트 (224.x.x.x)"; + if (u == 0xFFFFFFFFu) return "브로드캐스트"; + if (u == 0u) return "0.0.0.0 (비지정)"; + return "공인 IP"; + } + + private static string GetClass(uint u) + { + var b = (u >> 24) & 0xFF; + return b switch + { + <= 127 => "Class A", + <= 191 => "Class B", + <= 223 => "Class C", + <= 239 => "Class D (멀티캐스트)", + _ => "Class E (예약)" + }; + } + + private static uint IpToUint(IPAddress ip) + { + var bytes = ip.GetAddressBytes(); + if (bytes.Length != 4) return 0; + return (uint)((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]); + } + + private static string UintToIp(uint u) => + $"{(u >> 24) & 0xFF}.{(u >> 16) & 0xFF}.{(u >> 8) & 0xFF}.{u & 0xFF}"; + + private static string ToBinary(uint u) => Convert.ToString((long)u, 2).PadLeft(32, '0'); + + private static string ToBinaryDotted(uint u) + { + var b = ToBinary(u); + return $"{b[..8]}.{b[8..16]}.{b[16..24]}.{b[24..]}"; + } + + private static uint CidrToMask(int prefix) => + prefix == 0 ? 0u : (0xFFFFFFFFu << (32 - prefix)); + + private static int MaskToCidr(IPAddress? mask) + { + if (mask == null) return 0; + var u = IpToUint(mask); + int c = 0; + while ((u & 0x80000000u) != 0) { c++; u <<= 1; } + return c; + } + + private static string IpToHostCount(int prefix) => + prefix switch + { + 32 => "1 IP (단일 호스트)", + 31 => "2 IP (P2P 링크)", + 30 => "4 IP (2 호스트)", + _ => $"{(1L << (32 - prefix)):N0} IP ({(1L << (32 - prefix)) - 2:N0} 호스트)" + }; + + private static string ExpandIpv6(IPAddress ip) + { + var bytes = ip.GetAddressBytes(); + var groups = new string[8]; + for (int i = 0; i < 8; i++) + groups[i] = $"{bytes[i * 2]:X2}{bytes[i * 2 + 1]:X2}"; + return string.Join(":", groups).ToLowerInvariant(); + } + + private static LauncherItem CopyItem(string label, string value) => + new(label, value, null, ("copy", value), Symbol: "\uE968"); + + private static LauncherItem ErrorItem(string msg) => + new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783"); +} diff --git a/src/AxCopilot/Handlers/NpmHandler.cs b/src/AxCopilot/Handlers/NpmHandler.cs new file mode 100644 index 0000000..db6cd49 --- /dev/null +++ b/src/AxCopilot/Handlers/NpmHandler.cs @@ -0,0 +1,342 @@ +using System.Diagnostics; +using System.IO; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L19-4: npm/yarn/pnpm 패키지 매니저 명령어 생성·실행 핸들러. "npm" 프리픽스로 사용합니다. +/// +/// 예: npm → 자주 쓰는 명령어 목록 +/// npm init → npm/yarn/pnpm init 명령 비교 +/// npm install lodash → npm/yarn/pnpm install lodash 명령 비교 +/// npm i lodash → install 단축키 +/// npm run dev → npm/yarn/pnpm run dev +/// npm uninstall lodash → npm/yarn/pnpm uninstall +/// npm update → 패키지 업데이트 명령 +/// npm list → 패키지 목록 명령 +/// npm audit → 보안 감사 명령 +/// npm publish → 배포 명령 +/// npm scripts → package.json scripts 확인 방법 +/// npm global → 전역 패키지 목록 명령 +/// npm clean → 캐시·node_modules 정리 명령 +/// Enter → 클립보드 복사 (또는 터미널에서 실행). +/// +public class NpmHandler : IActionHandler +{ + public string? Prefix => "npm"; + + public PluginMetadata Metadata => new( + "NPM", + "npm/yarn/pnpm 명령어 생성기 — install·run·audit·publish·clean", + "1.0", + "AX"); + + private record PmCmd(string Manager, string Cmd, string Description); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem("npm/yarn/pnpm 명령어 생성기", + "npm install / run / init / build / test / audit / publish / clean …", + null, null, Symbol: "\uE756")); + AddCommandGroups(items); + return Task.FromResult>(items); + } + + var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + var arg = parts.Length > 1 ? parts[1].Trim() : ""; + + // 단축키 정규화 + sub = sub switch + { + "i" => "install", + "un" => "uninstall", + "rm" => "uninstall", + "up" => "update", + "ls" => "list", + _ => sub + }; + + switch (sub) + { + case "init": + AddSection(items, "프로젝트 초기화", [ + new("npm", "npm init -y", "기본값으로 package.json 생성"), + new("yarn", "yarn init -y", "기본값으로 package.json 생성"), + new("pnpm", "pnpm init", "기본값으로 package.json 생성"), + ]); + break; + + case "install" when !string.IsNullOrWhiteSpace(arg): + AddSection(items, $"패키지 설치: {arg}", [ + new("npm", $"npm install {arg}", $"npm으로 {arg} 설치"), + new("yarn", $"yarn add {arg}", $"yarn으로 {arg} 설치"), + new("pnpm", $"pnpm add {arg}", $"pnpm으로 {arg} 설치"), + ]); + AddSection(items, $"개발 의존성으로 설치: {arg}", [ + new("npm", $"npm install {arg} --save-dev", "devDependencies에 추가"), + new("yarn", $"yarn add {arg} --dev", "devDependencies에 추가"), + new("pnpm", $"pnpm add {arg} --save-dev", "devDependencies에 추가"), + ]); + AddSection(items, $"전역 설치: {arg}", [ + new("npm", $"npm install -g {arg}", "전역 설치"), + new("yarn", $"yarn global add {arg}", "전역 설치"), + new("pnpm", $"pnpm add -g {arg}", "전역 설치"), + ]); + break; + + case "install": + AddSection(items, "의존성 설치 (node_modules)", [ + new("npm", "npm install", "package.json 의존성 모두 설치"), + new("yarn", "yarn install", "package.json 의존성 모두 설치"), + new("pnpm", "pnpm install", "package.json 의존성 모두 설치"), + ]); + AddSection(items, "프로덕션 의존성만", [ + new("npm", "npm install --production", "--production 플래그"), + new("yarn", "yarn install --production", "--production 플래그"), + new("pnpm", "pnpm install --prod", "--prod 플래그"), + ]); + break; + + case "uninstall" when !string.IsNullOrWhiteSpace(arg): + AddSection(items, $"패키지 제거: {arg}", [ + new("npm", $"npm uninstall {arg}", $"npm으로 {arg} 제거"), + new("yarn", $"yarn remove {arg}", $"yarn으로 {arg} 제거"), + new("pnpm", $"pnpm remove {arg}", $"pnpm으로 {arg} 제거"), + ]); + break; + + case "run" when !string.IsNullOrWhiteSpace(arg): + AddSection(items, $"스크립트 실행: {arg}", [ + new("npm", $"npm run {arg}", $"npm run {arg}"), + new("yarn", $"yarn {arg}", $"yarn {arg}"), + new("pnpm", $"pnpm run {arg}", $"pnpm run {arg}"), + ]); + break; + + case "start": + case "run": + AddSection(items, "주요 스크립트", [ + new("npm", "npm start", "start 스크립트 실행"), + new("npm", "npm run dev", "dev 스크립트 실행"), + new("npm", "npm run build", "build 스크립트 실행"), + new("npm", "npm test", "test 스크립트 실행"), + ]); + AddSection(items, "yarn 동등 명령", [ + new("yarn", "yarn start", "start"), + new("yarn", "yarn dev", "dev"), + new("yarn", "yarn build", "build"), + new("yarn", "yarn test", "test"), + ]); + AddSection(items, "pnpm 동등 명령", [ + new("pnpm", "pnpm start", "start"), + new("pnpm", "pnpm run dev", "dev"), + new("pnpm", "pnpm run build", "build"), + new("pnpm", "pnpm test", "test"), + ]); + break; + + case "build": + AddSection(items, "빌드", [ + new("npm", "npm run build", "build 스크립트"), + new("yarn", "yarn build", "build 스크립트"), + new("pnpm", "pnpm run build", "build 스크립트"), + ]); + break; + + case "test": + AddSection(items, "테스트", [ + new("npm", "npm test", "test 스크립트"), + new("npm", "npm run test:watch", "테스트 와처"), + new("yarn", "yarn test", "test 스크립트"), + new("pnpm", "pnpm test", "test 스크립트"), + ]); + break; + + case "update" or "upgrade": + AddSection(items, "패키지 업데이트", [ + new("npm", "npm update", "모든 패키지 업데이트"), + new("npm", "npm outdated", "오래된 패키지 확인"), + new("npm", "npx npm-check-updates", "버전 업그레이드 체크"), + new("yarn", "yarn upgrade", "모든 패키지 업그레이드"), + new("pnpm", "pnpm update", "모든 패키지 업데이트"), + ]); + break; + + case "list" or "ls": + AddSection(items, "패키지 목록", [ + new("npm", "npm list", "설치된 패키지 목록"), + new("npm", "npm list --depth=0", "최상위 패키지만"), + new("npm", "npm list -g --depth=0", "전역 패키지 목록"), + new("yarn", "yarn list", "설치된 패키지 목록"), + new("pnpm", "pnpm list", "설치된 패키지 목록"), + ]); + break; + + case "audit": + AddSection(items, "보안 감사", [ + new("npm", "npm audit", "보안 취약점 스캔"), + new("npm", "npm audit fix", "자동 수정"), + new("yarn", "yarn audit", "보안 취약점 스캔"), + new("pnpm", "pnpm audit", "보안 취약점 스캔"), + ]); + break; + + case "publish": + AddSection(items, "패키지 배포", [ + new("npm", "npm publish", "npm 레지스트리에 배포"), + new("npm", "npm publish --access public","공개 패키지로 배포"), + new("npm", "npm version patch", "패치 버전 올리기"), + new("npm", "npm version minor", "마이너 버전 올리기"), + new("npm", "npm version major", "메이저 버전 올리기"), + new("yarn", "yarn publish", "yarn으로 배포"), + new("pnpm", "pnpm publish", "pnpm으로 배포"), + ]); + break; + + case "scripts": + AddSection(items, "package.json 스크립트 확인", [ + new("npm", "npm run", "스크립트 목록 보기"), + new("npm", "cat package.json", "package.json 확인"), + new("yarn", "yarn run", "스크립트 목록 보기"), + new("pnpm", "pnpm run", "스크립트 목록 보기"), + ]); + break; + + case "global": + AddSection(items, "전역 패키지", [ + new("npm", "npm list -g --depth=0", "전역 패키지 목록"), + new("npm", "npm root -g", "전역 node_modules 경로"), + new("yarn", "yarn global list", "전역 패키지 목록"), + new("pnpm", "pnpm list -g", "전역 패키지 목록"), + ]); + break; + + case "clean": + AddSection(items, "캐시 및 모듈 정리", [ + new("npm", "npm cache clean --force", "npm 캐시 삭제"), + new("npm", "Remove-Item -Recurse -Force node_modules", "node_modules 삭제 (PowerShell)"), + new("npm", "rm -rf node_modules && npm install", "재설치 (bash)"), + new("yarn", "yarn cache clean", "yarn 캐시 삭제"), + new("pnpm", "pnpm store prune", "pnpm 스토어 정리"), + ]); + break; + + case "ci": + AddSection(items, "CI/CD용 클린 설치", [ + new("npm", "npm ci", "package-lock.json 기반 클린 설치"), + new("yarn", "yarn install --frozen-lockfile", "lockfile 기반 클린 설치"), + new("pnpm", "pnpm install --frozen-lockfile", "lockfile 기반 클린 설치"), + ]); + break; + + case "lock": + AddSection(items, "lockfile 관리", [ + new("npm", "npm install --package-lock-only", "lockfile만 갱신"), + new("yarn", "yarn import", "package-lock → yarn.lock 변환"), + new("pnpm", "pnpm import", "다른 lockfile → pnpm-lock.yaml 변환"), + ]); + break; + + default: + items.Add(new LauncherItem($"알 수 없는 명령: '{sub}'", + "init · install · uninstall · run · build · test · update · list · audit · publish · clean · global", + null, null, Symbol: "\uE783")); + AddCommandGroups(items); + break; + } + + return Task.FromResult>(items); + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + switch (item.Data) + { + case ("copy", string text): + try + { + System.Windows.Application.Current.Dispatcher.Invoke( + () => Clipboard.SetText(text)); + NotificationService.Notify("NPM", "클립보드에 복사했습니다."); + } + catch { } + break; + + case ("run", string cmd): + RunInTerminal(cmd); + break; + } + return Task.CompletedTask; + } + + // ── 헬퍼 ──────────────────────────────────────────────────────────────── + + private static void AddCommandGroups(List items) + { + var groups = new (string Title, string[] Keys)[] + { + ("초기화·설치", ["npm init", "npm install", "npm install <패키지>"]), + ("개발 워크플로우", ["npm start", "npm run dev", "npm run build", "npm test"]), + ("패키지 관리", ["npm update", "npm uninstall <패키지>", "npm list"]), + ("보안·배포", ["npm audit", "npm audit fix", "npm publish"]), + ("캐시·정리", ["npm cache clean --force", "npm ci"]), + }; + foreach (var (title, keys) in groups) + items.Add(new LauncherItem(title, string.Join(" / ", keys), null, null, Symbol: "\uE756")); + } + + private static void AddSection(List items, string title, PmCmd[] cmds) + { + items.Add(new LauncherItem($"── {title} ──", "", null, null, Symbol: "\uE756")); + foreach (var c in cmds) + { + var icon = c.Manager switch + { + "yarn" => "🧶", + "pnpm" => "📦", + _ => "📦" + }; + items.Add(new LauncherItem(c.Cmd, $"{c.Manager} · {c.Description} · Enter 복사", + null, ("copy", c.Cmd), Symbol: "\uE756")); + } + } + + private static void RunInTerminal(string cmd) + { + try + { + // Windows Terminal 우선 + var wtPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + @"Microsoft\WindowsApps\wt.exe"); + if (File.Exists(wtPath)) + { + Process.Start(new ProcessStartInfo + { + FileName = wtPath, + Arguments = $"cmd /k \"{cmd}\"", + UseShellExecute = true + }); + return; + } + // cmd 폴백 + Process.Start(new ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = $"/k {cmd}", + UseShellExecute = true + }); + } + catch { } + } +} diff --git a/src/AxCopilot/Handlers/TimerHandler.cs b/src/AxCopilot/Handlers/TimerHandler.cs new file mode 100644 index 0000000..4776c20 --- /dev/null +++ b/src/AxCopilot/Handlers/TimerHandler.cs @@ -0,0 +1,270 @@ +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L19-2: 타이머·알람 핸들러. "timer" 프리픽스로 사용합니다. +/// +/// 예: timer → 사용법 + 실행 중인 타이머 목록 +/// timer 30 → 30초 타이머 (Enter로 시작) +/// timer 5m → 5분 타이머 +/// timer 1h30m → 1시간 30분 타이머 +/// timer 2h → 2시간 타이머 +/// timer 10m30s → 10분 30초 타이머 +/// timer stop → 모든 타이머 취소 +/// timer stop → 특정 타이머 취소 +/// Enter → 타이머 시작 (또는 중단). +/// +public class TimerHandler : IActionHandler +{ + public string? Prefix => "timer"; + + public PluginMetadata Metadata => new( + "Timer", + "타이머·알람 — 초/분/시간 단위 백그라운드 타이머", + "1.0", + "AX"); + + // 타이머 레코드 + private record TimerEntry(int Id, string Label, TimeSpan Duration, DateTime StartAt, CancellationTokenSource Cts); + + // 정적 타이머 레지스트리 + private static readonly List _timers = []; + private static readonly object _lock = new(); + private static int _nextId = 1; + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem("타이머·알람", + "timer 30 / timer 5m / timer 1h30m / timer stop", + null, null, Symbol: "\uE916")); + items.Add(new LauncherItem("사용법", "timer <시간> · 예: 30(초) / 5m / 1h30m / 2h", null, null, Symbol: "\uE916")); + AddRunningTimers(items); + return Task.FromResult>(items); + } + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + // stop 명령 + if (sub is "stop" or "cancel" or "취소") + { + lock (_lock) + { + if (_timers.Count == 0) + { + items.Add(new LauncherItem("실행 중인 타이머 없음", "취소할 타이머가 없습니다", + null, null, Symbol: "\uE916")); + return Task.FromResult>(items); + } + + // 특정 ID 취소 + if (parts.Length >= 2 && int.TryParse(parts[1], out var stopId)) + { + var t = _timers.FirstOrDefault(x => x.Id == stopId); + if (t != null) + items.Add(new LauncherItem($"타이머 #{t.Id} '{t.Label}' 취소", + $"Enter로 취소합니다", null, ("stop", stopId.ToString()), Symbol: "\uE916")); + else + items.Add(new LauncherItem($"타이머 #{stopId}를 찾을 수 없습니다", + "timer 명령으로 목록을 확인하세요", null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + // 전체 취소 + items.Add(new LauncherItem($"모든 타이머 취소 ({_timers.Count}개)", + "Enter로 모두 취소합니다", null, ("stop_all", ""), Symbol: "\uE916")); + foreach (var t in _timers) + items.Add(new LauncherItem($" #{t.Id} {t.Label}", + $"시작: {t.StartAt:HH:mm:ss} 남은: {Remaining(t)}", + null, ("stop", t.Id.ToString()), Symbol: "\uE916")); + } + return Task.FromResult>(items); + } + + // 시간 파싱 시도 + if (TryParseTime(sub, out var duration) && duration > TimeSpan.Zero) + { + var label = FormatDuration(duration); + var endTime = DateTime.Now.Add(duration); + items.Add(new LauncherItem($"⏱ {label} 타이머 시작", + $"완료: {endTime:HH:mm:ss} · Enter로 시작", + null, ("start", DurationToSeconds(duration).ToString()), Symbol: "\uE916")); + items.Add(new LauncherItem($"완료 예정", $"{endTime:HH:mm:ss}", null, null, Symbol: "\uE916")); + items.Add(new LauncherItem($"경과 시간", label, null, null, Symbol: "\uE916")); + + // 실행 중 타이머 표시 + AddRunningTimers(items); + } + else + { + items.Add(new LauncherItem($"형식 오류: '{q}'", + "예: timer 30 / timer 5m / timer 1h30m / timer stop", + null, null, Symbol: "\uE783")); + } + + return Task.FromResult>(items); + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + switch (item.Data) + { + case ("start", string secStr) when long.TryParse(secStr, out var sec): + { + var duration = TimeSpan.FromSeconds(sec); + var label = FormatDuration(duration); + var cts = new CancellationTokenSource(); + int id; + lock (_lock) + { + id = _nextId++; + _timers.Add(new TimerEntry(id, label, duration, DateTime.Now, cts)); + } + NotificationService.Notify("Timer", $"⏱ #{id} {label} 타이머 시작됩니다."); + _ = RunTimerAsync(id, label, duration, cts.Token); + break; + } + + case ("stop", string idStr) when int.TryParse(idStr, out var stopId): + { + TimerEntry? entry = null; + lock (_lock) + { + entry = _timers.FirstOrDefault(t => t.Id == stopId); + if (entry != null) _timers.Remove(entry); + } + if (entry != null) + { + entry.Cts.Cancel(); + NotificationService.Notify("Timer", $"⏹ #{entry.Id} '{entry.Label}' 타이머가 취소되었습니다."); + } + break; + } + + case ("stop_all", _): + { + List all; + lock (_lock) + { + all = [.._timers]; + _timers.Clear(); + } + foreach (var t in all) t.Cts.Cancel(); + NotificationService.Notify("Timer", $"⏹ 타이머 {all.Count}개 모두 취소됐습니다."); + break; + } + } + return Task.CompletedTask; + } + + // ── 타이머 실행 ────────────────────────────────────────────────────────── + + private static async Task RunTimerAsync(int id, string label, TimeSpan duration, CancellationToken token) + { + try + { + await Task.Delay(duration, token); + // 완료 — 레지스트리에서 제거 + lock (_lock) { _timers.RemoveAll(t => t.Id == id); } + NotificationService.Notify("⏰ 타이머 완료", $"#{id} {label} 타이머가 종료되었습니다!"); + } + catch (OperationCanceledException) + { + // 취소됨 — 이미 처리됨 + } + } + + // ── 파싱 헬퍼 ──────────────────────────────────────────────────────────── + + private static bool TryParseTime(string s, out TimeSpan ts) + { + ts = TimeSpan.Zero; + s = s.ToLowerInvariant().Trim(); + + // 숫자만 → 초 + if (long.TryParse(s, out var sec)) + { + ts = TimeSpan.FromSeconds(sec); + return true; + } + + // 복합 형식: 1h30m20s + long hours = 0, minutes = 0, seconds = 0; + var rem = s; + bool found = false; + + if (TryExtractUnit(ref rem, 'h', out var h)) { hours = h; found = true; } + if (TryExtractUnit(ref rem, 'm', out var m)) { minutes = m; found = true; } + if (TryExtractUnit(ref rem, 's', out var sv)){ seconds = sv; found = true; } + + if (found && rem.Length == 0) + { + ts = TimeSpan.FromSeconds(hours * 3600 + minutes * 60 + seconds); + return true; + } + + return false; + } + + private static bool TryExtractUnit(ref string s, char unit, out long value) + { + value = 0; + var idx = s.IndexOf(unit); + if (idx <= 0) return false; + var numStr = s[..idx]; + if (!long.TryParse(numStr, out value)) return false; + s = s[(idx + 1)..]; + return true; + } + + private static string FormatDuration(TimeSpan ts) + { + if (ts.TotalSeconds < 60) return $"{(long)ts.TotalSeconds}초"; + if (ts.TotalMinutes < 60) + { + var mins = (long)ts.TotalMinutes; + var secs = (long)(ts.TotalSeconds - mins * 60); + return secs > 0 ? $"{mins}분 {secs}초" : $"{mins}분"; + } + var h = (long)ts.TotalHours; + var m = (long)(ts.TotalMinutes - h * 60); + var s = (long)(ts.TotalSeconds - h * 3600 - m * 60); + var result = $"{h}시간"; + if (m > 0) result += $" {m}분"; + if (s > 0) result += $" {s}초"; + return result; + } + + private static long DurationToSeconds(TimeSpan ts) => (long)ts.TotalSeconds; + + private static string Remaining(TimerEntry t) + { + var elapsed = DateTime.Now - t.StartAt; + var left = t.Duration - elapsed; + if (left <= TimeSpan.Zero) return "완료"; + return FormatDuration(left); + } + + private static void AddRunningTimers(List items) + { + List running; + lock (_lock) { running = [.._timers]; } + if (running.Count == 0) return; + + items.Add(new LauncherItem($"── 실행 중인 타이머 {running.Count}개 ──", "", null, null, Symbol: "\uE916")); + foreach (var t in running) + { + var left = Remaining(t); + items.Add(new LauncherItem($"#{t.Id} {t.Label}", $"남은 시간: {left} · 시작: {t.StartAt:HH:mm:ss}", + null, ("stop", t.Id.ToString()), Symbol: "\uE916")); + } + } +}