Files
AX-Copilot-Codex/src/AxCopilot/Handlers/CsvHandler.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

339 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ""; }
}
}