From 7ca34d2ef40bd6cb98dbe35e6cfb5475db3db99f Mon Sep 17 00:00:00 2001 From: lacvet Date: Sat, 4 Apr 2026 13:48:53 +0900 Subject: [PATCH] =?UTF-8?q?[Phase=20L7]=20=EB=9F=B0=EC=B2=98=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C=EC=9E=90=C2=B7=EA=B8=80=EB=A1=9C=EB=B2=8C=20=EB=8F=84?= =?UTF-8?q?=EA=B5=AC=204=EC=A2=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHandler.cs (210줄, prefix=git): - git status/log/branch/stash/diff/pull 서브커맨드 - 작업 폴더에서 .git 루트 자동 탐색 (상위 디렉토리 재귀) - 비동기 Process 실행 + 출력 클립보드 복사 - pull은 별도 PowerShell 터미널로 실행 RegexHandler.cs (310줄, prefix=re): - 클립보드 텍스트에 패턴 적용, 매치 목록 LauncherItem 표시 - /old/new/ 치환 모드, flags:im 플래그 지정 - re patterns: 이메일/URL/전화/날짜/IP/UUID 등 14종 라이브러리 TimeZoneHandler.cs (220줄, prefix=tz): - 15개 주요 도시 현재 시각 실시간 표시 - tz <도시>, tz HH:mm to <도시>, tz meeting HH:mm 모드 - TimeZoneInfo 기반 UTC 오프셋 계산 NetDiagHandler.cs (260줄, prefix=net): - 로컬 어댑터 IP/MAC 즉시 표시 - net ping (4회, 사내 모드 내부 호스트 제한) - net dns A 레코드 조회 (사외 모드에서 외부 도메인) - InternalModeEnabled 사내/사외 모드 연동 App.xaml.cs: Phase L7 핸들러 4종 등록 docs/LAUNCHER_ROADMAP.md: Phase L7 섹션 추가 ✅ 빌드: 경고 0, 오류 0 --- docs/LAUNCHER_ROADMAP.md | 13 + src/AxCopilot/App.xaml.cs | 10 + src/AxCopilot/Handlers/GitHandler.cs | 251 +++++++++++++++ src/AxCopilot/Handlers/NetDiagHandler.cs | 365 ++++++++++++++++++++++ src/AxCopilot/Handlers/RegexHandler.cs | 340 ++++++++++++++++++++ src/AxCopilot/Handlers/TimeZoneHandler.cs | 259 +++++++++++++++ 6 files changed, 1238 insertions(+) create mode 100644 src/AxCopilot/Handlers/GitHandler.cs create mode 100644 src/AxCopilot/Handlers/NetDiagHandler.cs create mode 100644 src/AxCopilot/Handlers/RegexHandler.cs create mode 100644 src/AxCopilot/Handlers/TimeZoneHandler.cs diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md index f561aa2..79ac318 100644 --- a/docs/LAUNCHER_ROADMAP.md +++ b/docs/LAUNCHER_ROADMAP.md @@ -217,3 +217,16 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label | Everything 미연동 | ✓ EverythingHandler 구현 (`es` 프리픽스, P/Invoke, graceful fallback) | | 플러그인 설치 수동 | ✓ PluginHost.InstallFromZip() 로컬 zip 자동 추출·등록 (URL 제외: 사내 보안) | | 이미지 클립보드 미지원 | ✓ PNG→Base64 DPAPI 암호화 저장/복원, 앱 재시작 후 이미지 히스토리 유지 | + +--- + +## Phase L7 — 런처 개발자·글로벌 도구 확장 (v2.0.1) ✅ 완료 + +> **방향**: 개발자가 런처를 벗어나지 않고 Git 조회·정규식 테스트·시간대 변환·네트워크 진단을 수행. + +| # | 기능 | 설명 | 우선순위 | +|---|------|------|----------| +| L7-1 | **Git 빠른 조회** ✅ | `git` 프리픽스. `git status/log/branch/stash/diff/pull` 서브커맨드. 현재 AX Agent 작업 폴더에서 `.git` 루트 자동 탐색. 비동기 Process 실행 → 출력 클립보드 복사. `pull`은 별도 PowerShell 터미널로 실행. 브랜치명·변경 파일 수 실시간 요약 표시 | 높음 | +| L7-2 | **정규식 테스터** ✅ | `re` 프리픽스. 클립보드 텍스트에 패턴 적용 → 매치 목록 표시. `/old/new/` 치환 모드. `flags:im` 플래그 지정(대소문자·멀티라인·단일라인). `re patterns` 서브커맨드로 이메일·URL·전화번호·날짜·IP·UUID 등 14종 공통 패턴 라이브러리. 매치 결과·치환 결과 클립보드 복사 | 높음 | +| L7-3 | **시간대 변환기** ✅ | `tz` 프리픽스. 15개 주요 도시(서울·도쿄·베이징·뉴욕·LA·런던·파리·시드니 등) 현재 시각 실시간 표시. `tz <도시>` 단일 조회 + 서울 기준 시차 표시. `tz 14:00 to la` 시각 변환. `tz meeting 09:00` 미팅 시각 전 도시 동시 표시. Enter → 클립보드 복사 | 중간 | +| L7-4 | **네트워크 진단** ✅ | `net` 프리픽스. 로컬 어댑터 IP/MAC 즉시 표시. `net ping <호스트>` 4회 핑 테스트(사내 모드: 내부 호스트만). `net dns <도메인>` DNS A 레코드 조회(사외 모드에서 외부 도메인). `net ip`/`net adapter` 상세 정보. 기존 `port` 핸들러(포트·프로세스 조회)와 역할 분리 | 중간 | diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index 477dde7..b100401 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -197,6 +197,16 @@ public partial class App : System.Windows.Application // Phase L6-3: 컨텍스트 감지 자동완성 (prefix=ctx) commandResolver.RegisterHandler(new ContextHandler()); + // ─── Phase L7 핸들러 ────────────────────────────────────────────────── + // L7-1: Git 빠른 조회 (prefix=git) + commandResolver.RegisterHandler(new GitHandler()); + // L7-2: 정규식 테스터 (prefix=re) + commandResolver.RegisterHandler(new RegexHandler()); + // L7-3: 시간대 변환기 (prefix=tz) + commandResolver.RegisterHandler(new TimeZoneHandler()); + // L7-4: 네트워크 진단 (prefix=net) + commandResolver.RegisterHandler(new NetDiagHandler()); + // ─── 플러그인 로드 ──────────────────────────────────────────────────── var pluginHost = new PluginHost(settings, commandResolver); pluginHost.LoadAll(); diff --git a/src/AxCopilot/Handlers/GitHandler.cs b/src/AxCopilot/Handlers/GitHandler.cs new file mode 100644 index 0000000..5c0ea41 --- /dev/null +++ b/src/AxCopilot/Handlers/GitHandler.cs @@ -0,0 +1,251 @@ +using System.Diagnostics; +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L7-1: Git 빠른 조회 핸들러. "git" 프리픽스로 사용합니다. +/// +/// 예: git → 최근 작업 폴더(또는 현재 앱 폴더)의 git 상태 요약 +/// git status → git status --short 출력 +/// git log → 최근 커밋 10개 +/// git branch → 브랜치 목록 (현재 브랜치 강조) +/// git stash → stash 목록 +/// git diff → git diff --stat 요약 +/// git pull → git pull 실행 +/// Enter → 결과를 클립보드에 복사. +/// +public class GitHandler : IActionHandler +{ + public string? Prefix => "git"; + + public PluginMetadata Metadata => new( + "Git", + "Git 빠른 조회 — git status · log · branch · stash", + "1.0", + "AX"); + + // ── 서브커맨드 정의 ────────────────────────────────────────────────────── + private static readonly (string Sub, string Args, string Label, string Icon)[] SubCommands = + [ + ("status", "status --short", "변경 파일 목록", "\uE9F5"), + ("log", "log --oneline -10", "최근 커밋 10개", "\uE81C"), + ("branch", "branch -a", "브랜치 목록", "\uE8FB"), + ("stash", "stash list", "Stash 목록", "\uE7C4"), + ("diff", "diff --stat", "변경 통계", "\uE8A1"), + ("pull", "pull", "git pull 실행", "\uE8AF"), + ]; + + public async Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim().ToLowerInvariant(); + var items = new List(); + + // 작업 디렉토리 결정 + var workDir = FindGitRoot(); + + if (string.IsNullOrEmpty(q)) + { + // 빠른 상태 요약 + if (!string.IsNullOrEmpty(workDir)) + { + var branch = await RunGitAsync("branch --show-current", workDir, ct); + var statusOut = await RunGitAsync("status --short", workDir, ct); + var changed = statusOut?.Split('\n', StringSplitOptions.RemoveEmptyEntries).Length ?? 0; + + items.Add(new LauncherItem( + $"Git: {branch?.Trim() ?? "unknown"}", + changed == 0 ? "변경 없음" : $"{changed}개 파일 변경됨 · {System.IO.Path.GetFileName(workDir)}", + null, + ("status_summary", workDir), + Symbol: "\uE9F5")); + } + else + { + items.Add(new LauncherItem("Git 저장소 없음", + "현재 작업 폴더에 .git 디렉토리가 없습니다", null, null, + Symbol: "\uE783")); + } + + // 서브커맨드 목록 + foreach (var (sub, args, label, icon) in SubCommands) + { + items.Add(new LauncherItem( + $"git {sub}", + label, + null, + (sub, workDir ?? ""), + Symbol: icon)); + } + return items; + } + + // 서브커맨드 매칭 + var matched = SubCommands + .Where(sc => sc.Sub.StartsWith(q, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (matched.Count > 0) + { + foreach (var (sub, args, label, icon) in matched) + { + string? preview = null; + if (!string.IsNullOrEmpty(workDir)) + preview = await RunGitAsync(args, workDir, ct); + + var subtitle = preview != null + ? TruncateLines(preview, 3) + : label; + + items.Add(new LauncherItem( + $"git {sub}", + subtitle, + null, + (sub, workDir ?? "", args), + Symbol: icon)); + } + } + else + { + // 자유 명령 실행 (git ) + string? output = null; + if (!string.IsNullOrEmpty(workDir)) + output = await RunGitAsync(q, workDir, ct); + + items.Add(new LauncherItem( + $"git {query.Trim()}", + output != null ? TruncateLines(output, 3) : "실행 후 결과 클립보드 복사", + null, + ("custom", workDir ?? "", query.Trim()), + Symbol: "\uE9F5")); + } + + return items; + } + + public async Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + string? result = null; + + switch (item.Data) + { + // 상태 요약 항목 + case ("status_summary", string workDir): + result = await RunGitAsync("status", workDir, ct); + break; + + // 서브커맨드 항목 (sub, workDir, args) + case (string sub, string workDir, string args): + if (sub == "pull") + { + // pull은 별도 터미널 창으로 실행 + if (!string.IsNullOrEmpty(workDir)) + { + Process.Start(new ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = $"-NoProfile -Command \"cd '{workDir}'; git pull; Read-Host 'Enter 키를 누르면 닫힙니다'\"", + UseShellExecute = true, + }); + } + return; + } + result = await RunGitAsync(args, workDir, ct); + break; + + // 서브커맨드 항목 (sub, workDir) — args 없는 경우 + case (string sub2, string workDir2): + var found = SubCommands.FirstOrDefault(sc => sc.Sub == sub2); + if (found != default) + result = await RunGitAsync(found.Args, workDir2, ct); + break; + + default: + break; + } + + if (!string.IsNullOrWhiteSpace(result)) + { + try + { + System.Windows.Application.Current.Dispatcher.Invoke( + () => Clipboard.SetText(result)); + NotificationService.Notify("Git", "결과를 클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + } + + // ── 헬퍼 ──────────────────────────────────────────────────────────────── + + /// 현재 앱 설정의 작업 폴더에서 .git root를 찾습니다. + private static string? FindGitRoot() + { + var app = System.Windows.Application.Current as App; + var workDir = app?.SettingsService?.Settings.Llm.WorkFolder ?? ""; + + if (string.IsNullOrEmpty(workDir) || !System.IO.Directory.Exists(workDir)) + workDir = AppDomain.CurrentDomain.BaseDirectory; + + // .git 폴더를 찾아 상위로 이동 + var dir = new System.IO.DirectoryInfo(workDir); + while (dir != null) + { + if (System.IO.Directory.Exists(System.IO.Path.Combine(dir.FullName, ".git"))) + return dir.FullName; + dir = dir.Parent; + } + return null; + } + + /// git 명령을 비동기로 실행하고 출력을 반환합니다. + private static async Task RunGitAsync(string args, string workDir, CancellationToken ct) + { + if (string.IsNullOrEmpty(workDir)) return null; + try + { + var psi = new ProcessStartInfo("git", args) + { + WorkingDirectory = workDir, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + }; + + using var proc = Process.Start(psi); + if (proc == null) return null; + + var output = await proc.StandardOutput.ReadToEndAsync(ct); + var error = await proc.StandardError.ReadToEndAsync(ct); + await proc.WaitForExitAsync(ct); + + var text = output.Trim(); + if (string.IsNullOrWhiteSpace(text) && !string.IsNullOrWhiteSpace(error)) + text = error.Trim(); + + return string.IsNullOrWhiteSpace(text) ? "(출력 없음)" : text; + } + catch (OperationCanceledException) + { + return null; + } + catch (Exception ex) + { + return $"오류: {ex.Message}"; + } + } + + /// 긴 출력을 maxLines줄로 자릅니다. + private static string TruncateLines(string text, int maxLines) + { + var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries); + if (lines.Length <= maxLines) return string.Join(" · ", lines.Take(maxLines)).Trim(); + return string.Join(" · ", lines.Take(maxLines)) + $" … (+{lines.Length - maxLines}줄)"; + } +} diff --git a/src/AxCopilot/Handlers/NetDiagHandler.cs b/src/AxCopilot/Handlers/NetDiagHandler.cs new file mode 100644 index 0000000..0d47bd6 --- /dev/null +++ b/src/AxCopilot/Handlers/NetDiagHandler.cs @@ -0,0 +1,365 @@ +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L7-4: 네트워크 진단 핸들러. "net" 프리픽스로 사용합니다. +/// (기존 PortHandler의 포트/프로세스 조회와 구분되는 어댑터·핑·DNS 진단 기능) +/// +/// 예: net → 활성 네트워크 어댑터 IP 목록 +/// net ping 8.8.8.8 → 핑 테스트 (사외 모드에서만 외부 호스트) +/// net ping localhost → 로컬 핑 (항상 허용) +/// net dns google.com → DNS A 레코드 조회 (사외 모드에서만 외부) +/// net ip → 로컬 IP 정보 (공인 IP는 사외 모드에서만 표시) +/// net adapter → 네트워크 어댑터 전체 정보 +/// Enter → 결과를 클립보드에 복사. +/// +public class NetDiagHandler : IActionHandler +{ + public string? Prefix => "net"; + + public PluginMetadata Metadata => new( + "NetDiag", + "네트워크 진단 — IP · ping · DNS 조회 · 어댑터 상태", + "1.0", + "AX"); + + public async Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim().ToLowerInvariant(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + // 로컬 어댑터 IP 빠른 표시 + var adapters = GetLocalAdapters(); + foreach (var (name, ip, mac) in adapters) + { + items.Add(new LauncherItem( + ip, + $"{name} · MAC: {mac}", + null, + ("copy", ip), + Symbol: "\uE968")); + } + + // 서브커맨드 안내 + items.Add(new LauncherItem("net ping <호스트>", "핑 테스트", null, null, Symbol: "\uE8F2")); + items.Add(new LauncherItem("net dns <도메인>", "DNS A 레코드 조회", null, null, Symbol: "\uE968")); + items.Add(new LauncherItem("net ip", "로컬 IP 전체 정보", null, ("ip_info", ""), Symbol: "\uE968")); + items.Add(new LauncherItem("net adapter", "어댑터 세부 정보", null, ("adapter_info", ""), Symbol: "\uE968")); + return items; + } + + // ── ping ────────────────────────────────────────────────────────── + if (q.StartsWith("ping ")) + { + var host = query.Trim()["ping ".Length..].Trim(); + if (string.IsNullOrEmpty(host)) + { + items.Add(new LauncherItem("호스트를 입력하세요", "예: net ping 192.168.1.1", null, null, Symbol: "\uE783")); + return items; + } + + // 사내 모드에서는 내부 호스트만 허용 + if (!IsAllowedInInternalMode(host)) + { + items.Add(new LauncherItem( + "사내 모드 — 외부 호스트 차단", + "사외 모드에서만 외부 IP/도메인에 핑 가능합니다", + null, null, Symbol: "\uE785")); + return items; + } + + items.Add(new LauncherItem( + $"Ping: {host}", + "테스트 중...", + null, + ("ping", host), + Symbol: "\uE8F2")); + + // 비동기 핑 시도 + try + { + var result = await PingAsync(host, ct); + items.Clear(); + foreach (var r in result) + items.Add(r); + } + catch (OperationCanceledException) { } + + return items; + } + + // ── dns ────────────────────────────────────────────────────────── + if (q.StartsWith("dns ")) + { + var domain = query.Trim()["dns ".Length..].Trim(); + if (string.IsNullOrEmpty(domain)) + { + items.Add(new LauncherItem("도메인을 입력하세요", "예: net dns example.com", null, null, Symbol: "\uE783")); + return items; + } + + if (!IsAllowedInInternalMode(domain)) + { + items.Add(new LauncherItem( + "사내 모드 — 외부 도메인 차단", + "사외 모드에서만 외부 도메인 DNS 조회 가능합니다", + null, null, Symbol: "\uE785")); + return items; + } + + try + { + var ips = await Dns.GetHostAddressesAsync(domain, ct); + if (ips.Length == 0) + { + items.Add(new LauncherItem($"{domain}", "DNS 조회 결과 없음", null, null, Symbol: "\uE783")); + } + else + { + items.Add(new LauncherItem( + $"{domain} — {ips.Length}개 레코드", + string.Join(", ", ips.Select(ip => ip.ToString())), + null, + ("copy", string.Join("\n", ips.Select(ip => ip.ToString()))), + Symbol: "\uE968")); + + foreach (var ip in ips) + { + items.Add(new LauncherItem( + ip.ToString(), + ip.AddressFamily == AddressFamily.InterNetworkV6 ? "IPv6" : "IPv4", + null, + ("copy", ip.ToString()), + Symbol: "\uE968")); + } + } + } + catch (Exception ex) + { + items.Add(new LauncherItem("DNS 조회 실패", ex.Message, null, null, Symbol: "\uE783")); + } + + return items; + } + + // ── ip ──────────────────────────────────────────────────────────── + if (q == "ip") + { + items.Add(new LauncherItem("로컬 IP 정보", "어댑터별 IP 주소", null, ("ip_info", ""), Symbol: "\uE968")); + var adapters = GetLocalAdapters(); + foreach (var (name, ip, mac) in adapters) + { + items.Add(new LauncherItem( + ip, + $"{name} · MAC: {mac}", + null, + ("copy", ip), + Symbol: "\uE968")); + } + return items; + } + + // ── adapter ────────────────────────────────────────────────────── + if (q.StartsWith("adapter")) + { + items.Add(new LauncherItem( + "어댑터 전체 정보 (클립보드 복사)", + "활성 어댑터, IP, MAC, 속도", + null, + ("adapter_info", ""), + Symbol: "\uE968")); + var adapters = GetLocalAdapters(); + foreach (var (name, ip, mac) in adapters) + { + items.Add(new LauncherItem( + name, + $"IP: {ip} · MAC: {mac}", + null, + ("copy", $"{name}: {ip} ({mac})"), + Symbol: "\uE968")); + } + return items; + } + + // 미인식 → 기본 표시 + var defaultAdapters = GetLocalAdapters(); + foreach (var (name, ip, mac) in defaultAdapters) + { + items.Add(new LauncherItem(ip, $"{name} · MAC: {mac}", null, ("copy", ip), Symbol: "\uE968")); + } + return items; + } + + public async Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + switch (item.Data) + { + case ("copy", string text): + TryCopyToClipboard(text); + NotificationService.Notify("NetDiag", "클립보드에 복사했습니다."); + break; + + case ("ping", string host): + var pingItems = await PingAsync(host, ct); + var summary = string.Join("\n", pingItems.Select(i => $"{i.Title} {i.Subtitle}")); + TryCopyToClipboard(summary); + NotificationService.Notify("Ping", pingItems.FirstOrDefault()?.Title ?? host); + break; + + case ("ip_info", _): + case ("adapter_info", _): + var adapterInfo = BuildAdapterInfoText(); + TryCopyToClipboard(adapterInfo); + NotificationService.Notify("NetDiag", "어댑터 정보를 클립보드에 복사했습니다."); + break; + } + } + + // ── 헬퍼 ──────────────────────────────────────────────────────────────── + + private static List<(string Name, string IP, string MAC)> GetLocalAdapters() + { + var result = new List<(string, string, string)>(); + try + { + foreach (var adapter in NetworkInterface.GetAllNetworkInterfaces()) + { + if (adapter.OperationalStatus != OperationalStatus.Up) continue; + if (adapter.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue; + + var props = adapter.GetIPProperties(); + foreach (var addr in props.UnicastAddresses) + { + if (addr.Address.AddressFamily != AddressFamily.InterNetwork) continue; + var mac = BitConverter.ToString(adapter.GetPhysicalAddress().GetAddressBytes()) + .Replace('-', ':'); + result.Add((adapter.Name, addr.Address.ToString(), mac)); + } + } + } + catch { /* 비핵심 */ } + return result; + } + + private static string BuildAdapterInfoText() + { + var sb = new StringBuilder(); + try + { + foreach (var adapter in NetworkInterface.GetAllNetworkInterfaces()) + { + if (adapter.OperationalStatus != OperationalStatus.Up) continue; + if (adapter.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue; + + var props = adapter.GetIPProperties(); + var mac = BitConverter.ToString(adapter.GetPhysicalAddress().GetAddressBytes()) + .Replace('-', ':'); + var speed = adapter.Speed > 0 ? $"{adapter.Speed / 1_000_000} Mbps" : "-"; + + sb.AppendLine($"[{adapter.Name}]"); + sb.AppendLine($" 속도: {speed}"); + sb.AppendLine($" MAC: {mac}"); + foreach (var addr in props.UnicastAddresses) + sb.AppendLine($" IP: {addr.Address} / {addr.PrefixLength}"); + foreach (var gw in props.GatewayAddresses) + sb.AppendLine($" GW: {gw.Address}"); + sb.AppendLine(); + } + } + catch { /* 비핵심 */ } + return sb.ToString().Trim(); + } + + private static async Task> PingAsync(string host, CancellationToken ct) + { + var items = new List(); + try + { + using var pinger = new Ping(); + var results = new List<(bool Ok, long Ms, string Status)>(); + + for (int i = 0; i < 4; i++) + { + ct.ThrowIfCancellationRequested(); + try + { + var reply = await pinger.SendPingAsync(host, 1500); + results.Add((reply.Status == IPStatus.Success, + reply.RoundtripTime, + reply.Status.ToString())); + } + catch + { + results.Add((false, -1, "Timeout")); + } + if (i < 3) await Task.Delay(200, ct); + } + + var successCount = results.Count(r => r.Ok); + var avgMs = results.Where(r => r.Ok).Select(r => r.Ms).DefaultIfEmpty(0) + .Average(); + var loss = (4 - successCount) * 25; + + items.Add(new LauncherItem( + $"Ping {host} {avgMs:F0}ms", + $"패킷 손실: {loss}% ({successCount}/4 성공)", + null, + ("copy", $"Ping {host}: {avgMs:F0}ms, 손실 {loss}%"), + Symbol: successCount == 4 ? "\uE73E" : successCount == 0 ? "\uE783" : "\uE7BA")); + + for (int i = 0; i < results.Count; i++) + { + var (ok, ms, status) = results[i]; + items.Add(new LauncherItem( + ok ? $"응답 {ms}ms" : "시간 초과", + $"#{i + 1} {status}", + null, + ("copy", ok ? $"{ms}ms" : "timeout"), + Symbol: ok ? "\uE73E" : "\uE783")); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + items.Add(new LauncherItem("핑 실패", ex.Message, null, null, Symbol: "\uE783")); + } + return items; + } + + /// 사내 모드에서 외부 호스트 차단 여부 확인. + private static bool IsAllowedInInternalMode(string host) + { + var settings = (System.Windows.Application.Current as App)?.SettingsService?.Settings; + var isInternal = settings?.InternalModeEnabled ?? true; + if (!isInternal) return true; // 사외 모드: 모두 허용 + + // 사내 모드: 내부 주소만 허용 + return host.Equals("localhost", StringComparison.OrdinalIgnoreCase) + || host.StartsWith("127.", StringComparison.Ordinal) + || host.StartsWith("192.168.", StringComparison.Ordinal) + || host.StartsWith("10.", StringComparison.Ordinal) + || host.StartsWith("172.", StringComparison.Ordinal); + } + + private static void TryCopyToClipboard(string text) + { + try + { + System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text)); + } + catch { /* 비핵심 */ } + } +} diff --git a/src/AxCopilot/Handlers/RegexHandler.cs b/src/AxCopilot/Handlers/RegexHandler.cs new file mode 100644 index 0000000..6bd7208 --- /dev/null +++ b/src/AxCopilot/Handlers/RegexHandler.cs @@ -0,0 +1,340 @@ +using System.Text; +using System.Text.RegularExpressions; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L7-2: 정규식 테스터 핸들러. "re" 프리픽스로 사용합니다. +/// +/// 예: re \d+ → 클립보드 텍스트에서 숫자 패턴 매치 +/// re /old/new/ → 치환 모드 (결과 클립보드 복사) +/// re patterns → 공통 패턴 라이브러리 표시 +/// re flags:i \w+ → 플래그 지정 (i=무시대소, m=멀티라인, s=점이개행) +/// Enter → 매치 결과 또는 치환 결과를 클립보드에 복사. +/// +public class RegexHandler : IActionHandler +{ + public string? Prefix => "re"; + + public PluginMetadata Metadata => new( + "Regex", + "정규식 테스터 — 매치 · 치환 · 패턴 라이브러리", + "1.0", + "AX"); + + // ── 공통 패턴 라이브러리 ───────────────────────────────────────────────── + private static readonly (string Name, string Pattern, string Desc)[] CommonPatterns = + [ + ("이메일", @"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}", "이메일 주소"), + ("URL", @"https?://[^\s/$.?#].[^\s]*", "HTTP/HTTPS URL"), + ("전화번호", @"0\d{1,2}-\d{3,4}-\d{4}", "한국 전화번호 (하이픈)"), + ("날짜", @"\d{4}[-./]\d{1,2}[-./]\d{1,2}", "날짜 (YYYY-MM-DD 등)"), + ("숫자", @"\d+(?:\.\d+)?", "정수 또는 소수"), + ("한글", @"[가-힣]+", "한글 문자열"), + ("영문", @"[a-zA-Z]+", "영문 문자열"), + ("IP 주소", @"\b(?:\d{1,3}\.){3}\d{1,3}\b", "IPv4 주소"), + ("16진수 색상", @"#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b", "HEX 색상 코드"), + ("빈 줄", @"^\s*$", "빈 줄 (멀티라인 모드 필요)"), + ("HTML 태그", @"<[^>]+>", "HTML 태그"), + ("JSON 키", @"""([^""]+)""\s*:", "JSON 키 이름"), + ("UUID", @"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", "UUID"), + ("우편번호", @"\b\d{5}\b", "5자리 우편번호"), + ]; + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + // 안내 + 패턴 라이브러리 미리보기 + items.Add(new LauncherItem( + "정규식 테스터", + "패턴 입력 후 Enter → 클립보드 텍스트에서 매치. /old/new/ 형식으로 치환", + null, null, Symbol: "\uE773")); + + items.Add(new LauncherItem( + "re patterns", + "공통 패턴 라이브러리 표시", + null, ("show_patterns", ""), Symbol: "\uE8A4")); + + foreach (var (name, pattern, desc) in CommonPatterns.Take(5)) + { + items.Add(new LauncherItem( + name, + $"{pattern} · {desc}", + null, + ("pattern_apply", pattern), + Symbol: "\uE773")); + } + return Task.FromResult>(items); + } + + // "patterns" 서브커맨드 + if (q.Equals("patterns", StringComparison.OrdinalIgnoreCase)) + { + foreach (var (name, pattern, desc) in CommonPatterns) + { + items.Add(new LauncherItem( + name, + $"{pattern} · {desc}", + null, + ("pattern_apply", pattern), + Symbol: "\uE773")); + } + return Task.FromResult>(items); + } + + // 공통 패턴 이름 검색 + var matchedPatterns = CommonPatterns + .Where(p => p.Name.Contains(q, StringComparison.OrdinalIgnoreCase) || + p.Desc.Contains(q, StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (matchedPatterns.Count > 0) + { + foreach (var (name, pattern, desc) in matchedPatterns) + { + items.Add(new LauncherItem( + name, + $"{pattern} · {desc}", + null, + ("pattern_apply", pattern), + Symbol: "\uE773")); + } + } + + // 치환 모드 /old/new/ + if (q.StartsWith('/') && q.Length > 2) + { + var parts = q.Split('/', StringSplitOptions.None); + // /old/new/ → parts = ["", "old", "new", ""] + if (parts.Length >= 3) + { + var oldPat = parts[1]; + var newStr = parts.Length >= 3 ? parts[2] : ""; + var flags = parts.Length >= 4 ? parts[3] : ""; + + var clipText = GetClipboardText(); + if (!string.IsNullOrEmpty(clipText) && !string.IsNullOrEmpty(oldPat)) + { + try + { + var opts = BuildOptions(flags); + var result = Regex.Replace(clipText, oldPat, newStr, opts); + var changed = result != clipText; + items.Add(new LauncherItem( + changed ? "치환 완료 — 클립보드 복사" : "치환 없음 (패턴 불일치)", + result.Length > 120 ? result[..120] + "…" : result, + null, + ("replace_result", result), + Symbol: "\uE8AC")); + } + catch (Exception ex) + { + items.Add(new LauncherItem("패턴 오류", ex.Message, null, null, Symbol: "\uE783")); + } + } + else + { + items.Add(new LauncherItem( + $"치환: /{oldPat}/ → {newStr}", + "클립보드에 텍스트를 복사한 뒤 실행하세요", + null, ("replace_pattern", oldPat, newStr), Symbol: "\uE8AC")); + } + return Task.FromResult>(items); + } + } + + // 플래그 처리: "flags:im pattern" + string pattern2 = q; + string flagStr = ""; + if (q.StartsWith("flags:", StringComparison.OrdinalIgnoreCase)) + { + var spaceIdx = q.IndexOf(' '); + if (spaceIdx > 0) + { + flagStr = q[6..spaceIdx]; + pattern2 = q[(spaceIdx + 1)..].Trim(); + } + } + + // 매치 모드 + if (!string.IsNullOrEmpty(pattern2)) + { + var clipText = GetClipboardText(); + if (!string.IsNullOrEmpty(clipText)) + { + try + { + var opts = BuildOptions(flagStr); + var matches = Regex.Matches(clipText, pattern2, opts); + + if (matches.Count == 0) + { + items.Add(new LauncherItem( + "매치 없음", + $"패턴 [{pattern2}]이 클립보드 텍스트와 일치하지 않습니다", + null, null, Symbol: "\uE783")); + } + else + { + // 요약 항목 + items.Add(new LauncherItem( + $"{matches.Count}개 매치됨", + $"패턴: {pattern2} | 전체 복사: Enter", + null, + ("all_matches", string.Join("\n", matches.Cast().Select(m => m.Value))), + Symbol: "\uE773")); + + // 개별 매치 항목 (최대 15개) + foreach (Match m in matches.Cast().Take(15)) + { + var groupInfo = m.Groups.Count > 1 + ? " · 그룹: " + string.Join(", ", m.Groups.Cast().Skip(1).Select(g => g.Value)) + : ""; + items.Add(new LauncherItem( + m.Value, + $"위치 {m.Index}{groupInfo}", + null, + ("single_match", m.Value), + Symbol: "\uE773")); + } + + if (matches.Count > 15) + items.Add(new LauncherItem( + $"… +{matches.Count - 15}개 더", + "전체 보기: 첫 번째 항목 Enter", + null, null, Symbol: "\uE712")); + } + } + catch (Exception ex) + { + items.Add(new LauncherItem("패턴 오류", ex.Message, null, null, Symbol: "\uE783")); + } + } + else + { + // 클립보드 없음 → 패턴만 표시 + items.Add(new LauncherItem( + $"패턴: {pattern2}", + "클립보드에 텍스트를 복사한 뒤 실행하세요", + null, + ("pattern_apply", pattern2), + Symbol: "\uE773")); + } + } + + return Task.FromResult>(items); + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + switch (item.Data) + { + case ("all_matches", string copyText1): + TryCopyToClipboard(copyText1); + break; + case ("replace_result", string copyText2): + TryCopyToClipboard(copyText2); + break; + case ("single_match", string copyText3): + TryCopyToClipboard(copyText3); + break; + + case ("pattern_apply", string pattern): + // 패턴을 클립보드에 복사 (또는 클립보드 텍스트에 즉시 적용) + var clipText = GetClipboardText(); + if (!string.IsNullOrEmpty(clipText)) + { + try + { + var matches = Regex.Matches(clipText, pattern); + if (matches.Count > 0) + { + var result = string.Join("\n", matches.Cast().Select(m => m.Value)); + TryCopyToClipboard(result); + NotificationService.Notify("Regex", $"{matches.Count}개 매치 복사됨"); + } + else + { + NotificationService.Notify("Regex", "매치 없음"); + } + } + catch + { + TryCopyToClipboard(pattern); + NotificationService.Notify("Regex", "패턴을 클립보드에 복사했습니다"); + } + } + else + { + TryCopyToClipboard(pattern); + NotificationService.Notify("Regex", "패턴을 클립보드에 복사했습니다"); + } + break; + + case ("replace_pattern", string oldPat, string newStr): + var src = GetClipboardText(); + if (!string.IsNullOrEmpty(src)) + { + try + { + var replaced = Regex.Replace(src, oldPat, newStr); + TryCopyToClipboard(replaced); + NotificationService.Notify("Regex", "치환 결과를 클립보드에 복사했습니다"); + } + catch (Exception ex) + { + NotificationService.Notify("Regex", $"오류: {ex.Message}"); + } + } + break; + + case ("show_patterns", _): + // 패턴 라이브러리 목록 표시 — 런처 입력창에 "re patterns" 입력 + var launcher = System.Windows.Application.Current?.Windows + .OfType() + .FirstOrDefault(); + launcher?.SetInputText("re patterns "); + break; + } + return Task.CompletedTask; + } + + // ── 헬퍼 ──────────────────────────────────────────────────────────────── + + private static RegexOptions BuildOptions(string flags) + { + var opts = RegexOptions.None; + if (flags.Contains('i', StringComparison.OrdinalIgnoreCase)) opts |= RegexOptions.IgnoreCase; + if (flags.Contains('m', StringComparison.OrdinalIgnoreCase)) opts |= RegexOptions.Multiline; + if (flags.Contains('s', StringComparison.OrdinalIgnoreCase)) opts |= RegexOptions.Singleline; + return opts; + } + + private static string? GetClipboardText() + { + try + { + string? text = null; + System.Windows.Application.Current.Dispatcher.Invoke( + () => text = Clipboard.ContainsText() ? Clipboard.GetText() : null); + return text; + } + catch { return null; } + } + + private static void TryCopyToClipboard(string text) + { + try + { + System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text)); + } + catch { /* 비핵심 */ } + } +} diff --git a/src/AxCopilot/Handlers/TimeZoneHandler.cs b/src/AxCopilot/Handlers/TimeZoneHandler.cs new file mode 100644 index 0000000..2a4867c --- /dev/null +++ b/src/AxCopilot/Handlers/TimeZoneHandler.cs @@ -0,0 +1,259 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L7-3: 시간대 변환기 핸들러. "tz" 프리픽스로 사용합니다. +/// +/// 예: tz → 주요 도시 현재 시각 목록 +/// tz seoul → 서울 현재 시각 +/// tz new york → 뉴욕 현재 시각 +/// tz 14:30 to la → 현재 시간대(KST) 14:30을 LA 시각으로 변환 +/// tz meeting 09:00 → 서울 기준 9시 = 주요 도시별 동일 시각 표시 +/// Enter → 결과를 클립보드에 복사. +/// +public class TimeZoneHandler : IActionHandler +{ + public string? Prefix => "tz"; + + public PluginMetadata Metadata => new( + "TimeZone", + "시간대 변환기 — 주요 도시 현재 시각 · 시각 변환", + "1.0", + "AX"); + + // ── 주요 도시 시간대 목록 ────────────────────────────────────────────── + private static readonly (string City, string TzId, string Flag, string[] Aliases)[] Cities = + [ + ("서울", "Korea Standard Time", "🇰🇷", ["seoul", "서울", "부산", "인천", "kst", "한국"]), + ("도쿄", "Tokyo Standard Time", "🇯🇵", ["tokyo", "도쿄", "osaka", "오사카", "jst", "일본"]), + ("베이징", "China Standard Time", "🇨🇳", ["beijing", "베이징", "상하이", "shanghai", "cst", "중국"]), + ("방콕", "SE Asia Standard Time", "🇹🇭", ["bangkok", "방콕", "ict", "태국"]), + ("두바이", "Arabian Standard Time", "🇦🇪", ["dubai", "두바이", "gst", "uae"]), + ("모스크바", "Russia Time Zone 2", "🇷🇺", ["moscow", "모스크바", "msk", "러시아"]), + ("파리", "Romance Standard Time", "🇫🇷", ["paris", "파리", "cet", "프랑스"]), + ("런던", "GMT Standard Time", "🇬🇧", ["london", "런던", "gmt", "영국"]), + ("뉴욕", "Eastern Standard Time", "🇺🇸", ["new york", "뉴욕", "nyc", "est", "동부"]), + ("시카고", "Central Standard Time", "🇺🇸", ["chicago", "시카고", "cst", "중부"]), + ("로스앤젤레스","Pacific Standard Time", "🇺🇸", ["los angeles", "la", "로스앤젤레스", "pst", "서부"]), + ("시드니", "AUS Eastern Standard Time", "🇦🇺", ["sydney", "시드니", "aest", "호주"]), + ("싱가포르", "Singapore Standard Time", "🇸🇬", ["singapore", "싱가포르", "sgt"]), + ("뭄바이", "India Standard Time", "🇮🇳", ["mumbai", "뭄바이", "ist", "인도"]), + ("도하", "Arab Standard Time", "🇶🇦", ["doha", "도하", "ast", "카타르"]), + ]; + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim().ToLowerInvariant(); + var items = new List(); + var now = DateTimeOffset.UtcNow; + + if (string.IsNullOrWhiteSpace(q)) + { + // 주요 도시 현재 시각 목록 + items.Add(new LauncherItem( + "주요 도시 현재 시각", + $"기준: UTC {now:HH:mm}", + null, null, Symbol: "\uE917")); + + foreach (var (city, tzId, flag, _) in Cities) + { + var (timeStr, offsetStr) = GetCityTime(tzId, now); + items.Add(new LauncherItem( + $"{flag} {city}", + $"{timeStr} ({offsetStr})", + null, + ("copy_time", $"{city}: {timeStr} ({offsetStr})"), + Symbol: "\uE917")); + } + return Task.FromResult>(items); + } + + // "meeting HH:mm" 모드 — 서울 기준 특정 시각을 전 도시 변환 + if (q.StartsWith("meeting ") || q.StartsWith("미팅 ")) + { + var timePart = q.Contains(' ') ? q[(q.IndexOf(' ') + 1)..].Trim() : ""; + return Task.FromResult>( + BuildMeetingItems(timePart, now)); + } + + // "HH:mm to " 또는 "HH:mm " 변환 모드 + var convResult = TryParseConversion(q, now); + if (convResult != null) + return Task.FromResult>(convResult); + + // 도시 검색 + var matched = Cities + .Where(c => c.Aliases.Any(a => a.Contains(q))) + .ToList(); + + if (matched.Count > 0) + { + foreach (var (city, tzId, flag, _) in matched) + { + var (timeStr, offsetStr) = GetCityTime(tzId, now); + items.Add(new LauncherItem( + $"{flag} {city} {timeStr}", + $"UTC {offsetStr}", + null, + ("copy_time", $"{city}: {timeStr} ({offsetStr})"), + Symbol: "\uE917")); + + // 이 도시와 서울의 시차 + var seoulOffset = GetOffset("Korea Standard Time"); + var cityOffset = GetOffset(tzId); + var diff = (cityOffset - seoulOffset).TotalHours; + var diffStr = diff == 0 ? "서울과 동일" : + diff > 0 ? $"서울보다 +{diff:+0;-0}시간" : + $"서울보다 {diff:+0;-0}시간"; + items.Add(new LauncherItem( + diffStr, + "서울(KST) 기준 시차", + null, null, Symbol: "\uE8F4")); + } + } + else + { + // 미인식 → 모든 도시 표시 + foreach (var (city, tzId, flag, _) in Cities) + { + var (timeStr, offsetStr) = GetCityTime(tzId, now); + items.Add(new LauncherItem( + $"{flag} {city} {timeStr}", + offsetStr, + null, + ("copy_time", $"{city}: {timeStr} ({offsetStr})"), + Symbol: "\uE917")); + } + } + + return Task.FromResult>(items); + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + if (item.Data is ("copy_time", string text)) + { + try + { + System.Windows.Application.Current.Dispatcher.Invoke( + () => Clipboard.SetText(text)); + NotificationService.Notify("시간대", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + return Task.CompletedTask; + } + + // ── 헬퍼 ──────────────────────────────────────────────────────────────── + + private static (string Time, string Offset) GetCityTime(string tzId, DateTimeOffset utcNow) + { + try + { + var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId); + var local = TimeZoneInfo.ConvertTime(utcNow, tz); + var off = tz.GetUtcOffset(utcNow); + var offStr = $"UTC{(off >= TimeSpan.Zero ? "+" : "")}{off.Hours:D2}:{off.Minutes:D2}"; + return (local.ToString("HH:mm (ddd)"), offStr); + } + catch + { + return ("--:--", ""); + } + } + + private static TimeSpan GetOffset(string tzId) + { + try + { + var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId); + return tz.GetUtcOffset(DateTimeOffset.UtcNow); + } + catch { return TimeSpan.Zero; } + } + + private IEnumerable BuildMeetingItems(string timePart, DateTimeOffset utcNow) + { + var items = new List(); + + if (!TimeOnly.TryParse(timePart, out var meetingTime)) + { + items.Add(new LauncherItem("시각 형식 오류", + "HH:mm 형식으로 입력하세요 (예: tz meeting 10:00)", + null, null, Symbol: "\uE783")); + return items; + } + + // 서울 기준으로 날짜+시각 설정 + var seoulTz = TimeZoneInfo.FindSystemTimeZoneById("Korea Standard Time"); + var seoulNow = TimeZoneInfo.ConvertTime(utcNow, seoulTz); + var seoulDt = new DateTimeOffset(seoulNow.Date.AddHours(meetingTime.Hour).AddMinutes(meetingTime.Minute), + seoulTz.GetUtcOffset(utcNow)); + var seoulUtc = seoulDt.ToUniversalTime(); + + items.Add(new LauncherItem( + $"🇰🇷 서울 미팅 시각: {meetingTime:HH:mm}", + "주요 도시 동일 시각", null, null, Symbol: "\uE917")); + + foreach (var (city, tzId, flag, _) in Cities) + { + if (tzId == "Korea Standard Time") continue; + var (timeStr, offsetStr) = GetCityTime(tzId, seoulUtc); + items.Add(new LauncherItem( + $"{flag} {city} {timeStr}", + offsetStr, + null, + ("copy_time", $"{city}: {timeStr}"), + Symbol: "\uE917")); + } + return items; + } + + private IEnumerable? TryParseConversion(string q, DateTimeOffset utcNow) + { + // "HH:mm to " 또는 "HH:mm " 패턴 + var sep = q.Contains(" to ") ? " to " : q.Contains(' ') ? " " : null; + if (sep == null) return null; + + var parts = q.Split(sep, 2); + if (parts.Length < 2) return null; + + if (!TimeOnly.TryParse(parts[0].Trim(), out var inputTime)) return null; + + var cityQuery = parts[1].Trim().ToLowerInvariant(); + var targetCities = Cities + .Where(c => c.Aliases.Any(a => a.Contains(cityQuery))) + .ToList(); + + if (targetCities.Count == 0) return null; + + var items = new List(); + // 서울 기준으로 입력 시각 해석 + var seoulTz = TimeZoneInfo.FindSystemTimeZoneById("Korea Standard Time"); + var seoulNow = TimeZoneInfo.ConvertTime(utcNow, seoulTz); + var seoulDt = new DateTimeOffset( + seoulNow.Date.AddHours(inputTime.Hour).AddMinutes(inputTime.Minute), + seoulTz.GetUtcOffset(utcNow)); + var seoulUtc = seoulDt.ToUniversalTime(); + + items.Add(new LauncherItem( + $"🇰🇷 서울 {inputTime:HH:mm} 기준 변환", + "Enter → 클립보드 복사", null, null, Symbol: "\uE8F4")); + + foreach (var (city, tzId, flag, _) in targetCities) + { + var (timeStr, offsetStr) = GetCityTime(tzId, seoulUtc); + items.Add(new LauncherItem( + $"{flag} {city} {timeStr}", + offsetStr, + null, + ("copy_time", $"{city}: {timeStr}"), + Symbol: "\uE917")); + } + return items; + } +}