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