using System.Text; using System.Text.RegularExpressions; using System.Windows; using AxCopilot.SDK; using AxCopilot.Services; using AxCopilot.Themes; namespace AxCopilot.Handlers; /// /// L15-4: Markdown 분석기 핸들러. "md" 프리픽스로 사용합니다. /// /// 예: md → 클립보드 Markdown 분석 (구조·통계) /// md toc → 목차(TOC) 생성 /// md strip → Markdown 기호 제거 → 순수 텍스트 /// md count → 단어·줄·코드블록 수 세기 /// md links → 링크 목록 추출 /// md images → 이미지 목록 추출 /// Enter → 결과를 클립보드에 복사. /// public partial class MdHandler : IActionHandler { public string? Prefix => "md"; public PluginMetadata Metadata => new( "MD", "Markdown 분석기 — 구조 분석 · TOC · 기호 제거 · 링크 추출", "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("Markdown 분석기", "클립보드 Markdown 분석 · md toc / strip / count / links / images", null, null, Symbol: "\uE8A5")); items.Add(new LauncherItem("md toc", "목차(TOC) 생성", null, null, Symbol: "\uE8A5")); items.Add(new LauncherItem("md strip", "Markdown 기호 제거", null, null, Symbol: "\uE8A5")); items.Add(new LauncherItem("md count", "단어·줄·코드블록 통계", null, null, Symbol: "\uE8A5")); items.Add(new LauncherItem("md links", "링크 목록 추출", null, null, Symbol: "\uE8A5")); items.Add(new LauncherItem("md images", "이미지 목록 추출", null, null, Symbol: "\uE8A5")); if (string.IsNullOrWhiteSpace(clipboard)) { items.Add(new LauncherItem("클립보드가 비어 있습니다", "Markdown 텍스트를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946")); return Task.FromResult>(items); } // 간단 미리보기 통계 var stat = QuickStat(clipboard); items.Add(new LauncherItem("── 클립보드 미리보기 ──", "", null, null, Symbol: "\uE8A5")); items.Add(new LauncherItem("클립보드 Markdown 분석", stat, null, ("copy", stat), Symbol: "\uE8A5")); return Task.FromResult>(items); } // 클립보드 없으면 서브커맨드도 안내만 if (string.IsNullOrWhiteSpace(clipboard)) { items.Add(new LauncherItem("클립보드가 비어 있습니다", "Markdown 텍스트를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946")); return Task.FromResult>(items); } var sub = q.Split(' ')[0].ToLowerInvariant(); switch (sub) { case "toc": items.AddRange(BuildTocItems(clipboard)); break; case "strip": case "plain": case "text": items.AddRange(BuildStripItems(clipboard)); break; case "count": case "stat": case "stats": items.AddRange(BuildCountItems(clipboard)); break; case "links": case "link": items.AddRange(BuildLinkItems(clipboard)); break; case "images": case "image": case "img": items.AddRange(BuildImageItems(clipboard)); break; default: items.Add(new LauncherItem("알 수 없는 서브커맨드", "toc · strip · count · links · images", 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("MD", "클립보드에 복사했습니다."); } catch { /* 비핵심 */ } } return Task.CompletedTask; } // ── 분석 빌더 ───────────────────────────────────────────────────────────── private static string QuickStat(string md) { var lines = md.Split('\n'); var headings = lines.Count(l => HeadingRegex().IsMatch(l)); var codeBlocks = CountCodeBlocks(md); var links = LinkRegex().Matches(md).Count; var words = WordCount(md); return $"{lines.Length}줄 · 제목 {headings}개 · 코드블록 {codeBlocks}개 · 링크 {links}개 · 단어 {words}개"; } private static List BuildTocItems(string md) { var items = new List(); var lines = md.Split('\n'); var headings = new List<(int Level, string Text, string Anchor)>(); foreach (var line in lines) { var m = HeadingRegex().Match(line); if (!m.Success) continue; var level = m.Groups[1].Value.Length; var text = m.Groups[2].Value.Trim(); var anchor = MakeAnchor(text); headings.Add((level, text, anchor)); } if (headings.Count == 0) { items.Add(new LauncherItem("제목(#)이 없습니다", "Markdown 제목이 없으면 TOC를 생성할 수 없습니다", null, null, Symbol: "\uE946")); return items; } var sb = new StringBuilder(); foreach (var (level, text, anchor) in headings) { var indent = new string(' ', (level - 1) * 2); sb.AppendLine($"{indent}- [{text}](#{anchor})"); } var toc = sb.ToString().TrimEnd(); items.Add(new LauncherItem($"TOC 생성 완료 ({headings.Count}개 제목)", "Enter → 전체 TOC 복사", null, ("copy", toc), Symbol: "\uE8A5")); foreach (var (level, text, anchor) in headings.Take(20)) { var prefix = new string('#', level) + " "; var entry = $"- [{text}](#{anchor})"; items.Add(new LauncherItem($"{prefix}{text}", entry, null, ("copy", entry), Symbol: "\uE8A5")); } if (headings.Count > 20) items.Add(new LauncherItem($"… 외 {headings.Count - 20}개", "전체 복사는 첫 항목 Enter", null, null, Symbol: "\uE8A5")); return items; } private static List BuildStripItems(string md) { var items = new List(); var plain = StripMarkdown(md); var preview = plain.Length > 80 ? plain[..80] + "…" : plain; items.Add(new LauncherItem("Markdown 기호 제거 완료", $"Enter → 순수 텍스트 복사 ({plain.Length}자)", null, ("copy", plain), Symbol: "\uE8A5")); items.Add(new LauncherItem("미리보기", preview, null, ("copy", plain), Symbol: "\uE8A5")); return items; } private static List BuildCountItems(string md) { var items = new List(); var lines = md.Split('\n'); var totalLines = lines.Length; var blankLines = lines.Count(l => string.IsNullOrWhiteSpace(l)); var codeBlockCount= CountCodeBlocks(md); var headingCount = lines.Count(l => HeadingRegex().IsMatch(l)); var listCount = lines.Count(l => ListRegex().IsMatch(l)); var linkCount = LinkRegex().Matches(md).Count; var imageCount = ImageRegex().Matches(md).Count; var boldCount = BoldRegex().Matches(md).Count; var words = WordCount(md); var chars = md.Length; var charsNoSpace = md.Replace(" ", "").Replace("\n", "").Replace("\r", "").Length; items.Add(new LauncherItem($"Markdown 통계", $"{totalLines}줄 · {words}단어 · {chars}자", null, ("copy", $"줄 {totalLines} · 단어 {words} · 문자 {chars}"), Symbol: "\uE8A5")); items.Add(new LauncherItem("전체 줄 수", $"{totalLines}줄 (공백 {blankLines}줄)", null, ("copy", $"{totalLines}"), Symbol: "\uE8A5")); items.Add(new LauncherItem("단어 수", $"{words}단어", null, ("copy", $"{words}"), Symbol: "\uE8A5")); items.Add(new LauncherItem("문자 수", $"{chars}자 (공백 제외 {charsNoSpace}자)", null, ("copy", $"{chars}"), Symbol: "\uE8A5")); items.Add(new LauncherItem("제목(#) 수", $"{headingCount}개", null, ("copy", $"{headingCount}"), Symbol: "\uE8A5")); items.Add(new LauncherItem("코드 블록 수", $"{codeBlockCount}개", null, ("copy", $"{codeBlockCount}"), Symbol: "\uE8A5")); items.Add(new LauncherItem("목록 항목 수", $"{listCount}개", null, ("copy", $"{listCount}"), Symbol: "\uE8A5")); items.Add(new LauncherItem("링크 수", $"{linkCount}개", null, ("copy", $"{linkCount}"), Symbol: "\uE8A5")); items.Add(new LauncherItem("이미지 수", $"{imageCount}개", null, ("copy", $"{imageCount}"), Symbol: "\uE8A5")); items.Add(new LauncherItem("강조(**bold**) 수", $"{boldCount}개", null, ("copy", $"{boldCount}"), Symbol: "\uE8A5")); return items; } private static List BuildLinkItems(string md) { var items = new List(); var matches = LinkRegex().Matches(md); if (matches.Count == 0) { items.Add(new LauncherItem("링크 없음", "클립보드 Markdown에 링크([text](url))가 없습니다", null, null, Symbol: "\uE946")); return items; } var allUrls = string.Join("\n", matches.Cast().Select(m => m.Groups[2].Value)); items.Add(new LauncherItem($"링크 {matches.Count}개 발견", "Enter → 전체 URL 목록 복사", null, ("copy", allUrls), Symbol: "\uE8A5")); foreach (Match m in matches.Cast().Take(25)) { var text = m.Groups[1].Value; var url = m.Groups[2].Value; var display = text.Length > 30 ? text[..30] + "…" : text; items.Add(new LauncherItem(display, url, null, ("copy", url), Symbol: "\uE8A5")); } if (matches.Count > 25) items.Add(new LauncherItem($"… 외 {matches.Count - 25}개", "전체 복사는 첫 항목 Enter", null, null, Symbol: "\uE8A5")); return items; } private static List BuildImageItems(string md) { var items = new List(); var matches = ImageRegex().Matches(md); if (matches.Count == 0) { items.Add(new LauncherItem("이미지 없음", "클립보드 Markdown에 이미지(![alt](url))가 없습니다", null, null, Symbol: "\uE946")); return items; } var allUrls = string.Join("\n", matches.Cast().Select(m => m.Groups[2].Value)); items.Add(new LauncherItem($"이미지 {matches.Count}개 발견", "Enter → 전체 URL 목록 복사", null, ("copy", allUrls), Symbol: "\uE8A5")); foreach (Match m in matches.Cast().Take(25)) { var alt = m.Groups[1].Value; var url = m.Groups[2].Value; var display = string.IsNullOrWhiteSpace(alt) ? "(alt 없음)" : (alt.Length > 30 ? alt[..30] + "…" : alt); items.Add(new LauncherItem(display, url, null, ("copy", url), Symbol: "\uE8A5")); } return items; } // ── 헬퍼 ───────────────────────────────────────────────────────────────── private static string StripMarkdown(string md) { var s = md; // 코드 블록 제거 s = Regex.Replace(s, @"```[\s\S]*?```", "", RegexOptions.Multiline); s = Regex.Replace(s, @"`[^`]+`", ""); // 제목 기호 제거 s = Regex.Replace(s, @"^#{1,6}\s+", "", RegexOptions.Multiline); // 이미지, 링크 → 텍스트만 s = Regex.Replace(s, @"!\[([^\]]*)\]\([^\)]*\)", "$1"); s = Regex.Replace(s, @"\[([^\]]+)\]\([^\)]+\)", "$1"); // 강조 기호 제거 s = Regex.Replace(s, @"\*{1,3}([^*]+)\*{1,3}", "$1"); s = Regex.Replace(s, @"_{1,3}([^_]+)_{1,3}", "$1"); // 인용 기호 s = Regex.Replace(s, @"^>\s+", "", RegexOptions.Multiline); // 목록 기호 s = Regex.Replace(s, @"^[\-\*\+]\s+", "", RegexOptions.Multiline); s = Regex.Replace(s, @"^\d+\.\s+", "", RegexOptions.Multiline); // 수평선 s = Regex.Replace(s, @"^[-*_]{3,}\s*$", "", RegexOptions.Multiline); // 다중 공백 정리 s = Regex.Replace(s, @"\n{3,}", "\n\n"); return s.Trim(); } private static string MakeAnchor(string text) { var s = text.ToLowerInvariant(); s = Regex.Replace(s, @"[^\w\s\-가-힣]", ""); s = Regex.Replace(s, @"\s+", "-"); return s; } private static int CountCodeBlocks(string md) { var matches = Regex.Matches(md, @"^```", RegexOptions.Multiline); return matches.Count / 2; } private static int WordCount(string md) { var plain = StripMarkdown(md); return Regex.Matches(plain, @"\S+").Count; } [GeneratedRegex(@"^(#{1,6})\s+(.+)$", RegexOptions.Multiline)] private static partial Regex HeadingRegex(); [GeneratedRegex(@"^\s*[\-\*\+]\s+|^\s*\d+\.\s+", RegexOptions.Multiline)] private static partial Regex ListRegex(); [GeneratedRegex(@"\[([^\]]+)\]\(([^\)]+)\)")] private static partial Regex LinkRegex(); [GeneratedRegex(@"!\[([^\]]*)\]\(([^\)]+)\)")] private static partial Regex ImageRegex(); [GeneratedRegex(@"\*{2,3}[^*]+\*{2,3}")] private static partial Regex BoldRegex(); }