[Phase L19] Calc·Timer·IP·NPM 핸들러 4종 추가

CalcHandler (prefix=calc, 170줄):
- 삼각함수: sin·cos·tan·asin·acos·atan (도/라디안 변환 포함)
- 지수·로그: sqrt·cbrt·log·log2·ln·exp·pow
- 정수 연산: factorial(0~20)·GCD·LCM·mod
- 기타: abs·ceil·floor·round·sign·deg·rad·상수(π·e·φ)
- 결과에 과학적 표기법·log₁₀ 부가정보 동시 표시

TimerHandler (prefix=timer, 215줄):
- 파싱 형식: 30(초)·5m(분)·1h30m(복합) 지원
- 정적 레지스트리로 복수 타이머 동시 운영 (ID 기반 취소)
- Task.Delay 백그라운드 + NotificationService.Notify 완료 알림
- timer stop / timer stop <id> 취소 명령
- Lock→object 수정 (.NET 8 호환)

IpInfoHandler (prefix=ip, 260줄):
- ip my: NetworkInterface 전 어댑터 IPv4·마스크·게이트웨이
- ip <addr>: 분류(사설/공인/루프백/APIPA/멀티캐스트)·클래스 A~E·이진·16진·정수
- ip <addr>/prefix: CIDR 네트워크·브로드캐스트·와일드카드·호스트 범위·수
- ip range <s> <e>: IP 범위 계산
- ip bin/hex/int/from 변환 서브커맨드

NpmHandler (prefix=npm, 285줄):
- init·install·uninstall·run·build·test·update·list·audit·publish
- scripts·global·clean·ci·lock 서브커맨드
- npm/yarn/pnpm 3종 동등 명령 동시 표시
- devDependencies·전역 설치 옵션 분리 표시

- 빌드: 경고 0, 오류 0
This commit is contained in:
2026-04-04 16:24:36 +09:00
parent e0548c52a9
commit 498ae50785
6 changed files with 1288 additions and 0 deletions

View File

@@ -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 <id>` 특정 타이머 취소. 정적 타이머 레지스트리로 복수 타이머 동시 운영. `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 <start> <end>` IP 범위 계산. `ip bin/hex/int` 표현 변환. `ip from <uint>` 정수→IP | 높음 |
| L19-4 | **npm/yarn/pnpm 명령어 생성기** ✅ | `npm` 프리픽스. `npm install <pkg>` → 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로 클립보드 복사 | 높음 |

View File

@@ -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);

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);
}

View File

@@ -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;
/// <summary>
/// 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 → 값 복사.
/// </summary>
public class IpInfoHandler : IActionHandler
{
public string? Prefix => "ip";
public PluginMetadata Metadata => new(
"IP",
"IP 주소 유틸리티 — 분류·CIDR·이진·16진·정수 변환",
"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("IP 주소 유틸리티",
"ip my / ip 192.168.1.1 / ip 10.0.0.0/8 / ip bin/hex/int <IP>",
null, null, Symbol: "\uE968"));
BuildLocalIpItems(items, brief: true);
return Task.FromResult<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
}
// ip range <start> <end>
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<IEnumerable<LauncherItem>>(items);
}
// ip from <uint>
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<IEnumerable<LauncherItem>>(items);
}
// ip bin/hex/int <IP>
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<IEnumerable<LauncherItem>>(items);
}
// CIDR: 10.0.0.0/8
if (parts[0].Contains('/'))
{
BuildCidrItems(items, parts[0]);
return Task.FromResult<IEnumerable<LauncherItem>>(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<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("IP", "클립보드에 복사했습니다.");
}
catch { }
}
return Task.CompletedTask;
}
// ── 빌더 ────────────────────────────────────────────────────────────────
private static void BuildLocalIpItems(List<LauncherItem> 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<LauncherItem> 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<LauncherItem> 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<LauncherItem> 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<LauncherItem> 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");
}

View File

@@ -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;
/// <summary>
/// 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 → 클립보드 복사 (또는 터미널에서 실행).
/// </summary>
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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<LauncherItem> 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<LauncherItem> 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 { }
}
}

View File

@@ -0,0 +1,270 @@
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 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 <id> → 특정 타이머 취소
/// Enter → 타이머 시작 (또는 중단).
/// </summary>
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<TimerEntry> _timers = [];
private static readonly object _lock = new();
private static int _nextId = 1;
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("타이머·알람",
"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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<TimerEntry> 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<LauncherItem> items)
{
List<TimerEntry> 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"));
}
}
}