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개를 확인했습니다.
This commit is contained in:
2026-04-05 00:59:45 +09:00
parent 0929778ca7
commit 0336904258
115 changed files with 30749 additions and 1 deletions

View File

@@ -0,0 +1,539 @@
using System.Text;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
namespace AxCopilot.Handlers;
/// <summary>
/// L23-4: 한/영 타이핑 오류 교정. "fix" 프리픽스로 사용합니다.
///
/// 예: fix gksrmf → 안녕 (영타→한글 변환)
/// fix → 클립보드 텍스트 자동 교정
/// Enter → 교정 결과 클립보드 복사
/// </summary>
public class FixHandler : IActionHandler
{
public string? Prefix => "fix";
public PluginMetadata Metadata => new(
"타이핑 교정",
"영타→한글 변환 — 잘못 입력된 영문 타이핑을 한글로 교정",
"1.0",
"AX");
// ── 두벌식 영→자모 매핑 ──────────────────────────────────────────────────
// 소문자
private static readonly Dictionary<char, char> EngToJamo = new()
{
{'q', 'ㅂ'}, {'w', 'ㅈ'}, {'e', 'ㄷ'}, {'r', 'ㄱ'}, {'t', 'ㅅ'},
{'y', 'ㅛ'}, {'u', 'ㅕ'}, {'i', 'ㅑ'}, {'o', 'ㅐ'}, {'p', 'ㅔ'},
{'a', 'ㅁ'}, {'s', 'ㄴ'}, {'d', 'ㅇ'}, {'f', 'ㄹ'}, {'g', 'ㅎ'},
{'h', 'ㅗ'}, {'j', 'ㅓ'}, {'k', 'ㅏ'}, {'l', 'ㅣ'},
{'z', 'ㅋ'}, {'x', 'ㅌ'}, {'c', 'ㅊ'}, {'v', 'ㅍ'},
{'b', 'ㅠ'}, {'n', 'ㅜ'}, {'m', 'ㅡ'},
// 대문자 = 소문자와 동일 기본 (Shift 된소리/쌍모음 별도)
{'Q', 'ㅃ'}, {'W', 'ㅉ'}, {'E', 'ㄸ'}, {'R', 'ㄲ'}, {'T', 'ㅆ'},
{'Y', 'ㅛ'}, {'U', 'ㅕ'}, {'I', 'ㅑ'}, {'O', 'ㅒ'}, {'P', 'ㅖ'},
{'A', 'ㅁ'}, {'S', 'ㄴ'}, {'D', 'ㅇ'}, {'F', 'ㄹ'}, {'G', 'ㅎ'},
{'H', 'ㅗ'}, {'J', 'ㅓ'}, {'K', 'ㅏ'}, {'L', 'ㅣ'},
{'Z', 'ㅋ'}, {'X', 'ㅌ'}, {'C', 'ㅊ'}, {'V', 'ㅍ'},
{'B', 'ㅠ'}, {'N', 'ㅜ'}, {'M', 'ㅡ'},
};
// ── 초성 배열 (19개) ──────────────────────────────────────────────────────
private static readonly char[] Choseong =
['ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ','ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'];
// ── 중성 배열 (21개) ──────────────────────────────────────────────────────
private static readonly char[] Jungseong =
['ㅏ','ㅐ','ㅑ','ㅒ','ㅓ','ㅔ','ㅕ','ㅖ','ㅗ','ㅘ','ㅙ','ㅚ','ㅛ','ㅜ','ㅝ','ㅞ','ㅟ','ㅠ','ㅡ','ㅢ','ㅣ'];
// ── 종성 배열 (28개, 0=없음) ─────────────────────────────────────────────
private static readonly char[] Jongseong =
['\0','ㄱ','ㄲ','ㄳ','ㄴ','ㄵ','ㄶ','ㄷ','ㄹ','ㄺ','ㄻ','ㄼ','ㄽ','ㄾ','ㄿ','ㅀ','ㅁ','ㅂ','ㅄ','ㅅ','ㅆ','ㅇ','ㅈ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'];
// ── 복합 모음 ─────────────────────────────────────────────────────────────
private static readonly Dictionary<(char, char), char> CompoundVowel = new()
{
{('ㅗ','ㅏ'), 'ㅘ'}, {('ㅗ','ㅐ'), 'ㅙ'}, {('ㅗ','ㅣ'), 'ㅚ'},
{('ㅜ','ㅓ'), 'ㅝ'}, {('ㅜ','ㅔ'), 'ㅞ'}, {('ㅜ','ㅣ'), 'ㅟ'},
{('ㅡ','ㅣ'), 'ㅢ'},
};
// ── 복합 종성 ─────────────────────────────────────────────────────────────
private static readonly Dictionary<(char, char), char> CompoundJong = new()
{
{('ㄱ','ㅅ'), 'ㄳ'}, {('ㄴ','ㅈ'), 'ㄵ'}, {('ㄴ','ㅎ'), 'ㄶ'},
{('ㄹ','ㄱ'), 'ㄺ'}, {('ㄹ','ㅁ'), 'ㄻ'}, {('ㄹ','ㅂ'), 'ㄼ'},
{('ㄹ','ㅅ'), 'ㄽ'}, {('ㄹ','ㅌ'), 'ㄾ'}, {('ㄹ','ㅍ'), 'ㄿ'},
{('ㄹ','ㅎ'), 'ㅀ'}, {('ㅂ','ㅅ'), 'ㅄ'},
};
// 복합 종성 분리 (모음이 올 때 jong → cho + remain)
private static readonly Dictionary<char, (char First, char Second)> SplitJong = new()
{
{'ㄳ', ('ㄱ','ㅅ')}, {'ㄵ', ('ㄴ','ㅈ')}, {'ㄶ', ('ㄴ','ㅎ')},
{'ㄺ', ('ㄹ','ㄱ')}, {'ㄻ', ('ㄹ','ㅁ')}, {'ㄼ', ('ㄹ','ㅂ')},
{'ㄽ', ('ㄹ','ㅅ')}, {'ㄾ', ('ㄹ','ㅌ')}, {'ㄿ', ('ㄹ','ㅍ')},
{'ㅀ', ('ㄹ','ㅎ')}, {'ㅄ', ('ㅂ','ㅅ')},
};
// ── 인덱스 헬퍼 ──────────────────────────────────────────────────────────
private static int ChoIdx(char c) => Array.IndexOf(Choseong, c);
private static int JungIdx(char c) => Array.IndexOf(Jungseong, c);
private static int JongIdx(char c) => Array.IndexOf(Jongseong, c);
private static bool IsVowel(char jamo) => JungIdx(jamo) >= 0;
private static bool IsConsonant(char jamo) => ChoIdx(jamo) >= 0 || JongIdx(jamo) > 0;
private static char MakeSyllable(int cho, int jung, int jong) =>
(char)(0xAC00 + cho * 21 * 28 + jung * 28 + jong);
// ── 한글 조합기 (두벌식 상태 기계) ───────────────────────────────────────
private sealed class HangulComposer
{
private int _cho = -1;
private int _jung = -1;
private int _jong = -1;
private readonly StringBuilder _sb = new();
public string Result => _sb.ToString();
public void Flush()
{
if (_cho < 0) return;
if (_jung < 0)
{
// 초성만
_sb.Append(Choseong[_cho]);
}
else
{
_sb.Append(MakeSyllable(_cho, _jung, _jong < 0 ? 0 : _jong));
}
_cho = _jung = _jong = -1;
}
public void Feed(char jamo)
{
if (IsVowel(jamo))
{
FeedVowel(jamo);
}
else
{
FeedConsonant(jamo);
}
}
private void FeedVowel(char v)
{
var vi = JungIdx(v);
if (_cho < 0)
{
// 초성 없음 → ㅇ + 모음
_sb.Append(MakeSyllable(ChoIdx('ㅇ'), vi, 0));
return;
}
if (_jung < 0)
{
// 초성만 있음 → 중성 결합
_jung = vi;
return;
}
// 초성+중성 있음
if (_jong < 0)
{
// 복합 모음 시도
var curVowel = Jungseong[_jung];
if (CompoundVowel.TryGetValue((curVowel, v), out var compound))
{
_jung = JungIdx(compound);
}
else
{
// 현재 음절 확정, 새 음절 시작 (ㅇ + 모음)
Flush();
_cho = ChoIdx('ㅇ');
_jung = vi;
}
return;
}
// 초성+중성+종성 있음 → 종성을 새 음절의 초성으로
var jongChar = Jongseong[_jong];
if (SplitJong.TryGetValue(jongChar, out var split))
{
// 복합 종성: 앞 자음은 종성, 뒷 자음은 새 초성
var newCho = ChoIdx(split.Second);
if (newCho < 0) newCho = 0;
var remainJong = JongIdx(split.First);
// 현재 음절 (jong=split.First)
_sb.Append(MakeSyllable(_cho, _jung, remainJong));
_cho = newCho;
_jung = vi;
_jong = -1;
}
else
{
// 단일 종성 → 새 초성으로
var newCho = ChoIdx(jongChar);
if (newCho < 0) newCho = 0;
_sb.Append(MakeSyllable(_cho, _jung, 0));
_cho = newCho;
_jung = vi;
_jong = -1;
}
}
private void FeedConsonant(char c)
{
var ci = ChoIdx(c);
if (_cho < 0)
{
// 처음 자음
_cho = ci >= 0 ? ci : 0;
return;
}
if (_jung < 0)
{
// 초성만 있음 → 이전 초성 출력, 새 초성
_sb.Append(Choseong[_cho]);
_cho = ci >= 0 ? ci : 0;
_jung = -1;
_jong = -1;
return;
}
if (_jong < 0)
{
// 초성+중성 → 종성 후보
_jong = JongIdx(c);
if (_jong < 0) _jong = 0; // 종성에 없는 자음은 그냥 처리
if (_jong == 0)
{
// 종성에 들어갈 수 없는 자음(ㄸ, ㅃ, ㅉ)
Flush();
_cho = ci >= 0 ? ci : 0;
_jung = -1;
_jong = -1;
}
return;
}
// 초성+중성+종성 있음
var curJongChar = Jongseong[_jong];
if (CompoundJong.TryGetValue((curJongChar, c), out var compJ))
{
// 복합 종성 가능
var cji = JongIdx(compJ);
if (cji > 0)
{
_jong = cji;
return;
}
}
// 복합 종성 불가 → 현재 음절 확정, 새 초성
Flush();
_cho = ci >= 0 ? ci : 0;
_jung = -1;
_jong = -1;
}
}
// ── 영타→한글 변환 ────────────────────────────────────────────────────────
private static string EngToKorean(string input)
{
var composer = new HangulComposer();
var sb = new StringBuilder();
foreach (var ch in input)
{
if (EngToJamo.TryGetValue(ch, out var jamo))
{
if (composer.Result.Length > 0 || jamo != '\0')
{
// 현재 변환 중인 조합기에 피드
}
composer.Feed(jamo);
}
else
{
// 변환 불가 문자 → 조합기 플러시 후 그대로
var cur = composer.Result;
// 지금까지 쌓인 결과 덤프
composer.Feed('\0'); // 플러시 트리거 안 됨 → 직접 Flush
// 아래 로직: 조합기는 Flush()로만 비워짐
sb.Append(ch);
}
}
// 위 로직을 단순화: 문자별 처리
return ConvertEngToKor(input);
}
private static string ConvertEngToKor(string input)
{
// 먼저 자모 문자열로 변환
var jamoSeq = new List<char>();
foreach (var ch in input)
{
if (EngToJamo.TryGetValue(ch, out var jamo))
jamoSeq.Add(jamo);
else
jamoSeq.Add(ch); // 변환 불가 문자는 그대로
}
// 자모 시퀀스를 한글 음절로 조합
var result = new StringBuilder();
var i = 0;
while (i < jamoSeq.Count)
{
var ch = jamoSeq[i];
// 변환 불가 문자 (공백, 숫자, 특수문자 등)
if (!IsKorJamo(ch))
{
result.Append(ch);
i++;
continue;
}
// 자모 덩어리 추출
var jamoBlock = new List<char>();
var j = i;
while (j < jamoSeq.Count && IsKorJamo(jamoSeq[j]))
jamoBlock.Add(jamoSeq[j++]);
// 자모 블록을 한글로 조합
result.Append(ComposeHangul(jamoBlock));
i = j;
}
return result.ToString();
}
private static bool IsKorJamo(char c)
{
return (c >= 'ㄱ' && c <= 'ㅎ') || (c >= 'ㅏ' && c <= 'ㅣ');
}
private static string ComposeHangul(List<char> jamos)
{
var sb = new StringBuilder();
var idx = 0;
while (idx < jamos.Count)
{
var c = jamos[idx];
if (IsVowel(c))
{
// 단독 모음 → ㅇ + 모음
sb.Append(MakeSyllable(ChoIdx('ㅇ'), JungIdx(c), 0));
idx++;
continue;
}
// 자음: 초성 후보
var cho = c;
var choI = ChoIdx(cho);
if (choI < 0) { sb.Append(c); idx++; continue; }
idx++;
if (idx >= jamos.Count || IsConsonantOnly(jamos[idx]))
{
// 단독 초성
sb.Append(cho);
continue;
}
// 중성
var v1 = jamos[idx];
var jungI = JungIdx(v1);
if (jungI < 0) { sb.Append(cho); continue; }
idx++;
// 복합 모음 시도
if (idx < jamos.Count && IsVowel(jamos[idx]))
{
if (CompoundVowel.TryGetValue((v1, jamos[idx]), out var cv))
{
jungI = JungIdx(cv);
idx++;
}
}
// 종성 후보
if (idx >= jamos.Count)
{
sb.Append(MakeSyllable(choI, jungI, 0));
continue;
}
var next = jamos[idx];
if (IsVowel(next))
{
sb.Append(MakeSyllable(choI, jungI, 0));
continue;
}
// 종성 자음
var jongI = JongIdx(next);
if (jongI <= 0)
{
sb.Append(MakeSyllable(choI, jungI, 0));
continue;
}
idx++;
// 다음 모음 있으면 종성→초성 이동
if (idx < jamos.Count && IsVowel(jamos[idx]))
{
// 복합 종성 분리 확인
if (idx + 1 < jamos.Count && IsVowel(jamos[idx]))
{
// 분리 없음: next 자음 → 다음 음절 초성
}
sb.Append(MakeSyllable(choI, jungI, 0));
idx--; // next 자음을 다음 루프에서 초성으로 사용
continue;
}
// 복합 종성 시도
if (idx < jamos.Count && !IsVowel(jamos[idx]))
{
var next2 = jamos[idx];
var jongChar = Jongseong[jongI];
if (CompoundJong.TryGetValue((jongChar, next2), out var cj))
{
var cji = JongIdx(cj);
if (cji > 0)
{
// 다음에 모음이 있으면 복합 종성 분리
if (idx + 1 < jamos.Count && IsVowel(jamos[idx + 1]))
{
sb.Append(MakeSyllable(choI, jungI, jongI));
// next2는 다음 음절 초성으로
idx--; // next2를 다시 처리
idx++;
continue;
}
jongI = cji;
idx++;
}
}
// 다음에 모음이 있으면 종성→초성
if (idx < jamos.Count && IsVowel(jamos[idx]))
{
sb.Append(MakeSyllable(choI, jungI, 0));
idx -= 2;
idx++;
continue;
}
}
sb.Append(MakeSyllable(choI, jungI, jongI));
}
return sb.ToString();
}
// 초성으로만 사용 가능한 자음인지 (된소리 = 종성 불가)
private static bool IsConsonantOnly(char c)
{
if (!IsKorJamo(c)) return false;
if (IsVowel(c)) return false;
return JongIdx(c) <= 0;
}
// ── GetItemsAsync ─────────────────────────────────────────────────────────
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
string inputText;
if (string.IsNullOrWhiteSpace(q))
{
// 클립보드에서 읽기
string? clipText = null;
try
{
Application.Current.Dispatcher.Invoke(() =>
{
if (Clipboard.ContainsText())
clipText = Clipboard.GetText();
});
}
catch { }
if (string.IsNullOrWhiteSpace(clipText))
{
items.Add(new LauncherItem("한/영 타이핑 교정",
"fix <영타 텍스트> 또는 클립보드에 텍스트 복사 후 fix 입력",
null, null, Symbol: "\uE8AC"));
items.Add(new LauncherItem("예: fix gksrmf", "→ 안녕 (영타→한글)", null, null, Symbol: "\uE8AC"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
inputText = clipText;
}
else
{
inputText = q;
}
var converted = ConvertEngToKor(inputText);
if (converted == inputText)
{
items.Add(new LauncherItem("변환할 영타 오류가 없습니다",
$"입력: {inputText}", null, null, Symbol: "\uE8AC"));
}
else
{
items.Add(new LauncherItem(
converted,
$"영타 교정 결과 · Enter: 클립보드 복사",
null, ("copy", converted), Symbol: "\uE8AC"));
items.Add(new LauncherItem(
$"원본: {(inputText.Length > 50 ? inputText[..50] + "" : inputText)}",
"", null, null, Symbol: "\uE8AC"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ── ExecuteAsync ──────────────────────────────────────────────────────────
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
NotificationService.Notify("타이핑 교정", "교정 결과를 클립보드에 복사했습니다.");
}
catch { }
}
return Task.CompletedTask;
}
}