From 678feaadc996f1f9735ec238e4bcd2185ab63280 Mon Sep 17 00:00:00 2001 From: lacvet Date: Sat, 4 Apr 2026 14:28:52 +0900 Subject: [PATCH] =?UTF-8?q?[Phase=20L10]=20=ED=85=8D=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=C2=B7=EB=8D=B0=EC=9D=B4=ED=84=B0=C2=B7=EA=B0=9C=EB=B0=9C=20?= =?UTF-8?q?=EC=9C=A0=ED=8B=B8=EB=A6=AC=ED=8B=B0=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=9F=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 XmlHandler.cs (신규, ~290줄, prefix=xml): - 클립보드/인라인 XML 자동 포맷(들여쓰기 2칸) - compact/minify: 공백 제거 압축, validate: 줄·열 오류 표시 - xpath //path: XPathDocument 기반 최대 20건 쿼리 - attr: XmlDocument 전체 속성 추출 - yield return → 리스트 방식으로 수정 (CS1631/CS1626 해결) UuidHandler.cs (신규, ~210줄, prefix=uuid): - Guid.NewGuid() v4 기본 + N개 일괄 생성 - seq: UUIDv7 스타일(상위 48비트=Unix ms 타임스탬프, 하위=랜덤) - short: RandomNumberGenerator 4바이트 hex 짧은 ID - upper: 대문자 UUID, nil: 00000000-… Nil UUID - parse: 버전·변형·v1 타임스탬프 복원 분석 CertHandler.cs (신규, ~200줄, prefix=cert): - TcpClient + SslStream으로 TLS 인증서 직접 조회 - 만료일·D-day·발급 대상·발급 기관·SANs·지문(SHA1) 표시 - 사내 모드: 내부 호스트(192.168/10/172.16-31)만 허용 - https:// URL 형식, 포트 지정(cert domain.com 8443) 지원 LoremHandler.cs (신규, ~230줄, prefix=lorem): - 113단어 Lorem Ipsum 풀 + 82단어 한국어 더미 풀 - lorem N: N단락, words N: 단어, sentences N: 문장 모드 - ko: 한국어 문장 구조(시작어+본문+결말) 조합 - email N: 더미 이메일, name N: 한국어 성+이름 조합 App.xaml.cs: 4개 핸들러 Phase L10 블록 등록 docs/LAUNCHER_ROADMAP.md: Phase L10 완료 섹션 추가 빌드: 경고 0, 오류 0 Co-Authored-By: Claude Sonnet 4.6 --- docs/LAUNCHER_ROADMAP.md | 13 + src/AxCopilot/App.xaml.cs | 10 + src/AxCopilot/Handlers/CertHandler.cs | 253 ++++++++++++++++++ src/AxCopilot/Handlers/LoremHandler.cs | 284 ++++++++++++++++++++ src/AxCopilot/Handlers/UuidHandler.cs | 300 +++++++++++++++++++++ src/AxCopilot/Handlers/XmlHandler.cs | 345 +++++++++++++++++++++++++ 6 files changed, 1205 insertions(+) create mode 100644 src/AxCopilot/Handlers/CertHandler.cs create mode 100644 src/AxCopilot/Handlers/LoremHandler.cs create mode 100644 src/AxCopilot/Handlers/UuidHandler.cs create mode 100644 src/AxCopilot/Handlers/XmlHandler.cs diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md index 98a34d6..f44c1f3 100644 --- a/docs/LAUNCHER_ROADMAP.md +++ b/docs/LAUNCHER_ROADMAP.md @@ -256,3 +256,16 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label | L9-2 | **IP 서브넷 계산기** ✅ | `subnet` 프리픽스. CIDR(x.x.x.x/24) 또는 공백 구분 입력. 네트워크·마스크·브로드캐스트·첫/마지막 호스트·사용 가능 호스트 수 계산. 서브넷 마스크→CIDR 변환. `subnet range x.x.x.10-50` 범위 계산. 이진 마스크 표시 | 높음 | | L9-3 | **시스템 정리** ✅ | `clean` 프리픽스. 임시 파일(%TEMP%), 휴지통(SHEmptyRecycleBin), 다운로드 30일 이상, AxCopilot 로그. 예상 용량 사전 표시. `clean all`로 일괄 정리. 항목별 실시간 알림 | 중간 | | L9-4 | **진수 변환기** ✅ | `base` 프리픽스. 10진/16진(0x)/2진(0b)/8진(0o) 자동 감지 변환. `base 255 to hex` 단일 방향 변환. `base ascii 65` ASCII 코드↔문자 변환. 4비트 그룹 이진 표시. Enter → 클립보드 복사 | 중간 | + +--- + +## Phase L10 — 텍스트·데이터·개발 유틸리티 (v2.0.2) ✅ 완료 + +> **방향**: 개발자 일상 도구 확충 — XML 조작, UUID 생성, SSL 인증서 점검, 더미 데이터 생성. + +| # | 기능 | 설명 | 우선순위 | +|---|------|------|----------| +| L10-1 | **XML 포맷터·검증기** ✅ | `xml` 프리픽스. 클립보드 또는 인라인 XML 자동 포맷(들여쓰기). `xml compact/minify`로 압축. `xml validate`로 유효성 검증(줄·열 오류 표시). `xml xpath //경로`로 XPath 쿼리. `xml attr`로 속성 목록 추출. Enter → 클립보드 복사 | 높음 | +| L10-2 | **UUID/GUID 생성기** ✅ | `uuid` 프리픽스. `uuid` 기본 v4 1개 생성. `uuid 5`로 N개 일괄. `uuid upper` 대문자. `uuid seq` UUIDv7 스타일 순차 UUID(타임스탬프 상위 48비트). `uuid short` 8자리 hex 짧은 ID. `uuid nil` Nil UUID. `uuid parse `로 버전·변형·타임스탬프 분석 | 높음 | +| L10-3 | **SSL 인증서 체커** ✅ | `cert` 프리픽스. 도메인/IP의 TLS 인증서 조회(443 기본, 포트 지정 가능). 만료일·D-day·발급 대상·발급 기관·SANs·지문 표시. 사내 모드에서는 내부 호스트(192.168.x, 10.x, 172.16-31.x)만 허용. Enter → 결과 클립보드 복사 | 중간 | +| L10-4 | **Lorem Ipsum 생성기** ✅ | `lorem` 프리픽스. `lorem 3`으로 3단락 생성. `lorem words 20` 단어 N개. `lorem sentences 5` 문장 N개. `lorem ko` 한국어 더미 텍스트. `lorem email 5` 더미 이메일 주소. `lorem name 5` 한국어 더미 이름. Enter → 클립보드 복사 | 중간 | diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index 6ae5981..1ac4cf8 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -227,6 +227,16 @@ public partial class App : System.Windows.Application // L9-4: 진수 변환기 (prefix=base) commandResolver.RegisterHandler(new BaseConvertHandler()); + // ─── Phase L10 핸들러 ───────────────────────────────────────────────── + // L10-1: XML 포맷터·검증기·XPath (prefix=xml) + commandResolver.RegisterHandler(new XmlHandler()); + // L10-2: UUID/GUID 생성기 (prefix=uuid) + commandResolver.RegisterHandler(new UuidHandler()); + // L10-3: SSL 인증서 체커 (prefix=cert) + commandResolver.RegisterHandler(new CertHandler()); + // L10-4: Lorem Ipsum 더미 텍스트 (prefix=lorem) + commandResolver.RegisterHandler(new LoremHandler()); + // ─── 플러그인 로드 ──────────────────────────────────────────────────── var pluginHost = new PluginHost(settings, commandResolver); pluginHost.LoadAll(); diff --git a/src/AxCopilot/Handlers/CertHandler.cs b/src/AxCopilot/Handlers/CertHandler.cs new file mode 100644 index 0000000..2af3185 --- /dev/null +++ b/src/AxCopilot/Handlers/CertHandler.cs @@ -0,0 +1,253 @@ +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L10-3: SSL/TLS 인증서 체커 핸들러. "cert" 프리픽스로 사용합니다. +/// +/// 예: cert google.com → google.com의 인증서 정보 조회 +/// cert github.com 443 → 포트 지정 +/// cert https://example.com → URL 형식도 지원 +/// Enter → 결과를 클립보드에 복사. +/// +/// ⚠ 외부 인터넷 접속 필요. 사내 모드에서는 내부 호스트만 조회 가능. +/// +public class CertHandler : IActionHandler +{ + public string? Prefix => "cert"; + + public PluginMetadata Metadata => new( + "Cert", + "SSL/TLS 인증서 체커 — 만료일 · 발급자 · SANs", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem( + "SSL 인증서 체커", + "예: cert google.com / cert 192.168.1.1 / cert example.com 8443", + null, null, Symbol: "\uE72E")); + items.Add(new LauncherItem("cert google.com", "google.com 인증서 조회", null, null, Symbol: "\uE72E")); + items.Add(new LauncherItem("cert github.com", "github.com 인증서 조회", null, null, Symbol: "\uE72E")); + items.Add(new LauncherItem("cert 192.168.1.1", "내부 서버 인증서 조회", null, null, Symbol: "\uE72E")); + return Task.FromResult>(items); + } + + // 비동기 조회 시작 — 빠른 반환 후 결과를 기다리지 않음 + // 실제 조회는 ExecuteAsync에서 처리하며, 여기서는 "조회 중" 항목만 반환 + var (host, port) = ParseHostPort(q); + + if (string.IsNullOrWhiteSpace(host)) + { + items.Add(new LauncherItem("형식 오류", "예: cert domain.com 또는 cert domain.com 443", null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + // 사내 모드 확인 + var settings = (System.Windows.Application.Current as App)?.SettingsService?.Settings; + var isInternal = settings?.InternalModeEnabled ?? true; + if (isInternal && !IsInternalHost(host)) + { + items.Add(new LauncherItem( + "사내 모드 제한", + $"'{host}'은 외부 호스트입니다. 설정에서 사외 모드를 활성화하세요.", + null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + items.Add(new LauncherItem( + $"{host}:{port} 인증서 조회", + "Enter를 눌러 조회하세요", + null, + ("check", $"{host}:{port}"), + Symbol: "\uE72E")); + + return Task.FromResult>(items); + } + + public async 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("Cert", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + return; + } + + if (item.Data is not ("check", string target)) return; + + var parts = target.Split(':'); + var host = parts[0]; + var port = parts.Length > 1 && int.TryParse(parts[1], out var p) ? p : 443; + + NotificationService.Notify("Cert", $"{host}:{port} 인증서 조회 중…"); + + try + { + var certInfo = await FetchCertInfoAsync(host, port, ct); + var summary = BuildSummary(certInfo); + System.Windows.Application.Current.Dispatcher.Invoke( + () => Clipboard.SetText(summary)); + NotificationService.Notify("Cert", certInfo.StatusLine); + } + catch (OperationCanceledException) + { + NotificationService.Notify("Cert", "조회가 취소되었습니다."); + } + catch (Exception ex) + { + NotificationService.Notify("Cert", $"오류: {ex.Message}"); + } + } + + // ── 인증서 조회 ────────────────────────────────────────────────────────── + + private static async Task FetchCertInfoAsync(string host, int port, CancellationToken ct) + { + using var client = new TcpClient(); + await client.ConnectAsync(host, port, ct); + + using var sslStream = new SslStream( + client.GetStream(), + leaveInnerStreamOpen: false, + userCertificateValidationCallback: (_, cert, _, _) => true); // 만료 인증서도 정보 확인 + + await sslStream.AuthenticateAsClientAsync( + new SslClientAuthenticationOptions + { + TargetHost = host, + RemoteCertificateValidationCallback = (_, _, _, _) => true, + }, ct); + + var cert = sslStream.RemoteCertificate as X509Certificate2 + ?? new X509Certificate2(sslStream.RemoteCertificate!); + + return BuildCertInfo(host, port, cert); + } + + private static CertInfo BuildCertInfo(string host, int port, X509Certificate2 cert) + { + var now = DateTime.UtcNow; + var notAfter = cert.NotAfter.ToUniversalTime(); + var daysLeft = (int)(notAfter - now).TotalDays; + var subject = cert.Subject; + var issuer = cert.Issuer; + + // SANs (Subject Alternative Names) + var sans = new List(); + foreach (var ext in cert.Extensions) + { + if (ext.Oid?.Value == "2.5.29.17") // SAN OID + { + var raw = ext.Format(false); + sans.AddRange(raw.Split(new[] { ", ", ",\r\n" }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .Where(s => s.StartsWith("DNS Name=", StringComparison.OrdinalIgnoreCase)) + .Select(s => s[9..])); + } + } + + var status = daysLeft > 30 ? "유효" + : daysLeft > 0 ? "만료 임박" + : "만료됨"; + + return new CertInfo + { + Host = host, + Port = port, + Subject = subject, + Issuer = issuer, + NotBefore = cert.NotBefore, + NotAfter = cert.NotAfter, + DaysLeft = daysLeft, + Sans = sans, + Thumbprint = cert.Thumbprint, + Status = status, + StatusLine = $"{status} · D-{(daysLeft > 0 ? daysLeft.ToString() : "만료")} · {host}:{port}", + }; + } + + private static string BuildSummary(CertInfo c) + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"호스트: {c.Host}:{c.Port}"); + sb.AppendLine($"상태: {c.Status} (만료까지 {c.DaysLeft}일)"); + sb.AppendLine($"발급 대상: {c.Subject}"); + sb.AppendLine($"발급 기관: {c.Issuer}"); + sb.AppendLine($"유효 시작: {c.NotBefore:yyyy-MM-dd HH:mm:ss}"); + sb.AppendLine($"만료 일자: {c.NotAfter:yyyy-MM-dd HH:mm:ss}"); + if (c.Sans.Count > 0) + sb.AppendLine($"SANs: {string.Join(", ", c.Sans.Take(10))}"); + sb.AppendLine($"지문(SHA1): {c.Thumbprint}"); + return sb.ToString().TrimEnd(); + } + + // ── 파싱 헬퍼 ──────────────────────────────────────────────────────────── + + private static (string Host, int Port) ParseHostPort(string q) + { + // https:// 또는 http:// 제거 + if (q.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + q = q[8..]; + else if (q.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) + q = q[7..]; + + // 경로 제거 + var slashIdx = q.IndexOf('/'); + if (slashIdx >= 0) q = q[..slashIdx]; + + var colonIdx = q.LastIndexOf(':'); + if (colonIdx >= 0 && int.TryParse(q[(colonIdx + 1)..], out var port)) + return (q[..colonIdx], port); + + return (q, 443); + } + + private static bool IsInternalHost(string host) + { + if (host is "localhost" or "127.0.0.1") return true; + if (host.StartsWith("192.168.")) return true; + if (host.StartsWith("10.")) return true; + if (host.StartsWith("172.16.") || host.StartsWith("172.17.") || + host.StartsWith("172.18.") || host.StartsWith("172.19.") || + host.StartsWith("172.20.") || host.StartsWith("172.21.") || + host.StartsWith("172.22.") || host.StartsWith("172.23.") || + host.StartsWith("172.24.") || host.StartsWith("172.25.") || + host.StartsWith("172.26.") || host.StartsWith("172.27.") || + host.StartsWith("172.28.") || host.StartsWith("172.29.") || + host.StartsWith("172.30.") || host.StartsWith("172.31.")) return true; + return false; + } + + private record CertInfo + { + public string Host { get; init; } = ""; + public int Port { get; init; } + public string Subject { get; init; } = ""; + public string Issuer { get; init; } = ""; + public DateTime NotBefore { get; init; } + public DateTime NotAfter { get; init; } + public int DaysLeft { get; init; } + public List Sans { get; init; } = new(); + public string Thumbprint { get; init; } = ""; + public string Status { get; init; } = ""; + public string StatusLine { get; init; } = ""; + } +} diff --git a/src/AxCopilot/Handlers/LoremHandler.cs b/src/AxCopilot/Handlers/LoremHandler.cs new file mode 100644 index 0000000..5a26c54 --- /dev/null +++ b/src/AxCopilot/Handlers/LoremHandler.cs @@ -0,0 +1,284 @@ +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L10-4: Lorem Ipsum / 더미 텍스트 생성기 핸들러. "lorem" 프리픽스로 사용합니다. +/// +/// 예: lorem → 1단락 생성 (기본) +/// lorem 3 → 3단락 생성 +/// lorem words 20 → 단어 20개 +/// lorem sentences 5 → 문장 5개 +/// lorem ko → 한국어 더미 텍스트 1단락 +/// lorem ko 3 → 한국어 더미 텍스트 3단락 +/// lorem email 5 → 더미 이메일 주소 5개 +/// lorem name 5 → 더미 이름 5개 (한국어) +/// Enter → 결과를 클립보드에 복사. +/// +public class LoremHandler : IActionHandler +{ + public string? Prefix => "lorem"; + + public PluginMetadata Metadata => new( + "Lorem", + "더미 텍스트 생성기 — Lorem Ipsum · 한국어 · 이메일 · 이름", + "1.0", + "AX"); + + // ── Lorem Ipsum 단어 풀 ───────────────────────────────────────────────── + private static readonly string[] LoremWords = + [ + "lorem", "ipsum", "dolor", "sit", "amet", "consectetur", "adipiscing", "elit", + "sed", "do", "eiusmod", "tempor", "incididunt", "ut", "labore", "et", "dolore", + "magna", "aliqua", "enim", "ad", "minim", "veniam", "quis", "nostrud", + "exercitation", "ullamco", "laboris", "nisi", "aliquip", "ex", "ea", "commodo", + "consequat", "duis", "aute", "irure", "in", "reprehenderit", "voluptate", + "velit", "esse", "cillum", "eu", "fugiat", "nulla", "pariatur", "excepteur", + "sint", "occaecat", "cupidatat", "non", "proident", "sunt", "culpa", "qui", + "officia", "deserunt", "mollit", "anim", "id", "est", "laborum", "perspiciatis", + "unde", "omnis", "iste", "natus", "error", "voluptatem", "accusantium", + "doloremque", "laudantium", "totam", "rem", "aperiam", "eaque", "ipsa", "quae", + "ab", "illo", "inventore", "veritatis", "quasi", "architecto", "beatae", "vitae", + "dicta", "explicabo", "nemo", "ipsam", "quia", "voluptas", "aspernatur", "aut", + "odit", "fugit", "consequuntur", "magni", "dolores", "ratione", "sequi", + "nesciunt", "neque", "porro", "quisquam", "dolorem", "numquam", "eius", "modi", + "temporibus", "incidunt", "magnam", "aliquam", "quaerat", "minima", "nostrum", + "exercitationem", "ullam", "corporis", "suscipit", "laboriosam", "nisi", + "aliquid", "commodi", "consequatur", "quidem", "rerum", "facilis", + ]; + + // ── 한국어 더미 단어 풀 ────────────────────────────────────────────────── + private static readonly string[] KorWords = + [ + "가나다라", "마바사아", "자차카타", "파하", "데이터", "처리", "시스템", "네트워크", + "소프트웨어", "알고리즘", "데이터베이스", "인터페이스", "프레임워크", "모듈", "클래스", + "메서드", "함수", "변수", "구조체", "배열", "목록", "사전", "집합", "스택", "큐", + "트리", "그래프", "정렬", "탐색", "분석", "설계", "구현", "테스트", "배포", "운영", + "서비스", "플랫폼", "클라우드", "컨테이너", "가상화", "보안", "암호화", "인증", "권한", + "로그", "모니터링", "알림", "이벤트", "트랜잭션", "세션", "쿠키", "토큰", "키", + "값", "객체", "인스턴스", "프로세스", "스레드", "비동기", "병렬", "동기화", "잠금", + "버퍼", "캐시", "인덱스", "쿼리", "뷰", "프로시저", "스키마", "테이블", "컬럼", + "행", "열", "기본키", "외래키", "조인", "집계", "필터", "정렬", "그룹화", "분류", + ]; + + private static readonly string[] KorSentenceStarters = + [ + "이 시스템은", "해당 모듈은", "기능을 구현하면", "데이터를 처리하는", + "네트워크 연결이", "서비스가 시작되면", "사용자 인터페이스는", "알고리즘이", + "처리 과정에서", "설계 단계에서", "구현 방식은", "테스트 결과", + ]; + + private static readonly string[] KorSentenceEnders = + [ + "처리됩니다.", "구현되어 있습니다.", "필요합니다.", "중요한 역할을 합니다.", + "확인할 수 있습니다.", "설계되어 있습니다.", "활용됩니다.", "반환됩니다.", + "저장됩니다.", "업데이트됩니다.", "삭제됩니다.", "초기화됩니다.", + ]; + + // ── 더미 이름/이메일 데이터 ─────────────────────────────────────────────── + private static readonly string[] KorLastNames = ["김", "이", "박", "최", "정", "강", "조", "윤", "장", "임", "한", "오", "서", "신", "권", "황", "안", "송", "류", "전"]; + private static readonly string[] KorFirstNames = ["민준", "서연", "예준", "서현", "도윤", "지우", "시우", "수아", "지호", "하은", "준서", "하린", "건우", "소연", "현우", "지민", "우진", "지유", "연우", "채원"]; + private static readonly string[] EmailDomains = ["example.com", "test.co.kr", "dummy.net", "sample.org", "mock.io", "placeholder.dev"]; + + private static readonly Random Rng = new(); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + var para = GenerateParagraph(false); + items.Add(new LauncherItem( + "Lorem Ipsum 1단락", + para.Length > 80 ? para[..80] + "…" : para, + null, + ("copy", para), + Symbol: "\uE8BD")); + items.Add(new LauncherItem("lorem 3", "3단락 생성", null, null, Symbol: "\uE8BD")); + items.Add(new LauncherItem("lorem words 20", "단어 20개", null, null, Symbol: "\uE8BD")); + items.Add(new LauncherItem("lorem sentences 3", "문장 3개", null, null, Symbol: "\uE8BD")); + items.Add(new LauncherItem("lorem ko", "한국어 더미 텍스트", null, null, Symbol: "\uE8BD")); + items.Add(new LauncherItem("lorem email 5", "더미 이메일 5개", null, null, Symbol: "\uE8BD")); + items.Add(new LauncherItem("lorem name 5", "더미 이름 5개", null, null, Symbol: "\uE8BD")); + return Task.FromResult>(items); + } + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + switch (sub) + { + case "words": + case "word": + case "w": + { + var cnt = parts.Length > 1 && int.TryParse(parts[1], out var n) ? Math.Clamp(n, 1, 500) : 20; + var text = GenerateWords(cnt, false); + items.Add(new LauncherItem( + $"단어 {cnt}개", + text.Length > 80 ? text[..80] + "…" : text, + null, ("copy", text), Symbol: "\uE8BD")); + break; + } + + case "sentences": + case "sentence": + case "s": + { + var cnt = parts.Length > 1 && int.TryParse(parts[1], out var n) ? Math.Clamp(n, 1, 50) : 5; + var text = GenerateSentences(cnt, false); + items.Add(new LauncherItem( + $"문장 {cnt}개", + text.Length > 80 ? text[..80] + "…" : text, + null, ("copy", text), Symbol: "\uE8BD")); + break; + } + + case "ko": + case "kor": + case "korean": + { + var cnt = parts.Length > 1 && int.TryParse(parts[1], out var n) ? Math.Clamp(n, 1, 10) : 1; + var text = GenerateParagraphs(cnt, true); + items.Add(new LauncherItem( + $"한국어 더미 텍스트 {cnt}단락", + text.Length > 80 ? text[..80] + "…" : text, + null, ("copy", text), Symbol: "\uE8BD")); + break; + } + + case "email": + { + var cnt = parts.Length > 1 && int.TryParse(parts[1], out var n) ? Math.Clamp(n, 1, 20) : 5; + var emails = Enumerable.Range(0, cnt).Select(_ => GenerateEmail()).ToList(); + var all = string.Join("\n", emails); + items.Add(new LauncherItem( + $"더미 이메일 {cnt}개", + "전체 복사: Enter", + null, ("copy", all), Symbol: "\uE8BD")); + foreach (var email in emails) + items.Add(new LauncherItem(email, "Enter 복사", null, ("copy", email), Symbol: "\uE8BD")); + break; + } + + case "name": + case "names": + { + var cnt = parts.Length > 1 && int.TryParse(parts[1], out var n) ? Math.Clamp(n, 1, 20) : 5; + var names = Enumerable.Range(0, cnt).Select(_ => GenerateKorName()).ToList(); + var all = string.Join("\n", names); + items.Add(new LauncherItem( + $"더미 이름 {cnt}개", + "전체 복사: Enter", + null, ("copy", all), Symbol: "\uE8BD")); + foreach (var name in names) + items.Add(new LauncherItem(name, "한국어 이름 · Enter 복사", null, ("copy", name), Symbol: "\uE8BD")); + break; + } + + default: + { + // 숫자 단독 → 단락 수 + var cnt = int.TryParse(sub, out var n) ? Math.Clamp(n, 1, 10) : 1; + var text = GenerateParagraphs(cnt, false); + items.Add(new LauncherItem( + $"Lorem Ipsum {cnt}단락", + text.Length > 80 ? text[..80] + "…" : text, + null, ("copy", text), Symbol: "\uE8BD")); + 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("Lorem", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + return Task.CompletedTask; + } + + // ── 생성 헬퍼 ───────────────────────────────────────────────────────────── + + private static string GenerateParagraphs(int count, bool korean) + { + var paras = Enumerable.Range(0, count).Select(_ => GenerateParagraph(korean)); + return string.Join("\n\n", paras); + } + + private static string GenerateParagraph(bool korean) + { + var sentenceCount = Rng.Next(4, 8); + return GenerateSentences(sentenceCount, korean); + } + + private static string GenerateSentences(int count, bool korean) + { + var sb = new StringBuilder(); + for (var i = 0; i < count; i++) + { + if (i > 0) sb.Append(' '); + sb.Append(GenerateSentence(korean)); + } + return sb.ToString(); + } + + private static string GenerateSentence(bool korean) + { + if (korean) + { + var starter = KorSentenceStarters[Rng.Next(KorSentenceStarters.Length)]; + var wordCnt = Rng.Next(3, 8); + var words = Enumerable.Range(0, wordCnt).Select(_ => KorWords[Rng.Next(KorWords.Length)]); + var ender = KorSentenceEnders[Rng.Next(KorSentenceEnders.Length)]; + return $"{starter} {string.Join(" ", words)} {ender}"; + } + else + { + var wordCnt = Rng.Next(6, 15); + var words = Enumerable.Range(0, wordCnt).Select((_, idx) => + { + var w = LoremWords[Rng.Next(LoremWords.Length)]; + return idx == 0 ? char.ToUpper(w[0]) + w[1..] : w; + }); + return string.Join(" ", words) + "."; + } + } + + private static string GenerateWords(int count, bool korean) + { + var pool = korean ? KorWords : LoremWords; + return string.Join(" ", Enumerable.Range(0, count).Select(_ => pool[Rng.Next(pool.Length)])); + } + + private static string GenerateEmail() + { + var first = LoremWords[Rng.Next(LoremWords.Length)]; + var second = LoremWords[Rng.Next(LoremWords.Length)]; + var num = Rng.Next(10, 999); + var domain = EmailDomains[Rng.Next(EmailDomains.Length)]; + return $"{first}.{second}{num}@{domain}"; + } + + private static string GenerateKorName() + { + var last = KorLastNames[Rng.Next(KorLastNames.Length)]; + var first = KorFirstNames[Rng.Next(KorFirstNames.Length)]; + return last + first; + } +} diff --git a/src/AxCopilot/Handlers/UuidHandler.cs b/src/AxCopilot/Handlers/UuidHandler.cs new file mode 100644 index 0000000..74a3ba6 --- /dev/null +++ b/src/AxCopilot/Handlers/UuidHandler.cs @@ -0,0 +1,300 @@ +using System.Security.Cryptography; +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L10-2: UUID/GUID 생성기 핸들러. "uuid" 프리픽스로 사용합니다. +/// +/// 예: uuid → UUID v4 1개 생성 +/// uuid 5 → UUID v4 5개 생성 +/// uuid upper → 대문자 UUID 생성 +/// uuid v4 → UUID v4 (랜덤) +/// uuid seq → 순차 UUID (시간 기반, 정렬 가능) +/// uuid short → 짧은 고유 ID (8자리 hex) +/// uuid nil → Nil UUID (00000000-…) +/// uuid parse → UUID 분석 (버전, 타임스탬프 등) +/// Enter → 결과를 클립보드에 복사. +/// +public class UuidHandler : IActionHandler +{ + public string? Prefix => "uuid"; + + public PluginMetadata Metadata => new( + "UUID", + "UUID/GUID 생성기 — v4 · 순차 · 짧은 ID · 분석", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + // 기본: v4 1개 생성 + var uuid = Guid.NewGuid().ToString(); + items.Add(new LauncherItem( + uuid, + "UUID v4 (랜덤) · Enter 복사", + null, + ("copy", uuid), + Symbol: "\uF0E2")); + + items.Add(new LauncherItem("uuid 5", "5개 생성", null, null, Symbol: "\uF0E2")); + items.Add(new LauncherItem("uuid upper", "대문자 UUID", null, null, Symbol: "\uF0E2")); + items.Add(new LauncherItem("uuid seq", "순차 UUID (정렬가능)", null, null, Symbol: "\uF0E2")); + items.Add(new LauncherItem("uuid short", "짧은 ID (8자리)", null, null, Symbol: "\uF0E2")); + items.Add(new LauncherItem("uuid parse …", "UUID 분석", null, null, Symbol: "\uF0E2")); + return Task.FromResult>(items); + } + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + // "uuid parse " + if (sub == "parse" && parts.Length >= 2) + { + items.AddRange(ParseUuid(string.Join(" ", parts.Skip(1)))); + return Task.FromResult>(items); + } + + // "uuid nil" + if (sub == "nil") + { + var nil = "00000000-0000-0000-0000-000000000000"; + items.Add(new LauncherItem(nil, "Nil UUID", null, ("copy", nil), Symbol: "\uF0E2")); + return Task.FromResult>(items); + } + + // "uuid seq" + if (sub == "seq") + { + items.AddRange(GenerateSequential(5)); + return Task.FromResult>(items); + } + + // "uuid short" + if (sub == "short") + { + items.AddRange(GenerateShort(5)); + return Task.FromResult>(items); + } + + // "uuid upper" + if (sub == "upper") + { + var upper = Guid.NewGuid().ToString().ToUpperInvariant(); + items.Add(new LauncherItem(upper, "대문자 UUID v4 · Enter 복사", null, ("copy", upper), Symbol: "\uF0E2")); + for (var i = 0; i < 4; i++) + { + var u = Guid.NewGuid().ToString().ToUpperInvariant(); + items.Add(new LauncherItem(u, "대문자 UUID v4", null, ("copy", u), Symbol: "\uF0E2")); + } + return Task.FromResult>(items); + } + + // "uuid v4" + if (sub == "v4") + { + items.AddRange(GenerateV4(5)); + return Task.FromResult>(items); + } + + // "uuid <숫자>" — N개 생성 + if (int.TryParse(sub, out var count)) + { + count = Math.Clamp(count, 1, 20); + items.AddRange(GenerateV4(count)); + return Task.FromResult>(items); + } + + // UUID 자체를 입력한 경우 → parse + if (Guid.TryParse(q, out _)) + { + items.AddRange(ParseUuid(q)); + return Task.FromResult>(items); + } + + items.Add(new LauncherItem("알 수 없는 명령", + "예: uuid / uuid 5 / uuid upper / uuid seq / uuid short / uuid parse", + null, null, Symbol: "\uE783")); + 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("UUID", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + return Task.CompletedTask; + } + + // ── 생성 헬퍼 ───────────────────────────────────────────────────────────── + + private static IEnumerable GenerateV4(int count) + { + var all = new List(); + for (var i = 0; i < count; i++) + { + var uuid = Guid.NewGuid().ToString(); + all.Add(uuid); + } + + if (count > 1) + { + yield return new LauncherItem( + $"UUID v4 {count}개", + "전체 복사: Enter", + null, + ("copy", string.Join("\n", all)), + Symbol: "\uF0E2"); + } + + foreach (var uuid in all) + yield return new LauncherItem(uuid, "UUID v4 · Enter 복사", null, ("copy", uuid), Symbol: "\uF0E2"); + } + + /// + /// 시간 기반 순차 UUID (UUIDv7 스타일: 밀리초 타임스탬프 + 랜덤 비트). + /// 정렬 가능하고 시간 정보 포함. + /// + private static IEnumerable GenerateSequential(int count) + { + var all = new List(); + for (var i = 0; i < count; i++) + { + if (i > 0) System.Threading.Thread.Sleep(1); // 밀리초 차이 보장 + var uuid = NewSequentialGuid(); + all.Add(uuid); + } + + yield return new LauncherItem( + $"순차 UUID {count}개", + "시간 기반 정렬 가능 · 전체 복사: Enter", + null, + ("copy", string.Join("\n", all)), + Symbol: "\uF0E2"); + + foreach (var uuid in all) + yield return new LauncherItem(uuid, "순차 UUID · Enter 복사", null, ("copy", uuid), Symbol: "\uF0E2"); + } + + private static IEnumerable GenerateShort(int count) + { + var all = new List(); + for (var i = 0; i < count; i++) + all.Add(NewShortId()); + + yield return new LauncherItem( + $"짧은 ID {count}개", + "8자리 hex · 전체 복사: Enter", + null, + ("copy", string.Join("\n", all)), + Symbol: "\uF0E2"); + + foreach (var id in all) + yield return new LauncherItem(id, "짧은 ID (8자리) · Enter 복사", null, ("copy", id), Symbol: "\uF0E2"); + } + + private static IEnumerable ParseUuid(string raw) + { + raw = raw.Trim(); + if (!Guid.TryParse(raw, out var guid)) + { + yield return new LauncherItem("UUID 파싱 실패", $"'{raw}'은 유효한 UUID가 아닙니다", null, null, Symbol: "\uE783"); + yield break; + } + + var bytes = guid.ToByteArray(); + var version = (bytes[7] >> 4) & 0x0F; + var variant = (bytes[8] >> 6) & 0x03; + + yield return new LauncherItem( + guid.ToString(), + $"버전 {version} · 변형 {variant}", + null, + ("copy", guid.ToString()), + Symbol: "\uF0E2"); + + yield return new LauncherItem("소문자", guid.ToString(), null, ("copy", guid.ToString()), Symbol: "\uF0E2"); + yield return new LauncherItem("대문자", guid.ToString().ToUpper(), null, ("copy", guid.ToString().ToUpper()), Symbol: "\uF0E2"); + yield return new LauncherItem("중괄호", $"{{{guid}}}", null, ("copy", $"{{{guid}}}"), Symbol: "\uF0E2"); + yield return new LauncherItem("대시 없음", guid.ToString("N"), null, ("copy", guid.ToString("N")), Symbol: "\uF0E2"); + yield return new LauncherItem("버전", $"UUID v{version}", null, null, Symbol: "\uF0E2"); + yield return new LauncherItem("변형", variant == 2 ? "RFC 4122" : variant == 3 ? "Microsoft" : $"변형 {variant}", null, null, Symbol: "\uF0E2"); + + // 시간 기반 버전 (v1)이면 타임스탬프 복원 시도 + if (version == 1) + { + var ts = ExtractV1Timestamp(bytes); + if (ts.HasValue) + yield return new LauncherItem("타임스탬프", ts.Value.ToString("yyyy-MM-dd HH:mm:ss.fff UTC"), null, null, Symbol: "\uF0E2"); + } + } + + // ── UUID 생성 구현 ──────────────────────────────────────────────────────── + + /// UUIDv7 스타일 순차 GUID: 상위 48비트 = Unix ms 타임스탬프, 하위 = 랜덤 + private static string NewSequentialGuid() + { + var ms = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var rand = RandomNumberGenerator.GetBytes(10); + var bytes = new byte[16]; + + // 상위 6바이트 = 타임스탬프 (big-endian ms) + bytes[0] = (byte)(ms >> 40); + bytes[1] = (byte)(ms >> 32); + bytes[2] = (byte)(ms >> 24); + bytes[3] = (byte)(ms >> 16); + bytes[4] = (byte)(ms >> 8); + bytes[5] = (byte)(ms); + + // 버전 비트 (v7 = 0111) + bytes[6] = (byte)((rand[0] & 0x0F) | 0x70); + bytes[7] = rand[1]; + + // 변형 비트 (RFC 4122 = 10xx) + bytes[8] = (byte)((rand[2] & 0x3F) | 0x80); + bytes[9] = rand[3]; + + // 나머지 랜덤 + Array.Copy(rand, 4, bytes, 10, 6); + + return new Guid(bytes).ToString(); + } + + private static string NewShortId() + { + var bytes = RandomNumberGenerator.GetBytes(4); + return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant(); + } + + private static DateTime? ExtractV1Timestamp(byte[] bytes) + { + try + { + // UUID v1: time_low(4) + time_mid(2) + time_hi_version(2) + var timeLow = (long)((uint)((bytes[3] << 24) | (bytes[2] << 16) | (bytes[1] << 8) | bytes[0])); + var timeMid = (long)((ushort)((bytes[5] << 8) | bytes[4])); + var timeHigh = (long)((ushort)((bytes[7] << 8) | bytes[6]) & 0x0FFF); + var ticks = (timeHigh << 48) | (timeMid << 32) | timeLow; + // UUID epoch = Oct 15, 1582 + var uuidEpoch = new DateTime(1582, 10, 15, 0, 0, 0, DateTimeKind.Utc); + return uuidEpoch.AddTicks(ticks); + } + catch { return null; } + } +} diff --git a/src/AxCopilot/Handlers/XmlHandler.cs b/src/AxCopilot/Handlers/XmlHandler.cs new file mode 100644 index 0000000..1b2ce07 --- /dev/null +++ b/src/AxCopilot/Handlers/XmlHandler.cs @@ -0,0 +1,345 @@ +using System.Text; +using System.Windows; +using System.Xml; +using System.Xml.XPath; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L10-1: XML 포맷터·검증기·XPath 쿼리 핸들러. "xml" 프리픽스로 사용합니다. +/// +/// 예: xml → 클립보드의 XML 자동 포맷 +/// xml 1 → 인라인 XML 포맷 +/// xml compact → 클립보드 XML 압축 (공백 제거) +/// xml xpath //a → 클립보드 XML에 XPath 쿼리 +/// xml validate → XML 유효성 검증 +/// xml attr → XML 속성 목록 추출 +/// xml minify → compact 와 동일 +/// Enter → 결과를 클립보드에 복사. +/// +public class XmlHandler : IActionHandler +{ + public string? Prefix => "xml"; + + public PluginMetadata Metadata => new( + "XML", + "XML 포맷터 · 압축 · 검증 · XPath 쿼리", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + var clip = GetClipboard(); + if (!string.IsNullOrWhiteSpace(clip) && clip.TrimStart().StartsWith('<')) + { + // 클립보드에 XML이 있으면 즉시 포맷 미리보기 + items.AddRange(BuildXmlItems(clip, "클립보드")); + } + else + { + items.Add(new LauncherItem("XML 도구", + "예: xml … / xml compact / xml xpath //path / xml validate", + null, null, Symbol: "\uE943")); + items.Add(new LauncherItem("xml compact", "XML 압축 (공백 제거)", null, null, Symbol: "\uE943")); + items.Add(new LauncherItem("xml validate", "XML 유효성 검증", null, null, Symbol: "\uE943")); + items.Add(new LauncherItem("xml xpath //", "XPath 쿼리", null, null, Symbol: "\uE943")); + items.Add(new LauncherItem("xml attr", "속성 목록 추출", null, null, Symbol: "\uE943")); + } + return Task.FromResult>(items); + } + + var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + switch (sub) + { + case "compact": + case "minify": + { + var src = GetClipboard(); + if (TryMinify(src, out var mini)) + items.Add(new LauncherItem(mini.Length > 80 ? mini[..80] + "…" : mini, + $"압축됨 {src.Length} → {mini.Length} 글자 · Enter 복사", + null, ("copy", mini), Symbol: "\uE943")); + else + items.Add(new LauncherItem("XML 파싱 오류", "클립보드에 유효한 XML이 없습니다", null, null, Symbol: "\uE783")); + break; + } + case "validate": + { + var src = GetClipboard(); + if (string.IsNullOrWhiteSpace(src)) + { + items.Add(new LauncherItem("클립보드 비어 있음", "XML을 클립보드에 복사 후 시도하세요", null, null, Symbol: "\uE783")); + break; + } + var (ok, err) = ValidateXml(src); + items.Add(ok + ? new LauncherItem("✔ 유효한 XML", $"{src.Length:N0}자 · 잘 형식화된 XML입니다", null, null, Symbol: "\uE73E") + : new LauncherItem("✘ XML 오류", err ?? "알 수 없는 오류", null, null, Symbol: "\uE783")); + break; + } + case "xpath": + { + var xpathExpr = parts.Length > 1 ? parts[1].Trim() : ""; + if (string.IsNullOrWhiteSpace(xpathExpr)) + { + items.Add(new LauncherItem("XPath 식을 입력하세요", "예: xml xpath //item/title", null, null, Symbol: "\uE783")); + break; + } + var src = GetClipboard(); + items.AddRange(RunXPath(src, xpathExpr)); + break; + } + case "attr": + { + var src = GetClipboard(); + items.AddRange(ExtractAttributes(src)); + break; + } + default: + { + // 인라인 XML 입력 또는 전체 포맷 + var xmlSrc = q.TrimStart().StartsWith('<') ? q : GetClipboard(); + items.AddRange(BuildXmlItems(xmlSrc, q.TrimStart().StartsWith('<') ? "입력" : "클립보드")); + 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("XML", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + return Task.CompletedTask; + } + + // ── 빌더 ───────────────────────────────────────────────────────────────── + + private static IEnumerable BuildXmlItems(string xml, string source) + { + if (string.IsNullOrWhiteSpace(xml)) + { + yield return new LauncherItem("XML 없음", "클립보드에 XML을 복사해주세요", null, null, Symbol: "\uE783"); + yield break; + } + + if (TryFormat(xml, out var formatted)) + { + var preview = formatted.Length > 100 ? formatted[..100].Replace("\n", " ") + "…" : formatted.Replace("\n", " "); + yield return new LauncherItem( + $"포맷된 XML ({source})", + preview, + null, + ("copy", formatted), + Symbol: "\uE943"); + + // 루트 요소 이름 + if (TryGetRootElement(xml, out var root)) + yield return new LauncherItem("루트 요소", $"<{root}>", null, ("copy", root), Symbol: "\uE943"); + + // 요소 수 + var elemCount = CountElements(xml); + yield return new LauncherItem("요소 수", $"{elemCount:N0}개 요소", null, null, Symbol: "\uE943"); + + // 압축 버전 + if (TryMinify(xml, out var mini)) + yield return new LauncherItem( + "압축 XML", + $"{xml.Length:N0} → {mini.Length:N0} 글자", + null, + ("copy", mini), + Symbol: "\uE943"); + } + else + { + yield return new LauncherItem("XML 파싱 오류", "유효한 XML을 입력해주세요", null, null, Symbol: "\uE783"); + } + } + + private static IEnumerable RunXPath(string xml, string xpath) + { + if (string.IsNullOrWhiteSpace(xml)) + return [new LauncherItem("XML 없음", "클립보드에 XML을 복사해주세요", null, null, Symbol: "\uE783")]; + + XPathDocument doc; + try { doc = new XPathDocument(new System.IO.StringReader(xml)); } + catch (Exception ex) + { + return [new LauncherItem("XML 파싱 오류", ex.Message, null, null, Symbol: "\uE783")]; + } + + XPathNodeIterator iter; + try + { + var nav = doc.CreateNavigator(); + iter = nav.Select(xpath); + } + catch (XPathException ex) + { + return [new LauncherItem("XPath 오류", ex.Message, null, null, Symbol: "\uE783")]; + } + + var results = new List(); + while (iter.MoveNext() && results.Count < 20) + results.Add(iter.Current!.OuterXml); + + if (results.Count == 0) + return [new LauncherItem("결과 없음", $"XPath '{xpath}' — 일치하는 노드 없음", null, null, Symbol: "\uE946")]; + + var items = new List(); + var joined = string.Join("\n", results); + items.Add(new LauncherItem( + $"XPath 결과 {results.Count}개", + results[0].Length > 80 ? results[0][..80] + "…" : results[0], + null, ("copy", joined), Symbol: "\uE943")); + + foreach (var r in results.Take(10)) + { + var disp = r.Length > 80 ? r[..80] + "…" : r; + items.Add(new LauncherItem(disp, "Enter 복사", null, ("copy", r), Symbol: "\uE943")); + } + return items; + } + + private static IEnumerable ExtractAttributes(string xml) + { + if (string.IsNullOrWhiteSpace(xml)) + return [new LauncherItem("XML 없음", "클립보드에 XML을 복사해주세요", null, null, Symbol: "\uE783")]; + + try + { + var doc = new XmlDocument(); + doc.LoadXml(xml); + var attrs = new List<(string Element, string Attr, string Value)>(); + + foreach (XmlNode node in doc.SelectNodes("//*")!) + { + if (node.Attributes == null) continue; + foreach (XmlAttribute attr in node.Attributes) + attrs.Add((node.Name, attr.Name, attr.Value)); + } + + if (attrs.Count == 0) + return [new LauncherItem("속성 없음", "XML에 속성(attribute)이 없습니다", null, null, Symbol: "\uE946")]; + + var items = new List + { + new($"속성 {attrs.Count}개", "전체 복사: Enter", null, + ("copy", string.Join("\n", attrs.Select(a => $"{a.Element}@{a.Attr}={a.Value}"))), + Symbol: "\uE943"), + }; + items.AddRange(attrs.Take(15).Select(a => + new LauncherItem($"{a.Element}@{a.Attr}", a.Value, null, ("copy", a.Value), Symbol: "\uE943"))); + return items; + } + catch (Exception ex) + { + return [new LauncherItem("XML 오류", ex.Message, null, null, Symbol: "\uE783")]; + } + } + + // ── XML 헬퍼 ───────────────────────────────────────────────────────────── + + private static bool TryFormat(string xml, out string result) + { + result = ""; + try + { + var doc = new XmlDocument(); + doc.LoadXml(xml); + var sb = new StringBuilder(); + var st = new XmlWriterSettings { Indent = true, IndentChars = " ", NewLineChars = "\n" }; + using var writer = XmlWriter.Create(sb, st); + doc.WriteTo(writer); + writer.Flush(); + result = sb.ToString(); + return true; + } + catch { return false; } + } + + private static bool TryMinify(string xml, out string result) + { + result = ""; + try + { + var doc = new XmlDocument(); + doc.LoadXml(xml); + var sb = new StringBuilder(); + var st = new XmlWriterSettings { Indent = false, NewLineHandling = NewLineHandling.None }; + using var writer = XmlWriter.Create(sb, st); + doc.WriteTo(writer); + writer.Flush(); + result = sb.ToString(); + return true; + } + catch { return false; } + } + + private static (bool Ok, string? Error) ValidateXml(string xml) + { + try + { + var doc = new XmlDocument(); + doc.LoadXml(xml); + return (true, null); + } + catch (XmlException ex) + { + return (false, $"줄 {ex.LineNumber}, 열 {ex.LinePosition}: {ex.Message}"); + } + } + + private static bool TryGetRootElement(string xml, out string root) + { + root = ""; + try + { + var doc = new XmlDocument(); + doc.LoadXml(xml); + root = doc.DocumentElement?.Name ?? ""; + return !string.IsNullOrEmpty(root); + } + catch { return false; } + } + + private static int CountElements(string xml) + { + try + { + var doc = new XmlDocument(); + doc.LoadXml(xml); + return doc.SelectNodes("//*")?.Count ?? 0; + } + catch { return 0; } + } + + private static string GetClipboard() + { + try + { + return System.Windows.Application.Current.Dispatcher.Invoke( + () => Clipboard.ContainsText() ? Clipboard.GetText() : ""); + } + catch { return ""; } + } +}