diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md index be798db..b4b12cd 100644 --- a/docs/LAUNCHER_ROADMAP.md +++ b/docs/LAUNCHER_ROADMAP.md @@ -347,3 +347,16 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label | L16-2 | **Docker 관리** ✅ | `docker` 프리픽스. `docker ps` 실행 중 컨테이너 목록(이름·상태·포트). `docker all` 중지 포함 전체 목록. `docker images` 로컬 이미지 목록(크기·생성일). `docker stop/start <이름>` 터미널 없이 직접 실행. `docker logs <이름>` 터미널에서 로그. `docker shell <이름>` exec -it sh 접속. Docker 미설치 감지 | 높음 | | L16-3 | **할 일 목록** ✅ | `todo` 프리픽스. `todo <내용>` 새 항목 추가. `todo done <번호>` 완료 토글. `todo del <번호>` 삭제. `todo clear` 완료 항목 정리. `todo clear all` 전체 삭제. `todo <검색어>` 키워드 필터. 번호만 입력 시 빠른 완료 토글. 미완료 먼저, 완료 항목 하단 그룹. `%APPDATA%\AxCopilot\todos.json` 로컬 저장 | 높음 | | L16-4 | **텍스트 → 표 변환기** ✅ | `table` 프리픽스. 클립보드 텍스트 자동 읽기. 탭·CSV·공백 구분자 자동 감지. `table` → 마크다운 표. `table csv` → CSV 변환. `table html` → HTML `` 태그. `table flip` 행·열 전치(transpose). `table sort N` N번 열 기준 정렬(숫자/문자 자동 감지). 셀 너비 자동 정렬(PadRight). 미리보기 3줄 표시 | 높음 | + +--- + +## Phase L17 — 단위·숫자·YAML·Gitignore 도구 (v2.0.9) ✅ 완료 + +> **방향**: 개발자·업무 실용 도구 심화 — 단위 변환, 숫자 읽기, YAML 분석, 프로젝트 초기화. + +| # | 기능 | 설명 | 우선순위 | +|---|------|------|----------| +| L17-1 | **단위 변환기** ✅ | `unit` 프리픽스. 길이(km·m·ft·in·mi)·무게(kg·lb·oz·근)·온도(°C·°F·K)·넓이(m²·ha·acre·평)·속도(km/h·mph·m/s·knot)·데이터(bit·B·KB~PB)·압력(Pa·atm·bar·psi)·부피(L·mL·gallon·cup) 8개 카테고리 50+ 단위. `unit 100 km m` → 변환. 대상 단위 생략 시 같은 카테고리 전체 일괄 변환. 한글 단위명 별칭 지원 | 높음 | +| L17-2 | **숫자 포맷·읽기** ✅ | `num` 프리픽스. `num 1234567` → 천단위·한글 단위(만·억·조)·한국어 읽기·영어 읽기·16진수·8진수·2진수·과학표기·로마 숫자 일괄 표시. `0x/0b/0o` 접두사 진수 입력. `num 42 ko` 한국어 읽기만. `num 42 en` 영어 읽기만. 1~3999 로마 숫자 변환. ToKorean(): 조·억·만 단위 재귀 분해 | 높음 | +| L17-3 | **YAML 파서·분석기** ✅ | `yaml` 프리픽스. 클립보드 자동 읽기. 외부 라이브러리 없이 순수 구현(경량 파서). `yaml validate` 유효성 검사. `yaml keys` 최상위 키 목록. `yaml get key.sub` 점 표기법 경로 조회. `yaml stats` 줄·키·깊이 통계. `yaml flat` 점 표기법 평탄화(flatten). [GeneratedRegex] 소스 생성기 | 높음 | +| L17-4 | **.gitignore 생성기** ✅ | `gitignore` 프리픽스. Node/Python/C#(.NET)/Java/Go/Rust/React(Next.js·Vite·Vue)/Flutter/Android/iOS/Unity/Windows/macOS/Linux 14개 내장 템플릿. 별칭(nodejs·npm·dotnet·net·maven·golang·cargo·nextjs·swift 등) 지원. 여러 템플릿 명 입력 시 자동 병합. 미리보기 12줄 표시. Enter → 클립보드 복사 | 높음 | diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index ef46b29..91856c7 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -297,6 +297,16 @@ public partial class App : System.Windows.Application // L16-4: 텍스트 → 표 변환기 (prefix=table) commandResolver.RegisterHandler(new TableHandler()); + // ─── Phase L17 핸들러 ───────────────────────────────────────────────── + // L17-1: 단위 변환기 (prefix=unit) + commandResolver.RegisterHandler(new UnitHandler()); + // L17-2: 숫자 포맷·읽기 변환기 (prefix=num) + commandResolver.RegisterHandler(new NumHandler()); + // L17-3: YAML 파서·포맷터 (prefix=yaml) + commandResolver.RegisterHandler(new YamlHandler()); + // L17-4: .gitignore 생성기 (prefix=gitignore) + commandResolver.RegisterHandler(new GitignoreHandler()); + // ─── 플러그인 로드 ──────────────────────────────────────────────────── var pluginHost = new PluginHost(settings, commandResolver); pluginHost.LoadAll(); diff --git a/src/AxCopilot/Handlers/GitignoreHandler.cs b/src/AxCopilot/Handlers/GitignoreHandler.cs new file mode 100644 index 0000000..049b276 --- /dev/null +++ b/src/AxCopilot/Handlers/GitignoreHandler.cs @@ -0,0 +1,536 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L17-4: .gitignore 생성기 핸들러. "gitignore" 프리픽스로 사용합니다. +/// +/// 예: gitignore → 지원 언어·프레임워크 목록 +/// gitignore node → Node.js .gitignore 생성 +/// gitignore python → Python .gitignore 생성 +/// gitignore csharp → C# / .NET .gitignore 생성 +/// gitignore java → Java .gitignore 생성 +/// gitignore react → React (Node 기반) .gitignore 생성 +/// gitignore node python → 여러 템플릿 병합 +/// Enter → .gitignore 내용을 클립보드에 복사. +/// +public class GitignoreHandler : IActionHandler +{ + public string? Prefix => "gitignore"; + + public PluginMetadata Metadata => new( + "Gitignore", + ".gitignore 생성기 — Node·Python·C#·Java·Go·Rust 등 내장 템플릿", + "1.0", + "AX"); + + // ── 내장 템플릿 ────────────────────────────────────────────────────────── + + private static readonly Dictionary Templates = + new(StringComparer.OrdinalIgnoreCase) + { + ["node"] = ( + ["nodejs", "npm", "javascript", "js"], + "Node.js / npm", + """ + # Node.js + node_modules/ + npm-debug.log* + yarn-debug.log* + yarn-error.log* + pnpm-debug.log* + .pnpm-store/ + .npm/ + .yarn/ + package-lock.json + yarn.lock + pnpm-lock.yaml + .env + .env.local + .env.*.local + dist/ + build/ + .cache/ + .parcel-cache/ + .vite/ + coverage/ + .nyc_output/ + *.log + .DS_Store + Thumbs.db + """), + + ["python"] = ( + ["py", "django", "flask", "fastapi"], + "Python", + """ + # Python + __pycache__/ + *.py[cod] + *$py.class + *.so + .Python + build/ + dist/ + eggs/ + .eggs/ + lib/ + lib64/ + parts/ + sdist/ + var/ + wheels/ + *.egg-info/ + .installed.cfg + *.egg + MANIFEST + .env + .venv + env/ + venv/ + ENV/ + .pytest_cache/ + .mypy_cache/ + .ruff_cache/ + .coverage + htmlcov/ + *.log + .DS_Store + """), + + ["csharp"] = ( + ["cs", "dotnet", ".net", "net", "aspnet", "aspnetcore"], + "C# / .NET", + """ + # C# / .NET + bin/ + obj/ + *.user + *.suo + .vs/ + .vscode/ + *.userprefs + *.pidb + *.booproj + *.svd + *.userprefs + packages/ + *.nupkg + **/[Bb]in/ + **/[Oo]bj/ + **/[Ll]og/ + **/[Ll]ogs/ + TestResults/ + [Tt]est[Rr]esult*/ + BenchmarkDotNet.Artifacts/ + project.lock.json + project.fragment.lock.json + artifacts/ + *_i.c + *_p.c + *_h.h + *.ilk + *.meta + *.obj + *.iobj + *.pch + *.pdb + *.ipdb + *.pgc + *.pgd + *.rsp + *.sbr + *.tlb + *.tli + *.tlh + *.tmp + *.tmp_proj + *_wpftmp.csproj + *.log + *.vspscc + .DS_Store + """), + + ["java"] = ( + ["gradle", "maven", "mvn", "spring"], + "Java / Maven / Gradle", + """ + # Java / Maven / Gradle + *.class + *.log + *.ctxt + .mtj.tmp/ + *.jar + *.war + *.nar + *.ear + *.zip + *.tar.gz + *.rar + hs_err_pid* + replay_pid* + target/ + build/ + out/ + .gradle/ + .mvn/ + !.mvn/wrapper/maven-wrapper.jar + !.mvn/wrapper/maven-wrapper.properties + .idea/ + *.iws + *.iml + *.ipr + .classpath + .project + .settings/ + .DS_Store + """), + + ["go"] = ( + ["golang"], + "Go (Golang)", + """ + # Go + *.exe + *.exe~ + *.dll + *.so + *.dylib + *.test + *.out + go.work + go.work.sum + vendor/ + .env + dist/ + bin/ + .DS_Store + """), + + ["rust"] = ( + ["cargo"], + "Rust / Cargo", + """ + # Rust / Cargo + /target/ + Cargo.lock + **/*.rs.bk + *.pdb + .env + .DS_Store + """), + + ["react"] = ( + ["nextjs", "next", "vue", "vite", "svelte"], + "React / Next.js / Vue / Vite", + """ + # React / Next.js / Vite + node_modules/ + .next/ + out/ + build/ + dist/ + .env + .env.local + .env.development.local + .env.test.local + .env.production.local + npm-debug.log* + yarn-debug.log* + yarn-error.log* + .DS_Store + Thumbs.db + .cache/ + .parcel-cache/ + coverage/ + *.log + """), + + ["flutter"] = ( + ["dart"], + "Flutter / Dart", + """ + # Flutter / Dart + .dart_tool/ + .flutter-plugins + .flutter-plugins-dependencies + .packages + .pub-cache/ + .pub/ + /build/ + flutter_*.png + linked_*.ds + unlinked.ds + unlinked_spec.ds + *.log + .DS_Store + """), + + ["android"] = ( + ["kotlin", "gradle-android"], + "Android", + """ + # Android + *.iml + .gradle/ + /local.properties + /.idea/ + .DS_Store + /build/ + /captures/ + .externalNativeBuild/ + .cxx/ + *.jks + *.keystore + google-services.json + """), + + ["ios"] = ( + ["swift", "xcode", "objc", "objective-c"], + "iOS / Swift / Xcode", + """ + # iOS / Swift / Xcode + build/ + DerivedData/ + .build/ + *.pbxuser + *.mode1v3 + *.mode2v3 + *.perspectivev3 + xcuserdata/ + *.xcworkspace + !default.xcworkspace + .swiftpm/ + Packages/ + *.resolved + *.xccheckout + *.moved-aside + *.xcuserstate + .DS_Store + """), + + ["unity"] = ( + [], + "Unity", + """ + # Unity + /[Ll]ibrary/ + /[Tt]emp/ + /[Oo]bj/ + /[Bb]uild/ + /[Bb]uilds/ + /[Ll]ogs/ + /[Uu]ser[Ss]ettings/ + /[Mm]emoryCaptures/ + /[Rr]ecordings/ + /[Pp]rofiles/ + /[Pp]rofile[Ss] + /[Aa]ssets/Plugins/EditorVR.meta + /[Pp]ackages/ + !/[Pp]ackages/manifest.json + !/[Pp]ackages/packages-lock.json + /*.sln + /*.csproj + /.vs/ + .DS_Store + """), + + ["windows"] = ( + ["win", "powershell", "ps"], + "Windows 공통", + """ + # Windows + Thumbs.db + Thumbs.db:encryptable + ehthumbs.db + ehthumbs_vista.db + *.stackdump + [Dd]esktop.ini + $RECYCLE.BIN/ + *.cab + *.msi + *.msix + *.msm + *.msp + *.lnk + """), + + ["macos"] = ( + ["mac", "osx"], + "macOS 공통", + """ + # macOS + .DS_Store + .AppleDouble + .LSOverride + Icon + ._* + .DocumentRevisions-V100 + .fseventsd + .Spotlight-V100 + .TemporaryItems + .Trashes + .VolumeIcon.icns + .com.apple.timemachine.donotpresent + .AppleDB + .AppleDesktop + Network Trash Folder + Temporary Items + .apdisk + """), + + ["linux"] = ( + [], + "Linux 공통", + """ + # Linux + *~ + .fuse_hidden* + .directory + .Trash-* + .nfs* + *.swp + *.swo + """), + }; + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem(".gitignore 생성기", + "예: gitignore node / gitignore python / gitignore csharp / gitignore node python", + null, null, Symbol: "\uEA3C")); + items.Add(new LauncherItem($"── 지원 템플릿 {Templates.Count}개 ──", "", null, null, Symbol: "\uEA3C")); + foreach (var (key, (aliases, desc, _)) in Templates.OrderBy(t => t.Key)) + { + var aliasStr = aliases.Length > 0 ? $" ({string.Join(", ", aliases.Take(3))})" : ""; + items.Add(new LauncherItem(key, $"{desc}{aliasStr}", null, ("gen", key), Symbol: "\uEA3C")); + } + return Task.FromResult>(items); + } + + // 여러 키워드 → 병합 + var keywords = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var matched = new List(); // 템플릿 키 목록 + + foreach (var kw in keywords) + { + var found = FindTemplate(kw); + if (found != null && !matched.Contains(found)) + matched.Add(found); + } + + if (matched.Count == 0) + { + items.Add(new LauncherItem($"'{q}' 템플릿을 찾을 수 없습니다", + $"지원: {string.Join(", ", Templates.Keys.Take(10))}…", + null, null, Symbol: "\uE946")); + return Task.FromResult>(items); + } + + // 템플릿 생성 + if (matched.Count == 1) + { + var key = matched[0]; + var (_, desc, content) = Templates[key]; + var trimmed = content.Trim(); + + items.Add(new LauncherItem($".gitignore [{key}]", + $"{desc} · {trimmed.Split('\n').Length}줄 · Enter → 복사", + null, ("copy", trimmed), Symbol: "\uEA3C")); + + // 미리보기 + foreach (var line in trimmed.Split('\n').Take(12)) + items.Add(new LauncherItem(line, "", null, null, Symbol: "\uEA3C")); + + if (trimmed.Split('\n').Length > 12) + items.Add(new LauncherItem($"… 외 {trimmed.Split('\n').Length - 12}줄", + "전체는 첫 항목 Enter로 복사", null, null, Symbol: "\uEA3C")); + } + else + { + // 다중 병합 + var sb = new System.Text.StringBuilder(); + var totalLines = 0; + foreach (var key in matched) + { + var (_, desc, content) = Templates[key]; + sb.AppendLine($"# ===== {desc} ====="); + sb.AppendLine(content.Trim()); + sb.AppendLine(); + totalLines += content.Split('\n').Length; + } + var merged = sb.ToString().TrimEnd(); + + items.Add(new LauncherItem( + $".gitignore 병합 [{string.Join(" + ", matched)}]", + $"{totalLines}줄 · Enter → 복사", + null, ("copy", merged), Symbol: "\uEA3C")); + + foreach (var key in matched) + { + var (_, desc, _) = Templates[key]; + items.Add(new LauncherItem($"[{key}]", desc, null, ("gen", key), Symbol: "\uEA3C")); + } + } + + return Task.FromResult>(items); + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + switch (item.Data) + { + case ("copy", string text): + try + { + System.Windows.Application.Current.Dispatcher.Invoke( + () => Clipboard.SetText(text)); + NotificationService.Notify("Gitignore", ".gitignore 내용을 복사했습니다."); + } + catch { /* 비핵심 */ } + break; + + case ("gen", string key): + if (Templates.TryGetValue(key, out var t)) + { + try + { + System.Windows.Application.Current.Dispatcher.Invoke( + () => Clipboard.SetText(t.Content.Trim())); + NotificationService.Notify("Gitignore", $"[{key}] .gitignore 복사됨"); + } + catch { /* 비핵심 */ } + } + break; + } + return Task.CompletedTask; + } + + // ── 헬퍼 ───────────────────────────────────────────────────────────────── + + private static string? FindTemplate(string keyword) + { + // 직접 키 일치 + if (Templates.ContainsKey(keyword)) return keyword; + + // 별칭 검색 + foreach (var (key, (aliases, _, _)) in Templates) + { + if (aliases.Any(a => a.Equals(keyword, StringComparison.OrdinalIgnoreCase))) + return key; + } + + // 부분 일치 + var partial = Templates.Keys + .FirstOrDefault(k => k.Contains(keyword, StringComparison.OrdinalIgnoreCase)); + return partial; + } +} diff --git a/src/AxCopilot/Handlers/NumHandler.cs b/src/AxCopilot/Handlers/NumHandler.cs new file mode 100644 index 0000000..8dd4356 --- /dev/null +++ b/src/AxCopilot/Handlers/NumHandler.cs @@ -0,0 +1,288 @@ +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L17-2: 숫자 포맷·읽기 변환 핸들러. "num" 프리픽스로 사용합니다. +/// +/// 예: num 1234567 → 천단위·한글·영어·진수·과학표기 모두 표시 +/// num 0xff → 16진수 → 10진수 변환 +/// num 0b1010 → 2진수 → 10진수 변환 +/// num 42 ko → 한국어로 읽기 (사십이) +/// num 42 en → 영어로 읽기 (forty-two) +/// num 1e6 → 과학표기 → 일반 변환 +/// Enter → 결과 복사. +/// +public class NumHandler : IActionHandler +{ + public string? Prefix => "num"; + + public PluginMetadata Metadata => new( + "Num", + "숫자 포맷·읽기 변환 — 한글·영어·진수·천단위·과학표기", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem("숫자 포맷·읽기 변환기", + "예: num 1234567 / num 0xff / num 42 ko / num 42 en", + null, null, Symbol: "\uE8EF")); + items.Add(new LauncherItem("num <숫자>", "천단위·한글·영어·진수 변환", null, null, Symbol: "\uE8EF")); + items.Add(new LauncherItem("num 0xff", "16진수 입력", null, null, Symbol: "\uE8EF")); + items.Add(new LauncherItem("num 0b1010", "2진수 입력", null, null, Symbol: "\uE8EF")); + items.Add(new LauncherItem("num 0o17", "8진수 입력", null, null, Symbol: "\uE8EF")); + items.Add(new LauncherItem("num 42 ko", "한국어로 읽기", null, null, Symbol: "\uE8EF")); + items.Add(new LauncherItem("num 42 en", "영어로 읽기", null, null, Symbol: "\uE8EF")); + return Task.FromResult>(items); + } + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var numStr = parts[0]; + var mode = parts.Length > 1 ? parts[1].ToLowerInvariant() : null; + + // 숫자 파싱 (여러 진수 지원) + if (!TryParseNumber(numStr, out var value, out var inputBase)) + { + items.Add(new LauncherItem("숫자 형식 오류", + "정수 또는 0x/0b/0o 접두사 숫자를 입력하세요", null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + // 한국어 읽기 전용 + if (mode is "ko" or "kr" or "한국어" or "한글") + { + var ko = ToKorean(value); + items.Add(new LauncherItem(ko, $"{value:N0} 한국어 읽기", null, ("copy", ko), Symbol: "\uE8EF")); + var ko2 = ToKoreanMoney(value); + items.Add(new LauncherItem(ko2, "금액 읽기 (원)", null, ("copy", ko2), Symbol: "\uE8EF")); + return Task.FromResult>(items); + } + + // 영어 읽기 전용 + if (mode is "en" or "english" or "영어") + { + var en = ToEnglish(value); + items.Add(new LauncherItem(en, $"{value:N0} 영어 읽기", null, ("copy", en), Symbol: "\uE8EF")); + return Task.FromResult>(items); + } + + // 전체 변환 표시 + items.Add(new LauncherItem($"{value:N0}", + $"입력값 ({inputBase}진수 → 10진수)", + null, ("copy", $"{value}"), Symbol: "\uE8EF")); + + // 천단위 구분 + var commaSep = $"{value:N0}"; + items.Add(new LauncherItem(commaSep, "천단위 구분", null, ("copy", commaSep), Symbol: "\uE8EF")); + + // 한글 단위 (만·억·조) + var krUnit = ToKoreanUnit(value); + items.Add(new LauncherItem(krUnit, "한글 단위", null, ("copy", krUnit), Symbol: "\uE8EF")); + + // 한국어 읽기 + var koRead = ToKorean(value); + items.Add(new LauncherItem(koRead, "한국어 읽기", null, ("copy", koRead), Symbol: "\uE8EF")); + + // 영어 읽기 + if (Math.Abs(value) < 1_000_000_000_000L) + { + var enRead = ToEnglish(value); + items.Add(new LauncherItem(enRead, "영어 읽기", null, ("copy", enRead), Symbol: "\uE8EF")); + } + + // 진수 변환 (정수 범위만) + if (value >= 0 && value <= long.MaxValue) + { + var lv = (long)value; + var hex = $"0x{lv:X}"; + var bin = Convert.ToString(lv, 2); + var oct = Convert.ToString(lv, 8); + items.Add(new LauncherItem($"── 진수 변환 ──", "", null, null, Symbol: "\uE8EF")); + items.Add(new LauncherItem($"16진수 {hex}", $"Hex", null, ("copy", hex), Symbol: "\uE8EF")); + items.Add(new LauncherItem($"8진수 0o{oct}", $"Octal", null, ("copy", $"0o{oct}"), Symbol: "\uE8EF")); + items.Add(new LauncherItem($"2진수 {bin}", $"Binary ({bin.Length}bit)", null, ("copy", bin), Symbol: "\uE8EF")); + } + + // 과학 표기 + var sci = $"{value:E4}"; + items.Add(new LauncherItem($"과학 표기 {sci}", "Scientific Notation", null, ("copy", sci), Symbol: "\uE8EF")); + + // 로마 숫자 (1~3999) + if (value >= 1 && value <= 3999) + { + var roman = ToRoman((int)value); + items.Add(new LauncherItem($"로마 숫자 {roman}", "Roman Numerals", null, ("copy", roman), Symbol: "\uE8EF")); + } + + 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("Num", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + return Task.CompletedTask; + } + + // ── 파싱 ───────────────────────────────────────────────────────────────── + + private static bool TryParseNumber(string s, out double value, out int inputBase) + { + value = 0; + inputBase = 10; + s = s.Replace(",", "").Trim(); + + if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase) || + s.StartsWith("&H", StringComparison.OrdinalIgnoreCase)) + { + inputBase = 16; + var hex = s[2..]; + if (long.TryParse(hex, System.Globalization.NumberStyles.HexNumber, + System.Globalization.CultureInfo.InvariantCulture, out var lv)) + { value = lv; return true; } + return false; + } + if (s.StartsWith("0b", StringComparison.OrdinalIgnoreCase)) + { + inputBase = 2; + try { value = System.Convert.ToInt64(s[2..], 2); return true; } + catch { return false; } + } + if (s.StartsWith("0o", StringComparison.OrdinalIgnoreCase)) + { + inputBase = 8; + try { value = System.Convert.ToInt64(s[2..], 8); return true; } + catch { return false; } + } + + // 과학 표기법 포함 일반 double + if (double.TryParse(s, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out value)) + return true; + + return false; + } + + // ── 한글 단위 ───────────────────────────────────────────────────────────── + + private static string ToKoreanUnit(double value) + { + var abs = Math.Abs(value); + var sign = value < 0 ? "-" : ""; + + if (abs >= 1_0000_0000_0000) return $"{sign}{abs / 1_0000_0000_0000:N2}조"; + if (abs >= 1_0000_0000) return $"{sign}{abs / 1_0000_0000:N2}억"; + if (abs >= 1_0000) return $"{sign}{abs / 1_0000:N2}만"; + return $"{sign}{abs:N0}"; + } + + private static readonly string[] KoOnes = ["", "일", "이", "삼", "사", "오", "육", "칠", "팔", "구"]; + private static readonly string[] KoTens = ["", "십", "이십", "삼십", "사십", "오십", "육십", "칠십", "팔십", "구십"]; + private static readonly string[] KoHundreds= ["", "백", "이백", "삼백", "사백", "오백", "육백", "칠백", "팔백", "구백"]; + + private static string ToKorean(double value) + { + if (value == 0) return "영"; + var abs = (long)Math.Abs(value); + var sign = value < 0 ? "마이너스 " : ""; + return sign + KoNumber(abs); + } + + private static string KoNumber(long n) + { + if (n == 0) return ""; + var sb = new StringBuilder(); + var jo = n / 1_0000_0000_0000L; + var eok = n % 1_0000_0000_0000L / 1_0000_0000L; + var man = n % 1_0000_0000L / 1_0000L; + var rest = n % 1_0000L; + + if (jo > 0) { sb.Append(KoUnder1만(jo)); sb.Append("조 "); } + if (eok > 0) { sb.Append(KoUnder1만(eok)); sb.Append("억 "); } + if (man > 0) { sb.Append(KoUnder1만(man)); sb.Append("만 "); } + if (rest> 0) { sb.Append(KoUnder1만(rest)); } + return sb.ToString().Trim(); + } + + private static string KoUnder1만(long n) + { + var sb = new StringBuilder(); + var thou = (int)(n / 1000); + var hund = (int)(n % 1000 / 100); + var ten = (int)(n % 100 / 10); + var one = (int)(n % 10); + if (thou > 0) { sb.Append(thou == 1 ? "천" : KoOnes[thou] + "천"); } + if (hund > 0) { sb.Append(hund == 1 ? "백" : KoOnes[hund] + "백"); } + if (ten > 0) { sb.Append(ten == 1 ? "십" : KoOnes[ten] + "십"); } + if (one > 0) { sb.Append(KoOnes[one]); } + return sb.ToString(); + } + + private static string ToKoreanMoney(double value) + { + var ko = ToKorean(value); + return string.IsNullOrEmpty(ko) ? "영 원" : $"{ko} 원"; + } + + // ── 영어 읽기 ───────────────────────────────────────────────────────────── + + private static readonly string[] EnOnes = + ["", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", + "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", + "sixteen", "seventeen", "eighteen", "nineteen"]; + private static readonly string[] EnTens = + ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"]; + + private static string ToEnglish(double value) + { + if (value == 0) return "zero"; + var n = (long)Math.Abs(value); + var sign = value < 0 ? "negative " : ""; + return sign + EnNumber(n); + } + + private static string EnNumber(long n) + { + if (n == 0) return ""; + if (n < 20) return EnOnes[n]; + if (n < 100) return EnTens[n / 10] + (n % 10 > 0 ? "-" + EnOnes[n % 10] : ""); + if (n < 1000) + { + var rest = n % 100 > 0 ? " and " + EnNumber(n % 100) : ""; + return EnOnes[n / 100] + " hundred" + rest; + } + if (n < 1_000_000) return EnNumber(n / 1000) + " thousand" + (n % 1000 > 0 ? " " + EnNumber(n % 1000) : ""); + if (n < 1_000_000_000) return EnNumber(n / 1_000_000) + " million" + (n % 1_000_000 > 0 ? " " + EnNumber(n % 1_000_000) : ""); + return EnNumber(n / 1_000_000_000) + " billion" + (n % 1_000_000_000 > 0 ? " " + EnNumber(n % 1_000_000_000) : ""); + } + + // ── 로마 숫자 ───────────────────────────────────────────────────────────── + + private static string ToRoman(int n) + { + var vals = new[] { 1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1 }; + var syms = new[] { "M","CM","D","CD","C","XC","L","XL","X","IX","V","IV","I" }; + var sb = new StringBuilder(); + for (var i = 0; i < vals.Length; i++) + while (n >= vals[i]) { sb.Append(syms[i]); n -= vals[i]; } + return sb.ToString(); + } +} diff --git a/src/AxCopilot/Handlers/UnitHandler.cs b/src/AxCopilot/Handlers/UnitHandler.cs new file mode 100644 index 0000000..316bbcf --- /dev/null +++ b/src/AxCopilot/Handlers/UnitHandler.cs @@ -0,0 +1,284 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L17-1: 단위 변환기 핸들러. "unit" 프리픽스로 사용합니다. +/// +/// 예: unit 100 km m → 100km → m +/// unit 72 f c → 화씨 72°F → 섭씨 +/// unit 5 kg lb → 5kg → 파운드 +/// unit 1 gb mb → 1GB → MB +/// unit 60 mph kmh → 속도 변환 +/// unit length → 길이 단위 목록 +/// unit weight / temp / area / speed / data → 카테고리 목록 +/// Enter → 결과 복사. +/// +public class UnitHandler : IActionHandler +{ + public string? Prefix => "unit"; + + public PluginMetadata Metadata => new( + "Unit", + "단위 변환기 — 길이·무게·온도·넓이·속도·데이터", + "1.0", + "AX"); + + // ── 단위 정의 (기준 단위 → SI 변환 계수) ───────────────────────────────── + // 온도는 별도 처리 (비선형) + + private enum UnitCategory { Length, Weight, Area, Speed, Data, Temperature, Pressure, Volume } + + private record UnitDef(string[] Names, double ToBase, UnitCategory Cat, string Display); + + private static readonly UnitDef[] Units = + [ + // 길이 (기준: m) + new(["km","킬로미터"], 1000, UnitCategory.Length, "킬로미터 (km)"), + new(["m","미터"], 1, UnitCategory.Length, "미터 (m)"), + new(["cm","센티미터"], 0.01, UnitCategory.Length, "센티미터 (cm)"), + new(["mm","밀리미터"], 0.001, UnitCategory.Length, "밀리미터 (mm)"), + new(["mi","mile","마일"], 1609.344, UnitCategory.Length, "마일 (mi)"), + new(["yd","yard","야드"], 0.9144, UnitCategory.Length, "야드 (yd)"), + new(["ft","feet","foot","피트"], 0.3048, UnitCategory.Length, "피트 (ft)"), + new(["in","inch","인치"], 0.0254, UnitCategory.Length, "인치 (in)"), + new(["nm","해리"], 1852, UnitCategory.Length, "해리 (nm)"), + + // 무게 (기준: kg) + new(["t","ton","톤"], 1000, UnitCategory.Weight, "톤 (t)"), + new(["kg","킬로그램"], 1, UnitCategory.Weight, "킬로그램 (kg)"), + new(["g","그램"], 0.001, UnitCategory.Weight, "그램 (g)"), + new(["mg","밀리그램"], 1e-6, UnitCategory.Weight, "밀리그램 (mg)"), + new(["lb","lbs","파운드"], 0.453592, UnitCategory.Weight, "파운드 (lb)"), + new(["oz","온스"], 0.0283495, UnitCategory.Weight, "온스 (oz)"), + new(["근"], 0.6, UnitCategory.Weight, "근 (600g)"), + + // 넓이 (기준: m²) + new(["km2","km²"], 1e6, UnitCategory.Area, "제곱킬로미터 (km²)"), + new(["m2","m²","sqm"], 1, UnitCategory.Area, "제곱미터 (m²)"), + new(["cm2","cm²"], 0.0001, UnitCategory.Area, "제곱센티미터 (cm²)"), + new(["ha","헥타르"], 10000, UnitCategory.Area, "헥타르 (ha)"), + new(["a","아르"], 100, UnitCategory.Area, "아르 (a)"), + new(["acre","에이커"], 4046.856, UnitCategory.Area, "에이커 (acre)"), + new(["ft2","ft²","sqft"], 0.092903, UnitCategory.Area, "제곱피트 (ft²)"), + new(["평"], 3.30579, UnitCategory.Area, "평 (3.3058m²)"), + + // 속도 (기준: m/s) + new(["mps","m/s"], 1, UnitCategory.Speed, "미터/초 (m/s)"), + new(["kph","kmh","km/h","kmph"], 0.277778, UnitCategory.Speed, "킬로미터/시 (km/h)"), + new(["mph","mi/h"], 0.44704, UnitCategory.Speed, "마일/시 (mph)"), + new(["knot","kn","노트"], 0.514444, UnitCategory.Speed, "노트 (kn)"), + new(["fps","ft/s"], 0.3048, UnitCategory.Speed, "피트/초 (ft/s)"), + + // 데이터 (기준: byte) + new(["b","bit","비트"], 0.125, UnitCategory.Data, "비트 (bit)"), + new(["byte","바이트"], 1, UnitCategory.Data, "바이트 (byte)"), + new(["kb","킬로바이트"], 1024, UnitCategory.Data, "킬로바이트 (KB)"), + new(["mb","메가바이트"], 1048576, UnitCategory.Data, "메가바이트 (MB)"), + new(["gb","기가바이트"], 1073741824, UnitCategory.Data, "기가바이트 (GB)"), + new(["tb","테라바이트"], 1099511627776,UnitCategory.Data, "테라바이트 (TB)"), + new(["pb","페타바이트"], 1.12589990684e15, UnitCategory.Data, "페타바이트 (PB)"), + + // 온도 (기준: °C, 변환은 특수 처리) + new(["c","°c","celsius","섭씨"], 1, UnitCategory.Temperature, "섭씨 (°C)"), + new(["f","°f","fahrenheit","화씨"],1, UnitCategory.Temperature, "화씨 (°F)"), + new(["k","kelvin","켈빈"], 1, UnitCategory.Temperature, "켈빈 (K)"), + + // 압력 (기준: Pa) + new(["pa","파스칼"], 1, UnitCategory.Pressure, "파스칼 (Pa)"), + new(["kpa"], 1000, UnitCategory.Pressure, "킬로파스칼 (kPa)"), + new(["mpa"], 1e6, UnitCategory.Pressure, "메가파스칼 (MPa)"), + new(["atm","기압"], 101325, UnitCategory.Pressure, "기압 (atm)"), + new(["bar","바"], 100000, UnitCategory.Pressure, "바 (bar)"), + new(["psi"], 6894.757, UnitCategory.Pressure, "PSI (psi)"), + + // 부피 (기준: L) + new(["l","liter","리터"], 1, UnitCategory.Volume, "리터 (L)"), + new(["ml","밀리리터"], 0.001, UnitCategory.Volume, "밀리리터 (mL)"), + new(["m3","m³","cbm"], 1000, UnitCategory.Volume, "세제곱미터 (m³)"), + new(["cm3","cm³","cc"], 0.001, UnitCategory.Volume, "세제곱센티미터 (cc)"), + new(["gallon","gal","갤런"], 3.78541, UnitCategory.Volume, "갤런 (US, gal)"), + new(["floz","fl.oz"], 0.0295735, UnitCategory.Volume, "액량온스 (fl.oz)"), + new(["cup","컵"], 0.236588, UnitCategory.Volume, "컵 (cup)"), + ]; + + private static readonly Dictionary CategoryKeywords = + new(StringComparer.OrdinalIgnoreCase) + { + ["length"] = UnitCategory.Length, ["길이"] = UnitCategory.Length, + ["weight"] = UnitCategory.Weight, ["무게"] = UnitCategory.Weight, + ["mass"] = UnitCategory.Weight, + ["area"] = UnitCategory.Area, ["넓이"] = UnitCategory.Area, + ["speed"] = UnitCategory.Speed, ["속도"] = UnitCategory.Speed, + ["data"] = UnitCategory.Data, ["데이터"]= UnitCategory.Data, + ["temp"] = UnitCategory.Temperature, ["온도"] = UnitCategory.Temperature, + ["temperature"] = UnitCategory.Temperature, + ["pressure"] = UnitCategory.Pressure, ["압력"] = UnitCategory.Pressure, + ["volume"] = UnitCategory.Volume, ["부피"] = UnitCategory.Volume, + }; + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem("단위 변환기", + "예: unit 100 km m / unit 72 f c / unit 5 kg lb / unit 1 gb mb", + null, null, Symbol: "\uE8EF")); + items.Add(new LauncherItem("── 카테고리 ──", "", null, null, Symbol: "\uE8EF")); + items.Add(new LauncherItem("unit length", "길이 단위 (km·m·cm·ft·in·mi)", null, null, Symbol: "\uE8EF")); + items.Add(new LauncherItem("unit weight", "무게 단위 (kg·g·lb·oz·근)", null, null, Symbol: "\uE8EF")); + items.Add(new LauncherItem("unit temp", "온도 단위 (°C·°F·K)", null, null, Symbol: "\uE8EF")); + items.Add(new LauncherItem("unit area", "넓이 단위 (m²·ha·acre·평)", null, null, Symbol: "\uE8EF")); + items.Add(new LauncherItem("unit speed", "속도 단위 (km/h·mph·m/s·knot)", null, null, Symbol: "\uE8EF")); + items.Add(new LauncherItem("unit data", "데이터 단위 (bit·B·KB·MB·GB·TB)", null, null, Symbol: "\uE8EF")); + items.Add(new LauncherItem("unit pressure", "압력 단위 (Pa·atm·bar·psi)", null, null, Symbol: "\uE8EF")); + items.Add(new LauncherItem("unit volume", "부피 단위 (L·mL·m³·gallon·cup)", null, null, Symbol: "\uE8EF")); + return Task.FromResult>(items); + } + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + // 카테고리 목록 + if (parts.Length == 1 && CategoryKeywords.TryGetValue(parts[0], out var cat)) + { + var catUnits = Units.Where(u => u.Cat == cat).ToList(); + items.Add(new LauncherItem($"{cat} 단위 {catUnits.Count}개", + "예: unit 100 km m", null, null, Symbol: "\uE8EF")); + foreach (var u in catUnits) + items.Add(new LauncherItem(u.Display, string.Join(", ", u.Names), null, null, Symbol: "\uE8EF")); + return Task.FromResult>(items); + } + + // 변환: unit <값> + if (parts.Length < 2) + { + items.Add(new LauncherItem("입력 형식", + "unit <값> <단위> [대상단위] 예: unit 100 km m", null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + if (!double.TryParse(parts[0].Replace(",", ""), System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var value)) + { + items.Add(new LauncherItem("숫자 형식 오류", + "첫 번째 값이 숫자여야 합니다 예: unit 100 km m", null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + var fromKey = parts[1].ToLowerInvariant(); + var fromDef = FindUnit(fromKey); + if (fromDef == null) + { + items.Add(new LauncherItem($"'{parts[1]}' 단위를 찾을 수 없습니다", + "unit length / weight / temp / area / speed / data 로 단위 목록 확인", + null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + // 대상 단위 지정 + if (parts.Length >= 3) + { + var toKey = parts[2].ToLowerInvariant(); + var toDef = FindUnit(toKey); + if (toDef == null) + { + items.Add(new LauncherItem($"'{parts[2]}' 단위를 찾을 수 없습니다", + "", null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + if (fromDef.Cat != toDef.Cat) + { + items.Add(new LauncherItem("카테고리 불일치", + $"{fromDef.Cat} ≠ {toDef.Cat} — 같은 종류끼리만 변환 가능", + null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + var result = Convert(value, fromDef, toDef); + var label = $"{FormatNum(value)} {fromDef.Names[0].ToUpper()} = {FormatNum(result)} {toDef.Names[0].ToUpper()}"; + items.Add(new LauncherItem(label, "Enter → 복사", null, ("copy", label), Symbol: "\uE8EF")); + items.Add(new LauncherItem($"{FormatNum(result)} {toDef.Names[0].ToUpper()}", toDef.Display, + null, ("copy", FormatNum(result)), Symbol: "\uE8EF")); + } + else + { + // 같은 카테고리 모든 단위로 변환 + var sameCat = Units.Where(u => u.Cat == fromDef.Cat && u != fromDef).ToList(); + items.Add(new LauncherItem($"{FormatNum(value)} {fromDef.Names[0].ToUpper()} 변환 결과", + fromDef.Display, null, null, Symbol: "\uE8EF")); + foreach (var toDef in sameCat) + { + var result = Convert(value, fromDef, toDef); + var label = $"{FormatNum(result)} {toDef.Names[0].ToUpper()}"; + items.Add(new LauncherItem(label, toDef.Display, null, ("copy", FormatNum(result)), Symbol: "\uE8EF")); + } + } + + 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("Unit", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + return Task.CompletedTask; + } + + // ── 변환 로직 ───────────────────────────────────────────────────────────── + + private static double Convert(double value, UnitDef from, UnitDef to) + { + if (from.Cat == UnitCategory.Temperature) + return ConvertTemp(value, from.Names[0].ToLowerInvariant(), to.Names[0].ToLowerInvariant()); + + // 선형 변환: value × from.ToBase / to.ToBase + return value * from.ToBase / to.ToBase; + } + + private static double ConvertTemp(double value, string from, string to) + { + // 먼저 °C로 + var celsius = from switch + { + "c" or "°c" => value, + "f" or "°f" => (value - 32) * 5 / 9, + "k" => value - 273.15, + _ => value, + }; + // °C에서 목표로 + return to switch + { + "c" or "°c" => celsius, + "f" or "°f" => celsius * 9 / 5 + 32, + "k" => celsius + 273.15, + _ => celsius, + }; + } + + private static UnitDef? FindUnit(string key) => + Units.FirstOrDefault(u => u.Names.Any(n => n.Equals(key, StringComparison.OrdinalIgnoreCase))); + + private static string FormatNum(double v) + { + if (double.IsNaN(v) || double.IsInfinity(v)) return v.ToString(); + if (Math.Abs(v) >= 1e12 || (Math.Abs(v) < 1e-4 && v != 0)) + return v.ToString("E3", System.Globalization.CultureInfo.InvariantCulture); + if (v == Math.Floor(v) && Math.Abs(v) < 1e9) + return $"{v:N0}"; + return v.ToString("G6", System.Globalization.CultureInfo.InvariantCulture); + } +} diff --git a/src/AxCopilot/Handlers/YamlHandler.cs b/src/AxCopilot/Handlers/YamlHandler.cs new file mode 100644 index 0000000..da9e88f --- /dev/null +++ b/src/AxCopilot/Handlers/YamlHandler.cs @@ -0,0 +1,410 @@ +using System.Text; +using System.Text.RegularExpressions; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L17-3: YAML 파서·포맷터·검증 핸들러. "yaml" 프리픽스로 사용합니다. +/// +/// 예: yaml → 클립보드 YAML 구조 분석 +/// yaml validate → YAML 유효성 검사 +/// yaml keys → 최상위 키 목록 +/// yaml get key.subkey → 특정 경로 값 조회 (점 표기법) +/// yaml stats → 줄 수·키 수·깊이 통계 +/// yaml flat → 점 표기법으로 평탄화 +/// Enter → 결과 복사. +/// 외부 라이브러리 없이 순수 파싱 구현 (기본 YAML 스펙 지원). +/// +public partial class YamlHandler : IActionHandler +{ + public string? Prefix => "yaml"; + + public PluginMetadata Metadata => new( + "YAML", + "YAML 파서·검증 — 키 조회 · 구조 분석 · 평탄화", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + string? clipboard = null; + try + { + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + if (Clipboard.ContainsText()) + clipboard = Clipboard.GetText(); + }); + } + catch { /* 클립보드 접근 실패 */ } + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem("YAML 파서·분석기", + "클립보드 YAML 분석 · yaml validate / keys / get key / stats / flat", + null, null, Symbol: "\uE8A5")); + items.Add(new LauncherItem("yaml validate", "YAML 유효성 검사", null, null, Symbol: "\uE8A5")); + items.Add(new LauncherItem("yaml keys", "최상위 키 목록", null, null, Symbol: "\uE8A5")); + items.Add(new LauncherItem("yaml get key", "특정 키 값 조회", null, null, Symbol: "\uE8A5")); + items.Add(new LauncherItem("yaml stats", "줄·키·깊이 통계", null, null, Symbol: "\uE8A5")); + items.Add(new LauncherItem("yaml flat", "점 표기법 평탄화", null, null, Symbol: "\uE8A5")); + + if (string.IsNullOrWhiteSpace(clipboard)) + { + items.Add(new LauncherItem("클립보드가 비어 있습니다", + "YAML 텍스트를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946")); + return Task.FromResult>(items); + } + + var stat = QuickStat(clipboard); + items.Add(new LauncherItem("── 클립보드 미리보기 ──", stat, null, ("copy", stat), Symbol: "\uE8A5")); + return Task.FromResult>(items); + } + + if (string.IsNullOrWhiteSpace(clipboard)) + { + items.Add(new LauncherItem("클립보드가 비어 있습니다", + "YAML 텍스트를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946")); + return Task.FromResult>(items); + } + + var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + // 파싱 + var (yamlObj, parseError) = ParseYaml(clipboard); + + switch (sub) + { + case "validate": + case "check": + case "lint": + { + if (parseError != null) + { + items.Add(new LauncherItem("❌ YAML 오류", parseError, null, null, Symbol: "\uE783")); + } + else + { + var linesCount = clipboard.Split('\n').Length; + var keyCount = CountKeys(yamlObj); + items.Add(new LauncherItem("✓ 유효한 YAML", + $"{linesCount}줄 · 키 {keyCount}개", null, ("copy", "Valid YAML"), Symbol: "\uE8A5")); + } + break; + } + + case "keys": + case "key": + { + if (parseError != null) + { + items.Add(new LauncherItem("YAML 파싱 오류", parseError, null, null, Symbol: "\uE783")); + break; + } + if (yamlObj is Dictionary dict) + { + items.Add(new LauncherItem($"최상위 키 {dict.Count}개", "", null, null, Symbol: "\uE8A5")); + foreach (var (k, v) in dict) + { + var valStr = FormatValue(v); + items.Add(new LauncherItem(k, valStr.Length > 60 ? valStr[..60] + "…" : valStr, + null, ("copy", k), Symbol: "\uE8A5")); + } + } + else items.Add(new LauncherItem("최상위가 매핑이 아닙니다", "배열 또는 스칼라 값", null, null, Symbol: "\uE946")); + break; + } + + case "get": + { + var keyPath = parts.Length > 1 ? parts[1].Trim() : ""; + if (string.IsNullOrWhiteSpace(keyPath)) + { + items.Add(new LauncherItem("키 경로 입력", "예: yaml get server.port", null, null, Symbol: "\uE783")); + break; + } + if (parseError != null) + { + items.Add(new LauncherItem("YAML 파싱 오류", parseError, null, null, Symbol: "\uE783")); + break; + } + var found = GetByPath(yamlObj, keyPath.Split('.')); + if (found == null) + items.Add(new LauncherItem($"'{keyPath}' 없음", "경로가 존재하지 않습니다", null, null, Symbol: "\uE946")); + else + { + var valStr = FormatValue(found); + items.Add(new LauncherItem(keyPath, valStr, null, ("copy", valStr), Symbol: "\uE8A5")); + } + break; + } + + case "stats": + case "stat": + { + var lines = clipboard.Split('\n'); + var blank = lines.Count(l => string.IsNullOrWhiteSpace(l)); + var comments = lines.Count(l => l.TrimStart().StartsWith('#')); + var keyLines = lines.Count(l => KeyLineRegex().IsMatch(l)); + var maxDepth = GetMaxDepth(clipboard); + var keyCount = parseError == null ? CountKeys(yamlObj) : -1; + + items.Add(new LauncherItem($"YAML 통계", $"{lines.Length}줄 · 키 {keyCount}개 · 깊이 {maxDepth}", + null, null, Symbol: "\uE8A5")); + items.Add(new LauncherItem("전체 줄", $"{lines.Length}줄", null, ("copy", $"{lines.Length}"), Symbol: "\uE8A5")); + items.Add(new LauncherItem("빈 줄", $"{blank}줄", null, ("copy", $"{blank}"), Symbol: "\uE8A5")); + items.Add(new LauncherItem("주석 줄", $"{comments}줄", null, ("copy", $"{comments}"), Symbol: "\uE8A5")); + items.Add(new LauncherItem("키 줄", $"{keyLines}줄", null, ("copy", $"{keyLines}"), Symbol: "\uE8A5")); + items.Add(new LauncherItem("최대 들여쓰기 깊이", $"{maxDepth}단계", null, ("copy", $"{maxDepth}"), Symbol: "\uE8A5")); + if (parseError == null && keyCount >= 0) + items.Add(new LauncherItem("전체 키 수 (재귀)", $"{keyCount}개", null, ("copy", $"{keyCount}"), Symbol: "\uE8A5")); + if (parseError != null) + items.Add(new LauncherItem("파싱 오류", parseError, null, null, Symbol: "\uE783")); + break; + } + + case "flat": + case "flatten": + { + if (parseError != null) + { + items.Add(new LauncherItem("YAML 파싱 오류", parseError, null, null, Symbol: "\uE783")); + break; + } + var flat = new List<(string Key, string Value)>(); + Flatten(yamlObj, "", flat); + + var sb = new StringBuilder(); + foreach (var (k, v) in flat) sb.AppendLine($"{k}: {v}"); + var result = sb.ToString().TrimEnd(); + + items.Add(new LauncherItem($"평탄화 ({flat.Count}개 키)", + "Enter → 전체 복사", null, ("copy", result), Symbol: "\uE8A5")); + foreach (var (k, v) in flat.Take(25)) + { + var disp = v.Length > 50 ? v[..50] + "…" : v; + items.Add(new LauncherItem(k, disp, null, ("copy", v), Symbol: "\uE8A5")); + } + if (flat.Count > 25) + items.Add(new LauncherItem($"… 외 {flat.Count - 25}개", "전체 복사는 첫 항목 Enter", null, null, Symbol: "\uE8A5")); + break; + } + + default: + { + items.Add(new LauncherItem("알 수 없는 서브커맨드", + "validate · keys · get key · stats · flat", null, null, Symbol: "\uE783")); + 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("YAML", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + return Task.CompletedTask; + } + + // ── YAML 파서 (경량, 기본 스펙만) ──────────────────────────────────────── + + /// + /// 경량 YAML 파서. 지원: 스칼라, 매핑(들여쓰기 기반), 시퀀스(- 표기). + /// 멀티라인/앵커/태그/복잡 흐름 스타일 미지원. + /// + private static (object? Value, string? Error) ParseYaml(string yaml) + { + var error = ""; + var lines = yaml.Split('\n').Select(l => l.TrimEnd('\r')).ToList(); + + // 기본 유효성: 들여쓰기 일관성 확인 + int? indentUnit = null; + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#')) continue; + var indent = line.Length - line.TrimStart().Length; + if (indent > 0) + { + indentUnit ??= indent; + // 허용 범위 내 들여쓰기인지 간단 확인 + } + } + + // 간단 구조 파싱 + var result = ParseBlock(lines, 0, ref error, out _); + return (result, string.IsNullOrEmpty(error) ? null : error); + } + + private static object? ParseBlock(List lines, int baseIndent, ref string error, out int consumed) + { + consumed = 0; + var dict = new Dictionary(); + var list = new List(); + var isSeq = false; + var isMap = false; + + var i = 0; + while (i < lines.Count) + { + var line = lines[i]; + if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#')) { i++; continue; } + + var indent = line.Length - line.TrimStart().Length; + if (indent < baseIndent) break; + + var trimmed = line.TrimStart(); + + // 시퀀스 항목 + if (trimmed.StartsWith("- ")) + { + isSeq = true; + var valStr = trimmed[2..].Trim(); + if (string.IsNullOrWhiteSpace(valStr)) + { + // 다음 줄이 하위 블록 + var sub = lines.Skip(i + 1).TakeWhile(l => + string.IsNullOrWhiteSpace(l) || l.Length - l.TrimStart().Length > indent).ToList(); + var innerErr = ""; + var child = ParseBlock(sub, indent + 2, ref innerErr, out var childConsumed); + list.Add(child); + i += 1 + childConsumed; + } + else list.Add(ParseScalar(valStr)); + i++; + continue; + } + + // 매핑 항목 (key: value) + var colonIdx = trimmed.IndexOf(':'); + if (colonIdx > 0) + { + isMap = true; + var key = trimmed[..colonIdx].Trim().Trim('"', '\''); + var rest = trimmed[(colonIdx + 1)..].Trim(); + + if (string.IsNullOrEmpty(rest) || rest.StartsWith('#')) + { + // 다음 줄이 하위 블록 + var sub = lines.Skip(i + 1).TakeWhile(l => + string.IsNullOrWhiteSpace(l) || l.Length - l.TrimStart().Length > indent).ToList(); + var innerErr = ""; + var child = ParseBlock(sub, indent + 2, ref innerErr, out var childConsumed); + dict[key] = child; + i += 1 + childConsumed; + } + else + { + dict[key] = ParseScalar(rest.Split('#')[0].Trim()); + i++; + } + continue; + } + + i++; + } + + consumed = i; + if (isSeq) return list; + if (isMap) return dict; + return null; + } + + private static object? ParseScalar(string s) + { + if (s is "true" or "True" or "TRUE" or "yes" or "Yes") return true; + if (s is "false" or "False" or "FALSE" or "no" or "No") return false; + if (s is "null" or "~" or "Null" or "NULL") return null; + if (long.TryParse(s, out var l)) return l; + if (double.TryParse(s, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var d)) return d; + return s.Trim('"', '\''); + } + + // ── 헬퍼 ───────────────────────────────────────────────────────────────── + + private static string QuickStat(string yaml) + { + var lines = yaml.Split('\n').Length; + var keyLines = yaml.Split('\n').Count(l => KeyLineRegex().IsMatch(l)); + var depth = GetMaxDepth(yaml); + return $"{lines}줄 · 키 {keyLines}개 · 최대 깊이 {depth}단계"; + } + + private static int GetMaxDepth(string yaml) + { + var maxIndent = yaml.Split('\n') + .Where(l => !string.IsNullOrWhiteSpace(l) && !l.TrimStart().StartsWith('#')) + .Select(l => l.Length - l.TrimStart().Length) + .DefaultIfEmpty(0).Max(); + return maxIndent / 2 + 1; + } + + private static int CountKeys(object? node) => node switch + { + Dictionary d => d.Count + d.Values.Sum(v => CountKeys(v)), + List l => l.Sum(v => CountKeys(v)), + _ => 0, + }; + + private static object? GetByPath(object? node, string[] parts) + { + if (parts.Length == 0) return node; + if (node is Dictionary dict) + { + var key = parts[0]; + if (!dict.TryGetValue(key, out var child)) return null; + return GetByPath(child, parts[1..]); + } + if (node is List list && int.TryParse(parts[0], out var idx) && idx < list.Count) + return GetByPath(list[idx], parts[1..]); + return null; + } + + private static void Flatten(object? node, string prefix, List<(string, string)> result) + { + switch (node) + { + case Dictionary dict: + foreach (var (k, v) in dict) + Flatten(v, string.IsNullOrEmpty(prefix) ? k : $"{prefix}.{k}", result); + break; + case List list: + for (var i = 0; i < list.Count; i++) + Flatten(list[i], $"{prefix}[{i}]", result); + break; + default: + result.Add((prefix, FormatValue(node))); + break; + } + } + + private static string FormatValue(object? v) => v switch + { + null => "(null)", + bool b => b ? "true" : "false", + Dictionary d => $"{{...{d.Count}개 키}}", + List l => $"[...{l.Count}개 항목]", + _ => v.ToString() ?? "", + }; + + [GeneratedRegex(@"^\s*[\w\-""']+\s*:")] + private static partial Regex KeyLineRegex(); +}