diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md index f44c1f3..7e09c45 100644 --- a/docs/LAUNCHER_ROADMAP.md +++ b/docs/LAUNCHER_ROADMAP.md @@ -269,3 +269,16 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label | 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 → 클립보드 복사 | 중간 | + +--- + +## Phase L11 — 개발자 데이터 파싱 도구 (v2.0.3) ✅ 완료 + +> **방향**: 데이터 형식 분석·변환 도구 — CSV, JWT, Cron, 유니코드 문자 전문 처리. + +| # | 기능 | 설명 | 우선순위 | +|---|------|------|----------| +| L11-1 | **CSV 뷰어·파서** ✅ | `csv` 프리픽스. 클립보드 CSV/TSV 자동 감지·파싱. 행수·열수·헤더 미리보기. `csv col N` 컬럼 추출. `csv row N` 행 조회. `csv stats` 숫자 컬럼 합계·평균·최대·최소 계산. `csv tsv` TSV 변환. 쉼표/탭 구분자 자동 감지 | 높음 | +| L11-2 | **JWT 디코더** ✅ | `jwt` 프리픽스. 클립보드 또는 인라인 토큰 자동 감지(eyJ 시작). 헤더(alg·typ)·페이로드(claims)·서명 유무 분석. exp/iat/nbf 타임스탬프 → 날짜 변환. 만료 D-day·남은 시간 계산. `jwt header` / `jwt payload` 부분 조회. **서명 검증 미지원(분석 전용)** | 높음 | +| L11-3 | **Cron 설명기** ✅ | `cron` 프리픽스. 5필드 표준 cron 표현식 파싱. 한국어 설명 생성(예: "평일 오전 9시 실행"). 다음 5회 실행 시간 계산 + 상대 시간 표시. `@daily/@weekly/@monthly/@hourly` 특수 키워드. 필드별 분석(분·시·일·월·요일). Enter → 표현식 복사 | 중간 | +| L11-4 | **유니코드 조회** ✅ | `unicode` 프리픽스. 문자 직접 입력, `U+XXXX`, `0xXXXX`, 10진수 코드포인트 방식 지원. UTF-8·UTF-16 바이트, HTML 엔티티, 카테고리(Lu/Ll/So 등), 블록명 표시. 한글 음절 초·중·종성 분해. 여러 문자 입력 시 코드포인트 범위 요약 | 중간 | diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index 1ac4cf8..41af001 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -237,6 +237,16 @@ public partial class App : System.Windows.Application // L10-4: Lorem Ipsum 더미 텍스트 (prefix=lorem) commandResolver.RegisterHandler(new LoremHandler()); + // ─── Phase L11 핸들러 ───────────────────────────────────────────────── + // L11-1: CSV 뷰어·파서 (prefix=csv) + commandResolver.RegisterHandler(new CsvHandler()); + // L11-2: JWT 토큰 디코더 (prefix=jwt) + commandResolver.RegisterHandler(new JwtHandler()); + // L11-3: Cron 표현식 설명기 (prefix=cron) + commandResolver.RegisterHandler(new CronHandler()); + // L11-4: 유니코드 문자 조회 (prefix=unicode) + commandResolver.RegisterHandler(new UnicodeHandler()); + // ─── 플러그인 로드 ──────────────────────────────────────────────────── var pluginHost = new PluginHost(settings, commandResolver); pluginHost.LoadAll(); diff --git a/src/AxCopilot/Handlers/CronHandler.cs b/src/AxCopilot/Handlers/CronHandler.cs new file mode 100644 index 0000000..210b49e --- /dev/null +++ b/src/AxCopilot/Handlers/CronHandler.cs @@ -0,0 +1,340 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L11-3: Cron 표현식 설명기 핸들러. "cron" 프리픽스로 사용합니다. +/// +/// 예: cron * * * * * → 매 분 실행 (설명 + 다음 5회 실행 시간) +/// cron 0 9 * * 1-5 → 평일 오전 9시 실행 +/// cron 0 0 1 * * → 매월 1일 자정 +/// cron 30 18 * * 5 → 매주 금요일 오후 6시 30분 +/// cron @daily → 매일 자정 (특수 키워드) +/// cron @hourly → 매시간 +/// Enter → 표현식을 클립보드에 복사. +/// +/// 지원 형식: 분(0-59) 시(0-23) 일(1-31) 월(1-12) 요일(0-7) +/// +public class CronHandler : IActionHandler +{ + public string? Prefix => "cron"; + + public PluginMetadata Metadata => new( + "Cron", + "Cron 표현식 설명기 — 다음 실행 시간 · 한국어 설명", + "1.0", + "AX"); + + // 특수 키워드 + private static readonly Dictionary SpecialKeywords = new(StringComparer.OrdinalIgnoreCase) + { + ["@yearly"] = "0 0 1 1 *", + ["@annually"] = "0 0 1 1 *", + ["@monthly"] = "0 0 1 * *", + ["@weekly"] = "0 0 * * 0", + ["@daily"] = "0 0 * * *", + ["@midnight"] = "0 0 * * *", + ["@hourly"] = "0 * * * *", + }; + + // 자주 쓰는 예제 + private static readonly (string Expr, string Desc)[] CommonExamples = + [ + ("* * * * *", "매 분 실행"), + ("0 * * * *", "매 시간 정각"), + ("0 9 * * *", "매일 오전 9시"), + ("0 9 * * 1-5", "평일 오전 9시"), + ("0 0 * * *", "매일 자정"), + ("0 0 1 * *", "매월 1일 자정"), + ("0 0 1 1 *", "매년 1월 1일"), + ("*/5 * * * *", "5분마다"), + ("0 9,18 * * 1-5", "평일 오전 9시·오후 6시"), + ("30 23 * * 5", "매주 금요일 오후 11시 30분"), + ]; + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem("Cron 표현식 설명기", + "예: cron 0 9 * * 1-5 / cron @daily / cron */15 * * * *", + null, null, Symbol: "\uE823")); + foreach (var (expr, desc) in CommonExamples.Take(6)) + items.Add(new LauncherItem(expr, desc, null, ("copy", expr), Symbol: "\uE823")); + return Task.FromResult>(items); + } + + // 특수 키워드 처리 + var expr_ = q; + if (SpecialKeywords.TryGetValue(q, out var expanded)) + expr_ = expanded; + + if (!TryParseCron(expr_, out var cron)) + { + items.Add(new LauncherItem("파싱 실패", + $"'{q}'은 유효한 cron 표현식이 아닙니다. 형식: 분 시 일 월 요일", + null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + // 한국어 설명 + var description = Describe(cron); + items.Add(new LauncherItem( + description, + $"표현식: {expr_} · Enter 복사", + null, ("copy", expr_), Symbol: "\uE823")); + + // 다음 5회 실행 시간 + var nextRuns = GetNextRuns(cron, DateTime.Now, 5); + if (nextRuns.Count > 0) + { + items.Add(new LauncherItem("─ 다음 실행 시간 ─", "", null, null, Symbol: "\uE823")); + foreach (var run in nextRuns) + items.Add(new LauncherItem( + run.ToString("yyyy-MM-dd HH:mm (ddd)"), + GetRelativeTime(run), + null, ("copy", run.ToString("yyyy-MM-dd HH:mm:ss")), + Symbol: "\uE823")); + } + + // 필드별 설명 + items.Add(new LauncherItem("─ 필드 분석 ─", "", null, null, Symbol: "\uE823")); + items.Add(new LauncherItem("분", DescribeField(cron.Minute, 0, 59, "분"), null, null, Symbol: "\uE823")); + items.Add(new LauncherItem("시", DescribeField(cron.Hour, 0, 23, "시"), null, null, Symbol: "\uE823")); + items.Add(new LauncherItem("일", DescribeField(cron.Day, 1, 31, "일"), null, null, Symbol: "\uE823")); + items.Add(new LauncherItem("월", DescribeField(cron.Month, 1, 12, "월"), null, null, Symbol: "\uE823")); + items.Add(new LauncherItem("요일", DescribeField(cron.DayOfWeek, 0, 7, "요일"), null, null, Symbol: "\uE823")); + + 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("Cron", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + return Task.CompletedTask; + } + + // ── Cron 파서 ───────────────────────────────────────────────────────────── + + private record CronExpr(string Minute, string Hour, string Day, string Month, string DayOfWeek); + + private static bool TryParseCron(string expr, out CronExpr result) + { + result = new CronExpr("*", "*", "*", "*", "*"); + var parts = expr.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 5) return false; + + // 각 필드 유효성 검사 + if (!IsValidCronField(parts[0], 0, 59)) return false; + if (!IsValidCronField(parts[1], 0, 23)) return false; + if (!IsValidCronField(parts[2], 1, 31)) return false; + if (!IsValidCronField(parts[3], 1, 12)) return false; + if (!IsValidCronField(parts[4], 0, 7)) return false; + + result = new CronExpr(parts[0], parts[1], parts[2], parts[3], parts[4]); + return true; + } + + private static bool IsValidCronField(string field, int min, int max) + { + if (field == "*") return true; + foreach (var part in field.Split(',')) + { + if (part.Contains('/')) + { + var sp = part.Split('/'); + if (sp.Length != 2) return false; + if (sp[0] != "*" && !int.TryParse(sp[0], out _)) return false; + if (!int.TryParse(sp[1], out var step) || step < 1) return false; + } + else if (part.Contains('-')) + { + var sp = part.Split('-'); + if (sp.Length != 2) return false; + if (!int.TryParse(sp[0], out var a) || !int.TryParse(sp[1], out var b)) return false; + if (a < min || b > max || a > b) return false; + } + else + { + if (!int.TryParse(part, out var v)) return false; + if (v < min || v > max) return false; + } + } + return true; + } + + // ── 다음 실행 시간 계산 ──────────────────────────────────────────────────── + + private static List GetNextRuns(CronExpr cron, DateTime from, int count) + { + var results = new List(); + // 다음 분부터 시작 + var current = from.AddSeconds(-from.Second).AddMinutes(1); + var limit = from.AddDays(366); // 최대 1년 탐색 + + while (results.Count < count && current < limit) + { + if (MatchesMonth(cron.Month, current.Month) && + MatchesDay(cron.Day, current.Day) && + MatchesDayOfWeek(cron.DayOfWeek, (int)current.DayOfWeek) && + MatchesHour(cron.Hour, current.Hour) && + MatchesMinute(cron.Minute, current.Minute)) + { + results.Add(current); + current = current.AddMinutes(1); + } + else + { + current = AdvanceCron(cron, current); + } + } + return results; + } + + private static DateTime AdvanceCron(CronExpr cron, DateTime dt) + { + // 빠른 스킵: 분 단위로 증가 + return dt.AddMinutes(1); + } + + private static bool MatchesField(string field, int value, int min, int max) + { + if (field == "*") return true; + foreach (var part in field.Split(',')) + { + if (part.Contains('/')) + { + var sp = part.Split('/'); + var step = int.Parse(sp[1]); + var start = sp[0] == "*" ? min : int.Parse(sp[0]); + for (var v = start; v <= max; v += step) + if (v == value) return true; + } + else if (part.Contains('-')) + { + var sp = part.Split('-'); + var a = int.Parse(sp[0]); + var b = int.Parse(sp[1]); + if (value >= a && value <= b) return true; + } + else + { + if (int.Parse(part) == value) return true; + } + } + return false; + } + + private static bool MatchesMinute(string f, int v) => MatchesField(f, v, 0, 59); + private static bool MatchesHour(string f, int v) => MatchesField(f, v, 0, 23); + private static bool MatchesDay(string f, int v) => MatchesField(f, v, 1, 31); + private static bool MatchesMonth(string f, int v) => MatchesField(f, v, 1, 12); + private static bool MatchesDayOfWeek(string f, int v) + { + // 0과 7 모두 일요일 + if (f == "*") return true; + return MatchesField(f, v, 0, 7) || (v == 0 && MatchesField(f, 7, 0, 7)); + } + + // ── 한국어 설명 ─────────────────────────────────────────────────────────── + + private static string Describe(CronExpr c) + { + var parts = new List(); + + // 분 + var minDesc = c.Minute == "*" ? "매 분" : DescribeField(c.Minute, 0, 59, "분"); + // 시 + var hourDesc = c.Hour == "*" ? "매 시간" : DescribeField(c.Hour, 0, 23, "시"); + // 일 + var dayDesc = c.Day == "*" ? "" : DescribeField(c.Day, 1, 31, "일"); + // 월 + var monDesc = c.Month == "*" ? "" : DescribeField(c.Month, 1, 12, "월"); + // 요일 + var dowDesc = c.DayOfWeek == "*" ? "" : DescribeWeekday(c.DayOfWeek); + + if (c.Minute == "0" && c.Hour == "0" && c.Day == "*" && c.Month == "*" && c.DayOfWeek == "*") + return "매일 자정(00:00)"; + if (c.Minute == "0" && c.Hour == "*") + return "매 시간 정각"; + if (c.Minute == "*" && c.Hour == "*" && c.Day == "*" && c.Month == "*" && c.DayOfWeek == "*") + return "매 분 실행"; + + // 조합 + var sb = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(monDesc)) { sb.Append(monDesc); sb.Append(' '); } + if (!string.IsNullOrEmpty(dayDesc)) { sb.Append(dayDesc); sb.Append(' '); } + if (!string.IsNullOrEmpty(dowDesc)) { sb.Append(dowDesc); sb.Append(' '); } + sb.Append(hourDesc); + sb.Append(' '); + sb.Append(minDesc); + sb.Append(" 실행"); + return sb.ToString().Trim(); + } + + private static string DescribeField(string field, int min, int max, string unit) + { + if (field == "*") return $"모든 {unit}"; + if (field.StartsWith("*/")) + { + var step = field[2..]; + return $"{step}{unit}마다"; + } + if (field.Contains('-')) + { + var sp = field.Split('-'); + return $"{sp[0]}~{sp[1]}{unit}"; + } + if (field.Contains(',')) + { + return string.Join(",", field.Split(',')) + unit; + } + return $"{field}{unit}"; + } + + private static string DescribeWeekday(string field) + { + string[] days = ["일", "월", "화", "수", "목", "금", "토", "일"]; + if (field.Contains('-')) + { + var sp = field.Split('-'); + if (int.TryParse(sp[0], out var a) && int.TryParse(sp[1], out var b)) + return $"{days[a]}~{days[Math.Min(b, 7)]}요일"; + } + if (field.Contains(',')) + { + var parts = field.Split(',') + .Where(p => int.TryParse(p, out _)) + .Select(p => days[int.Parse(p) % 8]); + return string.Join(",", parts) + "요일"; + } + if (int.TryParse(field, out var d)) + return days[d % 8] + "요일"; + return field; + } + + private static string GetRelativeTime(DateTime dt) + { + var diff = dt - DateTime.Now; + if (diff.TotalMinutes < 1) return "1분 이내"; + if (diff.TotalHours < 1) return $"{(int)diff.TotalMinutes}분 후"; + if (diff.TotalDays < 1) return $"{(int)diff.TotalHours}시간 {diff.Minutes}분 후"; + if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}일 {diff.Hours}시간 후"; + return $"{(int)diff.TotalDays}일 후"; + } +} diff --git a/src/AxCopilot/Handlers/CsvHandler.cs b/src/AxCopilot/Handlers/CsvHandler.cs new file mode 100644 index 0000000..1df44d9 --- /dev/null +++ b/src/AxCopilot/Handlers/CsvHandler.cs @@ -0,0 +1,338 @@ +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L11-1: CSV 뷰어·파서 핸들러. "csv" 프리픽스로 사용합니다. +/// +/// 예: csv → 클립보드 CSV 파싱 (헤더·행수·컬럼수) +/// csv col 2 → 2번째 컬럼 값 목록 추출 +/// csv row 3 → 3번째 행 출력 +/// csv stats → 숫자 컬럼 합계·평균·최대·최소 +/// csv head → 헤더 컬럼명 목록 +/// csv tsv → CSV → TSV(탭 구분) 변환 +/// Enter → 결과를 클립보드에 복사. +/// +public class CsvHandler : IActionHandler +{ + public string? Prefix => "csv"; + + public PluginMetadata Metadata => new( + "CSV", + "CSV 뷰어·파서 — 컬럼 추출 · 통계 · 형식 변환", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + var clip = GetClipboard(); + var hasData = !string.IsNullOrWhiteSpace(clip) && LooksCsv(clip); + + if (string.IsNullOrWhiteSpace(q)) + { + if (hasData) + { + items.AddRange(BuildOverviewItems(clip)); + } + else + { + items.Add(new LauncherItem("CSV 뷰어", "CSV를 클립보드에 복사 후 'csv' 입력", + null, null, Symbol: "\uE8A5")); + items.Add(new LauncherItem("csv col 2", "2번째 컬럼 추출", null, null, Symbol: "\uE8A5")); + items.Add(new LauncherItem("csv stats", "숫자 컬럼 통계", null, null, Symbol: "\uE8A5")); + items.Add(new LauncherItem("csv tsv", "CSV → TSV 변환", null, null, Symbol: "\uE8A5")); + } + return Task.FromResult>(items); + } + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + if (!hasData) + { + items.Add(new LauncherItem("클립보드에 CSV 없음", + "CSV 형식 데이터를 클립보드에 복사 후 시도하세요", null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + var rows = ParseCsv(clip); + if (rows.Count == 0) + { + items.Add(new LauncherItem("파싱 실패", "유효한 CSV가 아닙니다", null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + switch (sub) + { + case "col": + case "column": + { + var colIdx = parts.Length > 1 && int.TryParse(parts[1], out var n) ? n - 1 : 0; + items.AddRange(ExtractColumn(rows, colIdx)); + break; + } + case "row": + { + var rowIdx = parts.Length > 1 && int.TryParse(parts[1], out var n) ? n - 1 : 0; + items.AddRange(ExtractRow(rows, rowIdx)); + break; + } + case "stats": + case "stat": + { + items.AddRange(BuildStatsItems(rows)); + break; + } + case "head": + case "header": + { + if (rows.Count > 0) + { + var header = rows[0]; + var all = string.Join(", ", header); + items.Add(new LauncherItem($"헤더 {header.Count}개 컬럼", all, null, ("copy", all), Symbol: "\uE8A5")); + for (var i = 0; i < header.Count; i++) + items.Add(new LauncherItem($"[{i+1}] {header[i]}", $"컬럼 {i+1}", null, ("copy", header[i]), Symbol: "\uE8A5")); + } + break; + } + case "tsv": + { + var tsv = ConvertToTsv(rows); + items.Add(new LauncherItem( + $"TSV 변환 {rows.Count}행", + "탭 구분자 · Enter 복사", + null, ("copy", tsv), Symbol: "\uE8A5")); + break; + } + default: + items.AddRange(BuildOverviewItems(clip)); + 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("CSV", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + return Task.CompletedTask; + } + + // ── 빌더 ───────────────────────────────────────────────────────────────── + + private static IEnumerable BuildOverviewItems(string csv) + { + var rows = ParseCsv(csv); + if (rows.Count == 0) + { + yield return new LauncherItem("파싱 실패", "유효한 CSV 데이터가 아닙니다", null, null, Symbol: "\uE783"); + yield break; + } + + var headerRow = rows[0]; + var dataRows = rows.Count > 1 ? rows.Count - 1 : 0; + var colCount = headerRow.Count; + + yield return new LauncherItem( + $"CSV {dataRows}행 × {colCount}열", + $"헤더: {string.Join(", ", headerRow.Take(5))}{(colCount > 5 ? " …" : "")}", + null, + ("copy", csv), + Symbol: "\uE8A5"); + + yield return new LauncherItem("컬럼 수", $"{colCount}개", null, null, Symbol: "\uE8A5"); + yield return new LauncherItem("데이터 행수", $"{dataRows}행", null, null, Symbol: "\uE8A5"); + yield return new LauncherItem("헤더", string.Join(" | ", headerRow.Take(8)), null, + ("copy", string.Join(",", headerRow)), Symbol: "\uE8A5"); + + // 첫 데이터 행 미리보기 + if (rows.Count > 1) + { + var first = rows[1]; + yield return new LauncherItem( + "첫 번째 행", + string.Join(" | ", first.Take(6)), + null, + ("copy", string.Join(",", first)), + Symbol: "\uE8A5"); + } + } + + private static IEnumerable ExtractColumn(List> rows, int colIdx) + { + if (rows.Count == 0) + { + yield return new LauncherItem("데이터 없음", "", null, null, Symbol: "\uE783"); + yield break; + } + + var maxCol = rows.Max(r => r.Count) - 1; + colIdx = Math.Clamp(colIdx, 0, maxCol); + + var header = rows[0].Count > colIdx ? rows[0][colIdx] : $"컬럼{colIdx+1}"; + var values = rows.Skip(1).Where(r => r.Count > colIdx).Select(r => r[colIdx]).ToList(); + var allText = string.Join("\n", values); + + yield return new LauncherItem( + $"[{colIdx+1}] {header} ({values.Count}개 값)", + "전체 복사: Enter", + null, ("copy", allText), Symbol: "\uE8A5"); + + foreach (var v in values.Take(15)) + yield return new LauncherItem(v, $"컬럼: {header}", null, ("copy", v), Symbol: "\uE8A5"); + } + + private static IEnumerable ExtractRow(List> rows, int rowIdx) + { + rowIdx = Math.Clamp(rowIdx, 0, rows.Count - 1); + var row = rows[rowIdx]; + var header = rows[0]; + var allText = string.Join(",", row); + + yield return new LauncherItem( + $"행 {rowIdx+1} ({row.Count}개 값)", + allText.Length > 80 ? allText[..80] + "…" : allText, + null, ("copy", allText), Symbol: "\uE8A5"); + + for (var i = 0; i < row.Count; i++) + { + var colName = header.Count > i ? header[i] : $"컬럼{i+1}"; + yield return new LauncherItem($"[{colName}]", row[i], null, ("copy", row[i]), Symbol: "\uE8A5"); + } + } + + private static IEnumerable BuildStatsItems(List> rows) + { + if (rows.Count < 2) + { + yield return new LauncherItem("데이터 없음", "헤더 포함 2행 이상 필요", null, null, Symbol: "\uE783"); + yield break; + } + + var header = rows[0]; + var data = rows.Skip(1).ToList(); + + for (var col = 0; col < header.Count; col++) + { + var colName = header.Count > col ? header[col] : $"컬럼{col+1}"; + var nums = data + .Where(r => r.Count > col) + .Select(r => r[col]) + .Where(v => double.TryParse(v, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out _)) + .Select(v => double.Parse(v, System.Globalization.CultureInfo.InvariantCulture)) + .ToList(); + + if (nums.Count == 0) continue; + + var sum = nums.Sum(); + var avg = nums.Average(); + var min = nums.Min(); + var max = nums.Max(); + + yield return new LauncherItem( + $"[{colName}] 합계: {sum:N2}", + $"평균: {avg:N2} 최소: {min:N2} 최대: {max:N2} ({nums.Count}개)", + null, + ("copy", $"{colName}: sum={sum:N2} avg={avg:N2} min={min:N2} max={max:N2}"), + Symbol: "\uE8A5"); + } + } + + // ── 변환 헬퍼 ──────────────────────────────────────────────────────────── + + private static string ConvertToTsv(List> rows) + { + var sb = new StringBuilder(); + foreach (var row in rows) + { + sb.AppendLine(string.Join("\t", row.Select(EscapeTsv))); + } + return sb.ToString().TrimEnd(); + } + + private static string EscapeTsv(string v) => v.Replace("\t", " ").Replace("\r", "").Replace("\n", " "); + + // ── CSV 파서 ───────────────────────────────────────────────────────────── + + private static List> ParseCsv(string text) + { + var result = new List>(); + // 구분자 자동 감지 (탭 또는 쉼표) + var firstLine = text.Split('\n')[0]; + var delimiter = firstLine.Count(c => c == '\t') > firstLine.Count(c => c == ',') ? '\t' : ','; + + using var reader = new System.IO.StringReader(text); + string? line; + while ((line = reader.ReadLine()) != null) + { + if (string.IsNullOrWhiteSpace(line)) continue; + result.Add(ParseCsvLine(line, delimiter)); + } + return result; + } + + private static List ParseCsvLine(string line, char delim) + { + var fields = new List(); + var sb = new StringBuilder(); + var inQuote = false; + + for (var i = 0; i < line.Length; i++) + { + var c = line[i]; + if (inQuote) + { + if (c == '"') + { + if (i + 1 < line.Length && line[i + 1] == '"') + { sb.Append('"'); i++; } + else + { inQuote = false; } + } + else { sb.Append(c); } + } + else + { + if (c == '"') { inQuote = true; } + else if (c == delim){ fields.Add(sb.ToString()); sb.Clear(); } + else { sb.Append(c); } + } + } + fields.Add(sb.ToString()); + return fields; + } + + private static bool LooksCsv(string text) + { + var firstLine = text.Split('\n').FirstOrDefault(l => !string.IsNullOrWhiteSpace(l)) ?? ""; + return firstLine.Contains(',') || firstLine.Contains('\t'); + } + + private static string GetClipboard() + { + try + { + return System.Windows.Application.Current.Dispatcher.Invoke( + () => Clipboard.ContainsText() ? Clipboard.GetText() : ""); + } + catch { return ""; } + } +} diff --git a/src/AxCopilot/Handlers/JwtHandler.cs b/src/AxCopilot/Handlers/JwtHandler.cs new file mode 100644 index 0000000..ec5089b --- /dev/null +++ b/src/AxCopilot/Handlers/JwtHandler.cs @@ -0,0 +1,298 @@ +using System.Text; +using System.Text.Json; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L11-2: JWT 토큰 디코더 핸들러. "jwt" 프리픽스로 사용합니다. +/// +/// 예: jwt → 클립보드의 JWT 자동 분석 +/// jwt eyJhbGci... → 토큰 직접 입력 +/// jwt header → 클립보드 JWT 헤더만 표시 +/// jwt payload → 클립보드 JWT 페이로드만 표시 +/// Enter → 결과를 클립보드에 복사. +/// 주의: 서명(signature) 검증은 수행하지 않음 — 분석 전용. +/// +public class JwtHandler : IActionHandler +{ + public string? Prefix => "jwt"; + + public PluginMetadata Metadata => new( + "JWT", + "JWT 토큰 디코더 — 헤더 · 페이로드 · 만료일 분석", + "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 (LooksJwt(clip)) + { + items.AddRange(DecodeJwt(clip, "all")); + } + else + { + items.Add(new LauncherItem("JWT 디코더", + "JWT 토큰을 클립보드에 복사하거나 직접 입력하세요", + null, null, Symbol: "\uE72E")); + items.Add(new LauncherItem("jwt eyJ…", "토큰 직접 입력", null, null, Symbol: "\uE72E")); + items.Add(new LauncherItem("jwt header", "헤더만 표시", null, null, Symbol: "\uE72E")); + items.Add(new LauncherItem("jwt payload", "페이로드만 표시", null, null, Symbol: "\uE72E")); + } + return Task.FromResult>(items); + } + + var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + switch (sub) + { + case "header": + { + var src = GetTokenSource(parts); + items.AddRange(DecodeJwt(src, "header")); + break; + } + case "payload": + case "claims": + case "body": + { + var src = GetTokenSource(parts); + items.AddRange(DecodeJwt(src, "payload")); + break; + } + default: + { + // 토큰 자체 입력 (eyJ로 시작) + var token = LooksJwt(q) ? q : GetClipboard(); + if (!LooksJwt(token)) + { + items.Add(new LauncherItem("JWT 형식 아님", + "eyJ…로 시작하는 JWT 토큰을 입력하거나 클립보드에 복사하세요", + null, null, Symbol: "\uE783")); + } + else + { + items.AddRange(DecodeJwt(token, "all")); + } + 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("JWT", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + return Task.CompletedTask; + } + + // ── JWT 디코딩 ──────────────────────────────────────────────────────────── + + private static IEnumerable DecodeJwt(string token, string mode) + { + if (!LooksJwt(token)) + { + yield return new LauncherItem("JWT 없음", + "클립보드에 eyJ…로 시작하는 JWT가 없습니다", null, null, Symbol: "\uE783"); + yield break; + } + + var parts = token.Split('.'); + if (parts.Length < 2) + { + yield return new LauncherItem("형식 오류", "JWT는 최소 2개의 점(.)으로 구성됩니다", null, null, Symbol: "\uE783"); + yield break; + } + + // 헤더 디코딩 + if (mode is "header" or "all") + { + var headerJson = TryDecodeBase64Url(parts[0]); + if (headerJson != null) + { + var pretty = TryPrettyJson(headerJson) ?? headerJson; + yield return new LauncherItem("─ 헤더 ─", "", null, null, Symbol: "\uE72E"); + foreach (var item in ExtractJsonFields(headerJson, "헤더")) + yield return item; + yield return new LauncherItem("헤더 JSON", "전체 복사", null, ("copy", pretty), Symbol: "\uE72E"); + } + } + + // 페이로드 디코딩 + if (mode is "payload" or "all") + { + if (parts.Length < 2) + { + yield return new LauncherItem("페이로드 없음", "JWT에 페이로드가 없습니다", null, null, Symbol: "\uE783"); + yield break; + } + + var payloadJson = TryDecodeBase64Url(parts[1]); + if (payloadJson != null) + { + yield return new LauncherItem("─ 페이로드 ─", "", null, null, Symbol: "\uE72E"); + + // 만료일(exp) 특별 처리 + var expItem = ExtractExpiry(payloadJson); + if (expItem != null) yield return expItem; + + foreach (var item in ExtractJsonFields(payloadJson, "페이로드")) + yield return item; + + var pretty = TryPrettyJson(payloadJson) ?? payloadJson; + yield return new LauncherItem("페이로드 JSON", "전체 복사", null, ("copy", pretty), Symbol: "\uE72E"); + } + } + + // 서명 유무 + if (mode == "all") + { + var hasSig = parts.Length >= 3 && !string.IsNullOrEmpty(parts[2]); + yield return new LauncherItem( + "서명", + hasSig ? "있음 (검증 미지원 — 분석 전용)" : "없음 (alg:none)", + null, null, Symbol: "\uE72E"); + } + } + + private static IEnumerable ExtractJsonFields(string json, string section) + { + JsonDocument doc; + try { doc = JsonDocument.Parse(json); } + catch { yield break; } + + using (doc) + { + foreach (var prop in doc.RootElement.EnumerateObject()) + { + var val = prop.Value.ValueKind switch + { + JsonValueKind.String => prop.Value.GetString() ?? "", + JsonValueKind.Number => prop.Value.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Null => "null", + _ => prop.Value.GetRawText(), + }; + + // exp, iat, nbf 는 타임스탬프 → 날짜 변환해서 별도 표시 + if (prop.Name is "exp" or "iat" or "nbf") + { + if (long.TryParse(val, out var ts)) + { + var dt = DateTimeOffset.FromUnixTimeSeconds(ts).ToLocalTime(); + var label = prop.Name switch { "exp" => "만료(exp)", "iat" => "발급(iat)", _ => "유효 시작(nbf)" }; + yield return new LauncherItem(label, dt.ToString("yyyy-MM-dd HH:mm:ss"), null, ("copy", dt.ToString("o")), Symbol: "\uE72E"); + continue; + } + } + + var display = val.Length > 60 ? val[..60] + "…" : val; + yield return new LauncherItem(prop.Name, display, null, ("copy", val), Symbol: "\uE72E"); + } + } + } + + private static LauncherItem? ExtractExpiry(string payloadJson) + { + try + { + var doc = JsonDocument.Parse(payloadJson); + if (!doc.RootElement.TryGetProperty("exp", out var expProp)) return null; + if (!expProp.TryGetInt64(out var exp)) return null; + + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var dt = DateTimeOffset.FromUnixTimeSeconds(exp).ToLocalTime(); + var remain = exp - now; + + string status; + if (remain < 0) + status = $"만료됨 ({Math.Abs(remain / 60)}분 전)"; + else if (remain < 60) + status = $"곧 만료 ({remain}초 남음)"; + else if (remain < 3600) + status = $"유효 ({remain / 60}분 남음)"; + else if (remain < 86400) + status = $"유효 ({remain / 3600}시간 남음)"; + else + status = $"유효 ({remain / 86400}일 남음)"; + + return new LauncherItem( + $"만료 상태: {status}", + dt.ToString("yyyy-MM-dd HH:mm:ss"), + null, null, Symbol: remain < 0 ? "\uE783" : "\uE73E"); + } + catch { return null; } + } + + // ── 헬퍼 ───────────────────────────────────────────────────────────────── + + private static string? TryDecodeBase64Url(string input) + { + try + { + // Base64Url → Base64 + var base64 = input.Replace('-', '+').Replace('_', '/'); + var pad = (4 - base64.Length % 4) % 4; + base64 += new string('=', pad); + var bytes = Convert.FromBase64String(base64); + return Encoding.UTF8.GetString(bytes); + } + catch { return null; } + } + + private static string? TryPrettyJson(string json) + { + try + { + var doc = JsonDocument.Parse(json); + return JsonSerializer.Serialize(doc.RootElement, + new JsonSerializerOptions { WriteIndented = true }); + } + catch { return null; } + } + + private static bool LooksJwt(string? s) + { + if (string.IsNullOrWhiteSpace(s)) return false; + s = s.Trim(); + return s.StartsWith("eyJ", StringComparison.Ordinal) && s.Contains('.'); + } + + private static string GetTokenSource(string[] parts) + { + // parts[1]에 토큰이 있으면 사용, 없으면 클립보드 + if (parts.Length > 1 && LooksJwt(parts[1])) return parts[1]; + return GetClipboard(); + } + + private static string GetClipboard() + { + try + { + return System.Windows.Application.Current.Dispatcher.Invoke( + () => Clipboard.ContainsText() ? Clipboard.GetText().Trim() : ""); + } + catch { return ""; } + } +} diff --git a/src/AxCopilot/Handlers/UnicodeHandler.cs b/src/AxCopilot/Handlers/UnicodeHandler.cs new file mode 100644 index 0000000..b25d30d --- /dev/null +++ b/src/AxCopilot/Handlers/UnicodeHandler.cs @@ -0,0 +1,344 @@ +using System.Globalization; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L11-4: 유니코드 문자 조회 핸들러. "unicode" 프리픽스로 사용합니다. +/// +/// 예: unicode A → 문자 'A'의 코드포인트·카테고리·이름 조회 +/// unicode U+1F600 → 코드포인트로 문자 조회 +/// unicode 0x1F600 → 16진수 코드포인트 +/// unicode 128512 → 10진수 코드포인트 +/// unicode 가 → 한글 문자 분석 +/// unicode smile → 문자 설명으로 검색 (이모지 이름 포함) +/// Enter → 문자를 클립보드에 복사. +/// +public class UnicodeHandler : IActionHandler +{ + public string? Prefix => "unicode"; + + public PluginMetadata Metadata => new( + "Unicode", + "유니코드 문자 조회 — 코드포인트 · 카테고리 · 블록", + "1.0", + "AX"); + + // 주요 유니코드 블록 범위 + private static readonly (int Start, int End, string Name)[] UnicodeBlocks = + [ + (0x0000, 0x007F, "Basic Latin"), + (0x0080, 0x00FF, "Latin-1 Supplement"), + (0x0100, 0x017F, "Latin Extended-A"), + (0x0370, 0x03FF, "Greek and Coptic"), + (0x0400, 0x04FF, "Cyrillic"), + (0x0600, 0x06FF, "Arabic"), + (0x0900, 0x097F, "Devanagari"), + (0x1100, 0x11FF, "Hangul Jamo"), + (0x2000, 0x206F, "General Punctuation"), + (0x2100, 0x214F, "Letterlike Symbols"), + (0x2200, 0x22FF, "Mathematical Operators"), + (0x2300, 0x23FF, "Miscellaneous Technical"), + (0x2600, 0x26FF, "Miscellaneous Symbols"), + (0x2700, 0x27BF, "Dingbats"), + (0x3000, 0x303F, "CJK Symbols and Punctuation"), + (0x3040, 0x309F, "Hiragana"), + (0x30A0, 0x30FF, "Katakana"), + (0x4E00, 0x9FFF, "CJK Unified Ideographs"), + (0xAC00, 0xD7AF, "Hangul Syllables"), + (0xE000, 0xF8FF, "Private Use Area"), + (0xF000, 0xF0FF, "Segoe MDL2 Assets (PUA)"), + (0x1F300, 0x1F5FF, "Miscellaneous Symbols and Pictographs"), + (0x1F600, 0x1F64F, "Emoticons"), + (0x1F680, 0x1F6FF, "Transport and Map Symbols"), + (0x1F900, 0x1F9FF, "Supplemental Symbols and Pictographs"), + ]; + + // 자주 쓰는 특수 문자 예제 + private static readonly (string Char, string Desc)[] QuickChars = + [ + ("©", "Copyright Sign (U+00A9)"), + ("®", "Registered Sign (U+00AE)"), + ("™", "Trade Mark Sign (U+2122)"), + ("•", "Bullet (U+2022)"), + ("→", "Rightwards Arrow (U+2192)"), + ("←", "Leftwards Arrow (U+2190)"), + ("✓", "Check Mark (U+2713)"), + ("✗", "Ballot X (U+2717)"), + ("★", "Black Star (U+2605)"), + ("♥", "Black Heart Suit (U+2665)"), + ("😀", "Grinning Face (U+1F600)"), + ("한", "Korean Syllable (AC00~D7AF)"), + ]; + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem("유니코드 문자 조회", + "예: unicode A / unicode U+1F600 / unicode 가 / unicode 0x2665", + null, null, Symbol: "\uE8D2")); + foreach (var (ch, desc) in QuickChars.Take(8)) + items.Add(new LauncherItem(ch, desc, null, ("copy", ch), Symbol: "\uE8D2")); + return Task.FromResult>(items); + } + + // U+XXXX 형식 + if (q.StartsWith("U+", StringComparison.OrdinalIgnoreCase) || + q.StartsWith("u+", StringComparison.OrdinalIgnoreCase)) + { + if (int.TryParse(q[2..], NumberStyles.HexNumber, null, out var cp)) + items.AddRange(BuildCodePointItems(cp)); + else + items.Add(new LauncherItem("형식 오류", "예: U+1F600", null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + // 0x 16진수 + if (q.StartsWith("0x", StringComparison.OrdinalIgnoreCase) || + q.StartsWith("0X", StringComparison.OrdinalIgnoreCase)) + { + if (int.TryParse(q[2..], NumberStyles.HexNumber, null, out var cp)) + items.AddRange(BuildCodePointItems(cp)); + else + items.Add(new LauncherItem("형식 오류", "예: 0x1F600", null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + // 순수 10진수 코드포인트 + if (int.TryParse(q, out var decCp) && decCp >= 0) + { + items.AddRange(BuildCodePointItems(decCp)); + return Task.FromResult>(items); + } + + // 문자(1~2자) 직접 입력 → 분석 + var codePoints = GetCodePoints(q); + if (codePoints.Count > 0 && codePoints.Count <= 6) + { + if (codePoints.Count == 1) + { + items.AddRange(BuildCodePointItems(codePoints[0])); + } + else + { + // 여러 문자 일괄 분석 + items.Add(new LauncherItem($"'{q}' {codePoints.Count}개 코드포인트", "전체 분석", null, null, Symbol: "\uE8D2")); + foreach (var cp in codePoints) + items.AddRange(BuildCodePointItems(cp)); + } + return Task.FromResult>(items); + } + + // 6자 초과 → 통계만 + if (codePoints.Count > 0) + { + items.Add(new LauncherItem( + $"'{(q.Length > 10 ? q[..10] + "…" : q)}' {codePoints.Count}개 코드포인트", + $"범위: U+{codePoints.Min():X4} ~ U+{codePoints.Max():X4}", + null, + ("copy", string.Join(" ", codePoints.Select(c => $"U+{c:X4}"))), + Symbol: "\uE8D2")); + } + else + { + items.Add(new LauncherItem("조회 실패", + $"'{q}'을(를) 인식할 수 없습니다. 예: unicode A / unicode U+1F600", + 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("Unicode", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + return Task.CompletedTask; + } + + // ── 코드포인트 분석 ──────────────────────────────────────────────────────── + + private static IEnumerable BuildCodePointItems(int codePoint) + { + if (codePoint < 0 || codePoint > 0x10FFFF) + { + yield return new LauncherItem("범위 초과", "유효한 유니코드 범위: U+0000 ~ U+10FFFF", null, null, Symbol: "\uE783"); + yield break; + } + + var charStr = char.ConvertFromUtf32(codePoint); + var category = GetCategoryName(charStr); + var block = GetBlock(codePoint); + var name = GetCharName(codePoint); + var display = codePoint < 32 || (codePoint >= 127 && codePoint < 160) ? $"(제어문자 U+{codePoint:X4})" : charStr; + + yield return new LauncherItem( + display, + $"U+{codePoint:X4} · {name}", + null, + ("copy", charStr), + Symbol: "\uE8D2"); + + yield return new LauncherItem("코드포인트", $"U+{codePoint:X4}", null, ("copy", $"U+{codePoint:X4}"), Symbol: "\uE8D2"); + yield return new LauncherItem("10진수", $"{codePoint}", null, ("copy", codePoint.ToString()), Symbol: "\uE8D2"); + yield return new LauncherItem("HTML 엔티티", $"&#{codePoint};", null, ("copy", $"&#{codePoint};"), Symbol: "\uE8D2"); + yield return new LauncherItem("HTML Hex", $"&#x{codePoint:X};", null, ("copy", $"&#x{codePoint:X};"), Symbol: "\uE8D2"); + + // UTF-8 바이트 + var utf8 = System.Text.Encoding.UTF8.GetBytes(charStr); + var utf8Hex = string.Join(" ", utf8.Select(b => $"{b:X2}")); + yield return new LauncherItem("UTF-8", utf8Hex, null, ("copy", utf8Hex), Symbol: "\uE8D2"); + + // UTF-16 + var utf16 = System.Text.Encoding.Unicode.GetBytes(charStr); + var utf16Hex = string.Join(" ", utf16.Select(b => $"{b:X2}")); + yield return new LauncherItem("UTF-16 LE", utf16Hex, null, ("copy", utf16Hex), Symbol: "\uE8D2"); + + yield return new LauncherItem("카테고리", category, null, null, Symbol: "\uE8D2"); + yield return new LauncherItem("블록", block, null, null, Symbol: "\uE8D2"); + + // 한글 음절이면 분해 + if (codePoint >= 0xAC00 && codePoint <= 0xD7A3) + { + var (initial, vowel, final) = DecomposeHangul(codePoint); + yield return new LauncherItem("초성", initial, null, ("copy", initial), Symbol: "\uE8D2"); + yield return new LauncherItem("중성", vowel, null, ("copy", vowel), Symbol: "\uE8D2"); + if (!string.IsNullOrEmpty(final)) + yield return new LauncherItem("종성", final, null, ("copy", final), Symbol: "\uE8D2"); + } + } + + // ── 헬퍼 ───────────────────────────────────────────────────────────────── + + private static List GetCodePoints(string s) + { + var result = new List(); + for (var i = 0; i < s.Length; ) + { + var cp = char.ConvertToUtf32(s, i); + result.Add(cp); + i += char.IsSurrogatePair(s, i) ? 2 : 1; + } + return result; + } + + private static string GetCategoryName(string charStr) + { + if (string.IsNullOrEmpty(charStr)) return "Unknown"; + var cat = char.GetUnicodeCategory(charStr, 0); + return cat switch + { + UnicodeCategory.UppercaseLetter => "대문자 (Lu)", + UnicodeCategory.LowercaseLetter => "소문자 (Ll)", + UnicodeCategory.TitlecaseLetter => "타이틀케이스 (Lt)", + UnicodeCategory.ModifierLetter => "수정 문자 (Lm)", + UnicodeCategory.OtherLetter => "기타 문자 (Lo)", + UnicodeCategory.DecimalDigitNumber => "10진수 숫자 (Nd)", + UnicodeCategory.LetterNumber => "문자 숫자 (Nl)", + UnicodeCategory.OtherNumber => "기타 숫자 (No)", + UnicodeCategory.SpaceSeparator => "공백 (Zs)", + UnicodeCategory.LineSeparator => "줄 구분자 (Zl)", + UnicodeCategory.ParagraphSeparator => "단락 구분자 (Zp)", + UnicodeCategory.Control => "제어 문자 (Cc)", + UnicodeCategory.MathSymbol => "수학 기호 (Sm)", + UnicodeCategory.CurrencySymbol => "통화 기호 (Sc)", + UnicodeCategory.ModifierSymbol => "수정 기호 (Sk)", + UnicodeCategory.OtherSymbol => "기타 기호 (So)", + UnicodeCategory.OpenPunctuation => "여는 구두점 (Ps)", + UnicodeCategory.ClosePunctuation => "닫는 구두점 (Pe)", + UnicodeCategory.DashPunctuation => "대시 구두점 (Pd)", + UnicodeCategory.ConnectorPunctuation => "연결 구두점 (Pc)", + UnicodeCategory.OtherPunctuation => "기타 구두점 (Po)", + _ => cat.ToString(), + }; + } + + private static string GetBlock(int cp) + { + foreach (var (start, end, name) in UnicodeBlocks) + if (cp >= start && cp <= end) return $"{name} (U+{start:X4}~U+{end:X4})"; + if (cp >= 0x10000) return $"Supplementary Planes (U+{cp:X4})"; + return $"U+{cp:X4} 범위 불명"; + } + + private static string GetCharName(int cp) => cp switch + { + 0x0020 => "Space", + 0x0021 => "Exclamation Mark", + 0x0022 => "Quotation Mark", + 0x0023 => "Number Sign", + 0x0024 => "Dollar Sign", + 0x0025 => "Percent Sign", + 0x0026 => "Ampersand", + 0x0027 => "Apostrophe", + 0x0028 => "Left Parenthesis", + 0x0029 => "Right Parenthesis", + 0x002A => "Asterisk", + 0x002B => "Plus Sign", + 0x002C => "Comma", + 0x002D => "Hyphen-Minus", + 0x002E => "Full Stop", + 0x002F => "Solidus", + >= 0x0030 and <= 0x0039 => $"Digit {(char)cp}", + >= 0x0041 and <= 0x005A => $"Latin Capital Letter {(char)cp}", + >= 0x0061 and <= 0x007A => $"Latin Small Letter {(char)cp}", + 0x00A9 => "Copyright Sign", + 0x00AE => "Registered Sign", + 0x2122 => "Trade Mark Sign", + 0x2022 => "Bullet", + 0x2192 => "Rightwards Arrow", + 0x2190 => "Leftwards Arrow", + 0x2191 => "Upwards Arrow", + 0x2193 => "Downwards Arrow", + 0x2713 => "Check Mark", + 0x2717 => "Ballot X", + 0x2605 => "Black Star", + 0x2606 => "White Star", + 0x2665 => "Black Heart Suit", + 0x2764 => "Heavy Black Heart", + 0x1F600 => "Grinning Face", + 0x1F601 => "Grinning Face With Smiling Eyes", + 0x1F602 => "Face With Tears of Joy", + 0x1F603 => "Smiling Face With Open Mouth", + 0x1F609 => "Winking Face", + 0x1F60D => "Smiling Face With Heart-Eyes", + 0x1F621 => "Pouting Face", + 0x1F625 => "Disappointed but Relieved Face", + >= 0xAC00 and <= 0xD7A3 => "Hangul Syllable", + >= 0x1100 and <= 0x11FF => "Hangul Jamo", + >= 0x3131 and <= 0x318E => "Hangul Compatibility Jamo", + >= 0x4E00 and <= 0x9FFF => "CJK Unified Ideograph", + >= 0x3040 and <= 0x309F => "Hiragana", + >= 0x30A0 and <= 0x30FF => "Katakana", + _ => $"U+{cp:X4}", + }; + + private static (string Initial, string Vowel, string Final) DecomposeHangul(int cp) + { + string[] initials = ["ㄱ","ㄲ","ㄴ","ㄷ","ㄸ","ㄹ","ㅁ","ㅂ","ㅃ","ㅅ","ㅆ","ㅇ","ㅈ","ㅉ","ㅊ","ㅋ","ㅌ","ㅍ","ㅎ"]; + string[] vowels = ["ㅏ","ㅐ","ㅑ","ㅒ","ㅓ","ㅔ","ㅕ","ㅖ","ㅗ","ㅘ","ㅙ","ㅚ","ㅛ","ㅜ","ㅝ","ㅞ","ㅟ","ㅠ","ㅡ","ㅢ","ㅣ"]; + string[] finals = ["", "ㄱ","ㄲ","ㄳ","ㄴ","ㄵ","ㄶ","ㄷ","ㄹ","ㄺ","ㄻ","ㄼ","ㄽ","ㄾ","ㄿ","ㅀ","ㅁ","ㅂ","ㅄ","ㅅ","ㅆ","ㅇ","ㅈ","ㅊ","ㅋ","ㅌ","ㅍ","ㅎ"]; + + var offset = cp - 0xAC00; + var finIdx = offset % 28; + var vowIdx = (offset / 28) % 21; + var iniIdx = offset / 28 / 21; + + return (initials[iniIdx], vowels[vowIdx], finals[finIdx]); + } +}