Files
AX-Copilot-Codex/src/AxCopilot/Handlers/MdHandler.cs
lacvet 0336904258 AX Commander 비교본 런처 기능 대량 이식
변경 목적: Agent Compare 아래 비교본의 개발 문서와 런처 소스를 기준으로 현재 AX Commander에 빠져 있던 신규 런처 기능을 동일한 흐름으로 옮겨, 비교본 수준의 기능 폭을 현재 제품에 반영했습니다.

핵심 수정사항: 비교본의 신규 런처 핸들러 다수를 src/AxCopilot/Handlers로 이식하고 App.xaml.cs 등록 흐름에 연결했습니다. 빠른 링크, 파일 태그, 알림 센터, 포모도로, 파일 브라우저, 핫키 관리, OCR, 세션/스케줄/매크로, Git/정규식/네트워크/압축/해시/UUID/JWT/QR 등 AX Commander 기능을 추가했습니다.

핵심 수정사항: 신규 기능이 실제 동작하도록 AppSettings 확장, SchedulerService/FileTagService/NotificationCenterService/IconCacheService/UrlTemplateEngine/PomodoroService 추가, 배치 이름변경/세션/스케줄/매크로 편집 창 추가, NotificationService와 Symbols 보강, QR/OCR용 csproj 의존성과 Windows 타겟 프레임워크를 반영했습니다.

문서 반영: README.md와 docs/DEVELOPMENT.md에 비교본 기반 런처 기능 이식 이력과 검증 결과를 업데이트했습니다.

검증 결과: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 실행 기준 경고 0개, 오류 0개를 확인했습니다.
2026-04-05 00:59:45 +09:00

357 lines
15 KiB
C#

using System.Text;
using System.Text.RegularExpressions;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L15-4: Markdown 분석기 핸들러. "md" 프리픽스로 사용합니다.
///
/// 예: md → 클립보드 Markdown 분석 (구조·통계)
/// md toc → 목차(TOC) 생성
/// md strip → Markdown 기호 제거 → 순수 텍스트
/// md count → 단어·줄·코드블록 수 세기
/// md links → 링크 목록 추출
/// md images → 이미지 목록 추출
/// Enter → 결과를 클립보드에 복사.
/// </summary>
public partial class MdHandler : IActionHandler
{
public string? Prefix => "md";
public PluginMetadata Metadata => new(
"MD",
"Markdown 분석기 — 구조 분석 · TOC · 기호 제거 · 링크 추출",
"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("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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
}
// 클립보드 없으면 서브커맨드도 안내만
if (string.IsNullOrWhiteSpace(clipboard))
{
items.Add(new LauncherItem("클립보드가 비어 있습니다",
"Markdown 텍스트를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946"));
return Task.FromResult<IEnumerable<LauncherItem>>(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<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("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<LauncherItem> BuildTocItems(string md)
{
var items = new List<LauncherItem>();
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<LauncherItem> BuildStripItems(string md)
{
var items = new List<LauncherItem>();
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<LauncherItem> BuildCountItems(string md)
{
var items = new List<LauncherItem>();
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<LauncherItem> BuildLinkItems(string md)
{
var items = new List<LauncherItem>();
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<Match>().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<Match>().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<LauncherItem> BuildImageItems(string md)
{
var items = new List<LauncherItem>();
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<Match>().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<Match>().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();
}