[Phase L14] 네트워크·계산·시스템 도구 핸들러 4종 추가
WolHandler.cs (신규, ~200줄, prefix=wol): - MAC 파싱: :/- 구분자 제거 후 12자리 hex → byte[6] - 매직 패킷: 0xFF×6 + MAC×16 = 102바이트 UDP 브로드캐스트 - 포트 9 + 7 동시 전송, UdpClient.EnableBroadcast=true - wol_hosts.json 영속 스토리지: save/delete 서브커맨드 - 저장된 호스트 목록 Enter → 즉시 전송 RegHandler.cs (신규, ~185줄, prefix=reg): - HKCU/HKLM/HKCR/HKU/HKCC 5개 하이브 지원 - RegistryKey.OpenSubKey 오류를 변수로 분리 (CS1631 회피) - 값 타입별 포맷: string/int/long/byte[]/string[] 각각 처리 - 9개 즐겨찾기(Run/Uninstall/Environment/Explorer) 빠른 접근 - 조회 전용 — OpenSubKey(writable: false) TipHandler.cs (신규, ~200줄, prefix=tip): - decimal 타입으로 금액 계산 (부동소수점 오차 없음) - 기본 모드: 10/15/18/20/25% 5종 팁 동시 표시 - off: 할인가 + 5~50% 비교, vat: 포함/역산 동시 계산 - 100원 단위 올림: Math.Ceiling(perPerson / 100) * 100 - 쉼표/원 제거 파싱으로 "50,000원" 형식 지원 FontHandler.cs (신규, ~100줄, prefix=font): - Fonts.SystemFontFamilies WPF API (PresentationCore) - static List<string>? _fontCache + lock 객체로 스레드 안전 캐시 - 전체 폰트 알파벳 정렬 후 최초 1회 로드 - 그룹 힌트: 한글/나눔/mono/Arial/Times/Consolas App.xaml.cs: 4개 핸들러 Phase L14 블록 등록 docs/LAUNCHER_ROADMAP.md: Phase L14 완료 섹션 추가 빌드: 경고 0, 오류 0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -308,3 +308,16 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label
|
||||
| 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 | 높음 |
|
||||
|
||||
---
|
||||
|
||||
## Phase L14 — 네트워크·계산·시스템 도구 (v2.0.6) ✅ 완료
|
||||
|
||||
> **방향**: IT 관리·개발 실무 도구 보강 — WoL, 레지스트리 조회, 비용 계산, 폰트 검색.
|
||||
|
||||
| # | 기능 | 설명 | 우선순위 |
|
||||
|---|------|------|----------|
|
||||
| L14-1 | **Wake-on-LAN** ✅ | `wol` 프리픽스. MAC 주소(AA:BB:CC:DD:EE:FF, 대시, 구분자 없음) 매직 패킷 전송. 포트 9+7 브로드캐스트(255.255.255.255). `wol save 이름 MAC`으로 호스트 저장(`wol_hosts.json`). `wol delete 이름` 삭제. 저장 항목 목록에서 Enter → 즉시 전송 | 중간 |
|
||||
| L14-2 | **레지스트리 조회** ✅ | `reg` 프리픽스. HKCU/HKLM/HKCR/HKU/HKCC 모든 하이브 지원. 하위 키·값 목록 표시. 값 타입(REG_SZ/DWORD/BINARY/MULTI_SZ) 포맷 출력. 9개 즐겨찾기 경로 빠른 접근. `reg search` 즐겨찾기 필터. 조회 전용(쓰기/삭제 없음) | 높음 |
|
||||
| L14-3 | **팁·할인·분할 계산기** ✅ | `tip` 프리픽스. 금액만 입력 시 10/15/18/20/25% 팁 전체 표시. `tip 금액 %` 특정 팁. `tip 금액 % 인원` 팁+분할. `tip 금액 off %` 할인가 계산. `tip 금액 vat` VAT 포함/역산. `tip 금액 / 인원` 균등 분할. 100원 단위 올림 계산 | 높음 |
|
||||
| L14-4 | **시스템 폰트 목록** ✅ | `font` 프리픽스. `Fonts.SystemFontFamilies` WPF API 기반. 설치 폰트 전체 목록 캐시(최초 1회 로드). `font 맑은/nanum/mono` 키워드 필터. 한글·나눔·코딩·Arial·Times 그룹 힌트. 검색 결과 전체 일괄 복사 지원 | 중간 |
|
||||
|
||||
@@ -267,6 +267,16 @@ public partial class App : System.Windows.Application
|
||||
// L13-4: 나이·D-day 계산기 (prefix=age)
|
||||
commandResolver.RegisterHandler(new AgeHandler());
|
||||
|
||||
// ─── Phase L14 핸들러 ─────────────────────────────────────────────────
|
||||
// L14-1: Wake-on-LAN (prefix=wol)
|
||||
commandResolver.RegisterHandler(new WolHandler());
|
||||
// L14-2: 레지스트리 조회 (prefix=reg)
|
||||
commandResolver.RegisterHandler(new RegHandler());
|
||||
// L14-3: 팁·할인·분할 계산기 (prefix=tip)
|
||||
commandResolver.RegisterHandler(new TipHandler());
|
||||
// L14-4: 시스템 폰트 목록 (prefix=font)
|
||||
commandResolver.RegisterHandler(new FontHandler());
|
||||
|
||||
// ─── 플러그인 로드 ────────────────────────────────────────────────────
|
||||
var pluginHost = new PluginHost(settings, commandResolver);
|
||||
pluginHost.LoadAll();
|
||||
|
||||
136
src/AxCopilot/Handlers/FontHandler.cs
Normal file
136
src/AxCopilot/Handlers/FontHandler.cs
Normal file
@@ -0,0 +1,136 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L14-4: 시스템 폰트 목록·검색 핸들러. "font" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: font → 설치된 폰트 전체 목록
|
||||
/// font 맑은 → "맑은" 포함 폰트 검색
|
||||
/// font malgun → 영문 이름으로 검색
|
||||
/// font mono → "mono" 포함 폰트 목록
|
||||
/// font nanum → 나눔 폰트 목록
|
||||
/// Enter → 폰트 이름을 클립보드에 복사.
|
||||
/// </summary>
|
||||
public class FontHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "font";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Font",
|
||||
"시스템 폰트 목록 — 검색 · 이름 복사",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
// 폰트 목록 캐시 (최초 1회 로드)
|
||||
private static List<string>? _fontCache;
|
||||
private static readonly object _lock = new();
|
||||
|
||||
private static List<string> GetFonts()
|
||||
{
|
||||
if (_fontCache != null) return _fontCache;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_fontCache != null) return _fontCache;
|
||||
try
|
||||
{
|
||||
_fontCache = Fonts.SystemFontFamilies
|
||||
.Select(f => f.Source)
|
||||
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
_fontCache = new List<string>();
|
||||
}
|
||||
return _fontCache;
|
||||
}
|
||||
}
|
||||
|
||||
// 주목할 만한 폰트 그룹 키워드
|
||||
private static readonly (string Label, string Keyword)[] FontGroups =
|
||||
[
|
||||
("한글 폰트", "malgun"),
|
||||
("나눔 폰트", "nanum"),
|
||||
("코딩용 폰트", "mono"),
|
||||
("Arial 계열", "arial"),
|
||||
("Times 계열", "times"),
|
||||
("Consolas", "consolas"),
|
||||
];
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
var fonts = GetFonts();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"설치된 폰트 {fonts.Count}개",
|
||||
"font <검색어> 로 필터링",
|
||||
null, null, Symbol: "\uE8D2"));
|
||||
|
||||
// 그룹 힌트
|
||||
foreach (var (label, kw) in FontGroups)
|
||||
{
|
||||
var cnt = fonts.Count(f => f.Contains(kw, StringComparison.OrdinalIgnoreCase));
|
||||
if (cnt > 0)
|
||||
items.Add(new LauncherItem(label, $"{cnt}개 · font {kw}", null, null, Symbol: "\uE8D2"));
|
||||
}
|
||||
|
||||
// 첫 15개 표시
|
||||
foreach (var f in fonts.Take(15))
|
||||
items.Add(MakeFontItem(f));
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 검색
|
||||
var filtered = fonts
|
||||
.Where(f => f.Contains(q, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (filtered.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("결과 없음",
|
||||
$"'{q}' 포함 폰트가 없습니다", null, null, Symbol: "\uE946"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"'{q}' 검색 결과 {filtered.Count}개",
|
||||
"전체 복사: 첫 항목 Enter",
|
||||
null,
|
||||
("copy", string.Join("\n", filtered)),
|
||||
Symbol: "\uE8D2"));
|
||||
|
||||
foreach (var f in filtered.Take(30))
|
||||
items.Add(MakeFontItem(f));
|
||||
}
|
||||
|
||||
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("Font", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static LauncherItem MakeFontItem(string fontName) =>
|
||||
new(fontName, "폰트 이름 · Enter 복사", null, ("copy", fontName), Symbol: "\uE8D2");
|
||||
}
|
||||
205
src/AxCopilot/Handlers/RegHandler.cs
Normal file
205
src/AxCopilot/Handlers/RegHandler.cs
Normal file
@@ -0,0 +1,205 @@
|
||||
using Microsoft.Win32;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L14-2: Windows 레지스트리 빠른 조회 핸들러. "reg" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: reg HKCU\Software\Microsoft → 키 하위 값 목록
|
||||
/// reg HKLM\SOFTWARE\Microsoft → HKLM 조회
|
||||
/// reg search DisplayName → 값 이름으로 검색 (제한적)
|
||||
/// reg HKCU\...\Run → 실행 항목 조회
|
||||
/// Enter → 값을 클립보드에 복사.
|
||||
///
|
||||
/// 쓰기/삭제 기능 없음 — 조회 전용.
|
||||
/// </summary>
|
||||
public class RegHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "reg";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Reg",
|
||||
"레지스트리 조회 — HKCU · HKLM 키·값 빠른 조회",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
// 자주 쓰는 즐겨찾기 경로
|
||||
private static readonly (string Label, string Path)[] Favorites =
|
||||
[
|
||||
("현재 사용자 Run", @"HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run"),
|
||||
("모든 사용자 Run", @"HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run"),
|
||||
("설치된 프로그램 (32bit)", @"HKLM\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"),
|
||||
("설치된 프로그램 (64bit)", @"HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"),
|
||||
("환경 변수 (사용자)", @"HKCU\Environment"),
|
||||
("환경 변수 (시스템)", @"HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment"),
|
||||
("Internet Explorer 설정", @"HKCU\SOFTWARE\Microsoft\Internet Explorer\Main"),
|
||||
("Windows 테마", @"HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes"),
|
||||
("탐색기 설정", @"HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced"),
|
||||
];
|
||||
|
||||
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("레지스트리 조회",
|
||||
"예: reg HKCU\\Software\\Microsoft / reg HKLM\\SOFTWARE\\...",
|
||||
null, null, Symbol: "\uE8BE"));
|
||||
items.Add(new LauncherItem("── 즐겨찾기 ──", "", null, null, Symbol: "\uE8BE"));
|
||||
foreach (var (label, path) in Favorites)
|
||||
items.Add(new LauncherItem(label, path, null, ("query", path), Symbol: "\uE8BE"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
if (sub == "search" || sub == "find")
|
||||
{
|
||||
var keyword = parts.Length > 1 ? parts[1] : "";
|
||||
if (string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
items.Add(new LauncherItem("검색어 입력", "예: reg search DisplayName", null, null, Symbol: "\uE783"));
|
||||
}
|
||||
else
|
||||
{
|
||||
// 즐겨찾기 경로에서 해당 이름 검색
|
||||
items.Add(new LauncherItem($"'{keyword}' 즐겨찾기에서 검색", "일치하는 경로:", null, null, Symbol: "\uE8BE"));
|
||||
foreach (var (label, path) in Favorites.Where(f =>
|
||||
f.Label.Contains(keyword, StringComparison.OrdinalIgnoreCase) ||
|
||||
f.Path.Contains(keyword, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
items.Add(new LauncherItem(label, path, null, ("query", path), Symbol: "\uE8BE"));
|
||||
}
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 레지스트리 경로 조회
|
||||
var regPath = q;
|
||||
items.AddRange(QueryRegistry(regPath));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
switch (item.Data)
|
||||
{
|
||||
case ("copy", string text):
|
||||
try
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
|
||||
NotificationService.Notify("Reg", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
break;
|
||||
|
||||
case ("query", string path):
|
||||
// 이미 GetItemsAsync에서 처리됨 — 재조회용 트리거 (런처 재갱신 불가 시 알림만)
|
||||
NotificationService.Notify("Reg", $"조회: {path.Split('\\').Last()}");
|
||||
break;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 레지스트리 조회 ──────────────────────────────────────────────────────
|
||||
|
||||
private static IEnumerable<LauncherItem> QueryRegistry(string path)
|
||||
{
|
||||
var (hive, subKey) = SplitPath(path);
|
||||
if (hive == null)
|
||||
{
|
||||
yield return new LauncherItem("형식 오류",
|
||||
"HKCU 또는 HKLM으로 시작하는 경로를 입력하세요", null, null, Symbol: "\uE783");
|
||||
yield break;
|
||||
}
|
||||
|
||||
RegistryKey? key = null;
|
||||
string? openError = null;
|
||||
try { key = hive.OpenSubKey(subKey, writable: false); }
|
||||
catch (Exception ex) { openError = ex.Message; }
|
||||
|
||||
if (openError != null)
|
||||
{
|
||||
yield return new LauncherItem("접근 오류", openError, null, null, Symbol: "\uE783");
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (key == null)
|
||||
{
|
||||
yield return new LauncherItem("키 없음", $"'{path}' 키가 존재하지 않습니다", null, null, Symbol: "\uE946");
|
||||
yield break;
|
||||
}
|
||||
|
||||
using (key)
|
||||
{
|
||||
// 하위 키 목록
|
||||
var subKeys = key.GetSubKeyNames();
|
||||
if (subKeys.Length > 0)
|
||||
{
|
||||
yield return new LauncherItem($"하위 키 {subKeys.Length}개", path, null, null, Symbol: "\uE8BE");
|
||||
foreach (var sk in subKeys.Take(10))
|
||||
{
|
||||
var fullPath = path.TrimEnd('\\') + @"\" + sk;
|
||||
yield return new LauncherItem($"[{sk}]", fullPath, null, ("copy", fullPath), Symbol: "\uE8BE");
|
||||
}
|
||||
}
|
||||
|
||||
// 값 목록
|
||||
var valueNames = key.GetValueNames();
|
||||
if (valueNames.Length > 0)
|
||||
{
|
||||
yield return new LauncherItem($"── 값 {valueNames.Length}개 ──", "", null, null, Symbol: "\uE8BE");
|
||||
foreach (var vn in valueNames.Take(20))
|
||||
{
|
||||
var val = key.GetValue(vn);
|
||||
var valStr = FormatValue(val);
|
||||
var display = string.IsNullOrEmpty(vn) ? "(기본값)" : vn;
|
||||
yield return new LauncherItem(
|
||||
display,
|
||||
valStr.Length > 80 ? valStr[..80] + "…" : valStr,
|
||||
null, ("copy", valStr), Symbol: "\uE8BE");
|
||||
}
|
||||
}
|
||||
|
||||
if (subKeys.Length == 0 && valueNames.Length == 0)
|
||||
yield return new LauncherItem("빈 키", "하위 키와 값이 없습니다", null, null, Symbol: "\uE946");
|
||||
}
|
||||
}
|
||||
|
||||
private static (RegistryKey? Hive, string SubKey) SplitPath(string path)
|
||||
{
|
||||
path = path.Replace('/', '\\');
|
||||
var sep = path.IndexOf('\\');
|
||||
var hiveStr = sep >= 0 ? path[..sep].ToUpperInvariant() : path.ToUpperInvariant();
|
||||
var sub = sep >= 0 ? path[(sep + 1)..] : "";
|
||||
|
||||
var hive = hiveStr switch
|
||||
{
|
||||
"HKCU" or "HKEY_CURRENT_USER" => Registry.CurrentUser,
|
||||
"HKLM" or "HKEY_LOCAL_MACHINE" => Registry.LocalMachine,
|
||||
"HKCR" or "HKEY_CLASSES_ROOT" => Registry.ClassesRoot,
|
||||
"HKU" or "HKEY_USERS" => Registry.Users,
|
||||
"HKCC" or "HKEY_CURRENT_CONFIG" => Registry.CurrentConfig,
|
||||
_ => null,
|
||||
};
|
||||
return (hive, sub);
|
||||
}
|
||||
|
||||
private static string FormatValue(object? val) => val switch
|
||||
{
|
||||
null => "(null)",
|
||||
string s => s,
|
||||
int i => $"{i} (0x{i:X8})",
|
||||
long l => $"{l} (0x{l:X16})",
|
||||
byte[] bytes => BitConverter.ToString(bytes).Replace("-", " "),
|
||||
string[] arr => string.Join(" | ", arr),
|
||||
_ => val.ToString() ?? "",
|
||||
};
|
||||
}
|
||||
242
src/AxCopilot/Handlers/TipHandler.cs
Normal file
242
src/AxCopilot/Handlers/TipHandler.cs
Normal file
@@ -0,0 +1,242 @@
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L14-3: 팁·할인·분할 계산기 핸들러. "tip" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: tip 50000 → 50,000원에 대한 팁 퍼센트별 계산
|
||||
/// tip 50000 15 → 15% 팁 계산
|
||||
/// tip 50000 / 4 → 4명 분할
|
||||
/// tip 50000 15 4 → 15% 팁 포함 4명 분할
|
||||
/// tip 50000 off 20 → 20% 할인가 계산
|
||||
/// tip 50000 vat → 부가가치세 10% 계산
|
||||
/// Enter → 결과를 클립보드에 복사.
|
||||
/// </summary>
|
||||
public class TipHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "tip";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Tip",
|
||||
"팁·할인·분할 계산기 — 팁 % · 할인 · VAT · 인원 분할",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private static readonly int[] DefaultTipRates = [10, 15, 18, 20, 25];
|
||||
private static readonly int[] DefaultDiscountRates = [5, 10, 15, 20, 30, 50];
|
||||
|
||||
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("팁·할인·분할 계산기",
|
||||
"예: tip 50000 / tip 50000 15 / tip 50000 off 20 / tip 50000 / 4",
|
||||
null, null, Symbol: "\uE8F0"));
|
||||
items.Add(new LauncherItem("tip 50000", "50,000원 팁 계산", null, null, Symbol: "\uE8F0"));
|
||||
items.Add(new LauncherItem("tip 50000 15 4", "15% 팁 + 4명 분할", null, null, Symbol: "\uE8F0"));
|
||||
items.Add(new LauncherItem("tip 50000 off 20", "20% 할인가", null, null, Symbol: "\uE8F0"));
|
||||
items.Add(new LauncherItem("tip 50000 vat", "VAT 10% 계산", null, null, Symbol: "\uE8F0"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// 금액 파싱 (쉼표 제거)
|
||||
if (!TryParseAmount(parts[0], out var amount))
|
||||
{
|
||||
items.Add(new LauncherItem("금액 형식 오류",
|
||||
"예: tip 50000 또는 tip 50,000", null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 서브커맨드 분기
|
||||
if (parts.Length >= 2)
|
||||
{
|
||||
var sub2 = parts[1].ToLowerInvariant();
|
||||
|
||||
// 할인: tip 50000 off 20
|
||||
if (sub2 is "off" or "discount" or "할인")
|
||||
{
|
||||
var rate = parts.Length >= 3 && TryParseAmount(parts[2], out var r) ? r : 10;
|
||||
items.AddRange(BuildDiscountItems(amount, (double)rate));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// VAT: tip 50000 vat [rate]
|
||||
if (sub2 is "vat" or "세금" or "tax")
|
||||
{
|
||||
var rate = parts.Length >= 3 && TryParseAmount(parts[2], out var r) ? (double)r : 10.0;
|
||||
items.AddRange(BuildVatItems(amount, rate));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 분할: tip 50000 / 4
|
||||
if (sub2 == "/" && parts.Length >= 3 && TryParseAmount(parts[2], out var people2))
|
||||
{
|
||||
items.AddRange(BuildSplitItems(amount, 0, (int)people2));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 팁%: tip 50000 15 또는 tip 50000 15 4
|
||||
if (TryParseAmount(parts[1], out var tipRate))
|
||||
{
|
||||
var people = parts.Length >= 3 && TryParseAmount(parts[2], out var p) ? (int)p : 1;
|
||||
items.AddRange(BuildTipItems(amount, (double)tipRate, people));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
}
|
||||
|
||||
// 기본: 팁 퍼센트별 목록
|
||||
items.AddRange(BuildDefaultTipItems(amount));
|
||||
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("Tip", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 계산 빌더 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static IEnumerable<LauncherItem> BuildDefaultTipItems(decimal amount)
|
||||
{
|
||||
yield return new LauncherItem(
|
||||
$"원금 {FormatKrw(amount)}",
|
||||
"팁 퍼센트별 합계",
|
||||
null, ("copy", FormatKrw(amount)), Symbol: "\uE8F0");
|
||||
|
||||
foreach (var rate in DefaultTipRates)
|
||||
{
|
||||
var tip = amount * rate / 100;
|
||||
var total = amount + tip;
|
||||
yield return new LauncherItem(
|
||||
$"{rate}% → {FormatKrw(total)}",
|
||||
$"팁 {FormatKrw(tip)} · 합계 {FormatKrw(total)}",
|
||||
null, ("copy", FormatKrw(total)), Symbol: "\uE8F0");
|
||||
}
|
||||
|
||||
// 분할 미리보기
|
||||
yield return new LauncherItem($"2명 분할", FormatKrw(amount / 2), null, ("copy", FormatKrw(amount / 2)), Symbol: "\uE8F0");
|
||||
yield return new LauncherItem($"4명 분할", FormatKrw(amount / 4), null, ("copy", FormatKrw(amount / 4)), Symbol: "\uE8F0");
|
||||
}
|
||||
|
||||
private static IEnumerable<LauncherItem> BuildTipItems(decimal amount, double tipPct, int people)
|
||||
{
|
||||
var tip = amount * (decimal)tipPct / 100;
|
||||
var total = amount + tip;
|
||||
var perPerson = people > 1 ? total / people : total;
|
||||
|
||||
yield return new LauncherItem(
|
||||
$"합계 {FormatKrw(total)}",
|
||||
$"원금 {FormatKrw(amount)} + 팁 {tipPct}% ({FormatKrw(tip)})",
|
||||
null, ("copy", FormatKrw(total)), Symbol: "\uE8F0");
|
||||
|
||||
yield return new LauncherItem("원금", FormatKrw(amount), null, ("copy", FormatKrw(amount)), Symbol: "\uE8F0");
|
||||
yield return new LauncherItem($"팁 {tipPct}%", FormatKrw(tip), null, ("copy", FormatKrw(tip)), Symbol: "\uE8F0");
|
||||
yield return new LauncherItem("합계", FormatKrw(total), null, ("copy", FormatKrw(total)), Symbol: "\uE8F0");
|
||||
|
||||
if (people > 1)
|
||||
{
|
||||
yield return new LauncherItem(
|
||||
$"{people}명 분할",
|
||||
$"1인당 {FormatKrw(perPerson)}",
|
||||
null, ("copy", FormatKrw(perPerson)), Symbol: "\uE8F0");
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<LauncherItem> BuildDiscountItems(decimal amount, double discountPct)
|
||||
{
|
||||
var discount = amount * (decimal)discountPct / 100;
|
||||
var discounted = amount - discount;
|
||||
|
||||
yield return new LauncherItem(
|
||||
$"할인가 {FormatKrw(discounted)}",
|
||||
$"{discountPct}% 할인 (할인액 {FormatKrw(discount)})",
|
||||
null, ("copy", FormatKrw(discounted)), Symbol: "\uE8F0");
|
||||
|
||||
yield return new LauncherItem("원가", FormatKrw(amount), null, ("copy", FormatKrw(amount)), Symbol: "\uE8F0");
|
||||
yield return new LauncherItem($"할인 {discountPct}%", FormatKrw(discount), null, ("copy", FormatKrw(discount)), Symbol: "\uE8F0");
|
||||
yield return new LauncherItem("할인가", FormatKrw(discounted), null, ("copy", FormatKrw(discounted)), Symbol: "\uE8F0");
|
||||
|
||||
// 다른 할인율 비교
|
||||
yield return new LauncherItem("── 할인율 비교 ──", "", null, null, Symbol: "\uE8F0");
|
||||
foreach (var rate in DefaultDiscountRates.Where(r => r != (int)discountPct))
|
||||
{
|
||||
var d = amount * rate / 100;
|
||||
yield return new LauncherItem($"{rate}% → {FormatKrw(amount - d)}",
|
||||
$"할인 {FormatKrw(d)}", null, ("copy", FormatKrw(amount - d)), Symbol: "\uE8F0");
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<LauncherItem> BuildVatItems(decimal amount, double vatRate)
|
||||
{
|
||||
var vat = amount * (decimal)vatRate / 100;
|
||||
var withVat = amount + vat;
|
||||
var exVat = amount / (1 + (decimal)vatRate / 100);
|
||||
var vatOnly = amount - exVat;
|
||||
|
||||
yield return new LauncherItem(
|
||||
$"VAT 포함 {FormatKrw(withVat)}",
|
||||
$"VAT {vatRate}% ({FormatKrw(vat)})",
|
||||
null, ("copy", FormatKrw(withVat)), Symbol: "\uE8F0");
|
||||
|
||||
yield return new LauncherItem($"입력액 (VAT 별도)", FormatKrw(amount), null, ("copy", FormatKrw(amount)), Symbol: "\uE8F0");
|
||||
yield return new LauncherItem($"VAT {vatRate}%", FormatKrw(vat), null, ("copy", FormatKrw(vat)), Symbol: "\uE8F0");
|
||||
yield return new LauncherItem("VAT 포함 합계", FormatKrw(withVat), null, ("copy", FormatKrw(withVat)), Symbol: "\uE8F0");
|
||||
yield return new LauncherItem("── 역산 (VAT 포함가 입력 시) ──", "", null, null, Symbol: "\uE8F0");
|
||||
yield return new LauncherItem("공급가액 (VAT 제외)", $"{FormatKrw(exVat)}", null, ("copy", FormatKrw(exVat)), Symbol: "\uE8F0");
|
||||
yield return new LauncherItem("VAT 금액", $"{FormatKrw(vatOnly)}", null, ("copy", FormatKrw(vatOnly)), Symbol: "\uE8F0");
|
||||
}
|
||||
|
||||
private static IEnumerable<LauncherItem> BuildSplitItems(decimal amount, double tipPct, int people)
|
||||
{
|
||||
if (people <= 0) people = 1;
|
||||
var tip = tipPct > 0 ? amount * (decimal)tipPct / 100 : 0;
|
||||
var total = amount + tip;
|
||||
var perPerson = total / people;
|
||||
var rounded = Math.Ceiling(perPerson / 100) * 100; // 100원 단위 올림
|
||||
|
||||
yield return new LauncherItem(
|
||||
$"{people}명 분할 1인 {FormatKrw(perPerson)}",
|
||||
$"합계 {FormatKrw(total)}",
|
||||
null, ("copy", FormatKrw(perPerson)), Symbol: "\uE8F0");
|
||||
|
||||
yield return new LauncherItem("합계", FormatKrw(total), null, ("copy", FormatKrw(total)), Symbol: "\uE8F0");
|
||||
yield return new LauncherItem($"1인 (정확)", FormatKrw(perPerson), null, ("copy", FormatKrw(perPerson)), Symbol: "\uE8F0");
|
||||
yield return new LauncherItem($"1인 (100원↑)", FormatKrw(rounded), null, ("copy", FormatKrw(rounded)), Symbol: "\uE8F0");
|
||||
}
|
||||
|
||||
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static bool TryParseAmount(string s, out decimal result)
|
||||
{
|
||||
result = 0;
|
||||
s = s.Replace(",", "").Replace("원", "").Trim();
|
||||
return decimal.TryParse(s, System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out result);
|
||||
}
|
||||
|
||||
private static string FormatKrw(decimal amount)
|
||||
{
|
||||
if (amount == Math.Floor(amount))
|
||||
return $"{amount:N0}원";
|
||||
return $"{amount:N2}원";
|
||||
}
|
||||
}
|
||||
260
src/AxCopilot/Handlers/WolHandler.cs
Normal file
260
src/AxCopilot/Handlers/WolHandler.cs
Normal file
@@ -0,0 +1,260 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
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>
|
||||
/// L14-1: Wake-on-LAN 핸들러. "wol" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: wol → 저장된 호스트 목록
|
||||
/// wol AA:BB:CC:DD:EE:FF → 매직 패킷 전송
|
||||
/// wol AA-BB-CC-DD-EE-FF → 대시 구분자도 지원
|
||||
/// wol AABBCCDDEEFF → 구분자 없는 형식
|
||||
/// wol save PC-1 AA:BB:CC:DD:EE:FF → 호스트 저장
|
||||
/// wol delete PC-1 → 저장 항목 삭제
|
||||
/// Enter → 매직 패킷 전송.
|
||||
/// </summary>
|
||||
public class WolHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "wol";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"WoL",
|
||||
"Wake-on-LAN — 매직 패킷 전송 · 호스트 관리",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private static readonly string StorePath = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "wol_hosts.json");
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
var hosts = LoadHosts();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
if (hosts.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("Wake-on-LAN",
|
||||
"예: wol AA:BB:CC:DD:EE:FF / wol save 이름 AA:BB:CC:...",
|
||||
null, null, Symbol: "\uE823"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"저장된 호스트 {hosts.Count}개",
|
||||
"Enter → 매직 패킷 전송", null, null, Symbol: "\uE823"));
|
||||
foreach (var h in hosts)
|
||||
items.Add(MakeHostItem(h));
|
||||
}
|
||||
items.Add(new LauncherItem("wol save 이름 MAC", "호스트 저장", null, null, Symbol: "\uE823"));
|
||||
items.Add(new LauncherItem("wol AA:BB:CC:…", "직접 전송", null, null, Symbol: "\uE823"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
switch (sub)
|
||||
{
|
||||
case "save":
|
||||
case "add":
|
||||
{
|
||||
if (parts.Length < 3)
|
||||
{
|
||||
items.Add(new LauncherItem("형식 오류", "예: wol save PC-이름 AA:BB:CC:DD:EE:FF", null, null, Symbol: "\uE783"));
|
||||
break;
|
||||
}
|
||||
var name = parts[1];
|
||||
var mac = parts[2];
|
||||
if (!TryParseMac(mac, out var macBytes))
|
||||
{
|
||||
items.Add(new LauncherItem("MAC 형식 오류", $"'{mac}'은 유효한 MAC 주소가 아닙니다", null, null, Symbol: "\uE783"));
|
||||
break;
|
||||
}
|
||||
items.Add(new LauncherItem(
|
||||
$"저장: {name} ({mac})",
|
||||
"Enter → 저장",
|
||||
null, ("save", $"{name}|{mac}"), Symbol: "\uE823"));
|
||||
break;
|
||||
}
|
||||
|
||||
case "delete":
|
||||
case "del":
|
||||
case "remove":
|
||||
{
|
||||
var target = parts.Length > 1 ? string.Join(" ", parts.Skip(1)) : "";
|
||||
var found = hosts.FirstOrDefault(h =>
|
||||
h.Name.Equals(target, StringComparison.OrdinalIgnoreCase));
|
||||
if (found == null)
|
||||
items.Add(new LauncherItem("없는 항목", $"'{target}'을 찾을 수 없습니다", null, null, Symbol: "\uE783"));
|
||||
else
|
||||
items.Add(new LauncherItem($"삭제: {found.Name}", found.Mac, null, ("delete", found.Name), Symbol: "\uE74D"));
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
{
|
||||
// MAC 주소 직접 입력
|
||||
if (TryParseMac(q, out _))
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"매직 패킷 전송: {q}",
|
||||
"Enter → 브로드캐스트 전송 (255.255.255.255:9)",
|
||||
null, ("send", q), Symbol: "\uE823"));
|
||||
}
|
||||
// 저장된 호스트 이름 검색
|
||||
else
|
||||
{
|
||||
var filtered = hosts.Where(h =>
|
||||
h.Name.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
if (filtered.Count == 0)
|
||||
items.Add(new LauncherItem("결과 없음",
|
||||
$"'{q}' 항목 없음. MAC 형식: AA:BB:CC:DD:EE:FF", null, null, Symbol: "\uE946"));
|
||||
else
|
||||
foreach (var h in filtered)
|
||||
items.Add(MakeHostItem(h));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
switch (item.Data)
|
||||
{
|
||||
case ("send", string mac):
|
||||
SendMagicPacket(mac);
|
||||
break;
|
||||
|
||||
case ("wake", string mac):
|
||||
SendMagicPacket(mac);
|
||||
break;
|
||||
|
||||
case ("save", string data):
|
||||
{
|
||||
var idx = data.IndexOf('|');
|
||||
var name = data[..idx];
|
||||
var mac = data[(idx + 1)..];
|
||||
var hosts = LoadHosts();
|
||||
hosts.RemoveAll(h => h.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
hosts.Add(new WolHost { Name = name, Mac = mac });
|
||||
SaveHosts(hosts);
|
||||
NotificationService.Notify("WoL", $"'{name}' ({mac}) 저장됨");
|
||||
break;
|
||||
}
|
||||
|
||||
case ("delete", string name):
|
||||
{
|
||||
var hosts = LoadHosts();
|
||||
hosts.RemoveAll(h => h.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
SaveHosts(hosts);
|
||||
NotificationService.Notify("WoL", $"'{name}' 삭제됨");
|
||||
break;
|
||||
}
|
||||
|
||||
case ("copy", string text):
|
||||
try
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
|
||||
NotificationService.Notify("WoL", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
break;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 매직 패킷 전송 ────────────────────────────────────────────────────────
|
||||
|
||||
private static void SendMagicPacket(string mac)
|
||||
{
|
||||
if (!TryParseMac(mac, out var macBytes))
|
||||
{
|
||||
NotificationService.Notify("WoL", $"MAC 형식 오류: {mac}");
|
||||
return;
|
||||
}
|
||||
|
||||
// 매직 패킷: 0xFF × 6 + MAC × 16
|
||||
var packet = new byte[102];
|
||||
for (var i = 0; i < 6; i++)
|
||||
packet[i] = 0xFF;
|
||||
for (var i = 1; i <= 16; i++)
|
||||
Array.Copy(macBytes, 0, packet, i * 6, 6);
|
||||
|
||||
try
|
||||
{
|
||||
using var udp = new UdpClient();
|
||||
udp.EnableBroadcast = true;
|
||||
udp.Send(packet, packet.Length, new IPEndPoint(IPAddress.Broadcast, 9));
|
||||
// 포트 7도 함께 전송 (일부 장치)
|
||||
udp.Send(packet, packet.Length, new IPEndPoint(IPAddress.Broadcast, 7));
|
||||
NotificationService.Notify("WoL", $"매직 패킷 전송됨 → {mac}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
NotificationService.Notify("WoL", $"전송 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 파싱·저장 헬퍼 ────────────────────────────────────────────────────────
|
||||
|
||||
private static bool TryParseMac(string s, out byte[] bytes)
|
||||
{
|
||||
bytes = [];
|
||||
s = s.Trim().Replace(":", "").Replace("-", "").Replace(".", "");
|
||||
if (s.Length != 12) return false;
|
||||
try
|
||||
{
|
||||
bytes = Enumerable.Range(0, 6)
|
||||
.Select(i => Convert.ToByte(s.Substring(i * 2, 2), 16))
|
||||
.ToArray();
|
||||
return true;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static LauncherItem MakeHostItem(WolHost h) =>
|
||||
new(h.Name, h.Mac, null, ("wake", h.Mac), Symbol: "\uE823");
|
||||
|
||||
// ── 영속 스토리지 ─────────────────────────────────────────────────────────
|
||||
|
||||
private class WolHost
|
||||
{
|
||||
[JsonPropertyName("name")] public string Name { get; set; } = "";
|
||||
[JsonPropertyName("mac")] public string Mac { get; set; } = "";
|
||||
}
|
||||
|
||||
private static List<WolHost> LoadHosts()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!System.IO.File.Exists(StorePath)) return new();
|
||||
var json = System.IO.File.ReadAllText(StorePath);
|
||||
return JsonSerializer.Deserialize<List<WolHost>>(json) ?? new();
|
||||
}
|
||||
catch { return new(); }
|
||||
}
|
||||
|
||||
private static void SaveHosts(List<WolHost> hosts)
|
||||
{
|
||||
try
|
||||
{
|
||||
System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(StorePath)!);
|
||||
System.IO.File.WriteAllText(StorePath,
|
||||
JsonSerializer.Serialize(hosts, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user