[Phase L20] Hex·Rand·Str·Perm 핸들러 4종 추가

HexHandler (prefix=hex, 235줄):
- 텍스트→hex 변환(공백·소문자·0x접두사·Base64)
- 순수 hex 문자열→UTF-8/ASCII 자동 디코딩·정수 해석(big-endian)
- hex dump: 오프셋+hex+ASCII 16바이트 단위 덤프
- 0xFF 단일 값→10진/8진/이진/ASCII/NOT 변환
- add/xor/and/or/not 비트 연산
- bytes <n> KB/KiB/MB/MiB/GB/GiB 크기 단위 변환
- CS1631 catch-yield 패턴(parseError 변수 분리) 적용

RandHandler (prefix=rand, 220줄):
- rand / rand <max> / rand <min> <max> 숫자 생성
- rand str [len] [charset]: alpha/num/alnum/hex/special 문자셋
- rand color: HEX+RGB+HSL 랜덤 색상 5개
- rand dice [NdS]: 다면체 주사위, 합계·상세·최솟값·최댓값
- rand coin: 동전 던지기 + 연속 5회
- rand pick/shuffle: 항목 선택·피셔-예이츠 셔플
- rand uuid: Guid.NewGuid() UUID v4
- rand token: RandomNumberGenerator 보안 토큰(hex+Base64)
- rand pin [len]: PIN 번호 생성

StrHandler (prefix=str, 295줄, partial class):
- escape/unescape: HTML·URL·JSON·Regex 이스케이프 변환
- repeat <n> [sep]: 텍스트 반복
- pad <w> [side] [char]: 좌/우/양쪽 패딩
- wrap <cols>: 단어 단위 줄바꿈
- sort [desc]: 줄 정렬
- unique: 중복 줄 제거
- join/split: 구분자 변환
- replace <from> <to>: 텍스트 치환
- extract email/url/number/ip: [GeneratedRegex] 패턴 추출
- lines: 줄·단어·문자·바이트 통계

PermHandler (prefix=perm, 265줄):
- 8진수(755)→기호(rwxr-xr-x)·상세 설명·용도·관련 권한
- 기호→8진수 역변환
- +x/-x/+w/-w/+r/-r 비트 수정 연산
- umask 022 → 파일/디렉토리 기본 권한 계산
- common: 14가지 자주 쓰는 권한 전체 목록
- CS8361 삼항연산자 보간 괄호 수정

- 빌드: 경고 0, 오류 0
This commit is contained in:
2026-04-04 16:34:06 +09:00
parent 498ae50785
commit 5c6a1b645d
6 changed files with 1396 additions and 0 deletions

View File

@@ -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 <id>` 특정 타이머 취소. 정적 타이머 레지스트리로 복수 타이머 동시 운영. `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 <start> <end>` IP 범위 계산. `ip bin/hex/int` 표현 변환. `ip from <uint>` 정수→IP | 높음 |
| L19-4 | **npm/yarn/pnpm 명령어 생성기** ✅ | `npm` 프리픽스. `npm install <pkg>` → 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 <text>` 오프셋+hex+ASCII 형식 16줄 단위 덤프. `hex 0xFF` 단일 hex값→10진/8진/이진/ASCII. `hex add/xor/and/or/not` 비트 연산. `hex bytes <n>` KB/MiB/GB 크기 단위 변환. CS1631 catch-yield 패턴 적용 | 높음 |
| L20-2 | **랜덤 생성기** ✅ | `rand` 프리픽스. 기본: 1~100 난수. `rand <max>` / `rand <min> <max>`. `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 <n> [sep]` 반복. `str pad <w> [left/right/both] [char]` 패딩. `str wrap <cols>` 단어 단위 줄바꿈. `str sort [desc]` 줄 정렬. `str unique` 중복 제거. `str join/split <sep>` 구분자 변환. `str replace <from> <to>` 치환. `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 명령 자동 생성 | 높음 |

View File

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

View File

@@ -0,0 +1,315 @@
using System.Text;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 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> → n바이트 크기 단위 표시 (KB·MB·GB)
/// Enter → 결과 복사.
/// </summary>
public class HexHandler : IActionHandler
{
public string? Prefix => "hex";
public PluginMetadata Metadata => new(
"Hex",
"16진수·바이트 변환기 — 텍스트↔hex·덤프·비트연산·크기 단위",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
// 클립보드 읽기
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 <hexstr> / hex dump <text> / hex 0xFF / hex bytes <n>",
null, null, Symbol: "\uE8EF"));
if (!string.IsNullOrWhiteSpace(clipboard))
items.AddRange(BuildFromText(clipboard!, brief: true));
return Task.FromResult<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
}
// hex bytes <n>
if (sub == "bytes" && parts.Length >= 2 &&
long.TryParse(parts[1], out var byteCount))
{
items.AddRange(BuildByteSize(byteCount));
return Task.FromResult<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
}
// 단일 hex 값 (0xFF, 0xAB, FF, AB...)
if (TryParseHexValue(parts[0], out var hexVal))
{
items.AddRange(BuildFromHexValue(hexVal, parts[0]));
return Task.FromResult<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
}
// 일반 텍스트 → hex 변환
var input = string.Join(" ", parts);
items.AddRange(BuildFromText(input, brief: false));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Hex", "클립보드에 복사했습니다.");
}
catch { }
}
return Task.CompletedTask;
}
// ── 빌더 ────────────────────────────────────────────────────────────────
private static IEnumerable<LauncherItem> 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<LauncherItem> 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<LauncherItem> 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<LauncherItem> BuildDump(string text)
{
var bytes = Encoding.UTF8.GetBytes(text);
var lines = new List<string>();
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<LauncherItem> 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<LauncherItem> 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");
}

View File

@@ -0,0 +1,277 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 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 → 값 복사.
/// </summary>
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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
}
// 8진수 표기 (755, 644...)
if (TryParseOctal(sub, out var octal))
{
items.AddRange(BuildFromOctal(octal));
return Task.FromResult<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Perm", "클립보드에 복사했습니다.");
}
catch { }
}
return Task.CompletedTask;
}
// ── 빌더 ────────────────────────────────────────────────────────────────
private static IEnumerable<LauncherItem> 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<LauncherItem> BuildFromSymbol(string sym)
{
var octal = SymbolToOctal(sym);
return BuildFromOctal(octal);
}
private static IEnumerable<LauncherItem> 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<LauncherItem> 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<string>();
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");
}

View File

@@ -0,0 +1,324 @@
using System.Text;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 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 → 결과 복사.
/// </summary>
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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
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 <max> / rand <min> <max> / rand str / rand color / rand dice / rand pick …",
null, null, Symbol: "\uE8D0"));
return Task.FromResult<IEnumerable<LauncherItem>>(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 <max> 또는 rand <min> <max>
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 <n> / rand str / rand color / rand dice / rand pick / rand uuid / rand token / rand pin",
null, null, Symbol: "\uE783"));
}
break;
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("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");
}

View File

@@ -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;
/// <summary>
/// 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 → 결과 복사.
/// </summary>
public partial class StrHandler : IActionHandler
{
public string? Prefix => "str";
public PluginMetadata Metadata => new(
"Str",
"문자열 조작 도구 — HTML/URL/JSON 이스케이프·반복·패딩·줄 정렬·추출",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
// 클립보드 읽기
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<string>()
};
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Str", "클립보드에 복사했습니다.");
}
catch { }
}
return Task.CompletedTask;
}
// ── 헬퍼 ────────────────────────────────────────────────────────────────
private static void BuildQuickMenu(List<LauncherItem> 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("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;")
.Replace("\"", "&quot;").Replace("'", "&#39;");
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();
}