변경 목적: 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개를 확인했습니다.
373 lines
14 KiB
C#
373 lines
14 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>
|
|
/// 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");
|
|
}
|