diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md index c425035..be798db 100644 --- a/docs/LAUNCHER_ROADMAP.md +++ b/docs/LAUNCHER_ROADMAP.md @@ -334,3 +334,16 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label | 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] 소스 생성기 활용 | 높음 | + +--- + +## Phase L16 — ping·Docker·Todo·Table 도구 (v2.0.8) ✅ 완료 + +> **방향**: 개발자 인프라 도구 + 생산성 도구 보강 — 네트워크 진단, 컨테이너 관리, 할 일 목록, 표 변환. + +| # | 기능 | 설명 | 우선순위 | +|---|------|------|----------| +| L16-1 | **ping·tracert 실행기** ✅ | `ping` 프리픽스. 입력 즉시 1회 ping 시도 결과 표시(응답 ms 실시간). Enter → Windows Terminal(wt.exe) 우선 실행. `ping trace ` tracert. `ping local` 로컬 어댑터 IP·게이트웨이 정보. `ping scan <대역>` PowerShell 스캔 스크립트 실행. `ping -t` 무한 반복. 사내 모드에서 외부 도메인 차단 | 높음 | +| L16-2 | **Docker 관리** ✅ | `docker` 프리픽스. `docker ps` 실행 중 컨테이너 목록(이름·상태·포트). `docker all` 중지 포함 전체 목록. `docker images` 로컬 이미지 목록(크기·생성일). `docker stop/start <이름>` 터미널 없이 직접 실행. `docker logs <이름>` 터미널에서 로그. `docker shell <이름>` exec -it sh 접속. Docker 미설치 감지 | 높음 | +| L16-3 | **할 일 목록** ✅ | `todo` 프리픽스. `todo <내용>` 새 항목 추가. `todo done <번호>` 완료 토글. `todo del <번호>` 삭제. `todo clear` 완료 항목 정리. `todo clear all` 전체 삭제. `todo <검색어>` 키워드 필터. 번호만 입력 시 빠른 완료 토글. 미완료 먼저, 완료 항목 하단 그룹. `%APPDATA%\AxCopilot\todos.json` 로컬 저장 | 높음 | +| L16-4 | **텍스트 → 표 변환기** ✅ | `table` 프리픽스. 클립보드 텍스트 자동 읽기. 탭·CSV·공백 구분자 자동 감지. `table` → 마크다운 표. `table csv` → CSV 변환. `table html` → HTML `` 태그. `table flip` 행·열 전치(transpose). `table sort N` N번 열 기준 정렬(숫자/문자 자동 감지). 셀 너비 자동 정렬(PadRight). 미리보기 3줄 표시 | 높음 | diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index 4983ed1..ef46b29 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -287,6 +287,16 @@ public partial class App : System.Windows.Application // L15-4: Markdown 분석기 (prefix=md) commandResolver.RegisterHandler(new MdHandler()); + // ─── Phase L16 핸들러 ───────────────────────────────────────────────── + // L16-1: ping·tracert 실행기 (prefix=ping) + commandResolver.RegisterHandler(new PingHandler()); + // L16-2: Docker 컨테이너·이미지 조회 (prefix=docker) + commandResolver.RegisterHandler(new DockerHandler()); + // L16-3: 할 일 목록 (prefix=todo) + commandResolver.RegisterHandler(new TodoHandler()); + // L16-4: 텍스트 → 표 변환기 (prefix=table) + commandResolver.RegisterHandler(new TableHandler()); + // ─── 플러그인 로드 ──────────────────────────────────────────────────── var pluginHost = new PluginHost(settings, commandResolver); pluginHost.LoadAll(); diff --git a/src/AxCopilot/Handlers/DockerHandler.cs b/src/AxCopilot/Handlers/DockerHandler.cs new file mode 100644 index 0000000..1aed989 --- /dev/null +++ b/src/AxCopilot/Handlers/DockerHandler.cs @@ -0,0 +1,375 @@ +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L16-2: Docker 컨테이너·이미지 조회 핸들러. "docker" 프리픽스로 사용합니다. +/// +/// 예: docker → 실행 중 컨테이너 목록 +/// docker all → 모든 컨테이너 (중지 포함) +/// docker images → 로컬 이미지 목록 +/// docker ps → 컨테이너 목록 (docker ps 동일) +/// docker stop → 컨테이너 중지 +/// docker start → 컨테이너 시작 +/// docker logs → 컨테이너 로그 (터미널) +/// docker shell → 컨테이너 shell 접속 +/// Enter → 명령 실행 또는 컨테이너 ID 복사. +/// +public class DockerHandler : IActionHandler +{ + public string? Prefix => "docker"; + + public PluginMetadata Metadata => new( + "Docker", + "Docker 컨테이너·이미지 조회 — 시작·중지·로그·쉘", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (!IsDockerAvailable()) + { + items.Add(new LauncherItem("Docker를 찾을 수 없습니다", + "Docker Desktop이 설치되어 있는지 확인하세요", null, null, Symbol: "\uE756")); + items.Add(new LauncherItem("Docker Desktop 설치", + "https://www.docker.com/products/docker-desktop", + null, ("open_url", "https://www.docker.com/products/docker-desktop"), Symbol: "\uE756")); + return Task.FromResult>(items); + } + + if (string.IsNullOrWhiteSpace(q)) + { + var containers = GetContainers(running: true); + items.Add(new LauncherItem( + $"실행 중 컨테이너 {containers.Count}개", + "docker ps / docker all / docker images", + null, null, Symbol: "\uE756")); + + if (containers.Count == 0) + items.Add(new LauncherItem("실행 중인 컨테이너 없음", "docker all → 전체 목록", null, null, Symbol: "\uE946")); + else + foreach (var c in containers) + items.Add(MakeContainerItem(c)); + + items.Add(new LauncherItem("docker images", "로컬 이미지 목록", null, ("sub", "images"), Symbol: "\uE756")); + items.Add(new LauncherItem("docker all", "모든 컨테이너 목록", null, ("sub", "all"), Symbol: "\uE756")); + return Task.FromResult>(items); + } + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + switch (sub) + { + case "all": + case "ps": + { + var all = sub == "all"; + var containers = GetContainers(running: !all); + items.Add(new LauncherItem( + $"{(all ? "전체" : "실행 중")} 컨테이너 {containers.Count}개", + "", null, null, Symbol: "\uE756")); + foreach (var c in containers) + items.Add(MakeContainerItem(c)); + if (containers.Count == 0) + items.Add(new LauncherItem("컨테이너 없음", "", null, null, Symbol: "\uE946")); + break; + } + + case "images": + case "image": + case "img": + { + var images = GetImages(); + items.Add(new LauncherItem($"로컬 이미지 {images.Count}개", "", null, null, Symbol: "\uE756")); + foreach (var img in images) + items.Add(MakeImageItem(img)); + if (images.Count == 0) + items.Add(new LauncherItem("이미지 없음", "docker pull <이름> 으로 받기", null, null, Symbol: "\uE946")); + break; + } + + case "stop": + { + var name = parts.Length > 1 ? parts[1] : ""; + if (string.IsNullOrWhiteSpace(name)) + { + // 실행 중 컨테이너 목록 표시 → 클릭 시 stop + var running = GetContainers(running: true); + items.Add(new LauncherItem("중지할 컨테이너 선택", "Enter → 중지", null, null, Symbol: "\uE756")); + foreach (var c in running) + items.Add(new LauncherItem($"중지: {c.Name}", c.Image, + null, ("stop", c.Id), Symbol: "\uE756")); + } + else + { + items.Add(new LauncherItem($"컨테이너 중지: {name}", + $"docker stop {name} · Enter 실행", + null, ("stop", name), Symbol: "\uE756")); + } + break; + } + + case "start": + { + var name = parts.Length > 1 ? parts[1] : ""; + if (string.IsNullOrWhiteSpace(name)) + { + var stopped = GetContainers(running: false, stopped: true); + items.Add(new LauncherItem("시작할 컨테이너 선택", "Enter → 시작", null, null, Symbol: "\uE756")); + foreach (var c in stopped) + items.Add(new LauncherItem($"시작: {c.Name}", c.Image, + null, ("start", c.Id), Symbol: "\uE756")); + } + else + { + items.Add(new LauncherItem($"컨테이너 시작: {name}", + $"docker start {name} · Enter 실행", + null, ("start", name), Symbol: "\uE756")); + } + break; + } + + case "logs": + case "log": + { + var name = parts.Length > 1 ? parts[1] : ""; + if (string.IsNullOrWhiteSpace(name)) + { + var running = GetContainers(running: true); + foreach (var c in running) + items.Add(new LauncherItem($"로그: {c.Name}", "Enter → 터미널에서 로그 보기", + null, ("logs", c.Name), Symbol: "\uE756")); + } + else + { + items.Add(new LauncherItem($"로그: {name}", $"docker logs -f {name}", + null, ("logs", name), Symbol: "\uE756")); + } + break; + } + + case "shell": + case "exec": + case "sh": + { + var name = parts.Length > 1 ? parts[1] : ""; + if (string.IsNullOrWhiteSpace(name)) + { + var running = GetContainers(running: true); + foreach (var c in running) + items.Add(new LauncherItem($"쉘: {c.Name}", "Enter → 컨테이너 shell 접속", + null, ("shell", c.Name), Symbol: "\uE756")); + } + else + { + items.Add(new LauncherItem($"쉘 접속: {name}", $"docker exec -it {name} sh", + null, ("shell", name), Symbol: "\uE756")); + } + break; + } + + default: + { + // 컨테이너 이름 검색 + var all = GetContainers(running: false, stopped: true, all: true); + var found = all.Where(c => + c.Name.Contains(q, StringComparison.OrdinalIgnoreCase) || + c.Image.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (found.Count > 0) + foreach (var c in found) + items.Add(MakeContainerItem(c)); + else + items.Add(new LauncherItem($"'{q}' 컨테이너 없음", + "docker all → 전체 목록", null, null, Symbol: "\uE946")); + break; + } + } + + return Task.FromResult>(items); + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + switch (item.Data) + { + case ("stop", string id): + RunDockerSilent($"stop {id}"); + NotificationService.Notify("Docker", $"중지: {id}"); + break; + + case ("start", string id): + RunDockerSilent($"start {id}"); + NotificationService.Notify("Docker", $"시작: {id}"); + break; + + case ("logs", string name): + RunInTerminal($"docker logs -f {name}"); + break; + + case ("shell", string name): + RunInTerminal($"docker exec -it {name} sh"); + break; + + case ("copy", string text): + try + { + System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text)); + NotificationService.Notify("Docker", "복사됨"); + } + catch { /* 비핵심 */ } + break; + + case ("open_url", string url): + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { FileName = url, UseShellExecute = true }); + } + catch { /* 비핵심 */ } + break; + } + return Task.CompletedTask; + } + + // ── Docker 조회 ────────────────────────────────────────────────────────── + + private record DockerContainer(string Id, string Name, string Image, string Status, string Ports); + private record DockerImage(string Repository, string Tag, string Id, string Size, string Created); + + private static List GetContainers(bool running = true, bool stopped = false, bool all = false) + { + var result = new List(); + try + { + var filter = all || (!running && stopped) ? "-a" : (running ? "" : "--filter status=exited"); + var output = RunDockerOutput($"ps {filter} --format \"{{{{.ID}}}}\\t{{{{.Names}}}}\\t{{{{.Image}}}}\\t{{{{.Status}}}}\\t{{{{.Ports}}}}\""); + foreach (var line in output.Split('\n')) + { + var trimmed = line.Trim(); + if (string.IsNullOrWhiteSpace(trimmed)) continue; + var cols = trimmed.Split('\t'); + if (cols.Length < 4) continue; + result.Add(new DockerContainer( + Id: cols[0], + Name: cols[1], + Image: cols[2], + Status: cols[3], + Ports: cols.Length > 4 ? cols[4] : "")); + } + } + catch { /* Docker 없음 */ } + return result; + } + + private static List GetImages() + { + var result = new List(); + try + { + var output = RunDockerOutput("images --format \"{{.Repository}}\\t{{.Tag}}\\t{{.ID}}\\t{{.Size}}\\t{{.CreatedSince}}\""); + foreach (var line in output.Split('\n')) + { + var trimmed = line.Trim(); + if (string.IsNullOrWhiteSpace(trimmed)) continue; + var cols = trimmed.Split('\t'); + if (cols.Length < 4) continue; + result.Add(new DockerImage( + Repository: cols[0], + Tag: cols.Length > 1 ? cols[1] : "latest", + Id: cols.Length > 2 ? cols[2] : "", + Size: cols.Length > 3 ? cols[3] : "", + Created: cols.Length > 4 ? cols[4] : "")); + } + } + catch { /* Docker 없음 */ } + return result; + } + + private static string RunDockerOutput(string args) + { + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "docker", + Arguments = args, + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + }; + using var proc = System.Diagnostics.Process.Start(psi); + if (proc == null) return ""; + var output = proc.StandardOutput.ReadToEnd(); + proc.WaitForExit(5000); + return output; + } + + private static void RunDockerSilent(string args) + { + try + { + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "docker", Arguments = args, + UseShellExecute = false, CreateNoWindow = true, + }; + using var proc = System.Diagnostics.Process.Start(psi); + proc?.WaitForExit(10000); + } + catch { /* 비핵심 */ } + } + + private static bool IsDockerAvailable() + { + try + { + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "docker", Arguments = "version --format json", + UseShellExecute = false, CreateNoWindow = true, + RedirectStandardOutput = true, + }; + using var proc = System.Diagnostics.Process.Start(psi); + proc?.WaitForExit(3000); + return proc?.ExitCode == 0; + } + catch { return false; } + } + + private static LauncherItem MakeContainerItem(DockerContainer c) + { + var isRunning = c.Status.StartsWith("Up", StringComparison.OrdinalIgnoreCase); + var icon = isRunning ? "\uE768" : "\uE71A"; + var ports = string.IsNullOrWhiteSpace(c.Ports) ? "" : $" · {c.Ports}"; + return new LauncherItem(c.Name, + $"{c.Status}{ports} · {c.Image}", + null, ("copy", c.Id), Symbol: icon); + } + + private static LauncherItem MakeImageItem(DockerImage img) + { + var name = img.Tag == "" ? img.Repository : $"{img.Repository}:{img.Tag}"; + return new LauncherItem(name, $"{img.Size} · {img.Created} · {img.Id[..Math.Min(12, img.Id.Length)]}", + null, ("copy", name), Symbol: "\uE756"); + } + + private static void RunInTerminal(string cmd) + { + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "cmd", Arguments = $"/K {cmd}", UseShellExecute = true, + }); + } + catch { /* 비핵심 */ } + } +} diff --git a/src/AxCopilot/Handlers/PingHandler.cs b/src/AxCopilot/Handlers/PingHandler.cs new file mode 100644 index 0000000..24335e5 --- /dev/null +++ b/src/AxCopilot/Handlers/PingHandler.cs @@ -0,0 +1,271 @@ +using System.Net; +using System.Net.NetworkInformation; +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L16-1: ping·tracert 빠른 실행 핸들러. "ping" 프리픽스로 사용합니다. +/// +/// 예: ping 8.8.8.8 → ping 결과 (4회) +/// ping google.com → 도메인 ping +/// ping trace 8.8.8.8 → tracert 실행 +/// ping local → 로컬 네트워크 어댑터 정보 +/// ping scan 192.168.1.0 → 간단 네트워크 스캔 (1~254) +/// Enter → 결과 복사 또는 외부 터미널 실행. +/// 사내 모드: 외부 IP/도메인 ping 차단. +/// +public class PingHandler : IActionHandler +{ + public string? Prefix => "ping"; + + public PluginMetadata Metadata => new( + "Ping", + "ping·tracert 빠른 실행 — 네트워크 연결 확인·경로 추적", + "1.0", + "AX"); + + private static readonly string[] QuickTargets = + ["localhost", "8.8.8.8", "1.1.1.1", "google.com", "192.168.1.1"]; + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem("ping / tracert 실행기", + "예: ping 8.8.8.8 / ping trace 192.168.1.1 / ping local / ping scan 192.168.1", + null, null, Symbol: "\uE968")); + items.Add(new LauncherItem("── 빠른 대상 ──", "", null, null, Symbol: "\uE968")); + foreach (var t in QuickTargets) + items.Add(new LauncherItem($"ping {t}", t, null, ("ping", t), Symbol: "\uE968")); + return Task.FromResult>(items); + } + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + // local → 로컬 어댑터 정보 + if (sub == "local" || sub == "lo") + { + items.AddRange(BuildLocalNetworkItems()); + return Task.FromResult>(items); + } + + // trace / tracert / traceroute + if (sub is "trace" or "tracert" or "traceroute") + { + var target = parts.Length > 1 ? parts[1] : ""; + if (string.IsNullOrWhiteSpace(target)) + { + items.Add(new LauncherItem("대상 주소 입력", "예: ping trace 8.8.8.8", null, null, Symbol: "\uE783")); + } + else + { + var blocked = CheckInternalMode(target); + if (blocked != null) { items.Add(blocked); return Task.FromResult>(items); } + items.Add(new LauncherItem($"tracert {target}", "Enter → 터미널에서 tracert 실행", + null, ("tracert", target), Symbol: "\uE968")); + items.Add(new LauncherItem("터미널 실행", $"tracert {target}", + null, ("tracert", target), Symbol: "\uE968")); + } + return Task.FromResult>(items); + } + + // scan → 간단 스캔 (비동기 결과는 실행 시 터미널) + if (sub == "scan") + { + var network = parts.Length > 1 ? parts[1] : "192.168.1"; + items.Add(new LauncherItem($"네트워크 스캔: {network}.1~254", + "Enter → 터미널에서 ping 스캔 스크립트 실행", + null, ("scan", network), Symbol: "\uE968")); + items.Add(new LauncherItem("팁", "결과가 많을 수 있습니다 — 터미널에서 확인하세요", + null, null, Symbol: "\uE946")); + return Task.FromResult>(items); + } + + // 직접 ping 대상 + var host = parts[0]; + var blocked2 = CheckInternalMode(host); + if (blocked2 != null) { items.Add(blocked2); return Task.FromResult>(items); } + + items.Add(new LauncherItem($"ping {host}", + "Enter → 비동기 ping (4회) 실행", + null, ("ping", host), Symbol: "\uE968")); + items.Add(new LauncherItem($"tracert {host}", + "Enter → 터미널에서 tracert 실행", + null, ("tracert", host), Symbol: "\uE968")); + items.Add(new LauncherItem($"ping 연속 {host}", + "ping -t (무한 반복) — 터미널", + null, ("ping_t", host), Symbol: "\uE968")); + + // 즉시 1회 ping 시도 + var pingResult = TryPingOnce(host); + if (pingResult != null) + { + items.Insert(0, pingResult); + } + + return Task.FromResult>(items); + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + switch (item.Data) + { + case ("ping", string host): + RunInTerminal($"ping {host}"); + break; + + case ("ping_t", string host): + RunInTerminal($"ping -t {host}"); + break; + + case ("tracert", string host): + RunInTerminal($"tracert {host}"); + break; + + case ("scan", string network): + // PowerShell로 간단 스캔 + var ps = $"1..254 | ForEach-Object {{ $ip = '{network}.$_'; if (Test-Connection $ip -Count 1 -Quiet -TimeoutSeconds 1) {{ Write-Host \"$ip is UP\" }} }}; Read-Host 'Press Enter'"; + RunInTerminal($"powershell -NoExit -Command \"{ps}\"", usePs: true); + break; + + case ("copy", string text): + try + { + System.Windows.Application.Current.Dispatcher.Invoke( + () => Clipboard.SetText(text)); + NotificationService.Notify("Ping", "복사됨"); + } + catch { /* 비핵심 */ } + break; + } + return Task.CompletedTask; + } + + // ── 헬퍼 ───────────────────────────────────────────────────────────────── + + private static List BuildLocalNetworkItems() + { + var items = new List(); + try + { + var ifaces = NetworkInterface.GetAllNetworkInterfaces() + .Where(n => n.OperationalStatus == OperationalStatus.Up && + n.NetworkInterfaceType != NetworkInterfaceType.Loopback) + .ToList(); + + items.Add(new LauncherItem($"로컬 네트워크 어댑터 {ifaces.Count}개", "", null, null, Symbol: "\uE968")); + + foreach (var iface in ifaces) + { + var ipProps = iface.GetIPProperties(); + var ipv4 = ipProps.UnicastAddresses + .FirstOrDefault(a => a.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork); + var gateway = ipProps.GatewayAddresses.FirstOrDefault()?.Address?.ToString() ?? "없음"; + + if (ipv4 == null) continue; + + var ip = ipv4.Address.ToString(); + var mask = ipv4.IPv4Mask.ToString(); + var label = $"{iface.Name} {ip}"; + var sub2 = $"넷마스크 {mask} · 게이트웨이 {gateway}"; + items.Add(new LauncherItem(label, sub2, null, ("copy", ip), Symbol: "\uE968")); + } + + // 외부 IP 안내 (사외 모드에서만) + var settings = (System.Windows.Application.Current as App)?.SettingsService?.Settings; + if (settings?.InternalModeEnabled == false) + items.Add(new LauncherItem("외부 IP 조회", "ping trace 8.8.8.8 으로 경로 확인", + null, null, Symbol: "\uE968")); + } + catch (Exception ex) + { + items.Add(new LauncherItem("네트워크 정보 조회 오류", ex.Message, null, null, Symbol: "\uE783")); + } + return items; + } + + private static LauncherItem? TryPingOnce(string host) + { + try + { + using var p = new Ping(); + var reply = p.Send(host, 1000); + if (reply.Status == IPStatus.Success) + { + var label = $"✓ 응답 {reply.RoundtripTime}ms"; + return new LauncherItem(label, $"TTL {reply.Options?.Ttl ?? 0} · {host}", + null, ("copy", $"{reply.RoundtripTime}ms"), Symbol: "\uE968"); + } + return new LauncherItem($"✗ 응답 없음 ({reply.Status})", host, null, null, Symbol: "\uE783"); + } + catch + { + return null; // 오류 시 무시 + } + } + + private static LauncherItem? CheckInternalMode(string host) + { + var settings = (System.Windows.Application.Current as App)?.SettingsService?.Settings; + if (settings?.InternalModeEnabled != true) return null; + + // 내부 주소는 허용 + if (host.StartsWith("192.168.", StringComparison.Ordinal) || + host.StartsWith("10.", StringComparison.Ordinal) || + host.StartsWith("172.", StringComparison.Ordinal) || + host.Equals("localhost", StringComparison.OrdinalIgnoreCase) || + host.StartsWith("127.", StringComparison.Ordinal)) + return null; + + if (IPAddress.TryParse(host, out _)) + return null; // IP 주소 → 내부로 간주 허용 + + return new LauncherItem("사내 모드 — 외부 도메인 차단", + "사외 모드에서 외부 주소 ping 가능. 설정에서 변경하세요.", + null, null, Symbol: "\uE783"); + } + + private static void RunInTerminal(string cmd, bool usePs = false) + { + try + { + var wtPath = FindExe("wt.exe"); + if (wtPath != null && !usePs) + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = wtPath, Arguments = $"cmd /K {cmd}", UseShellExecute = false, + }); + } + else + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = usePs ? "powershell" : "cmd", + Arguments = usePs ? $"-NoExit -Command \"{cmd}\"" : $"/K {cmd}", + UseShellExecute = true, + }); + } + } + catch { /* 비핵심 */ } + } + + private static string? FindExe(string name) + { + foreach (var dir in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(';')) + { + var full = System.IO.Path.Combine(dir.Trim(), name); + if (System.IO.File.Exists(full)) return full; + } + return null; + } +} diff --git a/src/AxCopilot/Handlers/TableHandler.cs b/src/AxCopilot/Handlers/TableHandler.cs new file mode 100644 index 0000000..b718b2f --- /dev/null +++ b/src/AxCopilot/Handlers/TableHandler.cs @@ -0,0 +1,376 @@ +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L16-4: 텍스트 → 표 변환 핸들러. "table" 프리픽스로 사용합니다. +/// +/// 클립보드 텍스트(탭 구분, CSV, 공백 정렬)를 표로 변환합니다. +/// +/// 예: table → 클립보드 → 마크다운 표 변환 +/// table csv → 마크다운 → CSV 변환 +/// table html → 마크다운 → HTML 테이블 변환 +/// table flip → 행·열 전치(transpose) +/// table sort 2 → 2번 열 기준 정렬 +/// table add <헤더> → 새 행 추가 (탭 구분) +/// Enter → 결과를 클립보드에 복사. +/// +public class TableHandler : IActionHandler +{ + public string? Prefix => "table"; + + public PluginMetadata Metadata => new( + "Table", + "텍스트·CSV → 마크다운·HTML 표 변환 — 전치 · 정렬 · 추가", + "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("텍스트 → 표 변환기", + "클립보드 탭·CSV·공백 구분 텍스트를 표로 변환 · table csv / html / flip / sort N", + null, null, Symbol: "\uE81E")); + items.Add(new LauncherItem("table", "→ 마크다운 표", null, null, Symbol: "\uE81E")); + items.Add(new LauncherItem("table csv", "→ CSV 변환", null, null, Symbol: "\uE81E")); + items.Add(new LauncherItem("table html", "→ HTML 테이블", null, null, Symbol: "\uE81E")); + items.Add(new LauncherItem("table flip", "행·열 전치 (transpose)", null, null, Symbol: "\uE81E")); + items.Add(new LauncherItem("table sort 2","2열 기준 정렬", null, null, Symbol: "\uE81E")); + + if (string.IsNullOrWhiteSpace(clipboard)) + { + items.Add(new LauncherItem("클립보드가 비어 있습니다", + "탭·쉼표·공백 구분 표 데이터를 복사한 뒤 사용하세요", + null, null, Symbol: "\uE946")); + return Task.FromResult>(items); + } + + // 미리보기 + var previewRows = ParseTable(clipboard); + if (previewRows.Count > 0) + { + var md = ToMarkdown(previewRows); + items.Add(new LauncherItem($"마크다운 표 변환 ({previewRows.Count}행 × {previewRows[0].Count}열)", + "Enter → 변환 결과 복사", null, ("copy", md), Symbol: "\uE81E")); + } + return Task.FromResult>(items); + } + + if (string.IsNullOrWhiteSpace(clipboard)) + { + items.Add(new LauncherItem("클립보드가 비어 있습니다", + "표 데이터를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946")); + return Task.FromResult>(items); + } + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + var rows = ParseTable(clipboard); + + if (rows.Count == 0) + { + items.Add(new LauncherItem("표로 변환할 수 없습니다", + "탭·쉼표·공백 구분 데이터가 아닌 것 같습니다", + null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + switch (sub) + { + case "csv": + { + var csv = ToCsv(rows); + items.Add(new LauncherItem($"CSV 변환 ({rows.Count}행 × {rows[0].Count}열)", + "Enter → CSV 복사", null, ("copy", csv), Symbol: "\uE81E")); + AddPreview(items, csv); + break; + } + + case "html": + { + var html = ToHtml(rows); + items.Add(new LauncherItem($"HTML 테이블 ({rows.Count}행 × {rows[0].Count}열)", + "Enter → HTML 복사", null, ("copy", html), Symbol: "\uE81E")); + AddPreview(items, html); + break; + } + + case "md": + case "markdown": + { + var md = ToMarkdown(rows); + items.Add(new LauncherItem($"마크다운 표 ({rows.Count}행 × {rows[0].Count}열)", + "Enter → 복사", null, ("copy", md), Symbol: "\uE81E")); + AddPreview(items, md); + break; + } + + case "flip": + case "transpose": + { + var flipped = Transpose(rows); + var md = ToMarkdown(flipped); + items.Add(new LauncherItem($"전치됨 ({flipped.Count}행 × {flipped[0].Count}열)", + "Enter → 마크다운 복사", null, ("copy", md), Symbol: "\uE81E")); + var csv = ToCsv(flipped); + items.Add(new LauncherItem("CSV로 복사", $"{flipped.Count}행 × {flipped[0].Count}열", + null, ("copy", csv), Symbol: "\uE81E")); + break; + } + + case "sort": + { + var colIdx = parts.Length > 1 && int.TryParse(parts[1], out var ci) ? ci - 1 : 0; + var sorted = SortByColumn(rows, colIdx); + var md = ToMarkdown(sorted); + items.Add(new LauncherItem($"{colIdx + 1}열 기준 정렬", + "Enter → 마크다운 복사", null, ("copy", md), Symbol: "\uE81E")); + AddPreview(items, md); + break; + } + + default: + { + // 기본: 마크다운 변환 + var md = ToMarkdown(rows); + items.Add(new LauncherItem($"마크다운 표 ({rows.Count}행 × {rows[0].Count}열)", + "Enter → 복사", null, ("copy", md), Symbol: "\uE81E")); + var csv = ToCsv(rows); + items.Add(new LauncherItem("CSV로 복사", $"{rows.Count}행", + null, ("copy", csv), Symbol: "\uE81E")); + var html = ToHtml(rows); + items.Add(new LauncherItem("HTML로 복사", "테이블 태그", + null, ("copy", html), Symbol: "\uE81E")); + 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("Table", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + return Task.CompletedTask; + } + + // ── 파서 ───────────────────────────────────────────────────────────────── + + /// 탭, 쉼표(CSV), 연속 공백 순으로 자동 감지하여 표로 파싱 + private static List> ParseTable(string text) + { + var lines = text.Split('\n') + .Select(l => l.TrimEnd('\r')) + .Where(l => !string.IsNullOrWhiteSpace(l)) + .ToList(); + + if (lines.Count == 0) return new List>(); + + // 구분자 자동 감지 + var tabCount = lines[0].Count(c => c == '\t'); + var commaCount = lines[0].Count(c => c == ','); + + char delimiter; + if (tabCount > 0) delimiter = '\t'; + else if (commaCount > 0) delimiter = ','; + else delimiter = '\t'; // 공백 구분은 단순 분할 + + var rows = new List>(); + foreach (var line in lines) + { + var cols = delimiter == ',' + ? ParseCsvLine(line) + : line.Split(delimiter).Select(c => c.Trim()).ToList(); + rows.Add(cols); + } + + // 열 수 통일 (가장 많은 열 수 기준 패딩) + var maxCols = rows.Max(r => r.Count); + foreach (var row in rows) + while (row.Count < maxCols) row.Add(""); + + return rows; + } + + private static List ParseCsvLine(string line) + { + var result = new List(); + var current = new StringBuilder(); + var inQuote = false; + + for (var i = 0; i < line.Length; i++) + { + var c = line[i]; + if (c == '"') + { + if (inQuote && i + 1 < line.Length && line[i + 1] == '"') { current.Append('"'); i++; } + else inQuote = !inQuote; + } + else if (c == ',' && !inQuote) + { + result.Add(current.ToString()); + current.Clear(); + } + else current.Append(c); + } + result.Add(current.ToString()); + return result; + } + + // ── 변환 ───────────────────────────────────────────────────────────────── + + private static string ToMarkdown(List> rows) + { + if (rows.Count == 0) return ""; + var sb = new StringBuilder(); + var maxCols = rows.Max(r => r.Count); + + // 각 열 최대 너비 + var widths = new int[maxCols]; + for (var c = 0; c < maxCols; c++) + widths[c] = rows.Max(r => c < r.Count ? r[c].Length : 0); + + // 헤더 + sb.Append('|'); + for (var c = 0; c < maxCols; c++) + sb.Append($" {rows[0][c].PadRight(widths[c])} |"); + sb.AppendLine(); + + // 구분선 + sb.Append('|'); + for (var c = 0; c < maxCols; c++) + sb.Append($" {new string('-', Math.Max(widths[c], 3))} |"); + sb.AppendLine(); + + // 데이터 행 + for (var r = 1; r < rows.Count; r++) + { + sb.Append('|'); + for (var c = 0; c < maxCols; c++) + { + var cell = c < rows[r].Count ? rows[r][c] : ""; + sb.Append($" {cell.PadRight(widths[c])} |"); + } + sb.AppendLine(); + } + + return sb.ToString().TrimEnd(); + } + + private static string ToCsv(List> rows) + { + var sb = new StringBuilder(); + foreach (var row in rows) + { + sb.AppendLine(string.Join(",", row.Select(c => + c.Contains(',') || c.Contains('"') || c.Contains('\n') + ? $"\"{c.Replace("\"", "\"\"")}\"" : c))); + } + return sb.ToString().TrimEnd(); + } + + private static string ToHtml(List> rows) + { + if (rows.Count == 0) return ""; + var sb = new StringBuilder(); + sb.AppendLine("
"); + + // 헤더 + sb.AppendLine(" "); + foreach (var cell in rows[0]) + sb.AppendLine($" "); + sb.AppendLine(" "); + + // 바디 + sb.AppendLine(" "); + for (var r = 1; r < rows.Count; r++) + { + sb.AppendLine(" "); + foreach (var cell in rows[r]) + sb.AppendLine($" "); + sb.AppendLine(" "); + } + sb.AppendLine(" "); + sb.Append("
{EscHtml(cell)}
{EscHtml(cell)}
"); + return sb.ToString(); + } + + private static List> Transpose(List> rows) + { + if (rows.Count == 0) return rows; + var maxCols = rows.Max(r => r.Count); + var result = new List>(); + for (var c = 0; c < maxCols; c++) + { + var row = new List(); + for (var r = 0; r < rows.Count; r++) + row.Add(c < rows[r].Count ? rows[r][c] : ""); + result.Add(row); + } + return result; + } + + private static List> SortByColumn(List> rows, int colIdx) + { + if (rows.Count <= 1) return rows; + var header = rows[0]; + var data = rows.Skip(1).ToList(); + data.Sort((a, b) => + { + var va = colIdx < a.Count ? a[colIdx] : ""; + var vb = colIdx < b.Count ? b[colIdx] : ""; + // 숫자이면 숫자 비교 + if (double.TryParse(va, out var na) && double.TryParse(vb, out var nb)) + return na.CompareTo(nb); + return string.Compare(va, vb, StringComparison.OrdinalIgnoreCase); + }); + var result = new List> { header }; + result.AddRange(data); + return result; + } + + // ── 헬퍼 ───────────────────────────────────────────────────────────────── + + private static void AddPreview(List items, string text) + { + var lines = text.Split('\n').Take(3); + foreach (var line in lines) + { + var t = line.Length > 60 ? line[..60] + "…" : line; + if (!string.IsNullOrWhiteSpace(t)) + items.Add(new LauncherItem(t, "", null, null, Symbol: "\uE81E")); + } + } + + private static string EscHtml(string s) => + s.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """); +} diff --git a/src/AxCopilot/Handlers/TodoHandler.cs b/src/AxCopilot/Handlers/TodoHandler.cs new file mode 100644 index 0000000..ca185c0 --- /dev/null +++ b/src/AxCopilot/Handlers/TodoHandler.cs @@ -0,0 +1,260 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L16-3: 간단 할 일 목록 핸들러. "todo" 프리픽스로 사용합니다. +/// +/// 예: todo → 전체 할 일 목록 +/// todo 보고서 작성 → 새 항목 추가 +/// todo done 1 → 1번 항목 완료 처리 +/// todo del 1 → 1번 항목 삭제 +/// todo clear → 완료 항목 모두 삭제 +/// todo clear all → 전체 삭제 +/// todo <검색어> → 키워드 필터 +/// Enter → 완료 토글 또는 항목 삭제. +/// 저장: %APPDATA%\AxCopilot\todos.json +/// +public class TodoHandler : IActionHandler +{ + public string? Prefix => "todo"; + + public PluginMetadata Metadata => new( + "Todo", + "할 일 목록 — 추가 · 완료 · 삭제 · 검색", + "1.0", + "AX"); + + private static readonly string DataPath = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "AxCopilot", "todos.json"); + + private static readonly JsonSerializerOptions JsonOpts = new() + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + private record TodoItem( + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("text")] string Text, + [property: JsonPropertyName("done")] bool Done, + [property: JsonPropertyName("at")] string CreatedAt); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + var todos = LoadTodos(); + + if (string.IsNullOrWhiteSpace(q)) + { + var pending = todos.Count(t => !t.Done); + var completed = todos.Count(t => t.Done); + items.Add(new LauncherItem( + $"할 일 {pending}개 완료 {completed}개", + "todo <내용> → 추가 / todo done <번호> → 완료 / todo del <번호> → 삭제", + null, null, Symbol: "\uE762")); + + if (todos.Count == 0) + { + items.Add(new LauncherItem("할 일이 없습니다", "todo <내용> 을 입력하면 추가됩니다", + null, null, Symbol: "\uE946")); + return Task.FromResult>(items); + } + + // 미완료 먼저, 완료 항목은 하단 + foreach (var t in todos.Where(t => !t.Done)) + items.Add(MakeTodoItem(t)); + + if (completed > 0) + { + items.Add(new LauncherItem("── 완료됨 ──", $"{completed}개 / todo clear → 정리", + null, ("clear_done", ""), Symbol: "\uE762")); + foreach (var t in todos.Where(t => t.Done)) + items.Add(MakeTodoItem(t)); + } + + items.Add(new LauncherItem("완료 항목 삭제", "todo clear — 완료 항목 정리", + null, ("clear_done", ""), Symbol: "\uE762")); + return Task.FromResult>(items); + } + + var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + // done / check / complete + if (sub is "done" or "check" or "complete" or "✓") + { + var num = parts.Length > 1 && int.TryParse(parts[1].Trim(), out var n) ? n : -1; + if (num < 0) + { + items.Add(new LauncherItem("번호를 입력하세요", "예: todo done 2", null, null, Symbol: "\uE783")); + } + else + { + var target = todos.FirstOrDefault(t => t.Id == num); + if (target == null) + items.Add(new LauncherItem("없는 항목", $"#{num} 항목이 없습니다", null, null, Symbol: "\uE783")); + else + items.Add(new LauncherItem( + target.Done ? $"#{num} 미완료로 되돌리기" : $"#{num} 완료 처리", + target.Text, null, ("toggle", num.ToString()), Symbol: "\uE762")); + } + return Task.FromResult>(items); + } + + // del / delete / remove / rm + if (sub is "del" or "delete" or "remove" or "rm") + { + var num = parts.Length > 1 && int.TryParse(parts[1].Trim(), out var n) ? n : -1; + if (num < 0) + { + items.Add(new LauncherItem("번호를 입력하세요", "예: todo del 3", null, null, Symbol: "\uE783")); + } + else + { + var target = todos.FirstOrDefault(t => t.Id == num); + if (target == null) + items.Add(new LauncherItem("없는 항목", $"#{num} 항목이 없습니다", null, null, Symbol: "\uE783")); + else + items.Add(new LauncherItem($"#{num} 삭제", + target.Text, null, ("delete", num.ToString()), Symbol: "\uE762")); + } + return Task.FromResult>(items); + } + + // clear — 완료 항목 삭제 / clear all → 전체 삭제 + if (sub == "clear") + { + var isAll = parts.Length > 1 && parts[1].ToLowerInvariant() == "all"; + if (isAll) + items.Add(new LauncherItem("전체 삭제", $"할 일 {todos.Count}개 모두 삭제 · Enter 실행", + null, ("clear_all", ""), Symbol: "\uE762")); + else + items.Add(new LauncherItem("완료 항목 삭제", + $"완료 {todos.Count(t => t.Done)}개 삭제 · Enter 실행", + null, ("clear_done", ""), Symbol: "\uE762")); + return Task.FromResult>(items); + } + + // 숫자만 → 완료 토글 단축 + if (int.TryParse(q, out var idNum)) + { + var target = todos.FirstOrDefault(t => t.Id == idNum); + if (target != null) + { + items.Add(new LauncherItem( + target.Done ? $"#{idNum} 미완료로 되돌리기" : $"#{idNum} 완료 처리", + target.Text, null, ("toggle", idNum.ToString()), Symbol: "\uE762")); + return Task.FromResult>(items); + } + } + + // 검색 또는 새 항목 추가 + var filtered = todos.Where(t => t.Text.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList(); + if (filtered.Count > 0) + { + items.Add(new LauncherItem($"'{q}' 검색 결과 {filtered.Count}개", "", null, null, Symbol: "\uE762")); + foreach (var t in filtered) + items.Add(MakeTodoItem(t)); + } + + // 새 항목 추가 제안 + items.Add(new LauncherItem($"새 할 일 추가: {q}", + "Enter → 목록에 추가", + null, ("add", q), Symbol: "\uE710")); + + return Task.FromResult>(items); + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + var todos = LoadTodos(); + + switch (item.Data) + { + case ("add", string text): + var nextId = todos.Count > 0 ? todos.Max(t => t.Id) + 1 : 1; + todos.Add(new TodoItem(nextId, text, false, + DateTime.Now.ToString("yyyy-MM-dd HH:mm"))); + SaveTodos(todos); + NotificationService.Notify("Todo", $"추가됨: {text}"); + break; + + case ("toggle", string idStr) when int.TryParse(idStr, out var id): + var idx = todos.FindIndex(t => t.Id == id); + if (idx >= 0) + { + todos[idx] = todos[idx] with { Done = !todos[idx].Done }; + SaveTodos(todos); + var state = todos[idx].Done ? "완료" : "미완료"; + NotificationService.Notify("Todo", $"#{id} {state}"); + } + break; + + case ("delete", string idStr) when int.TryParse(idStr, out var id): + var before = todos.Count; + todos.RemoveAll(t => t.Id == id); + if (todos.Count < before) + { + SaveTodos(todos); + NotificationService.Notify("Todo", $"#{id} 삭제됨"); + } + break; + + case ("clear_done", _): + var doneCount = todos.RemoveAll(t => t.Done); + SaveTodos(todos); + NotificationService.Notify("Todo", $"완료 항목 {doneCount}개 삭제됨"); + break; + + case ("clear_all", _): + SaveTodos(new List()); + NotificationService.Notify("Todo", "전체 삭제됨"); + break; + } + return Task.CompletedTask; + } + + // ── 저장/불러오기 ───────────────────────────────────────────────────────── + + private static List LoadTodos() + { + try + { + if (!System.IO.File.Exists(DataPath)) return new List(); + var json = System.IO.File.ReadAllText(DataPath, System.Text.Encoding.UTF8); + return JsonSerializer.Deserialize>(json, JsonOpts) ?? new List(); + } + catch { return new List(); } + } + + private static void SaveTodos(List todos) + { + try + { + System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(DataPath)!); + System.IO.File.WriteAllText(DataPath, + JsonSerializer.Serialize(todos, JsonOpts), + System.Text.Encoding.UTF8); + } + catch { /* 비핵심 */ } + } + + // ── 헬퍼 ───────────────────────────────────────────────────────────────── + + private static LauncherItem MakeTodoItem(TodoItem t) + { + var icon = t.Done ? "\uE73E" : "\uECC5"; + var prefix = t.Done ? $"[✓] #{t.Id}" : $"[ ] #{t.Id}"; + var subtitle = $"{t.CreatedAt} · done {t.Id} = 완료 / del {t.Id} = 삭제"; + return new LauncherItem($"{prefix} {t.Text}", subtitle, + null, ("toggle", t.Id.ToString()), Symbol: icon); + } +}