[Phase L15] WSL·환율·BMI·Markdown 핸들러 4종 추가
WslHandler.cs (신규, ~275줄, prefix=wsl):
- wsl --list --verbose 서브프로세스 기반 distro 목록 (Encoding.Unicode)
- 상태별 아이콘: Running=\uE768, Stopped=\uE756
- 서브커맨드: stop [all/distro] · default <distro> · 이름 검색 실행
- wt.exe(Windows Terminal) 우선 실행, 없으면 UseShellExecute 폴백
- Data 튜플: launch / shutdown / terminate / set_default
CurrencyHandler.cs (신규, ~185줄, prefix=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 rates → 전체 환율표
- JPY/KRW/VND 소수점 0자리, 기타 2자리 포맷
BmiHandler.cs (신규, ~210줄, prefix=bmi):
- bmi 170 65 → BMI 지수 + WHO 아시아태평양 기준 판정 + 적정 체중
- bmi 170 65 30 m → Harris-Benedict 기초대사량 + 5단계 활동 칼로리
- bmi ideal 170 → 키 기준 정상/과체중/비만 체중 범위 계산
- GetGrade(): 저체중/정상/과체중/비만1단계/비만2단계 switch expression
MdHandler.cs (신규, ~280줄, prefix=md, partial class):
- 클립보드 Markdown 자동 읽기 (ContainsText/GetText)
- md toc: 앵커 생성 포함 TOC 목차 생성
- md strip: Regex 기반 마크다운 기호 완전 제거 → 순수 텍스트
- md count: 줄/단어/문자/제목/코드블록/목록/링크/이미지/볼드 통계
- md links / md images: URL 목록 추출
- [GeneratedRegex] 소스 생성기 활용 (partial class 필수)
App.xaml.cs (수정): Phase L15 핸들러 4종 RegisterHandler 추가
docs/LAUNCHER_ROADMAP.md (수정): Phase L15 섹션 추가 (✅ 완료)
빌드: 경고 0, 오류 0
This commit is contained in:
@@ -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-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-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 그룹 힌트. 검색 결과 전체 일괄 복사 지원 | 중간 |
|
| 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>` 기본 설정. 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] 소스 생성기 활용 | 높음 |
|
||||||
|
|||||||
@@ -277,6 +277,16 @@ public partial class App : System.Windows.Application
|
|||||||
// L14-4: 시스템 폰트 목록 (prefix=font)
|
// L14-4: 시스템 폰트 목록 (prefix=font)
|
||||||
commandResolver.RegisterHandler(new FontHandler());
|
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);
|
var pluginHost = new PluginHost(settings, commandResolver);
|
||||||
pluginHost.LoadAll();
|
pluginHost.LoadAll();
|
||||||
|
|||||||
219
src/AxCopilot/Handlers/BmiHandler.cs
Normal file
219
src/AxCopilot/Handlers/BmiHandler.cs
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using AxCopilot.SDK;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
using AxCopilot.Themes;
|
||||||
|
|
||||||
|
namespace AxCopilot.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 → 결과를 클립보드에 복사.
|
||||||
|
/// </summary>
|
||||||
|
public class BmiHandler : IActionHandler
|
||||||
|
{
|
||||||
|
public string? Prefix => "bmi";
|
||||||
|
|
||||||
|
public PluginMetadata Metadata => new(
|
||||||
|
"BMI",
|
||||||
|
"BMI·건강 계산기 — BMI · 적정 체중 · 기초대사량 · 칼로리",
|
||||||
|
"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("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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
items.AddRange(BuildIdealItems(ht));
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<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("BMI", "클립보드에 복사했습니다.");
|
||||||
|
}
|
||||||
|
catch { /* 비핵심 */ }
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 계산 빌더 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static IEnumerable<LauncherItem> 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<LauncherItem> 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단계", "🔴"),
|
||||||
|
};
|
||||||
|
}
|
||||||
213
src/AxCopilot/Handlers/CurrencyHandler.cs
Normal file
213
src/AxCopilot/Handlers/CurrencyHandler.cs
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using AxCopilot.SDK;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
using AxCopilot.Themes;
|
||||||
|
|
||||||
|
namespace AxCopilot.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 → 결과를 클립보드에 복사.
|
||||||
|
///
|
||||||
|
/// 내장 기준환율 사용 (사내 모드). 사외 모드에서는 동일하게 동작.
|
||||||
|
/// </summary>
|
||||||
|
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<string, (string Name, string Symbol, double RateToKrw)> 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<string, string> 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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = query.Trim();
|
||||||
|
var items = new List<LauncherItem>();
|
||||||
|
|
||||||
|
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 금액 파싱
|
||||||
|
if (!TryParseAmount(parts[0], out var amount))
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("금액 형식 오류",
|
||||||
|
"예: currency 100 usd", null, null, Symbol: "\uE783"));
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Rates.TryGetValue(fromCode, out var fromInfo))
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("지원하지 않는 통화", fromCode, null, null, Symbol: "\uE783"));
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<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("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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
356
src/AxCopilot/Handlers/MdHandler.cs
Normal file
356
src/AxCopilot/Handlers/MdHandler.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// L15-4: Markdown 분석기 핸들러. "md" 프리픽스로 사용합니다.
|
||||||
|
///
|
||||||
|
/// 예: md → 클립보드 Markdown 분석 (구조·통계)
|
||||||
|
/// md toc → 목차(TOC) 생성
|
||||||
|
/// md strip → Markdown 기호 제거 → 순수 텍스트
|
||||||
|
/// md count → 단어·줄·코드블록 수 세기
|
||||||
|
/// md links → 링크 목록 추출
|
||||||
|
/// md images → 이미지 목록 추출
|
||||||
|
/// Enter → 결과를 클립보드에 복사.
|
||||||
|
/// </summary>
|
||||||
|
public partial class MdHandler : IActionHandler
|
||||||
|
{
|
||||||
|
public string? Prefix => "md";
|
||||||
|
|
||||||
|
public PluginMetadata Metadata => new(
|
||||||
|
"MD",
|
||||||
|
"Markdown 분석기 — 구조 분석 · TOC · 기호 제거 · 링크 추출",
|
||||||
|
"1.0",
|
||||||
|
"AX");
|
||||||
|
|
||||||
|
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = query.Trim();
|
||||||
|
var items = new List<LauncherItem>();
|
||||||
|
|
||||||
|
// 클립보드에서 텍스트 읽기
|
||||||
|
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 클립보드 없으면 서브커맨드도 안내만
|
||||||
|
if (string.IsNullOrWhiteSpace(clipboard))
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("클립보드가 비어 있습니다",
|
||||||
|
"Markdown 텍스트를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946"));
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(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<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("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<LauncherItem> BuildTocItems(string md)
|
||||||
|
{
|
||||||
|
var items = new List<LauncherItem>();
|
||||||
|
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<LauncherItem> BuildStripItems(string md)
|
||||||
|
{
|
||||||
|
var items = new List<LauncherItem>();
|
||||||
|
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<LauncherItem> BuildCountItems(string md)
|
||||||
|
{
|
||||||
|
var items = new List<LauncherItem>();
|
||||||
|
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<LauncherItem> BuildLinkItems(string md)
|
||||||
|
{
|
||||||
|
var items = new List<LauncherItem>();
|
||||||
|
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<Match>().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<Match>().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<LauncherItem> BuildImageItems(string md)
|
||||||
|
{
|
||||||
|
var items = new List<LauncherItem>();
|
||||||
|
var matches = ImageRegex().Matches(md);
|
||||||
|
|
||||||
|
if (matches.Count == 0)
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("이미지 없음", "클립보드 Markdown에 이미지()가 없습니다",
|
||||||
|
null, null, Symbol: "\uE946"));
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
var allUrls = string.Join("\n", matches.Cast<Match>().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<Match>().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();
|
||||||
|
}
|
||||||
274
src/AxCopilot/Handlers/WslHandler.cs
Normal file
274
src/AxCopilot/Handlers/WslHandler.cs
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Windows;
|
||||||
|
using AxCopilot.SDK;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
using AxCopilot.Themes;
|
||||||
|
|
||||||
|
namespace AxCopilot.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 실행 또는 명령 실행.
|
||||||
|
/// </summary>
|
||||||
|
public class WslHandler : IActionHandler
|
||||||
|
{
|
||||||
|
public string? Prefix => "wsl";
|
||||||
|
|
||||||
|
public PluginMetadata Metadata => new(
|
||||||
|
"WSL",
|
||||||
|
"WSL 관리 — distro 목록 · 실행 · 종료",
|
||||||
|
"1.0",
|
||||||
|
"AX");
|
||||||
|
|
||||||
|
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = query.Trim();
|
||||||
|
var items = new List<LauncherItem>();
|
||||||
|
|
||||||
|
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<WslDistro> GetDistros()
|
||||||
|
{
|
||||||
|
var result = new List<WslDistro>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user