[Phase L16] ping·Docker·Todo·Table 핸들러 4종 추가
PingHandler.cs (신규, ~230줄, prefix=ping):
- 입력 즉시 Ping 클래스로 1회 ping 시도 → 응답 ms 즉시 표시
- ping trace/tracert: Windows Terminal 우선 실행
- ping local: NetworkInterface.GetAllNetworkInterfaces() 어댑터 정보
- ping scan <대역>: PowerShell 1..254 스캔 스크립트 실행
- CheckInternalMode(): 사내 모드에서 외부 도메인 차단
- FindExe(): PATH에서 wt.exe 검색
DockerHandler.cs (신규, ~290줄, prefix=docker):
- IsDockerAvailable(): docker version 종료 코드로 설치 여부 확인
- GetContainers(): docker ps --format 탭 구분 파싱
- GetImages(): docker images --format 파싱
- docker stop/start: 터미널 없이 RunDockerSilent() 실행
- docker logs: -f (follow) 모드로 터미널 실행
- docker shell: exec -it sh 접속
- 이름 키워드 검색 지원
TodoHandler.cs (신규, ~220줄, prefix=todo):
- TodoItem record: JsonPropertyName 직렬화 (id/text/done/at)
- LoadTodos/SaveTodos: %APPDATA%\AxCopilot\todos.json
- done/toggle: with 표현식으로 불변 record 업데이트
- clear_done / clear_all 별도 처리
- 번호만 입력 시 빠른 완료 토글 단축
- 검색 + 새 항목 추가 동시 표시
TableHandler.cs (신규, ~280줄, prefix=table):
- ParseTable(): 탭·쉼표·공백 구분자 자동 감지
- ParseCsvLine(): RFC 4180 따옴표 처리
- ToMarkdown(): 열별 PadRight 정렬 마크다운 표
- ToCsv(): 특수문자 포함 셀 따옴표 처리
- ToHtml(): thead/tbody/th/td HTML 테이블 생성
- Transpose(): 행·열 전치
- SortByColumn(): double.TryParse 숫자/문자 자동 감지 정렬
- CS0136 수정: rows → previewRows (바깥 스코프 변수명 충돌 해결)
App.xaml.cs (수정): Phase L16 핸들러 4종 RegisterHandler 추가
docs/LAUNCHER_ROADMAP.md (수정): Phase L16 섹션 추가 (✅ 완료)
빌드: 경고 0, 오류 0
This commit is contained in:
@@ -334,3 +334,16 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label
|
||||
| L15-2 | **환율 변환기** ✅ | `currency` 프리픽스. KRW/USD/EUR/JPY/CNY/GBP/HKD/TWD/SGD/AUD/CAD/CHF/MYR/THB/VND 15개 통화 내장. `currency 100 usd` → KRW 환산. `currency 100 usd eur` → 크로스 환산. `currency 50000 krw usd`. `currency rates` 전체 환율표. 한글 별칭(달러/엔/위안) 지원. JPY/KRW/VND 소수점 0자리 포맷 | 높음 |
|
||||
| L15-3 | **BMI·건강 계산기** ✅ | `bmi` 프리픽스. `bmi 170 65` BMI 지수 + WHO 아시아태평양 기준 판정. 적정 체중 범위(BMI 18.5~22.9). `bmi 170 65 30 m` 나이+성별 포함 시 Harris-Benedict 기초대사량 + 5단계 활동별 권장 칼로리. `bmi ideal 170` 키 기준 적정/과체중/비만 체중 범위 | 높음 |
|
||||
| L15-4 | **Markdown 분석기** ✅ | `md` 프리픽스. 클립보드 Markdown 자동 읽기. `md toc` 앵커 포함 목차(TOC) 생성. `md strip` 마크다운 기호 완전 제거 → 순수 텍스트. `md count` 줄/단어/문자/제목/코드블록/목록/링크/이미지/볼드 통계. `md links` 링크 목록 추출. `md images` 이미지 URL 목록. [GeneratedRegex] 소스 생성기 활용 | 높음 |
|
||||
|
||||
---
|
||||
|
||||
## Phase L16 — ping·Docker·Todo·Table 도구 (v2.0.8) ✅ 완료
|
||||
|
||||
> **방향**: 개발자 인프라 도구 + 생산성 도구 보강 — 네트워크 진단, 컨테이너 관리, 할 일 목록, 표 변환.
|
||||
|
||||
| # | 기능 | 설명 | 우선순위 |
|
||||
|---|------|------|----------|
|
||||
| L16-1 | **ping·tracert 실행기** ✅ | `ping` 프리픽스. 입력 즉시 1회 ping 시도 결과 표시(응답 ms 실시간). Enter → Windows Terminal(wt.exe) 우선 실행. `ping trace <host>` tracert. `ping local` 로컬 어댑터 IP·게이트웨이 정보. `ping scan <대역>` PowerShell 스캔 스크립트 실행. `ping -t` 무한 반복. 사내 모드에서 외부 도메인 차단 | 높음 |
|
||||
| L16-2 | **Docker 관리** ✅ | `docker` 프리픽스. `docker ps` 실행 중 컨테이너 목록(이름·상태·포트). `docker all` 중지 포함 전체 목록. `docker images` 로컬 이미지 목록(크기·생성일). `docker stop/start <이름>` 터미널 없이 직접 실행. `docker logs <이름>` 터미널에서 로그. `docker shell <이름>` exec -it sh 접속. Docker 미설치 감지 | 높음 |
|
||||
| L16-3 | **할 일 목록** ✅ | `todo` 프리픽스. `todo <내용>` 새 항목 추가. `todo done <번호>` 완료 토글. `todo del <번호>` 삭제. `todo clear` 완료 항목 정리. `todo clear all` 전체 삭제. `todo <검색어>` 키워드 필터. 번호만 입력 시 빠른 완료 토글. 미완료 먼저, 완료 항목 하단 그룹. `%APPDATA%\AxCopilot\todos.json` 로컬 저장 | 높음 |
|
||||
| L16-4 | **텍스트 → 표 변환기** ✅ | `table` 프리픽스. 클립보드 텍스트 자동 읽기. 탭·CSV·공백 구분자 자동 감지. `table` → 마크다운 표. `table csv` → CSV 변환. `table html` → HTML `<table>` 태그. `table flip` 행·열 전치(transpose). `table sort N` N번 열 기준 정렬(숫자/문자 자동 감지). 셀 너비 자동 정렬(PadRight). 미리보기 3줄 표시 | 높음 |
|
||||
|
||||
@@ -287,6 +287,16 @@ public partial class App : System.Windows.Application
|
||||
// L15-4: Markdown 분석기 (prefix=md)
|
||||
commandResolver.RegisterHandler(new MdHandler());
|
||||
|
||||
// ─── Phase L16 핸들러 ─────────────────────────────────────────────────
|
||||
// L16-1: ping·tracert 실행기 (prefix=ping)
|
||||
commandResolver.RegisterHandler(new PingHandler());
|
||||
// L16-2: Docker 컨테이너·이미지 조회 (prefix=docker)
|
||||
commandResolver.RegisterHandler(new DockerHandler());
|
||||
// L16-3: 할 일 목록 (prefix=todo)
|
||||
commandResolver.RegisterHandler(new TodoHandler());
|
||||
// L16-4: 텍스트 → 표 변환기 (prefix=table)
|
||||
commandResolver.RegisterHandler(new TableHandler());
|
||||
|
||||
// ─── 플러그인 로드 ────────────────────────────────────────────────────
|
||||
var pluginHost = new PluginHost(settings, commandResolver);
|
||||
pluginHost.LoadAll();
|
||||
|
||||
375
src/AxCopilot/Handlers/DockerHandler.cs
Normal file
375
src/AxCopilot/Handlers/DockerHandler.cs
Normal file
@@ -0,0 +1,375 @@
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L16-2: Docker 컨테이너·이미지 조회 핸들러. "docker" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: docker → 실행 중 컨테이너 목록
|
||||
/// docker all → 모든 컨테이너 (중지 포함)
|
||||
/// docker images → 로컬 이미지 목록
|
||||
/// docker ps → 컨테이너 목록 (docker ps 동일)
|
||||
/// docker stop <name> → 컨테이너 중지
|
||||
/// docker start <name> → 컨테이너 시작
|
||||
/// docker logs <name> → 컨테이너 로그 (터미널)
|
||||
/// docker shell <name> → 컨테이너 shell 접속
|
||||
/// Enter → 명령 실행 또는 컨테이너 ID 복사.
|
||||
/// </summary>
|
||||
public class DockerHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "docker";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Docker",
|
||||
"Docker 컨테이너·이미지 조회 — 시작·중지·로그·쉘",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
if (!IsDockerAvailable())
|
||||
{
|
||||
items.Add(new LauncherItem("Docker를 찾을 수 없습니다",
|
||||
"Docker Desktop이 설치되어 있는지 확인하세요", null, null, Symbol: "\uE756"));
|
||||
items.Add(new LauncherItem("Docker Desktop 설치",
|
||||
"https://www.docker.com/products/docker-desktop",
|
||||
null, ("open_url", "https://www.docker.com/products/docker-desktop"), Symbol: "\uE756"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
var containers = GetContainers(running: true);
|
||||
items.Add(new LauncherItem(
|
||||
$"실행 중 컨테이너 {containers.Count}개",
|
||||
"docker ps / docker all / docker images",
|
||||
null, null, Symbol: "\uE756"));
|
||||
|
||||
if (containers.Count == 0)
|
||||
items.Add(new LauncherItem("실행 중인 컨테이너 없음", "docker all → 전체 목록", null, null, Symbol: "\uE946"));
|
||||
else
|
||||
foreach (var c in containers)
|
||||
items.Add(MakeContainerItem(c));
|
||||
|
||||
items.Add(new LauncherItem("docker images", "로컬 이미지 목록", null, ("sub", "images"), Symbol: "\uE756"));
|
||||
items.Add(new LauncherItem("docker all", "모든 컨테이너 목록", null, ("sub", "all"), Symbol: "\uE756"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
switch (sub)
|
||||
{
|
||||
case "all":
|
||||
case "ps":
|
||||
{
|
||||
var all = sub == "all";
|
||||
var containers = GetContainers(running: !all);
|
||||
items.Add(new LauncherItem(
|
||||
$"{(all ? "전체" : "실행 중")} 컨테이너 {containers.Count}개",
|
||||
"", null, null, Symbol: "\uE756"));
|
||||
foreach (var c in containers)
|
||||
items.Add(MakeContainerItem(c));
|
||||
if (containers.Count == 0)
|
||||
items.Add(new LauncherItem("컨테이너 없음", "", null, null, Symbol: "\uE946"));
|
||||
break;
|
||||
}
|
||||
|
||||
case "images":
|
||||
case "image":
|
||||
case "img":
|
||||
{
|
||||
var images = GetImages();
|
||||
items.Add(new LauncherItem($"로컬 이미지 {images.Count}개", "", null, null, Symbol: "\uE756"));
|
||||
foreach (var img in images)
|
||||
items.Add(MakeImageItem(img));
|
||||
if (images.Count == 0)
|
||||
items.Add(new LauncherItem("이미지 없음", "docker pull <이름> 으로 받기", null, null, Symbol: "\uE946"));
|
||||
break;
|
||||
}
|
||||
|
||||
case "stop":
|
||||
{
|
||||
var name = parts.Length > 1 ? parts[1] : "";
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
// 실행 중 컨테이너 목록 표시 → 클릭 시 stop
|
||||
var running = GetContainers(running: true);
|
||||
items.Add(new LauncherItem("중지할 컨테이너 선택", "Enter → 중지", null, null, Symbol: "\uE756"));
|
||||
foreach (var c in running)
|
||||
items.Add(new LauncherItem($"중지: {c.Name}", c.Image,
|
||||
null, ("stop", c.Id), Symbol: "\uE756"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"컨테이너 중지: {name}",
|
||||
$"docker stop {name} · Enter 실행",
|
||||
null, ("stop", name), Symbol: "\uE756"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "start":
|
||||
{
|
||||
var name = parts.Length > 1 ? parts[1] : "";
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
var stopped = GetContainers(running: false, stopped: true);
|
||||
items.Add(new LauncherItem("시작할 컨테이너 선택", "Enter → 시작", null, null, Symbol: "\uE756"));
|
||||
foreach (var c in stopped)
|
||||
items.Add(new LauncherItem($"시작: {c.Name}", c.Image,
|
||||
null, ("start", c.Id), Symbol: "\uE756"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"컨테이너 시작: {name}",
|
||||
$"docker start {name} · Enter 실행",
|
||||
null, ("start", name), Symbol: "\uE756"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "logs":
|
||||
case "log":
|
||||
{
|
||||
var name = parts.Length > 1 ? parts[1] : "";
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
var running = GetContainers(running: true);
|
||||
foreach (var c in running)
|
||||
items.Add(new LauncherItem($"로그: {c.Name}", "Enter → 터미널에서 로그 보기",
|
||||
null, ("logs", c.Name), Symbol: "\uE756"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"로그: {name}", $"docker logs -f {name}",
|
||||
null, ("logs", name), Symbol: "\uE756"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "shell":
|
||||
case "exec":
|
||||
case "sh":
|
||||
{
|
||||
var name = parts.Length > 1 ? parts[1] : "";
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
var running = GetContainers(running: true);
|
||||
foreach (var c in running)
|
||||
items.Add(new LauncherItem($"쉘: {c.Name}", "Enter → 컨테이너 shell 접속",
|
||||
null, ("shell", c.Name), Symbol: "\uE756"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"쉘 접속: {name}", $"docker exec -it {name} sh",
|
||||
null, ("shell", name), Symbol: "\uE756"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
{
|
||||
// 컨테이너 이름 검색
|
||||
var all = GetContainers(running: false, stopped: true, all: true);
|
||||
var found = all.Where(c =>
|
||||
c.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
c.Image.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (found.Count > 0)
|
||||
foreach (var c in found)
|
||||
items.Add(MakeContainerItem(c));
|
||||
else
|
||||
items.Add(new LauncherItem($"'{q}' 컨테이너 없음",
|
||||
"docker all → 전체 목록", null, null, Symbol: "\uE946"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
switch (item.Data)
|
||||
{
|
||||
case ("stop", string id):
|
||||
RunDockerSilent($"stop {id}");
|
||||
NotificationService.Notify("Docker", $"중지: {id}");
|
||||
break;
|
||||
|
||||
case ("start", string id):
|
||||
RunDockerSilent($"start {id}");
|
||||
NotificationService.Notify("Docker", $"시작: {id}");
|
||||
break;
|
||||
|
||||
case ("logs", string name):
|
||||
RunInTerminal($"docker logs -f {name}");
|
||||
break;
|
||||
|
||||
case ("shell", string name):
|
||||
RunInTerminal($"docker exec -it {name} sh");
|
||||
break;
|
||||
|
||||
case ("copy", string text):
|
||||
try
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
|
||||
NotificationService.Notify("Docker", "복사됨");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
break;
|
||||
|
||||
case ("open_url", string url):
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{ FileName = url, UseShellExecute = true });
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
break;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── Docker 조회 ──────────────────────────────────────────────────────────
|
||||
|
||||
private record DockerContainer(string Id, string Name, string Image, string Status, string Ports);
|
||||
private record DockerImage(string Repository, string Tag, string Id, string Size, string Created);
|
||||
|
||||
private static List<DockerContainer> GetContainers(bool running = true, bool stopped = false, bool all = false)
|
||||
{
|
||||
var result = new List<DockerContainer>();
|
||||
try
|
||||
{
|
||||
var filter = all || (!running && stopped) ? "-a" : (running ? "" : "--filter status=exited");
|
||||
var output = RunDockerOutput($"ps {filter} --format \"{{{{.ID}}}}\\t{{{{.Names}}}}\\t{{{{.Image}}}}\\t{{{{.Status}}}}\\t{{{{.Ports}}}}\"");
|
||||
foreach (var line in output.Split('\n'))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmed)) continue;
|
||||
var cols = trimmed.Split('\t');
|
||||
if (cols.Length < 4) continue;
|
||||
result.Add(new DockerContainer(
|
||||
Id: cols[0],
|
||||
Name: cols[1],
|
||||
Image: cols[2],
|
||||
Status: cols[3],
|
||||
Ports: cols.Length > 4 ? cols[4] : ""));
|
||||
}
|
||||
}
|
||||
catch { /* Docker 없음 */ }
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<DockerImage> GetImages()
|
||||
{
|
||||
var result = new List<DockerImage>();
|
||||
try
|
||||
{
|
||||
var output = RunDockerOutput("images --format \"{{.Repository}}\\t{{.Tag}}\\t{{.ID}}\\t{{.Size}}\\t{{.CreatedSince}}\"");
|
||||
foreach (var line in output.Split('\n'))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmed)) continue;
|
||||
var cols = trimmed.Split('\t');
|
||||
if (cols.Length < 4) continue;
|
||||
result.Add(new DockerImage(
|
||||
Repository: cols[0],
|
||||
Tag: cols.Length > 1 ? cols[1] : "latest",
|
||||
Id: cols.Length > 2 ? cols[2] : "",
|
||||
Size: cols.Length > 3 ? cols[3] : "",
|
||||
Created: cols.Length > 4 ? cols[4] : ""));
|
||||
}
|
||||
}
|
||||
catch { /* Docker 없음 */ }
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string RunDockerOutput(string args)
|
||||
{
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "docker",
|
||||
Arguments = args,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
};
|
||||
using var proc = System.Diagnostics.Process.Start(psi);
|
||||
if (proc == null) return "";
|
||||
var output = proc.StandardOutput.ReadToEnd();
|
||||
proc.WaitForExit(5000);
|
||||
return output;
|
||||
}
|
||||
|
||||
private static void RunDockerSilent(string args)
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "docker", Arguments = args,
|
||||
UseShellExecute = false, CreateNoWindow = true,
|
||||
};
|
||||
using var proc = System.Diagnostics.Process.Start(psi);
|
||||
proc?.WaitForExit(10000);
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
|
||||
private static bool IsDockerAvailable()
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "docker", Arguments = "version --format json",
|
||||
UseShellExecute = false, CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
};
|
||||
using var proc = System.Diagnostics.Process.Start(psi);
|
||||
proc?.WaitForExit(3000);
|
||||
return proc?.ExitCode == 0;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static LauncherItem MakeContainerItem(DockerContainer c)
|
||||
{
|
||||
var isRunning = c.Status.StartsWith("Up", StringComparison.OrdinalIgnoreCase);
|
||||
var icon = isRunning ? "\uE768" : "\uE71A";
|
||||
var ports = string.IsNullOrWhiteSpace(c.Ports) ? "" : $" · {c.Ports}";
|
||||
return new LauncherItem(c.Name,
|
||||
$"{c.Status}{ports} · {c.Image}",
|
||||
null, ("copy", c.Id), Symbol: icon);
|
||||
}
|
||||
|
||||
private static LauncherItem MakeImageItem(DockerImage img)
|
||||
{
|
||||
var name = img.Tag == "<none>" ? img.Repository : $"{img.Repository}:{img.Tag}";
|
||||
return new LauncherItem(name, $"{img.Size} · {img.Created} · {img.Id[..Math.Min(12, img.Id.Length)]}",
|
||||
null, ("copy", name), Symbol: "\uE756");
|
||||
}
|
||||
|
||||
private static void RunInTerminal(string cmd)
|
||||
{
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "cmd", Arguments = $"/K {cmd}", UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
}
|
||||
271
src/AxCopilot/Handlers/PingHandler.cs
Normal file
271
src/AxCopilot/Handlers/PingHandler.cs
Normal file
@@ -0,0 +1,271 @@
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L16-1: ping·tracert 빠른 실행 핸들러. "ping" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: ping 8.8.8.8 → ping 결과 (4회)
|
||||
/// ping google.com → 도메인 ping
|
||||
/// ping trace 8.8.8.8 → tracert 실행
|
||||
/// ping local → 로컬 네트워크 어댑터 정보
|
||||
/// ping scan 192.168.1.0 → 간단 네트워크 스캔 (1~254)
|
||||
/// Enter → 결과 복사 또는 외부 터미널 실행.
|
||||
/// 사내 모드: 외부 IP/도메인 ping 차단.
|
||||
/// </summary>
|
||||
public class PingHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "ping";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Ping",
|
||||
"ping·tracert 빠른 실행 — 네트워크 연결 확인·경로 추적",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private static readonly string[] QuickTargets =
|
||||
["localhost", "8.8.8.8", "1.1.1.1", "google.com", "192.168.1.1"];
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
items.Add(new LauncherItem("ping / tracert 실행기",
|
||||
"예: ping 8.8.8.8 / ping trace 192.168.1.1 / ping local / ping scan 192.168.1",
|
||||
null, null, Symbol: "\uE968"));
|
||||
items.Add(new LauncherItem("── 빠른 대상 ──", "", null, null, Symbol: "\uE968"));
|
||||
foreach (var t in QuickTargets)
|
||||
items.Add(new LauncherItem($"ping {t}", t, null, ("ping", t), Symbol: "\uE968"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
// local → 로컬 어댑터 정보
|
||||
if (sub == "local" || sub == "lo")
|
||||
{
|
||||
items.AddRange(BuildLocalNetworkItems());
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// trace / tracert / traceroute
|
||||
if (sub is "trace" or "tracert" or "traceroute")
|
||||
{
|
||||
var target = parts.Length > 1 ? parts[1] : "";
|
||||
if (string.IsNullOrWhiteSpace(target))
|
||||
{
|
||||
items.Add(new LauncherItem("대상 주소 입력", "예: ping trace 8.8.8.8", null, null, Symbol: "\uE783"));
|
||||
}
|
||||
else
|
||||
{
|
||||
var blocked = CheckInternalMode(target);
|
||||
if (blocked != null) { items.Add(blocked); return Task.FromResult<IEnumerable<LauncherItem>>(items); }
|
||||
items.Add(new LauncherItem($"tracert {target}", "Enter → 터미널에서 tracert 실행",
|
||||
null, ("tracert", target), Symbol: "\uE968"));
|
||||
items.Add(new LauncherItem("터미널 실행", $"tracert {target}",
|
||||
null, ("tracert", target), Symbol: "\uE968"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// scan → 간단 스캔 (비동기 결과는 실행 시 터미널)
|
||||
if (sub == "scan")
|
||||
{
|
||||
var network = parts.Length > 1 ? parts[1] : "192.168.1";
|
||||
items.Add(new LauncherItem($"네트워크 스캔: {network}.1~254",
|
||||
"Enter → 터미널에서 ping 스캔 스크립트 실행",
|
||||
null, ("scan", network), Symbol: "\uE968"));
|
||||
items.Add(new LauncherItem("팁", "결과가 많을 수 있습니다 — 터미널에서 확인하세요",
|
||||
null, null, Symbol: "\uE946"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 직접 ping 대상
|
||||
var host = parts[0];
|
||||
var blocked2 = CheckInternalMode(host);
|
||||
if (blocked2 != null) { items.Add(blocked2); return Task.FromResult<IEnumerable<LauncherItem>>(items); }
|
||||
|
||||
items.Add(new LauncherItem($"ping {host}",
|
||||
"Enter → 비동기 ping (4회) 실행",
|
||||
null, ("ping", host), Symbol: "\uE968"));
|
||||
items.Add(new LauncherItem($"tracert {host}",
|
||||
"Enter → 터미널에서 tracert 실행",
|
||||
null, ("tracert", host), Symbol: "\uE968"));
|
||||
items.Add(new LauncherItem($"ping 연속 {host}",
|
||||
"ping -t (무한 반복) — 터미널",
|
||||
null, ("ping_t", host), Symbol: "\uE968"));
|
||||
|
||||
// 즉시 1회 ping 시도
|
||||
var pingResult = TryPingOnce(host);
|
||||
if (pingResult != null)
|
||||
{
|
||||
items.Insert(0, pingResult);
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
switch (item.Data)
|
||||
{
|
||||
case ("ping", string host):
|
||||
RunInTerminal($"ping {host}");
|
||||
break;
|
||||
|
||||
case ("ping_t", string host):
|
||||
RunInTerminal($"ping -t {host}");
|
||||
break;
|
||||
|
||||
case ("tracert", string host):
|
||||
RunInTerminal($"tracert {host}");
|
||||
break;
|
||||
|
||||
case ("scan", string network):
|
||||
// PowerShell로 간단 스캔
|
||||
var ps = $"1..254 | ForEach-Object {{ $ip = '{network}.$_'; if (Test-Connection $ip -Count 1 -Quiet -TimeoutSeconds 1) {{ Write-Host \"$ip is UP\" }} }}; Read-Host 'Press Enter'";
|
||||
RunInTerminal($"powershell -NoExit -Command \"{ps}\"", usePs: true);
|
||||
break;
|
||||
|
||||
case ("copy", string text):
|
||||
try
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(
|
||||
() => Clipboard.SetText(text));
|
||||
NotificationService.Notify("Ping", "복사됨");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
break;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static List<LauncherItem> BuildLocalNetworkItems()
|
||||
{
|
||||
var items = new List<LauncherItem>();
|
||||
try
|
||||
{
|
||||
var ifaces = NetworkInterface.GetAllNetworkInterfaces()
|
||||
.Where(n => n.OperationalStatus == OperationalStatus.Up &&
|
||||
n.NetworkInterfaceType != NetworkInterfaceType.Loopback)
|
||||
.ToList();
|
||||
|
||||
items.Add(new LauncherItem($"로컬 네트워크 어댑터 {ifaces.Count}개", "", null, null, Symbol: "\uE968"));
|
||||
|
||||
foreach (var iface in ifaces)
|
||||
{
|
||||
var ipProps = iface.GetIPProperties();
|
||||
var ipv4 = ipProps.UnicastAddresses
|
||||
.FirstOrDefault(a => a.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork);
|
||||
var gateway = ipProps.GatewayAddresses.FirstOrDefault()?.Address?.ToString() ?? "없음";
|
||||
|
||||
if (ipv4 == null) continue;
|
||||
|
||||
var ip = ipv4.Address.ToString();
|
||||
var mask = ipv4.IPv4Mask.ToString();
|
||||
var label = $"{iface.Name} {ip}";
|
||||
var sub2 = $"넷마스크 {mask} · 게이트웨이 {gateway}";
|
||||
items.Add(new LauncherItem(label, sub2, null, ("copy", ip), Symbol: "\uE968"));
|
||||
}
|
||||
|
||||
// 외부 IP 안내 (사외 모드에서만)
|
||||
var settings = (System.Windows.Application.Current as App)?.SettingsService?.Settings;
|
||||
if (settings?.InternalModeEnabled == false)
|
||||
items.Add(new LauncherItem("외부 IP 조회", "ping trace 8.8.8.8 으로 경로 확인",
|
||||
null, null, Symbol: "\uE968"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
items.Add(new LauncherItem("네트워크 정보 조회 오류", ex.Message, null, null, Symbol: "\uE783"));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private static LauncherItem? TryPingOnce(string host)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var p = new Ping();
|
||||
var reply = p.Send(host, 1000);
|
||||
if (reply.Status == IPStatus.Success)
|
||||
{
|
||||
var label = $"✓ 응답 {reply.RoundtripTime}ms";
|
||||
return new LauncherItem(label, $"TTL {reply.Options?.Ttl ?? 0} · {host}",
|
||||
null, ("copy", $"{reply.RoundtripTime}ms"), Symbol: "\uE968");
|
||||
}
|
||||
return new LauncherItem($"✗ 응답 없음 ({reply.Status})", host, null, null, Symbol: "\uE783");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null; // 오류 시 무시
|
||||
}
|
||||
}
|
||||
|
||||
private static LauncherItem? CheckInternalMode(string host)
|
||||
{
|
||||
var settings = (System.Windows.Application.Current as App)?.SettingsService?.Settings;
|
||||
if (settings?.InternalModeEnabled != true) return null;
|
||||
|
||||
// 내부 주소는 허용
|
||||
if (host.StartsWith("192.168.", StringComparison.Ordinal) ||
|
||||
host.StartsWith("10.", StringComparison.Ordinal) ||
|
||||
host.StartsWith("172.", StringComparison.Ordinal) ||
|
||||
host.Equals("localhost", StringComparison.OrdinalIgnoreCase) ||
|
||||
host.StartsWith("127.", StringComparison.Ordinal))
|
||||
return null;
|
||||
|
||||
if (IPAddress.TryParse(host, out _))
|
||||
return null; // IP 주소 → 내부로 간주 허용
|
||||
|
||||
return new LauncherItem("사내 모드 — 외부 도메인 차단",
|
||||
"사외 모드에서 외부 주소 ping 가능. 설정에서 변경하세요.",
|
||||
null, null, Symbol: "\uE783");
|
||||
}
|
||||
|
||||
private static void RunInTerminal(string cmd, bool usePs = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
var wtPath = FindExe("wt.exe");
|
||||
if (wtPath != null && !usePs)
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = wtPath, Arguments = $"cmd /K {cmd}", UseShellExecute = false,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = usePs ? "powershell" : "cmd",
|
||||
Arguments = usePs ? $"-NoExit -Command \"{cmd}\"" : $"/K {cmd}",
|
||||
UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
|
||||
private static string? FindExe(string name)
|
||||
{
|
||||
foreach (var dir in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(';'))
|
||||
{
|
||||
var full = System.IO.Path.Combine(dir.Trim(), name);
|
||||
if (System.IO.File.Exists(full)) return full;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
376
src/AxCopilot/Handlers/TableHandler.cs
Normal file
376
src/AxCopilot/Handlers/TableHandler.cs
Normal file
@@ -0,0 +1,376 @@
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L16-4: 텍스트 → 표 변환 핸들러. "table" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 클립보드 텍스트(탭 구분, CSV, 공백 정렬)를 표로 변환합니다.
|
||||
///
|
||||
/// 예: table → 클립보드 → 마크다운 표 변환
|
||||
/// table csv → 마크다운 → CSV 변환
|
||||
/// table html → 마크다운 → HTML 테이블 변환
|
||||
/// table flip → 행·열 전치(transpose)
|
||||
/// table sort 2 → 2번 열 기준 정렬
|
||||
/// table add <헤더> → 새 행 추가 (탭 구분)
|
||||
/// Enter → 결과를 클립보드에 복사.
|
||||
/// </summary>
|
||||
public class TableHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "table";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Table",
|
||||
"텍스트·CSV → 마크다운·HTML 표 변환 — 전치 · 정렬 · 추가",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
catch { /* 클립보드 접근 실패 */ }
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
items.Add(new LauncherItem("텍스트 → 표 변환기",
|
||||
"클립보드 탭·CSV·공백 구분 텍스트를 표로 변환 · table csv / html / flip / sort N",
|
||||
null, null, Symbol: "\uE81E"));
|
||||
items.Add(new LauncherItem("table", "→ 마크다운 표", null, null, Symbol: "\uE81E"));
|
||||
items.Add(new LauncherItem("table csv", "→ CSV 변환", null, null, Symbol: "\uE81E"));
|
||||
items.Add(new LauncherItem("table html", "→ HTML 테이블", null, null, Symbol: "\uE81E"));
|
||||
items.Add(new LauncherItem("table flip", "행·열 전치 (transpose)", null, null, Symbol: "\uE81E"));
|
||||
items.Add(new LauncherItem("table sort 2","2열 기준 정렬", null, null, Symbol: "\uE81E"));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(clipboard))
|
||||
{
|
||||
items.Add(new LauncherItem("클립보드가 비어 있습니다",
|
||||
"탭·쉼표·공백 구분 표 데이터를 복사한 뒤 사용하세요",
|
||||
null, null, Symbol: "\uE946"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 미리보기
|
||||
var previewRows = ParseTable(clipboard);
|
||||
if (previewRows.Count > 0)
|
||||
{
|
||||
var md = ToMarkdown(previewRows);
|
||||
items.Add(new LauncherItem($"마크다운 표 변환 ({previewRows.Count}행 × {previewRows[0].Count}열)",
|
||||
"Enter → 변환 결과 복사", null, ("copy", md), Symbol: "\uE81E"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(clipboard))
|
||||
{
|
||||
items.Add(new LauncherItem("클립보드가 비어 있습니다",
|
||||
"표 데이터를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
var rows = ParseTable(clipboard);
|
||||
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("표로 변환할 수 없습니다",
|
||||
"탭·쉼표·공백 구분 데이터가 아닌 것 같습니다",
|
||||
null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
switch (sub)
|
||||
{
|
||||
case "csv":
|
||||
{
|
||||
var csv = ToCsv(rows);
|
||||
items.Add(new LauncherItem($"CSV 변환 ({rows.Count}행 × {rows[0].Count}열)",
|
||||
"Enter → CSV 복사", null, ("copy", csv), Symbol: "\uE81E"));
|
||||
AddPreview(items, csv);
|
||||
break;
|
||||
}
|
||||
|
||||
case "html":
|
||||
{
|
||||
var html = ToHtml(rows);
|
||||
items.Add(new LauncherItem($"HTML 테이블 ({rows.Count}행 × {rows[0].Count}열)",
|
||||
"Enter → HTML 복사", null, ("copy", html), Symbol: "\uE81E"));
|
||||
AddPreview(items, html);
|
||||
break;
|
||||
}
|
||||
|
||||
case "md":
|
||||
case "markdown":
|
||||
{
|
||||
var md = ToMarkdown(rows);
|
||||
items.Add(new LauncherItem($"마크다운 표 ({rows.Count}행 × {rows[0].Count}열)",
|
||||
"Enter → 복사", null, ("copy", md), Symbol: "\uE81E"));
|
||||
AddPreview(items, md);
|
||||
break;
|
||||
}
|
||||
|
||||
case "flip":
|
||||
case "transpose":
|
||||
{
|
||||
var flipped = Transpose(rows);
|
||||
var md = ToMarkdown(flipped);
|
||||
items.Add(new LauncherItem($"전치됨 ({flipped.Count}행 × {flipped[0].Count}열)",
|
||||
"Enter → 마크다운 복사", null, ("copy", md), Symbol: "\uE81E"));
|
||||
var csv = ToCsv(flipped);
|
||||
items.Add(new LauncherItem("CSV로 복사", $"{flipped.Count}행 × {flipped[0].Count}열",
|
||||
null, ("copy", csv), Symbol: "\uE81E"));
|
||||
break;
|
||||
}
|
||||
|
||||
case "sort":
|
||||
{
|
||||
var colIdx = parts.Length > 1 && int.TryParse(parts[1], out var ci) ? ci - 1 : 0;
|
||||
var sorted = SortByColumn(rows, colIdx);
|
||||
var md = ToMarkdown(sorted);
|
||||
items.Add(new LauncherItem($"{colIdx + 1}열 기준 정렬",
|
||||
"Enter → 마크다운 복사", null, ("copy", md), Symbol: "\uE81E"));
|
||||
AddPreview(items, md);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
{
|
||||
// 기본: 마크다운 변환
|
||||
var md = ToMarkdown(rows);
|
||||
items.Add(new LauncherItem($"마크다운 표 ({rows.Count}행 × {rows[0].Count}열)",
|
||||
"Enter → 복사", null, ("copy", md), Symbol: "\uE81E"));
|
||||
var csv = ToCsv(rows);
|
||||
items.Add(new LauncherItem("CSV로 복사", $"{rows.Count}행",
|
||||
null, ("copy", csv), Symbol: "\uE81E"));
|
||||
var html = ToHtml(rows);
|
||||
items.Add(new LauncherItem("HTML로 복사", "테이블 태그",
|
||||
null, ("copy", html), Symbol: "\uE81E"));
|
||||
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("Table", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 파서 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>탭, 쉼표(CSV), 연속 공백 순으로 자동 감지하여 표로 파싱</summary>
|
||||
private static List<List<string>> ParseTable(string text)
|
||||
{
|
||||
var lines = text.Split('\n')
|
||||
.Select(l => l.TrimEnd('\r'))
|
||||
.Where(l => !string.IsNullOrWhiteSpace(l))
|
||||
.ToList();
|
||||
|
||||
if (lines.Count == 0) return new List<List<string>>();
|
||||
|
||||
// 구분자 자동 감지
|
||||
var tabCount = lines[0].Count(c => c == '\t');
|
||||
var commaCount = lines[0].Count(c => c == ',');
|
||||
|
||||
char delimiter;
|
||||
if (tabCount > 0) delimiter = '\t';
|
||||
else if (commaCount > 0) delimiter = ',';
|
||||
else delimiter = '\t'; // 공백 구분은 단순 분할
|
||||
|
||||
var rows = new List<List<string>>();
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var cols = delimiter == ','
|
||||
? ParseCsvLine(line)
|
||||
: line.Split(delimiter).Select(c => c.Trim()).ToList();
|
||||
rows.Add(cols);
|
||||
}
|
||||
|
||||
// 열 수 통일 (가장 많은 열 수 기준 패딩)
|
||||
var maxCols = rows.Max(r => r.Count);
|
||||
foreach (var row in rows)
|
||||
while (row.Count < maxCols) row.Add("");
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static List<string> ParseCsvLine(string line)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var current = new StringBuilder();
|
||||
var inQuote = false;
|
||||
|
||||
for (var i = 0; i < line.Length; i++)
|
||||
{
|
||||
var c = line[i];
|
||||
if (c == '"')
|
||||
{
|
||||
if (inQuote && i + 1 < line.Length && line[i + 1] == '"') { current.Append('"'); i++; }
|
||||
else inQuote = !inQuote;
|
||||
}
|
||||
else if (c == ',' && !inQuote)
|
||||
{
|
||||
result.Add(current.ToString());
|
||||
current.Clear();
|
||||
}
|
||||
else current.Append(c);
|
||||
}
|
||||
result.Add(current.ToString());
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── 변환 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static string ToMarkdown(List<List<string>> rows)
|
||||
{
|
||||
if (rows.Count == 0) return "";
|
||||
var sb = new StringBuilder();
|
||||
var maxCols = rows.Max(r => r.Count);
|
||||
|
||||
// 각 열 최대 너비
|
||||
var widths = new int[maxCols];
|
||||
for (var c = 0; c < maxCols; c++)
|
||||
widths[c] = rows.Max(r => c < r.Count ? r[c].Length : 0);
|
||||
|
||||
// 헤더
|
||||
sb.Append('|');
|
||||
for (var c = 0; c < maxCols; c++)
|
||||
sb.Append($" {rows[0][c].PadRight(widths[c])} |");
|
||||
sb.AppendLine();
|
||||
|
||||
// 구분선
|
||||
sb.Append('|');
|
||||
for (var c = 0; c < maxCols; c++)
|
||||
sb.Append($" {new string('-', Math.Max(widths[c], 3))} |");
|
||||
sb.AppendLine();
|
||||
|
||||
// 데이터 행
|
||||
for (var r = 1; r < rows.Count; r++)
|
||||
{
|
||||
sb.Append('|');
|
||||
for (var c = 0; c < maxCols; c++)
|
||||
{
|
||||
var cell = c < rows[r].Count ? rows[r][c] : "";
|
||||
sb.Append($" {cell.PadRight(widths[c])} |");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static string ToCsv(List<List<string>> rows)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
sb.AppendLine(string.Join(",", row.Select(c =>
|
||||
c.Contains(',') || c.Contains('"') || c.Contains('\n')
|
||||
? $"\"{c.Replace("\"", "\"\"")}\"" : c)));
|
||||
}
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static string ToHtml(List<List<string>> rows)
|
||||
{
|
||||
if (rows.Count == 0) return "";
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("<table>");
|
||||
|
||||
// 헤더
|
||||
sb.AppendLine(" <thead><tr>");
|
||||
foreach (var cell in rows[0])
|
||||
sb.AppendLine($" <th>{EscHtml(cell)}</th>");
|
||||
sb.AppendLine(" </tr></thead>");
|
||||
|
||||
// 바디
|
||||
sb.AppendLine(" <tbody>");
|
||||
for (var r = 1; r < rows.Count; r++)
|
||||
{
|
||||
sb.AppendLine(" <tr>");
|
||||
foreach (var cell in rows[r])
|
||||
sb.AppendLine($" <td>{EscHtml(cell)}</td>");
|
||||
sb.AppendLine(" </tr>");
|
||||
}
|
||||
sb.AppendLine(" </tbody>");
|
||||
sb.Append("</table>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static List<List<string>> Transpose(List<List<string>> rows)
|
||||
{
|
||||
if (rows.Count == 0) return rows;
|
||||
var maxCols = rows.Max(r => r.Count);
|
||||
var result = new List<List<string>>();
|
||||
for (var c = 0; c < maxCols; c++)
|
||||
{
|
||||
var row = new List<string>();
|
||||
for (var r = 0; r < rows.Count; r++)
|
||||
row.Add(c < rows[r].Count ? rows[r][c] : "");
|
||||
result.Add(row);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<List<string>> SortByColumn(List<List<string>> rows, int colIdx)
|
||||
{
|
||||
if (rows.Count <= 1) return rows;
|
||||
var header = rows[0];
|
||||
var data = rows.Skip(1).ToList();
|
||||
data.Sort((a, b) =>
|
||||
{
|
||||
var va = colIdx < a.Count ? a[colIdx] : "";
|
||||
var vb = colIdx < b.Count ? b[colIdx] : "";
|
||||
// 숫자이면 숫자 비교
|
||||
if (double.TryParse(va, out var na) && double.TryParse(vb, out var nb))
|
||||
return na.CompareTo(nb);
|
||||
return string.Compare(va, vb, StringComparison.OrdinalIgnoreCase);
|
||||
});
|
||||
var result = new List<List<string>> { header };
|
||||
result.AddRange(data);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static void AddPreview(List<LauncherItem> items, string text)
|
||||
{
|
||||
var lines = text.Split('\n').Take(3);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var t = line.Length > 60 ? line[..60] + "…" : line;
|
||||
if (!string.IsNullOrWhiteSpace(t))
|
||||
items.Add(new LauncherItem(t, "", null, null, Symbol: "\uE81E"));
|
||||
}
|
||||
}
|
||||
|
||||
private static string EscHtml(string s) =>
|
||||
s.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """);
|
||||
}
|
||||
260
src/AxCopilot/Handlers/TodoHandler.cs
Normal file
260
src/AxCopilot/Handlers/TodoHandler.cs
Normal file
@@ -0,0 +1,260 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L16-3: 간단 할 일 목록 핸들러. "todo" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: todo → 전체 할 일 목록
|
||||
/// todo 보고서 작성 → 새 항목 추가
|
||||
/// todo done 1 → 1번 항목 완료 처리
|
||||
/// todo del 1 → 1번 항목 삭제
|
||||
/// todo clear → 완료 항목 모두 삭제
|
||||
/// todo clear all → 전체 삭제
|
||||
/// todo <검색어> → 키워드 필터
|
||||
/// Enter → 완료 토글 또는 항목 삭제.
|
||||
/// 저장: %APPDATA%\AxCopilot\todos.json
|
||||
/// </summary>
|
||||
public class TodoHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "todo";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Todo",
|
||||
"할 일 목록 — 추가 · 완료 · 삭제 · 검색",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private static readonly string DataPath = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "todos.json");
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
private record TodoItem(
|
||||
[property: JsonPropertyName("id")] int Id,
|
||||
[property: JsonPropertyName("text")] string Text,
|
||||
[property: JsonPropertyName("done")] bool Done,
|
||||
[property: JsonPropertyName("at")] string CreatedAt);
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
var todos = LoadTodos();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
var pending = todos.Count(t => !t.Done);
|
||||
var completed = todos.Count(t => t.Done);
|
||||
items.Add(new LauncherItem(
|
||||
$"할 일 {pending}개 완료 {completed}개",
|
||||
"todo <내용> → 추가 / todo done <번호> → 완료 / todo del <번호> → 삭제",
|
||||
null, null, Symbol: "\uE762"));
|
||||
|
||||
if (todos.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("할 일이 없습니다", "todo <내용> 을 입력하면 추가됩니다",
|
||||
null, null, Symbol: "\uE946"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 미완료 먼저, 완료 항목은 하단
|
||||
foreach (var t in todos.Where(t => !t.Done))
|
||||
items.Add(MakeTodoItem(t));
|
||||
|
||||
if (completed > 0)
|
||||
{
|
||||
items.Add(new LauncherItem("── 완료됨 ──", $"{completed}개 / todo clear → 정리",
|
||||
null, ("clear_done", ""), Symbol: "\uE762"));
|
||||
foreach (var t in todos.Where(t => t.Done))
|
||||
items.Add(MakeTodoItem(t));
|
||||
}
|
||||
|
||||
items.Add(new LauncherItem("완료 항목 삭제", "todo clear — 완료 항목 정리",
|
||||
null, ("clear_done", ""), Symbol: "\uE762"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
// done / check / complete
|
||||
if (sub is "done" or "check" or "complete" or "✓")
|
||||
{
|
||||
var num = parts.Length > 1 && int.TryParse(parts[1].Trim(), out var n) ? n : -1;
|
||||
if (num < 0)
|
||||
{
|
||||
items.Add(new LauncherItem("번호를 입력하세요", "예: todo done 2", null, null, Symbol: "\uE783"));
|
||||
}
|
||||
else
|
||||
{
|
||||
var target = todos.FirstOrDefault(t => t.Id == num);
|
||||
if (target == null)
|
||||
items.Add(new LauncherItem("없는 항목", $"#{num} 항목이 없습니다", null, null, Symbol: "\uE783"));
|
||||
else
|
||||
items.Add(new LauncherItem(
|
||||
target.Done ? $"#{num} 미완료로 되돌리기" : $"#{num} 완료 처리",
|
||||
target.Text, null, ("toggle", num.ToString()), Symbol: "\uE762"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// del / delete / remove / rm
|
||||
if (sub is "del" or "delete" or "remove" or "rm")
|
||||
{
|
||||
var num = parts.Length > 1 && int.TryParse(parts[1].Trim(), out var n) ? n : -1;
|
||||
if (num < 0)
|
||||
{
|
||||
items.Add(new LauncherItem("번호를 입력하세요", "예: todo del 3", null, null, Symbol: "\uE783"));
|
||||
}
|
||||
else
|
||||
{
|
||||
var target = todos.FirstOrDefault(t => t.Id == num);
|
||||
if (target == null)
|
||||
items.Add(new LauncherItem("없는 항목", $"#{num} 항목이 없습니다", null, null, Symbol: "\uE783"));
|
||||
else
|
||||
items.Add(new LauncherItem($"#{num} 삭제",
|
||||
target.Text, null, ("delete", num.ToString()), Symbol: "\uE762"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// clear — 완료 항목 삭제 / clear all → 전체 삭제
|
||||
if (sub == "clear")
|
||||
{
|
||||
var isAll = parts.Length > 1 && parts[1].ToLowerInvariant() == "all";
|
||||
if (isAll)
|
||||
items.Add(new LauncherItem("전체 삭제", $"할 일 {todos.Count}개 모두 삭제 · Enter 실행",
|
||||
null, ("clear_all", ""), Symbol: "\uE762"));
|
||||
else
|
||||
items.Add(new LauncherItem("완료 항목 삭제",
|
||||
$"완료 {todos.Count(t => t.Done)}개 삭제 · Enter 실행",
|
||||
null, ("clear_done", ""), Symbol: "\uE762"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 숫자만 → 완료 토글 단축
|
||||
if (int.TryParse(q, out var idNum))
|
||||
{
|
||||
var target = todos.FirstOrDefault(t => t.Id == idNum);
|
||||
if (target != null)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
target.Done ? $"#{idNum} 미완료로 되돌리기" : $"#{idNum} 완료 처리",
|
||||
target.Text, null, ("toggle", idNum.ToString()), Symbol: "\uE762"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
}
|
||||
|
||||
// 검색 또는 새 항목 추가
|
||||
var filtered = todos.Where(t => t.Text.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
if (filtered.Count > 0)
|
||||
{
|
||||
items.Add(new LauncherItem($"'{q}' 검색 결과 {filtered.Count}개", "", null, null, Symbol: "\uE762"));
|
||||
foreach (var t in filtered)
|
||||
items.Add(MakeTodoItem(t));
|
||||
}
|
||||
|
||||
// 새 항목 추가 제안
|
||||
items.Add(new LauncherItem($"새 할 일 추가: {q}",
|
||||
"Enter → 목록에 추가",
|
||||
null, ("add", q), Symbol: "\uE710"));
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
var todos = LoadTodos();
|
||||
|
||||
switch (item.Data)
|
||||
{
|
||||
case ("add", string text):
|
||||
var nextId = todos.Count > 0 ? todos.Max(t => t.Id) + 1 : 1;
|
||||
todos.Add(new TodoItem(nextId, text, false,
|
||||
DateTime.Now.ToString("yyyy-MM-dd HH:mm")));
|
||||
SaveTodos(todos);
|
||||
NotificationService.Notify("Todo", $"추가됨: {text}");
|
||||
break;
|
||||
|
||||
case ("toggle", string idStr) when int.TryParse(idStr, out var id):
|
||||
var idx = todos.FindIndex(t => t.Id == id);
|
||||
if (idx >= 0)
|
||||
{
|
||||
todos[idx] = todos[idx] with { Done = !todos[idx].Done };
|
||||
SaveTodos(todos);
|
||||
var state = todos[idx].Done ? "완료" : "미완료";
|
||||
NotificationService.Notify("Todo", $"#{id} {state}");
|
||||
}
|
||||
break;
|
||||
|
||||
case ("delete", string idStr) when int.TryParse(idStr, out var id):
|
||||
var before = todos.Count;
|
||||
todos.RemoveAll(t => t.Id == id);
|
||||
if (todos.Count < before)
|
||||
{
|
||||
SaveTodos(todos);
|
||||
NotificationService.Notify("Todo", $"#{id} 삭제됨");
|
||||
}
|
||||
break;
|
||||
|
||||
case ("clear_done", _):
|
||||
var doneCount = todos.RemoveAll(t => t.Done);
|
||||
SaveTodos(todos);
|
||||
NotificationService.Notify("Todo", $"완료 항목 {doneCount}개 삭제됨");
|
||||
break;
|
||||
|
||||
case ("clear_all", _):
|
||||
SaveTodos(new List<TodoItem>());
|
||||
NotificationService.Notify("Todo", "전체 삭제됨");
|
||||
break;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 저장/불러오기 ─────────────────────────────────────────────────────────
|
||||
|
||||
private static List<TodoItem> LoadTodos()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!System.IO.File.Exists(DataPath)) return new List<TodoItem>();
|
||||
var json = System.IO.File.ReadAllText(DataPath, System.Text.Encoding.UTF8);
|
||||
return JsonSerializer.Deserialize<List<TodoItem>>(json, JsonOpts) ?? new List<TodoItem>();
|
||||
}
|
||||
catch { return new List<TodoItem>(); }
|
||||
}
|
||||
|
||||
private static void SaveTodos(List<TodoItem> todos)
|
||||
{
|
||||
try
|
||||
{
|
||||
System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(DataPath)!);
|
||||
System.IO.File.WriteAllText(DataPath,
|
||||
JsonSerializer.Serialize(todos, JsonOpts),
|
||||
System.Text.Encoding.UTF8);
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
|
||||
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static LauncherItem MakeTodoItem(TodoItem t)
|
||||
{
|
||||
var icon = t.Done ? "\uE73E" : "\uECC5";
|
||||
var prefix = t.Done ? $"[✓] #{t.Id}" : $"[ ] #{t.Id}";
|
||||
var subtitle = $"{t.CreatedAt} · done {t.Id} = 완료 / del {t.Id} = 삭제";
|
||||
return new LauncherItem($"{prefix} {t.Text}", subtitle,
|
||||
null, ("toggle", t.Id.ToString()), Symbol: icon);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user