[Phase L13] 시스템 정보·계산 도구 핸들러 4종 추가
DnsQueryHandler.cs (신규, ~200줄, prefix=dns):
- A/AAAA: .NET Dns.GetHostAddressesAsync() 직접 호출
- MX/TXT/NS/CNAME: nslookup 서브프로세스 + 출력 파싱
- PTR: Dns.GetHostEntryAsync()로 역방향 조회
- 사내 모드: 내부 IP(192.168/10/172.16-31) 허용, 외부 차단
- Enter → 비동기 실행, 결과 클립보드 복사 + 알림
PathHandler.cs (신규, ~180줄, prefix=path):
- Environment.GetEnvironmentVariable("PATH", Process/User/Machine)
- Directory.Exists 기반 경로 존재 여부 아이콘 구분
- which: .exe/.cmd/.bat/.ps1/.com 확장자 순서 탐색
- DistinctBy로 중복 경로 제거 (대소문자 무시)
DriveHandler.cs (신규, ~170줄, prefix=drive):
- DriveInfo.GetDrives() + IsReady 체크 + try/catch 방어
- █░ 시각적 사용량 바 (MakeBar 12~20칸 가변)
- TB/GB/MB/KB 자동 단위 포맷
- large 서브커맨드: UsedSpace 내림차순 정렬
AgeHandler.cs (신규, ~230줄, prefix=age):
- YYYYMMDD / YYYY-MM-DD / YYYY.MM.DD 형식 파싱
- 만 나이(생일 미경과 시 -1) + 한국식(연도 차이 +1)
- NextBirthday: 올해 생일 지났으면 내년으로 계산
- christmas/newyear 특수 키워드 Dictionary<string, Func>
- "next monday" 형식 다음 요일 D-day 파싱
App.xaml.cs: 4개 핸들러 Phase L13 블록 등록
docs/LAUNCHER_ROADMAP.md: Phase L13 완료 섹션 추가
빌드: 경고 0, 오류 0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -295,3 +295,16 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label
|
|||||||
| L12-2 | **hosts 파일 관리** ✅ | `hosts` 프리픽스. C:\Windows\System32\drivers\etc\hosts 파싱. 활성·비활성(주석 처리) 항목 분류. `hosts search` 키워드 필터. `hosts open` 메모장 열기. `hosts copy` 전체 내용 복사. 항목 Enter → 클립보드 복사 | 중간 |
|
| L12-2 | **hosts 파일 관리** ✅ | `hosts` 프리픽스. C:\Windows\System32\drivers\etc\hosts 파싱. 활성·비활성(주석 처리) 항목 분류. `hosts search` 키워드 필터. `hosts open` 메모장 열기. `hosts copy` 전체 내용 복사. 항목 Enter → 클립보드 복사 | 중간 |
|
||||||
| L12-3 | **모스 부호 변환기** ✅ | `morse` 프리픽스. 텍스트 → 모스 부호 (영문자·숫자·구두점 56자 지원). 모스 → 텍스트 역변환 (.-/공백 자동 감지). SOS/AR/AS 프로사인 키워드. 클립보드 자동 감지. 문자별·코드별 대응표 표시 | 낮음 |
|
| L12-3 | **모스 부호 변환기** ✅ | `morse` 프리픽스. 텍스트 → 모스 부호 (영문자·숫자·구두점 56자 지원). 모스 → 텍스트 역변환 (.-/공백 자동 감지). SOS/AR/AS 프로사인 키워드. 클립보드 자동 감지. 문자별·코드별 대응표 표시 | 낮음 |
|
||||||
| L12-4 | **시작 프로그램 조회** ✅ | `startup` 프리픽스. HKCU/HKLM Run·RunOnce 레지스트리 + 시작 폴더(.lnk) 통합 조회. 범위(현재 사용자/모든 사용자) 그룹화. `startup search` 키워드 필터. `startup folder` 시작 폴더 열기. Enter → 명령 경로 클립보드 복사 | 중간 |
|
| L12-4 | **시작 프로그램 조회** ✅ | `startup` 프리픽스. HKCU/HKLM Run·RunOnce 레지스트리 + 시작 폴더(.lnk) 통합 조회. 범위(현재 사용자/모든 사용자) 그룹화. `startup search` 키워드 필터. `startup folder` 시작 폴더 열기. Enter → 명령 경로 클립보드 복사 | 중간 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase L13 — 시스템 정보·계산 도구 (v2.0.5) ✅ 완료
|
||||||
|
|
||||||
|
> **방향**: 네트워크 진단·파일시스템 정보·날짜 계산 도구 보강.
|
||||||
|
|
||||||
|
| # | 기능 | 설명 | 우선순위 |
|
||||||
|
|---|------|------|----------|
|
||||||
|
| L13-1 | **DNS 레코드 조회** ✅ | `dns` 프리픽스. A/AAAA는 .NET Dns API 직접 사용. MX/TXT/NS/CNAME은 nslookup 서브프로세스 파싱. PTR(역방향 조회) 지원. 사내 모드에서 외부 도메인 차단(내부 IP만 허용). Enter → 비동기 조회 실행 + 결과 복사 | 높음 |
|
||||||
|
| L13-2 | **PATH 환경변수** ✅ | `path` 프리픽스. Process/User/Machine 세 범위 통합 조회. 경로 존재 여부 아이콘 표시. `path which <파일>` .exe/.cmd/.bat/.ps1 확장자 자동 시도. `path user/system` 범위별 표시. `path search` 키워드 필터 | 높음 |
|
||||||
|
| L13-3 | **드라이브 정보** ✅ | `drive` 프리픽스. DriveInfo.GetDrives() 기반 전체 드라이브 목록. 고정/이동식/네트워크/CD 드라이브 종류 구분. █░ 시각적 사용량 바 그래프. `drive C` 특정 드라이브 상세. `drive large` 사용량 많은 순 정렬. TB/GB/MB/KB 자동 단위 | 중간 |
|
||||||
|
| L13-4 | **나이·D-day 계산기** ✅ | `age` 프리픽스. YYYY-MM-DD / YYYYMMDD / M.d 형식 파싱. 과거 날짜 → 만 나이·한국 나이·경과 일수·다음 생일 D-day. 미래 날짜 → D-day·남은 주 계산. `age christmas/newyear` 특수 키워드. `age next monday` 다음 요일까지 D-day | 높음 |
|
||||||
|
|||||||
@@ -257,6 +257,16 @@ public partial class App : System.Windows.Application
|
|||||||
// L12-4: 시작 프로그램 조회 (prefix=startup)
|
// L12-4: 시작 프로그램 조회 (prefix=startup)
|
||||||
commandResolver.RegisterHandler(new StartupHandler());
|
commandResolver.RegisterHandler(new StartupHandler());
|
||||||
|
|
||||||
|
// ─── Phase L13 핸들러 ─────────────────────────────────────────────────
|
||||||
|
// L13-1: DNS 레코드 조회 (prefix=dns)
|
||||||
|
commandResolver.RegisterHandler(new DnsQueryHandler());
|
||||||
|
// L13-2: PATH 환경변수 뷰어 (prefix=path)
|
||||||
|
commandResolver.RegisterHandler(new PathHandler());
|
||||||
|
// L13-3: 드라이브 정보 (prefix=drive)
|
||||||
|
commandResolver.RegisterHandler(new DriveHandler());
|
||||||
|
// L13-4: 나이·D-day 계산기 (prefix=age)
|
||||||
|
commandResolver.RegisterHandler(new AgeHandler());
|
||||||
|
|
||||||
// ─── 플러그인 로드 ────────────────────────────────────────────────────
|
// ─── 플러그인 로드 ────────────────────────────────────────────────────
|
||||||
var pluginHost = new PluginHost(settings, commandResolver);
|
var pluginHost = new PluginHost(settings, commandResolver);
|
||||||
pluginHost.LoadAll();
|
pluginHost.LoadAll();
|
||||||
|
|||||||
284
src/AxCopilot/Handlers/AgeHandler.cs
Normal file
284
src/AxCopilot/Handlers/AgeHandler.cs
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using AxCopilot.SDK;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
using AxCopilot.Themes;
|
||||||
|
|
||||||
|
namespace AxCopilot.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// L13-4: 나이·D-day 계산기 핸들러. "age" 프리픽스로 사용합니다.
|
||||||
|
///
|
||||||
|
/// 예: age 1990-05-15 → 나이 계산 (만/한국식)
|
||||||
|
/// age 1990.05.15 → 점 구분자도 지원
|
||||||
|
/// age 19900515 → 숫자만 입력 (YYYYMMDD)
|
||||||
|
/// age 2025-12-25 → D-day 계산 (미래 날짜)
|
||||||
|
/// age next monday → 다음 월요일까지 D-day
|
||||||
|
/// age christmas → 크리스마스까지 D-day
|
||||||
|
/// Enter → 결과를 클립보드에 복사.
|
||||||
|
/// </summary>
|
||||||
|
public class AgeHandler : IActionHandler
|
||||||
|
{
|
||||||
|
public string? Prefix => "age";
|
||||||
|
|
||||||
|
public PluginMetadata Metadata => new(
|
||||||
|
"Age",
|
||||||
|
"나이·D-day 계산기 — 만 나이 · 한국 나이 · D-day",
|
||||||
|
"1.0",
|
||||||
|
"AX");
|
||||||
|
|
||||||
|
// 특수 날짜 키워드
|
||||||
|
private static readonly Dictionary<string, Func<DateTime, DateTime>> Keywords =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["christmas"] = t => new DateTime(t.Month > 12 || (t.Month == 12 && t.Day >= 26) ? t.Year + 1 : t.Year, 12, 25),
|
||||||
|
["xmas"] = t => new DateTime(t.Month > 12 || (t.Month == 12 && t.Day >= 26) ? t.Year + 1 : t.Year, 12, 25),
|
||||||
|
["newyear"] = t => new DateTime(t.Year + 1, 1, 1),
|
||||||
|
["new year"] = t => new DateTime(t.Year + 1, 1, 1),
|
||||||
|
["설날"] = t => GetNextLunarNewYear(t),
|
||||||
|
["chuseok"] = t => GetNextChuseok(t),
|
||||||
|
["추석"] = t => GetNextChuseok(t),
|
||||||
|
};
|
||||||
|
|
||||||
|
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = query.Trim();
|
||||||
|
var items = new List<LauncherItem>();
|
||||||
|
var today = DateTime.Today;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(q))
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("나이·D-day 계산기",
|
||||||
|
"예: age 1990-05-15 / age 2025-12-25 / age christmas",
|
||||||
|
null, null, Symbol: "\uE787"));
|
||||||
|
items.Add(new LauncherItem("age 1990-01-01", "생년월일 → 나이", null, null, Symbol: "\uE787"));
|
||||||
|
items.Add(new LauncherItem("age 2025-12-25", "미래 날짜 D-day", null, null, Symbol: "\uE787"));
|
||||||
|
items.Add(new LauncherItem("age christmas", "크리스마스 D-day", null, null, Symbol: "\uE787"));
|
||||||
|
items.Add(new LauncherItem("age newyear", "신년 D-day", null, null, Symbol: "\uE787"));
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 특수 키워드 확인
|
||||||
|
foreach (var (kw, fn) in Keywords)
|
||||||
|
{
|
||||||
|
if (q.Equals(kw, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var targetDate = fn(today);
|
||||||
|
items.AddRange(BuildDdayItems(targetDate, kw, today));
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 요일 키워드: "next monday"
|
||||||
|
if (TryParseNextWeekday(q, out var weekdayDate))
|
||||||
|
{
|
||||||
|
items.AddRange(BuildDdayItems(weekdayDate, q, today));
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜 파싱 시도
|
||||||
|
if (!TryParseDate(q, out var date))
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("날짜 형식 오류",
|
||||||
|
"예: 1990-05-15 / 1990.05.15 / 19900515 / 2025-12-25",
|
||||||
|
null, null, Symbol: "\uE783"));
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date <= today)
|
||||||
|
{
|
||||||
|
// 과거 날짜 → 나이/경과 계산
|
||||||
|
items.AddRange(BuildAgeItems(date, today));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 미래 날짜 → D-day
|
||||||
|
items.AddRange(BuildDdayItems(date, date.ToString("yyyy-MM-dd"), today));
|
||||||
|
}
|
||||||
|
|
||||||
|
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("Age", "클립보드에 복사했습니다.");
|
||||||
|
}
|
||||||
|
catch { /* 비핵심 */ }
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 나이 계산 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static IEnumerable<LauncherItem> BuildAgeItems(DateTime birth, DateTime today)
|
||||||
|
{
|
||||||
|
var ageInt = CalcAge(birth, today);
|
||||||
|
var ageKor = today.Year - birth.Year + 1; // 한국식 나이
|
||||||
|
var days = (today - birth).Days;
|
||||||
|
var months = (today.Year - birth.Year) * 12 + (today.Month - birth.Month);
|
||||||
|
var nextBirthday = NextBirthday(birth, today);
|
||||||
|
var daysToNext = (nextBirthday - today).Days;
|
||||||
|
|
||||||
|
var summary = $"""
|
||||||
|
생년월일: {birth:yyyy-MM-dd}
|
||||||
|
만 나이: {ageInt}세
|
||||||
|
한국 나이: {ageKor}세
|
||||||
|
경과 일수: {days:N0}일
|
||||||
|
경과 개월: {months:N0}개월
|
||||||
|
다음 생일: {nextBirthday:yyyy-MM-dd} (D-{daysToNext})
|
||||||
|
""";
|
||||||
|
|
||||||
|
yield return new LauncherItem(
|
||||||
|
$"만 {ageInt}세 (한국식 {ageKor}세)",
|
||||||
|
$"{birth:yyyy-MM-dd} · {days:N0}일 경과 · Enter 복사",
|
||||||
|
null, ("copy", summary), Symbol: "\uE787");
|
||||||
|
|
||||||
|
yield return new LauncherItem("만 나이", $"{ageInt}세", null, ("copy", ageInt.ToString()), Symbol: "\uE787");
|
||||||
|
yield return new LauncherItem("한국 나이", $"{ageKor}세", null, ("copy", ageKor.ToString()), Symbol: "\uE787");
|
||||||
|
yield return new LauncherItem("경과 일수", $"{days:N0}일", null, ("copy", days.ToString()), Symbol: "\uE787");
|
||||||
|
yield return new LauncherItem("경과 개월", $"{months:N0}개월", null, ("copy", months.ToString()), Symbol: "\uE787");
|
||||||
|
yield return new LauncherItem(
|
||||||
|
"다음 생일",
|
||||||
|
$"{nextBirthday:yyyy-MM-dd} · D-{daysToNext}",
|
||||||
|
null, ("copy", nextBirthday.ToString("yyyy-MM-dd")), Symbol: "\uE787");
|
||||||
|
|
||||||
|
// 요일
|
||||||
|
yield return new LauncherItem("태어난 요일", DayKor(birth.DayOfWeek), null, null, Symbol: "\uE787");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<LauncherItem> BuildDdayItems(DateTime target, string label, DateTime today)
|
||||||
|
{
|
||||||
|
var diff = (target - today).Days;
|
||||||
|
var absDiff = Math.Abs(diff);
|
||||||
|
var dLabel = diff > 0 ? $"D-{diff}" : diff == 0 ? "D-Day!" : $"D+{absDiff}";
|
||||||
|
|
||||||
|
yield return new LauncherItem(
|
||||||
|
dLabel,
|
||||||
|
$"{target:yyyy-MM-dd} ({label}) · {DayKor(target.DayOfWeek)}",
|
||||||
|
null, ("copy", dLabel), Symbol: "\uE787");
|
||||||
|
|
||||||
|
yield return new LauncherItem("날짜", target.ToString("yyyy-MM-dd"), null, ("copy", target.ToString("yyyy-MM-dd")), Symbol: "\uE787");
|
||||||
|
yield return new LauncherItem("요일", DayKor(target.DayOfWeek), null, null, Symbol: "\uE787");
|
||||||
|
yield return new LauncherItem("남은 일수", diff > 0 ? $"{diff:N0}일 후" : diff == 0 ? "오늘!" : $"{absDiff:N0}일 전", null, ("copy", absDiff.ToString()), Symbol: "\uE787");
|
||||||
|
yield return new LauncherItem("남은 주", $"{diff / 7}주 {diff % 7}일", null, null, Symbol: "\uE787");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 파싱 헬퍼 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static bool TryParseDate(string s, out DateTime result)
|
||||||
|
{
|
||||||
|
result = default;
|
||||||
|
s = s.Trim();
|
||||||
|
|
||||||
|
// YYYYMMDD
|
||||||
|
if (s.Length == 8 && s.All(char.IsDigit))
|
||||||
|
{
|
||||||
|
return DateTime.TryParseExact(s, "yyyyMMdd",
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
System.Globalization.DateTimeStyles.None, out result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다양한 구분자 (-, ., /)
|
||||||
|
var normalized = s.Replace('.', '-').Replace('/', '-');
|
||||||
|
var formats = new[] { "yyyy-M-d", "yyyy-MM-dd", "yy-M-d", "M-d" };
|
||||||
|
|
||||||
|
foreach (var fmt in formats)
|
||||||
|
{
|
||||||
|
if (DateTime.TryParseExact(normalized, fmt,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
System.Globalization.DateTimeStyles.None, out result))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "M월 d일" 한국어 형식
|
||||||
|
if (normalized.Contains('월'))
|
||||||
|
{
|
||||||
|
var parts = normalized.Split('월', '일');
|
||||||
|
if (parts.Length >= 2 &&
|
||||||
|
int.TryParse(parts[0].Trim(), out var m) &&
|
||||||
|
int.TryParse(parts[1].Trim(), out var d))
|
||||||
|
{
|
||||||
|
result = new DateTime(DateTime.Today.Year, m, d);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseNextWeekday(string q, out DateTime result)
|
||||||
|
{
|
||||||
|
result = default;
|
||||||
|
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (parts.Length < 2 || !parts[0].Equals("next", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
DayOfWeek? dow = parts[1].ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"monday" or "mon" or "월" or "월요일" => DayOfWeek.Monday,
|
||||||
|
"tuesday" or "tue" or "화" or "화요일" => DayOfWeek.Tuesday,
|
||||||
|
"wednesday" or "wed" or "수" or "수요일" => DayOfWeek.Wednesday,
|
||||||
|
"thursday" or "thu" or "목" or "목요일" => DayOfWeek.Thursday,
|
||||||
|
"friday" or "fri" or "금" or "금요일" => DayOfWeek.Friday,
|
||||||
|
"saturday" or "sat" or "토" or "토요일" => DayOfWeek.Saturday,
|
||||||
|
"sunday" or "sun" or "일" or "일요일" => DayOfWeek.Sunday,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dow == null) return false;
|
||||||
|
|
||||||
|
var today = DateTime.Today;
|
||||||
|
var daysAhead = ((int)dow.Value - (int)today.DayOfWeek + 7) % 7;
|
||||||
|
if (daysAhead == 0) daysAhead = 7; // "next"이므로 다음 주
|
||||||
|
result = today.AddDays(daysAhead);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 날짜 계산 헬퍼 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static int CalcAge(DateTime birth, DateTime today)
|
||||||
|
{
|
||||||
|
var age = today.Year - birth.Year;
|
||||||
|
if (today.Month < birth.Month || (today.Month == birth.Month && today.Day < birth.Day))
|
||||||
|
age--;
|
||||||
|
return age;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime NextBirthday(DateTime birth, DateTime today)
|
||||||
|
{
|
||||||
|
var thisYear = new DateTime(today.Year, birth.Month, birth.Day);
|
||||||
|
return thisYear >= today ? thisYear : thisYear.AddYears(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 간략화된 양력 설날 근사값 (실제 음력 계산은 복잡하므로 고정 근사)
|
||||||
|
private static DateTime GetNextLunarNewYear(DateTime today)
|
||||||
|
{
|
||||||
|
// 설날은 대략 1월 말 ~ 2월 초이므로 2월 5일을 기준점으로 사용
|
||||||
|
var approx = new DateTime(today.Year, 2, 5);
|
||||||
|
return approx >= today ? approx : new DateTime(today.Year + 1, 2, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime GetNextChuseok(DateTime today)
|
||||||
|
{
|
||||||
|
// 추석은 대략 9월 말 ~ 10월 초이므로 9월 28일을 기준점으로 사용
|
||||||
|
var approx = new DateTime(today.Year, 9, 28);
|
||||||
|
return approx >= today ? approx : new DateTime(today.Year + 1, 9, 28);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DayKor(DayOfWeek dow) => dow switch
|
||||||
|
{
|
||||||
|
DayOfWeek.Monday => "월요일",
|
||||||
|
DayOfWeek.Tuesday => "화요일",
|
||||||
|
DayOfWeek.Wednesday => "수요일",
|
||||||
|
DayOfWeek.Thursday => "목요일",
|
||||||
|
DayOfWeek.Friday => "금요일",
|
||||||
|
DayOfWeek.Saturday => "토요일",
|
||||||
|
DayOfWeek.Sunday => "일요일",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
}
|
||||||
264
src/AxCopilot/Handlers/DnsQueryHandler.cs
Normal file
264
src/AxCopilot/Handlers/DnsQueryHandler.cs
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Windows;
|
||||||
|
using AxCopilot.SDK;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
using AxCopilot.Themes;
|
||||||
|
|
||||||
|
namespace AxCopilot.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// L13-1: DNS 레코드 조회 핸들러. "dns" 프리픽스로 사용합니다.
|
||||||
|
///
|
||||||
|
/// 예: dns google.com → A/AAAA 레코드 조회
|
||||||
|
/// dns google.com mx → MX 레코드
|
||||||
|
/// dns google.com txt → TXT 레코드
|
||||||
|
/// dns google.com ns → NS 레코드
|
||||||
|
/// dns 8.8.8.8 → PTR(역방향) 조회
|
||||||
|
/// Enter → 결과를 클립보드에 복사.
|
||||||
|
///
|
||||||
|
/// ⚠ 사내 모드: 내부 호스트만 조회 허용.
|
||||||
|
/// </summary>
|
||||||
|
public class DnsQueryHandler : IActionHandler
|
||||||
|
{
|
||||||
|
public string? Prefix => "dns";
|
||||||
|
|
||||||
|
public PluginMetadata Metadata => new(
|
||||||
|
"DNS",
|
||||||
|
"DNS 레코드 조회 — A · AAAA · MX · TXT · NS · PTR",
|
||||||
|
"1.0",
|
||||||
|
"AX");
|
||||||
|
|
||||||
|
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("DNS 레코드 조회",
|
||||||
|
"예: dns google.com / dns google.com mx / dns 8.8.8.8",
|
||||||
|
null, null, Symbol: "\uE968"));
|
||||||
|
items.Add(new LauncherItem("dns localhost", "로컬 A 레코드", null, null, Symbol: "\uE968"));
|
||||||
|
items.Add(new LauncherItem("dns example.com mx", "MX 레코드", null, null, Symbol: "\uE968"));
|
||||||
|
items.Add(new LauncherItem("dns example.com txt","TXT 레코드", null, null, Symbol: "\uE968"));
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var host = parts[0];
|
||||||
|
var recType = parts.Length > 1 ? parts[1].ToUpperInvariant() : "A";
|
||||||
|
|
||||||
|
// 사내 모드 확인
|
||||||
|
var settings = (System.Windows.Application.Current as App)?.SettingsService?.Settings;
|
||||||
|
var isInternal = settings?.InternalModeEnabled ?? true;
|
||||||
|
if (isInternal && !IsInternalHost(host))
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
"사내 모드 제한",
|
||||||
|
$"'{host}'은 외부 호스트입니다. 설정에서 사외 모드를 활성화하세요.",
|
||||||
|
null, null, Symbol: "\uE783"));
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"{host} [{recType}]",
|
||||||
|
"Enter를 눌러 조회 실행",
|
||||||
|
null,
|
||||||
|
("query", $"{host}|{recType}"),
|
||||||
|
Symbol: "\uE968"));
|
||||||
|
|
||||||
|
// 레코드 타입 빠른 선택 힌트
|
||||||
|
if (parts.Length == 1)
|
||||||
|
{
|
||||||
|
foreach (var t in new[] { "A", "AAAA", "MX", "TXT", "NS" })
|
||||||
|
items.Add(new LauncherItem($"dns {host} {t}", $"{t} 레코드 조회", null,
|
||||||
|
("query", $"{host}|{t}"), Symbol: "\uE968"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async 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("DNS", "클립보드에 복사했습니다.");
|
||||||
|
}
|
||||||
|
catch { /* 비핵심 */ }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Data is not ("query", string queryData)) return;
|
||||||
|
|
||||||
|
var idx = queryData.IndexOf('|');
|
||||||
|
var host = queryData[..idx];
|
||||||
|
var recType = queryData[(idx + 1)..];
|
||||||
|
|
||||||
|
NotificationService.Notify("DNS", $"{host} [{recType}] 조회 중…");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var results = await QueryDnsAsync(host, recType, ct);
|
||||||
|
if (results.Count == 0)
|
||||||
|
{
|
||||||
|
NotificationService.Notify("DNS", $"{host} [{recType}] — 레코드 없음");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary = string.Join("\n", results);
|
||||||
|
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(summary));
|
||||||
|
NotificationService.Notify("DNS", $"{results.Count}개 레코드 조회됨 · 클립보드 복사");
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
NotificationService.Notify("DNS", "조회 취소됨");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
NotificationService.Notify("DNS", $"오류: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DNS 조회 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static async Task<List<string>> QueryDnsAsync(string host, string type, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return type switch
|
||||||
|
{
|
||||||
|
"A" or "AAAA" => await QueryAAsync(host, type, ct),
|
||||||
|
"PTR" => await QueryPtrAsync(host, ct),
|
||||||
|
_ => await QueryViaNslookupAsync(host, type, ct),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<List<string>> QueryAAsync(string host, string type, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var family = type == "AAAA" ? AddressFamily.InterNetworkV6 : AddressFamily.InterNetwork;
|
||||||
|
var addrs = await Dns.GetHostAddressesAsync(host, family, ct);
|
||||||
|
return addrs.Select(a => a.ToString()).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<List<string>> QueryPtrAsync(string ip, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!IPAddress.TryParse(ip, out _))
|
||||||
|
return [$"'{ip}'은 유효한 IP 주소가 아닙니다"];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var entry = await Dns.GetHostEntryAsync(ip, ct);
|
||||||
|
return [entry.HostName];
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return [$"PTR 레코드 없음: {ip}"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>MX/TXT/NS/CNAME: nslookup 프로세스 실행으로 조회</summary>
|
||||||
|
private static async Task<List<string>> QueryViaNslookupAsync(string host, string type, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var psi = new System.Diagnostics.ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "nslookup",
|
||||||
|
Arguments = $"-type={type} {host}",
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
StandardOutputEncoding = Encoding.UTF8,
|
||||||
|
};
|
||||||
|
|
||||||
|
using var proc = new System.Diagnostics.Process { StartInfo = psi };
|
||||||
|
proc.Start();
|
||||||
|
|
||||||
|
var stdout = await proc.StandardOutput.ReadToEndAsync(ct);
|
||||||
|
await proc.WaitForExitAsync(ct);
|
||||||
|
|
||||||
|
return ParseNslookupOutput(stdout, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> ParseNslookupOutput(string output, string type)
|
||||||
|
{
|
||||||
|
var results = new List<string>();
|
||||||
|
var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
// 서버 응답 헤더 건너뜀 (첫 2줄)
|
||||||
|
var skip = true;
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
var trimmed = line.Trim();
|
||||||
|
if (skip)
|
||||||
|
{
|
||||||
|
if (trimmed.StartsWith("Non-authoritative", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
trimmed.StartsWith("Name:", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
(type == "MX" && trimmed.Contains("mail exchanger")) ||
|
||||||
|
(type == "TXT" && trimmed.Contains("text =")) ||
|
||||||
|
(type == "NS" && trimmed.Contains("nameserver")))
|
||||||
|
skip = false;
|
||||||
|
else
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(trimmed)) continue;
|
||||||
|
|
||||||
|
// MX: "... mail exchanger = 10 aspmx.l.google.com"
|
||||||
|
if (type == "MX" && trimmed.Contains("mail exchanger"))
|
||||||
|
{
|
||||||
|
var idx = trimmed.IndexOf('=');
|
||||||
|
if (idx >= 0) results.Add(trimmed[(idx + 1)..].Trim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TXT: "... text = "v=spf1 …""
|
||||||
|
if (type == "TXT" && trimmed.Contains("text ="))
|
||||||
|
{
|
||||||
|
var idx = trimmed.IndexOf("text =", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (idx >= 0) results.Add(trimmed[(idx + 6)..].Trim().Trim('"'));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NS: "nameserver = ns1.google.com"
|
||||||
|
if (type == "NS" && trimmed.Contains("nameserver"))
|
||||||
|
{
|
||||||
|
var idx = trimmed.IndexOf('=');
|
||||||
|
if (idx >= 0) results.Add(trimmed[(idx + 1)..].Trim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CNAME: "canonical name = …"
|
||||||
|
if (type == "CNAME" && trimmed.Contains("canonical name"))
|
||||||
|
{
|
||||||
|
var idx = trimmed.IndexOf('=');
|
||||||
|
if (idx >= 0) results.Add(trimmed[(idx + 1)..].Trim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address: 주소 행
|
||||||
|
if (trimmed.StartsWith("Address:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var idx = trimmed.IndexOf(':');
|
||||||
|
if (idx >= 0) results.Add(trimmed[(idx + 1)..].Trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.Count > 0 ? results : [$"조회 결과 없음 ({type})"];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsInternalHost(string host)
|
||||||
|
{
|
||||||
|
if (host is "localhost" or "127.0.0.1") return true;
|
||||||
|
if (IPAddress.TryParse(host, out var addr))
|
||||||
|
{
|
||||||
|
var s = addr.ToString();
|
||||||
|
return s.StartsWith("192.168.") || s.StartsWith("10.") ||
|
||||||
|
System.Text.RegularExpressions.Regex.IsMatch(s,
|
||||||
|
@"^172\.(1[6-9]|2\d|3[01])\.");
|
||||||
|
}
|
||||||
|
// 도메인 이름은 사내 모드에서 외부로 간주
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
202
src/AxCopilot/Handlers/DriveHandler.cs
Normal file
202
src/AxCopilot/Handlers/DriveHandler.cs
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Windows;
|
||||||
|
using AxCopilot.SDK;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
using AxCopilot.Themes;
|
||||||
|
|
||||||
|
namespace AxCopilot.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// L13-3: 드라이브 정보 핸들러. "drive" 프리픽스로 사용합니다.
|
||||||
|
///
|
||||||
|
/// 예: drive → 전체 드라이브 목록 + 용량 요약
|
||||||
|
/// drive C → C 드라이브 상세 정보
|
||||||
|
/// drive C:\ → 경로 형식도 지원
|
||||||
|
/// drive large → 사용량 많은 순서로 정렬
|
||||||
|
/// Enter → 드라이브 정보를 클립보드에 복사.
|
||||||
|
/// </summary>
|
||||||
|
public class DriveHandler : IActionHandler
|
||||||
|
{
|
||||||
|
public string? Prefix => "drive";
|
||||||
|
|
||||||
|
public PluginMetadata Metadata => new(
|
||||||
|
"Drive",
|
||||||
|
"드라이브 정보 — 용량 · 파일시스템 · 여유공간",
|
||||||
|
"1.0",
|
||||||
|
"AX");
|
||||||
|
|
||||||
|
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = query.Trim();
|
||||||
|
var items = new List<LauncherItem>();
|
||||||
|
|
||||||
|
var drives = GetDrives();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(q))
|
||||||
|
{
|
||||||
|
var totalSize = drives.Sum(d => d.TotalSize);
|
||||||
|
var totalFree = drives.Sum(d => d.AvailableFree);
|
||||||
|
var totalUsed = totalSize - totalFree;
|
||||||
|
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"드라이브 {drives.Count}개",
|
||||||
|
$"전체 {FormatBytes(totalSize)} · 사용 {FormatBytes(totalUsed)} · 여유 {FormatBytes(totalFree)}",
|
||||||
|
null, null, Symbol: "\uEDA2"));
|
||||||
|
|
||||||
|
foreach (var d in drives.OrderBy(d => d.Name))
|
||||||
|
items.Add(MakeDriveSummaryItem(d));
|
||||||
|
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
var sub = q.ToUpperInvariant().TrimEnd(':', '\\', '/');
|
||||||
|
|
||||||
|
if (sub == "LARGE")
|
||||||
|
{
|
||||||
|
// 사용량 많은 순
|
||||||
|
foreach (var d in drives.OrderByDescending(d => d.UsedSpace))
|
||||||
|
items.Add(MakeDriveSummaryItem(d));
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 특정 드라이브 상세
|
||||||
|
var target = drives.FirstOrDefault(d =>
|
||||||
|
d.Name.StartsWith(sub, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (target == null)
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("드라이브 없음", $"'{q}' 드라이브를 찾을 수 없습니다", null, null, Symbol: "\uE783"));
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
items.AddRange(BuildDetailItems(target));
|
||||||
|
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("Drive", "클립보드에 복사했습니다.");
|
||||||
|
}
|
||||||
|
catch { /* 비핵심 */ }
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 드라이브 정보 수집 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private record DriveInfo2(
|
||||||
|
string Name,
|
||||||
|
string VolumeLabel,
|
||||||
|
string DriveFormat,
|
||||||
|
DriveType DriveType,
|
||||||
|
long TotalSize,
|
||||||
|
long AvailableFree,
|
||||||
|
long UsedSpace,
|
||||||
|
bool IsReady);
|
||||||
|
|
||||||
|
private static List<DriveInfo2> GetDrives()
|
||||||
|
{
|
||||||
|
return DriveInfo.GetDrives()
|
||||||
|
.Select(d =>
|
||||||
|
{
|
||||||
|
if (!d.IsReady)
|
||||||
|
return new DriveInfo2(d.Name, "", "", d.DriveType, 0, 0, 0, false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new DriveInfo2(
|
||||||
|
d.Name,
|
||||||
|
d.VolumeLabel,
|
||||||
|
d.DriveFormat,
|
||||||
|
d.DriveType,
|
||||||
|
d.TotalSize,
|
||||||
|
d.AvailableFreeSpace,
|
||||||
|
d.TotalSize - d.AvailableFreeSpace,
|
||||||
|
true);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new DriveInfo2(d.Name, "", d.DriveFormat, d.DriveType, 0, 0, 0, false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<LauncherItem> BuildDetailItems(DriveInfo2 d)
|
||||||
|
{
|
||||||
|
var usagePercent = d.TotalSize > 0 ? (double)d.UsedSpace / d.TotalSize * 100 : 0;
|
||||||
|
var bar = MakeBar(usagePercent, 20);
|
||||||
|
var summary = $"""
|
||||||
|
드라이브: {d.Name}
|
||||||
|
볼륨 레이블: {(string.IsNullOrEmpty(d.VolumeLabel) ? "(없음)" : d.VolumeLabel)}
|
||||||
|
파일 시스템: {d.DriveFormat}
|
||||||
|
드라이브 종류: {DriveTypeName(d.DriveType)}
|
||||||
|
전체 용량: {FormatBytes(d.TotalSize)}
|
||||||
|
사용 중: {FormatBytes(d.UsedSpace)} ({usagePercent:F1}%)
|
||||||
|
여유 공간: {FormatBytes(d.AvailableFree)}
|
||||||
|
""";
|
||||||
|
|
||||||
|
yield return new LauncherItem(
|
||||||
|
$"{d.Name} {FormatBytes(d.TotalSize)}",
|
||||||
|
$"사용 {usagePercent:F0}% {bar} 여유 {FormatBytes(d.AvailableFree)}",
|
||||||
|
null, ("copy", summary), Symbol: "\uEDA2");
|
||||||
|
|
||||||
|
yield return new LauncherItem("볼륨 레이블", string.IsNullOrEmpty(d.VolumeLabel) ? "(없음)" : d.VolumeLabel, null, null, Symbol: "\uEDA2");
|
||||||
|
yield return new LauncherItem("파일 시스템", d.DriveFormat, null, null, Symbol: "\uEDA2");
|
||||||
|
yield return new LauncherItem("드라이브 종류", DriveTypeName(d.DriveType), null, null, Symbol: "\uEDA2");
|
||||||
|
yield return new LauncherItem("전체 용량", FormatBytes(d.TotalSize), null, ("copy", FormatBytes(d.TotalSize)), Symbol: "\uEDA2");
|
||||||
|
yield return new LauncherItem("사용 중", $"{FormatBytes(d.UsedSpace)} ({usagePercent:F1}%)", null, ("copy", FormatBytes(d.UsedSpace)), Symbol: "\uEDA2");
|
||||||
|
yield return new LauncherItem("여유 공간", FormatBytes(d.AvailableFree), null, ("copy", FormatBytes(d.AvailableFree)), Symbol: "\uEDA2");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LauncherItem MakeDriveSummaryItem(DriveInfo2 d)
|
||||||
|
{
|
||||||
|
if (!d.IsReady)
|
||||||
|
return new LauncherItem(d.Name, $"준비 안됨 ({DriveTypeName(d.DriveType)})", null, null, Symbol: "\uEDA2");
|
||||||
|
|
||||||
|
var usagePercent = d.TotalSize > 0 ? (double)d.UsedSpace / d.TotalSize * 100 : 0;
|
||||||
|
var bar = MakeBar(usagePercent, 12);
|
||||||
|
var label = string.IsNullOrEmpty(d.VolumeLabel) ? d.Name : $"{d.Name} ({d.VolumeLabel})";
|
||||||
|
|
||||||
|
return new LauncherItem(
|
||||||
|
label,
|
||||||
|
$"{bar} {usagePercent:F0}% · 여유 {FormatBytes(d.AvailableFree)} / {FormatBytes(d.TotalSize)}",
|
||||||
|
null,
|
||||||
|
("copy", $"{d.Name} {FormatBytes(d.TotalSize)} 사용{usagePercent:F0}% 여유{FormatBytes(d.AvailableFree)}"),
|
||||||
|
Symbol: "\uEDA2");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 유틸 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static string MakeBar(double percent, int width)
|
||||||
|
{
|
||||||
|
var filled = (int)(percent / 100.0 * width);
|
||||||
|
filled = Math.Clamp(filled, 0, width);
|
||||||
|
return "[" + new string('█', filled) + new string('░', width - filled) + "]";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DriveTypeName(DriveType dt) => dt switch
|
||||||
|
{
|
||||||
|
DriveType.Fixed => "고정 디스크",
|
||||||
|
DriveType.Removable => "이동식 디스크",
|
||||||
|
DriveType.Network => "네트워크 드라이브",
|
||||||
|
DriveType.CDRom => "CD/DVD",
|
||||||
|
DriveType.Ram => "RAM 디스크",
|
||||||
|
DriveType.NoRootDirectory => "루트 없음",
|
||||||
|
_ => "알 수 없음",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string FormatBytes(long bytes) => bytes switch
|
||||||
|
{
|
||||||
|
>= 1024L * 1024 * 1024 * 1024 => $"{bytes / 1024.0 / 1024 / 1024 / 1024:F2} TB",
|
||||||
|
>= 1024L * 1024 * 1024 => $"{bytes / 1024.0 / 1024 / 1024:F1} GB",
|
||||||
|
>= 1024L * 1024 => $"{bytes / 1024.0 / 1024:F1} MB",
|
||||||
|
>= 1024L => $"{bytes / 1024.0:F0} KB",
|
||||||
|
_ => $"{bytes} B",
|
||||||
|
};
|
||||||
|
}
|
||||||
219
src/AxCopilot/Handlers/PathHandler.cs
Normal file
219
src/AxCopilot/Handlers/PathHandler.cs
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Windows;
|
||||||
|
using AxCopilot.SDK;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
using AxCopilot.Themes;
|
||||||
|
|
||||||
|
namespace AxCopilot.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// L13-2: PATH 환경변수 뷰어·검색 핸들러. "path" 프리픽스로 사용합니다.
|
||||||
|
///
|
||||||
|
/// 예: path → PATH 전체 목록 (존재/미존재 여부 표시)
|
||||||
|
/// path search git → "git" 포함 경로 필터
|
||||||
|
/// path which git.exe → 실행 파일 위치 검색 (which/where 대응)
|
||||||
|
/// path which python → 확장자 없이도 검색 (.exe/.cmd/.bat 시도)
|
||||||
|
/// path user → 사용자 PATH만 표시
|
||||||
|
/// path system → 시스템 PATH만 표시
|
||||||
|
/// Enter → 경로를 클립보드에 복사.
|
||||||
|
/// </summary>
|
||||||
|
public class PathHandler : IActionHandler
|
||||||
|
{
|
||||||
|
public string? Prefix => "path";
|
||||||
|
|
||||||
|
public PluginMetadata Metadata => new(
|
||||||
|
"Path",
|
||||||
|
"PATH 환경변수 뷰어 — 경로 목록 · 검색 · which",
|
||||||
|
"1.0",
|
||||||
|
"AX");
|
||||||
|
|
||||||
|
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = query.Trim();
|
||||||
|
var items = new List<LauncherItem>();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(q))
|
||||||
|
{
|
||||||
|
var paths = GetAllPaths();
|
||||||
|
var exist = paths.Count(p => p.Exists);
|
||||||
|
var total = paths.Count;
|
||||||
|
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"PATH {total}개 경로 (존재 {exist}개)",
|
||||||
|
"path which <파일> 로 실행 파일 위치 검색",
|
||||||
|
null, null, Symbol: "\uE838"));
|
||||||
|
|
||||||
|
items.Add(new LauncherItem("path user", "사용자 PATH만", null, null, Symbol: "\uE838"));
|
||||||
|
items.Add(new LauncherItem("path system", "시스템 PATH만", null, null, Symbol: "\uE838"));
|
||||||
|
items.Add(new LauncherItem("path which git", "git 위치 검색", null, null, Symbol: "\uE838"));
|
||||||
|
|
||||||
|
foreach (var p in paths.Take(15))
|
||||||
|
items.Add(MakePathItem(p));
|
||||||
|
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var sub = parts[0].ToLowerInvariant();
|
||||||
|
|
||||||
|
switch (sub)
|
||||||
|
{
|
||||||
|
case "which":
|
||||||
|
case "where":
|
||||||
|
case "find":
|
||||||
|
{
|
||||||
|
var target = parts.Length > 1 ? parts[1].Trim() : "";
|
||||||
|
if (string.IsNullOrWhiteSpace(target))
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("파일명 입력", "예: path which git.exe", null, null, Symbol: "\uE783"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
items.AddRange(FindExecutable(target));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "user":
|
||||||
|
{
|
||||||
|
var paths = GetPaths(EnvironmentVariableTarget.User);
|
||||||
|
items.Add(new LauncherItem($"사용자 PATH {paths.Count}개", "", null, null, Symbol: "\uE838"));
|
||||||
|
foreach (var p in paths)
|
||||||
|
items.Add(MakePathItem(p));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "system":
|
||||||
|
{
|
||||||
|
var paths = GetPaths(EnvironmentVariableTarget.Machine);
|
||||||
|
items.Add(new LauncherItem($"시스템 PATH {paths.Count}개", "", null, null, Symbol: "\uE838"));
|
||||||
|
foreach (var p in paths)
|
||||||
|
items.Add(MakePathItem(p));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "search":
|
||||||
|
{
|
||||||
|
var keyword = parts.Length > 1 ? parts[1].ToLowerInvariant() : "";
|
||||||
|
if (string.IsNullOrWhiteSpace(keyword))
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem("검색어 입력", "예: path search python", null, null, Symbol: "\uE783"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
var allPaths = GetAllPaths();
|
||||||
|
var filtered = allPaths.Where(p =>
|
||||||
|
p.Directory.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
|
||||||
|
if (filtered.Count == 0)
|
||||||
|
items.Add(new LauncherItem("결과 없음", $"'{keyword}' 포함 경로 없음", null, null, Symbol: "\uE946"));
|
||||||
|
else
|
||||||
|
foreach (var p in filtered)
|
||||||
|
items.Add(MakePathItem(p));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
{
|
||||||
|
// 기본: 검색어로 처리
|
||||||
|
var keyword = q.ToLowerInvariant();
|
||||||
|
var allPaths = GetAllPaths();
|
||||||
|
var filtered = allPaths.Where(p =>
|
||||||
|
p.Directory.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
|
||||||
|
if (filtered.Count > 0)
|
||||||
|
foreach (var p in filtered.Take(15))
|
||||||
|
items.Add(MakePathItem(p));
|
||||||
|
else
|
||||||
|
// which 로 재시도
|
||||||
|
items.AddRange(FindExecutable(q));
|
||||||
|
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("Path", "클립보드에 복사했습니다.");
|
||||||
|
}
|
||||||
|
catch { /* 비핵심 */ }
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PATH 수집 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private record PathEntry(string Directory, bool Exists, EnvironmentVariableTarget Scope);
|
||||||
|
|
||||||
|
private static List<PathEntry> GetAllPaths()
|
||||||
|
{
|
||||||
|
var result = new List<PathEntry>();
|
||||||
|
result.AddRange(GetPaths(EnvironmentVariableTarget.Process));
|
||||||
|
return result.DistinctBy(p => p.Directory.ToLowerInvariant()).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<PathEntry> GetPaths(EnvironmentVariableTarget target)
|
||||||
|
{
|
||||||
|
var raw = Environment.GetEnvironmentVariable("PATH", target) ?? "";
|
||||||
|
return raw.Split(';', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Where(p => !string.IsNullOrWhiteSpace(p))
|
||||||
|
.Select(p => new PathEntry(p.Trim(), Directory.Exists(p.Trim()), target))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<LauncherItem> FindExecutable(string name)
|
||||||
|
{
|
||||||
|
var extensions = new[] { "", ".exe", ".cmd", ".bat", ".ps1", ".com" };
|
||||||
|
var paths = GetAllPaths();
|
||||||
|
var found = new List<string>();
|
||||||
|
|
||||||
|
foreach (var pathEntry in paths.Where(p => p.Exists))
|
||||||
|
{
|
||||||
|
foreach (var ext in extensions)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(pathEntry.Directory, name + ext);
|
||||||
|
if (File.Exists(candidate))
|
||||||
|
found.Add(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (found.Count == 0)
|
||||||
|
{
|
||||||
|
yield return new LauncherItem($"'{name}' 찾을 수 없음",
|
||||||
|
"PATH에서 해당 실행 파일이 없습니다", null, null, Symbol: "\uE946");
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return new LauncherItem(
|
||||||
|
$"'{name}' {found.Count}개 발견",
|
||||||
|
"전체 복사: Enter",
|
||||||
|
null, ("copy", string.Join("\n", found)), Symbol: "\uE838");
|
||||||
|
|
||||||
|
foreach (var f in found)
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(f) ?? "";
|
||||||
|
var fileName = Path.GetFileName(f);
|
||||||
|
yield return new LauncherItem(
|
||||||
|
fileName,
|
||||||
|
dir,
|
||||||
|
null, ("copy", f), Symbol: "\uE838");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LauncherItem MakePathItem(PathEntry p)
|
||||||
|
{
|
||||||
|
var icon = p.Exists ? "\uE838" : "\uE783";
|
||||||
|
var label = p.Exists ? "" : " (없는 경로)";
|
||||||
|
return new LauncherItem(
|
||||||
|
p.Directory + label,
|
||||||
|
p.Exists ? "존재함" : "경로 없음",
|
||||||
|
null,
|
||||||
|
("copy", p.Directory),
|
||||||
|
Symbol: icon);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user