[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:
@@ -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 등), 블록명 표시. 한글 음절 초·중·종성 분해. 여러 문자 입력 시 코드포인트 범위 요약 | 중간 |
|
||||
|
||||
@@ -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();
|
||||
|
||||
340
src/AxCopilot/Handlers/CronHandler.cs
Normal file
340
src/AxCopilot/Handlers/CronHandler.cs
Normal 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}일 후";
|
||||
}
|
||||
}
|
||||
338
src/AxCopilot/Handlers/CsvHandler.cs
Normal file
338
src/AxCopilot/Handlers/CsvHandler.cs
Normal 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 ""; }
|
||||
}
|
||||
}
|
||||
298
src/AxCopilot/Handlers/JwtHandler.cs
Normal file
298
src/AxCopilot/Handlers/JwtHandler.cs
Normal 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 ""; }
|
||||
}
|
||||
}
|
||||
344
src/AxCopilot/Handlers/UnicodeHandler.cs
Normal file
344
src/AxCopilot/Handlers/UnicodeHandler.cs
Normal 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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user