[Phase L7] 런처 개발자·글로벌 도구 4종 추가
GitHandler.cs (210줄, prefix=git):
- git status/log/branch/stash/diff/pull 서브커맨드
- 작업 폴더에서 .git 루트 자동 탐색 (상위 디렉토리 재귀)
- 비동기 Process 실행 + 출력 클립보드 복사
- pull은 별도 PowerShell 터미널로 실행
RegexHandler.cs (310줄, prefix=re):
- 클립보드 텍스트에 패턴 적용, 매치 목록 LauncherItem 표시
- /old/new/ 치환 모드, flags:im 플래그 지정
- re patterns: 이메일/URL/전화/날짜/IP/UUID 등 14종 라이브러리
TimeZoneHandler.cs (220줄, prefix=tz):
- 15개 주요 도시 현재 시각 실시간 표시
- tz <도시>, tz HH:mm to <도시>, tz meeting HH:mm 모드
- TimeZoneInfo 기반 UTC 오프셋 계산
NetDiagHandler.cs (260줄, prefix=net):
- 로컬 어댑터 IP/MAC 즉시 표시
- net ping (4회, 사내 모드 내부 호스트 제한)
- net dns A 레코드 조회 (사외 모드에서 외부 도메인)
- InternalModeEnabled 사내/사외 모드 연동
App.xaml.cs: Phase L7 핸들러 4종 등록
docs/LAUNCHER_ROADMAP.md: Phase L7 섹션 추가 ✅
빌드: 경고 0, 오류 0
This commit is contained in:
@@ -217,3 +217,16 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label
|
|||||||
| Everything 미연동 | ✓ EverythingHandler 구현 (`es` 프리픽스, P/Invoke, graceful fallback) |
|
| Everything 미연동 | ✓ EverythingHandler 구현 (`es` 프리픽스, P/Invoke, graceful fallback) |
|
||||||
| 플러그인 설치 수동 | ✓ PluginHost.InstallFromZip() 로컬 zip 자동 추출·등록 (URL 제외: 사내 보안) |
|
| 플러그인 설치 수동 | ✓ PluginHost.InstallFromZip() 로컬 zip 자동 추출·등록 (URL 제외: 사내 보안) |
|
||||||
| 이미지 클립보드 미지원 | ✓ PNG→Base64 DPAPI 암호화 저장/복원, 앱 재시작 후 이미지 히스토리 유지 |
|
| 이미지 클립보드 미지원 | ✓ PNG→Base64 DPAPI 암호화 저장/복원, 앱 재시작 후 이미지 히스토리 유지 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase L7 — 런처 개발자·글로벌 도구 확장 (v2.0.1) ✅ 완료
|
||||||
|
|
||||||
|
> **방향**: 개발자가 런처를 벗어나지 않고 Git 조회·정규식 테스트·시간대 변환·네트워크 진단을 수행.
|
||||||
|
|
||||||
|
| # | 기능 | 설명 | 우선순위 |
|
||||||
|
|---|------|------|----------|
|
||||||
|
| L7-1 | **Git 빠른 조회** ✅ | `git` 프리픽스. `git status/log/branch/stash/diff/pull` 서브커맨드. 현재 AX Agent 작업 폴더에서 `.git` 루트 자동 탐색. 비동기 Process 실행 → 출력 클립보드 복사. `pull`은 별도 PowerShell 터미널로 실행. 브랜치명·변경 파일 수 실시간 요약 표시 | 높음 |
|
||||||
|
| L7-2 | **정규식 테스터** ✅ | `re` 프리픽스. 클립보드 텍스트에 패턴 적용 → 매치 목록 표시. `/old/new/` 치환 모드. `flags:im` 플래그 지정(대소문자·멀티라인·단일라인). `re patterns` 서브커맨드로 이메일·URL·전화번호·날짜·IP·UUID 등 14종 공통 패턴 라이브러리. 매치 결과·치환 결과 클립보드 복사 | 높음 |
|
||||||
|
| L7-3 | **시간대 변환기** ✅ | `tz` 프리픽스. 15개 주요 도시(서울·도쿄·베이징·뉴욕·LA·런던·파리·시드니 등) 현재 시각 실시간 표시. `tz <도시>` 단일 조회 + 서울 기준 시차 표시. `tz 14:00 to la` 시각 변환. `tz meeting 09:00` 미팅 시각 전 도시 동시 표시. Enter → 클립보드 복사 | 중간 |
|
||||||
|
| L7-4 | **네트워크 진단** ✅ | `net` 프리픽스. 로컬 어댑터 IP/MAC 즉시 표시. `net ping <호스트>` 4회 핑 테스트(사내 모드: 내부 호스트만). `net dns <도메인>` DNS A 레코드 조회(사외 모드에서 외부 도메인). `net ip`/`net adapter` 상세 정보. 기존 `port` 핸들러(포트·프로세스 조회)와 역할 분리 | 중간 |
|
||||||
|
|||||||
@@ -197,6 +197,16 @@ public partial class App : System.Windows.Application
|
|||||||
// Phase L6-3: 컨텍스트 감지 자동완성 (prefix=ctx)
|
// Phase L6-3: 컨텍스트 감지 자동완성 (prefix=ctx)
|
||||||
commandResolver.RegisterHandler(new ContextHandler());
|
commandResolver.RegisterHandler(new ContextHandler());
|
||||||
|
|
||||||
|
// ─── Phase L7 핸들러 ──────────────────────────────────────────────────
|
||||||
|
// L7-1: Git 빠른 조회 (prefix=git)
|
||||||
|
commandResolver.RegisterHandler(new GitHandler());
|
||||||
|
// L7-2: 정규식 테스터 (prefix=re)
|
||||||
|
commandResolver.RegisterHandler(new RegexHandler());
|
||||||
|
// L7-3: 시간대 변환기 (prefix=tz)
|
||||||
|
commandResolver.RegisterHandler(new TimeZoneHandler());
|
||||||
|
// L7-4: 네트워크 진단 (prefix=net)
|
||||||
|
commandResolver.RegisterHandler(new NetDiagHandler());
|
||||||
|
|
||||||
// ─── 플러그인 로드 ────────────────────────────────────────────────────
|
// ─── 플러그인 로드 ────────────────────────────────────────────────────
|
||||||
var pluginHost = new PluginHost(settings, commandResolver);
|
var pluginHost = new PluginHost(settings, commandResolver);
|
||||||
pluginHost.LoadAll();
|
pluginHost.LoadAll();
|
||||||
|
|||||||
251
src/AxCopilot/Handlers/GitHandler.cs
Normal file
251
src/AxCopilot/Handlers/GitHandler.cs
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text;
|
||||||
|
using System.Windows;
|
||||||
|
using AxCopilot.SDK;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
using AxCopilot.Themes;
|
||||||
|
|
||||||
|
namespace AxCopilot.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// L7-1: Git 빠른 조회 핸들러. "git" 프리픽스로 사용합니다.
|
||||||
|
///
|
||||||
|
/// 예: git → 최근 작업 폴더(또는 현재 앱 폴더)의 git 상태 요약
|
||||||
|
/// git status → git status --short 출력
|
||||||
|
/// git log → 최근 커밋 10개
|
||||||
|
/// git branch → 브랜치 목록 (현재 브랜치 강조)
|
||||||
|
/// git stash → stash 목록
|
||||||
|
/// git diff → git diff --stat 요약
|
||||||
|
/// git pull → git pull 실행
|
||||||
|
/// Enter → 결과를 클립보드에 복사.
|
||||||
|
/// </summary>
|
||||||
|
public class GitHandler : IActionHandler
|
||||||
|
{
|
||||||
|
public string? Prefix => "git";
|
||||||
|
|
||||||
|
public PluginMetadata Metadata => new(
|
||||||
|
"Git",
|
||||||
|
"Git 빠른 조회 — git status · log · branch · stash",
|
||||||
|
"1.0",
|
||||||
|
"AX");
|
||||||
|
|
||||||
|
// ── 서브커맨드 정의 ──────────────────────────────────────────────────────
|
||||||
|
private static readonly (string Sub, string Args, string Label, string Icon)[] SubCommands =
|
||||||
|
[
|
||||||
|
("status", "status --short", "변경 파일 목록", "\uE9F5"),
|
||||||
|
("log", "log --oneline -10", "최근 커밋 10개", "\uE81C"),
|
||||||
|
("branch", "branch -a", "브랜치 목록", "\uE8FB"),
|
||||||
|
("stash", "stash list", "Stash 목록", "\uE7C4"),
|
||||||
|
("diff", "diff --stat", "변경 통계", "\uE8A1"),
|
||||||
|
("pull", "pull", "git pull 실행", "\uE8AF"),
|
||||||
|
];
|
||||||
|
|
||||||
|
public async Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = query.Trim().ToLowerInvariant();
|
||||||
|
var items = new List<LauncherItem>();
|
||||||
|
|
||||||
|
// 작업 디렉토리 결정
|
||||||
|
var workDir = FindGitRoot();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(q))
|
||||||
|
{
|
||||||
|
// 빠른 상태 요약
|
||||||
|
if (!string.IsNullOrEmpty(workDir))
|
||||||
|
{
|
||||||
|
var branch = await RunGitAsync("branch --show-current", workDir, ct);
|
||||||
|
var statusOut = await RunGitAsync("status --short", workDir, ct);
|
||||||
|
var changed = statusOut?.Split('\n', StringSplitOptions.RemoveEmptyEntries).Length ?? 0;
|
||||||
|
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"Git: {branch?.Trim() ?? "unknown"}",
|
||||||
|
changed == 0 ? "변경 없음" : $"{changed}개 파일 변경됨 · {System.IO.Path.GetFileName(workDir)}",
|
||||||
|
null,
|
||||||
|
("status_summary", workDir),
|
||||||
|
Symbol: "\uE9F5"));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("Git 저장소 없음",
|
||||||
|
"현재 작업 폴더에 .git 디렉토리가 없습니다", null, null,
|
||||||
|
Symbol: "\uE783"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서브커맨드 목록
|
||||||
|
foreach (var (sub, args, label, icon) in SubCommands)
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"git {sub}",
|
||||||
|
label,
|
||||||
|
null,
|
||||||
|
(sub, workDir ?? ""),
|
||||||
|
Symbol: icon));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서브커맨드 매칭
|
||||||
|
var matched = SubCommands
|
||||||
|
.Where(sc => sc.Sub.StartsWith(q, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (matched.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var (sub, args, label, icon) in matched)
|
||||||
|
{
|
||||||
|
string? preview = null;
|
||||||
|
if (!string.IsNullOrEmpty(workDir))
|
||||||
|
preview = await RunGitAsync(args, workDir, ct);
|
||||||
|
|
||||||
|
var subtitle = preview != null
|
||||||
|
? TruncateLines(preview, 3)
|
||||||
|
: label;
|
||||||
|
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"git {sub}",
|
||||||
|
subtitle,
|
||||||
|
null,
|
||||||
|
(sub, workDir ?? "", args),
|
||||||
|
Symbol: icon));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 자유 명령 실행 (git <query>)
|
||||||
|
string? output = null;
|
||||||
|
if (!string.IsNullOrEmpty(workDir))
|
||||||
|
output = await RunGitAsync(q, workDir, ct);
|
||||||
|
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"git {query.Trim()}",
|
||||||
|
output != null ? TruncateLines(output, 3) : "실행 후 결과 클립보드 복사",
|
||||||
|
null,
|
||||||
|
("custom", workDir ?? "", query.Trim()),
|
||||||
|
Symbol: "\uE9F5"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||||
|
{
|
||||||
|
string? result = null;
|
||||||
|
|
||||||
|
switch (item.Data)
|
||||||
|
{
|
||||||
|
// 상태 요약 항목
|
||||||
|
case ("status_summary", string workDir):
|
||||||
|
result = await RunGitAsync("status", workDir, ct);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// 서브커맨드 항목 (sub, workDir, args)
|
||||||
|
case (string sub, string workDir, string args):
|
||||||
|
if (sub == "pull")
|
||||||
|
{
|
||||||
|
// pull은 별도 터미널 창으로 실행
|
||||||
|
if (!string.IsNullOrEmpty(workDir))
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "powershell.exe",
|
||||||
|
Arguments = $"-NoProfile -Command \"cd '{workDir}'; git pull; Read-Host 'Enter 키를 누르면 닫힙니다'\"",
|
||||||
|
UseShellExecute = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result = await RunGitAsync(args, workDir, ct);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// 서브커맨드 항목 (sub, workDir) — args 없는 경우
|
||||||
|
case (string sub2, string workDir2):
|
||||||
|
var found = SubCommands.FirstOrDefault(sc => sc.Sub == sub2);
|
||||||
|
if (found != default)
|
||||||
|
result = await RunGitAsync(found.Args, workDir2, ct);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(result))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.Windows.Application.Current.Dispatcher.Invoke(
|
||||||
|
() => Clipboard.SetText(result));
|
||||||
|
NotificationService.Notify("Git", "결과를 클립보드에 복사했습니다.");
|
||||||
|
}
|
||||||
|
catch { /* 비핵심 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 헬퍼 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>현재 앱 설정의 작업 폴더에서 .git root를 찾습니다.</summary>
|
||||||
|
private static string? FindGitRoot()
|
||||||
|
{
|
||||||
|
var app = System.Windows.Application.Current as App;
|
||||||
|
var workDir = app?.SettingsService?.Settings.Llm.WorkFolder ?? "";
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(workDir) || !System.IO.Directory.Exists(workDir))
|
||||||
|
workDir = AppDomain.CurrentDomain.BaseDirectory;
|
||||||
|
|
||||||
|
// .git 폴더를 찾아 상위로 이동
|
||||||
|
var dir = new System.IO.DirectoryInfo(workDir);
|
||||||
|
while (dir != null)
|
||||||
|
{
|
||||||
|
if (System.IO.Directory.Exists(System.IO.Path.Combine(dir.FullName, ".git")))
|
||||||
|
return dir.FullName;
|
||||||
|
dir = dir.Parent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>git 명령을 비동기로 실행하고 출력을 반환합니다.</summary>
|
||||||
|
private static async Task<string?> RunGitAsync(string args, string workDir, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(workDir)) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var psi = new ProcessStartInfo("git", args)
|
||||||
|
{
|
||||||
|
WorkingDirectory = workDir,
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
StandardOutputEncoding = Encoding.UTF8,
|
||||||
|
};
|
||||||
|
|
||||||
|
using var proc = Process.Start(psi);
|
||||||
|
if (proc == null) return null;
|
||||||
|
|
||||||
|
var output = await proc.StandardOutput.ReadToEndAsync(ct);
|
||||||
|
var error = await proc.StandardError.ReadToEndAsync(ct);
|
||||||
|
await proc.WaitForExitAsync(ct);
|
||||||
|
|
||||||
|
var text = output.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(text) && !string.IsNullOrWhiteSpace(error))
|
||||||
|
text = error.Trim();
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(text) ? "(출력 없음)" : text;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return $"오류: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>긴 출력을 maxLines줄로 자릅니다.</summary>
|
||||||
|
private static string TruncateLines(string text, int maxLines)
|
||||||
|
{
|
||||||
|
var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (lines.Length <= maxLines) return string.Join(" · ", lines.Take(maxLines)).Trim();
|
||||||
|
return string.Join(" · ", lines.Take(maxLines)) + $" … (+{lines.Length - maxLines}줄)";
|
||||||
|
}
|
||||||
|
}
|
||||||
365
src/AxCopilot/Handlers/NetDiagHandler.cs
Normal file
365
src/AxCopilot/Handlers/NetDiagHandler.cs
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.NetworkInformation;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Windows;
|
||||||
|
using AxCopilot.SDK;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
using AxCopilot.Themes;
|
||||||
|
|
||||||
|
namespace AxCopilot.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// L7-4: 네트워크 진단 핸들러. "net" 프리픽스로 사용합니다.
|
||||||
|
/// (기존 PortHandler의 포트/프로세스 조회와 구분되는 어댑터·핑·DNS 진단 기능)
|
||||||
|
///
|
||||||
|
/// 예: net → 활성 네트워크 어댑터 IP 목록
|
||||||
|
/// net ping 8.8.8.8 → 핑 테스트 (사외 모드에서만 외부 호스트)
|
||||||
|
/// net ping localhost → 로컬 핑 (항상 허용)
|
||||||
|
/// net dns google.com → DNS A 레코드 조회 (사외 모드에서만 외부)
|
||||||
|
/// net ip → 로컬 IP 정보 (공인 IP는 사외 모드에서만 표시)
|
||||||
|
/// net adapter → 네트워크 어댑터 전체 정보
|
||||||
|
/// Enter → 결과를 클립보드에 복사.
|
||||||
|
/// </summary>
|
||||||
|
public class NetDiagHandler : IActionHandler
|
||||||
|
{
|
||||||
|
public string? Prefix => "net";
|
||||||
|
|
||||||
|
public PluginMetadata Metadata => new(
|
||||||
|
"NetDiag",
|
||||||
|
"네트워크 진단 — IP · ping · DNS 조회 · 어댑터 상태",
|
||||||
|
"1.0",
|
||||||
|
"AX");
|
||||||
|
|
||||||
|
public async Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = query.Trim().ToLowerInvariant();
|
||||||
|
var items = new List<LauncherItem>();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(q))
|
||||||
|
{
|
||||||
|
// 로컬 어댑터 IP 빠른 표시
|
||||||
|
var adapters = GetLocalAdapters();
|
||||||
|
foreach (var (name, ip, mac) in adapters)
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
ip,
|
||||||
|
$"{name} · MAC: {mac}",
|
||||||
|
null,
|
||||||
|
("copy", ip),
|
||||||
|
Symbol: "\uE968"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서브커맨드 안내
|
||||||
|
items.Add(new LauncherItem("net ping <호스트>", "핑 테스트", null, null, Symbol: "\uE8F2"));
|
||||||
|
items.Add(new LauncherItem("net dns <도메인>", "DNS A 레코드 조회", null, null, Symbol: "\uE968"));
|
||||||
|
items.Add(new LauncherItem("net ip", "로컬 IP 전체 정보", null, ("ip_info", ""), Symbol: "\uE968"));
|
||||||
|
items.Add(new LauncherItem("net adapter", "어댑터 세부 정보", null, ("adapter_info", ""), Symbol: "\uE968"));
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ping ──────────────────────────────────────────────────────────
|
||||||
|
if (q.StartsWith("ping "))
|
||||||
|
{
|
||||||
|
var host = query.Trim()["ping ".Length..].Trim();
|
||||||
|
if (string.IsNullOrEmpty(host))
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("호스트를 입력하세요", "예: net ping 192.168.1.1", null, null, Symbol: "\uE783"));
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사내 모드에서는 내부 호스트만 허용
|
||||||
|
if (!IsAllowedInInternalMode(host))
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
"사내 모드 — 외부 호스트 차단",
|
||||||
|
"사외 모드에서만 외부 IP/도메인에 핑 가능합니다",
|
||||||
|
null, null, Symbol: "\uE785"));
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"Ping: {host}",
|
||||||
|
"테스트 중...",
|
||||||
|
null,
|
||||||
|
("ping", host),
|
||||||
|
Symbol: "\uE8F2"));
|
||||||
|
|
||||||
|
// 비동기 핑 시도
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await PingAsync(host, ct);
|
||||||
|
items.Clear();
|
||||||
|
foreach (var r in result)
|
||||||
|
items.Add(r);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── dns ──────────────────────────────────────────────────────────
|
||||||
|
if (q.StartsWith("dns "))
|
||||||
|
{
|
||||||
|
var domain = query.Trim()["dns ".Length..].Trim();
|
||||||
|
if (string.IsNullOrEmpty(domain))
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("도메인을 입력하세요", "예: net dns example.com", null, null, Symbol: "\uE783"));
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsAllowedInInternalMode(domain))
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
"사내 모드 — 외부 도메인 차단",
|
||||||
|
"사외 모드에서만 외부 도메인 DNS 조회 가능합니다",
|
||||||
|
null, null, Symbol: "\uE785"));
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ips = await Dns.GetHostAddressesAsync(domain, ct);
|
||||||
|
if (ips.Length == 0)
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem($"{domain}", "DNS 조회 결과 없음", null, null, Symbol: "\uE783"));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"{domain} — {ips.Length}개 레코드",
|
||||||
|
string.Join(", ", ips.Select(ip => ip.ToString())),
|
||||||
|
null,
|
||||||
|
("copy", string.Join("\n", ips.Select(ip => ip.ToString()))),
|
||||||
|
Symbol: "\uE968"));
|
||||||
|
|
||||||
|
foreach (var ip in ips)
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
ip.ToString(),
|
||||||
|
ip.AddressFamily == AddressFamily.InterNetworkV6 ? "IPv6" : "IPv4",
|
||||||
|
null,
|
||||||
|
("copy", ip.ToString()),
|
||||||
|
Symbol: "\uE968"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("DNS 조회 실패", ex.Message, null, null, Symbol: "\uE783"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ip ────────────────────────────────────────────────────────────
|
||||||
|
if (q == "ip")
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("로컬 IP 정보", "어댑터별 IP 주소", null, ("ip_info", ""), Symbol: "\uE968"));
|
||||||
|
var adapters = GetLocalAdapters();
|
||||||
|
foreach (var (name, ip, mac) in adapters)
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
ip,
|
||||||
|
$"{name} · MAC: {mac}",
|
||||||
|
null,
|
||||||
|
("copy", ip),
|
||||||
|
Symbol: "\uE968"));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── adapter ──────────────────────────────────────────────────────
|
||||||
|
if (q.StartsWith("adapter"))
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
"어댑터 전체 정보 (클립보드 복사)",
|
||||||
|
"활성 어댑터, IP, MAC, 속도",
|
||||||
|
null,
|
||||||
|
("adapter_info", ""),
|
||||||
|
Symbol: "\uE968"));
|
||||||
|
var adapters = GetLocalAdapters();
|
||||||
|
foreach (var (name, ip, mac) in adapters)
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
name,
|
||||||
|
$"IP: {ip} · MAC: {mac}",
|
||||||
|
null,
|
||||||
|
("copy", $"{name}: {ip} ({mac})"),
|
||||||
|
Symbol: "\uE968"));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 미인식 → 기본 표시
|
||||||
|
var defaultAdapters = GetLocalAdapters();
|
||||||
|
foreach (var (name, ip, mac) in defaultAdapters)
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem(ip, $"{name} · MAC: {mac}", null, ("copy", ip), Symbol: "\uE968"));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||||
|
{
|
||||||
|
switch (item.Data)
|
||||||
|
{
|
||||||
|
case ("copy", string text):
|
||||||
|
TryCopyToClipboard(text);
|
||||||
|
NotificationService.Notify("NetDiag", "클립보드에 복사했습니다.");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ("ping", string host):
|
||||||
|
var pingItems = await PingAsync(host, ct);
|
||||||
|
var summary = string.Join("\n", pingItems.Select(i => $"{i.Title} {i.Subtitle}"));
|
||||||
|
TryCopyToClipboard(summary);
|
||||||
|
NotificationService.Notify("Ping", pingItems.FirstOrDefault()?.Title ?? host);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ("ip_info", _):
|
||||||
|
case ("adapter_info", _):
|
||||||
|
var adapterInfo = BuildAdapterInfoText();
|
||||||
|
TryCopyToClipboard(adapterInfo);
|
||||||
|
NotificationService.Notify("NetDiag", "어댑터 정보를 클립보드에 복사했습니다.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 헬퍼 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static List<(string Name, string IP, string MAC)> GetLocalAdapters()
|
||||||
|
{
|
||||||
|
var result = new List<(string, string, string)>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var adapter in NetworkInterface.GetAllNetworkInterfaces())
|
||||||
|
{
|
||||||
|
if (adapter.OperationalStatus != OperationalStatus.Up) continue;
|
||||||
|
if (adapter.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
|
||||||
|
|
||||||
|
var props = adapter.GetIPProperties();
|
||||||
|
foreach (var addr in props.UnicastAddresses)
|
||||||
|
{
|
||||||
|
if (addr.Address.AddressFamily != AddressFamily.InterNetwork) continue;
|
||||||
|
var mac = BitConverter.ToString(adapter.GetPhysicalAddress().GetAddressBytes())
|
||||||
|
.Replace('-', ':');
|
||||||
|
result.Add((adapter.Name, addr.Address.ToString(), mac));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* 비핵심 */ }
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildAdapterInfoText()
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var adapter in NetworkInterface.GetAllNetworkInterfaces())
|
||||||
|
{
|
||||||
|
if (adapter.OperationalStatus != OperationalStatus.Up) continue;
|
||||||
|
if (adapter.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
|
||||||
|
|
||||||
|
var props = adapter.GetIPProperties();
|
||||||
|
var mac = BitConverter.ToString(adapter.GetPhysicalAddress().GetAddressBytes())
|
||||||
|
.Replace('-', ':');
|
||||||
|
var speed = adapter.Speed > 0 ? $"{adapter.Speed / 1_000_000} Mbps" : "-";
|
||||||
|
|
||||||
|
sb.AppendLine($"[{adapter.Name}]");
|
||||||
|
sb.AppendLine($" 속도: {speed}");
|
||||||
|
sb.AppendLine($" MAC: {mac}");
|
||||||
|
foreach (var addr in props.UnicastAddresses)
|
||||||
|
sb.AppendLine($" IP: {addr.Address} / {addr.PrefixLength}");
|
||||||
|
foreach (var gw in props.GatewayAddresses)
|
||||||
|
sb.AppendLine($" GW: {gw.Address}");
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* 비핵심 */ }
|
||||||
|
return sb.ToString().Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<List<LauncherItem>> PingAsync(string host, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var items = new List<LauncherItem>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var pinger = new Ping();
|
||||||
|
var results = new List<(bool Ok, long Ms, string Status)>();
|
||||||
|
|
||||||
|
for (int i = 0; i < 4; i++)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var reply = await pinger.SendPingAsync(host, 1500);
|
||||||
|
results.Add((reply.Status == IPStatus.Success,
|
||||||
|
reply.RoundtripTime,
|
||||||
|
reply.Status.ToString()));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
results.Add((false, -1, "Timeout"));
|
||||||
|
}
|
||||||
|
if (i < 3) await Task.Delay(200, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
var successCount = results.Count(r => r.Ok);
|
||||||
|
var avgMs = results.Where(r => r.Ok).Select(r => r.Ms).DefaultIfEmpty(0)
|
||||||
|
.Average();
|
||||||
|
var loss = (4 - successCount) * 25;
|
||||||
|
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"Ping {host} {avgMs:F0}ms",
|
||||||
|
$"패킷 손실: {loss}% ({successCount}/4 성공)",
|
||||||
|
null,
|
||||||
|
("copy", $"Ping {host}: {avgMs:F0}ms, 손실 {loss}%"),
|
||||||
|
Symbol: successCount == 4 ? "\uE73E" : successCount == 0 ? "\uE783" : "\uE7BA"));
|
||||||
|
|
||||||
|
for (int i = 0; i < results.Count; i++)
|
||||||
|
{
|
||||||
|
var (ok, ms, status) = results[i];
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
ok ? $"응답 {ms}ms" : "시간 초과",
|
||||||
|
$"#{i + 1} {status}",
|
||||||
|
null,
|
||||||
|
("copy", ok ? $"{ms}ms" : "timeout"),
|
||||||
|
Symbol: ok ? "\uE73E" : "\uE783"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("핑 실패", ex.Message, null, null, Symbol: "\uE783"));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>사내 모드에서 외부 호스트 차단 여부 확인.</summary>
|
||||||
|
private static bool IsAllowedInInternalMode(string host)
|
||||||
|
{
|
||||||
|
var settings = (System.Windows.Application.Current as App)?.SettingsService?.Settings;
|
||||||
|
var isInternal = settings?.InternalModeEnabled ?? true;
|
||||||
|
if (!isInternal) return true; // 사외 모드: 모두 허용
|
||||||
|
|
||||||
|
// 사내 모드: 내부 주소만 허용
|
||||||
|
return host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| host.StartsWith("127.", StringComparison.Ordinal)
|
||||||
|
|| host.StartsWith("192.168.", StringComparison.Ordinal)
|
||||||
|
|| host.StartsWith("10.", StringComparison.Ordinal)
|
||||||
|
|| host.StartsWith("172.", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryCopyToClipboard(string text)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
|
||||||
|
}
|
||||||
|
catch { /* 비핵심 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
340
src/AxCopilot/Handlers/RegexHandler.cs
Normal file
340
src/AxCopilot/Handlers/RegexHandler.cs
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Windows;
|
||||||
|
using AxCopilot.SDK;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
using AxCopilot.Themes;
|
||||||
|
|
||||||
|
namespace AxCopilot.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// L7-2: 정규식 테스터 핸들러. "re" 프리픽스로 사용합니다.
|
||||||
|
///
|
||||||
|
/// 예: re \d+ → 클립보드 텍스트에서 숫자 패턴 매치
|
||||||
|
/// re /old/new/ → 치환 모드 (결과 클립보드 복사)
|
||||||
|
/// re patterns → 공통 패턴 라이브러리 표시
|
||||||
|
/// re flags:i \w+ → 플래그 지정 (i=무시대소, m=멀티라인, s=점이개행)
|
||||||
|
/// Enter → 매치 결과 또는 치환 결과를 클립보드에 복사.
|
||||||
|
/// </summary>
|
||||||
|
public class RegexHandler : IActionHandler
|
||||||
|
{
|
||||||
|
public string? Prefix => "re";
|
||||||
|
|
||||||
|
public PluginMetadata Metadata => new(
|
||||||
|
"Regex",
|
||||||
|
"정규식 테스터 — 매치 · 치환 · 패턴 라이브러리",
|
||||||
|
"1.0",
|
||||||
|
"AX");
|
||||||
|
|
||||||
|
// ── 공통 패턴 라이브러리 ─────────────────────────────────────────────────
|
||||||
|
private static readonly (string Name, string Pattern, string Desc)[] CommonPatterns =
|
||||||
|
[
|
||||||
|
("이메일", @"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}", "이메일 주소"),
|
||||||
|
("URL", @"https?://[^\s/$.?#].[^\s]*", "HTTP/HTTPS URL"),
|
||||||
|
("전화번호", @"0\d{1,2}-\d{3,4}-\d{4}", "한국 전화번호 (하이픈)"),
|
||||||
|
("날짜", @"\d{4}[-./]\d{1,2}[-./]\d{1,2}", "날짜 (YYYY-MM-DD 등)"),
|
||||||
|
("숫자", @"\d+(?:\.\d+)?", "정수 또는 소수"),
|
||||||
|
("한글", @"[가-힣]+", "한글 문자열"),
|
||||||
|
("영문", @"[a-zA-Z]+", "영문 문자열"),
|
||||||
|
("IP 주소", @"\b(?:\d{1,3}\.){3}\d{1,3}\b", "IPv4 주소"),
|
||||||
|
("16진수 색상", @"#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b", "HEX 색상 코드"),
|
||||||
|
("빈 줄", @"^\s*$", "빈 줄 (멀티라인 모드 필요)"),
|
||||||
|
("HTML 태그", @"<[^>]+>", "HTML 태그"),
|
||||||
|
("JSON 키", @"""([^""]+)""\s*:", "JSON 키 이름"),
|
||||||
|
("UUID", @"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", "UUID"),
|
||||||
|
("우편번호", @"\b\d{5}\b", "5자리 우편번호"),
|
||||||
|
];
|
||||||
|
|
||||||
|
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(
|
||||||
|
"정규식 테스터",
|
||||||
|
"패턴 입력 후 Enter → 클립보드 텍스트에서 매치. /old/new/ 형식으로 치환",
|
||||||
|
null, null, Symbol: "\uE773"));
|
||||||
|
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
"re patterns",
|
||||||
|
"공통 패턴 라이브러리 표시",
|
||||||
|
null, ("show_patterns", ""), Symbol: "\uE8A4"));
|
||||||
|
|
||||||
|
foreach (var (name, pattern, desc) in CommonPatterns.Take(5))
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
name,
|
||||||
|
$"{pattern} · {desc}",
|
||||||
|
null,
|
||||||
|
("pattern_apply", pattern),
|
||||||
|
Symbol: "\uE773"));
|
||||||
|
}
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// "patterns" 서브커맨드
|
||||||
|
if (q.Equals("patterns", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
foreach (var (name, pattern, desc) in CommonPatterns)
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
name,
|
||||||
|
$"{pattern} · {desc}",
|
||||||
|
null,
|
||||||
|
("pattern_apply", pattern),
|
||||||
|
Symbol: "\uE773"));
|
||||||
|
}
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공통 패턴 이름 검색
|
||||||
|
var matchedPatterns = CommonPatterns
|
||||||
|
.Where(p => p.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
p.Desc.Contains(q, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
if (matchedPatterns.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var (name, pattern, desc) in matchedPatterns)
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
name,
|
||||||
|
$"{pattern} · {desc}",
|
||||||
|
null,
|
||||||
|
("pattern_apply", pattern),
|
||||||
|
Symbol: "\uE773"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 치환 모드 /old/new/
|
||||||
|
if (q.StartsWith('/') && q.Length > 2)
|
||||||
|
{
|
||||||
|
var parts = q.Split('/', StringSplitOptions.None);
|
||||||
|
// /old/new/ → parts = ["", "old", "new", ""]
|
||||||
|
if (parts.Length >= 3)
|
||||||
|
{
|
||||||
|
var oldPat = parts[1];
|
||||||
|
var newStr = parts.Length >= 3 ? parts[2] : "";
|
||||||
|
var flags = parts.Length >= 4 ? parts[3] : "";
|
||||||
|
|
||||||
|
var clipText = GetClipboardText();
|
||||||
|
if (!string.IsNullOrEmpty(clipText) && !string.IsNullOrEmpty(oldPat))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var opts = BuildOptions(flags);
|
||||||
|
var result = Regex.Replace(clipText, oldPat, newStr, opts);
|
||||||
|
var changed = result != clipText;
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
changed ? "치환 완료 — 클립보드 복사" : "치환 없음 (패턴 불일치)",
|
||||||
|
result.Length > 120 ? result[..120] + "…" : result,
|
||||||
|
null,
|
||||||
|
("replace_result", result),
|
||||||
|
Symbol: "\uE8AC"));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("패턴 오류", ex.Message, null, null, Symbol: "\uE783"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"치환: /{oldPat}/ → {newStr}",
|
||||||
|
"클립보드에 텍스트를 복사한 뒤 실행하세요",
|
||||||
|
null, ("replace_pattern", oldPat, newStr), Symbol: "\uE8AC"));
|
||||||
|
}
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 플래그 처리: "flags:im pattern"
|
||||||
|
string pattern2 = q;
|
||||||
|
string flagStr = "";
|
||||||
|
if (q.StartsWith("flags:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var spaceIdx = q.IndexOf(' ');
|
||||||
|
if (spaceIdx > 0)
|
||||||
|
{
|
||||||
|
flagStr = q[6..spaceIdx];
|
||||||
|
pattern2 = q[(spaceIdx + 1)..].Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 매치 모드
|
||||||
|
if (!string.IsNullOrEmpty(pattern2))
|
||||||
|
{
|
||||||
|
var clipText = GetClipboardText();
|
||||||
|
if (!string.IsNullOrEmpty(clipText))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var opts = BuildOptions(flagStr);
|
||||||
|
var matches = Regex.Matches(clipText, pattern2, opts);
|
||||||
|
|
||||||
|
if (matches.Count == 0)
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
"매치 없음",
|
||||||
|
$"패턴 [{pattern2}]이 클립보드 텍스트와 일치하지 않습니다",
|
||||||
|
null, null, Symbol: "\uE783"));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 요약 항목
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"{matches.Count}개 매치됨",
|
||||||
|
$"패턴: {pattern2} | 전체 복사: Enter",
|
||||||
|
null,
|
||||||
|
("all_matches", string.Join("\n", matches.Cast<Match>().Select(m => m.Value))),
|
||||||
|
Symbol: "\uE773"));
|
||||||
|
|
||||||
|
// 개별 매치 항목 (최대 15개)
|
||||||
|
foreach (Match m in matches.Cast<Match>().Take(15))
|
||||||
|
{
|
||||||
|
var groupInfo = m.Groups.Count > 1
|
||||||
|
? " · 그룹: " + string.Join(", ", m.Groups.Cast<Group>().Skip(1).Select(g => g.Value))
|
||||||
|
: "";
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
m.Value,
|
||||||
|
$"위치 {m.Index}{groupInfo}",
|
||||||
|
null,
|
||||||
|
("single_match", m.Value),
|
||||||
|
Symbol: "\uE773"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.Count > 15)
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"… +{matches.Count - 15}개 더",
|
||||||
|
"전체 보기: 첫 번째 항목 Enter",
|
||||||
|
null, null, Symbol: "\uE712"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("패턴 오류", ex.Message, null, null, Symbol: "\uE783"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 클립보드 없음 → 패턴만 표시
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"패턴: {pattern2}",
|
||||||
|
"클립보드에 텍스트를 복사한 뒤 실행하세요",
|
||||||
|
null,
|
||||||
|
("pattern_apply", pattern2),
|
||||||
|
Symbol: "\uE773"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||||
|
{
|
||||||
|
switch (item.Data)
|
||||||
|
{
|
||||||
|
case ("all_matches", string copyText1):
|
||||||
|
TryCopyToClipboard(copyText1);
|
||||||
|
break;
|
||||||
|
case ("replace_result", string copyText2):
|
||||||
|
TryCopyToClipboard(copyText2);
|
||||||
|
break;
|
||||||
|
case ("single_match", string copyText3):
|
||||||
|
TryCopyToClipboard(copyText3);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ("pattern_apply", string pattern):
|
||||||
|
// 패턴을 클립보드에 복사 (또는 클립보드 텍스트에 즉시 적용)
|
||||||
|
var clipText = GetClipboardText();
|
||||||
|
if (!string.IsNullOrEmpty(clipText))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var matches = Regex.Matches(clipText, pattern);
|
||||||
|
if (matches.Count > 0)
|
||||||
|
{
|
||||||
|
var result = string.Join("\n", matches.Cast<Match>().Select(m => m.Value));
|
||||||
|
TryCopyToClipboard(result);
|
||||||
|
NotificationService.Notify("Regex", $"{matches.Count}개 매치 복사됨");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
NotificationService.Notify("Regex", "매치 없음");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
TryCopyToClipboard(pattern);
|
||||||
|
NotificationService.Notify("Regex", "패턴을 클립보드에 복사했습니다");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
TryCopyToClipboard(pattern);
|
||||||
|
NotificationService.Notify("Regex", "패턴을 클립보드에 복사했습니다");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ("replace_pattern", string oldPat, string newStr):
|
||||||
|
var src = GetClipboardText();
|
||||||
|
if (!string.IsNullOrEmpty(src))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var replaced = Regex.Replace(src, oldPat, newStr);
|
||||||
|
TryCopyToClipboard(replaced);
|
||||||
|
NotificationService.Notify("Regex", "치환 결과를 클립보드에 복사했습니다");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
NotificationService.Notify("Regex", $"오류: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ("show_patterns", _):
|
||||||
|
// 패턴 라이브러리 목록 표시 — 런처 입력창에 "re patterns" 입력
|
||||||
|
var launcher = System.Windows.Application.Current?.Windows
|
||||||
|
.OfType<Views.LauncherWindow>()
|
||||||
|
.FirstOrDefault();
|
||||||
|
launcher?.SetInputText("re patterns ");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 헬퍼 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static RegexOptions BuildOptions(string flags)
|
||||||
|
{
|
||||||
|
var opts = RegexOptions.None;
|
||||||
|
if (flags.Contains('i', StringComparison.OrdinalIgnoreCase)) opts |= RegexOptions.IgnoreCase;
|
||||||
|
if (flags.Contains('m', StringComparison.OrdinalIgnoreCase)) opts |= RegexOptions.Multiline;
|
||||||
|
if (flags.Contains('s', StringComparison.OrdinalIgnoreCase)) opts |= RegexOptions.Singleline;
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetClipboardText()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string? text = null;
|
||||||
|
System.Windows.Application.Current.Dispatcher.Invoke(
|
||||||
|
() => text = Clipboard.ContainsText() ? Clipboard.GetText() : null);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryCopyToClipboard(string text)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
|
||||||
|
}
|
||||||
|
catch { /* 비핵심 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
259
src/AxCopilot/Handlers/TimeZoneHandler.cs
Normal file
259
src/AxCopilot/Handlers/TimeZoneHandler.cs
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using AxCopilot.SDK;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
using AxCopilot.Themes;
|
||||||
|
|
||||||
|
namespace AxCopilot.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// L7-3: 시간대 변환기 핸들러. "tz" 프리픽스로 사용합니다.
|
||||||
|
///
|
||||||
|
/// 예: tz → 주요 도시 현재 시각 목록
|
||||||
|
/// tz seoul → 서울 현재 시각
|
||||||
|
/// tz new york → 뉴욕 현재 시각
|
||||||
|
/// tz 14:30 to la → 현재 시간대(KST) 14:30을 LA 시각으로 변환
|
||||||
|
/// tz meeting 09:00 → 서울 기준 9시 = 주요 도시별 동일 시각 표시
|
||||||
|
/// Enter → 결과를 클립보드에 복사.
|
||||||
|
/// </summary>
|
||||||
|
public class TimeZoneHandler : IActionHandler
|
||||||
|
{
|
||||||
|
public string? Prefix => "tz";
|
||||||
|
|
||||||
|
public PluginMetadata Metadata => new(
|
||||||
|
"TimeZone",
|
||||||
|
"시간대 변환기 — 주요 도시 현재 시각 · 시각 변환",
|
||||||
|
"1.0",
|
||||||
|
"AX");
|
||||||
|
|
||||||
|
// ── 주요 도시 시간대 목록 ──────────────────────────────────────────────
|
||||||
|
private static readonly (string City, string TzId, string Flag, string[] Aliases)[] Cities =
|
||||||
|
[
|
||||||
|
("서울", "Korea Standard Time", "🇰🇷", ["seoul", "서울", "부산", "인천", "kst", "한국"]),
|
||||||
|
("도쿄", "Tokyo Standard Time", "🇯🇵", ["tokyo", "도쿄", "osaka", "오사카", "jst", "일본"]),
|
||||||
|
("베이징", "China Standard Time", "🇨🇳", ["beijing", "베이징", "상하이", "shanghai", "cst", "중국"]),
|
||||||
|
("방콕", "SE Asia Standard Time", "🇹🇭", ["bangkok", "방콕", "ict", "태국"]),
|
||||||
|
("두바이", "Arabian Standard Time", "🇦🇪", ["dubai", "두바이", "gst", "uae"]),
|
||||||
|
("모스크바", "Russia Time Zone 2", "🇷🇺", ["moscow", "모스크바", "msk", "러시아"]),
|
||||||
|
("파리", "Romance Standard Time", "🇫🇷", ["paris", "파리", "cet", "프랑스"]),
|
||||||
|
("런던", "GMT Standard Time", "🇬🇧", ["london", "런던", "gmt", "영국"]),
|
||||||
|
("뉴욕", "Eastern Standard Time", "🇺🇸", ["new york", "뉴욕", "nyc", "est", "동부"]),
|
||||||
|
("시카고", "Central Standard Time", "🇺🇸", ["chicago", "시카고", "cst", "중부"]),
|
||||||
|
("로스앤젤레스","Pacific Standard Time", "🇺🇸", ["los angeles", "la", "로스앤젤레스", "pst", "서부"]),
|
||||||
|
("시드니", "AUS Eastern Standard Time", "🇦🇺", ["sydney", "시드니", "aest", "호주"]),
|
||||||
|
("싱가포르", "Singapore Standard Time", "🇸🇬", ["singapore", "싱가포르", "sgt"]),
|
||||||
|
("뭄바이", "India Standard Time", "🇮🇳", ["mumbai", "뭄바이", "ist", "인도"]),
|
||||||
|
("도하", "Arab Standard Time", "🇶🇦", ["doha", "도하", "ast", "카타르"]),
|
||||||
|
];
|
||||||
|
|
||||||
|
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = query.Trim().ToLowerInvariant();
|
||||||
|
var items = new List<LauncherItem>();
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(q))
|
||||||
|
{
|
||||||
|
// 주요 도시 현재 시각 목록
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
"주요 도시 현재 시각",
|
||||||
|
$"기준: UTC {now:HH:mm}",
|
||||||
|
null, null, Symbol: "\uE917"));
|
||||||
|
|
||||||
|
foreach (var (city, tzId, flag, _) in Cities)
|
||||||
|
{
|
||||||
|
var (timeStr, offsetStr) = GetCityTime(tzId, now);
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"{flag} {city}",
|
||||||
|
$"{timeStr} ({offsetStr})",
|
||||||
|
null,
|
||||||
|
("copy_time", $"{city}: {timeStr} ({offsetStr})"),
|
||||||
|
Symbol: "\uE917"));
|
||||||
|
}
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// "meeting HH:mm" 모드 — 서울 기준 특정 시각을 전 도시 변환
|
||||||
|
if (q.StartsWith("meeting ") || q.StartsWith("미팅 "))
|
||||||
|
{
|
||||||
|
var timePart = q.Contains(' ') ? q[(q.IndexOf(' ') + 1)..].Trim() : "";
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(
|
||||||
|
BuildMeetingItems(timePart, now));
|
||||||
|
}
|
||||||
|
|
||||||
|
// "HH:mm to <city>" 또는 "HH:mm <city>" 변환 모드
|
||||||
|
var convResult = TryParseConversion(q, now);
|
||||||
|
if (convResult != null)
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(convResult);
|
||||||
|
|
||||||
|
// 도시 검색
|
||||||
|
var matched = Cities
|
||||||
|
.Where(c => c.Aliases.Any(a => a.Contains(q)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (matched.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var (city, tzId, flag, _) in matched)
|
||||||
|
{
|
||||||
|
var (timeStr, offsetStr) = GetCityTime(tzId, now);
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"{flag} {city} {timeStr}",
|
||||||
|
$"UTC {offsetStr}",
|
||||||
|
null,
|
||||||
|
("copy_time", $"{city}: {timeStr} ({offsetStr})"),
|
||||||
|
Symbol: "\uE917"));
|
||||||
|
|
||||||
|
// 이 도시와 서울의 시차
|
||||||
|
var seoulOffset = GetOffset("Korea Standard Time");
|
||||||
|
var cityOffset = GetOffset(tzId);
|
||||||
|
var diff = (cityOffset - seoulOffset).TotalHours;
|
||||||
|
var diffStr = diff == 0 ? "서울과 동일" :
|
||||||
|
diff > 0 ? $"서울보다 +{diff:+0;-0}시간" :
|
||||||
|
$"서울보다 {diff:+0;-0}시간";
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
diffStr,
|
||||||
|
"서울(KST) 기준 시차",
|
||||||
|
null, null, Symbol: "\uE8F4"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 미인식 → 모든 도시 표시
|
||||||
|
foreach (var (city, tzId, flag, _) in Cities)
|
||||||
|
{
|
||||||
|
var (timeStr, offsetStr) = GetCityTime(tzId, now);
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"{flag} {city} {timeStr}",
|
||||||
|
offsetStr,
|
||||||
|
null,
|
||||||
|
("copy_time", $"{city}: {timeStr} ({offsetStr})"),
|
||||||
|
Symbol: "\uE917"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (item.Data is ("copy_time", string text))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.Windows.Application.Current.Dispatcher.Invoke(
|
||||||
|
() => Clipboard.SetText(text));
|
||||||
|
NotificationService.Notify("시간대", "클립보드에 복사했습니다.");
|
||||||
|
}
|
||||||
|
catch { /* 비핵심 */ }
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 헬퍼 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static (string Time, string Offset) GetCityTime(string tzId, DateTimeOffset utcNow)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId);
|
||||||
|
var local = TimeZoneInfo.ConvertTime(utcNow, tz);
|
||||||
|
var off = tz.GetUtcOffset(utcNow);
|
||||||
|
var offStr = $"UTC{(off >= TimeSpan.Zero ? "+" : "")}{off.Hours:D2}:{off.Minutes:D2}";
|
||||||
|
return (local.ToString("HH:mm (ddd)"), offStr);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return ("--:--", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TimeSpan GetOffset(string tzId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId);
|
||||||
|
return tz.GetUtcOffset(DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
catch { return TimeSpan.Zero; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<LauncherItem> BuildMeetingItems(string timePart, DateTimeOffset utcNow)
|
||||||
|
{
|
||||||
|
var items = new List<LauncherItem>();
|
||||||
|
|
||||||
|
if (!TimeOnly.TryParse(timePart, out var meetingTime))
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("시각 형식 오류",
|
||||||
|
"HH:mm 형식으로 입력하세요 (예: tz meeting 10:00)",
|
||||||
|
null, null, Symbol: "\uE783"));
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서울 기준으로 날짜+시각 설정
|
||||||
|
var seoulTz = TimeZoneInfo.FindSystemTimeZoneById("Korea Standard Time");
|
||||||
|
var seoulNow = TimeZoneInfo.ConvertTime(utcNow, seoulTz);
|
||||||
|
var seoulDt = new DateTimeOffset(seoulNow.Date.AddHours(meetingTime.Hour).AddMinutes(meetingTime.Minute),
|
||||||
|
seoulTz.GetUtcOffset(utcNow));
|
||||||
|
var seoulUtc = seoulDt.ToUniversalTime();
|
||||||
|
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"🇰🇷 서울 미팅 시각: {meetingTime:HH:mm}",
|
||||||
|
"주요 도시 동일 시각", null, null, Symbol: "\uE917"));
|
||||||
|
|
||||||
|
foreach (var (city, tzId, flag, _) in Cities)
|
||||||
|
{
|
||||||
|
if (tzId == "Korea Standard Time") continue;
|
||||||
|
var (timeStr, offsetStr) = GetCityTime(tzId, seoulUtc);
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"{flag} {city} {timeStr}",
|
||||||
|
offsetStr,
|
||||||
|
null,
|
||||||
|
("copy_time", $"{city}: {timeStr}"),
|
||||||
|
Symbol: "\uE917"));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<LauncherItem>? TryParseConversion(string q, DateTimeOffset utcNow)
|
||||||
|
{
|
||||||
|
// "HH:mm to <city>" 또는 "HH:mm <city>" 패턴
|
||||||
|
var sep = q.Contains(" to ") ? " to " : q.Contains(' ') ? " " : null;
|
||||||
|
if (sep == null) return null;
|
||||||
|
|
||||||
|
var parts = q.Split(sep, 2);
|
||||||
|
if (parts.Length < 2) return null;
|
||||||
|
|
||||||
|
if (!TimeOnly.TryParse(parts[0].Trim(), out var inputTime)) return null;
|
||||||
|
|
||||||
|
var cityQuery = parts[1].Trim().ToLowerInvariant();
|
||||||
|
var targetCities = Cities
|
||||||
|
.Where(c => c.Aliases.Any(a => a.Contains(cityQuery)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (targetCities.Count == 0) return null;
|
||||||
|
|
||||||
|
var items = new List<LauncherItem>();
|
||||||
|
// 서울 기준으로 입력 시각 해석
|
||||||
|
var seoulTz = TimeZoneInfo.FindSystemTimeZoneById("Korea Standard Time");
|
||||||
|
var seoulNow = TimeZoneInfo.ConvertTime(utcNow, seoulTz);
|
||||||
|
var seoulDt = new DateTimeOffset(
|
||||||
|
seoulNow.Date.AddHours(inputTime.Hour).AddMinutes(inputTime.Minute),
|
||||||
|
seoulTz.GetUtcOffset(utcNow));
|
||||||
|
var seoulUtc = seoulDt.ToUniversalTime();
|
||||||
|
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"🇰🇷 서울 {inputTime:HH:mm} 기준 변환",
|
||||||
|
"Enter → 클립보드 복사", null, null, Symbol: "\uE8F4"));
|
||||||
|
|
||||||
|
foreach (var (city, tzId, flag, _) in targetCities)
|
||||||
|
{
|
||||||
|
var (timeStr, offsetStr) = GetCityTime(tzId, seoulUtc);
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"{flag} {city} {timeStr}",
|
||||||
|
offsetStr,
|
||||||
|
null,
|
||||||
|
("copy_time", $"{city}: {timeStr}"),
|
||||||
|
Symbol: "\uE917"));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user