diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md index 3b9f962..d94fb5a 100644 --- a/docs/LAUNCHER_ROADMAP.md +++ b/docs/LAUNCHER_ROADMAP.md @@ -386,3 +386,16 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label | L19-2 | **타이머·알람** ✅ | `timer` 프리픽스. `timer 30`(초)·`timer 5m`(분)·`timer 1h30m`(시간+분) 형식 파싱. `timer stop`·`timer stop ` 특정 타이머 취소. 정적 타이머 레지스트리로 복수 타이머 동시 운영. `Task.Delay` 백그라운드 실행 + `NotificationService.Notify`로 완료 알림. 실행 중 목록 및 남은 시간 실시간 표시 | 높음 | | L19-3 | **IP 주소 유틸리티** ✅ | `ip` 프리픽스. `ip my` → NetworkInterface 전 어댑터 IPv4·마스크·게이트웨이. `ip 192.168.1.1` → 분류(사설/공인/루프백/APIPA/멀티캐스트)·클래스(A~E)·이진·16진·정수 변환. `ip 10.0.0.0/8` CIDR → 네트워크·브로드캐스트·와일드카드·호스트 범위·수. `ip range ` IP 범위 계산. `ip bin/hex/int` 표현 변환. `ip from ` 정수→IP | 높음 | | L19-4 | **npm/yarn/pnpm 명령어 생성기** ✅ | `npm` 프리픽스. `npm install ` → npm·yarn·pnpm 3종 설치 명령 동시 표시(일반/devDependencies/전역). init·uninstall·run·build·test·update·list·audit·publish·scripts·global·clean·ci·lock 서브커맨드 지원. `npm run dev` → yarn dev / pnpm run dev 동등 명령 비교. Enter로 클립보드 복사 | 높음 | + +--- + +## Phase L20 — Hex·Rand·Str·Perm 도구 (v2.1.0) ✅ 완료 + +> **방향**: 개발자 데이터 처리·생성 도구 강화 — 16진수 변환, 랜덤 생성, 문자열 조작, Unix 권한 계산. + +| # | 기능 | 설명 | 우선순위 | +|---|------|------|----------| +| L20-1 | **16진수·바이트 변환기** ✅ | `hex` 프리픽스. 클립보드/인라인 텍스트→hex 변환(공백 구분·소문자·0x접두사). 순수 hex 문자열→UTF-8/ASCII 자동 디코딩. `hex dump ` 오프셋+hex+ASCII 형식 16줄 단위 덤프. `hex 0xFF` 단일 hex값→10진/8진/이진/ASCII. `hex add/xor/and/or/not` 비트 연산. `hex bytes ` KB/MiB/GB 크기 단위 변환. CS1631 catch-yield 패턴 적용 | 높음 | +| L20-2 | **랜덤 생성기** ✅ | `rand` 프리픽스. 기본: 1~100 난수. `rand ` / `rand `. `rand str [len] [charset]` 영숫자/alpha/num/hex/special 문자셋. `rand color` HEX+RGB+HSL 랜덤 색상 5개. `rand dice [NdS]` 다면체 주사위(1d6~100d10000). `rand coin` 동전 던지기. `rand pick/shuffle` 항목 선택·셔플. `rand uuid` UUID v4. `rand token` RandomNumberGenerator 보안 토큰. `rand pin [len]` PIN 번호 | 높음 | +| L20-3 | **문자열 조작 도구** ✅ | `str` 프리픽스. `str escape/unescape html/url/json/regex` 이스케이프 변환. `str repeat [sep]` 반복. `str pad [left/right/both] [char]` 패딩. `str wrap ` 단어 단위 줄바꿈. `str sort [desc]` 줄 정렬. `str unique` 중복 제거. `str join/split ` 구분자 변환. `str replace ` 치환. `str extract email/url/number/ip` 패턴 추출. `str lines` 줄/단어/문자 통계. [GeneratedRegex] 소스 생성기 | 높음 | +| L20-4 | **Unix 파일 권한 계산기** ✅ | `perm` 프리픽스. `perm 755` 8진수→기호(rwxr-xr-x)·소유자/그룹/기타 상세 설명·용도 안내·관련 권한 제안. `perm rwxr-xr-x` 기호→8진수 역변환. `perm +x/-x/+w/-r 644` 비트 수정 연산. `perm umask 022` umask 적용 시 파일(666)/디렉토리(777) 결과 계산. `perm common` 14가지 자주 쓰는 권한 목록. chmod 명령 자동 생성 | 높음 | diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index e96b821..c2bd4c6 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -324,6 +324,14 @@ public partial class App : System.Windows.Application commandResolver.RegisterHandler(new IpInfoHandler()); // L19-4: npm/yarn/pnpm 명령어 생성기 (prefix=npm) commandResolver.RegisterHandler(new NpmHandler()); + // L20-1: 16진수·바이트 변환기 (prefix=hex) + commandResolver.RegisterHandler(new HexHandler()); + // L20-2: 랜덤 생성기 (prefix=rand) + commandResolver.RegisterHandler(new RandHandler()); + // L20-3: 문자열 조작 도구 (prefix=str) + commandResolver.RegisterHandler(new StrHandler()); + // L20-4: Unix 파일 권한 계산기 (prefix=perm) + commandResolver.RegisterHandler(new PermHandler()); // ─── 플러그인 로드 ──────────────────────────────────────────────────── var pluginHost = new PluginHost(settings, commandResolver); diff --git a/src/AxCopilot/Handlers/HexHandler.cs b/src/AxCopilot/Handlers/HexHandler.cs new file mode 100644 index 0000000..865c43f --- /dev/null +++ b/src/AxCopilot/Handlers/HexHandler.cs @@ -0,0 +1,315 @@ +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L20-1: 16진수·바이트 변환기 핸들러. "hex" 프리픽스로 사용합니다. +/// +/// 예: hex → 클립보드 텍스트 → hex 변환 +/// hex hello → "hello" → 68 65 6C 6C 6F +/// hex 68656c6c6f → hex → "hello" 디코딩 +/// hex dump hello world → 헥스 덤프 형식 (오프셋·hex·ASCII) +/// hex 0xFF → 0xFF = 255 (십진수·이진수·문자) +/// hex add 0x1A 0x2B → hex 덧셈 +/// hex xor 0xAB 0xCD → bitwise XOR +/// hex and 0xFF 0x0F → bitwise AND +/// hex or 0xA0 0x0F → bitwise OR +/// hex not 0xFF → bitwise NOT (8비트) +/// hex bytes → n바이트 크기 단위 표시 (KB·MB·GB) +/// Enter → 결과 복사. +/// +public class HexHandler : IActionHandler +{ + public string? Prefix => "hex"; + + public PluginMetadata Metadata => new( + "Hex", + "16진수·바이트 변환기 — 텍스트↔hex·덤프·비트연산·크기 단위", + "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().Trim(); + }); + } + catch { } + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem("16진수·바이트 변환기", + "hex <텍스트> / hex / hex dump / hex 0xFF / hex bytes ", + null, null, Symbol: "\uE8EF")); + if (!string.IsNullOrWhiteSpace(clipboard)) + items.AddRange(BuildFromText(clipboard!, brief: true)); + return Task.FromResult>(items); + } + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + // hex dump + if (sub == "dump") + { + var text = parts.Length > 1 ? string.Join(" ", parts[1..]) : clipboard ?? ""; + if (string.IsNullOrEmpty(text)) + { items.Add(ErrorItem("텍스트를 입력하거나 클립보드에 복사하세요")); } + else + items.AddRange(BuildDump(text)); + return Task.FromResult>(items); + } + + // hex bytes + if (sub == "bytes" && parts.Length >= 2 && + long.TryParse(parts[1], out var byteCount)) + { + items.AddRange(BuildByteSize(byteCount)); + return Task.FromResult>(items); + } + + // hex add/xor/and/or/not (비트 연산) + if (sub is "add" or "xor" or "and" or "or" or "not") + { + items.AddRange(BuildBitOp(sub, parts)); + return Task.FromResult>(items); + } + + // 단일 hex 값 (0xFF, 0xAB, FF, AB...) + if (TryParseHexValue(parts[0], out var hexVal)) + { + items.AddRange(BuildFromHexValue(hexVal, parts[0])); + return Task.FromResult>(items); + } + + // 순수 hex 문자열인지 판단 (2자 이상, 모두 hex digit, 짝수 길이) + var raw = parts[0].Replace(" ", "").Replace("-", "").Replace(":", ""); + if (raw.Length >= 2 && raw.Length % 2 == 0 && IsAllHex(raw)) + { + items.AddRange(BuildFromHexString(raw)); + return Task.FromResult>(items); + } + + // 일반 텍스트 → hex 변환 + var input = string.Join(" ", parts); + items.AddRange(BuildFromText(input, brief: false)); + 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("Hex", "클립보드에 복사했습니다."); + } + catch { } + } + return Task.CompletedTask; + } + + // ── 빌더 ──────────────────────────────────────────────────────────────── + + private static IEnumerable BuildFromText(string text, bool brief) + { + var bytes = Encoding.UTF8.GetBytes(text); + var hex = BitConverter.ToString(bytes).Replace("-", ""); + var spaced = string.Join(" ", Enumerable.Range(0, hex.Length / 2) + .Select(i => hex.Substring(i * 2, 2))); + + yield return new LauncherItem(spaced, + $"텍스트 → Hex ({bytes.Length} bytes) · Enter 복사", + null, ("copy", spaced), Symbol: "\uE8EF"); + + if (!brief) + { + yield return CopyItem("공백 없음", hex); + yield return CopyItem("소문자", hex.ToLowerInvariant()); + yield return CopyItem("0x 접두사", string.Join(" ", Enumerable.Range(0, hex.Length / 2) + .Select(i => "0x" + hex.Substring(i * 2, 2)))); + yield return CopyItem("바이트 수", $"{bytes.Length} bytes"); + var b64 = Convert.ToBase64String(bytes); + yield return CopyItem("Base64", b64); + } + } + + private static IEnumerable BuildFromHexString(string hex) + { + hex = hex.ToUpperInvariant(); + byte[]? bytes = null; + string? parseError = null; + try + { + bytes = Enumerable.Range(0, hex.Length / 2) + .Select(i => Convert.ToByte(hex.Substring(i * 2, 2), 16)) + .ToArray(); + } + catch { parseError = "올바른 hex 문자열이 아닙니다"; } + + if (parseError != null) + { + yield return ErrorItem(parseError); + yield break; + } + + var safeBytes = bytes!; + var utf8 = TrySafeUtf8(safeBytes); + var ascii = TrySafeAscii(safeBytes); + + yield return new LauncherItem($"Hex → 텍스트", + $"{safeBytes.Length} bytes · UTF-8 디코딩", + null, null, Symbol: "\uE8EF"); + + if (utf8 != null) yield return CopyItem("UTF-8 텍스트", utf8); + if (ascii != null && ascii != utf8) yield return CopyItem("ASCII 텍스트", ascii); + + yield return CopyItem("바이트 수", $"{safeBytes.Length}"); + // 숫자 해석 (최대 8바이트) + if (safeBytes.Length <= 8) + { + var padded = new byte[8]; + Buffer.BlockCopy(safeBytes, 0, padded, 8 - safeBytes.Length, safeBytes.Length); + var bigEndian = BitConverter.IsLittleEndian + ? BitConverter.ToUInt64(padded.Reverse().ToArray()) + : BitConverter.ToUInt64(padded); + yield return CopyItem($"정수 (big-endian)", bigEndian.ToString()); + } + } + + private static IEnumerable BuildFromHexValue(ulong val, string original) + { + yield return new LauncherItem($"{original} = {val}", + $"16진수 → 십진수 · Enter 복사", + null, ("copy", val.ToString()), Symbol: "\uE8EF"); + yield return CopyItem("십진수", val.ToString()); + yield return CopyItem("16진수", $"0x{val:X}"); + yield return CopyItem("8진수", $"0o{Convert.ToString((long)val, 8)}"); + yield return CopyItem("이진수", $"0b{Convert.ToString((long)val, 2)}"); + if (val <= 127) yield return CopyItem("ASCII 문자", ((char)val).ToString()); + yield return CopyItem("NOT (64bit)", $"0x{(~val):X16}"); + } + + private static IEnumerable BuildDump(string text) + { + var bytes = Encoding.UTF8.GetBytes(text); + var lines = new List(); + var sb = new StringBuilder(); + + for (int i = 0; i < bytes.Length; i += 16) + { + var chunk = bytes.Skip(i).Take(16).ToArray(); + var hexPart = string.Join(" ", chunk.Select(b => $"{b:X2}")).PadRight(47); + var asciiPart = new string(chunk.Select(b => b >= 32 && b < 127 ? (char)b : '.').ToArray()); + var line = $"{i:X8} {hexPart} |{asciiPart}|"; + lines.Add(line); + } + + var dump = string.Join("\n", lines); + yield return new LauncherItem("헥스 덤프", + $"{bytes.Length} bytes · {lines.Count} 행", null, ("copy", dump), Symbol: "\uE8EF"); + foreach (var line in lines.Take(8)) + yield return new LauncherItem(line, "", null, ("copy", line), Symbol: "\uE8EF"); + if (lines.Count > 8) + yield return new LauncherItem($"... ({lines.Count - 8}행 더 있음)", + "전체 복사하려면 상단 항목 Enter", null, null, Symbol: "\uE8EF"); + } + + private static IEnumerable BuildBitOp(string op, string[] parts) + { + if (op == "not") + { + if (parts.Length < 2 || !TryParseHexValue(parts[1], out var v)) + { yield return ErrorItem("예: hex not 0xFF"); yield break; } + var r8 = (byte)(~(byte)v); + var r16 = (ushort)(~(ushort)v); + yield return new LauncherItem($"NOT 0x{v:X} = 0x{r8:X2} (8bit) / 0x{r16:X4} (16bit)", + "비트 반전", null, ("copy", $"0x{r8:X2}"), Symbol: "\uE8EF"); + yield return CopyItem("NOT 8bit", $"0x{r8:X2} ({r8})"); + yield return CopyItem("NOT 16bit", $"0x{r16:X4} ({r16})"); + yield return CopyItem("NOT 64bit", $"0x{(~v):X16}"); + yield break; + } + if (parts.Length < 3 || !TryParseHexValue(parts[1], out var a) || !TryParseHexValue(parts[2], out var b)) + { yield return ErrorItem($"예: hex {op} 0xAB 0xCD"); yield break; } + + ulong result = op switch + { + "add" => a + b, + "xor" => a ^ b, + "and" => a & b, + "or" => a | b, + _ => 0 + }; + var symbol = op switch { "add" => "+", "xor" => "^", "and" => "&", "or" => "|", _ => "?" }; + yield return new LauncherItem($"0x{a:X} {symbol} 0x{b:X} = 0x{result:X}", + $"{a} {symbol} {b} = {result} · Enter 복사", + null, ("copy", $"0x{result:X}"), Symbol: "\uE8EF"); + yield return CopyItem("16진수", $"0x{result:X}"); + yield return CopyItem("십진수", result.ToString()); + yield return CopyItem("이진수", $"0b{Convert.ToString((long)result, 2)}"); + } + + private static IEnumerable BuildByteSize(long bytes) + { + yield return new LauncherItem($"{bytes:N0} bytes", + "크기 단위 변환 · Enter 복사", null, ("copy", bytes.ToString()), Symbol: "\uE8EF"); + yield return CopyItem("Bytes", $"{bytes:N0}"); + yield return CopyItem("KB (1000)", $"{bytes / 1000.0:F3}"); + yield return CopyItem("KiB (1024)",$"{bytes / 1024.0:F3}"); + yield return CopyItem("MB (1000)", $"{bytes / 1_000_000.0:F3}"); + yield return CopyItem("MiB (1024)",$"{bytes / 1_048_576.0:F3}"); + yield return CopyItem("GB (1000)", $"{bytes / 1_000_000_000.0:F4}"); + yield return CopyItem("GiB (1024)",$"{bytes / 1_073_741_824.0:F4}"); + if (bytes >= 1_000_000_000_000L) + yield return CopyItem("TB (1000)", $"{bytes / 1_000_000_000_000.0:F4}"); + } + + // ── 헬퍼 ──────────────────────────────────────────────────────────────── + + private static bool TryParseHexValue(string s, out ulong val) + { + val = 0; + s = s.Trim(); + if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + return ulong.TryParse(s[2..], System.Globalization.NumberStyles.HexNumber, null, out val); + if (s.StartsWith("0X", StringComparison.OrdinalIgnoreCase)) + return ulong.TryParse(s[2..], System.Globalization.NumberStyles.HexNumber, null, out val); + return false; + } + + private static bool IsAllHex(string s) => + s.All(c => c is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F'); + + private static string? TrySafeUtf8(byte[] b) + { + try { var s = Encoding.UTF8.GetString(b); return s; } + catch { return null; } + } + + private static string? TrySafeAscii(byte[] b) + { + try { return Encoding.ASCII.GetString(b); } + catch { return null; } + } + + private static LauncherItem CopyItem(string label, string value) => + new(label, value, null, ("copy", value), Symbol: "\uE8EF"); + + private static LauncherItem ErrorItem(string msg) => + new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783"); +} diff --git a/src/AxCopilot/Handlers/PermHandler.cs b/src/AxCopilot/Handlers/PermHandler.cs new file mode 100644 index 0000000..c16a213 --- /dev/null +++ b/src/AxCopilot/Handlers/PermHandler.cs @@ -0,0 +1,277 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L20-4: Unix 파일 권한 계산기 핸들러. "perm" 프리픽스로 사용합니다. +/// +/// 예: perm → 주요 권한 목록 +/// perm 755 → rwxr-xr-x 상세 설명 +/// perm 644 → rw-r--r-- 상세 설명 +/// perm rwxr-xr-x → 기호 → 숫자 변환 +/// perm rw-r--r-- → 기호 → 숫자 변환 +/// perm +x 644 → 실행 비트 추가 +/// perm -x 755 → 실행 비트 제거 +/// perm +w 444 → 쓰기 비트 추가 +/// perm umask 022 → umask 적용 결과 +/// perm common → 자주 쓰는 권한 목록 +/// Enter → 값 복사. +/// +public class PermHandler : IActionHandler +{ + public string? Prefix => "perm"; + + public PluginMetadata Metadata => new( + "Perm", + "Unix 파일 권한 계산기 — chmod 숫자↔기호·umask·권한 설명", + "1.0", + "AX"); + + private record CommonPerm(string Octal, string Symbol, string Description, string UseCase); + + private static readonly CommonPerm[] Common = + [ + new("777", "rwxrwxrwx", "모든 사용자 완전 권한", "임시 스크립트 (보안 주의)"), + new("755", "rwxr-xr-x", "소유자 완전·그룹/기타 읽기+실행", "실행 파일, 디렉토리"), + new("750", "rwxr-x---", "소유자 완전·그룹 읽기+실행·기타 없음", "그룹 공유 스크립트"), + new("700", "rwx------", "소유자만 완전 권한", "개인 스크립트"), + new("644", "rw-r--r--", "소유자 읽기+쓰기·그룹/기타 읽기만", "일반 파일"), + new("640", "rw-r-----", "소유자 읽기+쓰기·그룹 읽기·기타 없음", "설정 파일"), + new("600", "rw-------", "소유자만 읽기+쓰기", "개인 설정·SSH 키"), + new("444", "r--r--r--", "모든 사용자 읽기만", "공유 읽기 전용"), + new("400", "r--------", "소유자만 읽기", "SSL 인증서·개인 키"), + new("666", "rw-rw-rw-", "모든 사용자 읽기+쓰기 (실행 없음)", "임시 파일"), + new("664", "rw-rw-r--", "소유자+그룹 읽기+쓰기·기타 읽기", "그룹 협업 파일"), + new("660", "rw-rw----", "소유자+그룹 읽기+쓰기·기타 없음", "그룹 협업 파일"), + new("775", "rwxrwxr-x", "소유자+그룹 완전·기타 읽기+실행", "그룹 협업 디렉토리"), + new("770", "rwxrwx---", "소유자+그룹 완전·기타 없음", "그룹 전용 디렉토리"), + ]; + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem("Unix 파일 권한 계산기", + "perm 755 / perm rwxr-xr-x / perm +x 644 / perm umask 022 / perm common", + null, null, Symbol: "\uE8A5")); + foreach (var p in Common.Take(6)) + items.Add(new LauncherItem($"{p.Octal} {p.Symbol}", + $"{p.Description} ({p.UseCase})", + null, ("copy", p.Octal), Symbol: "\uE8A5")); + return Task.FromResult>(items); + } + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + // common + if (sub is "common" or "list" or "목록") + { + items.Add(new LauncherItem("자주 쓰는 파일 권한 목록", "", null, null, Symbol: "\uE8A5")); + foreach (var p in Common) + items.Add(new LauncherItem($"{p.Octal} {p.Symbol}", + $"{p.Description} · {p.UseCase}", + null, ("copy", p.Octal), Symbol: "\uE8A5")); + return Task.FromResult>(items); + } + + // umask + if (sub == "umask" && parts.Length >= 2) + { + if (TryParseOctal(parts[1], out var umaskVal)) + items.AddRange(BuildUmask(umaskVal)); + else + items.Add(ErrorItem("umask 값은 3자리 8진수입니다 (예: 022)")); + return Task.FromResult>(items); + } + + // +x / -x / +w / -w / +r / -r (비트 수정) + if (sub.Length == 2 && sub[0] is '+' or '-' && sub[1] is 'r' or 'w' or 'x') + { + if (parts.Length >= 2 && TryParseOctal(parts[1], out var baseVal)) + items.AddRange(BuildBitModify(sub[0] == '+', sub[1], baseVal)); + else + items.Add(ErrorItem($"예: perm {sub} 644")); + return Task.FromResult>(items); + } + + // 기호 표기 (rwxr-xr-x) + if (sub.Length == 9 && sub.All(c => c is 'r' or 'w' or 'x' or '-')) + { + items.AddRange(BuildFromSymbol(sub)); + return Task.FromResult>(items); + } + + // 8진수 표기 (755, 644...) + if (TryParseOctal(sub, out var octal)) + { + items.AddRange(BuildFromOctal(octal)); + return Task.FromResult>(items); + } + + items.Add(new LauncherItem($"인식할 수 없는 입력: '{sub}'", + "perm 755 / perm rwxr-xr-x / perm +x 644 / perm umask 022", + null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + if (item.Data is ("copy", string text)) + { + try + { + System.Windows.Application.Current.Dispatcher.Invoke( + () => Clipboard.SetText(text)); + NotificationService.Notify("Perm", "클립보드에 복사했습니다."); + } + catch { } + } + return Task.CompletedTask; + } + + // ── 빌더 ──────────────────────────────────────────────────────────────── + + private static IEnumerable BuildFromOctal(int octal) + { + var sym = OctalToSymbol(octal); + var desc = DescribeOctal(octal); + var preset = Common.FirstOrDefault(p => p.Octal == octal.ToString("D3")); + + yield return new LauncherItem($"{octal:D3} → {sym}", + preset?.Description ?? desc, + null, ("copy", octal.ToString("D3")), Symbol: "\uE8A5"); + + yield return CopyItem("8진수", octal.ToString("D3")); + yield return CopyItem("기호 표기", sym); + yield return CopyItem("chmod 명령", $"chmod {octal:D3} <파일>"); + + var parts = OctalToParts(octal); + yield return new LauncherItem("소유자 (Owner)", + $"{OctetToSymbol(parts[0])} ({BitsDesc(parts[0])})", + null, null, Symbol: "\uE8A5"); + yield return new LauncherItem("그룹 (Group)", + $"{OctetToSymbol(parts[1])} ({BitsDesc(parts[1])})", + null, null, Symbol: "\uE8A5"); + yield return new LauncherItem("기타 (Others)", + $"{OctetToSymbol(parts[2])} ({BitsDesc(parts[2])})", + null, null, Symbol: "\uE8A5"); + + if (preset != null) + yield return new LauncherItem("용도", preset.UseCase, null, null, Symbol: "\uE8A5"); + + // 관련 권한 제안 + yield return new LauncherItem("── 관련 권한 ──", "", null, null, Symbol: "\uE8A5"); + var related = Common.Where(p => Math.Abs(int.Parse(p.Octal) - octal) <= 11) + .Take(4).ToList(); + foreach (var r in related) + yield return new LauncherItem($"{r.Octal} {r.Symbol}", r.Description, + null, ("copy", r.Octal), Symbol: "\uE8A5"); + } + + private static IEnumerable BuildFromSymbol(string sym) + { + var octal = SymbolToOctal(sym); + return BuildFromOctal(octal); + } + + private static IEnumerable BuildUmask(int umask) + { + var fileDefault = 0666; + var dirDefault = 0777; + var fileResult = fileDefault & ~umask; + var dirResult = dirDefault & ~umask; + + yield return new LauncherItem($"umask {umask:D3} → 파일: {fileResult:D3} 디렉토리: {dirResult:D3}", + "umask 적용 결과", null, null, Symbol: "\uE8A5"); + yield return CopyItem("umask 값", umask.ToString("D3")); + yield return CopyItem("기본 파일 권한", fileResult.ToString("D3")); + yield return CopyItem("기본 디렉토리 권한", dirResult.ToString("D3")); + yield return CopyItem("파일 기호", OctalToSymbol(fileResult)); + yield return CopyItem("디렉토리 기호", OctalToSymbol(dirResult)); + yield return new LauncherItem("설명", + $"umask {umask:D3}: 파일={OctalToSymbol(fileResult)}, 디렉토리={OctalToSymbol(dirResult)}", + null, null, Symbol: "\uE8A5"); + } + + private static IEnumerable BuildBitModify(bool add, char bit, int baseOctal) + { + var mask = bit switch + { + 'r' => 0444, + 'w' => 0222, + 'x' => 0111, + _ => 0 + }; + var result = add ? (baseOctal | mask) : (baseOctal & ~mask); + result &= 0777; + + var label = add ? $"+{bit}" : $"-{bit}"; + yield return new LauncherItem($"{baseOctal:D3} {label} → {result:D3} ({OctalToSymbol(result)})", + "Enter 복사", null, ("copy", result.ToString("D3")), Symbol: "\uE8A5"); + yield return CopyItem("변경 전", baseOctal.ToString("D3")); + yield return CopyItem("변경 후", result.ToString("D3")); + yield return CopyItem("기호", OctalToSymbol(result)); + yield return CopyItem("chmod", $"chmod {label} <파일> (또는 chmod {result:D3} <파일>)"); + } + + // ── 변환 헬퍼 ──────────────────────────────────────────────────────────── + + private static bool TryParseOctal(string s, out int val) + { + val = 0; + s = s.TrimStart('0'); + if (string.IsNullOrEmpty(s)) { val = 0; return true; } + if (s.Length > 4) return false; + try { val = Convert.ToInt32(s, 8); return val <= 0777; } + catch { return false; } + } + + private static int[] OctalToParts(int octal) => + [(octal >> 6) & 7, (octal >> 3) & 7, octal & 7]; + + private static string OctetToSymbol(int v) => + $"{((v & 4) != 0 ? 'r' : '-')}{((v & 2) != 0 ? 'w' : '-')}{((v & 1) != 0 ? 'x' : '-')}"; + + private static string OctalToSymbol(int octal) + { + var p = OctalToParts(octal); + return OctetToSymbol(p[0]) + OctetToSymbol(p[1]) + OctetToSymbol(p[2]); + } + + private static int SymbolToOctal(string sym) + { + int TriBit(int off) => + ((sym[off] == 'r' ? 4 : 0) | + (sym[off + 1] == 'w' ? 2 : 0) | + (sym[off + 2] == 'x' ? 1 : 0)); + return (TriBit(0) << 6) | (TriBit(3) << 3) | TriBit(6); + } + + private static string BitsDesc(int v) + { + var parts = new List(); + if ((v & 4) != 0) parts.Add("읽기"); + if ((v & 2) != 0) parts.Add("쓰기"); + if ((v & 1) != 0) parts.Add("실행"); + return parts.Count == 0 ? "없음" : string.Join("·", parts); + } + + private static string DescribeOctal(int octal) + { + var p = OctalToParts(octal); + return $"소유자:{BitsDesc(p[0])} 그룹:{BitsDesc(p[1])} 기타:{BitsDesc(p[2])}"; + } + + private static LauncherItem CopyItem(string label, string value) => + new(label, value, null, ("copy", value), Symbol: "\uE8A5"); + + private static LauncherItem ErrorItem(string msg) => + new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783"); +} diff --git a/src/AxCopilot/Handlers/RandHandler.cs b/src/AxCopilot/Handlers/RandHandler.cs new file mode 100644 index 0000000..2ca25ea --- /dev/null +++ b/src/AxCopilot/Handlers/RandHandler.cs @@ -0,0 +1,324 @@ +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L20-2: 랜덤 생성기 핸들러. "rand" 프리픽스로 사용합니다. +/// +/// 예: rand → 사용법 목록 +/// rand → 1~100 기본 난수 +/// rand 50 → 1~50 난수 +/// rand 10 99 → 10~99 난수 +/// rand str → 랜덤 문자열 (12자, 영숫자) +/// rand str 20 → 20자 랜덤 문자열 +/// rand str 16 alpha → 16자 알파벳만 +/// rand str 8 num → 8자 숫자만 +/// rand str 12 hex → 12자 hex 문자열 +/// rand str 16 special → 특수문자 포함 +/// rand color → 랜덤 HEX 색상 +/// rand pick 항목1 항목2 → 목록에서 무작위 선택 +/// rand dice → 주사위 1d6 +/// rand dice 2d6 → 2개 주사위 +/// rand dice 1d20 → 20면체 주사위 +/// rand coin → 동전 던지기 +/// rand shuffle a b c d → 목록 셔플 +/// rand uuid → UUID v4 +/// rand token → 보안 토큰 (32자 hex) +/// rand pin → 6자리 PIN +/// rand pin 4 → 4자리 PIN +/// Enter → 결과 복사. +/// +public class RandHandler : IActionHandler +{ + public string? Prefix => "rand"; + + public PluginMetadata Metadata => new( + "Rand", + "랜덤 생성기 — 숫자·문자열·색상·주사위·UUID·토큰·PIN·셔플", + "1.0", + "AX"); + + private static readonly Random _rng = Random.Shared; + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + // 기본: 1~100 난수 + 사용법 + var n = _rng.Next(1, 101); + items.Add(new LauncherItem($"{n}", + "1~100 랜덤 숫자 · Enter 복사", null, ("copy", n.ToString()), Symbol: "\uE8D0")); + items.Add(new LauncherItem("사용법", + "rand / rand / rand str / rand color / rand dice / rand pick …", + null, null, Symbol: "\uE8D0")); + return Task.FromResult>(items); + } + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + switch (sub) + { + // rand str [length] [charset] + case "str" or "string" or "문자열": + { + int len = 12; + if (parts.Length >= 2 && int.TryParse(parts[1], out var l)) len = Math.Clamp(l, 1, 256); + var charset = parts.Length >= 3 ? parts[2].ToLowerInvariant() : "alnum"; + if (parts.Length == 2 && !int.TryParse(parts[1], out _)) charset = parts[1].ToLowerInvariant(); + var cs = GetCharset(charset); + if (string.IsNullOrEmpty(cs)) { items.Add(ErrorItem("charset: alpha/num/alnum/hex/special")); break; } + var result = RandStr(len, cs); + items.Add(new LauncherItem(result, $"{len}자 랜덤 문자열 ({charset}) · Enter 복사", + null, ("copy", result), Symbol: "\uE8D0")); + // 다른 자릿수 미리 생성 + foreach (var altLen in new[] { 8, 16, 32 }.Where(x => x != len)) + items.Add(new LauncherItem(RandStr(altLen, cs), $"{altLen}자 ({charset})", + null, ("copy", RandStr(altLen, cs)), Symbol: "\uE8D0")); + break; + } + + // rand color + case "color" or "색상" or "colour": + { + for (int i = 0; i < 5; i++) + { + var r = _rng.Next(256); var g = _rng.Next(256); var b = _rng.Next(256); + var hex = $"#{r:X2}{g:X2}{b:X2}"; + var rgb = $"rgb({r}, {g}, {b})"; + var hsl = RgbToHsl(r, g, b); + items.Add(new LauncherItem(hex, $"{rgb} {hsl} · Enter 복사", + null, ("copy", hex), Symbol: "\uE8D0")); + } + break; + } + + // rand dice [NdS] + case "dice" or "주사위": + { + var spec = parts.Length >= 2 ? parts[1].ToLowerInvariant() : "1d6"; + if (!TryParseDice(spec, out var dCount, out var dSides)) + { items.Add(ErrorItem("형식: 1d6 / 2d10 / 1d20")); break; } + var rolls = Enumerable.Range(0, dCount).Select(_ => _rng.Next(1, dSides + 1)).ToList(); + var total = rolls.Sum(); + var detail = string.Join(" + ", rolls); + items.Add(new LauncherItem($"{dCount}d{dSides} → {total}", + $"각 주사위: {detail} · Enter 복사", null, ("copy", total.ToString()), Symbol: "\uE8D0")); + if (dCount > 1) + { + items.Add(CopyItem("합계", total.ToString())); + items.Add(CopyItem("상세", detail)); + items.Add(CopyItem("최솟값", rolls.Min().ToString())); + items.Add(CopyItem("최댓값", rolls.Max().ToString())); + } + // 다시 굴리기 미리보기 + items.Add(new LauncherItem("── 다시 굴리기 (미리보기) ──", "", null, null, Symbol: "\uE8D0")); + for (int i = 0; i < 3; i++) + { + var r2 = Enumerable.Range(0, dCount).Select(_ => _rng.Next(1, dSides + 1)).ToList(); + items.Add(new LauncherItem($"{r2.Sum()}", string.Join(" + ", r2), + null, ("copy", r2.Sum().ToString()), Symbol: "\uE8D0")); + } + break; + } + + // rand coin + case "coin" or "동전": + { + var result = _rng.Next(2) == 0 ? "앞면 (Head)" : "뒷면 (Tail)"; + items.Add(new LauncherItem(result, "동전 던지기 · Enter 복사", + null, ("copy", result), Symbol: "\uE8D0")); + // 연속 5회 + items.Add(new LauncherItem("── 연속 5회 ──", "", null, null, Symbol: "\uE8D0")); + for (int i = 0; i < 5; i++) + items.Add(new LauncherItem(_rng.Next(2) == 0 ? "앞면 ⬤" : "뒷면 ○", + $"#{i + 1}", null, null, Symbol: "\uE8D0")); + break; + } + + // rand pick item1 item2 ... + case "pick" or "선택": + { + if (parts.Length < 2) { items.Add(ErrorItem("예: rand pick 치킨 피자 짜장면")); break; } + var pool = parts[1..]; + var picked = pool[_rng.Next(pool.Length)]; + items.Add(new LauncherItem(picked, + $"{pool.Length}개 중 선택 · Enter 복사", + null, ("copy", picked), Symbol: "\uE8D0")); + items.Add(new LauncherItem("── 다시 선택 ──", "", null, null, Symbol: "\uE8D0")); + for (int i = 0; i < Math.Min(3, pool.Length); i++) + items.Add(new LauncherItem(pool[_rng.Next(pool.Length)], + $"후보 {i + 1}", null, null, Symbol: "\uE8D0")); + break; + } + + // rand shuffle item1 item2 ... + case "shuffle" or "섞기": + { + if (parts.Length < 2) { items.Add(ErrorItem("예: rand shuffle a b c d e")); break; } + var list = parts[1..].ToList(); + for (int i = list.Count - 1; i > 0; i--) + { + var j = _rng.Next(i + 1); + (list[i], list[j]) = (list[j], list[i]); + } + var joined = string.Join(" ", list); + items.Add(new LauncherItem(joined, + $"{list.Count}개 셔플 · Enter 복사", + null, ("copy", joined), Symbol: "\uE8D0")); + items.Add(CopyItem("쉼표 구분", string.Join(", ", list))); + for (int r = 0; r < list.Count; r++) + items.Add(new LauncherItem($"#{r + 1} {list[r]}", "", null, null, Symbol: "\uE8D0")); + break; + } + + // rand uuid + case "uuid": + { + for (int i = 0; i < 5; i++) + { + var guid = Guid.NewGuid().ToString(); + items.Add(new LauncherItem(guid, $"UUID v4 · Enter 복사", + null, ("copy", guid), Symbol: "\uE8D0")); + } + break; + } + + // rand token + case "token" or "secret" or "key": + { + int tLen = 32; + if (parts.Length >= 2 && int.TryParse(parts[1], out var tl)) tLen = Math.Clamp(tl, 8, 128); + var tokenBytes = new byte[tLen]; + System.Security.Cryptography.RandomNumberGenerator.Fill(tokenBytes); + var tokenHex = BitConverter.ToString(tokenBytes).Replace("-", "").ToLowerInvariant(); + var tokenB64 = Convert.ToBase64String(tokenBytes); + items.Add(new LauncherItem(tokenHex, $"{tLen}바이트 보안 토큰 (hex) · Enter 복사", + null, ("copy", tokenHex), Symbol: "\uE8D0")); + items.Add(CopyItem("Hex", tokenHex)); + items.Add(CopyItem("Base64", tokenB64)); + break; + } + + // rand pin [length] + case "pin": + { + int pLen = 6; + if (parts.Length >= 2 && int.TryParse(parts[1], out var pl)) pLen = Math.Clamp(pl, 4, 12); + var pin = string.Concat(Enumerable.Range(0, pLen).Select(_ => _rng.Next(10))); + items.Add(new LauncherItem(pin, $"{pLen}자리 PIN · Enter 복사", + null, ("copy", pin), Symbol: "\uE8D0")); + items.Add(new LauncherItem("── 추가 PIN ──", "", null, null, Symbol: "\uE8D0")); + for (int i = 0; i < 4; i++) + items.Add(new LauncherItem( + string.Concat(Enumerable.Range(0, pLen).Select(_ => _rng.Next(10))), + $"{pLen}자리", null, null, Symbol: "\uE8D0")); + break; + } + + default: + { + // rand 또는 rand + if (int.TryParse(sub, out var max)) + { + int min = 1; + if (parts.Length >= 2 && int.TryParse(parts[1], out var mx)) { min = max; max = mx; } + if (min > max) (min, max) = (max, min); + var n = _rng.Next(min, max + 1); + items.Add(new LauncherItem($"{n}", + $"{min}~{max} 랜덤 숫자 · Enter 복사", + null, ("copy", n.ToString()), Symbol: "\uE8D0")); + items.Add(new LauncherItem("── 추가 결과 ──", "", null, null, Symbol: "\uE8D0")); + for (int i = 0; i < 4; i++) + items.Add(new LauncherItem(_rng.Next(min, max + 1).ToString(), + $"{min}~{max}", null, null, Symbol: "\uE8D0")); + } + else + { + items.Add(new LauncherItem($"알 수 없는 서브커맨드: '{sub}'", + "rand / rand / rand str / rand color / rand dice / rand pick / rand uuid / rand token / rand pin", + 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("Rand", "클립보드에 복사했습니다."); + } + catch { } + } + return Task.CompletedTask; + } + + // ── 헬퍼 ──────────────────────────────────────────────────────────────── + + private static string GetCharset(string name) => name switch + { + "alpha" or "알파" => "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", + "num" or "숫자" => "0123456789", + "alnum" or "영숫자" => "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", + "hex" => "0123456789abcdef", + "lower" => "abcdefghijklmnopqrstuvwxyz", + "upper" => "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + "special" => "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*", + _ => "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + }; + + private static string RandStr(int len, string charset) + { + var sb = new StringBuilder(len); + for (int i = 0; i < len; i++) sb.Append(charset[_rng.Next(charset.Length)]); + return sb.ToString(); + } + + private static bool TryParseDice(string s, out int count, out int sides) + { + count = sides = 0; + var d = s.IndexOf('d'); + if (d < 0) return false; + var left = d == 0 ? "1" : s[..d]; + var right = s[(d + 1)..]; + return int.TryParse(left, out count) && count >= 1 && count <= 100 && + int.TryParse(right, out sides) && sides >= 2 && sides <= 10000; + } + + private static string RgbToHsl(int r, int g, int b) + { + var rf = r / 255.0; var gf = g / 255.0; var bf = b / 255.0; + var max = Math.Max(rf, Math.Max(gf, bf)); + var min = Math.Min(rf, Math.Min(gf, bf)); + var l = (max + min) / 2; + if (max == min) return $"hsl(0, 0%, {l:P0})"; + var d = max - min; + var s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + var h = max == rf ? (gf - bf) / d + (gf < bf ? 6 : 0) + : max == gf ? (bf - rf) / d + 2 + : (rf - gf) / d + 4; + h /= 6; + return $"hsl({h * 360:F0}, {s:P0}, {l:P0})"; + } + + private static LauncherItem CopyItem(string label, string value) => + new(label, value, null, ("copy", value), Symbol: "\uE8D0"); + + private static LauncherItem ErrorItem(string msg) => + new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783"); +} diff --git a/src/AxCopilot/Handlers/StrHandler.cs b/src/AxCopilot/Handlers/StrHandler.cs new file mode 100644 index 0000000..6b29b5f --- /dev/null +++ b/src/AxCopilot/Handlers/StrHandler.cs @@ -0,0 +1,459 @@ +using System.Text; +using System.Text.RegularExpressions; +using System.Web; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L20-3: 문자열 조작 도구 핸들러. "str" 프리픽스로 사용합니다. +/// +/// 예: str → 클립보드 텍스트 조작 메뉴 +/// str escape html → HTML 특수문자 이스케이프 +/// str unescape html → HTML 이스케이프 해제 +/// str escape url → URL 인코딩 (퍼센트) +/// str unescape url → URL 디코딩 +/// str escape json → JSON 문자열 이스케이프 +/// str escape regex → 정규식 이스케이프 +/// str repeat 3 → 클립보드 텍스트 3회 반복 +/// str repeat 5 , → 쉼표 구분 5회 반복 +/// str pad 20 → 20자 우측 공백 패딩 +/// str pad 20 left → 좌측 패딩 +/// str pad 20 * right → 지정 문자로 우측 패딩 +/// str wrap 80 → 80자 줄바꿈 +/// str lines → 줄 수·단어·문자 통계 +/// str sort → 줄 정렬 (오름차순) +/// str sort desc → 줄 정렬 (내림차순) +/// str unique → 중복 줄 제거 +/// str join , → 여러 줄 → 쉼표 구분 한 줄 +/// str split , → 쉼표 구분 → 여러 줄 +/// str replace a b → 텍스트 내 a를 b로 교체 +/// str extract email → 이메일 주소 추출 +/// str extract url → URL 추출 +/// str extract number → 숫자 추출 +/// Enter → 결과 복사. +/// +public partial class StrHandler : IActionHandler +{ + public string? Prefix => "str"; + + public PluginMetadata Metadata => new( + "Str", + "문자열 조작 도구 — HTML/URL/JSON 이스케이프·반복·패딩·줄 정렬·추출", + "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("문자열 조작 도구", + "str escape/unescape / str repeat / str pad / str sort / str extract …", + null, null, Symbol: "\uE8AB")); + if (!string.IsNullOrWhiteSpace(clipboard)) + { + var preview = clipboard!.Length > 40 ? clipboard[..40] + "…" : clipboard; + items.Add(new LauncherItem($"클립보드: \"{preview}\"", + $"{clipboard.Length}자 · 아래 서브커맨드로 조작", null, null, Symbol: "\uE8AB")); + BuildQuickMenu(items, clipboard!); + } + else + { + items.Add(new LauncherItem("클립보드가 비어 있습니다", "텍스트를 복사한 뒤 사용하세요", + null, null, Symbol: "\uE946")); + } + return Task.FromResult>(items); + } + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + var text = parts.Length > 1 + ? string.Join(" ", parts[1..]) // 인라인 텍스트 (없으면 클립보드) + : clipboard ?? ""; + + // escape / unescape + if (sub is "escape" or "esc" or "unescape" or "unesc") + { + var isEscape = sub is "escape" or "esc"; + var target = parts.Length >= 2 ? parts[1].ToLowerInvariant() : "html"; + var src = parts.Length >= 3 ? string.Join(" ", parts[2..]) : clipboard ?? ""; + + if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("처리할 텍스트가 없습니다")); } + else + { + var (html, url, json, reg) = ( + isEscape ? HtmlEncode(src) : HtmlDecode(src), + isEscape ? Uri.EscapeDataString(src) : Uri.UnescapeDataString(src), + isEscape ? JsonEscape(src) : JsonUnescape(src), + isEscape ? RegexEscape(src) : src + ); + var label = isEscape ? "이스케이프" : "이스케이프 해제"; + items.Add(new LauncherItem($"── {label} ──", "", null, null, Symbol: "\uE8AB")); + items.Add(CopyItem("HTML", isEscape ? HtmlEncode(src) : HtmlDecode(src))); + items.Add(CopyItem("URL", isEscape ? Uri.EscapeDataString(src) : Uri.UnescapeDataString(src))); + items.Add(CopyItem("JSON", isEscape ? JsonEscape(src) : JsonUnescape(src))); + if (isEscape) items.Add(CopyItem("Regex", RegexEscape(src))); + } + return Task.FromResult>(items); + } + + // repeat + if (sub == "repeat") + { + int count = 3; + string sep = ""; + string src = clipboard ?? ""; + + if (parts.Length >= 2 && int.TryParse(parts[1], out var n)) count = Math.Clamp(n, 1, 100); + if (parts.Length >= 3) sep = parts[2] == "\\n" ? "\n" : parts[2] == "\\t" ? "\t" : parts[2]; + if (parts.Length >= 4) src = string.Join(" ", parts[3..]); + + if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("반복할 텍스트가 없습니다")); } + else + { + var result = string.Join(sep, Enumerable.Repeat(src, count)); + items.Add(new LauncherItem(result.Length > 60 ? result[..60] + "…" : result, + $"{count}회 반복 · Enter 복사", null, ("copy", result), Symbol: "\uE8AB")); + items.Add(CopyItem("결과", result)); + items.Add(CopyItem("결과 길이", $"{result.Length}자")); + } + return Task.FromResult>(items); + } + + // pad + if (sub == "pad") + { + if (parts.Length < 2 || !int.TryParse(parts[1], out var width)) + { items.Add(ErrorItem("예: str pad 20 / str pad 20 left / str pad 20 * right")); } + else + { + var side = parts.Length >= 3 ? parts[2].ToLowerInvariant() : "right"; + var padChar = ' '; + if (parts.Length >= 4 && parts[2].Length == 1) { padChar = parts[2][0]; side = parts[3].ToLowerInvariant(); } + if (parts.Length >= 3 && parts[2].Length == 1 && !"left right both".Contains(parts[2])) padChar = parts[2][0]; + + var src = clipboard ?? ""; + if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("클립보드에 텍스트가 없습니다")); } + else + { + var result = side switch + { + "left" => src.PadLeft(width, padChar), + "both" => src.PadLeft((src.Length + width) / 2, padChar).PadRight(width, padChar), + _ => src.PadRight(width, padChar) + }; + items.Add(new LauncherItem($"\"{result}\"", $"{side} 패딩 {width}자 · Enter 복사", + null, ("copy", result), Symbol: "\uE8AB")); + items.Add(CopyItem("결과", result)); + } + } + return Task.FromResult>(items); + } + + // wrap + if (sub == "wrap") + { + if (parts.Length < 2 || !int.TryParse(parts[1], out var cols)) + { items.Add(ErrorItem("예: str wrap 80")); } + else + { + var src = parts.Length >= 3 ? string.Join(" ", parts[2..]) : clipboard ?? ""; + if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("줄바꿈할 텍스트가 없습니다")); } + else + { + var result = WordWrap(src, cols); + var preview = result.Split('\n').Take(5); + items.Add(new LauncherItem($"{cols}자 줄바꿈", + $"{result.Split('\n').Length}줄 · Enter 복사", null, ("copy", result), Symbol: "\uE8AB")); + foreach (var line in preview) + items.Add(new LauncherItem(line.Length > 60 ? line[..60] + "…" : line, "", + null, null, Symbol: "\uE8AB")); + } + } + return Task.FromResult>(items); + } + + // sort + if (sub == "sort") + { + var src = parts.Length >= 3 ? string.Join(" ", parts[2..]) : clipboard ?? ""; + var desc = parts.Length >= 2 && parts[1].ToLowerInvariant() is "desc" or "d"; + if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("정렬할 텍스트가 없습니다")); } + else + { + var lines = src.Split('\n', StringSplitOptions.RemoveEmptyEntries); + var sorted = desc ? lines.OrderByDescending(l => l).ToArray() + : lines.OrderBy(l => l).ToArray(); + var result = string.Join("\n", sorted); + items.Add(new LauncherItem($"{sorted.Length}줄 {(desc ? "내림차순" : "오름차순")} 정렬", + "Enter 복사", null, ("copy", result), Symbol: "\uE8AB")); + foreach (var line in sorted.Take(6)) + items.Add(new LauncherItem(line.Length > 60 ? line[..60] : line, "", + null, null, Symbol: "\uE8AB")); + } + return Task.FromResult>(items); + } + + // unique + if (sub is "unique" or "dedup") + { + var src = parts.Length >= 2 ? string.Join(" ", parts[1..]) : clipboard ?? ""; + if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("중복 제거할 텍스트가 없습니다")); } + else + { + var lines = src.Split('\n', StringSplitOptions.RemoveEmptyEntries); + var unique = lines.Distinct(StringComparer.Ordinal).ToArray(); + var result = string.Join("\n", unique); + items.Add(new LauncherItem($"{lines.Length}줄 → {unique.Length}줄 (중복 {lines.Length - unique.Length}개 제거)", + "Enter 복사", null, ("copy", result), Symbol: "\uE8AB")); + foreach (var line in unique.Take(6)) + items.Add(new LauncherItem(line.Length > 60 ? line[..60] : line, "", + null, null, Symbol: "\uE8AB")); + } + return Task.FromResult>(items); + } + + // join + if (sub == "join") + { + var sep = parts.Length >= 2 ? parts[1] : ","; + if (sep == "\\n") sep = "\n"; else if (sep == "\\t") sep = "\t"; + var src = clipboard ?? ""; + if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("연결할 텍스트가 없습니다")); } + else + { + var lines = src.Split('\n', StringSplitOptions.RemoveEmptyEntries); + var result = string.Join(sep, lines); + items.Add(new LauncherItem(result.Length > 60 ? result[..60] + "…" : result, + $"{lines.Length}줄 연결 · Enter 복사", null, ("copy", result), Symbol: "\uE8AB")); + items.Add(CopyItem("결과", result)); + } + return Task.FromResult>(items); + } + + // split + if (sub == "split") + { + var sep = parts.Length >= 2 ? parts[1] : ","; + if (sep == "\\n") sep = "\n"; else if (sep == "\\t") sep = "\t"; + var src = parts.Length >= 3 ? string.Join(" ", parts[2..]) : clipboard ?? ""; + if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("분리할 텍스트가 없습니다")); } + else + { + var splitted = src.Split(sep, StringSplitOptions.None); + var result = string.Join("\n", splitted); + items.Add(new LauncherItem($"'{sep}'로 분리 → {splitted.Length}개", + "Enter 복사", null, ("copy", result), Symbol: "\uE8AB")); + foreach (var item in splitted.Take(8)) + items.Add(new LauncherItem(item.Length > 60 ? item[..60] : item, "", + null, null, Symbol: "\uE8AB")); + } + return Task.FromResult>(items); + } + + // replace + if (sub == "replace") + { + if (parts.Length < 3) { items.Add(ErrorItem("예: str replace 찾을텍스트 바꿀텍스트")); } + else + { + var from = parts[1]; + var to = parts[2]; + var src = parts.Length >= 4 ? string.Join(" ", parts[3..]) : clipboard ?? ""; + if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("처리할 텍스트가 없습니다")); } + else + { + var count = 0; + var result = ReplaceCount(src, from, to, out count); + items.Add(new LauncherItem($"'{from}' → '{to}' ({count}개 교체)", + "Enter 복사", null, ("copy", result), Symbol: "\uE8AB")); + items.Add(CopyItem("결과", result)); + } + } + return Task.FromResult>(items); + } + + // extract + if (sub == "extract") + { + var target = parts.Length >= 2 ? parts[1].ToLowerInvariant() : "url"; + var src = parts.Length >= 3 ? string.Join(" ", parts[2..]) : clipboard ?? ""; + if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("추출할 텍스트가 없습니다")); } + else + { + var matches = target switch + { + "email" => EmailRegex().Matches(src).Select(m => m.Value).Distinct().ToList(), + "url" => UrlRegex().Matches(src).Select(m => m.Value).Distinct().ToList(), + "num" or "number" or "숫자" => + NumberRegex().Matches(src).Select(m => m.Value).ToList(), + "ip" => IpRegex().Matches(src).Select(m => m.Value).Distinct().ToList(), + _ => new List() + }; + if (matches.Count == 0) + items.Add(new LauncherItem($"'{target}' 패턴 없음", src.Length > 40 ? src[..40] : src, + null, null, Symbol: "\uE8AB")); + else + { + items.Add(new LauncherItem($"{target} {matches.Count}개 추출", + "Enter로 전체 복사", null, ("copy", string.Join("\n", matches)), Symbol: "\uE8AB")); + foreach (var m in matches.Take(10)) + items.Add(new LauncherItem(m, "", null, ("copy", m), Symbol: "\uE8AB")); + } + } + return Task.FromResult>(items); + } + + // lines + if (sub is "lines" or "info" or "count") + { + var src = clipboard ?? ""; + if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("분석할 텍스트가 없습니다")); } + else + { + var lineArr = src.Split('\n'); + var words = src.Split(new[] {' ','\t','\n','\r'}, StringSplitOptions.RemoveEmptyEntries); + items.Add(new LauncherItem($"{lineArr.Length}줄 · {words.Length}단어 · {src.Length}자", + "텍스트 분석", null, null, Symbol: "\uE8AB")); + items.Add(CopyItem("전체 줄 수", lineArr.Length.ToString())); + items.Add(CopyItem("빈 줄 수", lineArr.Count(l => string.IsNullOrWhiteSpace(l)).ToString())); + items.Add(CopyItem("단어 수", words.Length.ToString())); + items.Add(CopyItem("전체 문자 수", src.Length.ToString())); + items.Add(CopyItem("공백 제외 문자", src.Count(c => !char.IsWhiteSpace(c)).ToString())); + items.Add(CopyItem("바이트 (UTF-8)", Encoding.UTF8.GetByteCount(src).ToString())); + } + return Task.FromResult>(items); + } + + // 알 수 없는 커맨드 + items.Add(new LauncherItem($"알 수 없는 서브커맨드: '{sub}'", + "escape · unescape · repeat · pad · wrap · sort · unique · join · split · replace · extract · lines", + null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + if (item.Data is ("copy", string text)) + { + try + { + System.Windows.Application.Current.Dispatcher.Invoke( + () => Clipboard.SetText(text)); + NotificationService.Notify("Str", "클립보드에 복사했습니다."); + } + catch { } + } + return Task.CompletedTask; + } + + // ── 헬퍼 ──────────────────────────────────────────────────────────────── + + private static void BuildQuickMenu(List items, string src) + { + items.Add(CopyItem("HTML 이스케이프", HtmlEncode(src))); + items.Add(CopyItem("URL 인코딩", Uri.EscapeDataString(src))); + items.Add(CopyItem("JSON 이스케이프", JsonEscape(src))); + } + + private static string HtmlEncode(string s) => s + .Replace("&", "&").Replace("<", "<").Replace(">", ">") + .Replace("\"", """).Replace("'", "'"); + + private static string HtmlDecode(string s) => + HttpUtility.HtmlDecode(s); + + private static string JsonEscape(string s) + { + var sb = new StringBuilder(); + foreach (var c in s) + { + switch (c) + { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + default: + if (c < 0x20) sb.Append($"\\u{(int)c:X4}"); + else sb.Append(c); + break; + } + } + return sb.ToString(); + } + + private static string JsonUnescape(string s) => + s.Replace("\\\"", "\"").Replace("\\\\", "\\") + .Replace("\\n", "\n").Replace("\\r", "\r").Replace("\\t", "\t"); + + private static string RegexEscape(string s) => Regex.Escape(s); + + private static string WordWrap(string text, int cols) + { + var words = text.Split(' '); + var sb = new StringBuilder(); + int colPos = 0; + foreach (var word in words) + { + if (colPos + word.Length + 1 > cols && colPos > 0) { sb.Append('\n'); colPos = 0; } + else if (colPos > 0) { sb.Append(' '); colPos++; } + sb.Append(word); + colPos += word.Length; + } + return sb.ToString(); + } + + private static string ReplaceCount(string src, string from, string to, out int count) + { + count = 0; + var sb = new StringBuilder(); + int pos = 0; + while (true) + { + var idx = src.IndexOf(from, pos, StringComparison.Ordinal); + if (idx < 0) { sb.Append(src[pos..]); break; } + sb.Append(src[pos..idx]); + sb.Append(to); + count++; + pos = idx + from.Length; + } + return sb.ToString(); + } + + private static LauncherItem CopyItem(string label, string value) => + new(label, value, null, ("copy", value), Symbol: "\uE8AB"); + + private static LauncherItem ErrorItem(string msg) => + new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783"); + + [GeneratedRegex(@"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}")] + private static partial Regex EmailRegex(); + + [GeneratedRegex(@"https?://[^\s""'<>]+")] + private static partial Regex UrlRegex(); + + [GeneratedRegex(@"-?\d+(\.\d+)?")] + private static partial Regex NumberRegex(); + + [GeneratedRegex(@"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b")] + private static partial Regex IpRegex(); +}