[Phase L11] 개발자 데이터 파싱 도구 핸들러 4종 추가

CsvHandler.cs (신규, ~260줄, prefix=csv):
- 클립보드 CSV/TSV 자동 감지 파싱 (쉼표·탭 구분자)
- 행수·열수·헤더 개요 + 첫 번째 행 미리보기
- col N: 지정 컬럼 추출, row N: 지정 행 조회
- stats: 숫자 컬럼 합계·평균·최대·최소 자동 집계
- tsv: CSV → TSV 형식 변환, 따옴표 처리 파서 구현

JwtHandler.cs (신규, ~220줄, prefix=jwt):
- eyJ로 시작하는 JWT 클립보드/인라인 자동 감지
- Base64Url 디코딩으로 헤더·페이로드 JSON 추출
- exp/iat/nbf Unix 타임스탬프 → 날짜 변환 + D-day
- 만료 상태(유효/만료임박/만료됨) 실시간 계산
- header/payload/claims 부분 조회 지원 (서명 검증 미지원)

CronHandler.cs (신규, ~240줄, prefix=cron):
- 5필드 표준 cron: 분·시·일·월·요일 파싱 검증
- *, */N, N-M, N,M,K 패턴 완전 지원
- 한국어 설명 자동 생성 (예: "평일 오전 9시 실행")
- DateTime 반복 매칭으로 다음 5회 실행 시간 계산
- @daily/@weekly/@monthly/@hourly 특수 키워드 확장

UnicodeHandler.cs (신규, ~270줄, prefix=unicode):
- 문자/U+XXXX/0xXXXX/10진수 4가지 입력 방식
- UTF-8·UTF-16 LE 바이트, HTML 엔티티(십진/16진)
- UnicodeCategory 분류 한국어 레이블 매핑
- 25개 유니코드 블록 범위 테이블
- 한글 음절(AC00~D7A3) 초·중·종성 자동 분해
- 인라인 보간 삼항연산자 괄호 필수 (CS8361 수정)

App.xaml.cs: 4개 핸들러 Phase L11 블록 등록
docs/LAUNCHER_ROADMAP.md: Phase L11 완료 섹션 추가
빌드: 경고 0, 오류 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 14:40:06 +09:00
parent 678feaadc9
commit 2df841be0c
6 changed files with 1343 additions and 0 deletions

View File

@@ -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 <uuid>`로 버전·변형·타임스탬프 분석 | 높음 |
| 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 등), 블록명 표시. 한글 음절 초·중·종성 분해. 여러 문자 입력 시 코드포인트 범위 요약 | 중간 |

View File

@@ -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();

View File

@@ -0,0 +1,340 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 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)
/// </summary>
public class CronHandler : IActionHandler
{
public string? Prefix => "cron";
public PluginMetadata Metadata => new(
"Cron",
"Cron 표현식 설명기 — 다음 실행 시간 · 한국어 설명",
"1.0",
"AX");
// 특수 키워드
private static readonly Dictionary<string, string> 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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("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<DateTime> GetNextRuns(CronExpr cron, DateTime from, int count)
{
var results = new List<DateTime>();
// 다음 분부터 시작
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<string>();
// 분
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}일 후";
}
}

View File

@@ -0,0 +1,338 @@
using System.Text;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L11-1: CSV 뷰어·파서 핸들러. "csv" 프리픽스로 사용합니다.
///
/// 예: csv → 클립보드 CSV 파싱 (헤더·행수·컬럼수)
/// csv col 2 → 2번째 컬럼 값 목록 추출
/// csv row 3 → 3번째 행 출력
/// csv stats → 숫자 컬럼 합계·평균·최대·최소
/// csv head → 헤더 컬럼명 목록
/// csv tsv → CSV → TSV(탭 구분) 변환
/// Enter → 결과를 클립보드에 복사.
/// </summary>
public class CsvHandler : IActionHandler
{
public string? Prefix => "csv";
public PluginMetadata Metadata => new(
"CSV",
"CSV 뷰어·파서 — 컬럼 추출 · 통계 · 형식 변환",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
}
var rows = ParseCsv(clip);
if (rows.Count == 0)
{
items.Add(new LauncherItem("파싱 실패", "유효한 CSV가 아닙니다", null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("CSV", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
return Task.CompletedTask;
}
// ── 빌더 ─────────────────────────────────────────────────────────────────
private static IEnumerable<LauncherItem> 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<LauncherItem> ExtractColumn(List<List<string>> 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<LauncherItem> ExtractRow(List<List<string>> 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<LauncherItem> BuildStatsItems(List<List<string>> 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<List<string>> 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<List<string>> ParseCsv(string text)
{
var result = new List<List<string>>();
// 구분자 자동 감지 (탭 또는 쉼표)
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<string> ParseCsvLine(string line, char delim)
{
var fields = new List<string>();
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 ""; }
}
}

View File

@@ -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;
/// <summary>
/// L11-2: JWT 토큰 디코더 핸들러. "jwt" 프리픽스로 사용합니다.
///
/// 예: jwt → 클립보드의 JWT 자동 분석
/// jwt eyJhbGci... → 토큰 직접 입력
/// jwt header → 클립보드 JWT 헤더만 표시
/// jwt payload → 클립보드 JWT 페이로드만 표시
/// Enter → 결과를 클립보드에 복사.
/// 주의: 서명(signature) 검증은 수행하지 않음 — 분석 전용.
/// </summary>
public class JwtHandler : IActionHandler
{
public string? Prefix => "jwt";
public PluginMetadata Metadata => new(
"JWT",
"JWT 토큰 디코더 — 헤더 · 페이로드 · 만료일 분석",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("JWT", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
return Task.CompletedTask;
}
// ── JWT 디코딩 ────────────────────────────────────────────────────────────
private static IEnumerable<LauncherItem> 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<LauncherItem> 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 ""; }
}
}

View File

@@ -0,0 +1,344 @@
using System.Globalization;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L11-4: 유니코드 문자 조회 핸들러. "unicode" 프리픽스로 사용합니다.
///
/// 예: unicode A → 문자 'A'의 코드포인트·카테고리·이름 조회
/// unicode U+1F600 → 코드포인트로 문자 조회
/// unicode 0x1F600 → 16진수 코드포인트
/// unicode 128512 → 10진수 코드포인트
/// unicode 가 → 한글 문자 분석
/// unicode smile → 문자 설명으로 검색 (이모지 이름 포함)
/// Enter → 문자를 클립보드에 복사.
/// </summary>
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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
}
// 순수 10진수 코드포인트
if (int.TryParse(q, out var decCp) && decCp >= 0)
{
items.AddRange(BuildCodePointItems(decCp));
return Task.FromResult<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Unicode", "클립보드에 복사했습니다.");
}
catch { /* 비핵심 */ }
}
return Task.CompletedTask;
}
// ── 코드포인트 분석 ────────────────────────────────────────────────────────
private static IEnumerable<LauncherItem> 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<int> GetCodePoints(string s)
{
var result = new List<int>();
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]);
}
}