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:
338
src/AxCopilot/Handlers/CsvHandler.cs
Normal file
338
src/AxCopilot/Handlers/CsvHandler.cs
Normal file
@@ -0,0 +1,338 @@
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L11-1: CSV 뷰어·파서 핸들러. "csv" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: csv → 클립보드 CSV 파싱 (헤더·행수·컬럼수)
|
||||
/// csv col 2 → 2번째 컬럼 값 목록 추출
|
||||
/// csv row 3 → 3번째 행 출력
|
||||
/// csv stats → 숫자 컬럼 합계·평균·최대·최소
|
||||
/// csv head → 헤더 컬럼명 목록
|
||||
/// csv tsv → CSV → TSV(탭 구분) 변환
|
||||
/// Enter → 결과를 클립보드에 복사.
|
||||
/// </summary>
|
||||
public class CsvHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "csv";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"CSV",
|
||||
"CSV 뷰어·파서 — 컬럼 추출 · 통계 · 형식 변환",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
var clip = GetClipboard();
|
||||
var hasData = !string.IsNullOrWhiteSpace(clip) && LooksCsv(clip);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
if (hasData)
|
||||
{
|
||||
items.AddRange(BuildOverviewItems(clip));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem("CSV 뷰어", "CSV를 클립보드에 복사 후 'csv' 입력",
|
||||
null, null, Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("csv col 2", "2번째 컬럼 추출", null, null, Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("csv stats", "숫자 컬럼 통계", null, null, Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("csv tsv", "CSV → TSV 변환", null, null, Symbol: "\uE8A5"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
if (!hasData)
|
||||
{
|
||||
items.Add(new LauncherItem("클립보드에 CSV 없음",
|
||||
"CSV 형식 데이터를 클립보드에 복사 후 시도하세요", null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var rows = ParseCsv(clip);
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("파싱 실패", "유효한 CSV가 아닙니다", null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
switch (sub)
|
||||
{
|
||||
case "col":
|
||||
case "column":
|
||||
{
|
||||
var colIdx = parts.Length > 1 && int.TryParse(parts[1], out var n) ? n - 1 : 0;
|
||||
items.AddRange(ExtractColumn(rows, colIdx));
|
||||
break;
|
||||
}
|
||||
case "row":
|
||||
{
|
||||
var rowIdx = parts.Length > 1 && int.TryParse(parts[1], out var n) ? n - 1 : 0;
|
||||
items.AddRange(ExtractRow(rows, rowIdx));
|
||||
break;
|
||||
}
|
||||
case "stats":
|
||||
case "stat":
|
||||
{
|
||||
items.AddRange(BuildStatsItems(rows));
|
||||
break;
|
||||
}
|
||||
case "head":
|
||||
case "header":
|
||||
{
|
||||
if (rows.Count > 0)
|
||||
{
|
||||
var header = rows[0];
|
||||
var all = string.Join(", ", header);
|
||||
items.Add(new LauncherItem($"헤더 {header.Count}개 컬럼", all, null, ("copy", all), Symbol: "\uE8A5"));
|
||||
for (var i = 0; i < header.Count; i++)
|
||||
items.Add(new LauncherItem($"[{i+1}] {header[i]}", $"컬럼 {i+1}", null, ("copy", header[i]), Symbol: "\uE8A5"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tsv":
|
||||
{
|
||||
var tsv = ConvertToTsv(rows);
|
||||
items.Add(new LauncherItem(
|
||||
$"TSV 변환 {rows.Count}행",
|
||||
"탭 구분자 · Enter 복사",
|
||||
null, ("copy", tsv), Symbol: "\uE8A5"));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
items.AddRange(BuildOverviewItems(clip));
|
||||
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("CSV", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 빌더 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static IEnumerable<LauncherItem> BuildOverviewItems(string csv)
|
||||
{
|
||||
var rows = ParseCsv(csv);
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
yield return new LauncherItem("파싱 실패", "유효한 CSV 데이터가 아닙니다", null, null, Symbol: "\uE783");
|
||||
yield break;
|
||||
}
|
||||
|
||||
var headerRow = rows[0];
|
||||
var dataRows = rows.Count > 1 ? rows.Count - 1 : 0;
|
||||
var colCount = headerRow.Count;
|
||||
|
||||
yield return new LauncherItem(
|
||||
$"CSV {dataRows}행 × {colCount}열",
|
||||
$"헤더: {string.Join(", ", headerRow.Take(5))}{(colCount > 5 ? " …" : "")}",
|
||||
null,
|
||||
("copy", csv),
|
||||
Symbol: "\uE8A5");
|
||||
|
||||
yield return new LauncherItem("컬럼 수", $"{colCount}개", null, null, Symbol: "\uE8A5");
|
||||
yield return new LauncherItem("데이터 행수", $"{dataRows}행", null, null, Symbol: "\uE8A5");
|
||||
yield return new LauncherItem("헤더", string.Join(" | ", headerRow.Take(8)), null,
|
||||
("copy", string.Join(",", headerRow)), Symbol: "\uE8A5");
|
||||
|
||||
// 첫 데이터 행 미리보기
|
||||
if (rows.Count > 1)
|
||||
{
|
||||
var first = rows[1];
|
||||
yield return new LauncherItem(
|
||||
"첫 번째 행",
|
||||
string.Join(" | ", first.Take(6)),
|
||||
null,
|
||||
("copy", string.Join(",", first)),
|
||||
Symbol: "\uE8A5");
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<LauncherItem> ExtractColumn(List<List<string>> rows, int colIdx)
|
||||
{
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
yield return new LauncherItem("데이터 없음", "", null, null, Symbol: "\uE783");
|
||||
yield break;
|
||||
}
|
||||
|
||||
var maxCol = rows.Max(r => r.Count) - 1;
|
||||
colIdx = Math.Clamp(colIdx, 0, maxCol);
|
||||
|
||||
var header = rows[0].Count > colIdx ? rows[0][colIdx] : $"컬럼{colIdx+1}";
|
||||
var values = rows.Skip(1).Where(r => r.Count > colIdx).Select(r => r[colIdx]).ToList();
|
||||
var allText = string.Join("\n", values);
|
||||
|
||||
yield return new LauncherItem(
|
||||
$"[{colIdx+1}] {header} ({values.Count}개 값)",
|
||||
"전체 복사: Enter",
|
||||
null, ("copy", allText), Symbol: "\uE8A5");
|
||||
|
||||
foreach (var v in values.Take(15))
|
||||
yield return new LauncherItem(v, $"컬럼: {header}", null, ("copy", v), Symbol: "\uE8A5");
|
||||
}
|
||||
|
||||
private static IEnumerable<LauncherItem> ExtractRow(List<List<string>> rows, int rowIdx)
|
||||
{
|
||||
rowIdx = Math.Clamp(rowIdx, 0, rows.Count - 1);
|
||||
var row = rows[rowIdx];
|
||||
var header = rows[0];
|
||||
var allText = string.Join(",", row);
|
||||
|
||||
yield return new LauncherItem(
|
||||
$"행 {rowIdx+1} ({row.Count}개 값)",
|
||||
allText.Length > 80 ? allText[..80] + "…" : allText,
|
||||
null, ("copy", allText), Symbol: "\uE8A5");
|
||||
|
||||
for (var i = 0; i < row.Count; i++)
|
||||
{
|
||||
var colName = header.Count > i ? header[i] : $"컬럼{i+1}";
|
||||
yield return new LauncherItem($"[{colName}]", row[i], null, ("copy", row[i]), Symbol: "\uE8A5");
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<LauncherItem> BuildStatsItems(List<List<string>> rows)
|
||||
{
|
||||
if (rows.Count < 2)
|
||||
{
|
||||
yield return new LauncherItem("데이터 없음", "헤더 포함 2행 이상 필요", null, null, Symbol: "\uE783");
|
||||
yield break;
|
||||
}
|
||||
|
||||
var header = rows[0];
|
||||
var data = rows.Skip(1).ToList();
|
||||
|
||||
for (var col = 0; col < header.Count; col++)
|
||||
{
|
||||
var colName = header.Count > col ? header[col] : $"컬럼{col+1}";
|
||||
var nums = data
|
||||
.Where(r => r.Count > col)
|
||||
.Select(r => r[col])
|
||||
.Where(v => double.TryParse(v, System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out _))
|
||||
.Select(v => double.Parse(v, System.Globalization.CultureInfo.InvariantCulture))
|
||||
.ToList();
|
||||
|
||||
if (nums.Count == 0) continue;
|
||||
|
||||
var sum = nums.Sum();
|
||||
var avg = nums.Average();
|
||||
var min = nums.Min();
|
||||
var max = nums.Max();
|
||||
|
||||
yield return new LauncherItem(
|
||||
$"[{colName}] 합계: {sum:N2}",
|
||||
$"평균: {avg:N2} 최소: {min:N2} 최대: {max:N2} ({nums.Count}개)",
|
||||
null,
|
||||
("copy", $"{colName}: sum={sum:N2} avg={avg:N2} min={min:N2} max={max:N2}"),
|
||||
Symbol: "\uE8A5");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 변환 헬퍼 ────────────────────────────────────────────────────────────
|
||||
|
||||
private static string ConvertToTsv(List<List<string>> rows)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
sb.AppendLine(string.Join("\t", row.Select(EscapeTsv)));
|
||||
}
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static string EscapeTsv(string v) => v.Replace("\t", " ").Replace("\r", "").Replace("\n", " ");
|
||||
|
||||
// ── CSV 파서 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static List<List<string>> ParseCsv(string text)
|
||||
{
|
||||
var result = new List<List<string>>();
|
||||
// 구분자 자동 감지 (탭 또는 쉼표)
|
||||
var firstLine = text.Split('\n')[0];
|
||||
var delimiter = firstLine.Count(c => c == '\t') > firstLine.Count(c => c == ',') ? '\t' : ',';
|
||||
|
||||
using var reader = new System.IO.StringReader(text);
|
||||
string? line;
|
||||
while ((line = reader.ReadLine()) != null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
result.Add(ParseCsvLine(line, delimiter));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<string> ParseCsvLine(string line, char delim)
|
||||
{
|
||||
var fields = new List<string>();
|
||||
var sb = new StringBuilder();
|
||||
var inQuote = false;
|
||||
|
||||
for (var i = 0; i < line.Length; i++)
|
||||
{
|
||||
var c = line[i];
|
||||
if (inQuote)
|
||||
{
|
||||
if (c == '"')
|
||||
{
|
||||
if (i + 1 < line.Length && line[i + 1] == '"')
|
||||
{ sb.Append('"'); i++; }
|
||||
else
|
||||
{ inQuote = false; }
|
||||
}
|
||||
else { sb.Append(c); }
|
||||
}
|
||||
else
|
||||
{
|
||||
if (c == '"') { inQuote = true; }
|
||||
else if (c == delim){ fields.Add(sb.ToString()); sb.Clear(); }
|
||||
else { sb.Append(c); }
|
||||
}
|
||||
}
|
||||
fields.Add(sb.ToString());
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static bool LooksCsv(string text)
|
||||
{
|
||||
var firstLine = text.Split('\n').FirstOrDefault(l => !string.IsNullOrWhiteSpace(l)) ?? "";
|
||||
return firstLine.Contains(',') || firstLine.Contains('\t');
|
||||
}
|
||||
|
||||
private static string GetClipboard()
|
||||
{
|
||||
try
|
||||
{
|
||||
return System.Windows.Application.Current.Dispatcher.Invoke(
|
||||
() => Clipboard.ContainsText() ? Clipboard.GetText() : "");
|
||||
}
|
||||
catch { return ""; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user