diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md index c95aa82..c425035 100644 --- a/docs/LAUNCHER_ROADMAP.md +++ b/docs/LAUNCHER_ROADMAP.md @@ -321,3 +321,16 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label | L14-2 | **레지스트리 조회** ✅ | `reg` 프리픽스. HKCU/HKLM/HKCR/HKU/HKCC 모든 하이브 지원. 하위 키·값 목록 표시. 값 타입(REG_SZ/DWORD/BINARY/MULTI_SZ) 포맷 출력. 9개 즐겨찾기 경로 빠른 접근. `reg search` 즐겨찾기 필터. 조회 전용(쓰기/삭제 없음) | 높음 | | L14-3 | **팁·할인·분할 계산기** ✅ | `tip` 프리픽스. 금액만 입력 시 10/15/18/20/25% 팁 전체 표시. `tip 금액 %` 특정 팁. `tip 금액 % 인원` 팁+분할. `tip 금액 off %` 할인가 계산. `tip 금액 vat` VAT 포함/역산. `tip 금액 / 인원` 균등 분할. 100원 단위 올림 계산 | 높음 | | L14-4 | **시스템 폰트 목록** ✅ | `font` 프리픽스. `Fonts.SystemFontFamilies` WPF API 기반. 설치 폰트 전체 목록 캐시(최초 1회 로드). `font 맑은/nanum/mono` 키워드 필터. 한글·나눔·코딩·Arial·Times 그룹 힌트. 검색 결과 전체 일괄 복사 지원 | 중간 | + +--- + +## Phase L15 — WSL·환율·건강·Markdown 도구 (v2.0.7) ✅ 완료 + +> **방향**: 개발자 편의·생활 실용 도구 보강 — WSL 관리, 환율 변환, BMI 계산, Markdown 분석. + +| # | 기능 | 설명 | 우선순위 | +|---|------|------|----------| +| L15-1 | **WSL 관리** ✅ | `wsl` 프리픽스. `wsl --list --verbose` 서브프로세스 기반 distro 목록(UTF-16 인코딩). 상태(Running/Stopped) 아이콘 구분. `wsl stop [all/distro]` 종료. `wsl default ` 기본 설정. distro 이름 직접 검색 → 실행. Windows Terminal(wt.exe) 우선, 없으면 UseShellExecute 폴백 | 높음 | +| L15-2 | **환율 변환기** ✅ | `currency` 프리픽스. KRW/USD/EUR/JPY/CNY/GBP/HKD/TWD/SGD/AUD/CAD/CHF/MYR/THB/VND 15개 통화 내장. `currency 100 usd` → KRW 환산. `currency 100 usd eur` → 크로스 환산. `currency 50000 krw usd`. `currency rates` 전체 환율표. 한글 별칭(달러/엔/위안) 지원. JPY/KRW/VND 소수점 0자리 포맷 | 높음 | +| L15-3 | **BMI·건강 계산기** ✅ | `bmi` 프리픽스. `bmi 170 65` BMI 지수 + WHO 아시아태평양 기준 판정. 적정 체중 범위(BMI 18.5~22.9). `bmi 170 65 30 m` 나이+성별 포함 시 Harris-Benedict 기초대사량 + 5단계 활동별 권장 칼로리. `bmi ideal 170` 키 기준 적정/과체중/비만 체중 범위 | 높음 | +| L15-4 | **Markdown 분석기** ✅ | `md` 프리픽스. 클립보드 Markdown 자동 읽기. `md toc` 앵커 포함 목차(TOC) 생성. `md strip` 마크다운 기호 완전 제거 → 순수 텍스트. `md count` 줄/단어/문자/제목/코드블록/목록/링크/이미지/볼드 통계. `md links` 링크 목록 추출. `md images` 이미지 URL 목록. [GeneratedRegex] 소스 생성기 활용 | 높음 | diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index bd53c91..4983ed1 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -277,6 +277,16 @@ public partial class App : System.Windows.Application // L14-4: 시스템 폰트 목록 (prefix=font) commandResolver.RegisterHandler(new FontHandler()); + // ─── Phase L15 핸들러 ───────────────────────────────────────────────── + // L15-1: WSL 관리 (prefix=wsl) + commandResolver.RegisterHandler(new WslHandler()); + // L15-2: 환율 변환기 (prefix=currency) + commandResolver.RegisterHandler(new CurrencyHandler()); + // L15-3: BMI·건강 계산기 (prefix=bmi) + commandResolver.RegisterHandler(new BmiHandler()); + // L15-4: Markdown 분석기 (prefix=md) + commandResolver.RegisterHandler(new MdHandler()); + // ─── 플러그인 로드 ──────────────────────────────────────────────────── var pluginHost = new PluginHost(settings, commandResolver); pluginHost.LoadAll(); diff --git a/src/AxCopilot/Handlers/BmiHandler.cs b/src/AxCopilot/Handlers/BmiHandler.cs new file mode 100644 index 0000000..6d1f868 --- /dev/null +++ b/src/AxCopilot/Handlers/BmiHandler.cs @@ -0,0 +1,219 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L15-3: BMI·건강 계산기 핸들러. "bmi" 프리픽스로 사용합니다. +/// +/// 예: bmi 170 65 → 키 170cm / 몸무게 65kg BMI 계산 +/// bmi 170 65 30 → 나이 포함 (기초대사량, 목표 칼로리) +/// bmi 170 65 30 m → 성별 포함 (남: m/male, 여: f/female) +/// bmi ideal 170 → 키 170cm 적정 체중 범위 +/// Enter → 결과를 클립보드에 복사. +/// +public class BmiHandler : IActionHandler +{ + public string? Prefix => "bmi"; + + public PluginMetadata Metadata => new( + "BMI", + "BMI·건강 계산기 — BMI · 적정 체중 · 기초대사량 · 칼로리", + "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("BMI·건강 계산기", + "예: bmi 170 65 / bmi 170 65 30 m / bmi ideal 170", + null, null, Symbol: "\uE73E")); + items.Add(new LauncherItem("bmi <키cm> <몸무게kg>", "BMI 지수 계산", null, null, Symbol: "\uE73E")); + items.Add(new LauncherItem("bmi <키> <몸무게> <나이>", "기초대사량 포함", null, null, Symbol: "\uE73E")); + items.Add(new LauncherItem("bmi <키> <몸무게> <나이> m/f", "성별 포함 (남/여)", null, null, Symbol: "\uE73E")); + items.Add(new LauncherItem("bmi ideal <키cm>", "적정 체중 범위", null, null, Symbol: "\uE73E")); + return Task.FromResult>(items); + } + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + // ideal 서브커맨드 + if (parts[0].Equals("ideal", StringComparison.OrdinalIgnoreCase)) + { + if (parts.Length < 2 || !double.TryParse(parts[1], out var ht) || ht < 100 || ht > 250) + { + items.Add(new LauncherItem("키를 입력하세요", "예: bmi ideal 170", null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + items.AddRange(BuildIdealItems(ht)); + return Task.FromResult>(items); + } + + // 키 파싱 + if (!double.TryParse(parts[0].Replace("cm", ""), out var height) || height < 100 || height > 250) + { + items.Add(new LauncherItem("키 형식 오류", + "키를 cm 단위로 입력하세요 (예: 170)", null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + // 몸무게 파싱 + if (parts.Length < 2 || !double.TryParse(parts[1].Replace("kg", ""), out var weight) || weight < 20 || weight > 300) + { + items.Add(new LauncherItem("몸무게를 입력하세요", + $"키 {height}cm → 예: bmi {height} 65", null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + // 나이, 성별 (선택) + int? age = null; + bool? male = null; + + for (var i = 2; i < parts.Length; i++) + { + var p = parts[i].ToLowerInvariant(); + if (p is "m" or "male" or "남" or "남자" or "남성") { male = true; continue; } + if (p is "f" or "female" or "여" or "여자" or "여성") { male = false; continue; } + if (int.TryParse(p, out var a) && a is >= 1 and <= 120) { age = a; continue; } + } + + items.AddRange(BuildBmiItems(height, weight, age, male)); + 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("BMI", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + return Task.CompletedTask; + } + + // ── 계산 빌더 ───────────────────────────────────────────────────────────── + + private static IEnumerable BuildBmiItems(double height, double weight, int? age, bool? male) + { + var hm = height / 100.0; + var bmi = weight / (hm * hm); + var (grade, gradeEmoji) = GetGrade(bmi); + + var summary = $"BMI {bmi:F1} ({grade})"; + yield return new LauncherItem(summary, + $"키 {height}cm · 몸무게 {weight}kg", + null, ("copy", summary), Symbol: "\uE73E"); + + yield return new LauncherItem($"BMI 지수", $"{bmi:F2} {gradeEmoji}", null, ("copy", $"{bmi:F2}"), Symbol: "\uE73E"); + yield return new LauncherItem("판정", grade, null, ("copy", grade), Symbol: "\uE73E"); + + // 적정 체중 (BMI 18.5 ~ 22.9 범위) + var idealMin = 18.5 * hm * hm; + var idealMax = 22.9 * hm * hm; + var idealStr = $"{idealMin:F1}kg ~ {idealMax:F1}kg"; + yield return new LauncherItem("적정 체중 범위", idealStr, null, ("copy", idealStr), Symbol: "\uE73E"); + + var diff = weight - (idealMin + idealMax) / 2; + if (Math.Abs(diff) > 0.5) + { + var diffStr = diff > 0 ? $"+{diff:F1}kg 과잉" : $"{diff:F1}kg 부족"; + yield return new LauncherItem("표준 체중 대비", diffStr, null, ("copy", diffStr), Symbol: "\uE73E"); + } + + // BMI 등급 기준 + yield return new LauncherItem("── BMI 기준 (WHO 아시아태평양) ──", "", null, null, Symbol: "\uE73E"); + yield return new LauncherItem("저체중", "BMI < 18.5", null, null, Symbol: "\uE73E"); + yield return new LauncherItem("정상", "18.5 ≤ BMI < 23", null, null, Symbol: "\uE73E"); + yield return new LauncherItem("과체중", "23 ≤ BMI < 25", null, null, Symbol: "\uE73E"); + yield return new LauncherItem("비만 1단계","25 ≤ BMI < 30", null, null, Symbol: "\uE73E"); + yield return new LauncherItem("비만 2단계","BMI ≥ 30", null, null, Symbol: "\uE73E"); + + // 기초대사량 (Harris-Benedict 개정식) + if (age.HasValue) + { + double bmr; + string bmrLabel; + if (male == true) + { + bmr = 88.362 + (13.397 * weight) + (4.799 * height) - (5.677 * age.Value); + bmrLabel = "남성 기초대사량 (Harris-Benedict)"; + } + else if (male == false) + { + bmr = 447.593 + (9.247 * weight) + (3.098 * height) - (4.330 * age.Value); + bmrLabel = "여성 기초대사량 (Harris-Benedict)"; + } + else + { + bmr = (88.362 + (13.397 * weight) + (4.799 * height) - (5.677 * age.Value) + + 447.593 + (9.247 * weight) + (3.098 * height) - (4.330 * age.Value)) / 2; + bmrLabel = "기초대사량 (남녀 평균)"; + } + + var bmrStr = $"{bmr:N0} kcal/일"; + yield return new LauncherItem("── 대사량 ──", "", null, null, Symbol: "\uE73E"); + yield return new LauncherItem(bmrLabel, bmrStr, null, ("copy", bmrStr), Symbol: "\uE73E"); + + // 활동 단계별 권장 칼로리 + var actLevels = new[] + { + ("비활동 (거의 운동 없음)", 1.2), + ("저활동 (주 1~3회 운동)", 1.375), + ("보통 활동 (주 3~5회)", 1.55), + ("활동적 (주 6~7회)", 1.725), + ("매우 활동적 (하루 2회)", 1.9), + }; + foreach (var (label, factor) in actLevels) + { + var cal = bmr * factor; + yield return new LauncherItem(label, $"{cal:N0} kcal/일", + null, ("copy", $"{cal:N0} kcal"), Symbol: "\uE73E"); + } + } + } + + private static IEnumerable BuildIdealItems(double height) + { + var hm = height / 100.0; + var idealMin = 18.5 * hm * hm; + var idealMax = 22.9 * hm * hm; + var idealMid = (idealMin + idealMax) / 2; + + yield return new LauncherItem( + $"키 {height}cm 적정 체중", + $"{idealMin:F1}kg ~ {idealMax:F1}kg", + null, ("copy", $"{idealMin:F1}kg ~ {idealMax:F1}kg"), Symbol: "\uE73E"); + + yield return new LauncherItem("최소 정상 체중 (BMI 18.5)", $"{idealMin:F1}kg", null, ("copy", $"{idealMin:F1}"), Symbol: "\uE73E"); + yield return new LauncherItem("표준 체중 (BMI 20.7)", $"{idealMid:F1}kg", null, ("copy", $"{idealMid:F1}"), Symbol: "\uE73E"); + yield return new LauncherItem("최대 정상 체중 (BMI 22.9)", $"{idealMax:F1}kg", null, ("copy", $"{idealMax:F1}"), Symbol: "\uE73E"); + + var overMin = 23.0 * hm * hm; + var overMax = 24.9 * hm * hm; + var obese = 25.0 * hm * hm; + yield return new LauncherItem("과체중 범위 (BMI 23~24.9)", $"{overMin:F1}kg ~ {overMax:F1}kg", null, null, Symbol: "\uE73E"); + yield return new LauncherItem("비만 기준 (BMI ≥ 25)", $"{obese:F1}kg 이상", null, null, Symbol: "\uE73E"); + } + + // ── 헬퍼 ───────────────────────────────────────────────────────────────── + + private static (string Grade, string Emoji) GetGrade(double bmi) => bmi switch + { + < 18.5 => ("저체중", "🔵"), + < 23.0 => ("정상", "🟢"), + < 25.0 => ("과체중", "🟡"), + < 30.0 => ("비만 1단계", "🟠"), + _ => ("비만 2단계", "🔴"), + }; +} diff --git a/src/AxCopilot/Handlers/CurrencyHandler.cs b/src/AxCopilot/Handlers/CurrencyHandler.cs new file mode 100644 index 0000000..ffc6ff3 --- /dev/null +++ b/src/AxCopilot/Handlers/CurrencyHandler.cs @@ -0,0 +1,213 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L15-2: 환율 변환기 핸들러. "currency" 프리픽스로 사용합니다. +/// +/// 예: currency → 주요 통화 기준환율 목록 +/// currency 100 usd → 100 USD → KRW +/// currency 100 usd eur → 100 USD → EUR +/// currency 50000 krw usd → 50,000 KRW → USD +/// currency rates → 전체 환율표 +/// Enter → 결과를 클립보드에 복사. +/// +/// 내장 기준환율 사용 (사내 모드). 사외 모드에서는 동일하게 동작. +/// +public class CurrencyHandler : IActionHandler +{ + public string? Prefix => "currency"; + + public PluginMetadata Metadata => new( + "Currency", + "환율 변환기 — KRW·USD·EUR·JPY·CNY 등 주요 통화 변환", + "1.0", + "AX"); + + // 내장 기준환율 (KRW 기준, 2025년 1분기 평균 참조값) + private static readonly Dictionary Rates = + new(StringComparer.OrdinalIgnoreCase) + { + ["KRW"] = ("한국 원", "₩", 1.0), + ["USD"] = ("미국 달러", "$", 1370.0), + ["EUR"] = ("유로", "€", 1480.0), + ["JPY"] = ("일본 엔", "¥", 9.2), + ["CNY"] = ("중국 위안", "¥", 189.0), + ["GBP"] = ("영국 파운드", "£", 1730.0), + ["HKD"] = ("홍콩 달러", "HK$",175.0), + ["TWD"] = ("대만 달러", "NT$", 42.0), + ["SGD"] = ("싱가포르 달러", "S$", 1020.0), + ["AUD"] = ("호주 달러", "A$", 870.0), + ["CAD"] = ("캐나다 달러", "C$", 995.0), + ["CHF"] = ("스위스 프랑", "Fr", 1540.0), + ["MYR"] = ("말레이시아 링깃", "RM", 310.0), + ["THB"] = ("태국 바트", "฿", 38.5), + ["VND"] = ("베트남 동", "₫", 0.054), + }; + + // 통화 별칭 + private static readonly Dictionary Aliases = + new(StringComparer.OrdinalIgnoreCase) + { + ["달러"] = "USD", ["엔"] = "JPY", ["위안"] = "CNY", ["유로"] = "EUR", + ["파운드"] = "GBP", ["원"] = "KRW", ["엔화"] = "JPY", ["달러화"] = "USD", + ["프랑"] = "CHF", ["바트"] = "THB", ["동"] = "VND", ["링깃"] = "MYR", + }; + + // 주요 통화 표시 순서 + private static readonly string[] MainCurrencies = ["USD", "EUR", "JPY", "CNY", "GBP", "HKD", "SGD", "AUD"]; + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem("환율 변환기", + "예: currency 100 usd / currency 50000 krw eur / currency rates", + null, null, Symbol: "\uE8C7")); + items.Add(new LauncherItem("── 주요 통화 (KRW 기준) ──", "", null, null, Symbol: "\uE8C7")); + foreach (var code in MainCurrencies) + { + if (!Rates.TryGetValue(code, out var info)) continue; + items.Add(new LauncherItem( + $"1 {code} = {info.RateToKrw:N0} KRW", + $"{info.Name} ({info.Symbol})", + null, ("copy", $"1 {code} = {info.RateToKrw:N0} KRW"), + Symbol: "\uE8C7")); + } + return Task.FromResult>(items); + } + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + // rates → 전체 환율표 + if (parts[0].Equals("rates", StringComparison.OrdinalIgnoreCase) || + parts[0].Equals("list", StringComparison.OrdinalIgnoreCase)) + { + items.Add(new LauncherItem($"전체 환율표 ({Rates.Count}개 통화)", "KRW 기준 내장 환율", + null, null, Symbol: "\uE8C7")); + foreach (var (code, info) in Rates.OrderBy(r => r.Key)) + { + items.Add(new LauncherItem( + $"1 {code} = {info.RateToKrw:N2} KRW", + $"{info.Symbol} {info.Name}", + null, ("copy", $"1 {code} = {info.RateToKrw:N2} KRW"), + Symbol: "\uE8C7")); + } + return Task.FromResult>(items); + } + + // 금액 파싱 + if (!TryParseAmount(parts[0], out var amount)) + { + items.Add(new LauncherItem("금액 형식 오류", + "예: currency 100 usd", null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + // 통화 코드 파싱 + var fromCode = parts.Length >= 2 ? ResolveCode(parts[1]) : "KRW"; + var toCode = parts.Length >= 3 ? ResolveCode(parts[2]) : null; + + if (fromCode == null) + { + items.Add(new LauncherItem("알 수 없는 통화", + $"'{parts[1]}' 코드를 찾을 수 없습니다. 예: USD EUR JPY CNY", + null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + if (!Rates.TryGetValue(fromCode, out var fromInfo)) + { + items.Add(new LauncherItem("지원하지 않는 통화", fromCode, null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + var amountKrw = amount * fromInfo.RateToKrw; + + // 특정 대상 통화 지정 + if (toCode != null) + { + if (!Rates.TryGetValue(toCode, out var toInfo)) + { + items.Add(new LauncherItem("지원하지 않는 대상 통화", toCode, null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + var converted = amountKrw / toInfo.RateToKrw; + var label = $"{FormatAmount(amount, fromCode)} = {FormatAmount(converted, toCode)}"; + items.Add(new LauncherItem(label, + $"{fromInfo.Name} → {toInfo.Name} (Enter 복사)", + null, ("copy", label), Symbol: "\uE8C7")); + items.Add(new LauncherItem($"{FormatAmount(converted, toCode)}", $"변환 결과", + null, ("copy", $"{converted:N2}"), Symbol: "\uE8C7")); + } + else + { + // 주요 통화들로 일괄 변환 + items.Add(new LauncherItem( + $"{FormatAmount(amount, fromCode)} 변환 결과", + "주요 통화 기준 (내장 환율)", + null, null, Symbol: "\uE8C7")); + + var targets = fromCode == "KRW" ? MainCurrencies : (new[] { "KRW" }).Concat(MainCurrencies.Where(c => c != fromCode)).ToArray(); + foreach (var tc in targets) + { + if (!Rates.TryGetValue(tc, out var tInfo)) continue; + var conv = amountKrw / tInfo.RateToKrw; + var label = $"{FormatAmount(conv, tc)}"; + items.Add(new LauncherItem(label, + $"{tInfo.Name} ({tInfo.Symbol})", + null, ("copy", label), Symbol: "\uE8C7")); + } + } + + 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("Currency", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + return Task.CompletedTask; + } + + // ── 헬퍼 ───────────────────────────────────────────────────────────────── + + private static string? ResolveCode(string input) + { + if (Rates.ContainsKey(input)) return input.ToUpperInvariant(); + if (Aliases.TryGetValue(input, out var code)) return code; + return null; + } + + private static bool TryParseAmount(string s, out double result) + { + result = 0; + s = s.Replace(",", "").Trim(); + return double.TryParse(s, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out result); + } + + private static string FormatAmount(double amount, string code) + { + if (!Rates.TryGetValue(code, out var info)) return $"{amount:N2} {code}"; + // 소수점 자릿수: JPY/KRW/VND = 0, 기타 = 2 + var decimals = code is "JPY" or "KRW" or "VND" ? 0 : 2; + var fmt = decimals == 0 ? $"{amount:N0}" : $"{amount:N2}"; + return $"{info.Symbol}{fmt} {code}"; + } +} diff --git a/src/AxCopilot/Handlers/MdHandler.cs b/src/AxCopilot/Handlers/MdHandler.cs new file mode 100644 index 0000000..37bf2d4 --- /dev/null +++ b/src/AxCopilot/Handlers/MdHandler.cs @@ -0,0 +1,356 @@ +using System.Text; +using System.Text.RegularExpressions; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L15-4: Markdown 분석기 핸들러. "md" 프리픽스로 사용합니다. +/// +/// 예: md → 클립보드 Markdown 분석 (구조·통계) +/// md toc → 목차(TOC) 생성 +/// md strip → Markdown 기호 제거 → 순수 텍스트 +/// md count → 단어·줄·코드블록 수 세기 +/// md links → 링크 목록 추출 +/// md images → 이미지 목록 추출 +/// Enter → 결과를 클립보드에 복사. +/// +public partial class MdHandler : IActionHandler +{ + public string? Prefix => "md"; + + public PluginMetadata Metadata => new( + "MD", + "Markdown 분석기 — 구조 분석 · TOC · 기호 제거 · 링크 추출", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + // 클립보드에서 텍스트 읽기 + string? clipboard = null; + try + { + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + if (Clipboard.ContainsText()) + clipboard = Clipboard.GetText(); + }); + } + catch { /* 클립보드 접근 실패 */ } + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem("Markdown 분석기", + "클립보드 Markdown 분석 · md toc / strip / count / links / images", + null, null, Symbol: "\uE8A5")); + items.Add(new LauncherItem("md toc", "목차(TOC) 생성", null, null, Symbol: "\uE8A5")); + items.Add(new LauncherItem("md strip", "Markdown 기호 제거", null, null, Symbol: "\uE8A5")); + items.Add(new LauncherItem("md count", "단어·줄·코드블록 통계", null, null, Symbol: "\uE8A5")); + items.Add(new LauncherItem("md links", "링크 목록 추출", null, null, Symbol: "\uE8A5")); + items.Add(new LauncherItem("md images", "이미지 목록 추출", null, null, Symbol: "\uE8A5")); + + if (string.IsNullOrWhiteSpace(clipboard)) + { + items.Add(new LauncherItem("클립보드가 비어 있습니다", + "Markdown 텍스트를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946")); + return Task.FromResult>(items); + } + + // 간단 미리보기 통계 + var stat = QuickStat(clipboard); + items.Add(new LauncherItem("── 클립보드 미리보기 ──", "", null, null, Symbol: "\uE8A5")); + items.Add(new LauncherItem("클립보드 Markdown 분석", stat, null, ("copy", stat), Symbol: "\uE8A5")); + return Task.FromResult>(items); + } + + // 클립보드 없으면 서브커맨드도 안내만 + if (string.IsNullOrWhiteSpace(clipboard)) + { + items.Add(new LauncherItem("클립보드가 비어 있습니다", + "Markdown 텍스트를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946")); + return Task.FromResult>(items); + } + + var sub = q.Split(' ')[0].ToLowerInvariant(); + switch (sub) + { + case "toc": + items.AddRange(BuildTocItems(clipboard)); + break; + + case "strip": + case "plain": + case "text": + items.AddRange(BuildStripItems(clipboard)); + break; + + case "count": + case "stat": + case "stats": + items.AddRange(BuildCountItems(clipboard)); + break; + + case "links": + case "link": + items.AddRange(BuildLinkItems(clipboard)); + break; + + case "images": + case "image": + case "img": + items.AddRange(BuildImageItems(clipboard)); + break; + + default: + items.Add(new LauncherItem("알 수 없는 서브커맨드", + "toc · strip · count · links · images", null, null, Symbol: "\uE783")); + break; + } + + 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("MD", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + return Task.CompletedTask; + } + + // ── 분석 빌더 ───────────────────────────────────────────────────────────── + + private static string QuickStat(string md) + { + var lines = md.Split('\n'); + var headings = lines.Count(l => HeadingRegex().IsMatch(l)); + var codeBlocks = CountCodeBlocks(md); + var links = LinkRegex().Matches(md).Count; + var words = WordCount(md); + return $"{lines.Length}줄 · 제목 {headings}개 · 코드블록 {codeBlocks}개 · 링크 {links}개 · 단어 {words}개"; + } + + private static List BuildTocItems(string md) + { + var items = new List(); + var lines = md.Split('\n'); + var headings = new List<(int Level, string Text, string Anchor)>(); + + foreach (var line in lines) + { + var m = HeadingRegex().Match(line); + if (!m.Success) continue; + var level = m.Groups[1].Value.Length; + var text = m.Groups[2].Value.Trim(); + var anchor = MakeAnchor(text); + headings.Add((level, text, anchor)); + } + + if (headings.Count == 0) + { + items.Add(new LauncherItem("제목(#)이 없습니다", "Markdown 제목이 없으면 TOC를 생성할 수 없습니다", + null, null, Symbol: "\uE946")); + return items; + } + + var sb = new StringBuilder(); + foreach (var (level, text, anchor) in headings) + { + var indent = new string(' ', (level - 1) * 2); + sb.AppendLine($"{indent}- [{text}](#{anchor})"); + } + var toc = sb.ToString().TrimEnd(); + + items.Add(new LauncherItem($"TOC 생성 완료 ({headings.Count}개 제목)", + "Enter → 전체 TOC 복사", null, ("copy", toc), Symbol: "\uE8A5")); + + foreach (var (level, text, anchor) in headings.Take(20)) + { + var prefix = new string('#', level) + " "; + var entry = $"- [{text}](#{anchor})"; + items.Add(new LauncherItem($"{prefix}{text}", entry, null, ("copy", entry), Symbol: "\uE8A5")); + } + if (headings.Count > 20) + items.Add(new LauncherItem($"… 외 {headings.Count - 20}개", "전체 복사는 첫 항목 Enter", null, null, Symbol: "\uE8A5")); + + return items; + } + + private static List BuildStripItems(string md) + { + var items = new List(); + var plain = StripMarkdown(md); + var preview = plain.Length > 80 ? plain[..80] + "…" : plain; + + items.Add(new LauncherItem("Markdown 기호 제거 완료", + $"Enter → 순수 텍스트 복사 ({plain.Length}자)", null, ("copy", plain), Symbol: "\uE8A5")); + items.Add(new LauncherItem("미리보기", preview, null, ("copy", plain), Symbol: "\uE8A5")); + return items; + } + + private static List BuildCountItems(string md) + { + var items = new List(); + var lines = md.Split('\n'); + + var totalLines = lines.Length; + var blankLines = lines.Count(l => string.IsNullOrWhiteSpace(l)); + var codeBlockCount= CountCodeBlocks(md); + var headingCount = lines.Count(l => HeadingRegex().IsMatch(l)); + var listCount = lines.Count(l => ListRegex().IsMatch(l)); + var linkCount = LinkRegex().Matches(md).Count; + var imageCount = ImageRegex().Matches(md).Count; + var boldCount = BoldRegex().Matches(md).Count; + var words = WordCount(md); + var chars = md.Length; + var charsNoSpace = md.Replace(" ", "").Replace("\n", "").Replace("\r", "").Length; + + items.Add(new LauncherItem($"Markdown 통계", $"{totalLines}줄 · {words}단어 · {chars}자", + null, ("copy", $"줄 {totalLines} · 단어 {words} · 문자 {chars}"), Symbol: "\uE8A5")); + + items.Add(new LauncherItem("전체 줄 수", $"{totalLines}줄 (공백 {blankLines}줄)", null, ("copy", $"{totalLines}"), Symbol: "\uE8A5")); + items.Add(new LauncherItem("단어 수", $"{words}단어", null, ("copy", $"{words}"), Symbol: "\uE8A5")); + items.Add(new LauncherItem("문자 수", $"{chars}자 (공백 제외 {charsNoSpace}자)", null, ("copy", $"{chars}"), Symbol: "\uE8A5")); + items.Add(new LauncherItem("제목(#) 수", $"{headingCount}개", null, ("copy", $"{headingCount}"), Symbol: "\uE8A5")); + items.Add(new LauncherItem("코드 블록 수", $"{codeBlockCount}개", null, ("copy", $"{codeBlockCount}"), Symbol: "\uE8A5")); + items.Add(new LauncherItem("목록 항목 수", $"{listCount}개", null, ("copy", $"{listCount}"), Symbol: "\uE8A5")); + items.Add(new LauncherItem("링크 수", $"{linkCount}개", null, ("copy", $"{linkCount}"), Symbol: "\uE8A5")); + items.Add(new LauncherItem("이미지 수", $"{imageCount}개", null, ("copy", $"{imageCount}"), Symbol: "\uE8A5")); + items.Add(new LauncherItem("강조(**bold**) 수", $"{boldCount}개", null, ("copy", $"{boldCount}"), Symbol: "\uE8A5")); + + return items; + } + + private static List BuildLinkItems(string md) + { + var items = new List(); + var matches = LinkRegex().Matches(md); + + if (matches.Count == 0) + { + items.Add(new LauncherItem("링크 없음", "클립보드 Markdown에 링크([text](url))가 없습니다", + null, null, Symbol: "\uE946")); + return items; + } + + var allUrls = string.Join("\n", matches.Cast().Select(m => m.Groups[2].Value)); + items.Add(new LauncherItem($"링크 {matches.Count}개 발견", + "Enter → 전체 URL 목록 복사", null, ("copy", allUrls), Symbol: "\uE8A5")); + + foreach (Match m in matches.Cast().Take(25)) + { + var text = m.Groups[1].Value; + var url = m.Groups[2].Value; + var display = text.Length > 30 ? text[..30] + "…" : text; + items.Add(new LauncherItem(display, url, null, ("copy", url), Symbol: "\uE8A5")); + } + if (matches.Count > 25) + items.Add(new LauncherItem($"… 외 {matches.Count - 25}개", "전체 복사는 첫 항목 Enter", null, null, Symbol: "\uE8A5")); + + return items; + } + + private static List BuildImageItems(string md) + { + var items = new List(); + var matches = ImageRegex().Matches(md); + + if (matches.Count == 0) + { + items.Add(new LauncherItem("이미지 없음", "클립보드 Markdown에 이미지(![alt](url))가 없습니다", + null, null, Symbol: "\uE946")); + return items; + } + + var allUrls = string.Join("\n", matches.Cast().Select(m => m.Groups[2].Value)); + items.Add(new LauncherItem($"이미지 {matches.Count}개 발견", + "Enter → 전체 URL 목록 복사", null, ("copy", allUrls), Symbol: "\uE8A5")); + + foreach (Match m in matches.Cast().Take(25)) + { + var alt = m.Groups[1].Value; + var url = m.Groups[2].Value; + var display = string.IsNullOrWhiteSpace(alt) ? "(alt 없음)" : (alt.Length > 30 ? alt[..30] + "…" : alt); + items.Add(new LauncherItem(display, url, null, ("copy", url), Symbol: "\uE8A5")); + } + + return items; + } + + // ── 헬퍼 ───────────────────────────────────────────────────────────────── + + private static string StripMarkdown(string md) + { + var s = md; + // 코드 블록 제거 + s = Regex.Replace(s, @"```[\s\S]*?```", "", RegexOptions.Multiline); + s = Regex.Replace(s, @"`[^`]+`", ""); + // 제목 기호 제거 + s = Regex.Replace(s, @"^#{1,6}\s+", "", RegexOptions.Multiline); + // 이미지, 링크 → 텍스트만 + s = Regex.Replace(s, @"!\[([^\]]*)\]\([^\)]*\)", "$1"); + s = Regex.Replace(s, @"\[([^\]]+)\]\([^\)]+\)", "$1"); + // 강조 기호 제거 + s = Regex.Replace(s, @"\*{1,3}([^*]+)\*{1,3}", "$1"); + s = Regex.Replace(s, @"_{1,3}([^_]+)_{1,3}", "$1"); + // 인용 기호 + s = Regex.Replace(s, @"^>\s+", "", RegexOptions.Multiline); + // 목록 기호 + s = Regex.Replace(s, @"^[\-\*\+]\s+", "", RegexOptions.Multiline); + s = Regex.Replace(s, @"^\d+\.\s+", "", RegexOptions.Multiline); + // 수평선 + s = Regex.Replace(s, @"^[-*_]{3,}\s*$", "", RegexOptions.Multiline); + // 다중 공백 정리 + s = Regex.Replace(s, @"\n{3,}", "\n\n"); + return s.Trim(); + } + + private static string MakeAnchor(string text) + { + var s = text.ToLowerInvariant(); + s = Regex.Replace(s, @"[^\w\s\-가-힣]", ""); + s = Regex.Replace(s, @"\s+", "-"); + return s; + } + + private static int CountCodeBlocks(string md) + { + var matches = Regex.Matches(md, @"^```", RegexOptions.Multiline); + return matches.Count / 2; + } + + private static int WordCount(string md) + { + var plain = StripMarkdown(md); + return Regex.Matches(plain, @"\S+").Count; + } + + [GeneratedRegex(@"^(#{1,6})\s+(.+)$", RegexOptions.Multiline)] + private static partial Regex HeadingRegex(); + + [GeneratedRegex(@"^\s*[\-\*\+]\s+|^\s*\d+\.\s+", RegexOptions.Multiline)] + private static partial Regex ListRegex(); + + [GeneratedRegex(@"\[([^\]]+)\]\(([^\)]+)\)")] + private static partial Regex LinkRegex(); + + [GeneratedRegex(@"!\[([^\]]*)\]\(([^\)]+)\)")] + private static partial Regex ImageRegex(); + + [GeneratedRegex(@"\*{2,3}[^*]+\*{2,3}")] + private static partial Regex BoldRegex(); +} diff --git a/src/AxCopilot/Handlers/WslHandler.cs b/src/AxCopilot/Handlers/WslHandler.cs new file mode 100644 index 0000000..937b94a --- /dev/null +++ b/src/AxCopilot/Handlers/WslHandler.cs @@ -0,0 +1,274 @@ +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L15-1: WSL(Windows Subsystem for Linux) 관리 핸들러. "wsl" 프리픽스로 사용합니다. +/// +/// 예: wsl → 설치된 distro 목록 + 상태 +/// wsl ubuntu → Ubuntu 실행 (새 터미널) +/// wsl stop ubuntu → 특정 distro 종료 +/// wsl stop all → 전체 WSL 종료 +/// wsl default ubuntu → 기본 distro 변경 +/// Enter → distro 실행 또는 명령 실행. +/// +public class WslHandler : IActionHandler +{ + public string? Prefix => "wsl"; + + public PluginMetadata Metadata => new( + "WSL", + "WSL 관리 — distro 목록 · 실행 · 종료", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + var distros = GetDistros(); + + if (string.IsNullOrWhiteSpace(q)) + { + if (distros.Count == 0) + { + items.Add(new LauncherItem("WSL 없음", + "WSL이 설치되지 않았거나 distro가 없습니다", null, null, Symbol: "\uE756")); + items.Add(new LauncherItem("WSL 설치", + "Microsoft Store에서 Ubuntu 등 설치", null, + ("open_url", "ms-windows-store://search/?query=linux"), Symbol: "\uE756")); + return Task.FromResult>(items); + } + + items.Add(new LauncherItem( + $"WSL distro {distros.Count}개", + "Enter → 실행 / wsl stop all → 전체 종료", + null, null, Symbol: "\uE756")); + + foreach (var d in distros) + items.Add(MakeDistroItem(d)); + + items.Add(new LauncherItem("wsl stop all", "전체 WSL 종료 (wsl --shutdown)", null, + ("shutdown", ""), Symbol: "\uE756")); + return Task.FromResult>(items); + } + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + switch (sub) + { + case "stop": + case "shutdown": + case "kill": + { + var target = parts.Length > 1 ? parts[1].ToLowerInvariant() : "all"; + if (target == "all" || target == "--all") + { + items.Add(new LauncherItem("WSL 전체 종료", "wsl --shutdown · Enter 실행", + null, ("shutdown", ""), Symbol: "\uE756")); + } + else + { + var found = distros.FirstOrDefault(d => + d.Name.Contains(target, StringComparison.OrdinalIgnoreCase)); + if (found == null) + items.Add(new LauncherItem("없는 distro", $"'{target}'를 찾을 수 없습니다", null, null, Symbol: "\uE783")); + else + items.Add(new LauncherItem($"{found.Name} 종료", $"wsl --terminate {found.Name}", + null, ("terminate", found.Name), Symbol: "\uE756")); + } + break; + } + + case "default": + case "set-default": + { + var target = parts.Length > 1 ? parts[1] : ""; + if (string.IsNullOrWhiteSpace(target)) + { + items.Add(new LauncherItem("distro 이름 입력", "예: wsl default Ubuntu", null, null, Symbol: "\uE783")); + break; + } + items.Add(new LauncherItem($"기본 distro: {target}", + $"wsl --set-default {target} · Enter 실행", + null, ("set_default", target), Symbol: "\uE756")); + break; + } + + default: + { + // distro 이름 검색 → 실행 + var found = distros.Where(d => + d.Name.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (found.Count > 0) + foreach (var d in found) + items.Add(MakeDistroItem(d)); + else + items.Add(new LauncherItem($"'{q}' distro 없음", + "wsl 입력으로 전체 목록 확인", null, null, Symbol: "\uE946")); + break; + } + } + + return Task.FromResult>(items); + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + switch (item.Data) + { + case ("launch", string distro): + RunWsl($"-d \"{distro}\""); + break; + + case ("shutdown", _): + RunWslSilent("--shutdown"); + NotificationService.Notify("WSL", "WSL 전체 종료 요청됨"); + break; + + case ("terminate", string distro): + RunWslSilent($"--terminate \"{distro}\""); + NotificationService.Notify("WSL", $"{distro} 종료됨"); + break; + + case ("set_default", string distro): + RunWslSilent($"--set-default \"{distro}\""); + NotificationService.Notify("WSL", $"기본 distro → {distro}"); + break; + + case ("open_url", string url): + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = url, UseShellExecute = true, + }); + } + catch { /* 비핵심 */ } + break; + + case ("copy", string text): + try + { + System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text)); + NotificationService.Notify("WSL", "복사됨"); + } + catch { /* 비핵심 */ } + break; + } + return Task.CompletedTask; + } + + // ── WSL 조회 ───────────────────────────────────────────────────────────── + + private record WslDistro(string Name, string State, string Version, bool IsDefault); + + private static List GetDistros() + { + var result = new List(); + try + { + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "wsl", + Arguments = "--list --verbose", + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.Unicode, // WSL outputs UTF-16 + }; + using var proc = System.Diagnostics.Process.Start(psi); + if (proc == null) return result; + + var output = proc.StandardOutput.ReadToEnd(); + proc.WaitForExit(3000); + + foreach (var line in output.Split('\n').Skip(1)) // 첫 줄은 헤더 + { + var trimmed = line.Trim().TrimEnd('\r'); + if (string.IsNullOrWhiteSpace(trimmed)) continue; + + var isDefault = trimmed.StartsWith('*'); + trimmed = trimmed.TrimStart('*').Trim(); + + var parts = trimmed.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2) continue; + + result.Add(new WslDistro( + Name: parts[0], + State: parts.Length > 1 ? parts[1] : "Unknown", + Version: parts.Length > 2 ? parts[2] : "?", + IsDefault: isDefault)); + } + } + catch { /* WSL 없음 */ } + return result; + } + + private static LauncherItem MakeDistroItem(WslDistro d) + { + var icon = d.State.Equals("Running", StringComparison.OrdinalIgnoreCase) ? "\uE768" : "\uE756"; + var label = d.IsDefault ? $"★ {d.Name}" : d.Name; + var subtitle = $"{d.State} · WSL {d.Version}" + (d.IsDefault ? " (기본)" : ""); + return new LauncherItem(label, subtitle, null, ("launch", d.Name), Symbol: icon); + } + + private static void RunWsl(string args) + { + // 터미널에서 실행 (wt 또는 powershell 폴백) + var wtPath = FindExe("wt.exe"); + if (wtPath != null) + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = wtPath, + Arguments = $"wsl {args}", + UseShellExecute = false, + }); + } + else + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "wsl", + Arguments = args, + UseShellExecute = true, + }); + } + } + + private static void RunWslSilent(string args) + { + try + { + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "wsl", + Arguments = args, + UseShellExecute = false, + CreateNoWindow = true, + }; + using var proc = System.Diagnostics.Process.Start(psi); + proc?.WaitForExit(5000); + } + catch { /* 비핵심 */ } + } + + private static string? FindExe(string name) + { + var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? ""; + foreach (var dir in pathEnv.Split(';')) + { + var full = System.IO.Path.Combine(dir.Trim(), name); + if (System.IO.File.Exists(full)) return full; + } + return null; + } +}