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:
372
src/AxCopilot/Handlers/TomlHandler.cs
Normal file
372
src/AxCopilot/Handlers/TomlHandler.cs
Normal file
@@ -0,0 +1,372 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L21-1: TOML 파서·분석기 핸들러. "toml" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: toml → 클립보드 TOML 전체 키 목록
|
||||
/// toml validate → 유효성 검사
|
||||
/// toml keys → 최상위 키 목록
|
||||
/// toml get key → 특정 키 값 조회
|
||||
/// toml get server.port → 점 표기법 중첩 키 조회
|
||||
/// toml stats → 줄·키·섹션·배열 통계
|
||||
/// toml flat → 점 표기법 평탄화 (모든 키·값)
|
||||
/// toml sections → [section] 목록
|
||||
/// Enter → 값 복사.
|
||||
/// </summary>
|
||||
public partial class TomlHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "toml";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"TOML",
|
||||
"TOML 파서·분석기 — 키 조회·유효성 검사·평탄화",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
// TOML 노드 (경량 표현)
|
||||
private sealed class TomlTable : Dictionary<string, object?> { }
|
||||
private sealed class TomlArray : List<object?> { }
|
||||
|
||||
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))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(clipboard))
|
||||
{
|
||||
items.Add(new LauncherItem("TOML 파서·분석기",
|
||||
"클립보드에 TOML을 복사하세요 · toml validate / keys / get / stats / flat",
|
||||
null, null, Symbol: "\uE8EC"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
// 기본: 유효성 확인 + 최상위 키
|
||||
var (tbl, err) = ParseToml(clipboard!);
|
||||
if (err != null)
|
||||
{
|
||||
items.Add(ErrorItem($"TOML 파싱 오류: {err}"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
items.Add(new LauncherItem("TOML 파싱 성공 ✓",
|
||||
$"최상위 키 {tbl!.Count}개 · toml get / flat / stats", null, null, Symbol: "\uE8EC"));
|
||||
BuildTopKeys(items, tbl!);
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
var src = parts.Length > 1 && (sub != "get")
|
||||
? string.Join(" ", parts[1..])
|
||||
: clipboard ?? "";
|
||||
|
||||
// validate
|
||||
if (sub is "validate" or "check" or "검사")
|
||||
{
|
||||
var text = clipboard ?? "";
|
||||
var (_, verr) = ParseToml(text);
|
||||
if (verr != null)
|
||||
items.Add(ErrorItem($"유효성 오류: {verr}"));
|
||||
else
|
||||
{
|
||||
var (vt, _) = ParseToml(text);
|
||||
items.Add(new LauncherItem("✓ 유효한 TOML", $"최상위 키 {vt!.Count}개", null, null, Symbol: "\uE8EC"));
|
||||
BuildTopKeys(items, vt!);
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var (table, parseErr) = ParseToml(clipboard ?? "");
|
||||
if (parseErr != null && sub != "validate")
|
||||
{
|
||||
items.Add(ErrorItem($"TOML 파싱 오류: {parseErr}"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
switch (sub)
|
||||
{
|
||||
case "keys" or "key":
|
||||
items.Add(new LauncherItem("최상위 키 목록", $"{table!.Count}개", null, null, Symbol: "\uE8EC"));
|
||||
BuildTopKeys(items, table!);
|
||||
break;
|
||||
|
||||
case "sections" or "section":
|
||||
{
|
||||
var secs = table!.Where(kv => kv.Value is TomlTable).Select(kv => kv.Key).ToList();
|
||||
items.Add(new LauncherItem($"섹션 {secs.Count}개", "", null, null, Symbol: "\uE8EC"));
|
||||
foreach (var s in secs)
|
||||
{
|
||||
var secCount = table![s] is TomlTable st ? st.Count : 0;
|
||||
items.Add(new LauncherItem($"[{s}]",
|
||||
$"{secCount}개 키", null, ("copy", s), Symbol: "\uE8EC"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "get":
|
||||
{
|
||||
if (parts.Length < 2) { items.Add(ErrorItem("예: toml get server.port")); break; }
|
||||
var keyPath = parts[1];
|
||||
var val = GetByPath(table!, keyPath);
|
||||
if (val == null)
|
||||
items.Add(new LauncherItem($"'{keyPath}' 키를 찾을 수 없습니다", "", null, null, Symbol: "\uE8EC"));
|
||||
else
|
||||
{
|
||||
var strVal = TomlValueToString(val);
|
||||
items.Add(new LauncherItem($"{keyPath} = {TruncateStr(strVal, 60)}",
|
||||
"Enter 복사", null, ("copy", strVal), Symbol: "\uE8EC"));
|
||||
items.Add(CopyItem("값", strVal));
|
||||
items.Add(CopyItem("키 경로", keyPath));
|
||||
items.Add(CopyItem("타입", GetTomlType(val)));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "stats":
|
||||
{
|
||||
var flat = new Dictionary<string, object?>();
|
||||
FlattenTable(table!, "", flat);
|
||||
var lines = (clipboard ?? "").Split('\n').Length;
|
||||
var arrays = flat.Values.Count(v => v is TomlArray);
|
||||
var tables2 = flat.Values.Count(v => v is TomlTable);
|
||||
var scalars = flat.Count - arrays - tables2;
|
||||
|
||||
items.Add(new LauncherItem("TOML 통계", "", null, null, Symbol: "\uE8EC"));
|
||||
items.Add(CopyItem("전체 줄", lines.ToString()));
|
||||
items.Add(CopyItem("최상위 키", table!.Count.ToString()));
|
||||
items.Add(CopyItem("전체 키(flat)", flat.Count.ToString()));
|
||||
items.Add(CopyItem("스칼라 값", scalars.ToString()));
|
||||
items.Add(CopyItem("배열", arrays.ToString()));
|
||||
items.Add(CopyItem("섹션(테이블)", table.Values.Count(v => v is TomlTable).ToString()));
|
||||
break;
|
||||
}
|
||||
|
||||
case "flat" or "flatten":
|
||||
{
|
||||
var flat = new Dictionary<string, object?>();
|
||||
FlattenTable(table!, "", flat);
|
||||
var all = string.Join("\n", flat.Select(kv => $"{kv.Key} = {TomlValueToString(kv.Value)}"));
|
||||
items.Add(new LauncherItem($"평탄화 결과 {flat.Count}개",
|
||||
"전체 복사 → Enter", null, ("copy", all), Symbol: "\uE8EC"));
|
||||
foreach (var (k, v) in flat.Take(20))
|
||||
items.Add(new LauncherItem($"{k} = {TruncateStr(TomlValueToString(v), 50)}",
|
||||
GetTomlType(v), null, ("copy", TomlValueToString(v)), Symbol: "\uE8EC"));
|
||||
if (flat.Count > 20)
|
||||
items.Add(new LauncherItem($"... ({flat.Count - 20}개 더)", "전체는 Enter로 복사",
|
||||
null, null, Symbol: "\uE8EC"));
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
items.Add(new LauncherItem($"알 수 없는 서브커맨드: '{sub}'",
|
||||
"validate · keys · sections · get <key> · stats · flat",
|
||||
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("TOML", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 경량 TOML 파서 ────────────────────────────────────────────────────
|
||||
|
||||
private static (TomlTable? table, string? error) ParseToml(string src)
|
||||
{
|
||||
var root = new TomlTable();
|
||||
var current = root;
|
||||
var lines = src.Split('\n');
|
||||
string? parseError = null;
|
||||
|
||||
for (int lineNo = 0; lineNo < lines.Length; lineNo++)
|
||||
{
|
||||
var raw = lines[lineNo];
|
||||
var line = StripComment(raw).Trim();
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
|
||||
// [section] 또는 [[array-of-tables]]
|
||||
if (line.StartsWith("[["))
|
||||
{
|
||||
var name = line.Trim('[', ']').Trim();
|
||||
// 배열 섹션 처리 (간략화: 마지막 테이블만 유지)
|
||||
var parts = name.Split('.');
|
||||
current = root;
|
||||
foreach (var p in parts)
|
||||
{
|
||||
if (!current.TryGetValue(p, out var existing) || existing is not TomlTable child)
|
||||
{ child = new TomlTable(); current[p] = child; }
|
||||
current = (TomlTable)current[p]!;
|
||||
}
|
||||
}
|
||||
else if (line.StartsWith("["))
|
||||
{
|
||||
var name = line.Trim('[', ']').Trim();
|
||||
var parts = name.Split('.');
|
||||
current = root;
|
||||
foreach (var p in parts)
|
||||
{
|
||||
if (!current.TryGetValue(p, out var existing) || existing is not TomlTable child)
|
||||
{ child = new TomlTable(); current[p] = child; }
|
||||
current = (TomlTable)current[p]!;
|
||||
}
|
||||
}
|
||||
// key = value
|
||||
else if (line.Contains('='))
|
||||
{
|
||||
var eqIdx = line.IndexOf('=');
|
||||
var key = line[..eqIdx].Trim().Trim('"');
|
||||
var val = line[(eqIdx + 1)..].Trim();
|
||||
try { current[key] = ParseTomlValue(val); }
|
||||
catch (Exception ex)
|
||||
{ parseError ??= $"줄 {lineNo + 1}: {ex.Message}"; }
|
||||
}
|
||||
}
|
||||
return parseError != null ? (null, parseError) : (root, null);
|
||||
}
|
||||
|
||||
private static object? ParseTomlValue(string val)
|
||||
{
|
||||
if (val.StartsWith('"') || val.StartsWith('\''))
|
||||
return val.Trim('"', '\'');
|
||||
if (val.StartsWith('['))
|
||||
{
|
||||
var arr = new TomlArray();
|
||||
var inner = val.Trim('[', ']');
|
||||
foreach (var item in inner.Split(','))
|
||||
{
|
||||
var t = item.Trim();
|
||||
if (!string.IsNullOrEmpty(t)) arr.Add(ParseTomlValue(t));
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
if (val.StartsWith('{'))
|
||||
{
|
||||
var tbl = new TomlTable();
|
||||
var inner = val.Trim('{', '}');
|
||||
foreach (var pair in inner.Split(','))
|
||||
{
|
||||
var parts = pair.Split('=', 2);
|
||||
if (parts.Length == 2)
|
||||
tbl[parts[0].Trim().Trim('"')] = ParseTomlValue(parts[1].Trim());
|
||||
}
|
||||
return tbl;
|
||||
}
|
||||
if (val is "true") return true;
|
||||
if (val is "false") return false;
|
||||
if (long.TryParse(val, out var lv)) return lv;
|
||||
if (double.TryParse(val, System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var dv)) return dv;
|
||||
return val.Trim('"', '\'');
|
||||
}
|
||||
|
||||
private static string StripComment(string line)
|
||||
{
|
||||
// '#' 앞에 따옴표가 홀수개인 경우 내부 → 유지, 아니면 제거
|
||||
bool inStr = false;
|
||||
char quote = '"';
|
||||
for (int i = 0; i < line.Length; i++)
|
||||
{
|
||||
var c = line[i];
|
||||
if (!inStr && (c == '"' || c == '\'')) { inStr = true; quote = c; }
|
||||
else if (inStr && c == quote) inStr = false;
|
||||
else if (!inStr && c == '#') return line[..i];
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
private static object? GetByPath(TomlTable table, string path)
|
||||
{
|
||||
var parts = path.Split('.');
|
||||
object? cur = table;
|
||||
foreach (var p in parts)
|
||||
{
|
||||
if (cur is TomlTable t && t.TryGetValue(p, out var next)) cur = next;
|
||||
else return null;
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
private static void FlattenTable(TomlTable table, string prefix, Dictionary<string, object?> result)
|
||||
{
|
||||
foreach (var (k, v) in table)
|
||||
{
|
||||
var key = string.IsNullOrEmpty(prefix) ? k : $"{prefix}.{k}";
|
||||
if (v is TomlTable child) FlattenTable(child, key, result);
|
||||
else result[key] = v;
|
||||
}
|
||||
}
|
||||
|
||||
private static void BuildTopKeys(List<LauncherItem> items, TomlTable table)
|
||||
{
|
||||
foreach (var (k, v) in table)
|
||||
{
|
||||
var type = GetTomlType(v);
|
||||
var disp = v is TomlTable t ? $"{{ {t.Count}개 키 }}" :
|
||||
v is TomlArray a ? $"[ {a.Count}개 항목 ]" :
|
||||
TruncateStr(TomlValueToString(v), 50);
|
||||
items.Add(new LauncherItem($"{k} = {disp}", type,
|
||||
null, ("copy", TomlValueToString(v)), Symbol: "\uE8EC"));
|
||||
}
|
||||
}
|
||||
|
||||
private static string TomlValueToString(object? v) => v switch
|
||||
{
|
||||
null => "null",
|
||||
bool b => b ? "true" : "false",
|
||||
TomlTable t => "{" + string.Join(", ", t.Select(kv => $"{kv.Key} = {TomlValueToString(kv.Value)}")) + "}",
|
||||
TomlArray a => "[" + string.Join(", ", a.Select(TomlValueToString)) + "]",
|
||||
_ => v.ToString() ?? ""
|
||||
};
|
||||
|
||||
private static string GetTomlType(object? v) => v switch
|
||||
{
|
||||
null => "null",
|
||||
bool => "Boolean",
|
||||
long => "Integer",
|
||||
double => "Float",
|
||||
string => "String",
|
||||
TomlTable => "Table",
|
||||
TomlArray => "Array",
|
||||
_ => v.GetType().Name
|
||||
};
|
||||
|
||||
private static string TruncateStr(string s, int max) =>
|
||||
s.Length <= max ? s : s[..max] + "…";
|
||||
|
||||
private static LauncherItem CopyItem(string label, string value) =>
|
||||
new(label, value, null, ("copy", value), Symbol: "\uE8EC");
|
||||
|
||||
private static LauncherItem ErrorItem(string msg) =>
|
||||
new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783");
|
||||
}
|
||||
Reference in New Issue
Block a user