AX Commander 비교본 런처 기능 대량 이식
변경 목적: Agent Compare 아래 비교본의 개발 문서와 런처 소스를 기준으로 현재 AX Commander에 빠져 있던 신규 런처 기능을 동일한 흐름으로 옮겨, 비교본 수준의 기능 폭을 현재 제품에 반영했습니다. 핵심 수정사항: 비교본의 신규 런처 핸들러 다수를 src/AxCopilot/Handlers로 이식하고 App.xaml.cs 등록 흐름에 연결했습니다. 빠른 링크, 파일 태그, 알림 센터, 포모도로, 파일 브라우저, 핫키 관리, OCR, 세션/스케줄/매크로, Git/정규식/네트워크/압축/해시/UUID/JWT/QR 등 AX Commander 기능을 추가했습니다. 핵심 수정사항: 신규 기능이 실제 동작하도록 AppSettings 확장, SchedulerService/FileTagService/NotificationCenterService/IconCacheService/UrlTemplateEngine/PomodoroService 추가, 배치 이름변경/세션/스케줄/매크로 편집 창 추가, NotificationService와 Symbols 보강, QR/OCR용 csproj 의존성과 Windows 타겟 프레임워크를 반영했습니다. 문서 반영: README.md와 docs/DEVELOPMENT.md에 비교본 기반 런처 기능 이식 이력과 검증 결과를 업데이트했습니다. 검증 결과: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 실행 기준 경고 0개, 오류 0개를 확인했습니다.
This commit is contained in:
@@ -8,6 +8,11 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저
|
||||
`docs/claw-code-parity-plan.md`
|
||||
|
||||
|
||||
- 업데이트: 2026-04-05 00:58 (KST)
|
||||
- `Agent Compare/AX Copilot`의 개발 문서와 런처 소스를 대조해 AX Commander 신규 기능 묶음을 이식했습니다. 빠른 링크, 파일 태그, 알림 센터, 포모도로, 파일 브라우저, 핫키 관리, OCR, 세션/스케줄/매크로, Git/정규식/네트워크/압축/해시/SSH/UUID/JWT/QR 등 비교본에 있던 다수의 런처 핸들러를 현재 앱에 등록했습니다.
|
||||
- 런처 기능 이식에 맞춰 스케줄러/태그/알림 기록/아이콘 캐시/URL 템플릿 서비스와 편집용 보조 창, 설정 모델, 런처 위치 기억 설정, QR/OCR 빌드 의존성도 함께 반영했습니다.
|
||||
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
|
||||
|
||||
- 업데이트: 2026-04-05 00:46 (KST)
|
||||
- 트레이 아이콘 우클릭 메뉴 맨 위의 앱 이름/버전 헤더 글자색을 진한 회색으로 조정해, 본문 메뉴 항목보다 덜 튀면서도 더 또렷하게 보이도록 정리했습니다.
|
||||
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# AX Copilot - 媛쒕컻 臾몄꽌
|
||||
|
||||
- Document update: 2026-04-05 00:58 (KST) - Compared the launcher implementation under `Agent Compare/AX Copilot` with the current AX Commander and imported the missing launcher handler set into the live app registration flow, excluding only the compare app's AI-coupled launcher handlers.
|
||||
- Document update: 2026-04-05 00:58 (KST) - Added launcher-side support files for the imported feature set: quick-link/session/schedule/macro/SSH settings models, scheduler/tag/notification-history/icon-cache/url-template/pomodoro services, editor windows, launcher position persistence fields, and QR/OCR build dependencies.
|
||||
- Document update: 2026-04-05 00:52 (KST) - Normalized the composer/footer wording so model, data-usage, permission, and Git branch surfaces read in the same status language instead of mixing short labels and raw values.
|
||||
- Document update: 2026-04-05 00:52 (KST) - Added state-colored border cues and clearer tooltips for the data-usage and permission chips so the bottom status row communicates the active mode more directly.
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ public partial class App : System.Windows.Application
|
||||
private SettingsService? _settings;
|
||||
private SettingsWindow? _settingsWindow;
|
||||
private PluginHost? _pluginHost;
|
||||
private SchedulerService? _schedulerService;
|
||||
private ClipboardHistoryService? _clipboardHistory;
|
||||
private DockBarWindow? _dockBar;
|
||||
private FileDialogWatcher? _fileDialogWatcher;
|
||||
@@ -172,6 +173,102 @@ public partial class App : System.Windows.Application
|
||||
commandResolver.RegisterHandler(new EverythingHandler());
|
||||
commandResolver.RegisterHandler(new HelpHandler(settings));
|
||||
commandResolver.RegisterHandler(new ChatHandler(settings));
|
||||
commandResolver.RegisterHandler(new QuickLinkHandler(settings));
|
||||
commandResolver.RegisterHandler(new TagHandler());
|
||||
commandResolver.RegisterHandler(new NotifHandler());
|
||||
commandResolver.RegisterHandler(new PomoHandler());
|
||||
commandResolver.RegisterHandler(new FileBrowserHandler());
|
||||
commandResolver.RegisterHandler(new HotkeyHandler(settings));
|
||||
commandResolver.RegisterHandler(new OcrHandler());
|
||||
commandResolver.RegisterHandler(new SessionHandler(settings));
|
||||
commandResolver.RegisterHandler(new BatchRenameHandler());
|
||||
_schedulerService = new SchedulerService(settings);
|
||||
_schedulerService.Start();
|
||||
commandResolver.RegisterHandler(new ScheduleHandler(settings));
|
||||
commandResolver.RegisterHandler(new MacroHandler(settings));
|
||||
commandResolver.RegisterHandler(new ContextHandler());
|
||||
commandResolver.RegisterHandler(new GitHandler());
|
||||
commandResolver.RegisterHandler(new RegexHandler());
|
||||
commandResolver.RegisterHandler(new TimeZoneHandler());
|
||||
commandResolver.RegisterHandler(new NetDiagHandler());
|
||||
commandResolver.RegisterHandler(new FileHashHandler());
|
||||
commandResolver.RegisterHandler(new ZipHandler());
|
||||
commandResolver.RegisterHandler(new EventLogHandler());
|
||||
commandResolver.RegisterHandler(new SshHandler(settings));
|
||||
commandResolver.RegisterHandler(new PasswordGenHandler());
|
||||
commandResolver.RegisterHandler(new SubnetHandler());
|
||||
commandResolver.RegisterHandler(new CleanHandler());
|
||||
commandResolver.RegisterHandler(new BaseConvertHandler());
|
||||
commandResolver.RegisterHandler(new XmlHandler());
|
||||
commandResolver.RegisterHandler(new UuidHandler());
|
||||
commandResolver.RegisterHandler(new CertHandler());
|
||||
commandResolver.RegisterHandler(new LoremHandler());
|
||||
commandResolver.RegisterHandler(new CsvHandler());
|
||||
commandResolver.RegisterHandler(new JwtHandler());
|
||||
commandResolver.RegisterHandler(new CronHandler());
|
||||
commandResolver.RegisterHandler(new UnicodeHandler());
|
||||
commandResolver.RegisterHandler(new HttpTesterHandler());
|
||||
commandResolver.RegisterHandler(new HostsHandler());
|
||||
commandResolver.RegisterHandler(new MorseHandler());
|
||||
commandResolver.RegisterHandler(new StartupHandler());
|
||||
commandResolver.RegisterHandler(new DnsQueryHandler());
|
||||
commandResolver.RegisterHandler(new PathHandler());
|
||||
commandResolver.RegisterHandler(new DriveHandler());
|
||||
commandResolver.RegisterHandler(new AgeHandler());
|
||||
commandResolver.RegisterHandler(new WolHandler());
|
||||
commandResolver.RegisterHandler(new RegHandler());
|
||||
commandResolver.RegisterHandler(new TipHandler());
|
||||
commandResolver.RegisterHandler(new FontHandler());
|
||||
commandResolver.RegisterHandler(new WslHandler());
|
||||
commandResolver.RegisterHandler(new CurrencyHandler());
|
||||
commandResolver.RegisterHandler(new BmiHandler());
|
||||
commandResolver.RegisterHandler(new MdHandler());
|
||||
commandResolver.RegisterHandler(new PingHandler());
|
||||
commandResolver.RegisterHandler(new DockerHandler());
|
||||
commandResolver.RegisterHandler(new TodoHandler());
|
||||
commandResolver.RegisterHandler(new TableHandler());
|
||||
commandResolver.RegisterHandler(new UnitHandler());
|
||||
commandResolver.RegisterHandler(new NumHandler());
|
||||
commandResolver.RegisterHandler(new YamlHandler());
|
||||
commandResolver.RegisterHandler(new GitignoreHandler());
|
||||
commandResolver.RegisterHandler(new SqlHandler());
|
||||
commandResolver.RegisterHandler(new TextCaseHandler());
|
||||
commandResolver.RegisterHandler(new AspectHandler());
|
||||
commandResolver.RegisterHandler(new AbbrHandler());
|
||||
commandResolver.RegisterHandler(new CalcHandler());
|
||||
commandResolver.RegisterHandler(new TimerHandler());
|
||||
commandResolver.RegisterHandler(new IpInfoHandler());
|
||||
commandResolver.RegisterHandler(new NpmHandler());
|
||||
commandResolver.RegisterHandler(new HexHandler());
|
||||
commandResolver.RegisterHandler(new RandHandler());
|
||||
commandResolver.RegisterHandler(new StrHandler());
|
||||
commandResolver.RegisterHandler(new PermHandler());
|
||||
commandResolver.RegisterHandler(new TomlHandler());
|
||||
commandResolver.RegisterHandler(new LogHandler());
|
||||
commandResolver.RegisterHandler(new PsHandler());
|
||||
commandResolver.RegisterHandler(new KeyHandler());
|
||||
commandResolver.RegisterHandler(new ProcHandler());
|
||||
commandResolver.RegisterHandler(new XlHandler());
|
||||
commandResolver.RegisterHandler(new PipHandler());
|
||||
commandResolver.RegisterHandler(new FormHandler());
|
||||
commandResolver.RegisterHandler(new CalHandler());
|
||||
commandResolver.RegisterHandler(new LeaveHandler());
|
||||
commandResolver.RegisterHandler(new WorkTimeHandler());
|
||||
commandResolver.RegisterHandler(new FixHandler());
|
||||
commandResolver.RegisterHandler(new SpellHandler());
|
||||
commandResolver.RegisterHandler(new ContactHandler());
|
||||
commandResolver.RegisterHandler(new RemindHandler());
|
||||
commandResolver.RegisterHandler(new PhraseHandler());
|
||||
commandResolver.RegisterHandler(new TodayHandler());
|
||||
commandResolver.RegisterHandler(new VolHandler());
|
||||
commandResolver.RegisterHandler(new QrHandler());
|
||||
commandResolver.RegisterHandler(new MeetHandler());
|
||||
commandResolver.RegisterHandler(new BrightHandler());
|
||||
commandResolver.RegisterHandler(new PasteHandler(_clipboardHistory));
|
||||
commandResolver.RegisterHandler(new PkgHandler());
|
||||
commandResolver.RegisterHandler(new ApHandler());
|
||||
commandResolver.RegisterHandler(new DictHandler());
|
||||
commandResolver.RegisterHandler(new FlowHandler());
|
||||
|
||||
// ─── 플러그인 로드 ────────────────────────────────────────────────────
|
||||
var pluginHost = new PluginHost(settings, commandResolver);
|
||||
@@ -848,6 +945,7 @@ public partial class App : System.Windows.Application
|
||||
_inputListener?.Dispose();
|
||||
_clipboardHistory?.Dispose();
|
||||
_indexService?.Dispose();
|
||||
_schedulerService?.Dispose();
|
||||
_sessionTracking?.Dispose();
|
||||
_worktimeReminder?.Dispose();
|
||||
_trayIcon?.Dispose();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<TargetFramework>net8.0-windows10.0.17763.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWPF>true</UseWPF>
|
||||
@@ -66,6 +66,7 @@
|
||||
<PackageReference Include="Markdig" Version="0.37.0" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2903.40" />
|
||||
<PackageReference Include="QRCoder" Version="1.6.0" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.1" />
|
||||
<PackageReference Include="UglyToad.PdfPig" Version="1.7.0-custom-5" />
|
||||
|
||||
427
src/AxCopilot/Handlers/AbbrHandler.cs
Normal file
427
src/AxCopilot/Handlers/AbbrHandler.cs
Normal file
@@ -0,0 +1,427 @@
|
||||
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>
|
||||
/// L18-4: IT·개발 약어 사전 핸들러. "abbr" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: abbr → 주요 약어 목록
|
||||
/// abbr api → API 뜻 + 설명
|
||||
/// abbr crud → CRUD 약어 풀이
|
||||
/// abbr rest → REST 설명
|
||||
/// abbr http → HTTP 약어 목록
|
||||
/// Enter → 약어 또는 뜻을 클립보드에 복사.
|
||||
/// </summary>
|
||||
public class AbbrHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "abbr";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Abbr",
|
||||
"IT·개발 약어 사전 — API · CRUD · REST · HTTP · SQL 등 내장",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private record AbbrEntry(string Short, string Full, string Description, string Category);
|
||||
|
||||
// ── 내장 약어 사전 (~150개) ───────────────────────────────────────────────
|
||||
private static readonly AbbrEntry[] Entries =
|
||||
[
|
||||
// 웹/네트워크
|
||||
new("API", "Application Programming Interface", "소프트웨어 간 통신 규격", "웹/네트워크"),
|
||||
new("REST", "Representational State Transfer", "HTTP 기반 아키텍처 스타일", "웹/네트워크"),
|
||||
new("HTTP", "HyperText Transfer Protocol", "웹 통신 프로토콜", "웹/네트워크"),
|
||||
new("HTTPS", "HTTP Secure", "TLS/SSL로 암호화된 HTTP", "웹/네트워크"),
|
||||
new("HTML", "HyperText Markup Language", "웹 페이지 마크업 언어", "웹/네트워크"),
|
||||
new("CSS", "Cascading Style Sheets", "웹 스타일 시트 언어", "웹/네트워크"),
|
||||
new("URL", "Uniform Resource Locator", "인터넷 자원 주소", "웹/네트워크"),
|
||||
new("URI", "Uniform Resource Identifier", "자원 식별자 (URL의 상위 개념)", "웹/네트워크"),
|
||||
new("DNS", "Domain Name System", "도메인 ↔ IP 변환 시스템", "웹/네트워크"),
|
||||
new("IP", "Internet Protocol", "인터넷 패킷 전송 프로토콜", "웹/네트워크"),
|
||||
new("TCP", "Transmission Control Protocol", "신뢰성 연결형 전송 프로토콜", "웹/네트워크"),
|
||||
new("UDP", "User Datagram Protocol", "비연결형 전송 프로토콜", "웹/네트워크"),
|
||||
new("SSH", "Secure Shell", "원격 보안 접속 프로토콜", "웹/네트워크"),
|
||||
new("FTP", "File Transfer Protocol", "파일 전송 프로토콜", "웹/네트워크"),
|
||||
new("SMTP", "Simple Mail Transfer Protocol", "이메일 전송 프로토콜", "웹/네트워크"),
|
||||
new("CDN", "Content Delivery Network", "콘텐츠 분산 배포 네트워크", "웹/네트워크"),
|
||||
new("VPN", "Virtual Private Network", "가상 사설 네트워크", "웹/네트워크"),
|
||||
new("NAT", "Network Address Translation", "사설↔공인 IP 주소 변환", "웹/네트워크"),
|
||||
new("CORS", "Cross-Origin Resource Sharing", "브라우저 교차 출처 리소스 공유", "웹/네트워크"),
|
||||
new("SSE", "Server-Sent Events", "서버→클라이언트 단방향 스트림", "웹/네트워크"),
|
||||
new("WebSocket","WebSocket Protocol", "양방향 실시간 통신 프로토콜", "웹/네트워크"),
|
||||
new("gRPC", "Google Remote Procedure Call", "프로토콜 버퍼 기반 RPC 프레임워크", "웹/네트워크"),
|
||||
new("GraphQL", "Graph Query Language", "API 쿼리 언어 (Facebook 개발)", "웹/네트워크"),
|
||||
new("SOAP", "Simple Object Access Protocol", "XML 기반 웹 서비스 프로토콜", "웹/네트워크"),
|
||||
new("WSDL", "Web Services Description Language", "웹 서비스 인터페이스 설명 언어", "웹/네트워크"),
|
||||
new("TLS", "Transport Layer Security", "암호화 통신 프로토콜 (SSL 후속)", "웹/네트워크"),
|
||||
new("SSL", "Secure Sockets Layer", "암호화 통신 프로토콜 (TLS의 전신)", "웹/네트워크"),
|
||||
new("OAuth", "Open Authorization", "위임 인증 표준 프로토콜", "웹/네트워크"),
|
||||
new("MIME", "Multipurpose Internet Mail Extensions", "이메일·웹 콘텐츠 타입 표준", "웹/네트워크"),
|
||||
|
||||
// 개발/프로그래밍
|
||||
new("CRUD", "Create Read Update Delete", "기본 데이터 처리 4가지 연산", "개발"),
|
||||
new("OOP", "Object-Oriented Programming", "객체 지향 프로그래밍", "개발"),
|
||||
new("SOLID", "Single responsibility Open/closed Liskov Interface-segregation Dependency-inversion",
|
||||
"객체지향 5가지 설계 원칙", "개발"),
|
||||
new("DRY", "Don't Repeat Yourself", "코드 중복 최소화 원칙", "개발"),
|
||||
new("KISS", "Keep It Simple, Stupid", "단순하게 유지하라 원칙", "개발"),
|
||||
new("YAGNI", "You Aren't Gonna Need It", "필요할 때만 구현하라 원칙", "개발"),
|
||||
new("TDD", "Test-Driven Development", "테스트 주도 개발 방법론", "개발"),
|
||||
new("BDD", "Behavior-Driven Development", "행동 주도 개발 방법론", "개발"),
|
||||
new("DDD", "Domain-Driven Design", "도메인 주도 설계", "개발"),
|
||||
new("MVC", "Model-View-Controller", "UI 아키텍처 패턴", "개발"),
|
||||
new("MVP", "Model-View-Presenter", "MVC의 변형, Presenter가 View와 Model 중재", "개발"),
|
||||
new("MVVM", "Model-View-ViewModel", "WPF·모바일 아키텍처 패턴", "개발"),
|
||||
new("SRP", "Single Responsibility Principle", "단일 책임 원칙 (SOLID의 S)", "개발"),
|
||||
new("OCP", "Open/Closed Principle", "개방/폐쇄 원칙 (SOLID의 O)", "개발"),
|
||||
new("LSP", "Liskov Substitution Principle", "리스코프 치환 원칙 (SOLID의 L)", "개발"),
|
||||
new("ISP", "Interface Segregation Principle", "인터페이스 분리 원칙 (SOLID의 I)", "개발"),
|
||||
new("DIP", "Dependency Inversion Principle", "의존성 역전 원칙 (SOLID의 D)", "개발"),
|
||||
new("IoC", "Inversion of Control", "제어 역전 (프레임워크 핵심 개념)", "개발"),
|
||||
new("DI", "Dependency Injection", "의존성 주입 패턴", "개발"),
|
||||
new("AOP", "Aspect-Oriented Programming", "관점 지향 프로그래밍", "개발"),
|
||||
new("FP", "Functional Programming", "함수형 프로그래밍", "개발"),
|
||||
new("DSL", "Domain-Specific Language", "특정 도메인 전용 언어", "개발"),
|
||||
new("SDK", "Software Development Kit", "소프트웨어 개발 도구 모음", "개발"),
|
||||
new("IDE", "Integrated Development Environment", "통합 개발 환경", "개발"),
|
||||
new("CLI", "Command Line Interface", "명령줄 인터페이스", "개발"),
|
||||
new("GUI", "Graphical User Interface", "그래픽 사용자 인터페이스", "개발"),
|
||||
new("UI", "User Interface", "사용자 인터페이스", "개발"),
|
||||
new("UX", "User Experience", "사용자 경험", "개발"),
|
||||
new("SPA", "Single Page Application", "단일 페이지 애플리케이션", "개발"),
|
||||
new("SSR", "Server-Side Rendering", "서버 사이드 렌더링", "개발"),
|
||||
new("CSR", "Client-Side Rendering", "클라이언트 사이드 렌더링", "개발"),
|
||||
new("PWA", "Progressive Web App", "프로그레시브 웹 앱", "개발"),
|
||||
new("CDK", "Cloud Development Kit", "클라우드 인프라 코드 개발 도구", "개발"),
|
||||
new("ORM", "Object-Relational Mapping", "객체-관계형 데이터베이스 매핑", "개발"),
|
||||
new("REPL", "Read-Eval-Print Loop", "대화형 프로그래밍 환경", "개발"),
|
||||
new("GC", "Garbage Collection", "자동 메모리 회수", "개발"),
|
||||
new("JIT", "Just-In-Time Compilation", "실행 시점 컴파일", "개발"),
|
||||
new("AOT", "Ahead-Of-Time Compilation", "사전 컴파일", "개발"),
|
||||
new("WASM", "WebAssembly", "브라우저용 바이너리 형식", "개발"),
|
||||
new("AST", "Abstract Syntax Tree", "추상 구문 트리", "개발"),
|
||||
new("LSP", "Language Server Protocol", "언어 분석 서버 표준 프로토콜", "개발"),
|
||||
|
||||
// 데이터베이스
|
||||
new("SQL", "Structured Query Language", "관계형 DB 쿼리 언어", "DB"),
|
||||
new("NoSQL", "Not Only SQL", "비관계형 데이터베이스 총칭", "DB"),
|
||||
new("ACID", "Atomicity Consistency Isolation Durability", "DB 트랜잭션 4가지 성질", "DB"),
|
||||
new("CAP", "Consistency Availability Partition-tolerance","분산 DB 설계 이론 (셋 중 2개만 보장)", "DB"),
|
||||
new("ETL", "Extract Transform Load", "데이터 추출·변환·적재 프로세스", "DB"),
|
||||
new("OLTP", "Online Transaction Processing", "트랜잭션 처리 중심 DB", "DB"),
|
||||
new("OLAP", "Online Analytical Processing", "분석 처리 중심 DB (데이터 웨어하우스)", "DB"),
|
||||
new("DDL", "Data Definition Language", "DB 구조 정의 SQL (CREATE/ALTER/DROP)", "DB"),
|
||||
new("DML", "Data Manipulation Language", "데이터 조작 SQL (SELECT/INSERT/UPDATE/DELETE)", "DB"),
|
||||
new("DCL", "Data Control Language", "접근 권한 SQL (GRANT/REVOKE)", "DB"),
|
||||
|
||||
// 보안
|
||||
new("JWT", "JSON Web Token", "JSON 기반 인증 토큰 표준", "보안"),
|
||||
new("RBAC", "Role-Based Access Control", "역할 기반 접근 제어", "보안"),
|
||||
new("ABAC", "Attribute-Based Access Control", "속성 기반 접근 제어", "보안"),
|
||||
new("MFA", "Multi-Factor Authentication", "다중 인증", "보안"),
|
||||
new("2FA", "Two-Factor Authentication", "이중 인증", "보안"),
|
||||
new("XSS", "Cross-Site Scripting", "악성 스크립트 삽입 공격", "보안"),
|
||||
new("CSRF", "Cross-Site Request Forgery", "교차 사이트 요청 위조 공격", "보안"),
|
||||
new("SQL Injection", "SQL Injection Attack", "SQL 쿼리 삽입 공격", "보안"),
|
||||
new("OWASP", "Open Web Application Security Project", "웹 보안 가이드라인 기관", "보안"),
|
||||
new("CVE", "Common Vulnerabilities and Exposures", "공개 보안 취약점 DB", "보안"),
|
||||
new("HTTPS", "HTTP Secure", "TLS로 암호화된 HTTP", "보안"),
|
||||
new("AES", "Advanced Encryption Standard", "대칭키 암호화 표준 (128/192/256bit)", "보안"),
|
||||
new("RSA", "Rivest–Shamir–Adleman", "공개키 비대칭 암호화 알고리즘", "보안"),
|
||||
new("HMAC", "Hash-based Message Authentication Code", "해시 기반 메시지 인증 코드", "보안"),
|
||||
new("PKI", "Public Key Infrastructure", "공개키 인프라", "보안"),
|
||||
|
||||
// 클라우드/인프라
|
||||
new("AWS", "Amazon Web Services", "아마존 클라우드 서비스", "클라우드"),
|
||||
new("GCP", "Google Cloud Platform", "구글 클라우드 서비스", "클라우드"),
|
||||
new("SaaS", "Software as a Service", "구독형 소프트웨어 서비스", "클라우드"),
|
||||
new("PaaS", "Platform as a Service", "플랫폼 서비스", "클라우드"),
|
||||
new("IaaS", "Infrastructure as a Service", "인프라 서비스", "클라우드"),
|
||||
new("FaaS", "Function as a Service", "서버리스 함수 서비스", "클라우드"),
|
||||
new("K8s", "Kubernetes", "컨테이너 오케스트레이션 플랫폼", "클라우드"),
|
||||
new("CI/CD", "Continuous Integration/Continuous Delivery","지속적 통합/배포 파이프라인", "클라우드"),
|
||||
new("IaC", "Infrastructure as Code", "코드로 관리하는 인프라", "클라우드"),
|
||||
new("VPC", "Virtual Private Cloud", "가상 사설 클라우드 네트워크", "클라우드"),
|
||||
new("SLA", "Service Level Agreement", "서비스 수준 계약 (가용성 보장)", "클라우드"),
|
||||
new("SLO", "Service Level Objective", "서비스 수준 목표", "클라우드"),
|
||||
new("RTO", "Recovery Time Objective", "복구 목표 시간", "클라우드"),
|
||||
new("RPO", "Recovery Point Objective", "복구 목표 지점", "클라우드"),
|
||||
new("CDN", "Content Delivery Network", "콘텐츠 분산 배포 네트워크", "클라우드"),
|
||||
new("ECS", "Elastic Container Service", "AWS 컨테이너 관리 서비스", "클라우드"),
|
||||
new("ECR", "Elastic Container Registry", "AWS 컨테이너 이미지 저장소", "클라우드"),
|
||||
new("EKS", "Elastic Kubernetes Service", "AWS 관리형 Kubernetes", "클라우드"),
|
||||
|
||||
// AI/ML
|
||||
new("AI", "Artificial Intelligence", "인공지능", "AI/ML"),
|
||||
new("ML", "Machine Learning", "머신러닝", "AI/ML"),
|
||||
new("DL", "Deep Learning", "딥러닝", "AI/ML"),
|
||||
new("LLM", "Large Language Model", "대규모 언어 모델", "AI/ML"),
|
||||
new("NLP", "Natural Language Processing", "자연어 처리", "AI/ML"),
|
||||
new("CNN", "Convolutional Neural Network", "합성곱 신경망", "AI/ML"),
|
||||
new("RNN", "Recurrent Neural Network", "순환 신경망", "AI/ML"),
|
||||
new("GAN", "Generative Adversarial Network", "생성적 적대 신경망", "AI/ML"),
|
||||
new("RAG", "Retrieval-Augmented Generation", "검색 증강 생성 (LLM + 검색)", "AI/ML"),
|
||||
new("RLHF", "Reinforcement Learning from Human Feedback","인간 피드백 강화학습", "AI/ML"),
|
||||
new("GPT", "Generative Pre-trained Transformer", "생성형 사전학습 변환기 (OpenAI)", "AI/ML"),
|
||||
new("BERT", "Bidirectional Encoder Representations from Transformers","양방향 트랜스포머 언어 모델", "AI/ML"),
|
||||
new("SFT", "Supervised Fine-Tuning", "지도 학습 파인튜닝", "AI/ML"),
|
||||
new("LoRA", "Low-Rank Adaptation", "저순위 행렬 파인튜닝 기법", "AI/ML"),
|
||||
new("MCP", "Model Context Protocol", "LLM 외부 도구 연결 표준 프로토콜", "AI/ML"),
|
||||
new("CoT", "Chain of Thought", "사고 단계 명시 프롬프팅", "AI/ML"),
|
||||
|
||||
// 데이터 형식
|
||||
new("JSON", "JavaScript Object Notation", "경량 데이터 교환 형식", "데이터형식"),
|
||||
new("XML", "Extensible Markup Language", "확장 가능 마크업 언어", "데이터형식"),
|
||||
new("YAML", "YAML Ain't Markup Language", "사람이 읽기 쉬운 데이터 직렬화 형식", "데이터형식"),
|
||||
new("TOML", "Tom's Obvious, Minimal Language", "설정 파일 전용 경량 형식", "데이터형식"),
|
||||
new("CSV", "Comma-Separated Values", "쉼표 구분 데이터 형식", "데이터형식"),
|
||||
new("TSV", "Tab-Separated Values", "탭 구분 데이터 형식", "데이터형식"),
|
||||
new("Protobuf","Protocol Buffers", "구글 이진 직렬화 형식", "데이터형식"),
|
||||
new("Avro", "Apache Avro", "Hadoop 생태계 이진 직렬화", "데이터형식"),
|
||||
new("Parquet", "Apache Parquet", "열 지향 이진 데이터 형식", "데이터형식"),
|
||||
new("Base64", "Base 64 encoding", "이진 데이터 텍스트 인코딩 (64진수)", "데이터형식"),
|
||||
|
||||
// 버전 관리/협업
|
||||
new("VCS", "Version Control System", "버전 관리 시스템", "협업"),
|
||||
new("SCM", "Source Code Management", "소스 코드 관리", "협업"),
|
||||
new("PR", "Pull Request", "코드 병합 요청 (GitHub 용어)", "협업"),
|
||||
new("MR", "Merge Request", "코드 병합 요청 (GitLab 용어)", "협업"),
|
||||
new("LGTM", "Looks Good To Me", "코드 리뷰 승인 표현", "협업"),
|
||||
new("WIP", "Work In Progress", "진행 중 작업", "협업"),
|
||||
new("RFC", "Request for Comments", "의견 요청 문서 (표준화 프로세스)", "협업"),
|
||||
new("POC", "Proof of Concept", "개념 검증", "협업"),
|
||||
new("MVP", "Minimum Viable Product", "최소 기능 제품", "협업"),
|
||||
new("KPI", "Key Performance Indicator", "핵심 성과 지표", "협업"),
|
||||
new("OKR", "Objectives and Key Results", "목표 및 핵심 결과", "협업"),
|
||||
new("SOP", "Standard Operating Procedure", "표준 운영 절차", "협업"),
|
||||
];
|
||||
|
||||
private static readonly string[] Categories = Entries.Select(e => e.Category).Distinct().ToArray();
|
||||
|
||||
// ── 커스텀 약어 ───────────────────────────────────────────────────────────
|
||||
private static readonly string CustomPath = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "abbr_custom.json");
|
||||
|
||||
private sealed class CustomAbbr
|
||||
{
|
||||
[JsonPropertyName("short")] public string Short { get; set; } = "";
|
||||
[JsonPropertyName("full")] public string Full { get; set; } = "";
|
||||
[JsonPropertyName("desc")] public string Description { get; set; } = "";
|
||||
[JsonPropertyName("cat")] public string Category { get; set; } = "사용자정의";
|
||||
}
|
||||
|
||||
private static List<CustomAbbr> LoadCustom()
|
||||
{
|
||||
try {
|
||||
if (System.IO.File.Exists(CustomPath))
|
||||
return JsonSerializer.Deserialize<List<CustomAbbr>>(
|
||||
System.IO.File.ReadAllText(CustomPath)) ?? [];
|
||||
} catch { }
|
||||
return [];
|
||||
}
|
||||
|
||||
private static void SaveCustom(List<CustomAbbr> list)
|
||||
{
|
||||
System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(CustomPath)!);
|
||||
System.IO.File.WriteAllText(CustomPath, JsonSerializer.Serialize(list,
|
||||
new JsonSerializerOptions { WriteIndented = true,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
|
||||
}
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
// ── 서브커맨드 처리 ───────────────────────────────────────────────────
|
||||
// abbr custom → 사용자 정의 약어 목록
|
||||
if (q.Equals("custom", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var customList = LoadCustom();
|
||||
if (customList.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("사용자 정의 약어 없음",
|
||||
"abbr add <약어> <풀이> [설명] 으로 추가하세요",
|
||||
null, null, Symbol: "\uE82D"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"사용자 정의 약어 {customList.Count}개", "", null, null, Symbol: "\uE82D"));
|
||||
foreach (var c in customList)
|
||||
items.Add(new LauncherItem(c.Short,
|
||||
$"{c.Full} · {c.Description}",
|
||||
null, ("copy", $"{c.Short}: {c.Full}"), Symbol: "\uE82D"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// abbr add <약어> <풀이> [설명]
|
||||
if (q.StartsWith("add ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var rest = q[4..].Trim();
|
||||
var parts = rest.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
items.Add(new LauncherItem("사용법: abbr add <약어> <풀이> [설명]",
|
||||
"예: abbr add ROI 투자수익률 Return on Investment",
|
||||
null, null, Symbol: "\uE82D"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
var abbr = parts[0].ToUpper();
|
||||
var full = parts[1];
|
||||
var desc = parts.Length > 2 ? parts[2] : "";
|
||||
var encoded = $"{abbr}|{full}|{desc}";
|
||||
items.Add(new LauncherItem($"약어 추가: {abbr} = {full}",
|
||||
desc.Length > 0 ? desc : "Enter: 저장",
|
||||
null, ("add", encoded), Symbol: "\uE82D"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// abbr del <약어>
|
||||
if (q.StartsWith("del ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var abbr = q[4..].Trim().ToUpper();
|
||||
if (string.IsNullOrEmpty(abbr))
|
||||
{
|
||||
items.Add(new LauncherItem("사용법: abbr del <약어>",
|
||||
"예: abbr del ROI",
|
||||
null, null, Symbol: "\uE82D"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
items.Add(new LauncherItem($"약어 삭제: {abbr}",
|
||||
"Enter: 삭제 확인",
|
||||
null, ("del", abbr), Symbol: "\uE82D"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
items.Add(new LauncherItem($"IT·개발 약어 사전 {Entries.Length}개",
|
||||
"abbr <약어> 예: abbr api / abbr crud / abbr jwt",
|
||||
null, null, Symbol: "\uE82D"));
|
||||
// 커스텀 약어 추가 안내
|
||||
items.Add(new LauncherItem("abbr add <약어> <풀이> — 나만의 약어 추가",
|
||||
"예: abbr add ROI 투자수익률",
|
||||
null, null, Symbol: "\uE82D"));
|
||||
items.Add(new LauncherItem("── 카테고리 ──", "", null, null, Symbol: "\uE82D"));
|
||||
foreach (var cat in Categories)
|
||||
{
|
||||
var cnt = Entries.Count(e => e.Category == cat);
|
||||
items.Add(new LauncherItem(cat, $"{cnt}개 · abbr {cat}로 목록 보기",
|
||||
null, null, Symbol: "\uE82D"));
|
||||
}
|
||||
// 자주 쓰는 약어 샘플
|
||||
items.Add(new LauncherItem("── 자주 쓰는 약어 ──", "", null, null, Symbol: "\uE82D"));
|
||||
var popular = new[] { "API", "CRUD", "REST", "JWT", "SQL", "CI/CD", "TDD", "OOP" };
|
||||
foreach (var p in popular)
|
||||
{
|
||||
var e = Entries.FirstOrDefault(x => x.Short.Equals(p, StringComparison.OrdinalIgnoreCase));
|
||||
if (e != null)
|
||||
items.Add(MakeAbbrItem(e));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 카테고리 검색
|
||||
if (Categories.Any(c => c.Equals(q, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var catEntries = Entries.Where(e => e.Category.Equals(q, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
items.Add(new LauncherItem($"{q} {catEntries.Count}개", "", null, null, Symbol: "\uE82D"));
|
||||
foreach (var e in catEntries)
|
||||
items.Add(MakeAbbrItem(e));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 약어 검색 (정확 일치 우선, 그 다음 부분 일치) — 내장 + 커스텀 통합
|
||||
var customSearch = LoadCustom();
|
||||
var customEntries = customSearch.Select(c => new AbbrEntry(c.Short, c.Full, c.Description, c.Category)).ToList();
|
||||
var allEntries = Entries.Concat(customEntries).ToList();
|
||||
|
||||
var exact = allEntries.Where(e =>
|
||||
e.Short.Equals(q, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
var partial = allEntries.Where(e =>
|
||||
!exact.Contains(e) &&
|
||||
(e.Short.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
e.Full.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
e.Description.Contains(q, StringComparison.OrdinalIgnoreCase))).ToList();
|
||||
|
||||
var all = exact.Concat(partial).ToList();
|
||||
|
||||
if (all.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem($"'{q}' 약어를 찾을 수 없습니다",
|
||||
"카테고리: " + string.Join(", ", Categories),
|
||||
null, null, Symbol: "\uE946"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
if (exact.Count == 1)
|
||||
{
|
||||
// 정확 일치 1개 → 상세 표시
|
||||
var e = exact[0];
|
||||
items.Add(new LauncherItem($"{e.Short} = {e.Full}",
|
||||
e.Description, null, ("copy", $"{e.Short}: {e.Full}"), Symbol: "\uE82D"));
|
||||
items.Add(new LauncherItem("약어", e.Short, null, ("copy", e.Short), Symbol: "\uE82D"));
|
||||
items.Add(new LauncherItem("원문", e.Full, null, ("copy", e.Full), Symbol: "\uE82D"));
|
||||
items.Add(new LauncherItem("설명", e.Description, null, ("copy", e.Description), Symbol: "\uE82D"));
|
||||
items.Add(new LauncherItem("카테고리",e.Category, null, null, Symbol: "\uE82D"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"'{q}' 검색 결과 {all.Count}개", "", null, null, Symbol: "\uE82D"));
|
||||
foreach (var e in all.Take(30))
|
||||
items.Add(MakeAbbrItem(e));
|
||||
}
|
||||
|
||||
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("Abbr", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { }
|
||||
break;
|
||||
|
||||
case ("add", string encoded):
|
||||
var parts = encoded.Split('|', 3);
|
||||
var custom = LoadCustom();
|
||||
custom.RemoveAll(c => c.Short.Equals(parts[0], StringComparison.OrdinalIgnoreCase));
|
||||
custom.Add(new CustomAbbr {
|
||||
Short = parts[0].ToUpper(),
|
||||
Full = parts.Length > 1 ? parts[1] : "",
|
||||
Description = parts.Length > 2 ? parts[2] : "",
|
||||
});
|
||||
SaveCustom(custom);
|
||||
NotificationService.Notify("약어", $"{parts[0].ToUpper()} 등록 완료");
|
||||
break;
|
||||
|
||||
case ("del", string abbr):
|
||||
var customDel = LoadCustom();
|
||||
customDel.RemoveAll(c => c.Short.Equals(abbr, StringComparison.OrdinalIgnoreCase));
|
||||
SaveCustom(customDel);
|
||||
NotificationService.Notify("약어", $"{abbr.ToUpper()} 삭제 완료");
|
||||
break;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static LauncherItem MakeAbbrItem(AbbrEntry e) =>
|
||||
new(e.Short,
|
||||
$"{e.Full} · {e.Description}",
|
||||
null, ("copy", $"{e.Short}: {e.Full}"), Symbol: "\uE82D");
|
||||
}
|
||||
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 => "일요일",
|
||||
_ => "",
|
||||
};
|
||||
}
|
||||
260
src/AxCopilot/Handlers/ApHandler.cs
Normal file
260
src/AxCopilot/Handlers/ApHandler.cs
Normal file
@@ -0,0 +1,260 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L28-4: 클립보드 텍스트 즉시 변환 핸들러. "ap" 프리픽스로 사용합니다.
|
||||
/// (Advanced Paste — PowerToys Advanced Paste 대응)
|
||||
///
|
||||
/// 예: ap → 클립보드 내용 표시 + 변환 목록
|
||||
/// ap upper → 대문자 변환
|
||||
/// ap lower → 소문자 변환
|
||||
/// ap trim → 앞뒤 공백 제거
|
||||
/// ap sort → 줄 정렬
|
||||
/// ap unique → 중복 줄 제거
|
||||
/// ap number → 줄 번호 추가
|
||||
/// ap reverse → 줄 순서 뒤집기
|
||||
/// ap count → 글자/단어/줄 수
|
||||
/// ap json → JSON 정리 (포맷팅)
|
||||
/// ap remove blank → 빈 줄 제거
|
||||
/// ap replace A B → A를 B로 전체 치환
|
||||
/// Enter → 변환된 텍스트를 클립보드에 복사.
|
||||
/// </summary>
|
||||
public class ApHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "ap";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"텍스트 변환",
|
||||
"클립보드 텍스트 즉시 변환 (Advanced Paste)",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private static readonly (string Cmd, string Label, string Desc)[] Commands =
|
||||
[
|
||||
("upper", "대문자 변환", "전체 텍스트를 대문자로"),
|
||||
("lower", "소문자 변환", "전체 텍스트를 소문자로"),
|
||||
("trim", "공백 정리", "각 줄 앞뒤 공백 제거"),
|
||||
("sort", "줄 정렬", "알파벳/가나다 순 줄 정렬"),
|
||||
("rsort", "줄 역순 정렬", "역순 줄 정렬"),
|
||||
("unique", "중복 제거", "동일 줄 제거 (순서 유지)"),
|
||||
("number", "줄 번호 추가", "각 줄 앞에 1. 2. 3. 번호"),
|
||||
("reverse", "줄 순서 뒤집기", "마지막 줄 → 첫 줄"),
|
||||
("count", "텍스트 통계", "글자·단어·줄 수 표시"),
|
||||
("blank", "빈 줄 제거", "빈 줄 삭제"),
|
||||
("json", "JSON 정리", "JSON 들여쓰기 포맷팅"),
|
||||
("single", "한 줄로 합치기", "줄바꿈 → 공백으로 연결"),
|
||||
("slug", "URL 슬러그", "소문자 + 하이픈 (공백/특수문자 변환)"),
|
||||
("base64", "Base64 인코딩", "텍스트 → Base64"),
|
||||
("decode64","Base64 디코딩", "Base64 → 텍스트"),
|
||||
];
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim().ToLowerInvariant();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
// 클립보드 텍스트 읽기
|
||||
string clipText;
|
||||
try
|
||||
{
|
||||
clipText = Application.Current.Dispatcher.Invoke(() => Clipboard.GetText()) ?? "";
|
||||
}
|
||||
catch
|
||||
{
|
||||
clipText = "";
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(clipText))
|
||||
{
|
||||
items.Add(new LauncherItem("클립보드가 비어 있습니다",
|
||||
"변환할 텍스트를 먼저 복사하세요",
|
||||
null, null, Symbol: Symbols.Clipboard));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var preview = clipText.Length > 80 ? clipText[..77].Replace("\n", " ") + "…" : clipText.Replace("\n", " ");
|
||||
int lineCount = clipText.Split('\n').Length;
|
||||
|
||||
// ── replace 명령 ───────────────────────────────────────────────────────
|
||||
if (q.StartsWith("replace "))
|
||||
{
|
||||
var parts = q[8..].Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length >= 2)
|
||||
{
|
||||
var from = parts[0];
|
||||
var to = parts[1];
|
||||
var result = clipText.Replace(from, to, StringComparison.OrdinalIgnoreCase);
|
||||
int count = (clipText.Length - result.Length) / Math.Max(from.Length - to.Length, 1);
|
||||
items.Add(new LauncherItem(
|
||||
$"치환: '{from}' → '{to}'",
|
||||
$"Enter: 클립보드 갱신",
|
||||
null, ("result", result), Symbol: Symbols.Clipboard));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem("사용법: ap replace {찾을값} {바꿀값}",
|
||||
"예: ap replace hello world", null, null, Symbol: Symbols.Info));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ── 빈 쿼리 → 전체 명령 목록 ─────────────────────────────────────────
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"클립보드: {preview}",
|
||||
$"{clipText.Length}자 · {lineCount}줄 · 아래 변환 명령 선택",
|
||||
null, null, Symbol: Symbols.Clipboard));
|
||||
|
||||
foreach (var (cmd, label, desc) in Commands)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"ap {cmd} — {label}",
|
||||
desc,
|
||||
null, ("cmd", cmd), Symbol: "\uE8AC"));
|
||||
}
|
||||
items.Add(new LauncherItem(
|
||||
"ap replace {A} {B} — 텍스트 치환",
|
||||
"A를 B로 전체 치환",
|
||||
null, null, Symbol: "\uE8AC"));
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ── 명령 실행 미리보기 ───────────────────────────────────────────────
|
||||
var transformed = Transform(clipText, q);
|
||||
if (transformed != null)
|
||||
{
|
||||
var tPreview = transformed.Length > 120 ? transformed[..117].Replace("\n", " ") + "…" : transformed.Replace("\n", " ");
|
||||
var cmdInfo = Commands.FirstOrDefault(c => c.Cmd == q);
|
||||
items.Add(new LauncherItem(
|
||||
$"{(cmdInfo.Label ?? q)} 결과",
|
||||
$"{tPreview} · Enter: 클립보드 갱신",
|
||||
null, ("result", transformed), Symbol: Symbols.Clipboard));
|
||||
}
|
||||
else
|
||||
{
|
||||
// 부분 매칭으로 명령 제안
|
||||
var matched = Commands.Where(c =>
|
||||
c.Cmd.Contains(q) || c.Label.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (matched.Count > 0)
|
||||
{
|
||||
foreach (var (cmd, label, desc) in matched)
|
||||
items.Add(new LauncherItem($"ap {cmd} — {label}", desc,
|
||||
null, ("cmd", cmd), Symbol: "\uE8AC"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"'{q}' 알 수 없는 변환 명령",
|
||||
"ap upper/lower/trim/sort/unique/number/reverse/json ...",
|
||||
null, null, Symbol: Symbols.Warning));
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is ("result", string result))
|
||||
{
|
||||
try
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(result));
|
||||
NotificationService.Notify("ap", "변환 결과가 클립보드에 복사되었습니다.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
NotificationService.Notify("ap", $"복사 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
else if (item.Data is ("cmd", string cmd))
|
||||
{
|
||||
try
|
||||
{
|
||||
var clipText = Application.Current.Dispatcher.Invoke(() => Clipboard.GetText()) ?? "";
|
||||
var result2 = Transform(clipText, cmd);
|
||||
if (result2 != null)
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(result2));
|
||||
NotificationService.Notify("ap", "변환 결과가 클립보드에 복사되었습니다.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
NotificationService.Notify("ap", $"변환 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ─── 변환 로직 ───────────────────────────────────────────────────────────
|
||||
|
||||
private static string? Transform(string text, string cmd)
|
||||
{
|
||||
var lines = text.Split('\n').Select(l => l.TrimEnd('\r')).ToArray();
|
||||
|
||||
return cmd switch
|
||||
{
|
||||
"upper" => text.ToUpperInvariant(),
|
||||
"lower" => text.ToLowerInvariant(),
|
||||
"trim" => string.Join("\n", lines.Select(l => l.Trim())),
|
||||
"sort" => string.Join("\n", lines.OrderBy(l => l, StringComparer.OrdinalIgnoreCase)),
|
||||
"rsort" => string.Join("\n", lines.OrderByDescending(l => l, StringComparer.OrdinalIgnoreCase)),
|
||||
"unique" => string.Join("\n", lines.Distinct()),
|
||||
"number" => string.Join("\n", lines.Select((l, i) => $"{i + 1}. {l}")),
|
||||
"reverse" => string.Join("\n", lines.Reverse()),
|
||||
"blank" => string.Join("\n", lines.Where(l => !string.IsNullOrWhiteSpace(l))),
|
||||
"single" => string.Join(" ", lines.Where(l => !string.IsNullOrWhiteSpace(l)).Select(l => l.Trim())),
|
||||
"count" => $"글자: {text.Length} · 단어: {CountWords(text)} · 줄: {lines.Length} · 바이트: {Encoding.UTF8.GetByteCount(text)}",
|
||||
"json" => TryFormatJson(text),
|
||||
"slug" => ToSlug(text),
|
||||
"base64" => Convert.ToBase64String(Encoding.UTF8.GetBytes(text)),
|
||||
"decode64" => TryDecodeBase64(text),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static int CountWords(string text)
|
||||
=> Regex.Matches(text, @"[\w가-힣]+").Count;
|
||||
|
||||
private static string TryFormatJson(string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
var doc = System.Text.Json.JsonDocument.Parse(text);
|
||||
using var ms = new System.IO.MemoryStream();
|
||||
using var writer = new System.Text.Json.Utf8JsonWriter(ms, new System.Text.Json.JsonWriterOptions { Indented = true });
|
||||
doc.WriteTo(writer);
|
||||
writer.Flush();
|
||||
return Encoding.UTF8.GetString(ms.ToArray());
|
||||
}
|
||||
catch { return "유효하지 않은 JSON입니다."; }
|
||||
}
|
||||
|
||||
private static string ToSlug(string text)
|
||||
{
|
||||
var slug = text.ToLowerInvariant().Trim();
|
||||
slug = Regex.Replace(slug, @"[^a-z0-9가-힣\s-]", "");
|
||||
slug = Regex.Replace(slug, @"[\s]+", "-");
|
||||
slug = Regex.Replace(slug, @"-{2,}", "-");
|
||||
return slug.Trim('-');
|
||||
}
|
||||
|
||||
private static string TryDecodeBase64(string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(text.Trim());
|
||||
return Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
catch { return "유효하지 않은 Base64입니다."; }
|
||||
}
|
||||
}
|
||||
297
src/AxCopilot/Handlers/AspectHandler.cs
Normal file
297
src/AxCopilot/Handlers/AspectHandler.cs
Normal file
@@ -0,0 +1,297 @@
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L18-3: 화면 비율·해상도 계산기 핸들러. "aspect" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: aspect → 주요 비율 목록
|
||||
/// aspect 1920 1080 → 1920x1080 비율 계산
|
||||
/// aspect 16:9 1280 → 16:9 비율에서 너비 1280의 높이
|
||||
/// aspect 16:9 h 720 → 16:9 비율에서 높이 720의 너비
|
||||
/// aspect 4:3 → 4:3 비율의 주요 해상도 목록
|
||||
/// aspect crop 1920 1080 4:3 → 크롭 영역 계산
|
||||
/// Enter → 해상도 복사.
|
||||
/// </summary>
|
||||
public class AspectHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "aspect";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Aspect",
|
||||
"화면 비율·해상도 계산기 — 16:9 · 4:3 · 21:9 · 크롭 영역",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private record AspectPreset(string Ratio, string Name, (int W, int H)[] Resolutions);
|
||||
|
||||
private static readonly AspectPreset[] Presets =
|
||||
[
|
||||
new("16:9", "와이드스크린 (모니터·TV·유튜브)",
|
||||
[(3840,2160),(2560,1440),(1920,1080),(1600,900),(1366,768),(1280,720),(960,540),(854,480),(640,360)]),
|
||||
|
||||
new("4:3", "전통 CRT·클래식 TV",
|
||||
[(2048,1536),(1600,1200),(1400,1050),(1280,960),(1024,768),(800,600),(640,480)]),
|
||||
|
||||
new("21:9", "울트라와이드 시네마",
|
||||
[(5120,2160),(3440,1440),(2560,1080),(2560,1080),(1280,540)]),
|
||||
|
||||
new("1:1", "정사각형 (인스타그램·SNS)",
|
||||
[(4096,4096),(2048,2048),(1080,1080),(720,720),(512,512)]),
|
||||
|
||||
new("9:16", "세로 (모바일·스토리·릴스)",
|
||||
[(1080,1920),(720,1280),(540,960),(360,640)]),
|
||||
|
||||
new("3:2", "DSLR 카메라 (35mm)",
|
||||
[(6000,4000),(4500,3000),(3000,2000),(1500,1000)]),
|
||||
|
||||
new("2:1", "시네마 와이드",
|
||||
[(4096,2048),(2048,1024),(1920,960)]),
|
||||
|
||||
new("5:4", "구형 모니터",
|
||||
[(1280,1024),(1024,819)]),
|
||||
|
||||
new("2.35:1","영화 시네마스코프",
|
||||
[(2560,1090),(1920,817),(1280,544)]),
|
||||
];
|
||||
|
||||
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("화면 비율·해상도 계산기",
|
||||
"예: aspect 1920 1080 / aspect 16:9 1280 / aspect 4:3",
|
||||
null, null, Symbol: "\uE7F4"));
|
||||
items.Add(new LauncherItem("── 주요 비율 ──", "", null, null, Symbol: "\uE7F4"));
|
||||
foreach (var p in Presets)
|
||||
items.Add(new LauncherItem($"{p.Ratio} {p.Name}",
|
||||
$"주요 해상도: {string.Join(", ", p.Resolutions.Take(3).Select(r => $"{r.W}×{r.H}"))}…",
|
||||
null, null, Symbol: "\uE7F4"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// aspect <W> <H> → 비율 계산
|
||||
if (parts.Length >= 2 && int.TryParse(parts[0], out var w1) && int.TryParse(parts[1], out var h1))
|
||||
{
|
||||
items.AddRange(BuildFromResolution(w1, h1));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// aspect <비율> 형식 (예: 16:9, 4:3, 16/9)
|
||||
if (TryParseRatio(parts[0], out var rw, out var rh))
|
||||
{
|
||||
// aspect 16:9 <너비> 또는 aspect 16:9 h <높이>
|
||||
if (parts.Length >= 2)
|
||||
{
|
||||
var isHeight = parts.Length >= 3 &&
|
||||
parts[1].ToLowerInvariant() is "h" or "height" or "높이";
|
||||
var dimStr = isHeight ? parts[2] : parts[1];
|
||||
|
||||
if (int.TryParse(dimStr, out var dim))
|
||||
{
|
||||
if (isHeight)
|
||||
{
|
||||
var calcW = (int)Math.Round((double)dim * rw / rh);
|
||||
items.AddRange(BuildFromRatioAndDim(rw, rh, calcW, dim));
|
||||
}
|
||||
else
|
||||
{
|
||||
var calcH = (int)Math.Round((double)dim * rh / rw);
|
||||
items.AddRange(BuildFromRatioAndDim(rw, rh, dim, calcH));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
}
|
||||
|
||||
// aspect 16:9 → 주요 해상도 목록
|
||||
items.AddRange(BuildFromRatio(rw, rh));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// crop 서브커맨드
|
||||
if (parts[0].ToLowerInvariant() == "crop" && parts.Length >= 5)
|
||||
{
|
||||
if (int.TryParse(parts[1], out var srcW) &&
|
||||
int.TryParse(parts[2], out var srcH) &&
|
||||
TryParseRatio(parts[3], out var cRw, out var cRh))
|
||||
{
|
||||
items.AddRange(BuildCropItems(srcW, srcH, cRw, cRh));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
}
|
||||
|
||||
items.Add(new LauncherItem("형식 오류",
|
||||
"예: aspect 1920 1080 / aspect 16:9 1280 / aspect 16:9 h 720",
|
||||
null, null, Symbol: "\uE783"));
|
||||
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("Aspect", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 빌더 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static List<LauncherItem> BuildFromResolution(int w, int h)
|
||||
{
|
||||
var items = new List<LauncherItem>();
|
||||
var gcd = Gcd(w, h);
|
||||
var rw = w / gcd;
|
||||
var rh = h / gcd;
|
||||
var ratio = $"{rw}:{rh}";
|
||||
var frac = (double)w / h;
|
||||
|
||||
items.Add(new LauncherItem($"{w} × {h} → 비율 {ratio}",
|
||||
$"소수 비율: {frac:F4}", null, ("copy", ratio), Symbol: "\uE7F4"));
|
||||
items.Add(new LauncherItem($"비율", ratio, null, ("copy", ratio), Symbol: "\uE7F4"));
|
||||
items.Add(new LauncherItem($"소수 비율", $"{frac:F4}", null, ("copy", $"{frac:F4}"), Symbol: "\uE7F4"));
|
||||
items.Add(new LauncherItem($"픽셀 수", $"{(long)w * h:N0} px ({(long)w * h / 1_000_000.0:F1} MP)",
|
||||
null, null, Symbol: "\uE7F4"));
|
||||
|
||||
// 비슷한 프리셋 찾기
|
||||
var preset = Presets.FirstOrDefault(p => p.Ratio == ratio);
|
||||
if (preset != null)
|
||||
{
|
||||
items.Add(new LauncherItem($"── {preset.Ratio} {preset.Name} ──", "", null, null, Symbol: "\uE7F4"));
|
||||
foreach (var (pw, ph) in preset.Resolutions)
|
||||
items.Add(new LauncherItem($"{pw} × {ph}", FormatPixels(pw, ph),
|
||||
null, ("copy", $"{pw}x{ph}"), Symbol: "\uE7F4"));
|
||||
}
|
||||
else
|
||||
{
|
||||
// 같은 비율의 다른 해상도 계산
|
||||
items.Add(new LauncherItem("── 같은 비율 기타 해상도 ──", "", null, null, Symbol: "\uE7F4"));
|
||||
var scales = new[] { 0.25, 0.5, 0.75, 1.25, 1.5, 2.0 };
|
||||
foreach (var s in scales)
|
||||
{
|
||||
var sw = (int)Math.Round(w * s / gcd) * gcd;
|
||||
var sh = (int)Math.Round(h * s / gcd) * gcd;
|
||||
if (sw > 0 && sh > 0)
|
||||
items.Add(new LauncherItem($"{sw} × {sh} ({s:P0})",
|
||||
FormatPixels(sw, sh), null, ("copy", $"{sw}x{sh}"), Symbol: "\uE7F4"));
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private static List<LauncherItem> BuildFromRatio(int rw, int rh)
|
||||
{
|
||||
var items = new List<LauncherItem>();
|
||||
var preset = Presets.FirstOrDefault(p => p.Ratio == $"{rw}:{rh}");
|
||||
|
||||
if (preset != null)
|
||||
{
|
||||
items.Add(new LauncherItem($"{preset.Ratio} {preset.Name}",
|
||||
$"주요 해상도 {preset.Resolutions.Length}개", null, null, Symbol: "\uE7F4"));
|
||||
foreach (var (pw, ph) in preset.Resolutions)
|
||||
items.Add(new LauncherItem($"{pw} × {ph}", FormatPixels(pw, ph),
|
||||
null, ("copy", $"{pw}x{ph}"), Symbol: "\uE7F4"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"{rw}:{rh} 비율",
|
||||
"자주 쓰는 너비 기준 해상도", null, null, Symbol: "\uE7F4"));
|
||||
var widths = new[] { 640, 1280, 1920, 2560, 3840 };
|
||||
foreach (var bw in widths)
|
||||
{
|
||||
var bh = (int)Math.Round((double)bw * rh / rw);
|
||||
items.Add(new LauncherItem($"{bw} × {bh}", FormatPixels(bw, bh),
|
||||
null, ("copy", $"{bw}x{bh}"), Symbol: "\uE7F4"));
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private static List<LauncherItem> BuildFromRatioAndDim(int rw, int rh, int w, int h)
|
||||
{
|
||||
var items = new List<LauncherItem>();
|
||||
var label = $"{rw}:{rh} → {w} × {h}";
|
||||
items.Add(new LauncherItem(label,
|
||||
$"픽셀 {(long)w * h:N0} · Enter 복사", null, ("copy", $"{w}x{h}"), Symbol: "\uE7F4"));
|
||||
items.Add(new LauncherItem($"너비", $"{w} px", null, ("copy", $"{w}"), Symbol: "\uE7F4"));
|
||||
items.Add(new LauncherItem($"높이", $"{h} px", null, ("copy", $"{h}"), Symbol: "\uE7F4"));
|
||||
items.Add(new LauncherItem("CSS", $"{w}px × {h}px", null, ("copy", $"{w}px × {h}px"), Symbol: "\uE7F4"));
|
||||
items.Add(new LauncherItem("w×h", $"{w}x{h}", null, ("copy", $"{w}x{h}"), Symbol: "\uE7F4"));
|
||||
return items;
|
||||
}
|
||||
|
||||
private static List<LauncherItem> BuildCropItems(int srcW, int srcH, int cRw, int cRh)
|
||||
{
|
||||
var items = new List<LauncherItem>();
|
||||
// 크롭 방향 결정
|
||||
var srcRatio = (double)srcW / srcH;
|
||||
var cropRatio = (double)cRw / cRh;
|
||||
|
||||
int cropW, cropH, offsetX, offsetY;
|
||||
if (srcRatio > cropRatio)
|
||||
{
|
||||
// 좌우 크롭
|
||||
cropH = srcH;
|
||||
cropW = (int)Math.Round(srcH * cropRatio);
|
||||
offsetX = (srcW - cropW) / 2;
|
||||
offsetY = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 상하 크롭
|
||||
cropW = srcW;
|
||||
cropH = (int)Math.Round(srcW / cropRatio);
|
||||
offsetX = 0;
|
||||
offsetY = (srcH - cropH) / 2;
|
||||
}
|
||||
|
||||
items.Add(new LauncherItem($"크롭: {srcW}×{srcH} → {cRw}:{cRh}",
|
||||
$"크롭 영역: {cropW}×{cropH} 오프셋: ({offsetX},{offsetY})",
|
||||
null, ("copy", $"{cropW}x{cropH}"), Symbol: "\uE7F4"));
|
||||
items.Add(new LauncherItem("크롭 크기", $"{cropW} × {cropH}", null, ("copy", $"{cropW}x{cropH}"), Symbol: "\uE7F4"));
|
||||
items.Add(new LauncherItem("X 오프셋", $"{offsetX} px", null, ("copy", $"{offsetX}"), Symbol: "\uE7F4"));
|
||||
items.Add(new LauncherItem("Y 오프셋", $"{offsetY} px", null, ("copy", $"{offsetY}"), Symbol: "\uE7F4"));
|
||||
items.Add(new LauncherItem("FFmpeg crop", $"crop={cropW}:{cropH}:{offsetX}:{offsetY}",
|
||||
null, ("copy", $"crop={cropW}:{cropH}:{offsetX}:{offsetY}"), Symbol: "\uE7F4"));
|
||||
return items;
|
||||
}
|
||||
|
||||
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static bool TryParseRatio(string s, out int rw, out int rh)
|
||||
{
|
||||
rw = rh = 0;
|
||||
var sep = s.Contains(':') ? ':' : s.Contains('/') ? '/' : '\0';
|
||||
if (sep == '\0') return false;
|
||||
var parts = s.Split(sep);
|
||||
if (parts.Length != 2) return false;
|
||||
return double.TryParse(parts[0], System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var drw) &&
|
||||
double.TryParse(parts[1], System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var drh) &&
|
||||
(rw = (int)Math.Round(drw * 100)) > 0 &&
|
||||
(rh = (int)Math.Round(drh * 100)) > 0;
|
||||
}
|
||||
|
||||
private static int Gcd(int a, int b) => b == 0 ? a : Gcd(b, a % b);
|
||||
|
||||
private static string FormatPixels(int w, int h)
|
||||
{
|
||||
var mp = (long)w * h / 1_000_000.0;
|
||||
return $"{(long)w * h:N0} px ({mp:F1} MP)";
|
||||
}
|
||||
}
|
||||
235
src/AxCopilot/Handlers/BaseConvertHandler.cs
Normal file
235
src/AxCopilot/Handlers/BaseConvertHandler.cs
Normal file
@@ -0,0 +1,235 @@
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L9-4: 진수 변환기 핸들러. "base" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: base 255 → 10진수 → 2/8/16진수 동시 변환
|
||||
/// base 0xFF → 16진수 → 10/2/8진수
|
||||
/// base 0b11111111 → 2진수 → 10/8/16진수
|
||||
/// base 0o377 → 8진수 → 10/2/16진수
|
||||
/// base 255 to hex → 10→16진수 결과만
|
||||
/// base ascii 65 → ASCII 코드 변환 (65 = 'A')
|
||||
/// base ascii A → 문자 → ASCII 코드
|
||||
/// Enter → 결과를 클립보드에 복사.
|
||||
/// </summary>
|
||||
public class BaseConvertHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "base";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"BaseConvert",
|
||||
"진수 변환기 — 2 · 8 · 10 · 16진수 · ASCII",
|
||||
"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(
|
||||
"진수 변환기",
|
||||
"예: base 255 / base 0xFF / base 0b1010 / base ascii 65",
|
||||
null, null, Symbol: "\uE8C4"));
|
||||
items.Add(new LauncherItem("base 255", "10진수 → 2/8/16진수", null, null, Symbol: "\uE8C4"));
|
||||
items.Add(new LauncherItem("base 0xFF", "16진수 → 10/2/8진수", null, null, Symbol: "\uE8C4"));
|
||||
items.Add(new LauncherItem("base 0b1111", "2진수 → 10/8/16진수", null, null, Symbol: "\uE8C4"));
|
||||
items.Add(new LauncherItem("base ascii 65", "ASCII 코드 변환", null, null, Symbol: "\uE8C4"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// ASCII 모드
|
||||
if (parts[0].Equals("ascii", StringComparison.OrdinalIgnoreCase) && parts.Length >= 2)
|
||||
{
|
||||
items.AddRange(BuildAsciiItems(string.Join(" ", parts.Skip(1))));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// "to" 지정 변환 모드: base 255 to hex
|
||||
if (parts.Length >= 3 && parts[1].Equals("to", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (TryParseNumber(parts[0], out var val))
|
||||
{
|
||||
var targetBase = parts[2].ToLowerInvariant();
|
||||
var result = ConvertToBase(val, targetBase);
|
||||
if (result != null)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
result,
|
||||
$"{parts[0]} → {targetBase}",
|
||||
null,
|
||||
("copy", result),
|
||||
Symbol: "\uE8C4"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem("알 수 없는 진수",
|
||||
"bin/oct/dec/hex 중 하나를 지정하세요", null, null, Symbol: "\uE783"));
|
||||
}
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 전체 변환 모드
|
||||
if (TryParseNumber(parts[0], out var number))
|
||||
{
|
||||
items.AddRange(BuildConversionItems(number, parts[0]));
|
||||
}
|
||||
else
|
||||
{
|
||||
// ASCII 문자열로 시도
|
||||
if (q.Length <= 8 && q.All(c => c >= 32 && c < 128))
|
||||
{
|
||||
items.AddRange(BuildAsciiItems(q));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem("파싱 실패", $"'{q}'을(를) 숫자로 해석할 수 없습니다", null, null, Symbol: "\uE783"));
|
||||
}
|
||||
}
|
||||
|
||||
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("BaseConvert", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 헬퍼 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static IEnumerable<LauncherItem> BuildConversionItems(long val, string inputStr)
|
||||
{
|
||||
var inputBase = DetectBase(inputStr);
|
||||
|
||||
yield return new LauncherItem(
|
||||
$"{val}",
|
||||
$"10진수 (입력: {inputStr})",
|
||||
null,
|
||||
("copy", val.ToString()),
|
||||
Symbol: "\uE8C4");
|
||||
|
||||
var hex = $"0x{val:X}";
|
||||
yield return new LauncherItem(hex, "16진수 (HEX)", null, ("copy", hex), Symbol: "\uE8C4");
|
||||
|
||||
var bin = val >= 0 ? $"0b{Convert.ToString(val, 2)}" : $"-0b{Convert.ToString(-val, 2)}";
|
||||
yield return new LauncherItem(bin, "2진수 (BIN)", null, ("copy", bin), Symbol: "\uE8C4");
|
||||
|
||||
var oct = val >= 0 ? $"0o{Convert.ToString(val, 8)}" : $"-0o{Convert.ToString(-val, 8)}";
|
||||
yield return new LauncherItem(oct, "8진수 (OCT)", null, ("copy", oct), Symbol: "\uE8C4");
|
||||
|
||||
// 2진수 그룹핑 표시 (4비트 단위)
|
||||
if (val >= 0 && val <= 0xFFFFFFFF)
|
||||
{
|
||||
var binRaw = Convert.ToString(val, 2).PadLeft((Convert.ToString(val, 2).Length + 3) / 4 * 4, '0');
|
||||
var binGrouped = string.Join(" ", Enumerable.Range(0, binRaw.Length / 4)
|
||||
.Select(i => binRaw.Substring(i * 4, 4)));
|
||||
yield return new LauncherItem(binGrouped, "2진수 (4비트 그룹)", null, ("copy", binGrouped), Symbol: "\uE8C4");
|
||||
}
|
||||
|
||||
// ASCII 문자 (0~127 범위)
|
||||
if (val is >= 32 and <= 126)
|
||||
{
|
||||
var ch = (char)val;
|
||||
yield return new LauncherItem($"'{ch}'", $"ASCII 문자 (코드 {val})", null, ("copy", ch.ToString()), Symbol: "\uE8C4");
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<LauncherItem> BuildAsciiItems(string input)
|
||||
{
|
||||
// 숫자 → 문자
|
||||
if (long.TryParse(input, out var code) && code >= 0 && code <= 127)
|
||||
{
|
||||
var ch = (char)code;
|
||||
yield return new LauncherItem(
|
||||
$"'{ch}'",
|
||||
$"ASCII {code} = '{ch}'",
|
||||
null,
|
||||
("copy", ch.ToString()),
|
||||
Symbol: "\uE8C4");
|
||||
|
||||
yield return new LauncherItem($"HEX: 0x{code:X2}", "16진수", null, ("copy", $"0x{code:X2}"), Symbol: "\uE8C4");
|
||||
yield return new LauncherItem($"BIN: {Convert.ToString(code, 2).PadLeft(8, '0')}", "2진수", null, ("copy", Convert.ToString(code, 2).PadLeft(8, '0')), Symbol: "\uE8C4");
|
||||
yield break;
|
||||
}
|
||||
|
||||
// 문자/문자열 → 코드
|
||||
foreach (var c in input.Where(c => c < 128))
|
||||
{
|
||||
var codeVal = (int)c;
|
||||
yield return new LauncherItem(
|
||||
$"'{c}' = {codeVal}",
|
||||
$"HEX: 0x{codeVal:X2} BIN: {Convert.ToString(codeVal, 2).PadLeft(8, '0')}",
|
||||
null,
|
||||
("copy", codeVal.ToString()),
|
||||
Symbol: "\uE8C4");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseNumber(string s, out long result)
|
||||
{
|
||||
result = 0;
|
||||
if (string.IsNullOrEmpty(s)) return false;
|
||||
|
||||
// 0x prefix → 16진수
|
||||
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase) || s.StartsWith("0X", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return long.TryParse(s[2..], System.Globalization.NumberStyles.HexNumber, null, out result);
|
||||
}
|
||||
// 0b prefix → 2진수
|
||||
if (s.StartsWith("0b", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try { result = Convert.ToInt64(s[2..], 2); return true; }
|
||||
catch { return false; }
|
||||
}
|
||||
// 0o prefix → 8진수
|
||||
if (s.StartsWith("0o", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try { result = Convert.ToInt64(s[2..], 8); return true; }
|
||||
catch { return false; }
|
||||
}
|
||||
// 순수 16진수 (0-9, A-F)
|
||||
if (s.Length >= 2 && s.All(c => "0123456789ABCDEFabcdef".Contains(c)) && !s.All(char.IsDigit))
|
||||
{
|
||||
return long.TryParse(s, System.Globalization.NumberStyles.HexNumber, null, out result);
|
||||
}
|
||||
// 10진수
|
||||
return long.TryParse(s, out result);
|
||||
}
|
||||
|
||||
private static string? ConvertToBase(long val, string targetBase) => targetBase switch
|
||||
{
|
||||
"hex" or "16" or "h" => $"0x{val:X}",
|
||||
"bin" or "2" or "b" => val >= 0 ? $"0b{Convert.ToString(val, 2)}" : $"-0b{Convert.ToString(-val, 2)}",
|
||||
"oct" or "8" or "o" => val >= 0 ? $"0o{Convert.ToString(val, 8)}" : $"-0o{Convert.ToString(-val, 8)}",
|
||||
"dec" or "10" or "d" => val.ToString(),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static string DetectBase(string s)
|
||||
{
|
||||
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) return "HEX";
|
||||
if (s.StartsWith("0b", StringComparison.OrdinalIgnoreCase)) return "BIN";
|
||||
if (s.StartsWith("0o", StringComparison.OrdinalIgnoreCase)) return "OCT";
|
||||
return "DEC";
|
||||
}
|
||||
}
|
||||
86
src/AxCopilot/Handlers/BatchRenameHandler.cs
Normal file
86
src/AxCopilot/Handlers/BatchRenameHandler.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using System.IO;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L5-5: 배치 파일 이름변경 핸들러. "batchren" 프리픽스로 사용합니다.
|
||||
/// 예: batchren → 기능 소개 + 창 열기
|
||||
/// batchren C:\work\*.xlsx → 해당 패턴 파일을 창에 미리 로드
|
||||
/// </summary>
|
||||
public class BatchRenameHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "batchren";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"BatchRename",
|
||||
"배치 파일 이름변경 — batchren",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
|
||||
var items = new List<LauncherItem>
|
||||
{
|
||||
new LauncherItem(
|
||||
"배치 파일 이름변경 창 열기",
|
||||
"변수 패턴 또는 정규식으로 여러 파일을 한 번에 이름변경합니다",
|
||||
null,
|
||||
string.IsNullOrWhiteSpace(q) ? "__open__" : q,
|
||||
Symbol: Symbols.Rename),
|
||||
|
||||
new LauncherItem(
|
||||
"변수: {name} 원본명 · {n} 순번 · {n:3} 세 자리 · {date} 날짜",
|
||||
"예: 보고서_{n:3}_{date} → 보고서_001_2026-04-04.xlsx",
|
||||
null, null,
|
||||
Symbol: Symbols.Info),
|
||||
|
||||
new LauncherItem(
|
||||
"변수: {ext} 확장자 · {date:yyyyMMdd} 날짜 형식 지정",
|
||||
"정규식 모드: /old_pattern/new_text/ → 패턴 일치 부분 치환",
|
||||
null, null,
|
||||
Symbol: Symbols.Info),
|
||||
};
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
var dataStr = item.Data as string;
|
||||
if (dataStr == null) return Task.CompletedTask;
|
||||
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
var win = new Views.BatchRenameWindow();
|
||||
|
||||
// 초기 경로 패턴이 지정된 경우 파일 미리 로드
|
||||
if (dataStr != "__open__" && !string.IsNullOrWhiteSpace(dataStr))
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(dataStr);
|
||||
var glob = Path.GetFileName(dataStr);
|
||||
if (!string.IsNullOrWhiteSpace(dir) && Directory.Exists(dir))
|
||||
{
|
||||
var files = Directory.GetFiles(dir, glob ?? "*");
|
||||
Array.Sort(files);
|
||||
win.AddFiles(files);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"BatchRenameHandler: 초기 로드 실패 — {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
win.Show();
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
219
src/AxCopilot/Handlers/BmiHandler.cs
Normal file
219
src/AxCopilot/Handlers/BmiHandler.cs
Normal file
@@ -0,0 +1,219 @@
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L15-3: BMI·건강 계산기 핸들러. "bmi" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: bmi 170 65 → 키 170cm / 몸무게 65kg BMI 계산
|
||||
/// bmi 170 65 30 → 나이 포함 (기초대사량, 목표 칼로리)
|
||||
/// bmi 170 65 30 m → 성별 포함 (남: m/male, 여: f/female)
|
||||
/// bmi ideal 170 → 키 170cm 적정 체중 범위
|
||||
/// Enter → 결과를 클립보드에 복사.
|
||||
/// </summary>
|
||||
public class BmiHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "bmi";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"BMI",
|
||||
"BMI·건강 계산기 — BMI · 적정 체중 · 기초대사량 · 칼로리",
|
||||
"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("BMI·건강 계산기",
|
||||
"예: bmi 170 65 / bmi 170 65 30 m / bmi ideal 170",
|
||||
null, null, Symbol: "\uE73E"));
|
||||
items.Add(new LauncherItem("bmi <키cm> <몸무게kg>", "BMI 지수 계산", null, null, Symbol: "\uE73E"));
|
||||
items.Add(new LauncherItem("bmi <키> <몸무게> <나이>", "기초대사량 포함", null, null, Symbol: "\uE73E"));
|
||||
items.Add(new LauncherItem("bmi <키> <몸무게> <나이> m/f", "성별 포함 (남/여)", null, null, Symbol: "\uE73E"));
|
||||
items.Add(new LauncherItem("bmi ideal <키cm>", "적정 체중 범위", null, null, Symbol: "\uE73E"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// ideal 서브커맨드
|
||||
if (parts[0].Equals("ideal", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (parts.Length < 2 || !double.TryParse(parts[1], out var ht) || ht < 100 || ht > 250)
|
||||
{
|
||||
items.Add(new LauncherItem("키를 입력하세요", "예: bmi ideal 170", null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
items.AddRange(BuildIdealItems(ht));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 키 파싱
|
||||
if (!double.TryParse(parts[0].Replace("cm", ""), out var height) || height < 100 || height > 250)
|
||||
{
|
||||
items.Add(new LauncherItem("키 형식 오류",
|
||||
"키를 cm 단위로 입력하세요 (예: 170)", null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 몸무게 파싱
|
||||
if (parts.Length < 2 || !double.TryParse(parts[1].Replace("kg", ""), out var weight) || weight < 20 || weight > 300)
|
||||
{
|
||||
items.Add(new LauncherItem("몸무게를 입력하세요",
|
||||
$"키 {height}cm → 예: bmi {height} 65", null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 나이, 성별 (선택)
|
||||
int? age = null;
|
||||
bool? male = null;
|
||||
|
||||
for (var i = 2; i < parts.Length; i++)
|
||||
{
|
||||
var p = parts[i].ToLowerInvariant();
|
||||
if (p is "m" or "male" or "남" or "남자" or "남성") { male = true; continue; }
|
||||
if (p is "f" or "female" or "여" or "여자" or "여성") { male = false; continue; }
|
||||
if (int.TryParse(p, out var a) && a is >= 1 and <= 120) { age = a; continue; }
|
||||
}
|
||||
|
||||
items.AddRange(BuildBmiItems(height, weight, age, male));
|
||||
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("BMI", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 계산 빌더 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static IEnumerable<LauncherItem> BuildBmiItems(double height, double weight, int? age, bool? male)
|
||||
{
|
||||
var hm = height / 100.0;
|
||||
var bmi = weight / (hm * hm);
|
||||
var (grade, gradeEmoji) = GetGrade(bmi);
|
||||
|
||||
var summary = $"BMI {bmi:F1} ({grade})";
|
||||
yield return new LauncherItem(summary,
|
||||
$"키 {height}cm · 몸무게 {weight}kg",
|
||||
null, ("copy", summary), Symbol: "\uE73E");
|
||||
|
||||
yield return new LauncherItem($"BMI 지수", $"{bmi:F2} {gradeEmoji}", null, ("copy", $"{bmi:F2}"), Symbol: "\uE73E");
|
||||
yield return new LauncherItem("판정", grade, null, ("copy", grade), Symbol: "\uE73E");
|
||||
|
||||
// 적정 체중 (BMI 18.5 ~ 22.9 범위)
|
||||
var idealMin = 18.5 * hm * hm;
|
||||
var idealMax = 22.9 * hm * hm;
|
||||
var idealStr = $"{idealMin:F1}kg ~ {idealMax:F1}kg";
|
||||
yield return new LauncherItem("적정 체중 범위", idealStr, null, ("copy", idealStr), Symbol: "\uE73E");
|
||||
|
||||
var diff = weight - (idealMin + idealMax) / 2;
|
||||
if (Math.Abs(diff) > 0.5)
|
||||
{
|
||||
var diffStr = diff > 0 ? $"+{diff:F1}kg 과잉" : $"{diff:F1}kg 부족";
|
||||
yield return new LauncherItem("표준 체중 대비", diffStr, null, ("copy", diffStr), Symbol: "\uE73E");
|
||||
}
|
||||
|
||||
// BMI 등급 기준
|
||||
yield return new LauncherItem("── BMI 기준 (WHO 아시아태평양) ──", "", null, null, Symbol: "\uE73E");
|
||||
yield return new LauncherItem("저체중", "BMI < 18.5", null, null, Symbol: "\uE73E");
|
||||
yield return new LauncherItem("정상", "18.5 ≤ BMI < 23", null, null, Symbol: "\uE73E");
|
||||
yield return new LauncherItem("과체중", "23 ≤ BMI < 25", null, null, Symbol: "\uE73E");
|
||||
yield return new LauncherItem("비만 1단계","25 ≤ BMI < 30", null, null, Symbol: "\uE73E");
|
||||
yield return new LauncherItem("비만 2단계","BMI ≥ 30", null, null, Symbol: "\uE73E");
|
||||
|
||||
// 기초대사량 (Harris-Benedict 개정식)
|
||||
if (age.HasValue)
|
||||
{
|
||||
double bmr;
|
||||
string bmrLabel;
|
||||
if (male == true)
|
||||
{
|
||||
bmr = 88.362 + (13.397 * weight) + (4.799 * height) - (5.677 * age.Value);
|
||||
bmrLabel = "남성 기초대사량 (Harris-Benedict)";
|
||||
}
|
||||
else if (male == false)
|
||||
{
|
||||
bmr = 447.593 + (9.247 * weight) + (3.098 * height) - (4.330 * age.Value);
|
||||
bmrLabel = "여성 기초대사량 (Harris-Benedict)";
|
||||
}
|
||||
else
|
||||
{
|
||||
bmr = (88.362 + (13.397 * weight) + (4.799 * height) - (5.677 * age.Value)
|
||||
+ 447.593 + (9.247 * weight) + (3.098 * height) - (4.330 * age.Value)) / 2;
|
||||
bmrLabel = "기초대사량 (남녀 평균)";
|
||||
}
|
||||
|
||||
var bmrStr = $"{bmr:N0} kcal/일";
|
||||
yield return new LauncherItem("── 대사량 ──", "", null, null, Symbol: "\uE73E");
|
||||
yield return new LauncherItem(bmrLabel, bmrStr, null, ("copy", bmrStr), Symbol: "\uE73E");
|
||||
|
||||
// 활동 단계별 권장 칼로리
|
||||
var actLevels = new[]
|
||||
{
|
||||
("비활동 (거의 운동 없음)", 1.2),
|
||||
("저활동 (주 1~3회 운동)", 1.375),
|
||||
("보통 활동 (주 3~5회)", 1.55),
|
||||
("활동적 (주 6~7회)", 1.725),
|
||||
("매우 활동적 (하루 2회)", 1.9),
|
||||
};
|
||||
foreach (var (label, factor) in actLevels)
|
||||
{
|
||||
var cal = bmr * factor;
|
||||
yield return new LauncherItem(label, $"{cal:N0} kcal/일",
|
||||
null, ("copy", $"{cal:N0} kcal"), Symbol: "\uE73E");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<LauncherItem> BuildIdealItems(double height)
|
||||
{
|
||||
var hm = height / 100.0;
|
||||
var idealMin = 18.5 * hm * hm;
|
||||
var idealMax = 22.9 * hm * hm;
|
||||
var idealMid = (idealMin + idealMax) / 2;
|
||||
|
||||
yield return new LauncherItem(
|
||||
$"키 {height}cm 적정 체중",
|
||||
$"{idealMin:F1}kg ~ {idealMax:F1}kg",
|
||||
null, ("copy", $"{idealMin:F1}kg ~ {idealMax:F1}kg"), Symbol: "\uE73E");
|
||||
|
||||
yield return new LauncherItem("최소 정상 체중 (BMI 18.5)", $"{idealMin:F1}kg", null, ("copy", $"{idealMin:F1}"), Symbol: "\uE73E");
|
||||
yield return new LauncherItem("표준 체중 (BMI 20.7)", $"{idealMid:F1}kg", null, ("copy", $"{idealMid:F1}"), Symbol: "\uE73E");
|
||||
yield return new LauncherItem("최대 정상 체중 (BMI 22.9)", $"{idealMax:F1}kg", null, ("copy", $"{idealMax:F1}"), Symbol: "\uE73E");
|
||||
|
||||
var overMin = 23.0 * hm * hm;
|
||||
var overMax = 24.9 * hm * hm;
|
||||
var obese = 25.0 * hm * hm;
|
||||
yield return new LauncherItem("과체중 범위 (BMI 23~24.9)", $"{overMin:F1}kg ~ {overMax:F1}kg", null, null, Symbol: "\uE73E");
|
||||
yield return new LauncherItem("비만 기준 (BMI ≥ 25)", $"{obese:F1}kg 이상", null, null, Symbol: "\uE73E");
|
||||
}
|
||||
|
||||
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static (string Grade, string Emoji) GetGrade(double bmi) => bmi switch
|
||||
{
|
||||
< 18.5 => ("저체중", "🔵"),
|
||||
< 23.0 => ("정상", "🟢"),
|
||||
< 25.0 => ("과체중", "🟡"),
|
||||
< 30.0 => ("비만 1단계", "🟠"),
|
||||
_ => ("비만 2단계", "🔴"),
|
||||
};
|
||||
}
|
||||
153
src/AxCopilot/Handlers/BrightHandler.cs
Normal file
153
src/AxCopilot/Handlers/BrightHandler.cs
Normal file
@@ -0,0 +1,153 @@
|
||||
using System.Diagnostics;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L27-5: 화면 밝기 제어 핸들러. "bright" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: bright → 현재 밝기 표시
|
||||
/// bright 70 → 밝기 70% 설정
|
||||
/// bright up / down → ±10% 조절
|
||||
/// Enter → 해당 밝기로 설정.
|
||||
/// WMI (WmiMonitorBrightness) 사용 — 노트북 내장 디스플레이 대상.
|
||||
/// </summary>
|
||||
public class BrightHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "bright";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"밝기 제어",
|
||||
"화면 밝기 조절 — 설정·증감 (노트북)",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim().ToLowerInvariant();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
// 현재 밝기 읽기
|
||||
int current = GetCurrentBrightness();
|
||||
if (current < 0)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
"밝기 센서를 찾을 수 없습니다",
|
||||
"노트북 내장 디스플레이에서만 동작합니다 (외장 모니터 미지원)",
|
||||
null, null, Symbol: "\uE7BA"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var bar = BrightnessBar(current);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"현재 밝기: {current}%",
|
||||
$"{bar} · bright 70 / bright up / bright down",
|
||||
null, null, Symbol: "\uE706"));
|
||||
|
||||
items.Add(new LauncherItem("bright up", "밝기 +10%", null, ("set", Math.Min(current + 10, 100)), Symbol: "\uE706"));
|
||||
items.Add(new LauncherItem("bright down", "밝기 −10%", null, ("set", Math.Max(current - 10, 0)), Symbol: "\uE706"));
|
||||
|
||||
foreach (var p in new[] { 10, 25, 50, 75, 100 })
|
||||
items.Add(new LauncherItem($"bright {p}", $"밝기 {p}%", null, ("set", p), Symbol: "\uE706"));
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
if (q is "up" or "올려" or "+")
|
||||
{
|
||||
var target = Math.Min(current + 10, 100);
|
||||
items.Add(new LauncherItem($"밝기 +10% → {target}%", BrightnessBar(target), null, ("set", target), Symbol: "\uE706"));
|
||||
}
|
||||
else if (q is "down" or "내려" or "-")
|
||||
{
|
||||
var target = Math.Max(current - 10, 0);
|
||||
items.Add(new LauncherItem($"밝기 −10% → {target}%", BrightnessBar(target), null, ("set", target), Symbol: "\uE706"));
|
||||
}
|
||||
else if (int.TryParse(q, out int val) && val is >= 0 and <= 100)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"밝기 {val}% 설정",
|
||||
$"{BrightnessBar(val)} (현재 {current}%)",
|
||||
null, ("set", val), Symbol: "\uE706"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"'{query}' — 알 수 없는 명령",
|
||||
"사용법: bright 70 / bright up / bright down",
|
||||
null, null, Symbol: "\uE7BA"));
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is ("set", int level))
|
||||
{
|
||||
bool ok = SetBrightness(level);
|
||||
if (ok)
|
||||
NotificationService.Notify("bright", $"밝기 {level}%");
|
||||
else
|
||||
NotificationService.Notify("bright", "밝기 설정 실패 — 노트북 내장 디스플레이에서만 동작합니다.");
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string BrightnessBar(int pct)
|
||||
{
|
||||
int filled = pct / 5;
|
||||
return "[" + new string('█', filled) + new string('░', 20 - filled) + "]";
|
||||
}
|
||||
|
||||
// ─── WMI 밝기 읽기/쓰기 (PowerShell subprocess) ──────────────────────────
|
||||
|
||||
private static int GetCurrentBrightness()
|
||||
{
|
||||
try
|
||||
{
|
||||
var output = RunPowerShell(
|
||||
"(Get-CimInstance -Namespace root/WMI -ClassName WmiMonitorBrightness -ErrorAction Stop).CurrentBrightness");
|
||||
if (int.TryParse(output.Trim(), out int val))
|
||||
return val;
|
||||
}
|
||||
catch { }
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static bool SetBrightness(int level)
|
||||
{
|
||||
try
|
||||
{
|
||||
level = Math.Clamp(level, 0, 100);
|
||||
var cmd = $"$m = Get-CimInstance -Namespace root/WMI -ClassName WmiMonitorBrightnessMethods -ErrorAction Stop; " +
|
||||
$"Invoke-CimMethod -InputObject $m -MethodName WmiSetBrightness -Arguments @{{Timeout=1; Brightness={level}}}";
|
||||
RunPowerShell(cmd);
|
||||
return true;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static string RunPowerShell(string command)
|
||||
{
|
||||
using var proc = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "powershell.exe",
|
||||
Arguments = $"-NoProfile -NonInteractive -Command \"{command}\"",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
proc.Start();
|
||||
var output = proc.StandardOutput.ReadToEnd();
|
||||
proc.WaitForExit(3000);
|
||||
return output;
|
||||
}
|
||||
}
|
||||
349
src/AxCopilot/Handlers/CalHandler.cs
Normal file
349
src/AxCopilot/Handlers/CalHandler.cs
Normal file
@@ -0,0 +1,349 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L23-1: 한국 공휴일·업무일 달력. "cal" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: cal → 이번달 달력·공휴일
|
||||
/// cal next → 다음 공휴일 D-day (5개)
|
||||
/// cal workdays → 이번달 업무일 수·잔여 업무일
|
||||
/// cal today → 오늘 공휴일 여부
|
||||
/// cal 2026-05 → 특정 월 조회
|
||||
/// cal 2026-04-10 → 특정 날짜 조회
|
||||
/// Enter → 복사
|
||||
/// </summary>
|
||||
public class CalHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "cal";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"한국 달력",
|
||||
"공휴일·업무일 달력 — 이번달 · 다음 공휴일 · 업무일 계산",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
// ── 공휴일 데이터 ─────────────────────────────────────────────────────────
|
||||
private static readonly Dictionary<DateOnly, string> Holidays = new()
|
||||
{
|
||||
// 2024
|
||||
{ new DateOnly(2024, 1, 1), "신정" },
|
||||
{ new DateOnly(2024, 2, 9), "설날연휴" },
|
||||
{ new DateOnly(2024, 2, 10), "설날" },
|
||||
{ new DateOnly(2024, 2, 11), "설날연휴" },
|
||||
{ new DateOnly(2024, 2, 12), "대체공휴일" },
|
||||
{ new DateOnly(2024, 3, 1), "삼일절" },
|
||||
{ new DateOnly(2024, 4, 10), "국회의원선거" },
|
||||
{ new DateOnly(2024, 5, 5), "어린이날" },
|
||||
{ new DateOnly(2024, 5, 6), "대체공휴일" },
|
||||
{ new DateOnly(2024, 5, 15), "부처님오신날" },
|
||||
{ new DateOnly(2024, 6, 6), "현충일" },
|
||||
{ new DateOnly(2024, 8, 15), "광복절" },
|
||||
{ new DateOnly(2024, 9, 16), "추석연휴" },
|
||||
{ new DateOnly(2024, 9, 17), "추석" },
|
||||
{ new DateOnly(2024, 9, 18), "추석연휴" },
|
||||
{ new DateOnly(2024, 10, 3), "개천절" },
|
||||
{ new DateOnly(2024, 10, 9), "한글날" },
|
||||
{ new DateOnly(2024, 12, 25), "크리스마스" },
|
||||
|
||||
// 2025
|
||||
{ new DateOnly(2025, 1, 1), "신정" },
|
||||
{ new DateOnly(2025, 1, 28), "설날연휴" },
|
||||
{ new DateOnly(2025, 1, 29), "설날" },
|
||||
{ new DateOnly(2025, 1, 30), "설날연휴" },
|
||||
{ new DateOnly(2025, 3, 1), "삼일절" },
|
||||
{ new DateOnly(2025, 3, 3), "대체공휴일" },
|
||||
{ new DateOnly(2025, 5, 5), "어린이날" },
|
||||
{ new DateOnly(2025, 5, 6), "부처님오신날" },
|
||||
{ new DateOnly(2025, 6, 6), "현충일" },
|
||||
{ new DateOnly(2025, 8, 15), "광복절" },
|
||||
{ new DateOnly(2025, 10, 3), "개천절" },
|
||||
{ new DateOnly(2025, 10, 5), "추석연휴" },
|
||||
{ new DateOnly(2025, 10, 6), "추석" },
|
||||
{ new DateOnly(2025, 10, 7), "추석연휴" },
|
||||
{ new DateOnly(2025, 10, 8), "대체공휴일" },
|
||||
{ new DateOnly(2025, 10, 9), "한글날" },
|
||||
{ new DateOnly(2025, 12, 25), "크리스마스" },
|
||||
|
||||
// 2026
|
||||
{ new DateOnly(2026, 1, 1), "신정" },
|
||||
{ new DateOnly(2026, 2, 17), "설날연휴" },
|
||||
{ new DateOnly(2026, 2, 18), "설날" },
|
||||
{ new DateOnly(2026, 2, 19), "설날연휴" },
|
||||
{ new DateOnly(2026, 3, 1), "삼일절" },
|
||||
{ new DateOnly(2026, 3, 2), "대체공휴일" },
|
||||
{ new DateOnly(2026, 5, 5), "어린이날" },
|
||||
{ new DateOnly(2026, 5, 24), "부처님오신날" },
|
||||
{ new DateOnly(2026, 5, 25), "대체공휴일" },
|
||||
{ new DateOnly(2026, 6, 6), "현충일" },
|
||||
{ new DateOnly(2026, 6, 8), "대체공휴일" },
|
||||
{ new DateOnly(2026, 8, 15), "광복절" },
|
||||
{ new DateOnly(2026, 8, 17), "대체공휴일" },
|
||||
{ new DateOnly(2026, 9, 24), "추석연휴" },
|
||||
{ new DateOnly(2026, 9, 25), "추석" },
|
||||
{ new DateOnly(2026, 9, 26), "추석연휴" },
|
||||
{ new DateOnly(2026, 10, 3), "개천절" },
|
||||
{ new DateOnly(2026, 10, 5), "대체공휴일" },
|
||||
{ new DateOnly(2026, 10, 9), "한글날" },
|
||||
{ new DateOnly(2026, 12, 25), "크리스마스" },
|
||||
|
||||
// 2027
|
||||
{ new DateOnly(2027, 1, 1), "신정" },
|
||||
{ new DateOnly(2027, 2, 7), "설날연휴" },
|
||||
{ new DateOnly(2027, 2, 8), "설날" },
|
||||
{ new DateOnly(2027, 2, 9), "설날연휴" },
|
||||
{ new DateOnly(2027, 3, 1), "삼일절" },
|
||||
{ new DateOnly(2027, 5, 5), "어린이날" },
|
||||
{ new DateOnly(2027, 5, 13), "부처님오신날" },
|
||||
{ new DateOnly(2027, 6, 6), "현충일" },
|
||||
{ new DateOnly(2027, 6, 7), "대체공휴일" },
|
||||
{ new DateOnly(2027, 8, 15), "광복절" },
|
||||
{ new DateOnly(2027, 8, 16), "대체공휴일" },
|
||||
{ new DateOnly(2027, 9, 13), "추석연휴" },
|
||||
{ new DateOnly(2027, 9, 14), "추석" },
|
||||
{ new DateOnly(2027, 9, 15), "추석연휴" },
|
||||
{ new DateOnly(2027, 10, 3), "개천절" },
|
||||
{ new DateOnly(2027, 10, 4), "대체공휴일" },
|
||||
{ new DateOnly(2027, 10, 9), "한글날" },
|
||||
{ new DateOnly(2027, 12, 25), "크리스마스" },
|
||||
{ new DateOnly(2027, 12, 27), "대체공휴일" },
|
||||
};
|
||||
|
||||
private static readonly string[] DayNames = ["일", "월", "화", "수", "목", "금", "토"];
|
||||
|
||||
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static bool IsHoliday(DateOnly d) =>
|
||||
Holidays.ContainsKey(d) || d.DayOfWeek == DayOfWeek.Saturday || d.DayOfWeek == DayOfWeek.Sunday;
|
||||
|
||||
private static bool IsWorkday(DateOnly d) => !IsHoliday(d);
|
||||
|
||||
private static int CountWorkdays(int year, int month)
|
||||
{
|
||||
var days = DateTime.DaysInMonth(year, month);
|
||||
var count = 0;
|
||||
for (var i = 1; i <= days; i++)
|
||||
if (IsWorkday(new DateOnly(year, month, i))) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
private static int CountWorkdaysFrom(DateOnly from, DateOnly to)
|
||||
{
|
||||
var count = 0;
|
||||
var cur = from;
|
||||
while (cur <= to)
|
||||
{
|
||||
if (IsWorkday(cur)) count++;
|
||||
cur = cur.AddDays(1);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static string DayOfWeekKor(DayOfWeek dow) => DayNames[(int)dow];
|
||||
|
||||
// ── GetItemsAsync ─────────────────────────────────────────────────────────
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
BuildMonthItems(today.Year, today.Month, today, items);
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var kw = q.ToLowerInvariant();
|
||||
|
||||
// next → 다음 공휴일 5개
|
||||
if (kw == "next")
|
||||
{
|
||||
items.Add(new LauncherItem("다음 공휴일 5개", "D-N일 표시 · Enter: 클립보드 복사",
|
||||
null, null, Symbol: "\uE787"));
|
||||
var upcoming = Holidays.Keys
|
||||
.Where(d => d > today)
|
||||
.OrderBy(d => d)
|
||||
.Take(5);
|
||||
foreach (var d in upcoming)
|
||||
{
|
||||
var diff = d.DayNumber - today.DayNumber;
|
||||
var name = Holidays[d];
|
||||
var label = $"{d:yyyy-MM-dd} ({DayOfWeekKor(d.DayOfWeek)}) — {name} D-{diff}일";
|
||||
items.Add(new LauncherItem(label, "Enter: 클립보드 복사",
|
||||
null, ("copy", $"{d:yyyy-MM-dd} {name}"), Symbol: "\uE787"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// workdays → 이번달 업무일
|
||||
if (kw == "workdays")
|
||||
{
|
||||
var total = CountWorkdays(today.Year, today.Month);
|
||||
var remaining = CountWorkdaysFrom(today, new DateOnly(today.Year, today.Month,
|
||||
DateTime.DaysInMonth(today.Year, today.Month)));
|
||||
items.Add(new LauncherItem(
|
||||
$"{today.Year}년 {today.Month}월 업무일 — 총 {total}일",
|
||||
$"오늘 기준 잔여 업무일: {remaining}일",
|
||||
null, ("copy", $"{today.Year}년 {today.Month}월 업무일: 총 {total}일, 잔여 {remaining}일"),
|
||||
Symbol: "\uE787"));
|
||||
|
||||
var monthHolidays = Holidays.Where(kv =>
|
||||
kv.Key.Year == today.Year && kv.Key.Month == today.Month)
|
||||
.OrderBy(kv => kv.Key);
|
||||
foreach (var kv in monthHolidays)
|
||||
{
|
||||
var label = $"🎌 {kv.Key.Day}일 ({DayOfWeekKor(kv.Key.DayOfWeek)}) — {kv.Value}";
|
||||
items.Add(new LauncherItem(label, "Enter: 클립보드 복사",
|
||||
null, ("copy", $"{kv.Key:yyyy-MM-dd} {kv.Value}"), Symbol: "\uE787"));
|
||||
}
|
||||
if (!monthHolidays.Any())
|
||||
items.Add(new LauncherItem("이번 달 공휴일 없음", "", null, null, Symbol: "\uE787"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// today → 오늘 정보
|
||||
if (kw == "today")
|
||||
{
|
||||
var isHol = Holidays.TryGetValue(today, out var holName);
|
||||
var isWknd = today.DayOfWeek == DayOfWeek.Saturday || today.DayOfWeek == DayOfWeek.Sunday;
|
||||
string status;
|
||||
if (isHol) status = $"공휴일 ({holName})";
|
||||
else if (isWknd) status = "주말";
|
||||
else status = "평일 (업무일)";
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
$"{today:yyyy-MM-dd} ({DayOfWeekKor(today.DayOfWeek)}) — {status}",
|
||||
"Enter: 클립보드 복사",
|
||||
null, ("copy", $"{today:yyyy-MM-dd} ({DayOfWeekKor(today.DayOfWeek)}) {status}"),
|
||||
Symbol: "\uE787"));
|
||||
|
||||
var next = Holidays.Keys.Where(d => d > today).OrderBy(d => d).FirstOrDefault();
|
||||
if (next != default)
|
||||
{
|
||||
var diff = next.DayNumber - today.DayNumber;
|
||||
items.Add(new LauncherItem(
|
||||
$"다음 공휴일: {next:yyyy-MM-dd} ({DayOfWeekKor(next.DayOfWeek)}) — {Holidays[next]} D-{diff}일",
|
||||
"", null, ("copy", $"{next:yyyy-MM-dd} {Holidays[next]}"), Symbol: "\uE787"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// yyyy-MM-dd 날짜 직접 조회
|
||||
var dateFormats = new[] { "yyyy-MM-dd", "yyyy/MM/dd", "yyyyMMdd" };
|
||||
if (DateOnly.TryParseExact(q, dateFormats, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None, out var specificDate))
|
||||
{
|
||||
var isHol = Holidays.TryGetValue(specificDate, out var hName);
|
||||
var isWknd = specificDate.DayOfWeek == DayOfWeek.Saturday ||
|
||||
specificDate.DayOfWeek == DayOfWeek.Sunday;
|
||||
string status;
|
||||
if (isHol) status = $"공휴일 ({hName})";
|
||||
else if (isWknd) status = "주말";
|
||||
else status = "평일 (업무일)";
|
||||
items.Add(new LauncherItem(
|
||||
$"{specificDate:yyyy-MM-dd} ({DayOfWeekKor(specificDate.DayOfWeek)}) — {status}",
|
||||
"Enter: 클립보드 복사",
|
||||
null, ("copy", $"{specificDate:yyyy-MM-dd} ({DayOfWeekKor(specificDate.DayOfWeek)}) {status}"),
|
||||
Symbol: "\uE787"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// yyyy-MM or yyyy/MM or yyyyMM 월 조회
|
||||
var monthFormats = new[] { "yyyy-MM", "yyyy/MM", "yyyyMM" };
|
||||
if (DateOnly.TryParseExact(q + "-01", new[] { "yyyy-MM-dd", "yyyy/MM/dd", "yyyyMM-dd" },
|
||||
CultureInfo.InvariantCulture, DateTimeStyles.None, out var monthDate) ||
|
||||
TryParseYearMonth(q, out monthDate))
|
||||
{
|
||||
BuildMonthItems(monthDate.Year, monthDate.Month, today, items);
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 기본 — 이번달
|
||||
BuildMonthItems(today.Year, today.Month, today, items);
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
private static bool TryParseYearMonth(string q, out DateOnly result)
|
||||
{
|
||||
result = default;
|
||||
// yyyy-MM, yyyy/MM, yyyyMM
|
||||
var formats = new[] { "yyyy-MM", "yyyy/MM" };
|
||||
foreach (var fmt in formats)
|
||||
{
|
||||
if (DateTime.TryParseExact(q, fmt, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None, out var dt))
|
||||
{
|
||||
result = new DateOnly(dt.Year, dt.Month, 1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// yyyyMM (6자리)
|
||||
if (q.Length == 6 && int.TryParse(q[..4], out var y) && int.TryParse(q[4..], out var m)
|
||||
&& m >= 1 && m <= 12)
|
||||
{
|
||||
result = new DateOnly(y, m, 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void BuildMonthItems(int year, int month, DateOnly today, List<LauncherItem> items)
|
||||
{
|
||||
var total = CountWorkdays(year, month);
|
||||
var lastDay = new DateOnly(year, month, DateTime.DaysInMonth(year, month));
|
||||
int remaining;
|
||||
if (today.Year == year && today.Month == month)
|
||||
remaining = CountWorkdaysFrom(today, lastDay);
|
||||
else if (today < new DateOnly(year, month, 1))
|
||||
remaining = total;
|
||||
else
|
||||
remaining = 0;
|
||||
|
||||
var header = $"{year}년 {month}월 · 업무일 {total}일";
|
||||
if (today.Year == year && today.Month == month)
|
||||
header += $" (잔여 {remaining}일)";
|
||||
|
||||
items.Add(new LauncherItem(header, "공휴일 목록 · Enter: 클립보드 복사",
|
||||
null, ("copy", header), Symbol: "\uE787"));
|
||||
|
||||
var monthHolidays = Holidays
|
||||
.Where(kv => kv.Key.Year == year && kv.Key.Month == month)
|
||||
.OrderBy(kv => kv.Key)
|
||||
.ToList();
|
||||
|
||||
if (monthHolidays.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("공휴일 없음", "", null, null, Symbol: "\uE787"));
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var kv in monthHolidays)
|
||||
{
|
||||
var label = $"🎌 {kv.Key.Day}일 ({DayOfWeekKor(kv.Key.DayOfWeek)}) — {kv.Value}";
|
||||
items.Add(new LauncherItem(label, "Enter: 클립보드 복사",
|
||||
null, ("copy", $"{kv.Key:yyyy-MM-dd} {kv.Value}"), Symbol: "\uE787"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── ExecuteAsync ──────────────────────────────────────────────────────────
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is ("copy", string text))
|
||||
{
|
||||
try
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
|
||||
NotificationService.Notify("달력", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
288
src/AxCopilot/Handlers/CalcHandler.cs
Normal file
288
src/AxCopilot/Handlers/CalcHandler.cs
Normal file
@@ -0,0 +1,288 @@
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L19-1: 공학 계산기 핸들러. "calc" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: calc → 사용법 목록
|
||||
/// calc sin 45 → sin(45°) = 0.7071
|
||||
/// calc cos 60 → cos(60°) = 0.5
|
||||
/// calc tan 45 → tan(45°) = 1.0
|
||||
/// calc sqrt 144 → √144 = 12
|
||||
/// calc log 1000 → log₁₀(1000) = 3
|
||||
/// calc ln 2.718 → ln(2.718) ≈ 1.0
|
||||
/// calc pow 2 10 → 2¹⁰ = 1024
|
||||
/// calc factorial 10 → 10! = 3628800
|
||||
/// calc gcd 12 18 → GCD(12,18) = 6
|
||||
/// calc lcm 4 6 → LCM(4,6) = 12
|
||||
/// calc pi → π = 3.14159265358979
|
||||
/// calc e → e = 2.71828182845905
|
||||
/// calc deg 1.5707 → 라디안 → 도 변환
|
||||
/// calc rad 90 → 도 → 라디안 변환
|
||||
/// calc abs -42 → 절댓값
|
||||
/// calc ceil 3.2 → 올림
|
||||
/// calc floor 3.8 → 내림
|
||||
/// calc round 3.567 2 → 반올림 (소수점 자리)
|
||||
/// Enter → 결과 복사.
|
||||
/// </summary>
|
||||
public class CalcHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "calc";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Calc",
|
||||
"공학 계산기 — sin·cos·log·sqrt·factorial·GCD·LCM 등",
|
||||
"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("공학 계산기",
|
||||
"calc sin/cos/tan/sqrt/log/ln/pow/factorial/gcd/lcm/pi/e/deg/rad/abs/ceil/floor/round",
|
||||
null, null, Symbol: "\uE8EF"));
|
||||
items.Add(BuildUsage("삼각함수", "calc sin 45 / calc cos 60 / calc tan 30"));
|
||||
items.Add(BuildUsage("제곱근·거듭제곱", "calc sqrt 144 / calc pow 2 10"));
|
||||
items.Add(BuildUsage("로그", "calc log 1000 / calc ln 2.718"));
|
||||
items.Add(BuildUsage("팩토리얼", "calc factorial 12"));
|
||||
items.Add(BuildUsage("GCD · LCM", "calc gcd 12 18 / calc lcm 4 6"));
|
||||
items.Add(BuildUsage("상수", "calc pi / calc e"));
|
||||
items.Add(BuildUsage("단위 변환", "calc deg 1.5707 / calc rad 90"));
|
||||
items.Add(BuildUsage("기타", "calc abs -42 / calc ceil 3.2 / calc floor 3.8 / calc round 3.567 2"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var fn = parts[0].ToLowerInvariant();
|
||||
|
||||
// 단순 상수
|
||||
if (fn is "pi") { var v = Math.PI; return Result(items, "π (Pi)", v, "π"); }
|
||||
if (fn is "e") { var v = Math.E; return Result(items, "e (자연상수)", v, "e"); }
|
||||
if (fn is "phi") { var v = 1.6180339887; return Result(items, "φ (황금비)", v, "φ"); }
|
||||
|
||||
// 단일 인수 함수
|
||||
if (parts.Length >= 2 && double.TryParse(parts[1],
|
||||
System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var a))
|
||||
{
|
||||
switch (fn)
|
||||
{
|
||||
case "sin":
|
||||
return Result(items, $"sin({a}°)", Math.Sin(DegToRad(a)), "sin");
|
||||
|
||||
case "cos":
|
||||
return Result(items, $"cos({a}°)", Math.Cos(DegToRad(a)), "cos");
|
||||
|
||||
case "tan":
|
||||
if (Math.Abs(a % 180 - 90) < 1e-9)
|
||||
{
|
||||
items.Add(ErrorItem("tan(90°±n·180°)는 정의되지 않습니다 (무한대)"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
return Result(items, $"tan({a}°)", Math.Tan(DegToRad(a)), "tan");
|
||||
|
||||
case "asin":
|
||||
if (a < -1 || a > 1) { items.Add(ErrorItem("asin 입력값은 -1 ~ 1 범위여야 합니다")); break; }
|
||||
return Result(items, $"asin({a}) °", RadToDeg(Math.Asin(a)), "asin", isAngle: true);
|
||||
|
||||
case "acos":
|
||||
if (a < -1 || a > 1) { items.Add(ErrorItem("acos 입력값은 -1 ~ 1 범위여야 합니다")); break; }
|
||||
return Result(items, $"acos({a}) °", RadToDeg(Math.Acos(a)), "acos", isAngle: true);
|
||||
|
||||
case "atan":
|
||||
return Result(items, $"atan({a}) °", RadToDeg(Math.Atan(a)), "atan", isAngle: true);
|
||||
|
||||
case "sqrt":
|
||||
if (a < 0) { items.Add(ErrorItem("음수의 실수 제곱근은 정의되지 않습니다")); break; }
|
||||
return Result(items, $"√{a}", Math.Sqrt(a), "sqrt");
|
||||
|
||||
case "cbrt":
|
||||
return Result(items, $"∛{a}", Math.Cbrt(a), "cbrt");
|
||||
|
||||
case "log":
|
||||
if (a <= 0) { items.Add(ErrorItem("log 입력값은 양수여야 합니다")); break; }
|
||||
return Result(items, $"log₁₀({a})", Math.Log10(a), "log10");
|
||||
|
||||
case "log2":
|
||||
if (a <= 0) { items.Add(ErrorItem("log2 입력값은 양수여야 합니다")); break; }
|
||||
return Result(items, $"log₂({a})", Math.Log2(a), "log2");
|
||||
|
||||
case "ln":
|
||||
if (a <= 0) { items.Add(ErrorItem("ln 입력값은 양수여야 합니다")); break; }
|
||||
return Result(items, $"ln({a})", Math.Log(a), "ln");
|
||||
|
||||
case "exp":
|
||||
return Result(items, $"e^{a}", Math.Exp(a), "exp");
|
||||
|
||||
case "factorial":
|
||||
{
|
||||
var n = (long)Math.Round(a);
|
||||
if (n < 0 || n > 20) { items.Add(ErrorItem("팩토리얼은 0~20 범위만 지원합니다")); break; }
|
||||
var fac = Factorial(n);
|
||||
items.Add(new LauncherItem($"{n}! = {fac:N0}", $"팩토리얼 · Enter 복사",
|
||||
null, ("copy", $"{fac}"), Symbol: "\uE8EF"));
|
||||
items.Add(new LauncherItem("결과", $"{fac}", null, ("copy", $"{fac}"), Symbol: "\uE8EF"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
case "abs":
|
||||
return Result(items, $"|{a}|", Math.Abs(a), "abs");
|
||||
|
||||
case "ceil":
|
||||
return Result(items, $"⌈{a}⌉", Math.Ceiling(a), "ceil");
|
||||
|
||||
case "floor":
|
||||
return Result(items, $"⌊{a}⌋", Math.Floor(a), "floor");
|
||||
|
||||
case "round":
|
||||
{
|
||||
int decimals = 0;
|
||||
if (parts.Length >= 3 && int.TryParse(parts[2], out var d)) decimals = Math.Clamp(d, 0, 15);
|
||||
var rounded = Math.Round(a, decimals, MidpointRounding.AwayFromZero);
|
||||
return Result(items, $"round({a}, {decimals})", rounded, "round");
|
||||
}
|
||||
|
||||
case "deg":
|
||||
return Result(items, $"{a} rad → °", RadToDeg(a), "deg", isAngle: true);
|
||||
|
||||
case "rad":
|
||||
return Result(items, $"{a}° → rad", DegToRad(a), "rad");
|
||||
|
||||
case "sign":
|
||||
items.Add(new LauncherItem($"sign({a}) = {Math.Sign(a)}",
|
||||
a > 0 ? "양수" : a < 0 ? "음수" : "0",
|
||||
null, ("copy", $"{Math.Sign(a)}"), Symbol: "\uE8EF"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
}
|
||||
|
||||
// 이인수 함수
|
||||
if (parts.Length >= 3 &&
|
||||
double.TryParse(parts[1], System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var x) &&
|
||||
double.TryParse(parts[2], System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var y))
|
||||
{
|
||||
switch (fn)
|
||||
{
|
||||
case "pow":
|
||||
return Result(items, $"{x}^{y}", Math.Pow(x, y), "pow");
|
||||
|
||||
case "log":
|
||||
if (x <= 0 || y <= 0 || y == 1) { items.Add(ErrorItem("밑과 진수는 양수, 밑 ≠ 1 이어야 합니다")); break; }
|
||||
return Result(items, $"log_({x}) {y}", Math.Log(y, x), "log");
|
||||
|
||||
case "atan2":
|
||||
return Result(items, $"atan2({x},{y}) °", RadToDeg(Math.Atan2(x, y)), "atan2", isAngle: true);
|
||||
|
||||
case "hypot":
|
||||
return Result(items, $"hypot({x},{y})", Math.Sqrt(x * x + y * y), "hypot");
|
||||
|
||||
case "gcd":
|
||||
{
|
||||
var ga = (long)Math.Abs(Math.Round(x));
|
||||
var gb = (long)Math.Abs(Math.Round(y));
|
||||
var gv = GcdLong(ga, gb);
|
||||
items.Add(new LauncherItem($"GCD({ga}, {gb}) = {gv}", "최대공약수 · Enter 복사",
|
||||
null, ("copy", $"{gv}"), Symbol: "\uE8EF"));
|
||||
items.Add(new LauncherItem("결과", $"{gv}", null, ("copy", $"{gv}"), Symbol: "\uE8EF"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
case "lcm":
|
||||
{
|
||||
var la = (long)Math.Abs(Math.Round(x));
|
||||
var lb = (long)Math.Abs(Math.Round(y));
|
||||
var g = GcdLong(la, lb);
|
||||
var lv = g == 0 ? 0 : la / g * lb;
|
||||
items.Add(new LauncherItem($"LCM({la}, {lb}) = {lv}", "최소공배수 · Enter 복사",
|
||||
null, ("copy", $"{lv}"), Symbol: "\uE8EF"));
|
||||
items.Add(new LauncherItem("결과", $"{lv}", null, ("copy", $"{lv}"), Symbol: "\uE8EF"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
case "mod":
|
||||
if (y == 0) { items.Add(ErrorItem("0으로 나눌 수 없습니다")); break; }
|
||||
return Result(items, $"{x} mod {y}", x % y, "mod");
|
||||
}
|
||||
}
|
||||
|
||||
// 파싱 실패 안내
|
||||
items.Add(new LauncherItem($"알 수 없는 함수: '{fn}'",
|
||||
"calc sin/cos/tan/sqrt/log/ln/pow/factorial/gcd/lcm/abs/ceil/floor/round/deg/rad",
|
||||
null, null, Symbol: "\uE783"));
|
||||
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("Calc", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static Task<IEnumerable<LauncherItem>> Result(
|
||||
List<LauncherItem> items, string label, double value, string fn, bool isAngle = false)
|
||||
{
|
||||
var formatted = FormatDouble(value);
|
||||
var extra = isAngle ? " °" : "";
|
||||
items.Add(new LauncherItem($"{label} = {formatted}{extra}",
|
||||
$"· Enter 복사", null, ("copy", formatted), Symbol: "\uE8EF"));
|
||||
items.Add(new LauncherItem("결과", $"{formatted}{extra}", null, ("copy", formatted), Symbol: "\uE8EF"));
|
||||
// 추가 정보
|
||||
if (!isAngle && value != 0)
|
||||
{
|
||||
items.Add(new LauncherItem("과학적 표기법", $"{value:E6}", null, ("copy", $"{value:E6}"), Symbol: "\uE8EF"));
|
||||
if (value > 0)
|
||||
items.Add(new LauncherItem("log₁₀ 값", $"{Math.Log10(value):F6}", null, null, Symbol: "\uE8EF"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
private static string FormatDouble(double v)
|
||||
{
|
||||
if (double.IsNaN(v)) return "NaN";
|
||||
if (double.IsPositiveInfinity(v)) return "+∞";
|
||||
if (double.IsNegativeInfinity(v)) return "-∞";
|
||||
// 정수라면 소수점 없이
|
||||
if (v == Math.Floor(v) && Math.Abs(v) < 1e15)
|
||||
return $"{(long)v}";
|
||||
return $"{v:G15}";
|
||||
}
|
||||
|
||||
private static LauncherItem BuildUsage(string title, string example) =>
|
||||
new(title, example, null, null, Symbol: "\uE8EF");
|
||||
|
||||
private static LauncherItem ErrorItem(string msg) =>
|
||||
new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783");
|
||||
|
||||
private static double DegToRad(double deg) => deg * Math.PI / 180.0;
|
||||
private static double RadToDeg(double rad) => rad * 180.0 / Math.PI;
|
||||
|
||||
private static long Factorial(long n)
|
||||
{
|
||||
long r = 1;
|
||||
for (long i = 2; i <= n; i++) r *= i;
|
||||
return r;
|
||||
}
|
||||
|
||||
private static long GcdLong(long a, long b) => b == 0 ? a : GcdLong(b, a % b);
|
||||
}
|
||||
253
src/AxCopilot/Handlers/CertHandler.cs
Normal file
253
src/AxCopilot/Handlers/CertHandler.cs
Normal file
@@ -0,0 +1,253 @@
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L10-3: SSL/TLS 인증서 체커 핸들러. "cert" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: cert google.com → google.com의 인증서 정보 조회
|
||||
/// cert github.com 443 → 포트 지정
|
||||
/// cert https://example.com → URL 형식도 지원
|
||||
/// Enter → 결과를 클립보드에 복사.
|
||||
///
|
||||
/// ⚠ 외부 인터넷 접속 필요. 사내 모드에서는 내부 호스트만 조회 가능.
|
||||
/// </summary>
|
||||
public class CertHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "cert";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Cert",
|
||||
"SSL/TLS 인증서 체커 — 만료일 · 발급자 · SANs",
|
||||
"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(
|
||||
"SSL 인증서 체커",
|
||||
"예: cert google.com / cert 192.168.1.1 / cert example.com 8443",
|
||||
null, null, Symbol: "\uE72E"));
|
||||
items.Add(new LauncherItem("cert google.com", "google.com 인증서 조회", null, null, Symbol: "\uE72E"));
|
||||
items.Add(new LauncherItem("cert github.com", "github.com 인증서 조회", null, null, Symbol: "\uE72E"));
|
||||
items.Add(new LauncherItem("cert 192.168.1.1", "내부 서버 인증서 조회", null, null, Symbol: "\uE72E"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 비동기 조회 시작 — 빠른 반환 후 결과를 기다리지 않음
|
||||
// 실제 조회는 ExecuteAsync에서 처리하며, 여기서는 "조회 중" 항목만 반환
|
||||
var (host, port) = ParseHostPort(q);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
items.Add(new LauncherItem("형식 오류", "예: cert domain.com 또는 cert domain.com 443", null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 사내 모드 확인
|
||||
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}:{port} 인증서 조회",
|
||||
"Enter를 눌러 조회하세요",
|
||||
null,
|
||||
("check", $"{host}:{port}"),
|
||||
Symbol: "\uE72E"));
|
||||
|
||||
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("Cert", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Data is not ("check", string target)) return;
|
||||
|
||||
var parts = target.Split(':');
|
||||
var host = parts[0];
|
||||
var port = parts.Length > 1 && int.TryParse(parts[1], out var p) ? p : 443;
|
||||
|
||||
NotificationService.Notify("Cert", $"{host}:{port} 인증서 조회 중…");
|
||||
|
||||
try
|
||||
{
|
||||
var certInfo = await FetchCertInfoAsync(host, port, ct);
|
||||
var summary = BuildSummary(certInfo);
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(
|
||||
() => Clipboard.SetText(summary));
|
||||
NotificationService.Notify("Cert", certInfo.StatusLine);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
NotificationService.Notify("Cert", "조회가 취소되었습니다.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
NotificationService.Notify("Cert", $"오류: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 인증서 조회 ──────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<CertInfo> FetchCertInfoAsync(string host, int port, CancellationToken ct)
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(host, port, ct);
|
||||
|
||||
using var sslStream = new SslStream(
|
||||
client.GetStream(),
|
||||
leaveInnerStreamOpen: false,
|
||||
userCertificateValidationCallback: (_, cert, _, _) => true); // 만료 인증서도 정보 확인
|
||||
|
||||
await sslStream.AuthenticateAsClientAsync(
|
||||
new SslClientAuthenticationOptions
|
||||
{
|
||||
TargetHost = host,
|
||||
RemoteCertificateValidationCallback = (_, _, _, _) => true,
|
||||
}, ct);
|
||||
|
||||
var cert = sslStream.RemoteCertificate as X509Certificate2
|
||||
?? new X509Certificate2(sslStream.RemoteCertificate!);
|
||||
|
||||
return BuildCertInfo(host, port, cert);
|
||||
}
|
||||
|
||||
private static CertInfo BuildCertInfo(string host, int port, X509Certificate2 cert)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var notAfter = cert.NotAfter.ToUniversalTime();
|
||||
var daysLeft = (int)(notAfter - now).TotalDays;
|
||||
var subject = cert.Subject;
|
||||
var issuer = cert.Issuer;
|
||||
|
||||
// SANs (Subject Alternative Names)
|
||||
var sans = new List<string>();
|
||||
foreach (var ext in cert.Extensions)
|
||||
{
|
||||
if (ext.Oid?.Value == "2.5.29.17") // SAN OID
|
||||
{
|
||||
var raw = ext.Format(false);
|
||||
sans.AddRange(raw.Split(new[] { ", ", ",\r\n" }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(s => s.Trim())
|
||||
.Where(s => s.StartsWith("DNS Name=", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(s => s[9..]));
|
||||
}
|
||||
}
|
||||
|
||||
var status = daysLeft > 30 ? "유효"
|
||||
: daysLeft > 0 ? "만료 임박"
|
||||
: "만료됨";
|
||||
|
||||
return new CertInfo
|
||||
{
|
||||
Host = host,
|
||||
Port = port,
|
||||
Subject = subject,
|
||||
Issuer = issuer,
|
||||
NotBefore = cert.NotBefore,
|
||||
NotAfter = cert.NotAfter,
|
||||
DaysLeft = daysLeft,
|
||||
Sans = sans,
|
||||
Thumbprint = cert.Thumbprint,
|
||||
Status = status,
|
||||
StatusLine = $"{status} · D-{(daysLeft > 0 ? daysLeft.ToString() : "만료")} · {host}:{port}",
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildSummary(CertInfo c)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine($"호스트: {c.Host}:{c.Port}");
|
||||
sb.AppendLine($"상태: {c.Status} (만료까지 {c.DaysLeft}일)");
|
||||
sb.AppendLine($"발급 대상: {c.Subject}");
|
||||
sb.AppendLine($"발급 기관: {c.Issuer}");
|
||||
sb.AppendLine($"유효 시작: {c.NotBefore:yyyy-MM-dd HH:mm:ss}");
|
||||
sb.AppendLine($"만료 일자: {c.NotAfter:yyyy-MM-dd HH:mm:ss}");
|
||||
if (c.Sans.Count > 0)
|
||||
sb.AppendLine($"SANs: {string.Join(", ", c.Sans.Take(10))}");
|
||||
sb.AppendLine($"지문(SHA1): {c.Thumbprint}");
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
// ── 파싱 헬퍼 ────────────────────────────────────────────────────────────
|
||||
|
||||
private static (string Host, int Port) ParseHostPort(string q)
|
||||
{
|
||||
// https:// 또는 http:// 제거
|
||||
if (q.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
q = q[8..];
|
||||
else if (q.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
|
||||
q = q[7..];
|
||||
|
||||
// 경로 제거
|
||||
var slashIdx = q.IndexOf('/');
|
||||
if (slashIdx >= 0) q = q[..slashIdx];
|
||||
|
||||
var colonIdx = q.LastIndexOf(':');
|
||||
if (colonIdx >= 0 && int.TryParse(q[(colonIdx + 1)..], out var port))
|
||||
return (q[..colonIdx], port);
|
||||
|
||||
return (q, 443);
|
||||
}
|
||||
|
||||
private static bool IsInternalHost(string host)
|
||||
{
|
||||
if (host is "localhost" or "127.0.0.1") return true;
|
||||
if (host.StartsWith("192.168.")) return true;
|
||||
if (host.StartsWith("10.")) return true;
|
||||
if (host.StartsWith("172.16.") || host.StartsWith("172.17.") ||
|
||||
host.StartsWith("172.18.") || host.StartsWith("172.19.") ||
|
||||
host.StartsWith("172.20.") || host.StartsWith("172.21.") ||
|
||||
host.StartsWith("172.22.") || host.StartsWith("172.23.") ||
|
||||
host.StartsWith("172.24.") || host.StartsWith("172.25.") ||
|
||||
host.StartsWith("172.26.") || host.StartsWith("172.27.") ||
|
||||
host.StartsWith("172.28.") || host.StartsWith("172.29.") ||
|
||||
host.StartsWith("172.30.") || host.StartsWith("172.31.")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private record CertInfo
|
||||
{
|
||||
public string Host { get; init; } = "";
|
||||
public int Port { get; init; }
|
||||
public string Subject { get; init; } = "";
|
||||
public string Issuer { get; init; } = "";
|
||||
public DateTime NotBefore { get; init; }
|
||||
public DateTime NotAfter { get; init; }
|
||||
public int DaysLeft { get; init; }
|
||||
public List<string> Sans { get; init; } = new();
|
||||
public string Thumbprint { get; init; } = "";
|
||||
public string Status { get; init; } = "";
|
||||
public string StatusLine { get; init; } = "";
|
||||
}
|
||||
}
|
||||
320
src/AxCopilot/Handlers/CleanHandler.cs
Normal file
320
src/AxCopilot/Handlers/CleanHandler.cs
Normal file
@@ -0,0 +1,320 @@
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L9-3: 시스템 정리 핸들러. "clean" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: clean → 정리 가능한 항목 목록 + 예상 용량
|
||||
/// clean temp → Windows 임시 파일 정리 (%TEMP%)
|
||||
/// clean recycle → 휴지통 비우기
|
||||
/// clean downloads → 다운로드 폴더 오래된 파일 목록 (30일 이상)
|
||||
/// clean logs → 앱 로그 폴더 정리 (%APPDATA%\AxCopilot\logs)
|
||||
/// clean all → temp + recycle + logs 한 번에 정리
|
||||
/// </summary>
|
||||
public class CleanHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "clean";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Clean",
|
||||
"시스템 정리 — 임시 파일 · 휴지통 · 다운로드 · 로그",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim().ToLowerInvariant();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
// 각 영역 크기 미리보기
|
||||
var tempSize = GetDirSize(Path.GetTempPath());
|
||||
var recycleSize = GetRecycleBinSize();
|
||||
var downloadsSize = GetOldDownloadsSize(30);
|
||||
var logSize = GetDirSize(GetAppLogPath());
|
||||
|
||||
var total = tempSize + recycleSize + downloadsSize + logSize;
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
$"정리 가능 {FormatBytes(total)}",
|
||||
"항목을 선택하거나 clean all 로 모두 정리",
|
||||
null, null, Symbol: "\uE74D"));
|
||||
|
||||
items.Add(MakeCleanItem("temp", "\uE8B6", "임시 파일 (%TEMP%)", tempSize));
|
||||
items.Add(MakeCleanItem("recycle", "\uE74D", "휴지통", recycleSize));
|
||||
items.Add(MakeCleanItem("downloads", "\uE896", "다운로드 (30일 이상)", downloadsSize));
|
||||
items.Add(MakeCleanItem("logs", "\uE9D9", "AxCopilot 로그 파일", logSize));
|
||||
items.Add(new LauncherItem(
|
||||
"clean all",
|
||||
$"temp + recycle + logs 한 번에 정리 ({FormatBytes(tempSize + recycleSize + logSize)})",
|
||||
null,
|
||||
("clean_all", ""),
|
||||
Symbol: "\uE74D"));
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
switch (q)
|
||||
{
|
||||
case "temp":
|
||||
{
|
||||
var tempPath = Path.GetTempPath();
|
||||
var size = GetDirSize(tempPath);
|
||||
var count = SafeCountFiles(tempPath);
|
||||
items.Add(new LauncherItem(
|
||||
$"임시 파일 정리 {FormatBytes(size)}",
|
||||
$"{count}개 파일 · {tempPath}",
|
||||
null,
|
||||
("clean_temp", tempPath),
|
||||
Symbol: "\uE8B6"));
|
||||
break;
|
||||
}
|
||||
case "recycle":
|
||||
{
|
||||
var size = GetRecycleBinSize();
|
||||
items.Add(new LauncherItem(
|
||||
$"휴지통 비우기 {FormatBytes(size)}",
|
||||
"복구 불가능합니다. Enter로 실행",
|
||||
null,
|
||||
("clean_recycle", ""),
|
||||
Symbol: "\uE74D"));
|
||||
break;
|
||||
}
|
||||
case "downloads":
|
||||
{
|
||||
var downloadsPath = Environment.GetFolderPath(
|
||||
Environment.SpecialFolder.UserProfile);
|
||||
downloadsPath = Path.Combine(downloadsPath, "Downloads");
|
||||
var oldFiles = GetOldFiles(downloadsPath, 30);
|
||||
var totalSz = oldFiles.Sum(f => f.Length);
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
$"다운로드 30일 이상 파일 {FormatBytes(totalSz)}",
|
||||
$"{oldFiles.Count}개 파일",
|
||||
null,
|
||||
("list_downloads", downloadsPath),
|
||||
Symbol: "\uE896"));
|
||||
|
||||
foreach (var f in oldFiles.Take(15))
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
f.Name,
|
||||
$"{FormatBytes(f.Length)} · {f.LastWriteTime:MM-dd HH:mm}",
|
||||
null,
|
||||
("open_file", f.FullName),
|
||||
Symbol: "\uE8A5"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "logs":
|
||||
{
|
||||
var logPath = GetAppLogPath();
|
||||
var size = GetDirSize(logPath);
|
||||
var count = SafeCountFiles(logPath);
|
||||
items.Add(new LauncherItem(
|
||||
$"AxCopilot 로그 정리 {FormatBytes(size)}",
|
||||
$"{count}개 파일 · {logPath}",
|
||||
null,
|
||||
("clean_logs", logPath),
|
||||
Symbol: "\uE9D9"));
|
||||
break;
|
||||
}
|
||||
case "all":
|
||||
{
|
||||
var tempSz = GetDirSize(Path.GetTempPath());
|
||||
var recycleSz = GetRecycleBinSize();
|
||||
var logSz = GetDirSize(GetAppLogPath());
|
||||
items.Add(new LauncherItem(
|
||||
$"모두 정리 {FormatBytes(tempSz + recycleSz + logSz)}",
|
||||
"temp + recycle + logs · Enter로 실행",
|
||||
null,
|
||||
("clean_all", ""),
|
||||
Symbol: "\uE74D"));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
items.Add(new LauncherItem("서브커맨드", "temp · recycle · downloads · logs · all", null, null, Symbol: "\uE946"));
|
||||
break;
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
switch (item.Data)
|
||||
{
|
||||
case ("clean_temp", string path):
|
||||
await Task.Run(() =>
|
||||
{
|
||||
var deleted = CleanDirectory(path, recursive: false);
|
||||
NotificationService.Notify("정리 완료", $"임시 파일 {deleted}개 삭제");
|
||||
}, ct);
|
||||
break;
|
||||
|
||||
case ("clean_recycle", _):
|
||||
await Task.Run(() =>
|
||||
{
|
||||
EmptyRecycleBin();
|
||||
NotificationService.Notify("정리 완료", "휴지통을 비웠습니다.");
|
||||
}, ct);
|
||||
break;
|
||||
|
||||
case ("clean_logs", string path):
|
||||
await Task.Run(() =>
|
||||
{
|
||||
var deleted = CleanDirectory(path, recursive: true);
|
||||
NotificationService.Notify("정리 완료", $"로그 파일 {deleted}개 삭제");
|
||||
}, ct);
|
||||
break;
|
||||
|
||||
case ("clean_all", _):
|
||||
await Task.Run(() =>
|
||||
{
|
||||
var t1 = CleanDirectory(Path.GetTempPath(), recursive: false);
|
||||
EmptyRecycleBin();
|
||||
var t2 = CleanDirectory(GetAppLogPath(), recursive: true);
|
||||
NotificationService.Notify("모두 정리 완료",
|
||||
$"임시 {t1}개, 로그 {t2}개 삭제, 휴지통 비움");
|
||||
}, ct);
|
||||
break;
|
||||
|
||||
case ("list_downloads", string path):
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = path,
|
||||
UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
break;
|
||||
|
||||
case ("open_file", string filePath):
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = filePath,
|
||||
UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 헬퍼 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static LauncherItem MakeCleanItem(string sub, string icon, string label, long size) =>
|
||||
new(
|
||||
$"clean {sub}",
|
||||
$"{label} · {FormatBytes(size)}",
|
||||
null,
|
||||
($"clean_{sub}", ""),
|
||||
Symbol: icon);
|
||||
|
||||
private static long GetDirSize(string path)
|
||||
{
|
||||
if (!Directory.Exists(path)) return 0;
|
||||
try
|
||||
{
|
||||
return Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)
|
||||
.Sum(f => { try { return new FileInfo(f).Length; } catch { return 0; } });
|
||||
}
|
||||
catch { return 0; }
|
||||
}
|
||||
|
||||
private static long GetRecycleBinSize()
|
||||
{
|
||||
try
|
||||
{
|
||||
// SHQueryRecycleBin P/Invoke 대신 Shell32 통해 추정
|
||||
// 간단히 0 반환 (실제 크기는 SHQueryRecycleBinW P/Invoke 필요)
|
||||
return 0;
|
||||
}
|
||||
catch { return 0; }
|
||||
}
|
||||
|
||||
private static long GetOldDownloadsSize(int olderThanDays)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Downloads");
|
||||
return GetOldFiles(path, olderThanDays).Sum(f => f.Length);
|
||||
}
|
||||
catch { return 0; }
|
||||
}
|
||||
|
||||
private static List<FileInfo> GetOldFiles(string dir, int olderThanDays)
|
||||
{
|
||||
if (!Directory.Exists(dir)) return [];
|
||||
var cutoff = DateTime.Now.AddDays(-olderThanDays);
|
||||
try
|
||||
{
|
||||
return Directory.EnumerateFiles(dir)
|
||||
.Select(f => new FileInfo(f))
|
||||
.Where(fi => fi.LastWriteTime < cutoff)
|
||||
.OrderByDescending(fi => fi.Length)
|
||||
.ToList();
|
||||
}
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
private static int SafeCountFiles(string path)
|
||||
{
|
||||
if (!Directory.Exists(path)) return 0;
|
||||
try
|
||||
{
|
||||
return Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories).Count();
|
||||
}
|
||||
catch { return 0; }
|
||||
}
|
||||
|
||||
private static int CleanDirectory(string path, bool recursive)
|
||||
{
|
||||
if (!Directory.Exists(path)) return 0;
|
||||
int deleted = 0;
|
||||
var opts = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
|
||||
foreach (var file in Directory.EnumerateFiles(path, "*", opts))
|
||||
{
|
||||
try { File.Delete(file); deleted++; } catch { /* 잠긴 파일 무시 */ }
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("shell32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)]
|
||||
private static extern int SHEmptyRecycleBin(IntPtr hwnd, string? pszRootPath, uint dwFlags);
|
||||
|
||||
private static void EmptyRecycleBin()
|
||||
{
|
||||
try
|
||||
{
|
||||
// SHERB_NOCONFIRMATION=0x1, SHERB_NOPROGRESSUI=0x2, SHERB_NOSOUND=0x4
|
||||
SHEmptyRecycleBin(IntPtr.Zero, null, 0x07);
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
|
||||
private static string GetAppLogPath() =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "logs");
|
||||
|
||||
private static string FormatBytes(long bytes) => bytes switch
|
||||
{
|
||||
>= 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",
|
||||
};
|
||||
}
|
||||
311
src/AxCopilot/Handlers/ContactHandler.cs
Normal file
311
src/AxCopilot/Handlers/ContactHandler.cs
Normal file
@@ -0,0 +1,311 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L25-1: 로컬 연락처 관리. "contact" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: contact → 전체 연락처 목록
|
||||
/// contact 홍길동 → 이름 검색
|
||||
/// contact add 홍길동 개발팀 010-1234-5678 hong@company.com → 추가
|
||||
/// contact del 홍길동 → 삭제
|
||||
/// contact <부서명> → 부서 필터
|
||||
/// Enter → 이메일 또는 전화번호 클립보드 복사
|
||||
/// 저장: %APPDATA%\AxCopilot\contacts.json
|
||||
/// </summary>
|
||||
public class ContactHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "contact";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"연락처",
|
||||
"로컬 연락처 관리 — 추가 · 검색 · 삭제 · 복사",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private static readonly string DataPath = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "contacts.json");
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
private sealed class Contact
|
||||
{
|
||||
[JsonPropertyName("name")] public string Name { get; set; } = "";
|
||||
[JsonPropertyName("dept")] public string Dept { get; set; } = "";
|
||||
[JsonPropertyName("phone")] public string Phone { get; set; } = "";
|
||||
[JsonPropertyName("email")] public string Email { get; set; } = "";
|
||||
[JsonPropertyName("memo")] public string Memo { get; set; } = "";
|
||||
}
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
var contacts = LoadContacts();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"연락처 {contacts.Count}개",
|
||||
"contact add 이름 부서 010-xxxx-xxxx email@company.com",
|
||||
null, null, Symbol: "\uE8D4"));
|
||||
|
||||
if (contacts.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("저장된 연락처가 없습니다",
|
||||
"contact add 이름 부서 전화 이메일 로 추가하세요",
|
||||
null, null, Symbol: "\uE946"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
foreach (var c in contacts)
|
||||
items.Add(MakeContactItem(c));
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
// add 명령
|
||||
if (sub == "add")
|
||||
{
|
||||
var addPart = parts.Length > 1 ? parts[1].Trim() : "";
|
||||
if (string.IsNullOrWhiteSpace(addPart))
|
||||
{
|
||||
items.Add(new LauncherItem("이름을 입력하세요",
|
||||
"예: contact add 홍길동 개발팀 010-1234-5678 hong@company.com",
|
||||
null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var (name, dept, phone, email, memo) = ParseAddArgs(addPart);
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
items.Add(new LauncherItem("이름을 입력하세요",
|
||||
"예: contact add 홍길동 개발팀 010-1234-5678 hong@company.com",
|
||||
null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var encoded = $"{name}|{dept}|{phone}|{email}|{memo}";
|
||||
items.Add(new LauncherItem(
|
||||
$"연락처 추가: {name}",
|
||||
$"{dept} {phone} {email} · Enter로 추가",
|
||||
null, ("add", encoded), Symbol: "\uE710"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// del 명령
|
||||
if (sub is "del" or "delete" or "remove")
|
||||
{
|
||||
var delName = parts.Length > 1 ? parts[1].Trim() : "";
|
||||
if (string.IsNullOrWhiteSpace(delName))
|
||||
{
|
||||
items.Add(new LauncherItem("삭제할 이름을 입력하세요",
|
||||
"예: contact del 홍길동",
|
||||
null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var matches = contacts.Where(c =>
|
||||
c.Name.Contains(delName, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (matches.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem($"'{delName}' 연락처를 찾을 수 없습니다", "",
|
||||
null, null, Symbol: "\uE783"));
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var c in matches)
|
||||
items.Add(new LauncherItem(
|
||||
$"{c.Name} 삭제",
|
||||
$"{c.Dept} {c.Phone} {c.Email} · Enter로 삭제",
|
||||
null, ("del", c.Name), Symbol: "\uE8D4"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 검색: 이름, 부서, 메모 포함
|
||||
var byStartsWith = contacts
|
||||
.Where(c => c.Name.StartsWith(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
c.Dept.StartsWith(q, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
var byContains = contacts
|
||||
.Where(c => !byStartsWith.Contains(c) &&
|
||||
(c.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
c.Dept.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
c.Memo.Contains(q, StringComparison.OrdinalIgnoreCase)))
|
||||
.ToList();
|
||||
|
||||
var results = byStartsWith.Concat(byContains).ToList();
|
||||
|
||||
if (results.Count > 0)
|
||||
{
|
||||
items.Add(new LauncherItem($"'{q}' 검색 결과 {results.Count}개", "",
|
||||
null, null, Symbol: "\uE8D4"));
|
||||
foreach (var c in results)
|
||||
items.Add(MakeContactItem(c));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"'{q}' 검색 결과 없음",
|
||||
"contact add 로 새 연락처를 추가하세요",
|
||||
null, null, Symbol: "\uE783"));
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
switch (item.Data)
|
||||
{
|
||||
case ("copy_email", string email) when !string.IsNullOrWhiteSpace(email):
|
||||
CopyToClipboard(email);
|
||||
NotificationService.Notify("연락처", $"{email} 복사");
|
||||
break;
|
||||
|
||||
case ("copy_phone", string phone) when !string.IsNullOrWhiteSpace(phone):
|
||||
CopyToClipboard(phone);
|
||||
NotificationService.Notify("연락처", $"{phone} 복사");
|
||||
break;
|
||||
|
||||
case ("copy", string text) when !string.IsNullOrWhiteSpace(text):
|
||||
CopyToClipboard(text);
|
||||
NotificationService.Notify("연락처", $"{text} 복사");
|
||||
break;
|
||||
|
||||
case ("add", string encoded):
|
||||
var addParts = encoded.Split('|');
|
||||
if (addParts.Length >= 5)
|
||||
{
|
||||
var contact = new Contact
|
||||
{
|
||||
Name = addParts[0],
|
||||
Dept = addParts[1],
|
||||
Phone = addParts[2],
|
||||
Email = addParts[3],
|
||||
Memo = addParts[4],
|
||||
};
|
||||
var list = LoadContacts();
|
||||
list.RemoveAll(c => c.Name == contact.Name);
|
||||
list.Add(contact);
|
||||
SaveContacts(list);
|
||||
NotificationService.Notify("연락처", $"{contact.Name} 추가됨");
|
||||
}
|
||||
break;
|
||||
|
||||
case ("del", string name):
|
||||
var contacts = LoadContacts();
|
||||
var removed = contacts.RemoveAll(c => c.Name == name);
|
||||
if (removed > 0)
|
||||
{
|
||||
SaveContacts(contacts);
|
||||
NotificationService.Notify("연락처", $"{name} 삭제됨");
|
||||
}
|
||||
break;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 저장/불러오기 ─────────────────────────────────────────────────────────
|
||||
|
||||
private static List<Contact> LoadContacts()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!System.IO.File.Exists(DataPath)) return new List<Contact>();
|
||||
var json = System.IO.File.ReadAllText(DataPath, System.Text.Encoding.UTF8);
|
||||
return JsonSerializer.Deserialize<List<Contact>>(json, JsonOpts) ?? new List<Contact>();
|
||||
}
|
||||
catch { return new List<Contact>(); }
|
||||
}
|
||||
|
||||
private static void SaveContacts(List<Contact> contacts)
|
||||
{
|
||||
try
|
||||
{
|
||||
System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(DataPath)!);
|
||||
System.IO.File.WriteAllText(DataPath,
|
||||
JsonSerializer.Serialize(contacts, JsonOpts),
|
||||
System.Text.Encoding.UTF8);
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
|
||||
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static LauncherItem MakeContactItem(Contact c)
|
||||
{
|
||||
var title = $"{c.Name}";
|
||||
if (!string.IsNullOrWhiteSpace(c.Dept)) title += $" · {c.Dept}";
|
||||
var sub = "";
|
||||
if (!string.IsNullOrWhiteSpace(c.Phone)) sub += c.Phone;
|
||||
if (!string.IsNullOrWhiteSpace(c.Email)) sub += (sub.Length > 0 ? " | " : "") + c.Email;
|
||||
|
||||
// 복사 우선순위: email > phone > name
|
||||
(string action, string value) data;
|
||||
if (!string.IsNullOrWhiteSpace(c.Email))
|
||||
data = ("copy_email", c.Email);
|
||||
else if (!string.IsNullOrWhiteSpace(c.Phone))
|
||||
data = ("copy_phone", c.Phone);
|
||||
else
|
||||
data = ("copy", c.Name);
|
||||
|
||||
return new LauncherItem(title, sub, null, data, Symbol: "\uE8D4");
|
||||
}
|
||||
|
||||
private static (string name, string dept, string phone, string email, string memo)
|
||||
ParseAddArgs(string addPart)
|
||||
{
|
||||
var tokens = addPart.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (tokens.Length == 0) return ("", "", "", "", "");
|
||||
|
||||
var name = tokens[0];
|
||||
var dept = "";
|
||||
var phone = "";
|
||||
var email = "";
|
||||
var memoTokens = new List<string>();
|
||||
|
||||
for (var i = 1; i < tokens.Length; i++)
|
||||
{
|
||||
var t = tokens[i];
|
||||
if (string.IsNullOrWhiteSpace(phone) && IsPhoneLike(t))
|
||||
phone = t;
|
||||
else if (string.IsNullOrWhiteSpace(email) && t.Contains('@'))
|
||||
email = t;
|
||||
else if (string.IsNullOrWhiteSpace(dept) && i == 1)
|
||||
dept = t;
|
||||
else
|
||||
memoTokens.Add(t);
|
||||
}
|
||||
|
||||
return (name, dept, phone, email, string.Join(" ", memoTokens));
|
||||
}
|
||||
|
||||
private static bool IsPhoneLike(string s)
|
||||
{
|
||||
var stripped = s.Replace("-", "").Replace(" ", "");
|
||||
return stripped.Length >= 9 && stripped.All(c => char.IsDigit(c));
|
||||
}
|
||||
|
||||
private static void CopyToClipboard(string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
193
src/AxCopilot/Handlers/ContextHandler.cs
Normal file
193
src/AxCopilot/Handlers/ContextHandler.cs
Normal file
@@ -0,0 +1,193 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L6-3: 컨텍스트 감지 자동완성 핸들러. "ctx" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 현재 포커스된 앱을 감지하여 상황별 런처 명령을 제안합니다.
|
||||
/// 예: ctx → 현재 Chrome 사용 중이면 북마크/웹 검색 명령 추천
|
||||
/// 현재 VS Code 사용 중이면 파일/스니펫/git 명령 추천
|
||||
/// </summary>
|
||||
public class ContextHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "ctx";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Context",
|
||||
"컨텍스트 명령 제안 — ctx",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
// ─── P/Invoke ─────────────────────────────────────────────────────────
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr GetForegroundWindow();
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint dwProcessId);
|
||||
|
||||
// ─── 컨텍스트 규칙 ─────────────────────────────────────────────────────
|
||||
// 프로세스 이름 → (앱 표시명, 추천 명령 목록)
|
||||
private static readonly Dictionary<string[], (string AppName, ContextSuggestion[] Suggestions)> ContextMap
|
||||
= new(new StringArrayEqualityComparer())
|
||||
{
|
||||
{
|
||||
new[] { "chrome", "msedge", "firefox", "brave", "opera" },
|
||||
("웹 브라우저", new[]
|
||||
{
|
||||
new ContextSuggestion("북마크 검색", "북마크를 검색합니다", "bm", Symbols.Favorite),
|
||||
new ContextSuggestion("웹 검색", "기본 검색엔진으로 검색합니다", "?", "\uE721"),
|
||||
new ContextSuggestion("URL 열기", "URL을 런처에서 직접 실행합니다", "url", "\uE71B"),
|
||||
new ContextSuggestion("클립보드 내용 검색", "클립보드 이력을 검색합니다", "#", "\uE8C8"),
|
||||
})
|
||||
},
|
||||
{
|
||||
new[] { "code", "devenv", "rider", "idea64", "pycharm64", "webstorm64", "clion64" },
|
||||
("코드 편집기", new[]
|
||||
{
|
||||
new ContextSuggestion("파일 검색", "인덱싱된 파일을 빠르게 엽니다", "", "\uE8A5"),
|
||||
new ContextSuggestion("스니펫 입력", "텍스트 스니펫을 확장합니다", ";", "\uE8D2"),
|
||||
new ContextSuggestion("클립보드 이력", "복사한 내용을 검색합니다", "#", "\uE8C8"),
|
||||
new ContextSuggestion("파일 미리보기", "F3으로 파일 내용을 미리봅니다", "", "\uE7C3"),
|
||||
new ContextSuggestion("QuickLook 편집","파일 인라인 편집 (Ctrl+E)", "", "\uE70F"),
|
||||
})
|
||||
},
|
||||
{
|
||||
new[] { "excel", "powerpnt", "winword", "onenote", "outlook", "hwp", "hwpx" },
|
||||
("오피스", new[]
|
||||
{
|
||||
new ContextSuggestion("클립보드 이력", "복사한 셀·텍스트를 재사용합니다", "#", "\uE8C8"),
|
||||
new ContextSuggestion("계산기", "수식을 빠르게 계산합니다", "=", "\uE8EF"),
|
||||
new ContextSuggestion("날짜 계산", "날짜·기간을 계산합니다", "=today", "\uE787"),
|
||||
new ContextSuggestion("파일 검색", "문서 파일을 빠르게 찾습니다", "", "\uE8A5"),
|
||||
new ContextSuggestion("스니펫", "자주 쓰는 문구를 입력합니다", ";", "\uE8D2"),
|
||||
})
|
||||
},
|
||||
{
|
||||
new[] { "explorer" },
|
||||
("파일 탐색기", new[]
|
||||
{
|
||||
new ContextSuggestion("파일 태그 검색", "태그로 파일을 찾습니다", "tag", "\uE932"),
|
||||
new ContextSuggestion("배치 이름변경", "여러 파일을 한번에 이름변경합니다", "batchren", "\uE8AC"),
|
||||
new ContextSuggestion("파일 미리보기", "선택 파일을 미리봅니다 (F3)", "", "\uE7C3"),
|
||||
new ContextSuggestion("폴더 즐겨찾기", "즐겨찾기 폴더를 엽니다", "fav", Symbols.Favorite),
|
||||
})
|
||||
},
|
||||
{
|
||||
new[] { "slack", "teams", "zoom", "msteams" },
|
||||
("커뮤니케이션", new[]
|
||||
{
|
||||
new ContextSuggestion("클립보드 이력", "공유할 내용을 클립보드에서 선택", "#", "\uE8C8"),
|
||||
new ContextSuggestion("스크린 캡처", "화면을 캡처해 공유합니다", "cap", "\uE722"),
|
||||
new ContextSuggestion("스니펫", "자주 쓰는 답변 텍스트를 입력합니다",";", "\uE8D2"),
|
||||
new ContextSuggestion("번역", "텍스트를 번역합니다", "! 번역", "\uF2B7"),
|
||||
})
|
||||
},
|
||||
};
|
||||
|
||||
// ─── 기본 제안 (앱 미인식) ────────────────────────────────────────────
|
||||
private static readonly ContextSuggestion[] DefaultSuggestions =
|
||||
{
|
||||
new("파일/앱 검색", "이름으로 파일·앱을 빠르게 검색", "", "\uE721"),
|
||||
new("클립보드 이력", "최근 복사한 내용 검색", "#", "\uE8C8"),
|
||||
new("계산기", "수식 계산", "=", "\uE8EF"),
|
||||
new("스니펫", "텍스트 스니펫 확장", ";", "\uE8D2"),
|
||||
new("스케줄러", "자동화 스케줄 목록", "sched","\uE916"),
|
||||
};
|
||||
|
||||
// ─── 항목 목록 ──────────────────────────────────────────────────────────
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var (procName, appDisplayName, suggestions) = GetContextInfo();
|
||||
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
var headerSub = string.IsNullOrEmpty(procName)
|
||||
? "현재 포그라운드 앱을 인식할 수 없습니다"
|
||||
: $"현재 앱: {appDisplayName} ({procName}) · 상황별 명령 제안";
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
"컨텍스트 제안",
|
||||
headerSub,
|
||||
null, null,
|
||||
Symbol: "\uE945"));
|
||||
|
||||
var q = query.Trim().ToLowerInvariant();
|
||||
|
||||
foreach (var s in suggestions)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(q) &&
|
||||
!s.Title.Contains(q, StringComparison.OrdinalIgnoreCase) &&
|
||||
!s.Subtitle.Contains(q, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var subtitle = string.IsNullOrEmpty(s.Prefix)
|
||||
? s.Subtitle
|
||||
: $"{s.Subtitle} · 프리픽스: [{s.Prefix}]";
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
s.Title, subtitle, null, s.Prefix, Symbol: s.Symbol));
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ─── 실행 ─────────────────────────────────────────────────────────────
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
// 제안 항목 실행 → 런처 입력창에 프리픽스 삽입
|
||||
// LauncherWindow가 SetInputText를 지원하므로 Application 수준에서 접근
|
||||
if (item.Data is string prefix && !string.IsNullOrEmpty(prefix))
|
||||
{
|
||||
var launcher = System.Windows.Application.Current?.Windows
|
||||
.OfType<Views.LauncherWindow>()
|
||||
.FirstOrDefault();
|
||||
launcher?.SetInputText(prefix + " ");
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ─── 내부 유틸 ────────────────────────────────────────────────────────
|
||||
private static (string ProcName, string AppName, ContextSuggestion[] Suggestions) GetContextInfo()
|
||||
{
|
||||
try
|
||||
{
|
||||
var hwnd = GetForegroundWindow();
|
||||
if (hwnd == IntPtr.Zero) return ("", "", DefaultSuggestions);
|
||||
|
||||
GetWindowThreadProcessId(hwnd, out var pid);
|
||||
if (pid == 0) return ("", "", DefaultSuggestions);
|
||||
|
||||
var proc = Process.GetProcessById((int)pid);
|
||||
var pName = proc.ProcessName.ToLowerInvariant();
|
||||
|
||||
foreach (var kv in ContextMap)
|
||||
{
|
||||
if (kv.Key.Any(k => pName.Contains(k)))
|
||||
return (pName, kv.Value.AppName, kv.Value.Suggestions);
|
||||
}
|
||||
|
||||
return (pName, pName, DefaultSuggestions);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return ("", "", DefaultSuggestions);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 제안 레코드 ─────────────────────────────────────────────────────
|
||||
private record ContextSuggestion(string Title, string Subtitle, string Prefix, string Symbol);
|
||||
|
||||
// ─── 키 비교기 ───────────────────────────────────────────────────────
|
||||
private class StringArrayEqualityComparer : IEqualityComparer<string[]>
|
||||
{
|
||||
public bool Equals(string[]? x, string[]? y) =>
|
||||
x != null && y != null && x.SequenceEqual(y);
|
||||
public int GetHashCode(string[] obj) =>
|
||||
obj.Aggregate(17, (h, s) => h * 31 + s.GetHashCode());
|
||||
}
|
||||
}
|
||||
340
src/AxCopilot/Handlers/CronHandler.cs
Normal file
340
src/AxCopilot/Handlers/CronHandler.cs
Normal file
@@ -0,0 +1,340 @@
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L11-3: Cron 표현식 설명기 핸들러. "cron" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: cron * * * * * → 매 분 실행 (설명 + 다음 5회 실행 시간)
|
||||
/// cron 0 9 * * 1-5 → 평일 오전 9시 실행
|
||||
/// cron 0 0 1 * * → 매월 1일 자정
|
||||
/// cron 30 18 * * 5 → 매주 금요일 오후 6시 30분
|
||||
/// cron @daily → 매일 자정 (특수 키워드)
|
||||
/// cron @hourly → 매시간
|
||||
/// Enter → 표현식을 클립보드에 복사.
|
||||
///
|
||||
/// 지원 형식: 분(0-59) 시(0-23) 일(1-31) 월(1-12) 요일(0-7)
|
||||
/// </summary>
|
||||
public class CronHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "cron";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Cron",
|
||||
"Cron 표현식 설명기 — 다음 실행 시간 · 한국어 설명",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
// 특수 키워드
|
||||
private static readonly Dictionary<string, string> SpecialKeywords = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["@yearly"] = "0 0 1 1 *",
|
||||
["@annually"] = "0 0 1 1 *",
|
||||
["@monthly"] = "0 0 1 * *",
|
||||
["@weekly"] = "0 0 * * 0",
|
||||
["@daily"] = "0 0 * * *",
|
||||
["@midnight"] = "0 0 * * *",
|
||||
["@hourly"] = "0 * * * *",
|
||||
};
|
||||
|
||||
// 자주 쓰는 예제
|
||||
private static readonly (string Expr, string Desc)[] CommonExamples =
|
||||
[
|
||||
("* * * * *", "매 분 실행"),
|
||||
("0 * * * *", "매 시간 정각"),
|
||||
("0 9 * * *", "매일 오전 9시"),
|
||||
("0 9 * * 1-5", "평일 오전 9시"),
|
||||
("0 0 * * *", "매일 자정"),
|
||||
("0 0 1 * *", "매월 1일 자정"),
|
||||
("0 0 1 1 *", "매년 1월 1일"),
|
||||
("*/5 * * * *", "5분마다"),
|
||||
("0 9,18 * * 1-5", "평일 오전 9시·오후 6시"),
|
||||
("30 23 * * 5", "매주 금요일 오후 11시 30분"),
|
||||
];
|
||||
|
||||
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("Cron 표현식 설명기",
|
||||
"예: cron 0 9 * * 1-5 / cron @daily / cron */15 * * * *",
|
||||
null, null, Symbol: "\uE823"));
|
||||
foreach (var (expr, desc) in CommonExamples.Take(6))
|
||||
items.Add(new LauncherItem(expr, desc, null, ("copy", expr), Symbol: "\uE823"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 특수 키워드 처리
|
||||
var expr_ = q;
|
||||
if (SpecialKeywords.TryGetValue(q, out var expanded))
|
||||
expr_ = expanded;
|
||||
|
||||
if (!TryParseCron(expr_, out var cron))
|
||||
{
|
||||
items.Add(new LauncherItem("파싱 실패",
|
||||
$"'{q}'은 유효한 cron 표현식이 아닙니다. 형식: 분 시 일 월 요일",
|
||||
null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 한국어 설명
|
||||
var description = Describe(cron);
|
||||
items.Add(new LauncherItem(
|
||||
description,
|
||||
$"표현식: {expr_} · Enter 복사",
|
||||
null, ("copy", expr_), Symbol: "\uE823"));
|
||||
|
||||
// 다음 5회 실행 시간
|
||||
var nextRuns = GetNextRuns(cron, DateTime.Now, 5);
|
||||
if (nextRuns.Count > 0)
|
||||
{
|
||||
items.Add(new LauncherItem("─ 다음 실행 시간 ─", "", null, null, Symbol: "\uE823"));
|
||||
foreach (var run in nextRuns)
|
||||
items.Add(new LauncherItem(
|
||||
run.ToString("yyyy-MM-dd HH:mm (ddd)"),
|
||||
GetRelativeTime(run),
|
||||
null, ("copy", run.ToString("yyyy-MM-dd HH:mm:ss")),
|
||||
Symbol: "\uE823"));
|
||||
}
|
||||
|
||||
// 필드별 설명
|
||||
items.Add(new LauncherItem("─ 필드 분석 ─", "", null, null, Symbol: "\uE823"));
|
||||
items.Add(new LauncherItem("분", DescribeField(cron.Minute, 0, 59, "분"), null, null, Symbol: "\uE823"));
|
||||
items.Add(new LauncherItem("시", DescribeField(cron.Hour, 0, 23, "시"), null, null, Symbol: "\uE823"));
|
||||
items.Add(new LauncherItem("일", DescribeField(cron.Day, 1, 31, "일"), null, null, Symbol: "\uE823"));
|
||||
items.Add(new LauncherItem("월", DescribeField(cron.Month, 1, 12, "월"), null, null, Symbol: "\uE823"));
|
||||
items.Add(new LauncherItem("요일", DescribeField(cron.DayOfWeek, 0, 7, "요일"), null, null, Symbol: "\uE823"));
|
||||
|
||||
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("Cron", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── Cron 파서 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private record CronExpr(string Minute, string Hour, string Day, string Month, string DayOfWeek);
|
||||
|
||||
private static bool TryParseCron(string expr, out CronExpr result)
|
||||
{
|
||||
result = new CronExpr("*", "*", "*", "*", "*");
|
||||
var parts = expr.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length != 5) return false;
|
||||
|
||||
// 각 필드 유효성 검사
|
||||
if (!IsValidCronField(parts[0], 0, 59)) return false;
|
||||
if (!IsValidCronField(parts[1], 0, 23)) return false;
|
||||
if (!IsValidCronField(parts[2], 1, 31)) return false;
|
||||
if (!IsValidCronField(parts[3], 1, 12)) return false;
|
||||
if (!IsValidCronField(parts[4], 0, 7)) return false;
|
||||
|
||||
result = new CronExpr(parts[0], parts[1], parts[2], parts[3], parts[4]);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsValidCronField(string field, int min, int max)
|
||||
{
|
||||
if (field == "*") return true;
|
||||
foreach (var part in field.Split(','))
|
||||
{
|
||||
if (part.Contains('/'))
|
||||
{
|
||||
var sp = part.Split('/');
|
||||
if (sp.Length != 2) return false;
|
||||
if (sp[0] != "*" && !int.TryParse(sp[0], out _)) return false;
|
||||
if (!int.TryParse(sp[1], out var step) || step < 1) return false;
|
||||
}
|
||||
else if (part.Contains('-'))
|
||||
{
|
||||
var sp = part.Split('-');
|
||||
if (sp.Length != 2) return false;
|
||||
if (!int.TryParse(sp[0], out var a) || !int.TryParse(sp[1], out var b)) return false;
|
||||
if (a < min || b > max || a > b) return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!int.TryParse(part, out var v)) return false;
|
||||
if (v < min || v > max) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── 다음 실행 시간 계산 ────────────────────────────────────────────────────
|
||||
|
||||
private static List<DateTime> GetNextRuns(CronExpr cron, DateTime from, int count)
|
||||
{
|
||||
var results = new List<DateTime>();
|
||||
// 다음 분부터 시작
|
||||
var current = from.AddSeconds(-from.Second).AddMinutes(1);
|
||||
var limit = from.AddDays(366); // 최대 1년 탐색
|
||||
|
||||
while (results.Count < count && current < limit)
|
||||
{
|
||||
if (MatchesMonth(cron.Month, current.Month) &&
|
||||
MatchesDay(cron.Day, current.Day) &&
|
||||
MatchesDayOfWeek(cron.DayOfWeek, (int)current.DayOfWeek) &&
|
||||
MatchesHour(cron.Hour, current.Hour) &&
|
||||
MatchesMinute(cron.Minute, current.Minute))
|
||||
{
|
||||
results.Add(current);
|
||||
current = current.AddMinutes(1);
|
||||
}
|
||||
else
|
||||
{
|
||||
current = AdvanceCron(cron, current);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private static DateTime AdvanceCron(CronExpr cron, DateTime dt)
|
||||
{
|
||||
// 빠른 스킵: 분 단위로 증가
|
||||
return dt.AddMinutes(1);
|
||||
}
|
||||
|
||||
private static bool MatchesField(string field, int value, int min, int max)
|
||||
{
|
||||
if (field == "*") return true;
|
||||
foreach (var part in field.Split(','))
|
||||
{
|
||||
if (part.Contains('/'))
|
||||
{
|
||||
var sp = part.Split('/');
|
||||
var step = int.Parse(sp[1]);
|
||||
var start = sp[0] == "*" ? min : int.Parse(sp[0]);
|
||||
for (var v = start; v <= max; v += step)
|
||||
if (v == value) return true;
|
||||
}
|
||||
else if (part.Contains('-'))
|
||||
{
|
||||
var sp = part.Split('-');
|
||||
var a = int.Parse(sp[0]);
|
||||
var b = int.Parse(sp[1]);
|
||||
if (value >= a && value <= b) return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (int.Parse(part) == value) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool MatchesMinute(string f, int v) => MatchesField(f, v, 0, 59);
|
||||
private static bool MatchesHour(string f, int v) => MatchesField(f, v, 0, 23);
|
||||
private static bool MatchesDay(string f, int v) => MatchesField(f, v, 1, 31);
|
||||
private static bool MatchesMonth(string f, int v) => MatchesField(f, v, 1, 12);
|
||||
private static bool MatchesDayOfWeek(string f, int v)
|
||||
{
|
||||
// 0과 7 모두 일요일
|
||||
if (f == "*") return true;
|
||||
return MatchesField(f, v, 0, 7) || (v == 0 && MatchesField(f, 7, 0, 7));
|
||||
}
|
||||
|
||||
// ── 한국어 설명 ───────────────────────────────────────────────────────────
|
||||
|
||||
private static string Describe(CronExpr c)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
// 분
|
||||
var minDesc = c.Minute == "*" ? "매 분" : DescribeField(c.Minute, 0, 59, "분");
|
||||
// 시
|
||||
var hourDesc = c.Hour == "*" ? "매 시간" : DescribeField(c.Hour, 0, 23, "시");
|
||||
// 일
|
||||
var dayDesc = c.Day == "*" ? "" : DescribeField(c.Day, 1, 31, "일");
|
||||
// 월
|
||||
var monDesc = c.Month == "*" ? "" : DescribeField(c.Month, 1, 12, "월");
|
||||
// 요일
|
||||
var dowDesc = c.DayOfWeek == "*" ? "" : DescribeWeekday(c.DayOfWeek);
|
||||
|
||||
if (c.Minute == "0" && c.Hour == "0" && c.Day == "*" && c.Month == "*" && c.DayOfWeek == "*")
|
||||
return "매일 자정(00:00)";
|
||||
if (c.Minute == "0" && c.Hour == "*")
|
||||
return "매 시간 정각";
|
||||
if (c.Minute == "*" && c.Hour == "*" && c.Day == "*" && c.Month == "*" && c.DayOfWeek == "*")
|
||||
return "매 분 실행";
|
||||
|
||||
// 조합
|
||||
var sb = new System.Text.StringBuilder();
|
||||
if (!string.IsNullOrEmpty(monDesc)) { sb.Append(monDesc); sb.Append(' '); }
|
||||
if (!string.IsNullOrEmpty(dayDesc)) { sb.Append(dayDesc); sb.Append(' '); }
|
||||
if (!string.IsNullOrEmpty(dowDesc)) { sb.Append(dowDesc); sb.Append(' '); }
|
||||
sb.Append(hourDesc);
|
||||
sb.Append(' ');
|
||||
sb.Append(minDesc);
|
||||
sb.Append(" 실행");
|
||||
return sb.ToString().Trim();
|
||||
}
|
||||
|
||||
private static string DescribeField(string field, int min, int max, string unit)
|
||||
{
|
||||
if (field == "*") return $"모든 {unit}";
|
||||
if (field.StartsWith("*/"))
|
||||
{
|
||||
var step = field[2..];
|
||||
return $"{step}{unit}마다";
|
||||
}
|
||||
if (field.Contains('-'))
|
||||
{
|
||||
var sp = field.Split('-');
|
||||
return $"{sp[0]}~{sp[1]}{unit}";
|
||||
}
|
||||
if (field.Contains(','))
|
||||
{
|
||||
return string.Join(",", field.Split(',')) + unit;
|
||||
}
|
||||
return $"{field}{unit}";
|
||||
}
|
||||
|
||||
private static string DescribeWeekday(string field)
|
||||
{
|
||||
string[] days = ["일", "월", "화", "수", "목", "금", "토", "일"];
|
||||
if (field.Contains('-'))
|
||||
{
|
||||
var sp = field.Split('-');
|
||||
if (int.TryParse(sp[0], out var a) && int.TryParse(sp[1], out var b))
|
||||
return $"{days[a]}~{days[Math.Min(b, 7)]}요일";
|
||||
}
|
||||
if (field.Contains(','))
|
||||
{
|
||||
var parts = field.Split(',')
|
||||
.Where(p => int.TryParse(p, out _))
|
||||
.Select(p => days[int.Parse(p) % 8]);
|
||||
return string.Join(",", parts) + "요일";
|
||||
}
|
||||
if (int.TryParse(field, out var d))
|
||||
return days[d % 8] + "요일";
|
||||
return field;
|
||||
}
|
||||
|
||||
private static string GetRelativeTime(DateTime dt)
|
||||
{
|
||||
var diff = dt - DateTime.Now;
|
||||
if (diff.TotalMinutes < 1) return "1분 이내";
|
||||
if (diff.TotalHours < 1) return $"{(int)diff.TotalMinutes}분 후";
|
||||
if (diff.TotalDays < 1) return $"{(int)diff.TotalHours}시간 {diff.Minutes}분 후";
|
||||
if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}일 {diff.Hours}시간 후";
|
||||
return $"{(int)diff.TotalDays}일 후";
|
||||
}
|
||||
}
|
||||
338
src/AxCopilot/Handlers/CsvHandler.cs
Normal file
338
src/AxCopilot/Handlers/CsvHandler.cs
Normal file
@@ -0,0 +1,338 @@
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L11-1: CSV 뷰어·파서 핸들러. "csv" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: csv → 클립보드 CSV 파싱 (헤더·행수·컬럼수)
|
||||
/// csv col 2 → 2번째 컬럼 값 목록 추출
|
||||
/// csv row 3 → 3번째 행 출력
|
||||
/// csv stats → 숫자 컬럼 합계·평균·최대·최소
|
||||
/// csv head → 헤더 컬럼명 목록
|
||||
/// csv tsv → CSV → TSV(탭 구분) 변환
|
||||
/// Enter → 결과를 클립보드에 복사.
|
||||
/// </summary>
|
||||
public class CsvHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "csv";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"CSV",
|
||||
"CSV 뷰어·파서 — 컬럼 추출 · 통계 · 형식 변환",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
var clip = GetClipboard();
|
||||
var hasData = !string.IsNullOrWhiteSpace(clip) && LooksCsv(clip);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
if (hasData)
|
||||
{
|
||||
items.AddRange(BuildOverviewItems(clip));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem("CSV 뷰어", "CSV를 클립보드에 복사 후 'csv' 입력",
|
||||
null, null, Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("csv col 2", "2번째 컬럼 추출", null, null, Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("csv stats", "숫자 컬럼 통계", null, null, Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("csv tsv", "CSV → TSV 변환", null, null, Symbol: "\uE8A5"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
if (!hasData)
|
||||
{
|
||||
items.Add(new LauncherItem("클립보드에 CSV 없음",
|
||||
"CSV 형식 데이터를 클립보드에 복사 후 시도하세요", null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var rows = ParseCsv(clip);
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("파싱 실패", "유효한 CSV가 아닙니다", null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
switch (sub)
|
||||
{
|
||||
case "col":
|
||||
case "column":
|
||||
{
|
||||
var colIdx = parts.Length > 1 && int.TryParse(parts[1], out var n) ? n - 1 : 0;
|
||||
items.AddRange(ExtractColumn(rows, colIdx));
|
||||
break;
|
||||
}
|
||||
case "row":
|
||||
{
|
||||
var rowIdx = parts.Length > 1 && int.TryParse(parts[1], out var n) ? n - 1 : 0;
|
||||
items.AddRange(ExtractRow(rows, rowIdx));
|
||||
break;
|
||||
}
|
||||
case "stats":
|
||||
case "stat":
|
||||
{
|
||||
items.AddRange(BuildStatsItems(rows));
|
||||
break;
|
||||
}
|
||||
case "head":
|
||||
case "header":
|
||||
{
|
||||
if (rows.Count > 0)
|
||||
{
|
||||
var header = rows[0];
|
||||
var all = string.Join(", ", header);
|
||||
items.Add(new LauncherItem($"헤더 {header.Count}개 컬럼", all, null, ("copy", all), Symbol: "\uE8A5"));
|
||||
for (var i = 0; i < header.Count; i++)
|
||||
items.Add(new LauncherItem($"[{i+1}] {header[i]}", $"컬럼 {i+1}", null, ("copy", header[i]), Symbol: "\uE8A5"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tsv":
|
||||
{
|
||||
var tsv = ConvertToTsv(rows);
|
||||
items.Add(new LauncherItem(
|
||||
$"TSV 변환 {rows.Count}행",
|
||||
"탭 구분자 · Enter 복사",
|
||||
null, ("copy", tsv), Symbol: "\uE8A5"));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
items.AddRange(BuildOverviewItems(clip));
|
||||
break;
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is ("copy", string text))
|
||||
{
|
||||
try
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(
|
||||
() => Clipboard.SetText(text));
|
||||
NotificationService.Notify("CSV", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 빌더 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static IEnumerable<LauncherItem> BuildOverviewItems(string csv)
|
||||
{
|
||||
var rows = ParseCsv(csv);
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
yield return new LauncherItem("파싱 실패", "유효한 CSV 데이터가 아닙니다", null, null, Symbol: "\uE783");
|
||||
yield break;
|
||||
}
|
||||
|
||||
var headerRow = rows[0];
|
||||
var dataRows = rows.Count > 1 ? rows.Count - 1 : 0;
|
||||
var colCount = headerRow.Count;
|
||||
|
||||
yield return new LauncherItem(
|
||||
$"CSV {dataRows}행 × {colCount}열",
|
||||
$"헤더: {string.Join(", ", headerRow.Take(5))}{(colCount > 5 ? " …" : "")}",
|
||||
null,
|
||||
("copy", csv),
|
||||
Symbol: "\uE8A5");
|
||||
|
||||
yield return new LauncherItem("컬럼 수", $"{colCount}개", null, null, Symbol: "\uE8A5");
|
||||
yield return new LauncherItem("데이터 행수", $"{dataRows}행", null, null, Symbol: "\uE8A5");
|
||||
yield return new LauncherItem("헤더", string.Join(" | ", headerRow.Take(8)), null,
|
||||
("copy", string.Join(",", headerRow)), Symbol: "\uE8A5");
|
||||
|
||||
// 첫 데이터 행 미리보기
|
||||
if (rows.Count > 1)
|
||||
{
|
||||
var first = rows[1];
|
||||
yield return new LauncherItem(
|
||||
"첫 번째 행",
|
||||
string.Join(" | ", first.Take(6)),
|
||||
null,
|
||||
("copy", string.Join(",", first)),
|
||||
Symbol: "\uE8A5");
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<LauncherItem> ExtractColumn(List<List<string>> rows, int colIdx)
|
||||
{
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
yield return new LauncherItem("데이터 없음", "", null, null, Symbol: "\uE783");
|
||||
yield break;
|
||||
}
|
||||
|
||||
var maxCol = rows.Max(r => r.Count) - 1;
|
||||
colIdx = Math.Clamp(colIdx, 0, maxCol);
|
||||
|
||||
var header = rows[0].Count > colIdx ? rows[0][colIdx] : $"컬럼{colIdx+1}";
|
||||
var values = rows.Skip(1).Where(r => r.Count > colIdx).Select(r => r[colIdx]).ToList();
|
||||
var allText = string.Join("\n", values);
|
||||
|
||||
yield return new LauncherItem(
|
||||
$"[{colIdx+1}] {header} ({values.Count}개 값)",
|
||||
"전체 복사: Enter",
|
||||
null, ("copy", allText), Symbol: "\uE8A5");
|
||||
|
||||
foreach (var v in values.Take(15))
|
||||
yield return new LauncherItem(v, $"컬럼: {header}", null, ("copy", v), Symbol: "\uE8A5");
|
||||
}
|
||||
|
||||
private static IEnumerable<LauncherItem> ExtractRow(List<List<string>> rows, int rowIdx)
|
||||
{
|
||||
rowIdx = Math.Clamp(rowIdx, 0, rows.Count - 1);
|
||||
var row = rows[rowIdx];
|
||||
var header = rows[0];
|
||||
var allText = string.Join(",", row);
|
||||
|
||||
yield return new LauncherItem(
|
||||
$"행 {rowIdx+1} ({row.Count}개 값)",
|
||||
allText.Length > 80 ? allText[..80] + "…" : allText,
|
||||
null, ("copy", allText), Symbol: "\uE8A5");
|
||||
|
||||
for (var i = 0; i < row.Count; i++)
|
||||
{
|
||||
var colName = header.Count > i ? header[i] : $"컬럼{i+1}";
|
||||
yield return new LauncherItem($"[{colName}]", row[i], null, ("copy", row[i]), Symbol: "\uE8A5");
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<LauncherItem> BuildStatsItems(List<List<string>> rows)
|
||||
{
|
||||
if (rows.Count < 2)
|
||||
{
|
||||
yield return new LauncherItem("데이터 없음", "헤더 포함 2행 이상 필요", null, null, Symbol: "\uE783");
|
||||
yield break;
|
||||
}
|
||||
|
||||
var header = rows[0];
|
||||
var data = rows.Skip(1).ToList();
|
||||
|
||||
for (var col = 0; col < header.Count; col++)
|
||||
{
|
||||
var colName = header.Count > col ? header[col] : $"컬럼{col+1}";
|
||||
var nums = data
|
||||
.Where(r => r.Count > col)
|
||||
.Select(r => r[col])
|
||||
.Where(v => double.TryParse(v, System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out _))
|
||||
.Select(v => double.Parse(v, System.Globalization.CultureInfo.InvariantCulture))
|
||||
.ToList();
|
||||
|
||||
if (nums.Count == 0) continue;
|
||||
|
||||
var sum = nums.Sum();
|
||||
var avg = nums.Average();
|
||||
var min = nums.Min();
|
||||
var max = nums.Max();
|
||||
|
||||
yield return new LauncherItem(
|
||||
$"[{colName}] 합계: {sum:N2}",
|
||||
$"평균: {avg:N2} 최소: {min:N2} 최대: {max:N2} ({nums.Count}개)",
|
||||
null,
|
||||
("copy", $"{colName}: sum={sum:N2} avg={avg:N2} min={min:N2} max={max:N2}"),
|
||||
Symbol: "\uE8A5");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 변환 헬퍼 ────────────────────────────────────────────────────────────
|
||||
|
||||
private static string ConvertToTsv(List<List<string>> rows)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
sb.AppendLine(string.Join("\t", row.Select(EscapeTsv)));
|
||||
}
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static string EscapeTsv(string v) => v.Replace("\t", " ").Replace("\r", "").Replace("\n", " ");
|
||||
|
||||
// ── CSV 파서 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static List<List<string>> ParseCsv(string text)
|
||||
{
|
||||
var result = new List<List<string>>();
|
||||
// 구분자 자동 감지 (탭 또는 쉼표)
|
||||
var firstLine = text.Split('\n')[0];
|
||||
var delimiter = firstLine.Count(c => c == '\t') > firstLine.Count(c => c == ',') ? '\t' : ',';
|
||||
|
||||
using var reader = new System.IO.StringReader(text);
|
||||
string? line;
|
||||
while ((line = reader.ReadLine()) != null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
result.Add(ParseCsvLine(line, delimiter));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<string> ParseCsvLine(string line, char delim)
|
||||
{
|
||||
var fields = new List<string>();
|
||||
var sb = new StringBuilder();
|
||||
var inQuote = false;
|
||||
|
||||
for (var i = 0; i < line.Length; i++)
|
||||
{
|
||||
var c = line[i];
|
||||
if (inQuote)
|
||||
{
|
||||
if (c == '"')
|
||||
{
|
||||
if (i + 1 < line.Length && line[i + 1] == '"')
|
||||
{ sb.Append('"'); i++; }
|
||||
else
|
||||
{ inQuote = false; }
|
||||
}
|
||||
else { sb.Append(c); }
|
||||
}
|
||||
else
|
||||
{
|
||||
if (c == '"') { inQuote = true; }
|
||||
else if (c == delim){ fields.Add(sb.ToString()); sb.Clear(); }
|
||||
else { sb.Append(c); }
|
||||
}
|
||||
}
|
||||
fields.Add(sb.ToString());
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static bool LooksCsv(string text)
|
||||
{
|
||||
var firstLine = text.Split('\n').FirstOrDefault(l => !string.IsNullOrWhiteSpace(l)) ?? "";
|
||||
return firstLine.Contains(',') || firstLine.Contains('\t');
|
||||
}
|
||||
|
||||
private static string GetClipboard()
|
||||
{
|
||||
try
|
||||
{
|
||||
return System.Windows.Application.Current.Dispatcher.Invoke(
|
||||
() => Clipboard.ContainsText() ? Clipboard.GetText() : "");
|
||||
}
|
||||
catch { return ""; }
|
||||
}
|
||||
}
|
||||
213
src/AxCopilot/Handlers/CurrencyHandler.cs
Normal file
213
src/AxCopilot/Handlers/CurrencyHandler.cs
Normal file
@@ -0,0 +1,213 @@
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L15-2: 환율 변환기 핸들러. "currency" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: currency → 주요 통화 기준환율 목록
|
||||
/// currency 100 usd → 100 USD → KRW
|
||||
/// currency 100 usd eur → 100 USD → EUR
|
||||
/// currency 50000 krw usd → 50,000 KRW → USD
|
||||
/// currency rates → 전체 환율표
|
||||
/// Enter → 결과를 클립보드에 복사.
|
||||
///
|
||||
/// 내장 기준환율 사용 (사내 모드). 사외 모드에서는 동일하게 동작.
|
||||
/// </summary>
|
||||
public class CurrencyHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "currency";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Currency",
|
||||
"환율 변환기 — KRW·USD·EUR·JPY·CNY 등 주요 통화 변환",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
// 내장 기준환율 (KRW 기준, 2025년 1분기 평균 참조값)
|
||||
private static readonly Dictionary<string, (string Name, string Symbol, double RateToKrw)> Rates =
|
||||
new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["KRW"] = ("한국 원", "₩", 1.0),
|
||||
["USD"] = ("미국 달러", "$", 1370.0),
|
||||
["EUR"] = ("유로", "€", 1480.0),
|
||||
["JPY"] = ("일본 엔", "¥", 9.2),
|
||||
["CNY"] = ("중국 위안", "¥", 189.0),
|
||||
["GBP"] = ("영국 파운드", "£", 1730.0),
|
||||
["HKD"] = ("홍콩 달러", "HK$",175.0),
|
||||
["TWD"] = ("대만 달러", "NT$", 42.0),
|
||||
["SGD"] = ("싱가포르 달러", "S$", 1020.0),
|
||||
["AUD"] = ("호주 달러", "A$", 870.0),
|
||||
["CAD"] = ("캐나다 달러", "C$", 995.0),
|
||||
["CHF"] = ("스위스 프랑", "Fr", 1540.0),
|
||||
["MYR"] = ("말레이시아 링깃", "RM", 310.0),
|
||||
["THB"] = ("태국 바트", "฿", 38.5),
|
||||
["VND"] = ("베트남 동", "₫", 0.054),
|
||||
};
|
||||
|
||||
// 통화 별칭
|
||||
private static readonly Dictionary<string, string> Aliases =
|
||||
new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["달러"] = "USD", ["엔"] = "JPY", ["위안"] = "CNY", ["유로"] = "EUR",
|
||||
["파운드"] = "GBP", ["원"] = "KRW", ["엔화"] = "JPY", ["달러화"] = "USD",
|
||||
["프랑"] = "CHF", ["바트"] = "THB", ["동"] = "VND", ["링깃"] = "MYR",
|
||||
};
|
||||
|
||||
// 주요 통화 표시 순서
|
||||
private static readonly string[] MainCurrencies = ["USD", "EUR", "JPY", "CNY", "GBP", "HKD", "SGD", "AUD"];
|
||||
|
||||
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("환율 변환기",
|
||||
"예: currency 100 usd / currency 50000 krw eur / currency rates",
|
||||
null, null, Symbol: "\uE8C7"));
|
||||
items.Add(new LauncherItem("── 주요 통화 (KRW 기준) ──", "", null, null, Symbol: "\uE8C7"));
|
||||
foreach (var code in MainCurrencies)
|
||||
{
|
||||
if (!Rates.TryGetValue(code, out var info)) continue;
|
||||
items.Add(new LauncherItem(
|
||||
$"1 {code} = {info.RateToKrw:N0} KRW",
|
||||
$"{info.Name} ({info.Symbol})",
|
||||
null, ("copy", $"1 {code} = {info.RateToKrw:N0} KRW"),
|
||||
Symbol: "\uE8C7"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// rates → 전체 환율표
|
||||
if (parts[0].Equals("rates", StringComparison.OrdinalIgnoreCase) ||
|
||||
parts[0].Equals("list", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
items.Add(new LauncherItem($"전체 환율표 ({Rates.Count}개 통화)", "KRW 기준 내장 환율",
|
||||
null, null, Symbol: "\uE8C7"));
|
||||
foreach (var (code, info) in Rates.OrderBy(r => r.Key))
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"1 {code} = {info.RateToKrw:N2} KRW",
|
||||
$"{info.Symbol} {info.Name}",
|
||||
null, ("copy", $"1 {code} = {info.RateToKrw:N2} KRW"),
|
||||
Symbol: "\uE8C7"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 금액 파싱
|
||||
if (!TryParseAmount(parts[0], out var amount))
|
||||
{
|
||||
items.Add(new LauncherItem("금액 형식 오류",
|
||||
"예: currency 100 usd", null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 통화 코드 파싱
|
||||
var fromCode = parts.Length >= 2 ? ResolveCode(parts[1]) : "KRW";
|
||||
var toCode = parts.Length >= 3 ? ResolveCode(parts[2]) : null;
|
||||
|
||||
if (fromCode == null)
|
||||
{
|
||||
items.Add(new LauncherItem("알 수 없는 통화",
|
||||
$"'{parts[1]}' 코드를 찾을 수 없습니다. 예: USD EUR JPY CNY",
|
||||
null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
if (!Rates.TryGetValue(fromCode, out var fromInfo))
|
||||
{
|
||||
items.Add(new LauncherItem("지원하지 않는 통화", fromCode, null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var amountKrw = amount * fromInfo.RateToKrw;
|
||||
|
||||
// 특정 대상 통화 지정
|
||||
if (toCode != null)
|
||||
{
|
||||
if (!Rates.TryGetValue(toCode, out var toInfo))
|
||||
{
|
||||
items.Add(new LauncherItem("지원하지 않는 대상 통화", toCode, null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var converted = amountKrw / toInfo.RateToKrw;
|
||||
var label = $"{FormatAmount(amount, fromCode)} = {FormatAmount(converted, toCode)}";
|
||||
items.Add(new LauncherItem(label,
|
||||
$"{fromInfo.Name} → {toInfo.Name} (Enter 복사)",
|
||||
null, ("copy", label), Symbol: "\uE8C7"));
|
||||
items.Add(new LauncherItem($"{FormatAmount(converted, toCode)}", $"변환 결과",
|
||||
null, ("copy", $"{converted:N2}"), Symbol: "\uE8C7"));
|
||||
}
|
||||
else
|
||||
{
|
||||
// 주요 통화들로 일괄 변환
|
||||
items.Add(new LauncherItem(
|
||||
$"{FormatAmount(amount, fromCode)} 변환 결과",
|
||||
"주요 통화 기준 (내장 환율)",
|
||||
null, null, Symbol: "\uE8C7"));
|
||||
|
||||
var targets = fromCode == "KRW" ? MainCurrencies : (new[] { "KRW" }).Concat(MainCurrencies.Where(c => c != fromCode)).ToArray();
|
||||
foreach (var tc in targets)
|
||||
{
|
||||
if (!Rates.TryGetValue(tc, out var tInfo)) continue;
|
||||
var conv = amountKrw / tInfo.RateToKrw;
|
||||
var label = $"{FormatAmount(conv, tc)}";
|
||||
items.Add(new LauncherItem(label,
|
||||
$"{tInfo.Name} ({tInfo.Symbol})",
|
||||
null, ("copy", label), Symbol: "\uE8C7"));
|
||||
}
|
||||
}
|
||||
|
||||
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("Currency", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static string? ResolveCode(string input)
|
||||
{
|
||||
if (Rates.ContainsKey(input)) return input.ToUpperInvariant();
|
||||
if (Aliases.TryGetValue(input, out var code)) return code;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryParseAmount(string s, out double result)
|
||||
{
|
||||
result = 0;
|
||||
s = s.Replace(",", "").Trim();
|
||||
return double.TryParse(s, System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out result);
|
||||
}
|
||||
|
||||
private static string FormatAmount(double amount, string code)
|
||||
{
|
||||
if (!Rates.TryGetValue(code, out var info)) return $"{amount:N2} {code}";
|
||||
// 소수점 자릿수: JPY/KRW/VND = 0, 기타 = 2
|
||||
var decimals = code is "JPY" or "KRW" or "VND" ? 0 : 2;
|
||||
var fmt = decimals == 0 ? $"{amount:N0}" : $"{amount:N2}";
|
||||
return $"{info.Symbol}{fmt} {code}";
|
||||
}
|
||||
}
|
||||
201
src/AxCopilot/Handlers/DictHandler.cs
Normal file
201
src/AxCopilot/Handlers/DictHandler.cs
Normal file
@@ -0,0 +1,201 @@
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L29-1: 오프라인 국어사전 핸들러. "dict" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: dict → 사용법 안내
|
||||
/// dict 부럽다 → "부럽다" 검색 (표제어·뜻풀이)
|
||||
/// dict en hello → 영한 사전 검색
|
||||
/// dict 유의어 돕다 → 유의어 검색
|
||||
/// Enter → 뜻풀이 클립보드 복사.
|
||||
/// 내장 데이터 기반 (인터넷 불필요). 혼동어·유의어·반의어 중심.
|
||||
/// </summary>
|
||||
public class DictHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "dict";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"국어사전",
|
||||
"오프라인 국어·영한 사전 — 뜻풀이·유의어·혼동어",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
// ─── 내장 국어 사전 데이터 ────────────────────────────────────────────────
|
||||
private sealed record KorEntry(string Word, string Pos, string Def, string? Synonym = null, string? Antonym = null, string? Note = null);
|
||||
|
||||
private static readonly KorEntry[] KorDict =
|
||||
[
|
||||
// ── 혼동어 ────────────────────────────────────────────────────────────
|
||||
new("가르치다", "동사", "지식·기술을 알려 주다", Note: "'가리키다'와 혼동 주의. 가리키다=손가락으로 방향을 보여 주다"),
|
||||
new("가리키다", "동사", "손가락 등으로 방향이나 대상을 보여 주다", Note: "'가르치다'와 혼동 주의"),
|
||||
new("다르다", "형용사", "비교 대상이 같지 아니하다", Synonym: "상이하다", Antonym: "같다", Note: "'틀리다'와 혼동 주의. 틀리다=옳지 않다"),
|
||||
new("틀리다", "동사", "사실이나 이치에 맞지 아니하다, 잘못되다", Synonym: "그르다", Antonym: "맞다", Note: "'다르다'와 혼동 주의"),
|
||||
new("바라다", "동사", "생각대로 되기를 기대하다", Note: "'바래다(햇볕에 색이 변하다)'와 혼동 주의"),
|
||||
new("바래다", "동사", "1. 햇볕·비에 색이 변하다 2. 어떤 곳까지 배웅하다"),
|
||||
new("늘이다", "동사", "길이를 길게 하다 (고무줄을 늘이다)", Note: "'늘리다'와 혼동 주의. 늘리다=수량·범위를 크게 하다"),
|
||||
new("늘리다", "동사", "수량·범위·세력을 크게 하다 (매출을 늘리다)", Note: "'늘이다'와 혼동 주의. 늘이다=길이를 길게 하다"),
|
||||
new("부치다", "동사", "1. 편지를 보내다 2. 부침개를 만들다 3. 세금을 매기다"),
|
||||
new("붙이다", "동사", "1. 떨어지지 않게 접합하다 2. 이름을 정하다 3. 조건을 달다"),
|
||||
new("맞추다", "동사", "1. 서로 대어 비교하다 2. 정답을 알아 내다", Note: "'맞히다(맞다의 사동사)'와 혼동 주의"),
|
||||
new("맞히다", "동사", "'맞다'의 사동사. 1. 적중시키다 2. 정답을 고르게 하다"),
|
||||
new("받치다", "동사", "1. 밑에서 떠받치다 2. 감정이 치밀다", Note: "'바치다(드리다)'와 혼동 주의"),
|
||||
new("바치다", "동사", "웃어른에게 물건이나 정성을 드리다"),
|
||||
new("반듯이", "부사", "모양이 기울거나 비뚤어지지 않게", Note: "'반드시'와 혼동 주의"),
|
||||
new("반드시", "부사", "틀림없이, 꼭", Synonym: "기필코, 필히"),
|
||||
new("어이없다", "형용사", "뜻밖에 기가 막혀 말이 나오지 않다", Note: "'어의(御醫)없다'가 아님"),
|
||||
new("설레다", "동사", "마음이 가라앉지 아니하고 들뜨다", Note: "'설레이다'는 비표준"),
|
||||
new("깨끗이", "부사", "깨끗하게", Note: "'-이'로 적음 (깨끗히 ×)"),
|
||||
new("일찍이", "부사", "일찍", Note: "'-이'로 적음 (일찍히 ×)"),
|
||||
|
||||
// ── 업무 용어 ─────────────────────────────────────────────────────────
|
||||
new("품의", "명사", "윗사람에게 어떤 일의 처리를 의논하여 품함"),
|
||||
new("결재", "명사", "윗사람이 아랫사람이 제출한 안건을 승인함", Note: "'결제(대금 지불)'와 혼동 주의"),
|
||||
new("결제", "명사", "대금을 주고받아 거래를 끝맺음", Note: "'결재(승인)'와 혼동 주의"),
|
||||
new("수립", "명사", "계획이나 정책을 세움", Synonym: "설정, 설립"),
|
||||
new("이관", "명사", "관할이나 담당을 다른 곳으로 옮김"),
|
||||
new("선결", "명사", "다른 것보다 앞서 먼저 해결함"),
|
||||
new("귀결", "명사", "어떤 결론에 다다름"),
|
||||
new("전결", "명사", "위임을 받아 대신 결재함"),
|
||||
new("대조", "명사", "둘 이상을 맞대어 비교함", Synonym: "비교"),
|
||||
new("취합", "명사", "여러 곳의 자료를 모아서 합침", Synonym: "수합"),
|
||||
new("회람", "명사", "문서를 여러 사람이 돌려 봄"),
|
||||
new("시달", "명사", "상급 기관이 하급 기관에 지시를 내림"),
|
||||
new("협조", "명사", "힘을 합하여 도움", Synonym: "협력"),
|
||||
new("검토", "명사", "자세히 살펴서 따져 봄", Synonym: "심사, 분석"),
|
||||
new("조율", "명사", "서로 다른 의견을 맞추어 조정함", Synonym: "조정"),
|
||||
new("후속", "명사", "앞에 한 일의 뒤를 이음"),
|
||||
new("단축", "명사", "시간·거리를 줄임", Antonym: "연장"),
|
||||
new("지양", "명사", "바람직하지 않은 것을 피하거나 그만둠", Note: "'지향(나아감)'과 혼동 주의"),
|
||||
new("지향", "명사", "목표를 향하여 나아감", Note: "'지양(피함)'과 혼동 주의"),
|
||||
|
||||
// ── 자주 헷갈리는 한자어 ──────────────────────────────────────────────
|
||||
new("이상", "명사", "1. 보통 정도를 넘어선 상태 (以上) 2. 생각하는 것 중 가장 완전한 상태 (理想)"),
|
||||
new("이하", "명사", "기준이 되는 정도에 미치지 못하는 상태", Antonym: "이상(以上)"),
|
||||
new("과반", "명사", "절반을 넘는 수"),
|
||||
new("역할", "명사", "자기가 마땅히 하여야 할 임무나 직책", Note: "'역활'은 비표준"),
|
||||
new("요지", "명사", "중요한 내용의 핵심", Synonym: "골자, 핵심"),
|
||||
new("명시", "명사", "분명하게 드러내어 보임"),
|
||||
new("고지", "명사", "1. 알려 줌 2. 높은 곳의 땅"),
|
||||
new("기한", "명사", "미리 정해 놓은 시한", Synonym: "시한, 마감"),
|
||||
];
|
||||
|
||||
// ─── 내장 영한 사전 데이터 (업무 필수 영어) ────────────────────────────────
|
||||
private sealed record EngEntry(string Word, string Pos, string Kor, string? Example = null);
|
||||
|
||||
private static readonly EngEntry[] EngDict =
|
||||
[
|
||||
new("agenda", "명사", "의제, 안건", "Let's move to the next agenda item."),
|
||||
new("align", "동사", "맞추다, 조율하다", "We need to align our goals."),
|
||||
new("approve", "동사", "승인하다", "The manager approved the budget."),
|
||||
new("assign", "동사", "배정하다, 할당하다", "I'll assign this task to you."),
|
||||
new("brief", "동사", "간략히 보고하다", "Can you brief me on the project?"),
|
||||
new("deadline", "명사", "마감일, 기한", "The deadline is next Friday."),
|
||||
new("delegate", "동사", "위임하다", "You should delegate this to the team."),
|
||||
new("deploy", "동사", "배포하다, 배치하다", "We'll deploy the update tonight."),
|
||||
new("escalate", "동사", "상부에 보고하다, 확대하다", "Let's escalate this issue."),
|
||||
new("feasible", "형용사","실현 가능한", "Is this plan feasible?"),
|
||||
new("follow-up", "명사", "후속 조치", "I'll send a follow-up email."),
|
||||
new("implement", "동사", "구현하다, 시행하다", "We need to implement this feature."),
|
||||
new("initiative", "명사", "주도적 행동, 계획", "This is a company-wide initiative."),
|
||||
new("milestone", "명사", "중요 시점, 이정표", "We've reached a major milestone."),
|
||||
new("onboard", "동사", "입사시키다, 적응시키다", "We'll onboard new hires next week."),
|
||||
new("optimize", "동사", "최적화하다", "Let's optimize the workflow."),
|
||||
new("prioritize", "동사", "우선순위를 정하다", "We need to prioritize these tasks."),
|
||||
new("provisional", "형용사","잠정적인, 임시의", "This is a provisional plan."),
|
||||
new("regarding", "전치사","~에 관하여", "Regarding your request..."),
|
||||
new("scope", "명사", "범위", "This is out of scope."),
|
||||
new("stakeholder", "명사", "이해관계자", "All stakeholders should attend."),
|
||||
new("sync", "동사", "동기화하다, 맞추다", "Let's sync on this topic."),
|
||||
new("tentative", "형용사","잠정적인, 시험적인", "This is a tentative schedule."),
|
||||
new("throughput", "명사", "처리량", "We need to improve throughput."),
|
||||
new("viable", "형용사","실행 가능한", "Is this a viable option?"),
|
||||
];
|
||||
|
||||
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("국어·영한 사전",
|
||||
$"국어 {KorDict.Length}개 · 영한 {EngDict.Length}개 · dict {{단어}} / dict en {{word}}",
|
||||
null, null, Symbol: "\uE82D"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ── 영한 사전 ─────────────────────────────────────────────────────────
|
||||
if (q.StartsWith("en ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var word = q[3..].Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(word))
|
||||
{
|
||||
items.Add(new LauncherItem("dict en {영단어}", "예: dict en deadline", null, null, Symbol: "\uE774"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var found = EngDict.Where(e => e.Word.Contains(word, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
if (found.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem($"'{word}' 검색 결과 없음",
|
||||
"내장 업무 영어 사전에 없는 단어입니다", null, null, Symbol: "\uE783"));
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var e in found)
|
||||
{
|
||||
var sub = $"[{e.Pos}] {e.Kor}";
|
||||
if (e.Example != null) sub += $" · {e.Example}";
|
||||
items.Add(new LauncherItem(e.Word, sub, null, ("copy", $"{e.Word}: {e.Kor}"), Symbol: "\uE774"));
|
||||
}
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ── 국어 사전 검색 ────────────────────────────────────────────────────
|
||||
var kw = q;
|
||||
var results = KorDict.Where(e =>
|
||||
e.Word.Contains(kw, StringComparison.OrdinalIgnoreCase) ||
|
||||
e.Def.Contains(kw, StringComparison.OrdinalIgnoreCase) ||
|
||||
(e.Synonym?.Contains(kw, StringComparison.OrdinalIgnoreCase) ?? false) ||
|
||||
(e.Note?.Contains(kw, StringComparison.OrdinalIgnoreCase) ?? false)).ToList();
|
||||
|
||||
if (results.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem($"'{kw}' 검색 결과 없음",
|
||||
"내장 국어 사전에 없는 단어입니다", null, null, Symbol: "\uE783"));
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var e in results.Take(12))
|
||||
{
|
||||
var title = $"{e.Word} [{e.Pos}]";
|
||||
var sub = e.Def;
|
||||
if (e.Synonym != null) sub += $" · 유의어: {e.Synonym}";
|
||||
if (e.Antonym != null) sub += $" · 반의어: {e.Antonym}";
|
||||
if (e.Note != null) sub += $" · 💡 {e.Note}";
|
||||
items.Add(new LauncherItem(title, sub, null, ("copy", $"{e.Word}: {e.Def}"), Symbol: "\uE82D"));
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is ("copy", string text))
|
||||
{
|
||||
try
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
|
||||
NotificationService.Notify("dict", "뜻풀이가 클립보드에 복사되었습니다.");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
375
src/AxCopilot/Handlers/DockerHandler.cs
Normal file
375
src/AxCopilot/Handlers/DockerHandler.cs
Normal file
@@ -0,0 +1,375 @@
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L16-2: Docker 컨테이너·이미지 조회 핸들러. "docker" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: docker → 실행 중 컨테이너 목록
|
||||
/// docker all → 모든 컨테이너 (중지 포함)
|
||||
/// docker images → 로컬 이미지 목록
|
||||
/// docker ps → 컨테이너 목록 (docker ps 동일)
|
||||
/// docker stop <name> → 컨테이너 중지
|
||||
/// docker start <name> → 컨테이너 시작
|
||||
/// docker logs <name> → 컨테이너 로그 (터미널)
|
||||
/// docker shell <name> → 컨테이너 shell 접속
|
||||
/// Enter → 명령 실행 또는 컨테이너 ID 복사.
|
||||
/// </summary>
|
||||
public class DockerHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "docker";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Docker",
|
||||
"Docker 컨테이너·이미지 조회 — 시작·중지·로그·쉘",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
if (!IsDockerAvailable())
|
||||
{
|
||||
items.Add(new LauncherItem("Docker를 찾을 수 없습니다",
|
||||
"Docker Desktop이 설치되어 있는지 확인하세요", null, null, Symbol: "\uE756"));
|
||||
items.Add(new LauncherItem("Docker Desktop 설치",
|
||||
"https://www.docker.com/products/docker-desktop",
|
||||
null, ("open_url", "https://www.docker.com/products/docker-desktop"), Symbol: "\uE756"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
var containers = GetContainers(running: true);
|
||||
items.Add(new LauncherItem(
|
||||
$"실행 중 컨테이너 {containers.Count}개",
|
||||
"docker ps / docker all / docker images",
|
||||
null, null, Symbol: "\uE756"));
|
||||
|
||||
if (containers.Count == 0)
|
||||
items.Add(new LauncherItem("실행 중인 컨테이너 없음", "docker all → 전체 목록", null, null, Symbol: "\uE946"));
|
||||
else
|
||||
foreach (var c in containers)
|
||||
items.Add(MakeContainerItem(c));
|
||||
|
||||
items.Add(new LauncherItem("docker images", "로컬 이미지 목록", null, ("sub", "images"), Symbol: "\uE756"));
|
||||
items.Add(new LauncherItem("docker all", "모든 컨테이너 목록", null, ("sub", "all"), Symbol: "\uE756"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
switch (sub)
|
||||
{
|
||||
case "all":
|
||||
case "ps":
|
||||
{
|
||||
var all = sub == "all";
|
||||
var containers = GetContainers(running: !all);
|
||||
items.Add(new LauncherItem(
|
||||
$"{(all ? "전체" : "실행 중")} 컨테이너 {containers.Count}개",
|
||||
"", null, null, Symbol: "\uE756"));
|
||||
foreach (var c in containers)
|
||||
items.Add(MakeContainerItem(c));
|
||||
if (containers.Count == 0)
|
||||
items.Add(new LauncherItem("컨테이너 없음", "", null, null, Symbol: "\uE946"));
|
||||
break;
|
||||
}
|
||||
|
||||
case "images":
|
||||
case "image":
|
||||
case "img":
|
||||
{
|
||||
var images = GetImages();
|
||||
items.Add(new LauncherItem($"로컬 이미지 {images.Count}개", "", null, null, Symbol: "\uE756"));
|
||||
foreach (var img in images)
|
||||
items.Add(MakeImageItem(img));
|
||||
if (images.Count == 0)
|
||||
items.Add(new LauncherItem("이미지 없음", "docker pull <이름> 으로 받기", null, null, Symbol: "\uE946"));
|
||||
break;
|
||||
}
|
||||
|
||||
case "stop":
|
||||
{
|
||||
var name = parts.Length > 1 ? parts[1] : "";
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
// 실행 중 컨테이너 목록 표시 → 클릭 시 stop
|
||||
var running = GetContainers(running: true);
|
||||
items.Add(new LauncherItem("중지할 컨테이너 선택", "Enter → 중지", null, null, Symbol: "\uE756"));
|
||||
foreach (var c in running)
|
||||
items.Add(new LauncherItem($"중지: {c.Name}", c.Image,
|
||||
null, ("stop", c.Id), Symbol: "\uE756"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"컨테이너 중지: {name}",
|
||||
$"docker stop {name} · Enter 실행",
|
||||
null, ("stop", name), Symbol: "\uE756"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "start":
|
||||
{
|
||||
var name = parts.Length > 1 ? parts[1] : "";
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
var stopped = GetContainers(running: false, stopped: true);
|
||||
items.Add(new LauncherItem("시작할 컨테이너 선택", "Enter → 시작", null, null, Symbol: "\uE756"));
|
||||
foreach (var c in stopped)
|
||||
items.Add(new LauncherItem($"시작: {c.Name}", c.Image,
|
||||
null, ("start", c.Id), Symbol: "\uE756"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"컨테이너 시작: {name}",
|
||||
$"docker start {name} · Enter 실행",
|
||||
null, ("start", name), Symbol: "\uE756"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "logs":
|
||||
case "log":
|
||||
{
|
||||
var name = parts.Length > 1 ? parts[1] : "";
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
var running = GetContainers(running: true);
|
||||
foreach (var c in running)
|
||||
items.Add(new LauncherItem($"로그: {c.Name}", "Enter → 터미널에서 로그 보기",
|
||||
null, ("logs", c.Name), Symbol: "\uE756"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"로그: {name}", $"docker logs -f {name}",
|
||||
null, ("logs", name), Symbol: "\uE756"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "shell":
|
||||
case "exec":
|
||||
case "sh":
|
||||
{
|
||||
var name = parts.Length > 1 ? parts[1] : "";
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
var running = GetContainers(running: true);
|
||||
foreach (var c in running)
|
||||
items.Add(new LauncherItem($"쉘: {c.Name}", "Enter → 컨테이너 shell 접속",
|
||||
null, ("shell", c.Name), Symbol: "\uE756"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"쉘 접속: {name}", $"docker exec -it {name} sh",
|
||||
null, ("shell", name), Symbol: "\uE756"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
{
|
||||
// 컨테이너 이름 검색
|
||||
var all = GetContainers(running: false, stopped: true, all: true);
|
||||
var found = all.Where(c =>
|
||||
c.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
c.Image.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (found.Count > 0)
|
||||
foreach (var c in found)
|
||||
items.Add(MakeContainerItem(c));
|
||||
else
|
||||
items.Add(new LauncherItem($"'{q}' 컨테이너 없음",
|
||||
"docker all → 전체 목록", null, null, Symbol: "\uE946"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
switch (item.Data)
|
||||
{
|
||||
case ("stop", string id):
|
||||
RunDockerSilent($"stop {id}");
|
||||
NotificationService.Notify("Docker", $"중지: {id}");
|
||||
break;
|
||||
|
||||
case ("start", string id):
|
||||
RunDockerSilent($"start {id}");
|
||||
NotificationService.Notify("Docker", $"시작: {id}");
|
||||
break;
|
||||
|
||||
case ("logs", string name):
|
||||
RunInTerminal($"docker logs -f {name}");
|
||||
break;
|
||||
|
||||
case ("shell", string name):
|
||||
RunInTerminal($"docker exec -it {name} sh");
|
||||
break;
|
||||
|
||||
case ("copy", string text):
|
||||
try
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
|
||||
NotificationService.Notify("Docker", "복사됨");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
break;
|
||||
|
||||
case ("open_url", string url):
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{ FileName = url, UseShellExecute = true });
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
break;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── Docker 조회 ──────────────────────────────────────────────────────────
|
||||
|
||||
private record DockerContainer(string Id, string Name, string Image, string Status, string Ports);
|
||||
private record DockerImage(string Repository, string Tag, string Id, string Size, string Created);
|
||||
|
||||
private static List<DockerContainer> GetContainers(bool running = true, bool stopped = false, bool all = false)
|
||||
{
|
||||
var result = new List<DockerContainer>();
|
||||
try
|
||||
{
|
||||
var filter = all || (!running && stopped) ? "-a" : (running ? "" : "--filter status=exited");
|
||||
var output = RunDockerOutput($"ps {filter} --format \"{{{{.ID}}}}\\t{{{{.Names}}}}\\t{{{{.Image}}}}\\t{{{{.Status}}}}\\t{{{{.Ports}}}}\"");
|
||||
foreach (var line in output.Split('\n'))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmed)) continue;
|
||||
var cols = trimmed.Split('\t');
|
||||
if (cols.Length < 4) continue;
|
||||
result.Add(new DockerContainer(
|
||||
Id: cols[0],
|
||||
Name: cols[1],
|
||||
Image: cols[2],
|
||||
Status: cols[3],
|
||||
Ports: cols.Length > 4 ? cols[4] : ""));
|
||||
}
|
||||
}
|
||||
catch { /* Docker 없음 */ }
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<DockerImage> GetImages()
|
||||
{
|
||||
var result = new List<DockerImage>();
|
||||
try
|
||||
{
|
||||
var output = RunDockerOutput("images --format \"{{.Repository}}\\t{{.Tag}}\\t{{.ID}}\\t{{.Size}}\\t{{.CreatedSince}}\"");
|
||||
foreach (var line in output.Split('\n'))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmed)) continue;
|
||||
var cols = trimmed.Split('\t');
|
||||
if (cols.Length < 4) continue;
|
||||
result.Add(new DockerImage(
|
||||
Repository: cols[0],
|
||||
Tag: cols.Length > 1 ? cols[1] : "latest",
|
||||
Id: cols.Length > 2 ? cols[2] : "",
|
||||
Size: cols.Length > 3 ? cols[3] : "",
|
||||
Created: cols.Length > 4 ? cols[4] : ""));
|
||||
}
|
||||
}
|
||||
catch { /* Docker 없음 */ }
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string RunDockerOutput(string args)
|
||||
{
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "docker",
|
||||
Arguments = args,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
};
|
||||
using var proc = System.Diagnostics.Process.Start(psi);
|
||||
if (proc == null) return "";
|
||||
var output = proc.StandardOutput.ReadToEnd();
|
||||
proc.WaitForExit(5000);
|
||||
return output;
|
||||
}
|
||||
|
||||
private static void RunDockerSilent(string args)
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "docker", Arguments = args,
|
||||
UseShellExecute = false, CreateNoWindow = true,
|
||||
};
|
||||
using var proc = System.Diagnostics.Process.Start(psi);
|
||||
proc?.WaitForExit(10000);
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
|
||||
private static bool IsDockerAvailable()
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "docker", Arguments = "version --format json",
|
||||
UseShellExecute = false, CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
};
|
||||
using var proc = System.Diagnostics.Process.Start(psi);
|
||||
proc?.WaitForExit(3000);
|
||||
return proc?.ExitCode == 0;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static LauncherItem MakeContainerItem(DockerContainer c)
|
||||
{
|
||||
var isRunning = c.Status.StartsWith("Up", StringComparison.OrdinalIgnoreCase);
|
||||
var icon = isRunning ? "\uE768" : "\uE71A";
|
||||
var ports = string.IsNullOrWhiteSpace(c.Ports) ? "" : $" · {c.Ports}";
|
||||
return new LauncherItem(c.Name,
|
||||
$"{c.Status}{ports} · {c.Image}",
|
||||
null, ("copy", c.Id), Symbol: icon);
|
||||
}
|
||||
|
||||
private static LauncherItem MakeImageItem(DockerImage img)
|
||||
{
|
||||
var name = img.Tag == "<none>" ? img.Repository : $"{img.Repository}:{img.Tag}";
|
||||
return new LauncherItem(name, $"{img.Size} · {img.Created} · {img.Id[..Math.Min(12, img.Id.Length)]}",
|
||||
null, ("copy", name), Symbol: "\uE756");
|
||||
}
|
||||
|
||||
private static void RunInTerminal(string cmd)
|
||||
{
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "cmd", Arguments = $"/K {cmd}", UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
}
|
||||
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",
|
||||
};
|
||||
}
|
||||
157
src/AxCopilot/Handlers/EventLogHandler.cs
Normal file
157
src/AxCopilot/Handlers/EventLogHandler.cs
Normal file
@@ -0,0 +1,157 @@
|
||||
using System.Diagnostics;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L8-3: 시스템 이벤트 로그 핸들러. "evt" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: evt → 최근 오류/경고 이벤트 (System + Application 합산)
|
||||
/// evt error → 오류(Error) 이벤트만
|
||||
/// evt warn → 경고(Warning) 이벤트만
|
||||
/// evt app → Application 로그
|
||||
/// evt sys → System 로그
|
||||
/// evt <키워드> → 소스 또는 메시지에 키워드 포함된 이벤트
|
||||
/// Enter → 이벤트 내용을 클립보드에 복사.
|
||||
/// </summary>
|
||||
public class EventLogHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "evt";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"EventLog",
|
||||
"Windows 이벤트 로그 — 오류 · 경고 · 소스별 조회",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private const int MaxItems = 20;
|
||||
private const int LookbackH = 24; // 최근 24시간
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim().ToLowerInvariant();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
try
|
||||
{
|
||||
// 필터 결정
|
||||
var logName = "System";
|
||||
var level = EventLogEntryType.Error; // 기본 오류
|
||||
bool allLevel = false;
|
||||
string keyword = "";
|
||||
|
||||
if (q == "app") { logName = "Application"; allLevel = false; }
|
||||
else if (q == "sys") { logName = "System"; allLevel = false; }
|
||||
else if (q == "error") { allLevel = false; level = EventLogEntryType.Error; }
|
||||
else if (q is "warn" or "warning") { allLevel = false; level = EventLogEntryType.Warning; }
|
||||
else if (string.IsNullOrEmpty(q)) { allLevel = false; /* Error 기본 */ }
|
||||
else { keyword = q; allLevel = true; }
|
||||
|
||||
// System + Application 병합 또는 단일 로그
|
||||
var logNames = string.IsNullOrEmpty(q) || q is "error" or "warn" or "warning"
|
||||
? new[] { "System", "Application" }
|
||||
: logName == "Application" ? new[] { "Application" } : new[] { logName };
|
||||
|
||||
var entries = new List<EventLogEntry>();
|
||||
var cutoff = DateTime.Now.AddHours(-LookbackH);
|
||||
|
||||
foreach (var ln in logNames)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var log = new EventLog(ln);
|
||||
for (int i = log.Entries.Count - 1; i >= 0 && entries.Count < MaxItems * 2; i--)
|
||||
{
|
||||
var entry = log.Entries[i];
|
||||
if (entry.TimeGenerated < cutoff) break;
|
||||
|
||||
bool matchLevel = allLevel
|
||||
? true
|
||||
: entry.EntryType == level;
|
||||
|
||||
bool matchKeyword = string.IsNullOrEmpty(keyword)
|
||||
? true
|
||||
: (entry.Source?.Contains(keyword, StringComparison.OrdinalIgnoreCase) == true
|
||||
|| entry.Message?.Contains(keyword, StringComparison.OrdinalIgnoreCase) == true);
|
||||
|
||||
if (matchLevel && matchKeyword)
|
||||
entries.Add(entry);
|
||||
}
|
||||
}
|
||||
catch { /* 특정 로그 접근 실패 시 무시 */ }
|
||||
}
|
||||
|
||||
if (entries.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
"이벤트 없음",
|
||||
$"최근 {LookbackH}시간 내 해당 이벤트가 없습니다",
|
||||
null, null, Symbol: "\uE73E"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 정렬 (최신 순) 및 중복 제거
|
||||
var sorted = entries
|
||||
.OrderByDescending(e => e.TimeGenerated)
|
||||
.Take(MaxItems)
|
||||
.ToList();
|
||||
|
||||
foreach (var entry in sorted)
|
||||
{
|
||||
var icon = entry.EntryType == EventLogEntryType.Error ? "\uE783" :
|
||||
entry.EntryType == EventLogEntryType.Warning ? "\uE7BA" : "\uE946";
|
||||
var level2 = entry.EntryType == EventLogEntryType.Error ? "오류" :
|
||||
entry.EntryType == EventLogEntryType.Warning ? "경고" : "정보";
|
||||
|
||||
var msg = entry.Message ?? "";
|
||||
if (msg.Length > 80) msg = msg[..80].Replace('\n', ' ').Replace('\r', ' ') + "…";
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
$"[{level2}] {entry.Source}",
|
||||
$"{entry.TimeGenerated:MM-dd HH:mm} · {msg}",
|
||||
null,
|
||||
("copy_event", FormatEvent(entry)),
|
||||
Symbol: icon));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
"이벤트 로그 접근 실패",
|
||||
ex.Message,
|
||||
null, null, Symbol: "\uE783"));
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is ("copy_event", string eventText))
|
||||
{
|
||||
try
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(
|
||||
() => Clipboard.SetText(eventText));
|
||||
NotificationService.Notify("EventLog", "이벤트 정보를 클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 헬퍼 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static string FormatEvent(EventLogEntry e) =>
|
||||
$"""
|
||||
[이벤트 ID] {e.InstanceId}
|
||||
[시각] {e.TimeGenerated:yyyy-MM-dd HH:mm:ss}
|
||||
[유형] {e.EntryType}
|
||||
[소스] {e.Source}
|
||||
[메시지]
|
||||
{e.Message}
|
||||
""";
|
||||
}
|
||||
186
src/AxCopilot/Handlers/FileBrowserHandler.cs
Normal file
186
src/AxCopilot/Handlers/FileBrowserHandler.cs
Normal file
@@ -0,0 +1,186 @@
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L4-1: 인라인 파일 탐색기 핸들러.
|
||||
/// 입력이 파일시스템 경로처럼 보이면 (예: C:\, D:\Users\) 해당 폴더의 내용을 런처 목록으로 표시합니다.
|
||||
/// → 키로 하위 폴더 진입, ← 키 또는 Backspace로 상위 폴더 이동.
|
||||
/// prefix=null: 일반 쿼리 파이프라인에서 경로 감지 후 동작.
|
||||
/// </summary>
|
||||
public class FileBrowserHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => null; // 경로 패턴 직접 감지
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"FileBrowser",
|
||||
"파일 탐색기 — 경로 입력 후 → 키로 탐색",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
// C:\, D:\path, \\server\share, ~\ 패턴 감지
|
||||
private static readonly Regex PathPattern = new(
|
||||
@"^([A-Za-z]:\\|\\\\|~\\|~\/|\/)",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
/// <summary>쿼리가 파일시스템 경로처럼 보이는지 빠르게 판별합니다.</summary>
|
||||
public static bool IsPathQuery(string query)
|
||||
=> !string.IsNullOrEmpty(query) && PathPattern.IsMatch(query.Trim());
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = ExpandPath(query.Trim());
|
||||
|
||||
// 경로가 아닌 쿼리는 빈 결과 반환 (다른 핸들러가 처리)
|
||||
if (!IsPathQuery(query.Trim()))
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(Array.Empty<LauncherItem>());
|
||||
|
||||
// 입력이 존재하는 디렉터리이면 그 내용 표시
|
||||
if (Directory.Exists(q))
|
||||
return Task.FromResult(ListDirectory(q));
|
||||
|
||||
// 부분 경로: 마지막 세그먼트를 필터로 사용
|
||||
var parent = Path.GetDirectoryName(q);
|
||||
var filter = Path.GetFileName(q).ToLowerInvariant();
|
||||
|
||||
if (parent != null && Directory.Exists(parent))
|
||||
return Task.FromResult(ListDirectory(parent, filter));
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
|
||||
{
|
||||
new LauncherItem("경로를 찾을 수 없습니다", q, null, null, Symbol: Symbols.Error)
|
||||
});
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is FileBrowserEntry { IsFolder: true } dir)
|
||||
{
|
||||
// 폴더: 탐색기로 열기
|
||||
System.Diagnostics.Process.Start(
|
||||
new System.Diagnostics.ProcessStartInfo("explorer.exe", dir.Path)
|
||||
{ UseShellExecute = true });
|
||||
}
|
||||
else if (item.Data is FileBrowserEntry { IsFolder: false } file)
|
||||
{
|
||||
// 파일: 기본 앱으로 열기
|
||||
System.Diagnostics.Process.Start(
|
||||
new System.Diagnostics.ProcessStartInfo(file.Path)
|
||||
{ UseShellExecute = true });
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ─── 디렉터리 내용 나열 ─────────────────────────────────────────────────────
|
||||
|
||||
private static IEnumerable<LauncherItem> ListDirectory(string dir, string filter = "")
|
||||
{
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
// 상위 폴더 항목 (루트가 아닐 때)
|
||||
var parent = Path.GetDirectoryName(dir.TrimEnd('\\', '/'));
|
||||
if (!string.IsNullOrEmpty(parent))
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
".. (상위 폴더)",
|
||||
parent,
|
||||
IconCacheService.GetIconPath(parent, true),
|
||||
new FileBrowserEntry(parent, true),
|
||||
Symbol: "\uE74A")); // Back 아이콘
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 폴더 먼저
|
||||
var dirs = Directory.GetDirectories(dir)
|
||||
.Where(d => string.IsNullOrEmpty(filter) ||
|
||||
Path.GetFileName(d).Contains(filter, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(d => Path.GetFileName(d), StringComparer.OrdinalIgnoreCase)
|
||||
.Take(40);
|
||||
|
||||
foreach (var d in dirs)
|
||||
{
|
||||
var name = Path.GetFileName(d);
|
||||
items.Add(new LauncherItem(
|
||||
name,
|
||||
d,
|
||||
IconCacheService.GetIconPath(d, true),
|
||||
new FileBrowserEntry(d, true),
|
||||
Symbol: Symbols.Folder));
|
||||
}
|
||||
|
||||
// 파일
|
||||
var files = Directory.GetFiles(dir)
|
||||
.Where(f => string.IsNullOrEmpty(filter) ||
|
||||
Path.GetFileName(f).Contains(filter, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(f => Path.GetFileName(f), StringComparer.OrdinalIgnoreCase)
|
||||
.Take(30);
|
||||
|
||||
foreach (var f in files)
|
||||
{
|
||||
var name = Path.GetFileName(f);
|
||||
var ext = Path.GetExtension(f).ToLowerInvariant();
|
||||
var size = FormatSize(new FileInfo(f).Length);
|
||||
items.Add(new LauncherItem(
|
||||
name,
|
||||
$"{size} · {ext.TrimStart('.')} 파일",
|
||||
IconCacheService.GetIconPath(f),
|
||||
new FileBrowserEntry(f, false),
|
||||
Symbol: ExtToSymbol(ext)));
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
items.Add(new LauncherItem("접근 권한 없음", dir, null, null, Symbol: Symbols.Error));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
items.Add(new LauncherItem("읽기 오류", ex.Message, null, null, Symbol: Symbols.Error));
|
||||
}
|
||||
|
||||
if (items.Count == 0 || (items.Count == 1 && items[0].Symbol == "\uE74A"))
|
||||
items.Add(new LauncherItem("(빈 폴더)", dir, null, null, Symbol: Symbols.Folder));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
// ─── 헬퍼 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static string ExpandPath(string path)
|
||||
{
|
||||
if (path.StartsWith("~")) path = "%USERPROFILE%" + path[1..];
|
||||
return Environment.ExpandEnvironmentVariables(path);
|
||||
}
|
||||
|
||||
private static string FormatSize(long bytes) => bytes switch
|
||||
{
|
||||
< 1_024L => $"{bytes} B",
|
||||
< 1_024L * 1_024 => $"{bytes / 1_024.0:F1} KB",
|
||||
< 1_024L * 1_024 * 1_024 => $"{bytes / 1_048_576.0:F1} MB",
|
||||
_ => $"{bytes / 1_073_741_824.0:F1} GB",
|
||||
};
|
||||
|
||||
private static string ExtToSymbol(string ext) => ext switch
|
||||
{
|
||||
".exe" or ".msi" => Symbols.App,
|
||||
".pdf" => "\uEA90",
|
||||
".docx" or ".doc" => "\uE8A5",
|
||||
".xlsx" or ".xls" => "\uE9F9",
|
||||
".pptx" or ".ppt" => "\uE8A5",
|
||||
".zip" or ".7z" or ".rar" => "\uED25",
|
||||
".mp4" or ".avi" or ".mkv" => "\uE714",
|
||||
".mp3" or ".wav" or ".flac" => "\uE767",
|
||||
".png" or ".jpg" or ".jpeg" or ".gif" => "\uEB9F",
|
||||
".txt" or ".md" or ".log" => "\uE8A5",
|
||||
".cs" or ".py" or ".js" or ".ts" => "\uE8A5",
|
||||
".lnk" => "\uE71B",
|
||||
_ => "\uE7C3",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>파일 탐색기 핸들러에서 사용하는 항목 데이터</summary>
|
||||
public record FileBrowserEntry(string Path, bool IsFolder);
|
||||
273
src/AxCopilot/Handlers/FileHashHandler.cs
Normal file
273
src/AxCopilot/Handlers/FileHashHandler.cs
Normal file
@@ -0,0 +1,273 @@
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L8-1: 파일 해시 검증 핸들러. "hash" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: hash → 사용법 안내
|
||||
/// hash C:\file.zip → SHA256 (기본) 계산
|
||||
/// hash md5 C:\file.zip → MD5 계산
|
||||
/// hash sha1 C:\file.zip → SHA1 계산
|
||||
/// hash sha512 C:\file.zip → SHA512 계산
|
||||
/// hash check <기대값> → 클립보드의 해시값과 비교
|
||||
/// 경로 미입력 시 클립보드에서 파일 경로 자동 감지.
|
||||
/// Enter → 해시 결과를 클립보드에 복사.
|
||||
/// </summary>
|
||||
public class FileHashHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "hash";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"FileHash",
|
||||
"파일 해시 검증 — MD5 · SHA1 · SHA256 · SHA512",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private static readonly string[] Algos = ["md5", "sha1", "sha256", "sha512"];
|
||||
|
||||
public async Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
// 클립보드에 파일 경로가 있으면 자동 감지
|
||||
var clipPath = GetClipboardFilePath();
|
||||
if (!string.IsNullOrEmpty(clipPath))
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"SHA256: {Path.GetFileName(clipPath)}",
|
||||
clipPath,
|
||||
null,
|
||||
("compute", "sha256", clipPath),
|
||||
Symbol: "\uE8C4"));
|
||||
|
||||
foreach (var algo in Algos)
|
||||
items.Add(new LauncherItem(
|
||||
$"hash {algo}",
|
||||
$"{algo.ToUpperInvariant()} 계산",
|
||||
null,
|
||||
("compute", algo, clipPath),
|
||||
Symbol: "\uE8C4"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
"파일 해시 계산",
|
||||
"hash <경로> 또는 hash md5|sha1|sha256|sha512 <경로>",
|
||||
null, null, Symbol: "\uE8C4"));
|
||||
items.Add(new LauncherItem(
|
||||
"hash check <기대 해시값>",
|
||||
"클립보드의 해시와 비교 검증",
|
||||
null, null, Symbol: "\uE73E"));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// "check <hashValue>" — 클립보드 해시 비교
|
||||
if (q.StartsWith("check ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var expected = q[6..].Trim();
|
||||
var clipText = GetClipboardText()?.Trim();
|
||||
if (!string.IsNullOrEmpty(clipText) && !string.IsNullOrEmpty(expected))
|
||||
{
|
||||
var match = expected.Equals(clipText, StringComparison.OrdinalIgnoreCase);
|
||||
items.Add(new LauncherItem(
|
||||
match ? "✓ 해시 일치" : "✗ 해시 불일치",
|
||||
$"기대값: {Truncate(expected, 40)}",
|
||||
null, null,
|
||||
Symbol: match ? "\uE73E" : "\uE711"));
|
||||
if (!match)
|
||||
items.Add(new LauncherItem(
|
||||
"클립보드",
|
||||
Truncate(clipText, 60),
|
||||
null, null, Symbol: "\uE8C8"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
"비교 대상 없음",
|
||||
"먼저 해시 계산 결과를 클립보드에 복사하세요",
|
||||
null, null, Symbol: "\uE783"));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// 알고리즘 + 경로 파싱
|
||||
string algo2 = "sha256";
|
||||
string filePath = q;
|
||||
|
||||
var parts = q.Split(' ', 2);
|
||||
if (parts.Length == 2 && Algos.Contains(parts[0].ToLowerInvariant()))
|
||||
{
|
||||
algo2 = parts[0].ToLowerInvariant();
|
||||
filePath = parts[1].Trim().Trim('"');
|
||||
}
|
||||
else
|
||||
{
|
||||
// 알고리즘 없이 경로만 → 모든 알고리즘 표시
|
||||
filePath = q.Trim('"');
|
||||
}
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
// 클립보드 경로 시도
|
||||
var clipPath = GetClipboardFilePath();
|
||||
if (!string.IsNullOrEmpty(clipPath) && File.Exists(clipPath))
|
||||
filePath = clipPath;
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
"파일을 찾을 수 없음",
|
||||
filePath,
|
||||
null, null, Symbol: "\uE783"));
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
var fileSize = new FileInfo(filePath).Length;
|
||||
var sizeMb = fileSize / 1024.0 / 1024.0;
|
||||
|
||||
if (algo2 == "sha256" && parts.Length == 1)
|
||||
{
|
||||
// 경로만 입력 → 모든 알고리즘 항목 표시
|
||||
items.Add(new LauncherItem(
|
||||
fileName,
|
||||
$"{sizeMb:F1} MB",
|
||||
null, null, Symbol: "\uE8F4"));
|
||||
|
||||
foreach (var a in Algos)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
a.ToUpperInvariant(),
|
||||
"계산 중... (Enter로 실행)",
|
||||
null,
|
||||
("compute", a, filePath),
|
||||
Symbol: "\uE8C4"));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 특정 알고리즘 계산
|
||||
items.Add(new LauncherItem(
|
||||
$"계산 중: {algo2.ToUpperInvariant()}",
|
||||
$"{fileName} ({sizeMb:F1} MB)",
|
||||
null,
|
||||
("compute", algo2, filePath),
|
||||
Symbol: "\uE8C4"));
|
||||
|
||||
try
|
||||
{
|
||||
var hash = await ComputeHashAsync(filePath, algo2, ct);
|
||||
items.Clear();
|
||||
items.Add(new LauncherItem(
|
||||
hash,
|
||||
$"{algo2.ToUpperInvariant()} · {fileName}",
|
||||
null,
|
||||
("copy", hash),
|
||||
Symbol: "\uE8C4"));
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
items.Add(new LauncherItem("해시 계산 실패", ex.Message, null, null, Symbol: "\uE783"));
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
switch (item.Data)
|
||||
{
|
||||
case ("copy", string hash):
|
||||
TryCopyToClipboard(hash);
|
||||
NotificationService.Notify("FileHash", "해시를 클립보드에 복사했습니다.");
|
||||
break;
|
||||
|
||||
case ("compute", string algo, string filePath):
|
||||
try
|
||||
{
|
||||
var hash = await ComputeHashAsync(filePath, algo, ct);
|
||||
TryCopyToClipboard(hash);
|
||||
NotificationService.Notify(
|
||||
$"{algo.ToUpperInvariant()} 완료",
|
||||
$"{Path.GetFileName(filePath)}: {Truncate(hash, 32)}…");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
NotificationService.Notify("FileHash 오류", ex.Message);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 헬퍼 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<string> ComputeHashAsync(
|
||||
string filePath, string algo, CancellationToken ct)
|
||||
{
|
||||
using HashAlgorithm hasher = algo.ToLowerInvariant() switch
|
||||
{
|
||||
"md5" => MD5.Create(),
|
||||
"sha1" => SHA1.Create(),
|
||||
"sha512" => SHA512.Create(),
|
||||
_ => SHA256.Create(),
|
||||
};
|
||||
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var hashBytes = await Task.Run(() => hasher.ComputeHash(stream), ct);
|
||||
return Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string? GetClipboardFilePath()
|
||||
{
|
||||
try
|
||||
{
|
||||
string? text = null;
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
if (Clipboard.ContainsText())
|
||||
text = Clipboard.GetText()?.Trim().Trim('"');
|
||||
});
|
||||
return !string.IsNullOrEmpty(text) && File.Exists(text) ? text : null;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static string? GetClipboardText()
|
||||
{
|
||||
try
|
||||
{
|
||||
string? text = null;
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
if (Clipboard.ContainsText()) text = Clipboard.GetText();
|
||||
});
|
||||
return text;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static void TryCopyToClipboard(string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
|
||||
private static string Truncate(string s, int max) =>
|
||||
s.Length <= max ? s : s[..max];
|
||||
}
|
||||
539
src/AxCopilot/Handlers/FixHandler.cs
Normal file
539
src/AxCopilot/Handlers/FixHandler.cs
Normal file
@@ -0,0 +1,539 @@
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L23-4: 한/영 타이핑 오류 교정. "fix" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: fix gksrmf → 안녕 (영타→한글 변환)
|
||||
/// fix → 클립보드 텍스트 자동 교정
|
||||
/// Enter → 교정 결과 클립보드 복사
|
||||
/// </summary>
|
||||
public class FixHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "fix";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"타이핑 교정",
|
||||
"영타→한글 변환 — 잘못 입력된 영문 타이핑을 한글로 교정",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
// ── 두벌식 영→자모 매핑 ──────────────────────────────────────────────────
|
||||
|
||||
// 소문자
|
||||
private static readonly Dictionary<char, char> EngToJamo = new()
|
||||
{
|
||||
{'q', 'ㅂ'}, {'w', 'ㅈ'}, {'e', 'ㄷ'}, {'r', 'ㄱ'}, {'t', 'ㅅ'},
|
||||
{'y', 'ㅛ'}, {'u', 'ㅕ'}, {'i', 'ㅑ'}, {'o', 'ㅐ'}, {'p', 'ㅔ'},
|
||||
{'a', 'ㅁ'}, {'s', 'ㄴ'}, {'d', 'ㅇ'}, {'f', 'ㄹ'}, {'g', 'ㅎ'},
|
||||
{'h', 'ㅗ'}, {'j', 'ㅓ'}, {'k', 'ㅏ'}, {'l', 'ㅣ'},
|
||||
{'z', 'ㅋ'}, {'x', 'ㅌ'}, {'c', 'ㅊ'}, {'v', 'ㅍ'},
|
||||
{'b', 'ㅠ'}, {'n', 'ㅜ'}, {'m', 'ㅡ'},
|
||||
// 대문자 = 소문자와 동일 기본 (Shift 된소리/쌍모음 별도)
|
||||
{'Q', 'ㅃ'}, {'W', 'ㅉ'}, {'E', 'ㄸ'}, {'R', 'ㄲ'}, {'T', 'ㅆ'},
|
||||
{'Y', 'ㅛ'}, {'U', 'ㅕ'}, {'I', 'ㅑ'}, {'O', 'ㅒ'}, {'P', 'ㅖ'},
|
||||
{'A', 'ㅁ'}, {'S', 'ㄴ'}, {'D', 'ㅇ'}, {'F', 'ㄹ'}, {'G', 'ㅎ'},
|
||||
{'H', 'ㅗ'}, {'J', 'ㅓ'}, {'K', 'ㅏ'}, {'L', 'ㅣ'},
|
||||
{'Z', 'ㅋ'}, {'X', 'ㅌ'}, {'C', 'ㅊ'}, {'V', 'ㅍ'},
|
||||
{'B', 'ㅠ'}, {'N', 'ㅜ'}, {'M', 'ㅡ'},
|
||||
};
|
||||
|
||||
// ── 초성 배열 (19개) ──────────────────────────────────────────────────────
|
||||
private static readonly char[] Choseong =
|
||||
['ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ','ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'];
|
||||
|
||||
// ── 중성 배열 (21개) ──────────────────────────────────────────────────────
|
||||
private static readonly char[] Jungseong =
|
||||
['ㅏ','ㅐ','ㅑ','ㅒ','ㅓ','ㅔ','ㅕ','ㅖ','ㅗ','ㅘ','ㅙ','ㅚ','ㅛ','ㅜ','ㅝ','ㅞ','ㅟ','ㅠ','ㅡ','ㅢ','ㅣ'];
|
||||
|
||||
// ── 종성 배열 (28개, 0=없음) ─────────────────────────────────────────────
|
||||
private static readonly char[] Jongseong =
|
||||
['\0','ㄱ','ㄲ','ㄳ','ㄴ','ㄵ','ㄶ','ㄷ','ㄹ','ㄺ','ㄻ','ㄼ','ㄽ','ㄾ','ㄿ','ㅀ','ㅁ','ㅂ','ㅄ','ㅅ','ㅆ','ㅇ','ㅈ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'];
|
||||
|
||||
// ── 복합 모음 ─────────────────────────────────────────────────────────────
|
||||
private static readonly Dictionary<(char, char), char> CompoundVowel = new()
|
||||
{
|
||||
{('ㅗ','ㅏ'), 'ㅘ'}, {('ㅗ','ㅐ'), 'ㅙ'}, {('ㅗ','ㅣ'), 'ㅚ'},
|
||||
{('ㅜ','ㅓ'), 'ㅝ'}, {('ㅜ','ㅔ'), 'ㅞ'}, {('ㅜ','ㅣ'), 'ㅟ'},
|
||||
{('ㅡ','ㅣ'), 'ㅢ'},
|
||||
};
|
||||
|
||||
// ── 복합 종성 ─────────────────────────────────────────────────────────────
|
||||
private static readonly Dictionary<(char, char), char> CompoundJong = new()
|
||||
{
|
||||
{('ㄱ','ㅅ'), 'ㄳ'}, {('ㄴ','ㅈ'), 'ㄵ'}, {('ㄴ','ㅎ'), 'ㄶ'},
|
||||
{('ㄹ','ㄱ'), 'ㄺ'}, {('ㄹ','ㅁ'), 'ㄻ'}, {('ㄹ','ㅂ'), 'ㄼ'},
|
||||
{('ㄹ','ㅅ'), 'ㄽ'}, {('ㄹ','ㅌ'), 'ㄾ'}, {('ㄹ','ㅍ'), 'ㄿ'},
|
||||
{('ㄹ','ㅎ'), 'ㅀ'}, {('ㅂ','ㅅ'), 'ㅄ'},
|
||||
};
|
||||
|
||||
// 복합 종성 분리 (모음이 올 때 jong → cho + remain)
|
||||
private static readonly Dictionary<char, (char First, char Second)> SplitJong = new()
|
||||
{
|
||||
{'ㄳ', ('ㄱ','ㅅ')}, {'ㄵ', ('ㄴ','ㅈ')}, {'ㄶ', ('ㄴ','ㅎ')},
|
||||
{'ㄺ', ('ㄹ','ㄱ')}, {'ㄻ', ('ㄹ','ㅁ')}, {'ㄼ', ('ㄹ','ㅂ')},
|
||||
{'ㄽ', ('ㄹ','ㅅ')}, {'ㄾ', ('ㄹ','ㅌ')}, {'ㄿ', ('ㄹ','ㅍ')},
|
||||
{'ㅀ', ('ㄹ','ㅎ')}, {'ㅄ', ('ㅂ','ㅅ')},
|
||||
};
|
||||
|
||||
// ── 인덱스 헬퍼 ──────────────────────────────────────────────────────────
|
||||
|
||||
private static int ChoIdx(char c) => Array.IndexOf(Choseong, c);
|
||||
private static int JungIdx(char c) => Array.IndexOf(Jungseong, c);
|
||||
private static int JongIdx(char c) => Array.IndexOf(Jongseong, c);
|
||||
|
||||
private static bool IsVowel(char jamo) => JungIdx(jamo) >= 0;
|
||||
private static bool IsConsonant(char jamo) => ChoIdx(jamo) >= 0 || JongIdx(jamo) > 0;
|
||||
|
||||
private static char MakeSyllable(int cho, int jung, int jong) =>
|
||||
(char)(0xAC00 + cho * 21 * 28 + jung * 28 + jong);
|
||||
|
||||
// ── 한글 조합기 (두벌식 상태 기계) ───────────────────────────────────────
|
||||
|
||||
private sealed class HangulComposer
|
||||
{
|
||||
private int _cho = -1;
|
||||
private int _jung = -1;
|
||||
private int _jong = -1;
|
||||
private readonly StringBuilder _sb = new();
|
||||
|
||||
public string Result => _sb.ToString();
|
||||
|
||||
public void Flush()
|
||||
{
|
||||
if (_cho < 0) return;
|
||||
if (_jung < 0)
|
||||
{
|
||||
// 초성만
|
||||
_sb.Append(Choseong[_cho]);
|
||||
}
|
||||
else
|
||||
{
|
||||
_sb.Append(MakeSyllable(_cho, _jung, _jong < 0 ? 0 : _jong));
|
||||
}
|
||||
_cho = _jung = _jong = -1;
|
||||
}
|
||||
|
||||
public void Feed(char jamo)
|
||||
{
|
||||
if (IsVowel(jamo))
|
||||
{
|
||||
FeedVowel(jamo);
|
||||
}
|
||||
else
|
||||
{
|
||||
FeedConsonant(jamo);
|
||||
}
|
||||
}
|
||||
|
||||
private void FeedVowel(char v)
|
||||
{
|
||||
var vi = JungIdx(v);
|
||||
|
||||
if (_cho < 0)
|
||||
{
|
||||
// 초성 없음 → ㅇ + 모음
|
||||
_sb.Append(MakeSyllable(ChoIdx('ㅇ'), vi, 0));
|
||||
return;
|
||||
}
|
||||
|
||||
if (_jung < 0)
|
||||
{
|
||||
// 초성만 있음 → 중성 결합
|
||||
_jung = vi;
|
||||
return;
|
||||
}
|
||||
|
||||
// 초성+중성 있음
|
||||
if (_jong < 0)
|
||||
{
|
||||
// 복합 모음 시도
|
||||
var curVowel = Jungseong[_jung];
|
||||
if (CompoundVowel.TryGetValue((curVowel, v), out var compound))
|
||||
{
|
||||
_jung = JungIdx(compound);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 현재 음절 확정, 새 음절 시작 (ㅇ + 모음)
|
||||
Flush();
|
||||
_cho = ChoIdx('ㅇ');
|
||||
_jung = vi;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 초성+중성+종성 있음 → 종성을 새 음절의 초성으로
|
||||
var jongChar = Jongseong[_jong];
|
||||
|
||||
if (SplitJong.TryGetValue(jongChar, out var split))
|
||||
{
|
||||
// 복합 종성: 앞 자음은 종성, 뒷 자음은 새 초성
|
||||
var newCho = ChoIdx(split.Second);
|
||||
if (newCho < 0) newCho = 0;
|
||||
var remainJong = JongIdx(split.First);
|
||||
// 현재 음절 (jong=split.First)
|
||||
_sb.Append(MakeSyllable(_cho, _jung, remainJong));
|
||||
_cho = newCho;
|
||||
_jung = vi;
|
||||
_jong = -1;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 단일 종성 → 새 초성으로
|
||||
var newCho = ChoIdx(jongChar);
|
||||
if (newCho < 0) newCho = 0;
|
||||
_sb.Append(MakeSyllable(_cho, _jung, 0));
|
||||
_cho = newCho;
|
||||
_jung = vi;
|
||||
_jong = -1;
|
||||
}
|
||||
}
|
||||
|
||||
private void FeedConsonant(char c)
|
||||
{
|
||||
var ci = ChoIdx(c);
|
||||
|
||||
if (_cho < 0)
|
||||
{
|
||||
// 처음 자음
|
||||
_cho = ci >= 0 ? ci : 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_jung < 0)
|
||||
{
|
||||
// 초성만 있음 → 이전 초성 출력, 새 초성
|
||||
_sb.Append(Choseong[_cho]);
|
||||
_cho = ci >= 0 ? ci : 0;
|
||||
_jung = -1;
|
||||
_jong = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_jong < 0)
|
||||
{
|
||||
// 초성+중성 → 종성 후보
|
||||
_jong = JongIdx(c);
|
||||
if (_jong < 0) _jong = 0; // 종성에 없는 자음은 그냥 처리
|
||||
if (_jong == 0)
|
||||
{
|
||||
// 종성에 들어갈 수 없는 자음(ㄸ, ㅃ, ㅉ)
|
||||
Flush();
|
||||
_cho = ci >= 0 ? ci : 0;
|
||||
_jung = -1;
|
||||
_jong = -1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 초성+중성+종성 있음
|
||||
var curJongChar = Jongseong[_jong];
|
||||
if (CompoundJong.TryGetValue((curJongChar, c), out var compJ))
|
||||
{
|
||||
// 복합 종성 가능
|
||||
var cji = JongIdx(compJ);
|
||||
if (cji > 0)
|
||||
{
|
||||
_jong = cji;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 복합 종성 불가 → 현재 음절 확정, 새 초성
|
||||
Flush();
|
||||
_cho = ci >= 0 ? ci : 0;
|
||||
_jung = -1;
|
||||
_jong = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 영타→한글 변환 ────────────────────────────────────────────────────────
|
||||
|
||||
private static string EngToKorean(string input)
|
||||
{
|
||||
var composer = new HangulComposer();
|
||||
var sb = new StringBuilder();
|
||||
|
||||
foreach (var ch in input)
|
||||
{
|
||||
if (EngToJamo.TryGetValue(ch, out var jamo))
|
||||
{
|
||||
if (composer.Result.Length > 0 || jamo != '\0')
|
||||
{
|
||||
// 현재 변환 중인 조합기에 피드
|
||||
}
|
||||
composer.Feed(jamo);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 변환 불가 문자 → 조합기 플러시 후 그대로
|
||||
var cur = composer.Result;
|
||||
// 지금까지 쌓인 결과 덤프
|
||||
composer.Feed('\0'); // 플러시 트리거 안 됨 → 직접 Flush
|
||||
// 아래 로직: 조합기는 Flush()로만 비워짐
|
||||
sb.Append(ch);
|
||||
}
|
||||
}
|
||||
|
||||
// 위 로직을 단순화: 문자별 처리
|
||||
return ConvertEngToKor(input);
|
||||
}
|
||||
|
||||
private static string ConvertEngToKor(string input)
|
||||
{
|
||||
// 먼저 자모 문자열로 변환
|
||||
var jamoSeq = new List<char>();
|
||||
foreach (var ch in input)
|
||||
{
|
||||
if (EngToJamo.TryGetValue(ch, out var jamo))
|
||||
jamoSeq.Add(jamo);
|
||||
else
|
||||
jamoSeq.Add(ch); // 변환 불가 문자는 그대로
|
||||
}
|
||||
|
||||
// 자모 시퀀스를 한글 음절로 조합
|
||||
var result = new StringBuilder();
|
||||
var i = 0;
|
||||
while (i < jamoSeq.Count)
|
||||
{
|
||||
var ch = jamoSeq[i];
|
||||
|
||||
// 변환 불가 문자 (공백, 숫자, 특수문자 등)
|
||||
if (!IsKorJamo(ch))
|
||||
{
|
||||
result.Append(ch);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 자모 덩어리 추출
|
||||
var jamoBlock = new List<char>();
|
||||
var j = i;
|
||||
while (j < jamoSeq.Count && IsKorJamo(jamoSeq[j]))
|
||||
jamoBlock.Add(jamoSeq[j++]);
|
||||
|
||||
// 자모 블록을 한글로 조합
|
||||
result.Append(ComposeHangul(jamoBlock));
|
||||
i = j;
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
private static bool IsKorJamo(char c)
|
||||
{
|
||||
return (c >= 'ㄱ' && c <= 'ㅎ') || (c >= 'ㅏ' && c <= 'ㅣ');
|
||||
}
|
||||
|
||||
private static string ComposeHangul(List<char> jamos)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var idx = 0;
|
||||
|
||||
while (idx < jamos.Count)
|
||||
{
|
||||
var c = jamos[idx];
|
||||
|
||||
if (IsVowel(c))
|
||||
{
|
||||
// 단독 모음 → ㅇ + 모음
|
||||
sb.Append(MakeSyllable(ChoIdx('ㅇ'), JungIdx(c), 0));
|
||||
idx++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 자음: 초성 후보
|
||||
var cho = c;
|
||||
var choI = ChoIdx(cho);
|
||||
if (choI < 0) { sb.Append(c); idx++; continue; }
|
||||
idx++;
|
||||
|
||||
if (idx >= jamos.Count || IsConsonantOnly(jamos[idx]))
|
||||
{
|
||||
// 단독 초성
|
||||
sb.Append(cho);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 중성
|
||||
var v1 = jamos[idx];
|
||||
var jungI = JungIdx(v1);
|
||||
if (jungI < 0) { sb.Append(cho); continue; }
|
||||
idx++;
|
||||
|
||||
// 복합 모음 시도
|
||||
if (idx < jamos.Count && IsVowel(jamos[idx]))
|
||||
{
|
||||
if (CompoundVowel.TryGetValue((v1, jamos[idx]), out var cv))
|
||||
{
|
||||
jungI = JungIdx(cv);
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
|
||||
// 종성 후보
|
||||
if (idx >= jamos.Count)
|
||||
{
|
||||
sb.Append(MakeSyllable(choI, jungI, 0));
|
||||
continue;
|
||||
}
|
||||
|
||||
var next = jamos[idx];
|
||||
if (IsVowel(next))
|
||||
{
|
||||
sb.Append(MakeSyllable(choI, jungI, 0));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 종성 자음
|
||||
var jongI = JongIdx(next);
|
||||
if (jongI <= 0)
|
||||
{
|
||||
sb.Append(MakeSyllable(choI, jungI, 0));
|
||||
continue;
|
||||
}
|
||||
|
||||
idx++;
|
||||
|
||||
// 다음 모음 있으면 종성→초성 이동
|
||||
if (idx < jamos.Count && IsVowel(jamos[idx]))
|
||||
{
|
||||
// 복합 종성 분리 확인
|
||||
if (idx + 1 < jamos.Count && IsVowel(jamos[idx]))
|
||||
{
|
||||
// 분리 없음: next 자음 → 다음 음절 초성
|
||||
}
|
||||
sb.Append(MakeSyllable(choI, jungI, 0));
|
||||
idx--; // next 자음을 다음 루프에서 초성으로 사용
|
||||
continue;
|
||||
}
|
||||
|
||||
// 복합 종성 시도
|
||||
if (idx < jamos.Count && !IsVowel(jamos[idx]))
|
||||
{
|
||||
var next2 = jamos[idx];
|
||||
var jongChar = Jongseong[jongI];
|
||||
if (CompoundJong.TryGetValue((jongChar, next2), out var cj))
|
||||
{
|
||||
var cji = JongIdx(cj);
|
||||
if (cji > 0)
|
||||
{
|
||||
// 다음에 모음이 있으면 복합 종성 분리
|
||||
if (idx + 1 < jamos.Count && IsVowel(jamos[idx + 1]))
|
||||
{
|
||||
sb.Append(MakeSyllable(choI, jungI, jongI));
|
||||
// next2는 다음 음절 초성으로
|
||||
idx--; // next2를 다시 처리
|
||||
idx++;
|
||||
continue;
|
||||
}
|
||||
jongI = cji;
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
|
||||
// 다음에 모음이 있으면 종성→초성
|
||||
if (idx < jamos.Count && IsVowel(jamos[idx]))
|
||||
{
|
||||
sb.Append(MakeSyllable(choI, jungI, 0));
|
||||
idx -= 2;
|
||||
idx++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append(MakeSyllable(choI, jungI, jongI));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// 초성으로만 사용 가능한 자음인지 (된소리 = 종성 불가)
|
||||
private static bool IsConsonantOnly(char c)
|
||||
{
|
||||
if (!IsKorJamo(c)) return false;
|
||||
if (IsVowel(c)) return false;
|
||||
return JongIdx(c) <= 0;
|
||||
}
|
||||
|
||||
// ── GetItemsAsync ─────────────────────────────────────────────────────────
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
string inputText;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
// 클립보드에서 읽기
|
||||
string? clipText = null;
|
||||
try
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
if (Clipboard.ContainsText())
|
||||
clipText = Clipboard.GetText();
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
|
||||
if (string.IsNullOrWhiteSpace(clipText))
|
||||
{
|
||||
items.Add(new LauncherItem("한/영 타이핑 교정",
|
||||
"fix <영타 텍스트> 또는 클립보드에 텍스트 복사 후 fix 입력",
|
||||
null, null, Symbol: "\uE8AC"));
|
||||
items.Add(new LauncherItem("예: fix gksrmf", "→ 안녕 (영타→한글)", null, null, Symbol: "\uE8AC"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
inputText = clipText;
|
||||
}
|
||||
else
|
||||
{
|
||||
inputText = q;
|
||||
}
|
||||
|
||||
var converted = ConvertEngToKor(inputText);
|
||||
|
||||
if (converted == inputText)
|
||||
{
|
||||
items.Add(new LauncherItem("변환할 영타 오류가 없습니다",
|
||||
$"입력: {inputText}", null, null, Symbol: "\uE8AC"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
converted,
|
||||
$"영타 교정 결과 · Enter: 클립보드 복사",
|
||||
null, ("copy", converted), Symbol: "\uE8AC"));
|
||||
items.Add(new LauncherItem(
|
||||
$"원본: {(inputText.Length > 50 ? inputText[..50] + "…" : inputText)}",
|
||||
"", null, null, Symbol: "\uE8AC"));
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ── ExecuteAsync ──────────────────────────────────────────────────────────
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is ("copy", string text))
|
||||
{
|
||||
try
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
|
||||
NotificationService.Notify("타이핑 교정", "교정 결과를 클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
237
src/AxCopilot/Handlers/FlowHandler.cs
Normal file
237
src/AxCopilot/Handlers/FlowHandler.cs
Normal file
@@ -0,0 +1,237 @@
|
||||
using System.IO;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L29-2: 명령 체인(워크플로우) 핸들러. "flow" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: flow → 등록된 플로우 목록
|
||||
/// flow add 출근준비 "remind 09:00 회의" > "today" > "todo list" → 플로우 추가
|
||||
/// flow 출근준비 → 플로우 실행
|
||||
/// flow del 출근준비 → 플로우 삭제
|
||||
/// flow edit 출근준비 → 플로우 명령 목록 표시 (클립보드 복사)
|
||||
/// Enter → 저장된 명령들을 순서대로 런처에 실행 (클립보드에 명령 목록 복사).
|
||||
/// Alfred 워크플로우 경량 대응.
|
||||
/// 저장: %APPDATA%\AxCopilot\flows.json
|
||||
/// </summary>
|
||||
public class FlowHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "flow";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"명령 체인",
|
||||
"여러 명령을 묶어 순서대로 실행 (워크플로우)",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private sealed record FlowEntry(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("commands")] List<string> Commands,
|
||||
[property: JsonPropertyName("created")] DateTime Created);
|
||||
|
||||
private static readonly string DataPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "flows.json");
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpt = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
var flows = Load();
|
||||
|
||||
// ── add 명령 ──────────────────────────────────────────────────────────
|
||||
if (q.StartsWith("add ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var rest = q[4..].Trim();
|
||||
var spaceIdx = rest.IndexOf(' ');
|
||||
if (spaceIdx < 1)
|
||||
{
|
||||
items.Add(new LauncherItem("사용법: flow add {이름} {명령1} > {명령2} > ...",
|
||||
"예: flow add 출근 \"today\" > \"todo list\" > \"remind 09:00 회의\"",
|
||||
null, null, Symbol: "\uE710"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var name = rest[..spaceIdx];
|
||||
var cmdStr = rest[(spaceIdx + 1)..].Trim();
|
||||
var commands = ParseCommands(cmdStr);
|
||||
|
||||
if (commands.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("명령을 > 로 구분해 입력하세요",
|
||||
"예: \"today\" > \"todo list\"",
|
||||
null, null, Symbol: Themes.Symbols.Warning));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"플로우 저장: {name} ({commands.Count}개 명령)",
|
||||
string.Join(" → ", commands),
|
||||
null, ("add", name, commands), Symbol: "\uE710"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ── del 명령 ──────────────────────────────────────────────────────────
|
||||
if (q.StartsWith("del ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var name = q[4..].Trim();
|
||||
var found = flows.FirstOrDefault(f => f.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
if (found != null)
|
||||
{
|
||||
items.Add(new LauncherItem($"플로우 삭제: {found.Name}",
|
||||
$"{found.Commands.Count}개 명령 · {string.Join(" → ", found.Commands)}",
|
||||
null, ("del", found.Name), Symbol: "\uE74D"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"'{name}' 플로우를 찾을 수 없습니다",
|
||||
"flow del {이름}", null, null, Symbol: "\uE783"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ── 빈 쿼리 → 전체 목록 ──────────────────────────────────────────────
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
if (flows.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("등록된 명령 체인이 없습니다",
|
||||
"flow add {이름} {명령1} > {명령2} > ... 로 추가하세요",
|
||||
null, null, Symbol: "\uE8A0"));
|
||||
items.Add(new LauncherItem("예시: flow add 출근 \"today\" > \"todo list\"",
|
||||
"오늘 업무 뷰 → 할일 목록 순서대로 실행",
|
||||
null, null, Symbol: Themes.Symbols.Info));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
items.Add(new LauncherItem($"명령 체인 {flows.Count}개",
|
||||
"Enter: 명령 목록 클립보드 복사 · flow add/del 로 관리",
|
||||
null, null, Symbol: "\uE8A0"));
|
||||
|
||||
foreach (var f in flows)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"▶ {f.Name} ({f.Commands.Count}단계)",
|
||||
string.Join(" → ", f.Commands),
|
||||
null, ("run", f), Symbol: "\uE768"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ── 이름 검색 → 실행 ──────────────────────────────────────────────────
|
||||
var match = flows.FirstOrDefault(f => f.Name.Equals(q, StringComparison.OrdinalIgnoreCase));
|
||||
if (match != null)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"▶ {match.Name} 실행",
|
||||
string.Join(" → ", match.Commands),
|
||||
null, ("run", match), Symbol: "\uE768"));
|
||||
|
||||
for (int i = 0; i < match.Commands.Count; i++)
|
||||
items.Add(new LauncherItem($" {i + 1}. {match.Commands[i]}", "",
|
||||
null, null, Symbol: Themes.Symbols.Terminal));
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 부분 매칭
|
||||
var searched = flows.Where(f =>
|
||||
f.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
f.Commands.Any(c => c.Contains(q, StringComparison.OrdinalIgnoreCase))).ToList();
|
||||
|
||||
if (searched.Count > 0)
|
||||
{
|
||||
foreach (var f in searched)
|
||||
items.Add(new LauncherItem($"▶ {f.Name} ({f.Commands.Count}단계)",
|
||||
string.Join(" → ", f.Commands),
|
||||
null, ("run", f), Symbol: "\uE768"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"'{q}' 플로우를 찾을 수 없습니다",
|
||||
"flow add {이름} {명령} > {명령} 으로 추가하세요",
|
||||
null, null, Symbol: "\uE783"));
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is ("add", string name, List<string> commands))
|
||||
{
|
||||
var flows = Load();
|
||||
flows.RemoveAll(f => f.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
flows.Add(new FlowEntry(name, commands, DateTime.Now));
|
||||
Save(flows);
|
||||
NotificationService.Notify("flow", $"'{name}' 플로우가 저장되었습니다. ({commands.Count}단계)");
|
||||
}
|
||||
else if (item.Data is ("del", string delName))
|
||||
{
|
||||
var flows = Load();
|
||||
flows.RemoveAll(f => f.Name.Equals(delName, StringComparison.OrdinalIgnoreCase));
|
||||
Save(flows);
|
||||
NotificationService.Notify("flow", $"'{delName}' 플로우가 삭제되었습니다.");
|
||||
}
|
||||
else if (item.Data is ("run", FlowEntry flow))
|
||||
{
|
||||
// 명령 목록을 클립보드에 복사 (사용자가 순서대로 런처에 입력)
|
||||
var text = string.Join("\n", flow.Commands.Select((c, i) => $"{i + 1}. {c}"));
|
||||
try
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
|
||||
NotificationService.Notify("flow", $"'{flow.Name}' 명령 {flow.Commands.Count}개가 클립보드에 복사되었습니다.");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ─── 명령 파싱 ────────────────────────────────────────────────────────────
|
||||
|
||||
private static List<string> ParseCommands(string input)
|
||||
{
|
||||
// "cmd1" > "cmd2" > "cmd3" 또는 cmd1 > cmd2 > cmd3
|
||||
return input.Split('>')
|
||||
.Select(s => s.Trim().Trim('"').Trim())
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// ─── JSON I/O ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static List<FlowEntry> Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(DataPath)) return [];
|
||||
var json = File.ReadAllText(DataPath);
|
||||
return JsonSerializer.Deserialize<List<FlowEntry>>(json) ?? [];
|
||||
}
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
private static void Save(List<FlowEntry> list)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(DataPath)!;
|
||||
if (!Directory.Exists(dir)) Directory.CreateDirectory(dir);
|
||||
File.WriteAllText(DataPath, JsonSerializer.Serialize(list, JsonOpt));
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
1113
src/AxCopilot/Handlers/FormHandler.cs
Normal file
1113
src/AxCopilot/Handlers/FormHandler.cs
Normal file
File diff suppressed because it is too large
Load Diff
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}줄)";
|
||||
}
|
||||
}
|
||||
536
src/AxCopilot/Handlers/GitignoreHandler.cs
Normal file
536
src/AxCopilot/Handlers/GitignoreHandler.cs
Normal file
@@ -0,0 +1,536 @@
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L17-4: .gitignore 생성기 핸들러. "gitignore" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: gitignore → 지원 언어·프레임워크 목록
|
||||
/// gitignore node → Node.js .gitignore 생성
|
||||
/// gitignore python → Python .gitignore 생성
|
||||
/// gitignore csharp → C# / .NET .gitignore 생성
|
||||
/// gitignore java → Java .gitignore 생성
|
||||
/// gitignore react → React (Node 기반) .gitignore 생성
|
||||
/// gitignore node python → 여러 템플릿 병합
|
||||
/// Enter → .gitignore 내용을 클립보드에 복사.
|
||||
/// </summary>
|
||||
public class GitignoreHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "gitignore";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Gitignore",
|
||||
".gitignore 생성기 — Node·Python·C#·Java·Go·Rust 등 내장 템플릿",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
// ── 내장 템플릿 ──────────────────────────────────────────────────────────
|
||||
|
||||
private static readonly Dictionary<string, (string[] Aliases, string Description, string Content)> Templates =
|
||||
new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["node"] = (
|
||||
["nodejs", "npm", "javascript", "js"],
|
||||
"Node.js / npm",
|
||||
"""
|
||||
# Node.js
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
.pnpm-store/
|
||||
.npm/
|
||||
.yarn/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
dist/
|
||||
build/
|
||||
.cache/
|
||||
.parcel-cache/
|
||||
.vite/
|
||||
coverage/
|
||||
.nyc_output/
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
"""),
|
||||
|
||||
["python"] = (
|
||||
["py", "django", "flask", "fastapi"],
|
||||
"Python",
|
||||
"""
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
dist/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
*.log
|
||||
.DS_Store
|
||||
"""),
|
||||
|
||||
["csharp"] = (
|
||||
["cs", "dotnet", ".net", "net", "aspnet", "aspnetcore"],
|
||||
"C# / .NET",
|
||||
"""
|
||||
# C# / .NET
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
*.suo
|
||||
.vs/
|
||||
.vscode/
|
||||
*.userprefs
|
||||
*.pidb
|
||||
*.booproj
|
||||
*.svd
|
||||
*.userprefs
|
||||
packages/
|
||||
*.nupkg
|
||||
**/[Bb]in/
|
||||
**/[Oo]bj/
|
||||
**/[Ll]og/
|
||||
**/[Ll]ogs/
|
||||
TestResults/
|
||||
[Tt]est[Rr]esult*/
|
||||
BenchmarkDotNet.Artifacts/
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_h.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.iobj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*_wpftmp.csproj
|
||||
*.log
|
||||
*.vspscc
|
||||
.DS_Store
|
||||
"""),
|
||||
|
||||
["java"] = (
|
||||
["gradle", "maven", "mvn", "spring"],
|
||||
"Java / Maven / Gradle",
|
||||
"""
|
||||
# Java / Maven / Gradle
|
||||
*.class
|
||||
*.log
|
||||
*.ctxt
|
||||
.mtj.tmp/
|
||||
*.jar
|
||||
*.war
|
||||
*.nar
|
||||
*.ear
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
hs_err_pid*
|
||||
replay_pid*
|
||||
target/
|
||||
build/
|
||||
out/
|
||||
.gradle/
|
||||
.mvn/
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
!.mvn/wrapper/maven-wrapper.properties
|
||||
.idea/
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
.classpath
|
||||
.project
|
||||
.settings/
|
||||
.DS_Store
|
||||
"""),
|
||||
|
||||
["go"] = (
|
||||
["golang"],
|
||||
"Go (Golang)",
|
||||
"""
|
||||
# Go
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
go.work
|
||||
go.work.sum
|
||||
vendor/
|
||||
.env
|
||||
dist/
|
||||
bin/
|
||||
.DS_Store
|
||||
"""),
|
||||
|
||||
["rust"] = (
|
||||
["cargo"],
|
||||
"Rust / Cargo",
|
||||
"""
|
||||
# Rust / Cargo
|
||||
/target/
|
||||
Cargo.lock
|
||||
**/*.rs.bk
|
||||
*.pdb
|
||||
.env
|
||||
.DS_Store
|
||||
"""),
|
||||
|
||||
["react"] = (
|
||||
["nextjs", "next", "vue", "vite", "svelte"],
|
||||
"React / Next.js / Vue / Vite",
|
||||
"""
|
||||
# React / Next.js / Vite
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
build/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.cache/
|
||||
.parcel-cache/
|
||||
coverage/
|
||||
*.log
|
||||
"""),
|
||||
|
||||
["flutter"] = (
|
||||
["dart"],
|
||||
"Flutter / Dart",
|
||||
"""
|
||||
# Flutter / Dart
|
||||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.packages
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
flutter_*.png
|
||||
linked_*.ds
|
||||
unlinked.ds
|
||||
unlinked_spec.ds
|
||||
*.log
|
||||
.DS_Store
|
||||
"""),
|
||||
|
||||
["android"] = (
|
||||
["kotlin", "gradle-android"],
|
||||
"Android",
|
||||
"""
|
||||
# Android
|
||||
*.iml
|
||||
.gradle/
|
||||
/local.properties
|
||||
/.idea/
|
||||
.DS_Store
|
||||
/build/
|
||||
/captures/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
*.jks
|
||||
*.keystore
|
||||
google-services.json
|
||||
"""),
|
||||
|
||||
["ios"] = (
|
||||
["swift", "xcode", "objc", "objective-c"],
|
||||
"iOS / Swift / Xcode",
|
||||
"""
|
||||
# iOS / Swift / Xcode
|
||||
build/
|
||||
DerivedData/
|
||||
.build/
|
||||
*.pbxuser
|
||||
*.mode1v3
|
||||
*.mode2v3
|
||||
*.perspectivev3
|
||||
xcuserdata/
|
||||
*.xcworkspace
|
||||
!default.xcworkspace
|
||||
.swiftpm/
|
||||
Packages/
|
||||
*.resolved
|
||||
*.xccheckout
|
||||
*.moved-aside
|
||||
*.xcuserstate
|
||||
.DS_Store
|
||||
"""),
|
||||
|
||||
["unity"] = (
|
||||
[],
|
||||
"Unity",
|
||||
"""
|
||||
# Unity
|
||||
/[Ll]ibrary/
|
||||
/[Tt]emp/
|
||||
/[Oo]bj/
|
||||
/[Bb]uild/
|
||||
/[Bb]uilds/
|
||||
/[Ll]ogs/
|
||||
/[Uu]ser[Ss]ettings/
|
||||
/[Mm]emoryCaptures/
|
||||
/[Rr]ecordings/
|
||||
/[Pp]rofiles/
|
||||
/[Pp]rofile[Ss]
|
||||
/[Aa]ssets/Plugins/EditorVR.meta
|
||||
/[Pp]ackages/
|
||||
!/[Pp]ackages/manifest.json
|
||||
!/[Pp]ackages/packages-lock.json
|
||||
/*.sln
|
||||
/*.csproj
|
||||
/.vs/
|
||||
.DS_Store
|
||||
"""),
|
||||
|
||||
["windows"] = (
|
||||
["win", "powershell", "ps"],
|
||||
"Windows 공통",
|
||||
"""
|
||||
# Windows
|
||||
Thumbs.db
|
||||
Thumbs.db:encryptable
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
*.stackdump
|
||||
[Dd]esktop.ini
|
||||
$RECYCLE.BIN/
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
*.lnk
|
||||
"""),
|
||||
|
||||
["macos"] = (
|
||||
["mac", "osx"],
|
||||
"macOS 공통",
|
||||
"""
|
||||
# macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
Icon
|
||||
._*
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
"""),
|
||||
|
||||
["linux"] = (
|
||||
[],
|
||||
"Linux 공통",
|
||||
"""
|
||||
# Linux
|
||||
*~
|
||||
.fuse_hidden*
|
||||
.directory
|
||||
.Trash-*
|
||||
.nfs*
|
||||
*.swp
|
||||
*.swo
|
||||
"""),
|
||||
};
|
||||
|
||||
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(".gitignore 생성기",
|
||||
"예: gitignore node / gitignore python / gitignore csharp / gitignore node python",
|
||||
null, null, Symbol: "\uEA3C"));
|
||||
items.Add(new LauncherItem($"── 지원 템플릿 {Templates.Count}개 ──", "", null, null, Symbol: "\uEA3C"));
|
||||
foreach (var (key, (aliases, desc, _)) in Templates.OrderBy(t => t.Key))
|
||||
{
|
||||
var aliasStr = aliases.Length > 0 ? $" ({string.Join(", ", aliases.Take(3))})" : "";
|
||||
items.Add(new LauncherItem(key, $"{desc}{aliasStr}", null, ("gen", key), Symbol: "\uEA3C"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 여러 키워드 → 병합
|
||||
var keywords = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var matched = new List<string>(); // 템플릿 키 목록
|
||||
|
||||
foreach (var kw in keywords)
|
||||
{
|
||||
var found = FindTemplate(kw);
|
||||
if (found != null && !matched.Contains(found))
|
||||
matched.Add(found);
|
||||
}
|
||||
|
||||
if (matched.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem($"'{q}' 템플릿을 찾을 수 없습니다",
|
||||
$"지원: {string.Join(", ", Templates.Keys.Take(10))}…",
|
||||
null, null, Symbol: "\uE946"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 템플릿 생성
|
||||
if (matched.Count == 1)
|
||||
{
|
||||
var key = matched[0];
|
||||
var (_, desc, content) = Templates[key];
|
||||
var trimmed = content.Trim();
|
||||
|
||||
items.Add(new LauncherItem($".gitignore [{key}]",
|
||||
$"{desc} · {trimmed.Split('\n').Length}줄 · Enter → 복사",
|
||||
null, ("copy", trimmed), Symbol: "\uEA3C"));
|
||||
|
||||
// 미리보기
|
||||
foreach (var line in trimmed.Split('\n').Take(12))
|
||||
items.Add(new LauncherItem(line, "", null, null, Symbol: "\uEA3C"));
|
||||
|
||||
if (trimmed.Split('\n').Length > 12)
|
||||
items.Add(new LauncherItem($"… 외 {trimmed.Split('\n').Length - 12}줄",
|
||||
"전체는 첫 항목 Enter로 복사", null, null, Symbol: "\uEA3C"));
|
||||
}
|
||||
else
|
||||
{
|
||||
// 다중 병합
|
||||
var sb = new System.Text.StringBuilder();
|
||||
var totalLines = 0;
|
||||
foreach (var key in matched)
|
||||
{
|
||||
var (_, desc, content) = Templates[key];
|
||||
sb.AppendLine($"# ===== {desc} =====");
|
||||
sb.AppendLine(content.Trim());
|
||||
sb.AppendLine();
|
||||
totalLines += content.Split('\n').Length;
|
||||
}
|
||||
var merged = sb.ToString().TrimEnd();
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
$".gitignore 병합 [{string.Join(" + ", matched)}]",
|
||||
$"{totalLines}줄 · Enter → 복사",
|
||||
null, ("copy", merged), Symbol: "\uEA3C"));
|
||||
|
||||
foreach (var key in matched)
|
||||
{
|
||||
var (_, desc, _) = Templates[key];
|
||||
items.Add(new LauncherItem($"[{key}]", desc, null, ("gen", key), Symbol: "\uEA3C"));
|
||||
}
|
||||
}
|
||||
|
||||
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("Gitignore", ".gitignore 내용을 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
break;
|
||||
|
||||
case ("gen", string key):
|
||||
if (Templates.TryGetValue(key, out var t))
|
||||
{
|
||||
try
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(
|
||||
() => Clipboard.SetText(t.Content.Trim()));
|
||||
NotificationService.Notify("Gitignore", $"[{key}] .gitignore 복사됨");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
break;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static string? FindTemplate(string keyword)
|
||||
{
|
||||
// 직접 키 일치
|
||||
if (Templates.ContainsKey(keyword)) return keyword;
|
||||
|
||||
// 별칭 검색
|
||||
foreach (var (key, (aliases, _, _)) in Templates)
|
||||
{
|
||||
if (aliases.Any(a => a.Equals(keyword, StringComparison.OrdinalIgnoreCase)))
|
||||
return key;
|
||||
}
|
||||
|
||||
// 부분 일치
|
||||
var partial = Templates.Keys
|
||||
.FirstOrDefault(k => k.Contains(keyword, StringComparison.OrdinalIgnoreCase));
|
||||
return partial;
|
||||
}
|
||||
}
|
||||
315
src/AxCopilot/Handlers/HexHandler.cs
Normal file
315
src/AxCopilot/Handlers/HexHandler.cs
Normal file
@@ -0,0 +1,315 @@
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L20-1: 16진수·바이트 변환기 핸들러. "hex" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: hex → 클립보드 텍스트 → hex 변환
|
||||
/// hex hello → "hello" → 68 65 6C 6C 6F
|
||||
/// hex 68656c6c6f → hex → "hello" 디코딩
|
||||
/// hex dump hello world → 헥스 덤프 형식 (오프셋·hex·ASCII)
|
||||
/// hex 0xFF → 0xFF = 255 (십진수·이진수·문자)
|
||||
/// hex add 0x1A 0x2B → hex 덧셈
|
||||
/// hex xor 0xAB 0xCD → bitwise XOR
|
||||
/// hex and 0xFF 0x0F → bitwise AND
|
||||
/// hex or 0xA0 0x0F → bitwise OR
|
||||
/// hex not 0xFF → bitwise NOT (8비트)
|
||||
/// hex bytes <n> → n바이트 크기 단위 표시 (KB·MB·GB)
|
||||
/// Enter → 결과 복사.
|
||||
/// </summary>
|
||||
public class HexHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "hex";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Hex",
|
||||
"16진수·바이트 변환기 — 텍스트↔hex·덤프·비트연산·크기 단위",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
// 클립보드 읽기
|
||||
string? clipboard = null;
|
||||
try
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
if (Clipboard.ContainsText()) clipboard = Clipboard.GetText().Trim();
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
items.Add(new LauncherItem("16진수·바이트 변환기",
|
||||
"hex <텍스트> / hex <hexstr> / hex dump <text> / hex 0xFF / hex bytes <n>",
|
||||
null, null, Symbol: "\uE8EF"));
|
||||
if (!string.IsNullOrWhiteSpace(clipboard))
|
||||
items.AddRange(BuildFromText(clipboard!, brief: true));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
// hex dump
|
||||
if (sub == "dump")
|
||||
{
|
||||
var text = parts.Length > 1 ? string.Join(" ", parts[1..]) : clipboard ?? "";
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{ items.Add(ErrorItem("텍스트를 입력하거나 클립보드에 복사하세요")); }
|
||||
else
|
||||
items.AddRange(BuildDump(text));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// hex bytes <n>
|
||||
if (sub == "bytes" && parts.Length >= 2 &&
|
||||
long.TryParse(parts[1], out var byteCount))
|
||||
{
|
||||
items.AddRange(BuildByteSize(byteCount));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// hex add/xor/and/or/not (비트 연산)
|
||||
if (sub is "add" or "xor" or "and" or "or" or "not")
|
||||
{
|
||||
items.AddRange(BuildBitOp(sub, parts));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 단일 hex 값 (0xFF, 0xAB, FF, AB...)
|
||||
if (TryParseHexValue(parts[0], out var hexVal))
|
||||
{
|
||||
items.AddRange(BuildFromHexValue(hexVal, parts[0]));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 순수 hex 문자열인지 판단 (2자 이상, 모두 hex digit, 짝수 길이)
|
||||
var raw = parts[0].Replace(" ", "").Replace("-", "").Replace(":", "");
|
||||
if (raw.Length >= 2 && raw.Length % 2 == 0 && IsAllHex(raw))
|
||||
{
|
||||
items.AddRange(BuildFromHexString(raw));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 일반 텍스트 → hex 변환
|
||||
var input = string.Join(" ", parts);
|
||||
items.AddRange(BuildFromText(input, brief: false));
|
||||
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("Hex", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 빌더 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static IEnumerable<LauncherItem> BuildFromText(string text, bool brief)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(text);
|
||||
var hex = BitConverter.ToString(bytes).Replace("-", "");
|
||||
var spaced = string.Join(" ", Enumerable.Range(0, hex.Length / 2)
|
||||
.Select(i => hex.Substring(i * 2, 2)));
|
||||
|
||||
yield return new LauncherItem(spaced,
|
||||
$"텍스트 → Hex ({bytes.Length} bytes) · Enter 복사",
|
||||
null, ("copy", spaced), Symbol: "\uE8EF");
|
||||
|
||||
if (!brief)
|
||||
{
|
||||
yield return CopyItem("공백 없음", hex);
|
||||
yield return CopyItem("소문자", hex.ToLowerInvariant());
|
||||
yield return CopyItem("0x 접두사", string.Join(" ", Enumerable.Range(0, hex.Length / 2)
|
||||
.Select(i => "0x" + hex.Substring(i * 2, 2))));
|
||||
yield return CopyItem("바이트 수", $"{bytes.Length} bytes");
|
||||
var b64 = Convert.ToBase64String(bytes);
|
||||
yield return CopyItem("Base64", b64);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<LauncherItem> BuildFromHexString(string hex)
|
||||
{
|
||||
hex = hex.ToUpperInvariant();
|
||||
byte[]? bytes = null;
|
||||
string? parseError = null;
|
||||
try
|
||||
{
|
||||
bytes = Enumerable.Range(0, hex.Length / 2)
|
||||
.Select(i => Convert.ToByte(hex.Substring(i * 2, 2), 16))
|
||||
.ToArray();
|
||||
}
|
||||
catch { parseError = "올바른 hex 문자열이 아닙니다"; }
|
||||
|
||||
if (parseError != null)
|
||||
{
|
||||
yield return ErrorItem(parseError);
|
||||
yield break;
|
||||
}
|
||||
|
||||
var safeBytes = bytes!;
|
||||
var utf8 = TrySafeUtf8(safeBytes);
|
||||
var ascii = TrySafeAscii(safeBytes);
|
||||
|
||||
yield return new LauncherItem($"Hex → 텍스트",
|
||||
$"{safeBytes.Length} bytes · UTF-8 디코딩",
|
||||
null, null, Symbol: "\uE8EF");
|
||||
|
||||
if (utf8 != null) yield return CopyItem("UTF-8 텍스트", utf8);
|
||||
if (ascii != null && ascii != utf8) yield return CopyItem("ASCII 텍스트", ascii);
|
||||
|
||||
yield return CopyItem("바이트 수", $"{safeBytes.Length}");
|
||||
// 숫자 해석 (최대 8바이트)
|
||||
if (safeBytes.Length <= 8)
|
||||
{
|
||||
var padded = new byte[8];
|
||||
Buffer.BlockCopy(safeBytes, 0, padded, 8 - safeBytes.Length, safeBytes.Length);
|
||||
var bigEndian = BitConverter.IsLittleEndian
|
||||
? BitConverter.ToUInt64(padded.Reverse().ToArray())
|
||||
: BitConverter.ToUInt64(padded);
|
||||
yield return CopyItem($"정수 (big-endian)", bigEndian.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<LauncherItem> BuildFromHexValue(ulong val, string original)
|
||||
{
|
||||
yield return new LauncherItem($"{original} = {val}",
|
||||
$"16진수 → 십진수 · Enter 복사",
|
||||
null, ("copy", val.ToString()), Symbol: "\uE8EF");
|
||||
yield return CopyItem("십진수", val.ToString());
|
||||
yield return CopyItem("16진수", $"0x{val:X}");
|
||||
yield return CopyItem("8진수", $"0o{Convert.ToString((long)val, 8)}");
|
||||
yield return CopyItem("이진수", $"0b{Convert.ToString((long)val, 2)}");
|
||||
if (val <= 127) yield return CopyItem("ASCII 문자", ((char)val).ToString());
|
||||
yield return CopyItem("NOT (64bit)", $"0x{(~val):X16}");
|
||||
}
|
||||
|
||||
private static IEnumerable<LauncherItem> BuildDump(string text)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(text);
|
||||
var lines = new List<string>();
|
||||
var sb = new StringBuilder();
|
||||
|
||||
for (int i = 0; i < bytes.Length; i += 16)
|
||||
{
|
||||
var chunk = bytes.Skip(i).Take(16).ToArray();
|
||||
var hexPart = string.Join(" ", chunk.Select(b => $"{b:X2}")).PadRight(47);
|
||||
var asciiPart = new string(chunk.Select(b => b >= 32 && b < 127 ? (char)b : '.').ToArray());
|
||||
var line = $"{i:X8} {hexPart} |{asciiPart}|";
|
||||
lines.Add(line);
|
||||
}
|
||||
|
||||
var dump = string.Join("\n", lines);
|
||||
yield return new LauncherItem("헥스 덤프",
|
||||
$"{bytes.Length} bytes · {lines.Count} 행", null, ("copy", dump), Symbol: "\uE8EF");
|
||||
foreach (var line in lines.Take(8))
|
||||
yield return new LauncherItem(line, "", null, ("copy", line), Symbol: "\uE8EF");
|
||||
if (lines.Count > 8)
|
||||
yield return new LauncherItem($"... ({lines.Count - 8}행 더 있음)",
|
||||
"전체 복사하려면 상단 항목 Enter", null, null, Symbol: "\uE8EF");
|
||||
}
|
||||
|
||||
private static IEnumerable<LauncherItem> BuildBitOp(string op, string[] parts)
|
||||
{
|
||||
if (op == "not")
|
||||
{
|
||||
if (parts.Length < 2 || !TryParseHexValue(parts[1], out var v))
|
||||
{ yield return ErrorItem("예: hex not 0xFF"); yield break; }
|
||||
var r8 = (byte)(~(byte)v);
|
||||
var r16 = (ushort)(~(ushort)v);
|
||||
yield return new LauncherItem($"NOT 0x{v:X} = 0x{r8:X2} (8bit) / 0x{r16:X4} (16bit)",
|
||||
"비트 반전", null, ("copy", $"0x{r8:X2}"), Symbol: "\uE8EF");
|
||||
yield return CopyItem("NOT 8bit", $"0x{r8:X2} ({r8})");
|
||||
yield return CopyItem("NOT 16bit", $"0x{r16:X4} ({r16})");
|
||||
yield return CopyItem("NOT 64bit", $"0x{(~v):X16}");
|
||||
yield break;
|
||||
}
|
||||
if (parts.Length < 3 || !TryParseHexValue(parts[1], out var a) || !TryParseHexValue(parts[2], out var b))
|
||||
{ yield return ErrorItem($"예: hex {op} 0xAB 0xCD"); yield break; }
|
||||
|
||||
ulong result = op switch
|
||||
{
|
||||
"add" => a + b,
|
||||
"xor" => a ^ b,
|
||||
"and" => a & b,
|
||||
"or" => a | b,
|
||||
_ => 0
|
||||
};
|
||||
var symbol = op switch { "add" => "+", "xor" => "^", "and" => "&", "or" => "|", _ => "?" };
|
||||
yield return new LauncherItem($"0x{a:X} {symbol} 0x{b:X} = 0x{result:X}",
|
||||
$"{a} {symbol} {b} = {result} · Enter 복사",
|
||||
null, ("copy", $"0x{result:X}"), Symbol: "\uE8EF");
|
||||
yield return CopyItem("16진수", $"0x{result:X}");
|
||||
yield return CopyItem("십진수", result.ToString());
|
||||
yield return CopyItem("이진수", $"0b{Convert.ToString((long)result, 2)}");
|
||||
}
|
||||
|
||||
private static IEnumerable<LauncherItem> BuildByteSize(long bytes)
|
||||
{
|
||||
yield return new LauncherItem($"{bytes:N0} bytes",
|
||||
"크기 단위 변환 · Enter 복사", null, ("copy", bytes.ToString()), Symbol: "\uE8EF");
|
||||
yield return CopyItem("Bytes", $"{bytes:N0}");
|
||||
yield return CopyItem("KB (1000)", $"{bytes / 1000.0:F3}");
|
||||
yield return CopyItem("KiB (1024)",$"{bytes / 1024.0:F3}");
|
||||
yield return CopyItem("MB (1000)", $"{bytes / 1_000_000.0:F3}");
|
||||
yield return CopyItem("MiB (1024)",$"{bytes / 1_048_576.0:F3}");
|
||||
yield return CopyItem("GB (1000)", $"{bytes / 1_000_000_000.0:F4}");
|
||||
yield return CopyItem("GiB (1024)",$"{bytes / 1_073_741_824.0:F4}");
|
||||
if (bytes >= 1_000_000_000_000L)
|
||||
yield return CopyItem("TB (1000)", $"{bytes / 1_000_000_000_000.0:F4}");
|
||||
}
|
||||
|
||||
// ── 헬퍼 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static bool TryParseHexValue(string s, out ulong val)
|
||||
{
|
||||
val = 0;
|
||||
s = s.Trim();
|
||||
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
return ulong.TryParse(s[2..], System.Globalization.NumberStyles.HexNumber, null, out val);
|
||||
if (s.StartsWith("0X", StringComparison.OrdinalIgnoreCase))
|
||||
return ulong.TryParse(s[2..], System.Globalization.NumberStyles.HexNumber, null, out val);
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsAllHex(string s) =>
|
||||
s.All(c => c is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F');
|
||||
|
||||
private static string? TrySafeUtf8(byte[] b)
|
||||
{
|
||||
try { var s = Encoding.UTF8.GetString(b); return s; }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static string? TrySafeAscii(byte[] b)
|
||||
{
|
||||
try { return Encoding.ASCII.GetString(b); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static LauncherItem CopyItem(string label, string value) =>
|
||||
new(label, value, null, ("copy", value), Symbol: "\uE8EF");
|
||||
|
||||
private static LauncherItem ErrorItem(string msg) =>
|
||||
new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783");
|
||||
}
|
||||
253
src/AxCopilot/Handlers/HostsHandler.cs
Normal file
253
src/AxCopilot/Handlers/HostsHandler.cs
Normal file
@@ -0,0 +1,253 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L12-2: Windows hosts 파일 관리 핸들러. "hosts" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: hosts → 현재 hosts 파일 항목 목록
|
||||
/// hosts search dev → "dev" 포함 항목 필터
|
||||
/// hosts open → hosts 파일을 메모장으로 열기
|
||||
/// hosts copy → 전체 hosts 내용 클립보드 복사
|
||||
/// Enter → 해당 항목을 클립보드에 복사.
|
||||
/// </summary>
|
||||
public class HostsHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "hosts";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Hosts",
|
||||
"Windows hosts 파일 뷰어 — 항목 조회 · 검색 · 복사",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private static readonly string HostsPath =
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System),
|
||||
@"drivers\etc\hosts");
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
var entries = ReadHostsEntries();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
var activeCount = entries.Count(e => !e.IsComment);
|
||||
var commentCount = entries.Count(e => e.IsComment && e.IsDisabledEntry);
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
$"hosts 파일 활성 {activeCount}개" + (commentCount > 0 ? $" · 비활성 {commentCount}개" : ""),
|
||||
HostsPath,
|
||||
null,
|
||||
("copy_path", HostsPath),
|
||||
Symbol: "\uE8D2"));
|
||||
|
||||
items.Add(new LauncherItem("파일 열기 (메모장)", HostsPath, null, ("open", HostsPath), Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("전체 내용 복사", $"{entries.Count}줄", null, ("copy_all", ""), Symbol: "\uE8A5"));
|
||||
|
||||
// 유효 항목 목록
|
||||
foreach (var e in entries.Where(e => !e.IsComment).Take(20))
|
||||
items.Add(MakeEntryItem(e));
|
||||
|
||||
// 비활성 항목 (주석 처리된 IP 항목)
|
||||
var disabled = entries.Where(e => e.IsDisabledEntry).Take(5).ToList();
|
||||
if (disabled.Count > 0)
|
||||
{
|
||||
items.Add(new LauncherItem($"── 비활성 항목 {disabled.Count}개 ──", "", null, null, Symbol: "\uE8D2"));
|
||||
foreach (var e in disabled)
|
||||
items.Add(MakeEntryItem(e));
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
switch (sub)
|
||||
{
|
||||
case "open":
|
||||
items.Add(new LauncherItem("메모장으로 열기", HostsPath, null, ("open", HostsPath), Symbol: "\uE8A5"));
|
||||
break;
|
||||
|
||||
case "copy":
|
||||
case "copy_all":
|
||||
{
|
||||
var content = ReadHostsRaw();
|
||||
items.Add(new LauncherItem("전체 내용 복사", $"{content.Split('\n').Length}줄 · Enter 복사",
|
||||
null, ("copy", content), Symbol: "\uE8A5"));
|
||||
break;
|
||||
}
|
||||
|
||||
case "search":
|
||||
case "find":
|
||||
{
|
||||
var keyword = parts.Length > 1 ? parts[1].ToLowerInvariant() : "";
|
||||
if (string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
items.Add(new LauncherItem("검색어 입력", "예: hosts search dev", null, null, Symbol: "\uE783"));
|
||||
break;
|
||||
}
|
||||
var filtered = entries.Where(e =>
|
||||
e.Hostname.Contains(keyword, StringComparison.OrdinalIgnoreCase) ||
|
||||
e.IpAddress.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (filtered.Count == 0)
|
||||
items.Add(new LauncherItem("결과 없음", $"'{keyword}' 항목 없음", null, null, Symbol: "\uE946"));
|
||||
else
|
||||
foreach (var e in filtered.Take(20))
|
||||
items.Add(MakeEntryItem(e));
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
{
|
||||
// 검색어로 처리
|
||||
var keyword = q.ToLowerInvariant();
|
||||
var filtered = entries.Where(e =>
|
||||
e.Hostname.Contains(keyword, StringComparison.OrdinalIgnoreCase) ||
|
||||
e.IpAddress.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (filtered.Count == 0)
|
||||
items.Add(new LauncherItem("결과 없음", $"'{q}' 항목 없음", null, null, Symbol: "\uE946"));
|
||||
else
|
||||
foreach (var e in filtered.Take(20))
|
||||
items.Add(MakeEntryItem(e));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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("Hosts", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
break;
|
||||
|
||||
case ("copy_path", string path):
|
||||
try
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(path));
|
||||
NotificationService.Notify("Hosts", "경로가 복사되었습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
break;
|
||||
|
||||
case ("copy_all", _):
|
||||
try
|
||||
{
|
||||
var content = ReadHostsRaw();
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(content));
|
||||
NotificationService.Notify("Hosts", $"hosts 파일 내용 복사됨 ({content.Split('\n').Length}줄)");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
break;
|
||||
|
||||
case ("open", string path):
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "notepad.exe",
|
||||
Arguments = $"\"{path}\"",
|
||||
UseShellExecute = false,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
NotificationService.Notify("Hosts", $"열기 실패: {ex.Message}");
|
||||
}
|
||||
break;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── hosts 파일 파서 ───────────────────────────────────────────────────────
|
||||
|
||||
private record HostsEntry(string IpAddress, string Hostname, string RawLine,
|
||||
bool IsComment, bool IsDisabledEntry);
|
||||
|
||||
private static List<HostsEntry> ReadHostsEntries()
|
||||
{
|
||||
var result = new List<HostsEntry>();
|
||||
string[] lines;
|
||||
try { lines = File.ReadAllLines(HostsPath, Encoding.UTF8); }
|
||||
catch { return result; }
|
||||
|
||||
foreach (var rawLine in lines)
|
||||
{
|
||||
var line = rawLine.Trim();
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
|
||||
// 순수 주석 (# 으로 시작, IP 없음)
|
||||
if (line.StartsWith('#'))
|
||||
{
|
||||
// 비활성 IP 항목인지 확인 (예: "# 127.0.0.1 example.com")
|
||||
var inner = line[1..].Trim();
|
||||
if (TryParseIpEntry(inner, out var disIp, out var disHost))
|
||||
{
|
||||
result.Add(new HostsEntry(disIp, disHost, rawLine, true, true));
|
||||
}
|
||||
// 순수 주석은 목록에서 제외
|
||||
continue;
|
||||
}
|
||||
|
||||
// IP 항목 (인라인 주석 포함 가능)
|
||||
var withoutComment = line.Contains('#') ? line[..line.IndexOf('#')].Trim() : line;
|
||||
if (TryParseIpEntry(withoutComment, out var ip, out var host))
|
||||
{
|
||||
result.Add(new HostsEntry(ip, host, rawLine, false, false));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool TryParseIpEntry(string line, out string ip, out string host)
|
||||
{
|
||||
ip = host = "";
|
||||
var parts = line.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length < 2) return false;
|
||||
|
||||
// 첫 토큰이 IP 주소 형태인지 간단히 확인
|
||||
if (!System.Net.IPAddress.TryParse(parts[0], out _)) return false;
|
||||
ip = parts[0];
|
||||
host = parts[1];
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ReadHostsRaw()
|
||||
{
|
||||
try { return File.ReadAllText(HostsPath, Encoding.UTF8); }
|
||||
catch { return "(hosts 파일을 읽을 수 없습니다)"; }
|
||||
}
|
||||
|
||||
private static LauncherItem MakeEntryItem(HostsEntry e)
|
||||
{
|
||||
var prefix = e.IsComment ? "# " : "";
|
||||
var icon = e.IsComment ? "\uE946" : "\uE8D2";
|
||||
var subtitle = e.IsComment ? "비활성 항목" : e.IpAddress;
|
||||
|
||||
return new LauncherItem(
|
||||
$"{prefix}{e.Hostname}",
|
||||
subtitle,
|
||||
null,
|
||||
("copy", $"{e.IpAddress}\t{e.Hostname}"),
|
||||
Symbol: icon);
|
||||
}
|
||||
}
|
||||
155
src/AxCopilot/Handlers/HotkeyHandler.cs
Normal file
155
src/AxCopilot/Handlers/HotkeyHandler.cs
Normal file
@@ -0,0 +1,155 @@
|
||||
using AxCopilot.Core;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L5-1: 전용 핫키 관리 핸들러.
|
||||
/// 예: hotkey → 등록된 전용 핫키 목록 표시
|
||||
/// hotkey 1doc → 라벨 또는 대상에 "1doc" 포함 항목 필터
|
||||
/// Enter 시 해당 핫키 항목의 대상을 실행합니다.
|
||||
/// </summary>
|
||||
public class HotkeyHandler : IActionHandler
|
||||
{
|
||||
private readonly SettingsService? _settings;
|
||||
|
||||
public HotkeyHandler(SettingsService? settings = null)
|
||||
{
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
public string? Prefix => "hotkey";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"HotkeyManager",
|
||||
"전용 핫키 목록 관리",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var items = new List<LauncherItem>();
|
||||
var hotkeys = _settings?.Settings.CustomHotkeys ?? new List<Models.HotkeyAssignment>();
|
||||
|
||||
if (hotkeys.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
"등록된 전용 핫키 없음",
|
||||
"설정 → 전용 핫키 탭에서 항목별 글로벌 단축키를 등록하세요",
|
||||
null,
|
||||
"__open_settings__",
|
||||
Symbol: "\uE713"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var filter = query.Trim().ToLowerInvariant();
|
||||
|
||||
foreach (var h in hotkeys)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(filter) &&
|
||||
!h.Label.Contains(filter, StringComparison.OrdinalIgnoreCase) &&
|
||||
!h.Hotkey.Contains(filter, StringComparison.OrdinalIgnoreCase) &&
|
||||
!h.Target.Contains(filter, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var typeSymbol = h.Type switch
|
||||
{
|
||||
"url" => Symbols.Globe,
|
||||
"folder" => Symbols.Folder,
|
||||
"command" => "\uE756",
|
||||
_ => Symbols.App
|
||||
};
|
||||
|
||||
var label = string.IsNullOrWhiteSpace(h.Label)
|
||||
? System.IO.Path.GetFileNameWithoutExtension(h.Target)
|
||||
: h.Label;
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
$"[{h.Hotkey}] {label}",
|
||||
h.Target,
|
||||
null,
|
||||
h,
|
||||
Symbol: typeSymbol));
|
||||
}
|
||||
|
||||
// 설정 단축키 안내 항목
|
||||
items.Add(new LauncherItem(
|
||||
"전용 핫키 설정 열기",
|
||||
"설정 → 전용 핫키 탭에서 핫키를 추가하거나 제거합니다",
|
||||
null,
|
||||
"__open_settings__",
|
||||
Symbol: "\uE713"));
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is string s && s == "__open_settings__")
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
var app = System.Windows.Application.Current as AxCopilot.App;
|
||||
app?.OpenSettingsFromChat();
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (item.Data is Models.HotkeyAssignment ha)
|
||||
{
|
||||
ExecuteHotkeyTarget(ha.Target, ha.Type);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>전용 핫키 대상을 타입에 따라 실행합니다.</summary>
|
||||
internal static void ExecuteHotkeyTarget(string target, string type)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case "url":
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = target,
|
||||
UseShellExecute = true
|
||||
});
|
||||
break;
|
||||
|
||||
case "folder":
|
||||
System.Diagnostics.Process.Start("explorer.exe", target);
|
||||
break;
|
||||
|
||||
case "command":
|
||||
var parts = target.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
var cmdFile = parts[0];
|
||||
var cmdArgs = parts.Length > 1 ? parts[1] : "";
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = cmdFile,
|
||||
Arguments = cmdArgs,
|
||||
UseShellExecute = true
|
||||
});
|
||||
break;
|
||||
|
||||
default: // app
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = target,
|
||||
UseShellExecute = true
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
Services.LogService.Info($"전용 핫키 실행: {target} ({type})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Services.LogService.Error($"전용 핫키 실행 오류: {target} — {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
191
src/AxCopilot/Handlers/HttpTesterHandler.cs
Normal file
191
src/AxCopilot/Handlers/HttpTesterHandler.cs
Normal file
@@ -0,0 +1,191 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L12-1: HTTP 요청 테스터 핸들러. "http" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: http example.com → GET 요청 (http:// 자동 추가)
|
||||
/// http https://api.example → GET 요청 + 응답 코드·시간
|
||||
/// http head https://example → HEAD 요청
|
||||
/// http post https://example → POST (빈 바디)
|
||||
/// http 192.168.1.1 → 내부 IP GET
|
||||
/// Enter → 응답 요약을 클립보드에 복사.
|
||||
///
|
||||
/// ⚠ 외부 URL: 사내 모드 차단. 내부 IP(10./192.168./172.16-31.)는 허용.
|
||||
/// </summary>
|
||||
public class HttpTesterHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "http";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"HTTP",
|
||||
"HTTP 요청 테스터 — GET · HEAD · POST · 응답 코드",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private static readonly HttpClient _client = new(new HttpClientHandler
|
||||
{
|
||||
AllowAutoRedirect = true,
|
||||
MaxAutomaticRedirections = 3,
|
||||
ServerCertificateCustomValidationCallback = (_, _, _, _) => true,
|
||||
})
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(10),
|
||||
};
|
||||
|
||||
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("HTTP 요청 테스터",
|
||||
"예: http example.com / http head https://api / http post https://url",
|
||||
null, null, Symbol: "\uE774"));
|
||||
items.Add(new LauncherItem("http localhost", "로컬 서버 GET", null, null, Symbol: "\uE774"));
|
||||
items.Add(new LauncherItem("http 192.168.1.1", "내부 IP GET", null, null, Symbol: "\uE774"));
|
||||
items.Add(new LauncherItem("http head https://…", "HEAD 요청", null, null, Symbol: "\uE774"));
|
||||
items.Add(new LauncherItem("http post https://…", "POST 요청", null, null, Symbol: "\uE774"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
string method, url;
|
||||
|
||||
if (parts[0].ToUpperInvariant() is "GET" or "HEAD" or "POST" or "PUT" or "DELETE" or "OPTIONS")
|
||||
{
|
||||
method = parts[0].ToUpperInvariant();
|
||||
url = parts.Length > 1 ? parts[1].Trim() : "";
|
||||
}
|
||||
else
|
||||
{
|
||||
method = "GET";
|
||||
url = q;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
items.Add(new LauncherItem("URL을 입력하세요", $"예: http {method.ToLower()} https://example.com", null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 스키마 자동 추가
|
||||
if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) &&
|
||||
!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
url = "http://" + url;
|
||||
}
|
||||
|
||||
// 사내 모드 확인
|
||||
var settings = (System.Windows.Application.Current as App)?.SettingsService?.Settings;
|
||||
var isInternal = settings?.InternalModeEnabled ?? true;
|
||||
if (isInternal && !IsInternalUrl(url))
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
"사내 모드 제한",
|
||||
$"외부 URL '{url}'은 차단됩니다. 설정에서 사외 모드를 활성화하세요.",
|
||||
null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
$"{method} {TruncateUrl(url)}",
|
||||
"Enter를 눌러 요청 실행",
|
||||
null,
|
||||
("request", $"{method}|{url}"),
|
||||
Symbol: "\uE774"));
|
||||
|
||||
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("HTTP", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Data is not ("request", string reqData)) return;
|
||||
|
||||
var idx = reqData.IndexOf('|');
|
||||
var method = reqData[..idx];
|
||||
var url = reqData[(idx + 1)..];
|
||||
|
||||
NotificationService.Notify("HTTP", $"{method} {TruncateUrl(url)} 요청 중…");
|
||||
|
||||
try
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
HttpResponseMessage resp;
|
||||
|
||||
using var reqMsg = new HttpRequestMessage(new HttpMethod(method), url);
|
||||
reqMsg.Headers.TryAddWithoutValidation("User-Agent", "AX-Copilot/2.0 HTTP-Tester");
|
||||
|
||||
resp = await _client.SendAsync(reqMsg, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
sw.Stop();
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"URL: {url}");
|
||||
sb.AppendLine($"메서드: {method}");
|
||||
sb.AppendLine($"상태 코드: {(int)resp.StatusCode} {resp.ReasonPhrase}");
|
||||
sb.AppendLine($"응답 시간: {sw.ElapsedMilliseconds}ms");
|
||||
sb.AppendLine($"Content-Type: {resp.Content.Headers.ContentType}");
|
||||
sb.AppendLine($"Content-Length: {resp.Content.Headers.ContentLength?.ToString() ?? "unknown"}");
|
||||
|
||||
// 주요 헤더
|
||||
foreach (var h in new[] { "Server", "X-Powered-By", "Cache-Control", "ETag", "Last-Modified" })
|
||||
if (resp.Headers.TryGetValues(h, out var vals))
|
||||
sb.AppendLine($"{h}: {string.Join(", ", vals)}");
|
||||
|
||||
var summary = sb.ToString().TrimEnd();
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(summary));
|
||||
|
||||
var status = (int)resp.StatusCode;
|
||||
var emoji = status < 300 ? "✔" : status < 400 ? "↪" : "✘";
|
||||
NotificationService.Notify("HTTP",
|
||||
$"{emoji} {status} {resp.ReasonPhrase} ({sw.ElapsedMilliseconds}ms) · 결과 복사됨");
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
NotificationService.Notify("HTTP", "요청 타임아웃 (10초 초과)");
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
NotificationService.Notify("HTTP", $"요청 오류: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
NotificationService.Notify("HTTP", $"오류: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static bool IsInternalUrl(string url)
|
||||
{
|
||||
var lower = url.ToLowerInvariant();
|
||||
return lower.Contains("://localhost") ||
|
||||
lower.Contains("://127.0.0.1") ||
|
||||
lower.Contains("://192.168.") ||
|
||||
lower.Contains("://10.") ||
|
||||
System.Text.RegularExpressions.Regex.IsMatch(lower,
|
||||
@"://172\.(1[6-9]|2\d|3[01])\.");
|
||||
}
|
||||
|
||||
private static string TruncateUrl(string url) =>
|
||||
url.Length > 60 ? url[..60] + "…" : url;
|
||||
}
|
||||
367
src/AxCopilot/Handlers/IpInfoHandler.cs
Normal file
367
src/AxCopilot/Handlers/IpInfoHandler.cs
Normal file
@@ -0,0 +1,367 @@
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L19-3: IP 주소 유틸리티 핸들러. "ip" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: ip → 로컬 IP 주소 목록
|
||||
/// ip my → 전체 어댑터 IP 목록
|
||||
/// ip 192.168.1.100 → IP 분류·타입·이진 표현
|
||||
/// ip 10.0.0.0/8 → CIDR 네트워크 정보
|
||||
/// ip 192.168.1.0/24 → 네트워크 주소·브로드캐스트·호스트 범위·수
|
||||
/// ip range 192.168.1.1 192.168.1.100 → 범위 내 IP 수
|
||||
/// ip bin 192.168.1.1 → 이진 표현
|
||||
/// ip hex 192.168.1.1 → 16진수 표현
|
||||
/// ip int 192.168.1.1 → 정수(uint32) 표현
|
||||
/// ip from 3232235777 → 정수 → IP 주소
|
||||
/// Enter → 값 복사.
|
||||
/// </summary>
|
||||
public class IpInfoHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "ip";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"IP",
|
||||
"IP 주소 유틸리티 — 분류·CIDR·이진·16진·정수 변환",
|
||||
"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("IP 주소 유틸리티",
|
||||
"ip my / ip 192.168.1.1 / ip 10.0.0.0/8 / ip bin/hex/int <IP>",
|
||||
null, null, Symbol: "\uE968"));
|
||||
BuildLocalIpItems(items, brief: true);
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
// ip my
|
||||
if (sub is "my" or "local" or "내" or "로컬")
|
||||
{
|
||||
items.Add(new LauncherItem("로컬 네트워크 어댑터 IP 목록", "", null, null, Symbol: "\uE968"));
|
||||
BuildLocalIpItems(items, brief: false);
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ip range <start> <end>
|
||||
if (sub == "range" && parts.Length >= 3)
|
||||
{
|
||||
if (IPAddress.TryParse(parts[1], out var start) &&
|
||||
IPAddress.TryParse(parts[2], out var end) &&
|
||||
start.AddressFamily == AddressFamily.InterNetwork &&
|
||||
end.AddressFamily == AddressFamily.InterNetwork)
|
||||
{
|
||||
var s = IpToUint(start);
|
||||
var e = IpToUint(end);
|
||||
if (s > e) (s, e) = (e, s);
|
||||
var count = e - s + 1;
|
||||
items.Add(new LauncherItem($"{UintToIp(s)} ~ {UintToIp(e)}",
|
||||
$"IP 수: {count:N0}개", null, null, Symbol: "\uE968"));
|
||||
items.Add(CopyItem("시작 IP", UintToIp(s)));
|
||||
items.Add(CopyItem("끝 IP", UintToIp(e)));
|
||||
items.Add(CopyItem("IP 개수", count.ToString("N0")));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(ErrorItem("올바른 IPv4 주소를 입력하세요"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ip from <uint>
|
||||
if (sub == "from" && parts.Length >= 2)
|
||||
{
|
||||
if (uint.TryParse(parts[1], out var uval))
|
||||
{
|
||||
var ip = UintToIp(uval);
|
||||
items.Add(new LauncherItem($"{uval} → {ip}", "정수 → IPv4 변환", null, ("copy", ip), Symbol: "\uE968"));
|
||||
items.Add(CopyItem("IP 주소", ip));
|
||||
items.Add(CopyItem("이진", ToBinary(uval)));
|
||||
items.Add(CopyItem("16진수", $"0x{uval:X8}"));
|
||||
}
|
||||
else items.Add(ErrorItem("올바른 32비트 정수를 입력하세요"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ip bin/hex/int <IP>
|
||||
if (sub is "bin" or "binary" or "이진" or "hex" or "int" or "integer")
|
||||
{
|
||||
if (parts.Length >= 2 && IPAddress.TryParse(parts[1], out var ip4) &&
|
||||
ip4.AddressFamily == AddressFamily.InterNetwork)
|
||||
{
|
||||
BuildConversionItems(items, ip4);
|
||||
}
|
||||
else items.Add(ErrorItem("올바른 IPv4 주소를 입력하세요"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// CIDR: 10.0.0.0/8
|
||||
if (parts[0].Contains('/'))
|
||||
{
|
||||
BuildCidrItems(items, parts[0]);
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 단순 IP 주소
|
||||
if (IPAddress.TryParse(parts[0], out var addr))
|
||||
{
|
||||
if (addr.AddressFamily == AddressFamily.InterNetwork)
|
||||
BuildIpInfoItems(items, addr);
|
||||
else if (addr.AddressFamily == AddressFamily.InterNetworkV6)
|
||||
BuildIpv6Items(items, addr);
|
||||
else
|
||||
items.Add(ErrorItem("IPv4 또는 IPv6 주소를 입력하세요"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"인식할 수 없는 입력: '{parts[0]}'",
|
||||
"ip 192.168.1.1 / ip 10.0.0.0/8 / ip my / ip bin 1.2.3.4",
|
||||
null, null, Symbol: "\uE783"));
|
||||
}
|
||||
|
||||
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("IP", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 빌더 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static void BuildLocalIpItems(List<LauncherItem> items, bool brief)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ifaces = NetworkInterface.GetAllNetworkInterfaces()
|
||||
.Where(n => n.OperationalStatus == OperationalStatus.Up &&
|
||||
n.NetworkInterfaceType != NetworkInterfaceType.Loopback)
|
||||
.ToList();
|
||||
|
||||
if (ifaces.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("활성 네트워크 어댑터 없음", "", null, null, Symbol: "\uE968"));
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var nic in ifaces)
|
||||
{
|
||||
var ipProps = nic.GetIPProperties();
|
||||
var ipv4s = ipProps.UnicastAddresses
|
||||
.Where(u => u.Address.AddressFamily == AddressFamily.InterNetwork)
|
||||
.ToList();
|
||||
|
||||
if (ipv4s.Count == 0) continue;
|
||||
|
||||
if (!brief)
|
||||
items.Add(new LauncherItem($"── {nic.Name} ──",
|
||||
$"{nic.Description} ({nic.Speed / 1_000_000} Mbps)", null, null, Symbol: "\uE968"));
|
||||
|
||||
foreach (var uni in ipv4s)
|
||||
{
|
||||
var mask = uni.IPv4Mask?.ToString() ?? "";
|
||||
var cidr = MaskToCidr(uni.IPv4Mask);
|
||||
var label = brief ? uni.Address.ToString() : $"{uni.Address}/{cidr}";
|
||||
items.Add(new LauncherItem(label,
|
||||
brief ? nic.Name : $"마스크: {mask} CIDR: /{cidr} ({ClassifyIp(uni.Address)})",
|
||||
null, ("copy", uni.Address.ToString()), Symbol: "\uE968"));
|
||||
}
|
||||
|
||||
if (!brief)
|
||||
{
|
||||
var gateways = ipProps.GatewayAddresses
|
||||
.Where(g => g.Address.AddressFamily == AddressFamily.InterNetwork)
|
||||
.Select(g => g.Address.ToString()).ToList();
|
||||
if (gateways.Count > 0)
|
||||
items.Add(new LauncherItem("게이트웨이",
|
||||
string.Join(", ", gateways), null, ("copy", gateways[0]), Symbol: "\uE968"));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
items.Add(ErrorItem($"네트워크 정보 조회 오류: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void BuildIpInfoItems(List<LauncherItem> items, IPAddress ip)
|
||||
{
|
||||
var type = ClassifyIp(ip);
|
||||
var uval = IpToUint(ip);
|
||||
items.Add(new LauncherItem($"{ip} ({type})",
|
||||
$"클래스: {GetClass(uval)} · Enter 복사", null, ("copy", ip.ToString()), Symbol: "\uE968"));
|
||||
items.Add(CopyItem("IP 주소", ip.ToString()));
|
||||
items.Add(CopyItem("분류", type));
|
||||
items.Add(CopyItem("클래스", GetClass(uval)));
|
||||
items.Add(CopyItem("이진", ToBinary(uval)));
|
||||
items.Add(CopyItem("16진수", $"0x{uval:X8}"));
|
||||
items.Add(CopyItem("정수", uval.ToString()));
|
||||
BuildConversionItems(items, ip);
|
||||
}
|
||||
|
||||
private static void BuildConversionItems(List<LauncherItem> items, IPAddress ip)
|
||||
{
|
||||
var uval = IpToUint(ip);
|
||||
var bin = ToBinary(uval);
|
||||
items.Add(new LauncherItem("── 변환 ──", "", null, null, Symbol: "\uE968"));
|
||||
items.Add(CopyItem("이진 (32bit)", bin));
|
||||
items.Add(CopyItem("이진 (점구분)", ToBinaryDotted(uval)));
|
||||
items.Add(CopyItem("16진수", $"0x{uval:X8}"));
|
||||
items.Add(CopyItem("정수 (uint32)", uval.ToString()));
|
||||
}
|
||||
|
||||
private static void BuildIpv6Items(List<LauncherItem> items, IPAddress ip)
|
||||
{
|
||||
items.Add(new LauncherItem($"{ip}", "IPv6 주소", null, ("copy", ip.ToString()), Symbol: "\uE968"));
|
||||
items.Add(CopyItem("전체 표기", ip.ToString()));
|
||||
var expanded = ExpandIpv6(ip);
|
||||
if (expanded != ip.ToString())
|
||||
items.Add(CopyItem("확장 표기", expanded));
|
||||
}
|
||||
|
||||
private static void BuildCidrItems(List<LauncherItem> items, string cidr)
|
||||
{
|
||||
var slash = cidr.IndexOf('/');
|
||||
if (slash < 0) { items.Add(ErrorItem("올바른 CIDR 형식: 10.0.0.0/8")); return; }
|
||||
|
||||
var ipStr = cidr[..slash];
|
||||
var lenStr = cidr[(slash + 1)..];
|
||||
|
||||
if (!IPAddress.TryParse(ipStr, out var addr) || addr.AddressFamily != AddressFamily.InterNetwork ||
|
||||
!int.TryParse(lenStr, out var prefix) || prefix < 0 || prefix > 32)
|
||||
{
|
||||
items.Add(ErrorItem("올바른 IPv4 CIDR을 입력하세요 (예: 192.168.1.0/24)"));
|
||||
return;
|
||||
}
|
||||
|
||||
var mask = CidrToMask(prefix);
|
||||
var maskIp = UintToIp(mask);
|
||||
var wildcard = ~mask;
|
||||
var network = IpToUint(addr) & mask;
|
||||
var broadcast = network | wildcard;
|
||||
var hostCount = prefix < 31 ? (broadcast - network - 1) : (broadcast - network + 1);
|
||||
var firstHost = prefix < 31 ? network + 1 : network;
|
||||
var lastHost = prefix < 31 ? broadcast - 1 : broadcast;
|
||||
|
||||
items.Add(new LauncherItem($"{cidr} → {IpToHostCount(prefix)}",
|
||||
$"네트워크: {UintToIp(network)} 브로드캐스트: {UintToIp(broadcast)}", null, null, Symbol: "\uE968"));
|
||||
items.Add(CopyItem("네트워크 주소", UintToIp(network)));
|
||||
items.Add(CopyItem("브로드캐스트 주소", UintToIp(broadcast)));
|
||||
items.Add(CopyItem("서브넷 마스크", maskIp.ToString()));
|
||||
items.Add(CopyItem("와일드카드 마스크", UintToIp(wildcard)));
|
||||
items.Add(CopyItem("첫 번째 호스트", UintToIp(firstHost)));
|
||||
items.Add(CopyItem("마지막 호스트", UintToIp(lastHost)));
|
||||
items.Add(CopyItem("호스트 수", $"{hostCount:N0}"));
|
||||
items.Add(CopyItem("CIDR 표기", $"{UintToIp(network)}/{prefix}"));
|
||||
items.Add(CopyItem("이진 마스크", ToBinaryDotted(mask)));
|
||||
}
|
||||
|
||||
// ── 헬퍼 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static string ClassifyIp(IPAddress ip)
|
||||
{
|
||||
var u = IpToUint(ip);
|
||||
if (u == 0x7F000001u) return "루프백 (localhost)";
|
||||
if ((u & 0xFF000000u) == 0x7F000000u) return "루프백";
|
||||
if ((u & 0xFF000000u) == 0x0A000000u) return "사설 (Class A: 10.x.x.x)";
|
||||
if ((u & 0xFFF00000u) == 0xAC100000u) return "사설 (Class B: 172.16-31.x.x)";
|
||||
if ((u & 0xFFFF0000u) == 0xC0A80000u) return "사설 (Class C: 192.168.x.x)";
|
||||
if ((u & 0xFFFF0000u) == 0xA9FE0000u) return "링크-로컬 (APIPA: 169.254.x.x)";
|
||||
if ((u & 0xF0000000u) == 0xE0000000u) return "멀티캐스트 (224.x.x.x)";
|
||||
if (u == 0xFFFFFFFFu) return "브로드캐스트";
|
||||
if (u == 0u) return "0.0.0.0 (비지정)";
|
||||
return "공인 IP";
|
||||
}
|
||||
|
||||
private static string GetClass(uint u)
|
||||
{
|
||||
var b = (u >> 24) & 0xFF;
|
||||
return b switch
|
||||
{
|
||||
<= 127 => "Class A",
|
||||
<= 191 => "Class B",
|
||||
<= 223 => "Class C",
|
||||
<= 239 => "Class D (멀티캐스트)",
|
||||
_ => "Class E (예약)"
|
||||
};
|
||||
}
|
||||
|
||||
private static uint IpToUint(IPAddress ip)
|
||||
{
|
||||
var bytes = ip.GetAddressBytes();
|
||||
if (bytes.Length != 4) return 0;
|
||||
return (uint)((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]);
|
||||
}
|
||||
|
||||
private static string UintToIp(uint u) =>
|
||||
$"{(u >> 24) & 0xFF}.{(u >> 16) & 0xFF}.{(u >> 8) & 0xFF}.{u & 0xFF}";
|
||||
|
||||
private static string ToBinary(uint u) => Convert.ToString((long)u, 2).PadLeft(32, '0');
|
||||
|
||||
private static string ToBinaryDotted(uint u)
|
||||
{
|
||||
var b = ToBinary(u);
|
||||
return $"{b[..8]}.{b[8..16]}.{b[16..24]}.{b[24..]}";
|
||||
}
|
||||
|
||||
private static uint CidrToMask(int prefix) =>
|
||||
prefix == 0 ? 0u : (0xFFFFFFFFu << (32 - prefix));
|
||||
|
||||
private static int MaskToCidr(IPAddress? mask)
|
||||
{
|
||||
if (mask == null) return 0;
|
||||
var u = IpToUint(mask);
|
||||
int c = 0;
|
||||
while ((u & 0x80000000u) != 0) { c++; u <<= 1; }
|
||||
return c;
|
||||
}
|
||||
|
||||
private static string IpToHostCount(int prefix) =>
|
||||
prefix switch
|
||||
{
|
||||
32 => "1 IP (단일 호스트)",
|
||||
31 => "2 IP (P2P 링크)",
|
||||
30 => "4 IP (2 호스트)",
|
||||
_ => $"{(1L << (32 - prefix)):N0} IP ({(1L << (32 - prefix)) - 2:N0} 호스트)"
|
||||
};
|
||||
|
||||
private static string ExpandIpv6(IPAddress ip)
|
||||
{
|
||||
var bytes = ip.GetAddressBytes();
|
||||
var groups = new string[8];
|
||||
for (int i = 0; i < 8; i++)
|
||||
groups[i] = $"{bytes[i * 2]:X2}{bytes[i * 2 + 1]:X2}";
|
||||
return string.Join(":", groups).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static LauncherItem CopyItem(string label, string value) =>
|
||||
new(label, value, null, ("copy", value), Symbol: "\uE968");
|
||||
|
||||
private static LauncherItem ErrorItem(string msg) =>
|
||||
new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783");
|
||||
}
|
||||
298
src/AxCopilot/Handlers/JwtHandler.cs
Normal file
298
src/AxCopilot/Handlers/JwtHandler.cs
Normal file
@@ -0,0 +1,298 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L11-2: JWT 토큰 디코더 핸들러. "jwt" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: jwt → 클립보드의 JWT 자동 분석
|
||||
/// jwt eyJhbGci... → 토큰 직접 입력
|
||||
/// jwt header → 클립보드 JWT 헤더만 표시
|
||||
/// jwt payload → 클립보드 JWT 페이로드만 표시
|
||||
/// Enter → 결과를 클립보드에 복사.
|
||||
/// 주의: 서명(signature) 검증은 수행하지 않음 — 분석 전용.
|
||||
/// </summary>
|
||||
public class JwtHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "jwt";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"JWT",
|
||||
"JWT 토큰 디코더 — 헤더 · 페이로드 · 만료일 분석",
|
||||
"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 clip = GetClipboard();
|
||||
if (LooksJwt(clip))
|
||||
{
|
||||
items.AddRange(DecodeJwt(clip, "all"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem("JWT 디코더",
|
||||
"JWT 토큰을 클립보드에 복사하거나 직접 입력하세요",
|
||||
null, null, Symbol: "\uE72E"));
|
||||
items.Add(new LauncherItem("jwt eyJ…", "토큰 직접 입력", null, null, Symbol: "\uE72E"));
|
||||
items.Add(new LauncherItem("jwt header", "헤더만 표시", null, null, Symbol: "\uE72E"));
|
||||
items.Add(new LauncherItem("jwt payload", "페이로드만 표시", null, null, Symbol: "\uE72E"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
switch (sub)
|
||||
{
|
||||
case "header":
|
||||
{
|
||||
var src = GetTokenSource(parts);
|
||||
items.AddRange(DecodeJwt(src, "header"));
|
||||
break;
|
||||
}
|
||||
case "payload":
|
||||
case "claims":
|
||||
case "body":
|
||||
{
|
||||
var src = GetTokenSource(parts);
|
||||
items.AddRange(DecodeJwt(src, "payload"));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
{
|
||||
// 토큰 자체 입력 (eyJ로 시작)
|
||||
var token = LooksJwt(q) ? q : GetClipboard();
|
||||
if (!LooksJwt(token))
|
||||
{
|
||||
items.Add(new LauncherItem("JWT 형식 아님",
|
||||
"eyJ…로 시작하는 JWT 토큰을 입력하거나 클립보드에 복사하세요",
|
||||
null, null, Symbol: "\uE783"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.AddRange(DecodeJwt(token, "all"));
|
||||
}
|
||||
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("JWT", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── JWT 디코딩 ────────────────────────────────────────────────────────────
|
||||
|
||||
private static IEnumerable<LauncherItem> DecodeJwt(string token, string mode)
|
||||
{
|
||||
if (!LooksJwt(token))
|
||||
{
|
||||
yield return new LauncherItem("JWT 없음",
|
||||
"클립보드에 eyJ…로 시작하는 JWT가 없습니다", null, null, Symbol: "\uE783");
|
||||
yield break;
|
||||
}
|
||||
|
||||
var parts = token.Split('.');
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
yield return new LauncherItem("형식 오류", "JWT는 최소 2개의 점(.)으로 구성됩니다", null, null, Symbol: "\uE783");
|
||||
yield break;
|
||||
}
|
||||
|
||||
// 헤더 디코딩
|
||||
if (mode is "header" or "all")
|
||||
{
|
||||
var headerJson = TryDecodeBase64Url(parts[0]);
|
||||
if (headerJson != null)
|
||||
{
|
||||
var pretty = TryPrettyJson(headerJson) ?? headerJson;
|
||||
yield return new LauncherItem("─ 헤더 ─", "", null, null, Symbol: "\uE72E");
|
||||
foreach (var item in ExtractJsonFields(headerJson, "헤더"))
|
||||
yield return item;
|
||||
yield return new LauncherItem("헤더 JSON", "전체 복사", null, ("copy", pretty), Symbol: "\uE72E");
|
||||
}
|
||||
}
|
||||
|
||||
// 페이로드 디코딩
|
||||
if (mode is "payload" or "all")
|
||||
{
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
yield return new LauncherItem("페이로드 없음", "JWT에 페이로드가 없습니다", null, null, Symbol: "\uE783");
|
||||
yield break;
|
||||
}
|
||||
|
||||
var payloadJson = TryDecodeBase64Url(parts[1]);
|
||||
if (payloadJson != null)
|
||||
{
|
||||
yield return new LauncherItem("─ 페이로드 ─", "", null, null, Symbol: "\uE72E");
|
||||
|
||||
// 만료일(exp) 특별 처리
|
||||
var expItem = ExtractExpiry(payloadJson);
|
||||
if (expItem != null) yield return expItem;
|
||||
|
||||
foreach (var item in ExtractJsonFields(payloadJson, "페이로드"))
|
||||
yield return item;
|
||||
|
||||
var pretty = TryPrettyJson(payloadJson) ?? payloadJson;
|
||||
yield return new LauncherItem("페이로드 JSON", "전체 복사", null, ("copy", pretty), Symbol: "\uE72E");
|
||||
}
|
||||
}
|
||||
|
||||
// 서명 유무
|
||||
if (mode == "all")
|
||||
{
|
||||
var hasSig = parts.Length >= 3 && !string.IsNullOrEmpty(parts[2]);
|
||||
yield return new LauncherItem(
|
||||
"서명",
|
||||
hasSig ? "있음 (검증 미지원 — 분석 전용)" : "없음 (alg:none)",
|
||||
null, null, Symbol: "\uE72E");
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<LauncherItem> ExtractJsonFields(string json, string section)
|
||||
{
|
||||
JsonDocument doc;
|
||||
try { doc = JsonDocument.Parse(json); }
|
||||
catch { yield break; }
|
||||
|
||||
using (doc)
|
||||
{
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
var val = prop.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => prop.Value.GetString() ?? "",
|
||||
JsonValueKind.Number => prop.Value.GetRawText(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
JsonValueKind.Null => "null",
|
||||
_ => prop.Value.GetRawText(),
|
||||
};
|
||||
|
||||
// exp, iat, nbf 는 타임스탬프 → 날짜 변환해서 별도 표시
|
||||
if (prop.Name is "exp" or "iat" or "nbf")
|
||||
{
|
||||
if (long.TryParse(val, out var ts))
|
||||
{
|
||||
var dt = DateTimeOffset.FromUnixTimeSeconds(ts).ToLocalTime();
|
||||
var label = prop.Name switch { "exp" => "만료(exp)", "iat" => "발급(iat)", _ => "유효 시작(nbf)" };
|
||||
yield return new LauncherItem(label, dt.ToString("yyyy-MM-dd HH:mm:ss"), null, ("copy", dt.ToString("o")), Symbol: "\uE72E");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var display = val.Length > 60 ? val[..60] + "…" : val;
|
||||
yield return new LauncherItem(prop.Name, display, null, ("copy", val), Symbol: "\uE72E");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static LauncherItem? ExtractExpiry(string payloadJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
var doc = JsonDocument.Parse(payloadJson);
|
||||
if (!doc.RootElement.TryGetProperty("exp", out var expProp)) return null;
|
||||
if (!expProp.TryGetInt64(out var exp)) return null;
|
||||
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
var dt = DateTimeOffset.FromUnixTimeSeconds(exp).ToLocalTime();
|
||||
var remain = exp - now;
|
||||
|
||||
string status;
|
||||
if (remain < 0)
|
||||
status = $"만료됨 ({Math.Abs(remain / 60)}분 전)";
|
||||
else if (remain < 60)
|
||||
status = $"곧 만료 ({remain}초 남음)";
|
||||
else if (remain < 3600)
|
||||
status = $"유효 ({remain / 60}분 남음)";
|
||||
else if (remain < 86400)
|
||||
status = $"유효 ({remain / 3600}시간 남음)";
|
||||
else
|
||||
status = $"유효 ({remain / 86400}일 남음)";
|
||||
|
||||
return new LauncherItem(
|
||||
$"만료 상태: {status}",
|
||||
dt.ToString("yyyy-MM-dd HH:mm:ss"),
|
||||
null, null, Symbol: remain < 0 ? "\uE783" : "\uE73E");
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static string? TryDecodeBase64Url(string input)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Base64Url → Base64
|
||||
var base64 = input.Replace('-', '+').Replace('_', '/');
|
||||
var pad = (4 - base64.Length % 4) % 4;
|
||||
base64 += new string('=', pad);
|
||||
var bytes = Convert.FromBase64String(base64);
|
||||
return Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static string? TryPrettyJson(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
var doc = JsonDocument.Parse(json);
|
||||
return JsonSerializer.Serialize(doc.RootElement,
|
||||
new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static bool LooksJwt(string? s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s)) return false;
|
||||
s = s.Trim();
|
||||
return s.StartsWith("eyJ", StringComparison.Ordinal) && s.Contains('.');
|
||||
}
|
||||
|
||||
private static string GetTokenSource(string[] parts)
|
||||
{
|
||||
// parts[1]에 토큰이 있으면 사용, 없으면 클립보드
|
||||
if (parts.Length > 1 && LooksJwt(parts[1])) return parts[1];
|
||||
return GetClipboard();
|
||||
}
|
||||
|
||||
private static string GetClipboard()
|
||||
{
|
||||
try
|
||||
{
|
||||
return System.Windows.Application.Current.Dispatcher.Invoke(
|
||||
() => Clipboard.ContainsText() ? Clipboard.GetText().Trim() : "");
|
||||
}
|
||||
catch { return ""; }
|
||||
}
|
||||
}
|
||||
391
src/AxCopilot/Handlers/KeyHandler.cs
Normal file
391
src/AxCopilot/Handlers/KeyHandler.cs
Normal file
@@ -0,0 +1,391 @@
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L21-4: 키보드 단축키 참조 사전 핸들러. "key" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: key → 앱 카테고리 목록
|
||||
/// key win → Windows 단축키
|
||||
/// key vscode → VS Code 단축키
|
||||
/// key chrome → Chrome/Edge 단축키
|
||||
/// key vim → Vim 명령
|
||||
/// key excel → Excel 단축키
|
||||
/// key terminal → Windows Terminal 단축키
|
||||
/// key <키워드> → 전체 단축키에서 키워드 검색 (예: key 찾기, key find)
|
||||
/// Enter → 단축키 복사.
|
||||
/// </summary>
|
||||
public class KeyHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "key";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Key",
|
||||
"키보드 단축키 참조 사전 — Windows·VS Code·Chrome·Vim·Excel",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private record Shortcut(string Keys, string Description, string App);
|
||||
|
||||
private static readonly Shortcut[] All =
|
||||
[
|
||||
// Windows
|
||||
new("Win + D", "바탕화면 보기/복원", "win"),
|
||||
new("Win + E", "파일 탐색기 열기", "win"),
|
||||
new("Win + L", "PC 잠금", "win"),
|
||||
new("Win + R", "실행 대화상자", "win"),
|
||||
new("Win + S", "Windows 검색", "win"),
|
||||
new("Win + V", "클립보드 히스토리", "win"),
|
||||
new("Win + X", "빠른 링크 메뉴 (전원 메뉴)", "win"),
|
||||
new("Win + Tab", "작업 보기(가상 데스크톱)", "win"),
|
||||
new("Win + Ctrl + D", "새 가상 데스크톱 추가", "win"),
|
||||
new("Win + Ctrl + →/←", "가상 데스크톱 전환", "win"),
|
||||
new("Win + ↑", "창 최대화", "win"),
|
||||
new("Win + ↓", "창 최소화/복원", "win"),
|
||||
new("Win + ←/→", "창 좌/우 절반 스냅", "win"),
|
||||
new("Win + Shift + S", "화면 부분 캡처 (Snipping Tool)", "win"),
|
||||
new("Alt + Tab", "실행 중인 앱 전환", "win"),
|
||||
new("Alt + F4", "현재 창 닫기", "win"),
|
||||
new("Ctrl + Shift + Esc", "작업 관리자 직접 열기", "win"),
|
||||
new("Ctrl + Alt + Del", "보안 옵션 화면", "win"),
|
||||
new("F2", "선택 항목 이름 변경", "win"),
|
||||
new("F5", "새로고침", "win"),
|
||||
new("F11", "전체화면 토글", "win"),
|
||||
new("Win + . (점)", "이모지 피커", "win"),
|
||||
new("Win + P", "프로젝션 모드 선택 (다중 모니터)", "win"),
|
||||
new("Win + I", "Windows 설정", "win"),
|
||||
new("PrtSc", "전체 화면 캡처 → 클립보드", "win"),
|
||||
new("Alt + PrtSc", "현재 창 캡처 → 클립보드", "win"),
|
||||
|
||||
// VS Code
|
||||
new("Ctrl + P", "파일 빠른 열기", "vscode"),
|
||||
new("Ctrl + Shift + P", "명령 팔레트", "vscode"),
|
||||
new("Ctrl + `", "통합 터미널 열기/닫기", "vscode"),
|
||||
new("Ctrl + B", "사이드바 토글", "vscode"),
|
||||
new("Ctrl + J", "패널 토글(터미널·출력)", "vscode"),
|
||||
new("Ctrl + F", "파일 내 찾기", "vscode"),
|
||||
new("Ctrl + H", "파일 내 찾아 바꾸기", "vscode"),
|
||||
new("Ctrl + Shift + F", "전체 검색", "vscode"),
|
||||
new("Ctrl + Shift + H", "전체 찾아 바꾸기", "vscode"),
|
||||
new("Ctrl + G", "줄 이동", "vscode"),
|
||||
new("Ctrl + /", "줄 주석 토글", "vscode"),
|
||||
new("Alt + ↑/↓", "현재 줄 위/아래 이동", "vscode"),
|
||||
new("Alt + Shift + ↑/↓","현재 줄 위/아래 복사", "vscode"),
|
||||
new("Ctrl + D", "다음 동일 단어 선택", "vscode"),
|
||||
new("Ctrl + Shift + L", "동일 단어 모두 선택", "vscode"),
|
||||
new("Ctrl + Shift + K", "현재 줄 삭제", "vscode"),
|
||||
new("Ctrl + Enter", "아래에 새 줄 추가", "vscode"),
|
||||
new("Ctrl + Shift + Enter", "위에 새 줄 추가", "vscode"),
|
||||
new("F12", "정의로 이동", "vscode"),
|
||||
new("Alt + F12", "정의 미리보기", "vscode"),
|
||||
new("Shift + F12", "모든 참조 찾기", "vscode"),
|
||||
new("F2", "기호 이름 변경(리팩터링)", "vscode"),
|
||||
new("Ctrl + .", "빠른 수정 (Quick Fix)", "vscode"),
|
||||
new("Ctrl + K Ctrl + F","선택 영역 포맷", "vscode"),
|
||||
new("Shift + Alt + F", "전체 파일 포맷", "vscode"),
|
||||
new("Ctrl + Z / Y", "실행 취소 / 다시 실행", "vscode"),
|
||||
new("Ctrl + W", "현재 탭 닫기", "vscode"),
|
||||
new("Ctrl + Tab", "에디터 탭 전환", "vscode"),
|
||||
new("Ctrl + \\", "에디터 분할", "vscode"),
|
||||
new("Ctrl + 1/2/3", "에디터 그룹 포커스", "vscode"),
|
||||
|
||||
// Chrome / Edge
|
||||
new("Ctrl + T", "새 탭 열기", "chrome"),
|
||||
new("Ctrl + W", "현재 탭 닫기", "chrome"),
|
||||
new("Ctrl + Shift + T", "닫은 탭 다시 열기", "chrome"),
|
||||
new("Ctrl + Tab", "다음 탭으로 이동", "chrome"),
|
||||
new("Ctrl + Shift + Tab","이전 탭으로 이동", "chrome"),
|
||||
new("Ctrl + 1~8", "번호로 탭 이동", "chrome"),
|
||||
new("Ctrl + L", "주소창 포커스", "chrome"),
|
||||
new("Ctrl + D", "현재 페이지 북마크", "chrome"),
|
||||
new("Ctrl + F", "페이지 내 찾기", "chrome"),
|
||||
new("Ctrl + H", "방문 기록", "chrome"),
|
||||
new("Ctrl + J", "다운로드 목록", "chrome"),
|
||||
new("Ctrl + Shift + J", "개발자 도구 콘솔", "chrome"),
|
||||
new("F12", "개발자 도구 토글", "chrome"),
|
||||
new("F5 / Ctrl + R", "페이지 새로고침", "chrome"),
|
||||
new("Ctrl + Shift + R", "캐시 무시 새로고침", "chrome"),
|
||||
new("Ctrl + +/-", "페이지 확대/축소", "chrome"),
|
||||
new("Ctrl + 0", "확대/축소 기본값 복원", "chrome"),
|
||||
new("Alt + ← / →", "뒤로/앞으로", "chrome"),
|
||||
new("Ctrl + N", "새 창 열기", "chrome"),
|
||||
new("Ctrl + Shift + N", "시크릿 창", "chrome"),
|
||||
new("Ctrl + Shift + B", "북마크 바 토글", "chrome"),
|
||||
new("Ctrl + U", "페이지 소스 보기", "chrome"),
|
||||
|
||||
// Vim
|
||||
new("i", "삽입 모드 진입", "vim"),
|
||||
new("Esc", "노멀 모드로 복귀", "vim"),
|
||||
new(":w", "저장", "vim"),
|
||||
new(":q", "종료", "vim"),
|
||||
new(":wq / :x", "저장 후 종료", "vim"),
|
||||
new(":q!", "저장 없이 강제 종료", "vim"),
|
||||
new("h/j/k/l", "좌/하/상/우 이동", "vim"),
|
||||
new("w / b", "단어 앞/뒤로 이동", "vim"),
|
||||
new("gg / G", "파일 처음 / 끝으로 이동", "vim"),
|
||||
new("0 / $", "줄 처음 / 끝으로 이동", "vim"),
|
||||
new("dd", "현재 줄 삭제", "vim"),
|
||||
new("yy", "현재 줄 복사(yank)", "vim"),
|
||||
new("p / P", "붙여넣기 (다음/현재 위치)", "vim"),
|
||||
new("u / Ctrl + R", "실행 취소 / 다시 실행", "vim"),
|
||||
new("/keyword", "앞으로 검색", "vim"),
|
||||
new("n / N", "다음/이전 검색 결과", "vim"),
|
||||
new(":%s/old/new/g", "전체 치환", "vim"),
|
||||
new("v / V", "비주얼 모드 (문자/줄)", "vim"),
|
||||
new("Ctrl + V", "비주얼 블록 모드", "vim"),
|
||||
new(":split / :vsplit", "수평/수직 창 분할", "vim"),
|
||||
new("Ctrl + W + W", "다음 창으로 포커스 이동", "vim"),
|
||||
new(":noh", "검색 하이라이트 제거", "vim"),
|
||||
new(">> / <<", "들여쓰기 추가/제거", "vim"),
|
||||
|
||||
// Excel
|
||||
new("Ctrl + Arrow", "데이터 끝으로 이동", "excel"),
|
||||
new("Ctrl + Shift + Arrow","데이터 끝까지 선택", "excel"),
|
||||
new("Ctrl + Home / End","셀 A1 / 마지막 셀로 이동", "excel"),
|
||||
new("Ctrl + Space", "열 전체 선택", "excel"),
|
||||
new("Shift + Space", "행 전체 선택", "excel"),
|
||||
new("Ctrl + Shift + +", "행/열 삽입", "excel"),
|
||||
new("Ctrl + -", "행/열 삭제", "excel"),
|
||||
new("Ctrl + D", "아래로 채우기", "excel"),
|
||||
new("Ctrl + R", "오른쪽으로 채우기", "excel"),
|
||||
new("Alt + =", "SUM 자동 합계", "excel"),
|
||||
new("F4", "참조 절대/상대 전환 ($)", "excel"),
|
||||
new("Ctrl + 1", "셀 서식 창", "excel"),
|
||||
new("Ctrl + ;", "현재 날짜 입력", "excel"),
|
||||
new("Ctrl + Shift + ;", "현재 시간 입력", "excel"),
|
||||
new("F2", "셀 편집 모드", "excel"),
|
||||
new("Alt + Enter", "셀 내 줄바꿈", "excel"),
|
||||
new("Ctrl + Z / Y", "실행 취소 / 다시 실행", "excel"),
|
||||
new("Ctrl + F", "찾기", "excel"),
|
||||
new("Ctrl + H", "찾아 바꾸기", "excel"),
|
||||
new("Ctrl + Shift + L", "필터 자동 설정 토글", "excel"),
|
||||
|
||||
// Windows Terminal
|
||||
new("Ctrl + Shift + T", "새 탭 열기", "terminal"),
|
||||
new("Ctrl + Shift + W", "탭 닫기", "terminal"),
|
||||
new("Ctrl + Tab", "다음 탭 이동", "terminal"),
|
||||
new("Ctrl + Shift + 1~9","번호 탭으로 이동", "terminal"),
|
||||
new("Ctrl + Shift + D", "창 복제", "terminal"),
|
||||
new("Alt + Shift + D", "창 분할 (자동 방향)", "terminal"),
|
||||
new("Alt + Shift + +", "수평 분할", "terminal"),
|
||||
new("Alt + Shift + -", "수직 분할", "terminal"),
|
||||
new("Alt + Arrow", "분할된 창 간 포커스 이동", "terminal"),
|
||||
new("Ctrl + +/-", "폰트 크기 변경", "terminal"),
|
||||
new("Ctrl + F", "터미널 내 텍스트 검색", "terminal"),
|
||||
new("Ctrl + Shift + P", "명령 팔레트", "terminal"),
|
||||
new("Ctrl + Shift + F", "전체 화면 토글", "terminal"),
|
||||
|
||||
// Word
|
||||
new("Ctrl + B", "굵게", "word"),
|
||||
new("Ctrl + I", "기울임꼴", "word"),
|
||||
new("Ctrl + U", "밑줄", "word"),
|
||||
new("Ctrl + Z", "실행 취소", "word"),
|
||||
new("Ctrl + Y", "다시 실행", "word"),
|
||||
new("Ctrl + S", "저장", "word"),
|
||||
new("Ctrl + P", "인쇄", "word"),
|
||||
new("Ctrl + F", "찾기", "word"),
|
||||
new("Ctrl + H", "찾아 바꾸기", "word"),
|
||||
new("Ctrl + A", "전체 선택", "word"),
|
||||
new("Ctrl + C / X / V", "복사 / 잘라내기 / 붙여넣기", "word"),
|
||||
new("Ctrl + K", "하이퍼링크 삽입", "word"),
|
||||
new("Ctrl + Enter", "페이지 나누기 삽입", "word"),
|
||||
new("Alt + Shift + D", "현재 날짜 삽입", "word"),
|
||||
new("Ctrl + Shift + >/<","글꼴 크기 늘리기/줄이기", "word"),
|
||||
new("Ctrl + ] / [", "글꼴 크기 +1 / -1", "word"),
|
||||
new("Ctrl + L/E/R/J", "왼쪽/가운데/오른쪽/양쪽 정렬", "word"),
|
||||
new("Ctrl + 1 / 2 / 5", "줄 간격 1 / 2 / 1.5배", "word"),
|
||||
new("Ctrl + Alt + 1/2/3","제목1 / 제목2 / 제목3 스타일", "word"),
|
||||
new("Ctrl + Shift + N", "기본 스타일 적용", "word"),
|
||||
new("F7", "맞춤법 및 문법 검사", "word"),
|
||||
new("Ctrl + Shift + C", "서식 복사", "word"),
|
||||
new("Ctrl + Shift + V", "서식 붙여넣기", "word"),
|
||||
new("Shift + F3", "대/소문자 전환", "word"),
|
||||
new("Ctrl + Home / End","문서 처음 / 끝으로 이동", "word"),
|
||||
|
||||
// PowerPoint
|
||||
new("F5", "처음부터 슬라이드 쇼 시작", "ppt"),
|
||||
new("Shift + F5", "현재 슬라이드부터 시작", "ppt"),
|
||||
new("Esc", "슬라이드 쇼 종료", "ppt"),
|
||||
new("B", "슬라이드 쇼 중 화면 검정", "ppt"),
|
||||
new("W", "슬라이드 쇼 중 화면 흰색", "ppt"),
|
||||
new("Ctrl + M", "새 슬라이드 삽입", "ppt"),
|
||||
new("Ctrl + D", "슬라이드 복제", "ppt"),
|
||||
new("Ctrl + Z / Y", "실행 취소 / 다시 실행", "ppt"),
|
||||
new("Ctrl + S", "저장", "ppt"),
|
||||
new("Ctrl + G", "개체 그룹화", "ppt"),
|
||||
new("Ctrl + Shift + G", "그룹 해제", "ppt"),
|
||||
new("Tab", "다음 개체로 포커스 이동", "ppt"),
|
||||
new("Shift + Tab", "이전 개체로 포커스 이동", "ppt"),
|
||||
new("Ctrl + A", "모든 개체 선택", "ppt"),
|
||||
new("Alt + Shift + ←/→","슬라이드 내 개체 정렬 (왼쪽/오른쪽)","ppt"),
|
||||
new("Ctrl + Shift + >/<","글꼴 크기 늘리기/줄이기", "ppt"),
|
||||
new("Arrow Keys", "슬라이드 쇼 중 다음/이전", "ppt"),
|
||||
new("숫자 + Enter", "슬라이드 쇼 중 특정 슬라이드로 이동", "ppt"),
|
||||
|
||||
// Teams
|
||||
new("Ctrl + E", "검색창 포커스", "teams"),
|
||||
new("Ctrl + /", "명령 팔레트 열기", "teams"),
|
||||
new("Ctrl + N", "새 채팅 시작", "teams"),
|
||||
new("Ctrl + Shift + M", "오디오 뮤트 토글", "teams"),
|
||||
new("Ctrl + Shift + O", "비디오 카메라 토글", "teams"),
|
||||
new("Ctrl + Shift + H", "회의 종료", "teams"),
|
||||
new("Ctrl + Shift + K", "손 들기/내리기", "teams"),
|
||||
new("Ctrl + Shift + B", "배경 흐림 토글", "teams"),
|
||||
new("Ctrl + Shift + F", "전체 화면 토글", "teams"),
|
||||
new("Ctrl + Shift + A", "팀 활동 피드 열기", "teams"),
|
||||
new("Ctrl + 1", "활동 탭", "teams"),
|
||||
new("Ctrl + 2", "채팅 탭", "teams"),
|
||||
new("Ctrl + 3", "팀 탭", "teams"),
|
||||
new("Ctrl + 4", "캘린더 탭", "teams"),
|
||||
new("Ctrl + 5", "통화 탭", "teams"),
|
||||
new("Alt + ↑/↓", "채널 목록 위/아래 이동", "teams"),
|
||||
new("Ctrl + R", "메시지에 답장", "teams"),
|
||||
new("Enter", "회의 참가 (알림에서)", "teams"),
|
||||
|
||||
// Outlook
|
||||
new("Ctrl + N", "새 이메일 작성", "outlook"),
|
||||
new("Ctrl + R", "답장", "outlook"),
|
||||
new("Ctrl + Shift + R", "전체 답장", "outlook"),
|
||||
new("Ctrl + F", "이메일 전달", "outlook"),
|
||||
new("Ctrl + Enter", "이메일 전송", "outlook"),
|
||||
new("Ctrl + S", "초안 저장", "outlook"),
|
||||
new("Delete", "이메일 삭제", "outlook"),
|
||||
new("Ctrl + Z", "실행 취소", "outlook"),
|
||||
new("Ctrl + 1", "메일 보기", "outlook"),
|
||||
new("Ctrl + 2", "일정 보기", "outlook"),
|
||||
new("Ctrl + 3", "연락처 보기", "outlook"),
|
||||
new("Ctrl + 4", "작업 보기", "outlook"),
|
||||
new("F9", "모든 계정 보내기/받기", "outlook"),
|
||||
new("Ctrl + Shift + I", "받은 편지함으로 이동", "outlook"),
|
||||
new("Ctrl + Shift + V", "이메일 이동", "outlook"),
|
||||
new("Ctrl + Shift + G", "플래그 설정", "outlook"),
|
||||
new("Ctrl + Q", "읽음 표시", "outlook"),
|
||||
new("Ctrl + U", "읽지 않음 표시", "outlook"),
|
||||
new("Ctrl + Shift + J", "정크 메일 처리", "outlook"),
|
||||
new("Ctrl + K", "이름 확인 (주소 자동 완성)", "outlook"),
|
||||
new("Alt + S", "이메일 보내기", "outlook"),
|
||||
];
|
||||
|
||||
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("키보드 단축키 참조 사전",
|
||||
"key win / vscode / chrome / vim / excel / terminal / word / ppt / teams / outlook / key <키워드 검색>",
|
||||
null, null, Symbol: "\uE92E"));
|
||||
AddAppOverview(items);
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
// 앱 카테고리 조회
|
||||
var catKeys = new Dictionary<string, string[]>
|
||||
{
|
||||
["win"] = ["win", "windows"],
|
||||
["vscode"] = ["vscode", "code", "vs"],
|
||||
["chrome"] = ["chrome", "edge", "browser"],
|
||||
["vim"] = ["vim", "vi", "neovim"],
|
||||
["excel"] = ["excel", "spreadsheet"],
|
||||
["terminal"] = ["terminal", "wt"],
|
||||
["word"] = ["word", "워드"],
|
||||
["ppt"] = ["ppt", "powerpoint", "파워포인트", "프레젠테이션"],
|
||||
["teams"] = ["teams", "팀즈"],
|
||||
["outlook"] = ["outlook", "아웃룩", "mail"],
|
||||
};
|
||||
|
||||
foreach (var (cat, aliases) in catKeys)
|
||||
{
|
||||
if (aliases.Contains(sub))
|
||||
{
|
||||
var catItems = All.Where(s => s.App == cat).ToList();
|
||||
var catName = cat switch
|
||||
{
|
||||
"win" => "Windows",
|
||||
"vscode" => "VS Code",
|
||||
"chrome" => "Chrome / Edge",
|
||||
"vim" => "Vim",
|
||||
"excel" => "Excel",
|
||||
"terminal" => "Windows Terminal",
|
||||
"word" => "Word",
|
||||
"ppt" => "PowerPoint",
|
||||
"teams" => "Teams",
|
||||
"outlook" => "Outlook",
|
||||
_ => cat
|
||||
};
|
||||
items.Add(new LauncherItem($"{catName} 단축키 {catItems.Count}개", "", null, null, Symbol: "\uE92E"));
|
||||
foreach (var s in catItems)
|
||||
items.Add(new LauncherItem(s.Keys, s.Description, null, ("copy", s.Keys), Symbol: "\uE92E"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
}
|
||||
|
||||
// 키워드 검색 (전체)
|
||||
var keyword = string.Join(" ", parts);
|
||||
var found = All.Where(s =>
|
||||
s.Keys.Contains(keyword, StringComparison.OrdinalIgnoreCase) ||
|
||||
s.Description.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (found.Count > 0)
|
||||
{
|
||||
items.Add(new LauncherItem($"'{keyword}' 검색 결과: {found.Count}개", "", null, null, Symbol: "\uE92E"));
|
||||
foreach (var s in found)
|
||||
{
|
||||
var appLabel = s.App.ToUpperInvariant();
|
||||
items.Add(new LauncherItem(s.Keys, $"[{appLabel}] {s.Description}",
|
||||
null, ("copy", s.Keys), Symbol: "\uE92E"));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"'{keyword}' 결과 없음",
|
||||
"key win / vscode / chrome / vim / excel / terminal / word / ppt / teams / outlook",
|
||||
null, null, Symbol: "\uE783"));
|
||||
AddAppOverview(items);
|
||||
}
|
||||
|
||||
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("Key", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static void AddAppOverview(List<LauncherItem> items)
|
||||
{
|
||||
var apps = new (string Key, string Label, int Count)[]
|
||||
{
|
||||
("win", "Windows 단축키", All.Count(s => s.App == "win")),
|
||||
("vscode", "VS Code 단축키", All.Count(s => s.App == "vscode")),
|
||||
("chrome", "Chrome / Edge 단축키", All.Count(s => s.App == "chrome")),
|
||||
("vim", "Vim 명령", All.Count(s => s.App == "vim")),
|
||||
("excel", "Excel 단축키", All.Count(s => s.App == "excel")),
|
||||
("terminal", "Windows Terminal 단축키", All.Count(s => s.App == "terminal")),
|
||||
("word", "Word 단축키", All.Count(s => s.App == "word")),
|
||||
("ppt", "PowerPoint 단축키", All.Count(s => s.App == "ppt")),
|
||||
("teams", "Teams 단축키", All.Count(s => s.App == "teams")),
|
||||
("outlook", "Outlook 단축키", All.Count(s => s.App == "outlook")),
|
||||
};
|
||||
foreach (var (key, label, count) in apps)
|
||||
items.Add(new LauncherItem($"key {key}", $"{label} ({count}개)",
|
||||
null, null, Symbol: "\uE92E"));
|
||||
}
|
||||
}
|
||||
323
src/AxCopilot/Handlers/LeaveHandler.cs
Normal file
323
src/AxCopilot/Handlers/LeaveHandler.cs
Normal file
@@ -0,0 +1,323 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L23-2: 연차·휴가 관리. "leave" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: leave → 잔여 연차 현황
|
||||
/// leave set 15 → 연간 연차 일수 설정
|
||||
/// leave use 2026-04-10 → 1일 연차 기록
|
||||
/// leave use 2026-04-10 0.5 → 반차 기록
|
||||
/// leave use 2026-04-10 0.5 반차메모 → 메모 포함
|
||||
/// leave del 2026-04-10 → 기록 삭제
|
||||
/// leave remaining → 잔여 연차
|
||||
/// leave list → 올해 사용 이력
|
||||
/// leave clear → 올해 기록 초기화
|
||||
/// Enter → 저장 실행 또는 복사
|
||||
/// 저장: %APPDATA%\AxCopilot\leave.json
|
||||
/// </summary>
|
||||
public class LeaveHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "leave";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"연차 관리",
|
||||
"연차·휴가 기록 — 설정 · 사용 · 잔여 조회 · 이력",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private static readonly string DataPath = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "leave.json");
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
private sealed class LeaveData
|
||||
{
|
||||
[JsonPropertyName("annualDays")] public double AnnualDays { get; set; } = 15;
|
||||
[JsonPropertyName("records")] public List<LeaveRecord> Records { get; set; } = [];
|
||||
}
|
||||
|
||||
private sealed class LeaveRecord
|
||||
{
|
||||
[JsonPropertyName("date")] public string Date { get; set; } = "";
|
||||
[JsonPropertyName("days")] public double Days { get; set; } = 1;
|
||||
[JsonPropertyName("note")] public string Note { get; set; } = "";
|
||||
}
|
||||
|
||||
// ── GetItemsAsync ─────────────────────────────────────────────────────────
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
var data = LoadData();
|
||||
var year = DateTime.Today.Year;
|
||||
var used = data.Records.Where(r => r.Date.StartsWith(year.ToString())).Sum(r => r.Days);
|
||||
var left = data.AnnualDays - used;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"연차 현황 — 연간 {data.AnnualDays}일 사용 {used}일 잔여 {left}일",
|
||||
"leave set N · leave use 날짜 [일수] [메모] · leave list · leave remaining",
|
||||
null, null, Symbol: "\uE716"));
|
||||
items.Add(new LauncherItem("leave set <일수>", "연간 연차 총일수 설정", null, null, Symbol: "\uE716"));
|
||||
items.Add(new LauncherItem("leave use <날짜>", "연차 사용 기록 (기본 1일)", null, null, Symbol: "\uE716"));
|
||||
items.Add(new LauncherItem("leave list", "올해 사용 이력 전체 조회", null, null, Symbol: "\uE716"));
|
||||
items.Add(new LauncherItem("leave remaining", "잔여 연차 확인", null, null, Symbol: "\uE716"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
// set N
|
||||
if (sub == "set")
|
||||
{
|
||||
if (parts.Length >= 2 && double.TryParse(parts[1], out var n) && n > 0)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"연간 연차를 {n}일로 설정",
|
||||
$"현재: {data.AnnualDays}일 → {n}일 · Enter: 저장",
|
||||
null, ("set", n.ToString(System.Globalization.CultureInfo.InvariantCulture)),
|
||||
Symbol: "\uE716"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem("일수를 입력하세요", "예: leave set 15", null, null, Symbol: "\uE783"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// use 날짜 [일수] [메모]
|
||||
if (sub == "use")
|
||||
{
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
items.Add(new LauncherItem("날짜를 입력하세요", "예: leave use 2026-04-10", null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
var dateStr = parts[1];
|
||||
if (!DateOnly.TryParseExact(dateStr, new[] { "yyyy-MM-dd", "yyyy/MM/dd" },
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.None, out _))
|
||||
{
|
||||
items.Add(new LauncherItem("날짜 형식 오류", "yyyy-MM-dd 형식으로 입력하세요", null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
var useDays = 1.0;
|
||||
var note = "";
|
||||
if (parts.Length >= 3 && double.TryParse(parts[2],
|
||||
System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var pd))
|
||||
{
|
||||
useDays = pd;
|
||||
if (parts.Length >= 4)
|
||||
note = string.Join(" ", parts[3..]);
|
||||
}
|
||||
else if (parts.Length >= 3)
|
||||
{
|
||||
note = string.Join(" ", parts[2..]);
|
||||
}
|
||||
var newLeft = left - useDays;
|
||||
var label = note.Length > 0
|
||||
? $"{dateStr} 연차 {useDays}일 기록 [{note}]"
|
||||
: $"{dateStr} 연차 {useDays}일 기록";
|
||||
items.Add(new LauncherItem(
|
||||
label,
|
||||
$"잔여: {newLeft}일 · Enter: 저장",
|
||||
null, ("use", $"{dateStr}|{useDays.ToString(System.Globalization.CultureInfo.InvariantCulture)}|{note}"),
|
||||
Symbol: "\uE716"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// del 날짜
|
||||
if (sub == "del")
|
||||
{
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
items.Add(new LauncherItem("날짜를 입력하세요", "예: leave del 2026-04-10", null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
var delDate = parts[1];
|
||||
var target = data.Records.FirstOrDefault(r => r.Date == delDate);
|
||||
if (target == null)
|
||||
{
|
||||
items.Add(new LauncherItem($"{delDate} 기록 없음", "해당 날짜의 연차 기록이 없습니다", null, null, Symbol: "\uE783"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"{delDate} 기록 삭제 ({target.Days}일{(target.Note.Length > 0 ? " / " + target.Note : "")})",
|
||||
"Enter: 삭제",
|
||||
null, ("del", delDate), Symbol: "\uE716"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// remaining
|
||||
if (sub == "remaining")
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"잔여 연차: {left}일 (연간 {data.AnnualDays}일 - 사용 {used}일)",
|
||||
"Enter: 클립보드 복사",
|
||||
null, ("copy", $"잔여 연차: {left}일"), Symbol: "\uE716"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// list
|
||||
if (sub == "list")
|
||||
{
|
||||
var yearRecords = data.Records
|
||||
.Where(r => r.Date.StartsWith(year.ToString()))
|
||||
.OrderBy(r => r.Date)
|
||||
.ToList();
|
||||
items.Add(new LauncherItem(
|
||||
$"{year}년 연차 사용 이력 — {yearRecords.Count}건 총 {used}일",
|
||||
$"잔여: {left}일", null, null, Symbol: "\uE716"));
|
||||
if (yearRecords.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("사용 이력 없음", "", null, null, Symbol: "\uE716"));
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var r in yearRecords)
|
||||
{
|
||||
var noteStr = r.Note.Length > 0 ? $" [{r.Note}]" : "";
|
||||
items.Add(new LauncherItem(
|
||||
$"{r.Date} {r.Days}일{noteStr}",
|
||||
"Enter: 삭제",
|
||||
null, ("del", r.Date), Symbol: "\uE716"));
|
||||
}
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// clear
|
||||
if (sub == "clear")
|
||||
{
|
||||
var cnt = data.Records.Count(r => r.Date.StartsWith(year.ToString()));
|
||||
items.Add(new LauncherItem(
|
||||
$"{year}년 연차 기록 초기화 ({cnt}건)",
|
||||
"Enter: 확인",
|
||||
null, ("clear", year.ToString()), Symbol: "\uE716"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 기본 — 안내
|
||||
items.Add(new LauncherItem($"'{q}' — 알 수 없는 명령",
|
||||
"leave set · use · del · remaining · list · clear",
|
||||
null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ── ExecuteAsync ──────────────────────────────────────────────────────────
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
var data = LoadData();
|
||||
|
||||
switch (item.Data)
|
||||
{
|
||||
case ("set", string nStr) when double.TryParse(nStr,
|
||||
System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var n):
|
||||
data.AnnualDays = n;
|
||||
SaveData(data);
|
||||
NotificationService.Notify("연차 관리", $"연간 연차를 {n}일로 설정했습니다.");
|
||||
break;
|
||||
|
||||
case ("use", string payload):
|
||||
{
|
||||
var parts = payload.Split('|');
|
||||
if (parts.Length >= 2 &&
|
||||
double.TryParse(parts[1],
|
||||
System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var days))
|
||||
{
|
||||
var dateStr = parts[0];
|
||||
var note = parts.Length >= 3 ? parts[2] : "";
|
||||
// 중복 날짜 처리 — 누적
|
||||
var existing = data.Records.FirstOrDefault(r => r.Date == dateStr);
|
||||
if (existing != null)
|
||||
{
|
||||
existing.Days += days;
|
||||
if (note.Length > 0) existing.Note = note;
|
||||
}
|
||||
else
|
||||
{
|
||||
data.Records.Add(new LeaveRecord { Date = dateStr, Days = days, Note = note });
|
||||
}
|
||||
data.Records.Sort((a, b) => string.Compare(a.Date, b.Date, StringComparison.Ordinal));
|
||||
SaveData(data);
|
||||
NotificationService.Notify("연차 관리", $"{dateStr} 연차 {days}일 기록됐습니다.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case ("del", string delDate):
|
||||
{
|
||||
var removed = data.Records.RemoveAll(r => r.Date == delDate);
|
||||
if (removed > 0)
|
||||
{
|
||||
SaveData(data);
|
||||
NotificationService.Notify("연차 관리", $"{delDate} 기록이 삭제됐습니다.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case ("clear", string yearStr) when int.TryParse(yearStr, out var y):
|
||||
{
|
||||
var cnt = data.Records.RemoveAll(r => r.Date.StartsWith(y.ToString()));
|
||||
SaveData(data);
|
||||
NotificationService.Notify("연차 관리", $"{y}년 연차 기록 {cnt}건 초기화됐습니다.");
|
||||
break;
|
||||
}
|
||||
|
||||
case ("copy", string text):
|
||||
try
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
|
||||
NotificationService.Notify("연차 관리", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { }
|
||||
break;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 저장/불러오기 ─────────────────────────────────────────────────────────
|
||||
|
||||
private static LeaveData LoadData()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!System.IO.File.Exists(DataPath)) return new LeaveData();
|
||||
var json = System.IO.File.ReadAllText(DataPath, System.Text.Encoding.UTF8);
|
||||
return JsonSerializer.Deserialize<LeaveData>(json, JsonOpts) ?? new LeaveData();
|
||||
}
|
||||
catch { return new LeaveData(); }
|
||||
}
|
||||
|
||||
private static void SaveData(LeaveData data)
|
||||
{
|
||||
try
|
||||
{
|
||||
System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(DataPath)!);
|
||||
System.IO.File.WriteAllText(DataPath,
|
||||
JsonSerializer.Serialize(data, JsonOpts),
|
||||
System.Text.Encoding.UTF8);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
402
src/AxCopilot/Handlers/LogHandler.cs
Normal file
402
src/AxCopilot/Handlers/LogHandler.cs
Normal file
@@ -0,0 +1,402 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L21-2: 로그 파일 분석기 핸들러. "log" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: log → 클립보드 로그 통계
|
||||
/// log <경로> → 파일 경로 직접 분석
|
||||
/// log error → ERROR 줄만 표시
|
||||
/// log warn → WARN 줄만 표시
|
||||
/// log last 20 → 마지막 20줄 (tail)
|
||||
/// log head 20 → 처음 20줄
|
||||
/// log grep <키워드> → 키워드 필터
|
||||
/// log stats → 레벨별 통계 + 시간대 분포
|
||||
/// log exceptions → 예외·스택트레이스 블록 추출
|
||||
/// log today → 오늘 날짜 포함 줄만 표시
|
||||
/// Enter → 값 복사.
|
||||
/// </summary>
|
||||
public partial class LogHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "log";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Log",
|
||||
"로그 파일 분석기 — ERROR/WARN 필터·tail·grep·통계·예외 추출",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private record LogLine(int No, string Level, string Raw);
|
||||
|
||||
// 로그 레벨 감지 패턴
|
||||
private static readonly (string Level, string[] Keywords)[] LevelPatterns =
|
||||
[
|
||||
("ERROR", ["[ERROR]", "ERROR:", "ERRO ", "error", "Error", "FATAL", "[FATAL]", "Exception", "EXCEPTION"]),
|
||||
("WARN", ["[WARN]", "WARN:", "WARNING", "warning", "Warning", "[WARNING]"]),
|
||||
("INFO", ["[INFO]", "INFO:", "information", "Information"]),
|
||||
("DEBUG", ["[DEBUG]", "DEBUG:", "debug", "Debug", "TRACE", "[TRACE]"]),
|
||||
];
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
// 클립보드 or 파일
|
||||
string? src = null;
|
||||
string? srcLabel = null;
|
||||
try
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
if (Clipboard.ContainsText()) src = Clipboard.GetText();
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(src))
|
||||
{
|
||||
items.Add(new LauncherItem("로그 파일 분석기",
|
||||
"클립보드에 로그를 복사하거나 log <파일경로> / log error / log grep <키워드>",
|
||||
null, null, Symbol: "\uE9D9"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
srcLabel = "클립보드";
|
||||
BuildSummary(items, src!, srcLabel);
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
// 파일 경로인지 판단
|
||||
if ((sub.Contains('\\') || sub.Contains('/') || sub.Contains(':') || sub.EndsWith(".log") || sub.EndsWith(".txt"))
|
||||
&& File.Exists(parts[0]))
|
||||
{
|
||||
string? fileErr = null;
|
||||
string? content = null;
|
||||
try { content = File.ReadAllText(parts[0]); }
|
||||
catch (Exception ex) { fileErr = ex.Message; }
|
||||
|
||||
if (fileErr != null) { items.Add(ErrorItem($"파일 읽기 오류: {fileErr}")); return Task.FromResult<IEnumerable<LauncherItem>>(items); }
|
||||
src = content;
|
||||
srcLabel = Path.GetFileName(parts[0]);
|
||||
if (parts.Length == 1)
|
||||
{
|
||||
BuildSummary(items, src!, srcLabel);
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
// 파일 경로 + 서브커맨드
|
||||
sub = parts[1].ToLowerInvariant();
|
||||
parts = parts[1..];
|
||||
}
|
||||
else
|
||||
{
|
||||
srcLabel = "클립보드";
|
||||
}
|
||||
|
||||
var logSrc = src ?? "";
|
||||
if (string.IsNullOrWhiteSpace(logSrc))
|
||||
{
|
||||
items.Add(ErrorItem("분석할 로그가 없습니다. 클립보드에 로그를 복사하세요."));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var allLines = ParseLogLines(logSrc);
|
||||
|
||||
switch (sub)
|
||||
{
|
||||
case "error" or "err":
|
||||
BuildFilteredLines(items, allLines, "ERROR", "ERROR / FATAL / Exception", srcLabel!);
|
||||
break;
|
||||
|
||||
case "warn" or "warning":
|
||||
BuildFilteredLines(items, allLines, "WARN", "WARN / WARNING", srcLabel!);
|
||||
break;
|
||||
|
||||
case "info":
|
||||
BuildFilteredLines(items, allLines, "INFO", "INFO / information", srcLabel!);
|
||||
break;
|
||||
|
||||
case "debug" or "trace":
|
||||
BuildFilteredLines(items, allLines, "DEBUG", "DEBUG / TRACE", srcLabel!);
|
||||
break;
|
||||
|
||||
case "last" or "tail":
|
||||
{
|
||||
int n = 20;
|
||||
if (parts.Length >= 2 && int.TryParse(parts[1], out var pn)) n = Math.Clamp(pn, 1, 500);
|
||||
var tail = allLines.TakeLast(n).ToList();
|
||||
var joined = string.Join("\n", tail.Select(l => l.Raw));
|
||||
items.Add(new LauncherItem($"마지막 {tail.Count}줄 ({srcLabel})",
|
||||
"Enter 전체 복사", null, ("copy", joined), Symbol: "\uE9D9"));
|
||||
foreach (var l in tail)
|
||||
items.Add(BuildLineItem(l));
|
||||
break;
|
||||
}
|
||||
|
||||
case "head" or "first":
|
||||
{
|
||||
int n = 20;
|
||||
if (parts.Length >= 2 && int.TryParse(parts[1], out var pn)) n = Math.Clamp(pn, 1, 500);
|
||||
var head = allLines.Take(n).ToList();
|
||||
var joined = string.Join("\n", head.Select(l => l.Raw));
|
||||
items.Add(new LauncherItem($"처음 {head.Count}줄 ({srcLabel})",
|
||||
"Enter 전체 복사", null, ("copy", joined), Symbol: "\uE9D9"));
|
||||
foreach (var l in head)
|
||||
items.Add(BuildLineItem(l));
|
||||
break;
|
||||
}
|
||||
|
||||
case "grep" or "find" or "search":
|
||||
{
|
||||
if (parts.Length < 2) { items.Add(ErrorItem("예: log grep Exception")); break; }
|
||||
var kw = string.Join(" ", parts[1..]);
|
||||
var matched = allLines.Where(l => l.Raw.Contains(kw, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
var joined = string.Join("\n", matched.Select(l => l.Raw));
|
||||
items.Add(new LauncherItem($"'{kw}' 검색 결과: {matched.Count}줄",
|
||||
"Enter 전체 복사", null, ("copy", joined), Symbol: "\uE9D9"));
|
||||
foreach (var l in matched.Take(15))
|
||||
items.Add(BuildLineItem(l));
|
||||
if (matched.Count > 15)
|
||||
items.Add(new LauncherItem($"... ({matched.Count - 15}줄 더)", "Enter 전체 복사",
|
||||
null, ("copy", joined), Symbol: "\uE9D9"));
|
||||
break;
|
||||
}
|
||||
|
||||
case "stats":
|
||||
BuildStats(items, allLines, logSrc, srcLabel!);
|
||||
break;
|
||||
|
||||
case "exceptions" or "exception" or "ex":
|
||||
{
|
||||
var blocks = ExtractExceptions(logSrc);
|
||||
items.Add(new LauncherItem($"예외 블록 {blocks.Count}개 발견",
|
||||
srcLabel!, null, null, Symbol: "\uE9D9"));
|
||||
for (int i = 0; i < blocks.Count; i++)
|
||||
{
|
||||
var b = blocks[i];
|
||||
var preview = b.Split('\n')[0];
|
||||
items.Add(new LauncherItem($"#{i + 1} {TruncateStr(preview, 60)}",
|
||||
$"{b.Split('\n').Length}줄", null, ("copy", b), Symbol: "\uE9D9"));
|
||||
}
|
||||
if (blocks.Count == 0)
|
||||
items.Add(new LauncherItem("예외·스택트레이스 패턴 없음", "", null, null, Symbol: "\uE9D9"));
|
||||
break;
|
||||
}
|
||||
|
||||
case "today":
|
||||
{
|
||||
var today = DateTime.Today.ToString("yyyy-MM-dd");
|
||||
var today2 = DateTime.Today.ToString("yyyy/MM/dd");
|
||||
var matched = allLines.Where(l =>
|
||||
l.Raw.Contains(today) || l.Raw.Contains(today2)).ToList();
|
||||
var joined = string.Join("\n", matched.Select(l => l.Raw));
|
||||
items.Add(new LauncherItem($"오늘({today}) {matched.Count}줄",
|
||||
"Enter 전체 복사", null, ("copy", joined), Symbol: "\uE9D9"));
|
||||
foreach (var l in matched.Take(15))
|
||||
items.Add(BuildLineItem(l));
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
// 기본 요약으로 폴백
|
||||
BuildSummary(items, logSrc, srcLabel!);
|
||||
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("Log", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 빌더 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static void BuildSummary(List<LauncherItem> items, string src, string label)
|
||||
{
|
||||
var lines = ParseLogLines(src);
|
||||
var errors = lines.Count(l => l.Level == "ERROR");
|
||||
var warns = lines.Count(l => l.Level == "WARN");
|
||||
var infos = lines.Count(l => l.Level == "INFO");
|
||||
var debugs = lines.Count(l => l.Level == "DEBUG");
|
||||
var unknowns = lines.Count(l => l.Level == "OTHER");
|
||||
|
||||
items.Add(new LauncherItem($"{label} — {lines.Count}줄",
|
||||
$"ERROR:{errors} WARN:{warns} INFO:{infos} DEBUG:{debugs}",
|
||||
null, null, Symbol: "\uE9D9"));
|
||||
if (errors > 0)
|
||||
{
|
||||
items.Add(new LauncherItem($"🔴 ERROR {errors}줄",
|
||||
"log error 서브커맨드로 상세 보기", null, null, Symbol: "\uE9D9"));
|
||||
}
|
||||
if (warns > 0)
|
||||
{
|
||||
items.Add(new LauncherItem($"🟡 WARN {warns}줄",
|
||||
"log warn 서브커맨드로 상세 보기", null, null, Symbol: "\uE9D9"));
|
||||
}
|
||||
items.Add(CopyItem("전체 줄 수", lines.Count.ToString()));
|
||||
items.Add(CopyItem("ERROR 줄", errors.ToString()));
|
||||
items.Add(CopyItem("WARN 줄", warns.ToString()));
|
||||
|
||||
// 마지막 5줄 미리보기
|
||||
items.Add(new LauncherItem("── 마지막 5줄 ──", "", null, null, Symbol: "\uE9D9"));
|
||||
foreach (var l in lines.TakeLast(5))
|
||||
items.Add(BuildLineItem(l));
|
||||
}
|
||||
|
||||
private static void BuildFilteredLines(List<LauncherItem> items, List<LogLine> lines,
|
||||
string level, string label, string srcLabel)
|
||||
{
|
||||
var filtered = lines.Where(l => l.Level == level).ToList();
|
||||
var joined = string.Join("\n", filtered.Select(l => l.Raw));
|
||||
items.Add(new LauncherItem($"{label} {filtered.Count}줄 ({srcLabel})",
|
||||
"Enter 전체 복사", null, ("copy", joined), Symbol: "\uE9D9"));
|
||||
if (filtered.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem($"{label} 줄이 없습니다", "", null, null, Symbol: "\uE9D9"));
|
||||
return;
|
||||
}
|
||||
foreach (var l in filtered.Take(15))
|
||||
items.Add(BuildLineItem(l));
|
||||
if (filtered.Count > 15)
|
||||
items.Add(new LauncherItem($"... ({filtered.Count - 15}줄 더)", "Enter 전체 복사",
|
||||
null, ("copy", joined), Symbol: "\uE9D9"));
|
||||
}
|
||||
|
||||
private static void BuildStats(List<LauncherItem> items, List<LogLine> lines, string src, string label)
|
||||
{
|
||||
var counts = new Dictionary<string, int>
|
||||
{
|
||||
["ERROR"] = lines.Count(l => l.Level == "ERROR"),
|
||||
["WARN"] = lines.Count(l => l.Level == "WARN"),
|
||||
["INFO"] = lines.Count(l => l.Level == "INFO"),
|
||||
["DEBUG"] = lines.Count(l => l.Level == "DEBUG"),
|
||||
["OTHER"] = lines.Count(l => l.Level == "OTHER"),
|
||||
};
|
||||
|
||||
items.Add(new LauncherItem($"로그 통계 ({label})", $"총 {lines.Count}줄",
|
||||
null, null, Symbol: "\uE9D9"));
|
||||
foreach (var (lvl, cnt) in counts.Where(kv => kv.Value > 0))
|
||||
items.Add(CopyItem(lvl, $"{cnt}줄 ({cnt * 100.0 / Math.Max(1, lines.Count):F1}%)"));
|
||||
|
||||
// 날짜별 분포 (yyyy-MM-dd 패턴 추출)
|
||||
var dateCounts = new Dictionary<string, int>();
|
||||
foreach (var l in lines)
|
||||
{
|
||||
var m = DatePattern().Match(l.Raw);
|
||||
if (m.Success)
|
||||
{
|
||||
var date = m.Value[..10];
|
||||
dateCounts[date] = dateCounts.GetValueOrDefault(date) + 1;
|
||||
}
|
||||
}
|
||||
if (dateCounts.Count > 0)
|
||||
{
|
||||
items.Add(new LauncherItem("── 날짜별 분포 ──", "", null, null, Symbol: "\uE9D9"));
|
||||
foreach (var (date, cnt) in dateCounts.OrderByDescending(kv => kv.Key).Take(5))
|
||||
items.Add(CopyItem(date, $"{cnt}줄"));
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> ExtractExceptions(string src)
|
||||
{
|
||||
var blocks = new List<string>();
|
||||
var lines = src.Split('\n');
|
||||
var inBlock = false;
|
||||
var current = new StringBuilder();
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var isEx = line.Contains("Exception") || line.Contains("Error:") ||
|
||||
line.Contains("at ") || line.TrimStart().StartsWith("Caused by");
|
||||
if (!inBlock && isEx)
|
||||
{
|
||||
inBlock = true;
|
||||
current.Clear();
|
||||
current.AppendLine(line);
|
||||
}
|
||||
else if (inBlock)
|
||||
{
|
||||
if (isEx || line.TrimStart().StartsWith("at ") || line.TrimStart().StartsWith("..."))
|
||||
current.AppendLine(line);
|
||||
else
|
||||
{
|
||||
if (current.Length > 0) blocks.Add(current.ToString().Trim());
|
||||
inBlock = false;
|
||||
current.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (inBlock && current.Length > 0) blocks.Add(current.ToString().Trim());
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// ── 파서·헬퍼 ───────────────────────────────────────────────────────────
|
||||
|
||||
private static List<LogLine> ParseLogLines(string src)
|
||||
{
|
||||
var lines = src.Split('\n');
|
||||
return lines.Select((raw, i) =>
|
||||
{
|
||||
var level = DetectLevel(raw);
|
||||
return new LogLine(i + 1, level, raw.TrimEnd('\r'));
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static string DetectLevel(string line)
|
||||
{
|
||||
foreach (var (level, keywords) in LevelPatterns)
|
||||
if (keywords.Any(kw => line.Contains(kw, StringComparison.Ordinal)))
|
||||
return level;
|
||||
return "OTHER";
|
||||
}
|
||||
|
||||
private static LauncherItem BuildLineItem(LogLine l)
|
||||
{
|
||||
var icon = l.Level switch
|
||||
{
|
||||
"ERROR" => "🔴",
|
||||
"WARN" => "🟡",
|
||||
"INFO" => "🔵",
|
||||
"DEBUG" => "⚪",
|
||||
_ => " "
|
||||
};
|
||||
var display = TruncateStr(l.Raw, 80);
|
||||
return new LauncherItem($"{icon} {display}",
|
||||
$"줄 {l.No} ({l.Level})", null, ("copy", l.Raw), Symbol: "\uE9D9");
|
||||
}
|
||||
|
||||
private static string TruncateStr(string s, int max) =>
|
||||
s.Length <= max ? s : s[..max] + "…";
|
||||
|
||||
private static LauncherItem CopyItem(string label, string value) =>
|
||||
new(label, value, null, ("copy", value), Symbol: "\uE9D9");
|
||||
|
||||
private static LauncherItem ErrorItem(string msg) =>
|
||||
new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783");
|
||||
|
||||
[GeneratedRegex(@"\d{4}[-/]\d{2}[-/]\d{2}")]
|
||||
private static partial Regex DatePattern();
|
||||
}
|
||||
284
src/AxCopilot/Handlers/LoremHandler.cs
Normal file
284
src/AxCopilot/Handlers/LoremHandler.cs
Normal file
@@ -0,0 +1,284 @@
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L10-4: Lorem Ipsum / 더미 텍스트 생성기 핸들러. "lorem" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: lorem → 1단락 생성 (기본)
|
||||
/// lorem 3 → 3단락 생성
|
||||
/// lorem words 20 → 단어 20개
|
||||
/// lorem sentences 5 → 문장 5개
|
||||
/// lorem ko → 한국어 더미 텍스트 1단락
|
||||
/// lorem ko 3 → 한국어 더미 텍스트 3단락
|
||||
/// lorem email 5 → 더미 이메일 주소 5개
|
||||
/// lorem name 5 → 더미 이름 5개 (한국어)
|
||||
/// Enter → 결과를 클립보드에 복사.
|
||||
/// </summary>
|
||||
public class LoremHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "lorem";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Lorem",
|
||||
"더미 텍스트 생성기 — Lorem Ipsum · 한국어 · 이메일 · 이름",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
// ── Lorem Ipsum 단어 풀 ─────────────────────────────────────────────────
|
||||
private static readonly string[] LoremWords =
|
||||
[
|
||||
"lorem", "ipsum", "dolor", "sit", "amet", "consectetur", "adipiscing", "elit",
|
||||
"sed", "do", "eiusmod", "tempor", "incididunt", "ut", "labore", "et", "dolore",
|
||||
"magna", "aliqua", "enim", "ad", "minim", "veniam", "quis", "nostrud",
|
||||
"exercitation", "ullamco", "laboris", "nisi", "aliquip", "ex", "ea", "commodo",
|
||||
"consequat", "duis", "aute", "irure", "in", "reprehenderit", "voluptate",
|
||||
"velit", "esse", "cillum", "eu", "fugiat", "nulla", "pariatur", "excepteur",
|
||||
"sint", "occaecat", "cupidatat", "non", "proident", "sunt", "culpa", "qui",
|
||||
"officia", "deserunt", "mollit", "anim", "id", "est", "laborum", "perspiciatis",
|
||||
"unde", "omnis", "iste", "natus", "error", "voluptatem", "accusantium",
|
||||
"doloremque", "laudantium", "totam", "rem", "aperiam", "eaque", "ipsa", "quae",
|
||||
"ab", "illo", "inventore", "veritatis", "quasi", "architecto", "beatae", "vitae",
|
||||
"dicta", "explicabo", "nemo", "ipsam", "quia", "voluptas", "aspernatur", "aut",
|
||||
"odit", "fugit", "consequuntur", "magni", "dolores", "ratione", "sequi",
|
||||
"nesciunt", "neque", "porro", "quisquam", "dolorem", "numquam", "eius", "modi",
|
||||
"temporibus", "incidunt", "magnam", "aliquam", "quaerat", "minima", "nostrum",
|
||||
"exercitationem", "ullam", "corporis", "suscipit", "laboriosam", "nisi",
|
||||
"aliquid", "commodi", "consequatur", "quidem", "rerum", "facilis",
|
||||
];
|
||||
|
||||
// ── 한국어 더미 단어 풀 ──────────────────────────────────────────────────
|
||||
private static readonly string[] KorWords =
|
||||
[
|
||||
"가나다라", "마바사아", "자차카타", "파하", "데이터", "처리", "시스템", "네트워크",
|
||||
"소프트웨어", "알고리즘", "데이터베이스", "인터페이스", "프레임워크", "모듈", "클래스",
|
||||
"메서드", "함수", "변수", "구조체", "배열", "목록", "사전", "집합", "스택", "큐",
|
||||
"트리", "그래프", "정렬", "탐색", "분석", "설계", "구현", "테스트", "배포", "운영",
|
||||
"서비스", "플랫폼", "클라우드", "컨테이너", "가상화", "보안", "암호화", "인증", "권한",
|
||||
"로그", "모니터링", "알림", "이벤트", "트랜잭션", "세션", "쿠키", "토큰", "키",
|
||||
"값", "객체", "인스턴스", "프로세스", "스레드", "비동기", "병렬", "동기화", "잠금",
|
||||
"버퍼", "캐시", "인덱스", "쿼리", "뷰", "프로시저", "스키마", "테이블", "컬럼",
|
||||
"행", "열", "기본키", "외래키", "조인", "집계", "필터", "정렬", "그룹화", "분류",
|
||||
];
|
||||
|
||||
private static readonly string[] KorSentenceStarters =
|
||||
[
|
||||
"이 시스템은", "해당 모듈은", "기능을 구현하면", "데이터를 처리하는",
|
||||
"네트워크 연결이", "서비스가 시작되면", "사용자 인터페이스는", "알고리즘이",
|
||||
"처리 과정에서", "설계 단계에서", "구현 방식은", "테스트 결과",
|
||||
];
|
||||
|
||||
private static readonly string[] KorSentenceEnders =
|
||||
[
|
||||
"처리됩니다.", "구현되어 있습니다.", "필요합니다.", "중요한 역할을 합니다.",
|
||||
"확인할 수 있습니다.", "설계되어 있습니다.", "활용됩니다.", "반환됩니다.",
|
||||
"저장됩니다.", "업데이트됩니다.", "삭제됩니다.", "초기화됩니다.",
|
||||
];
|
||||
|
||||
// ── 더미 이름/이메일 데이터 ───────────────────────────────────────────────
|
||||
private static readonly string[] KorLastNames = ["김", "이", "박", "최", "정", "강", "조", "윤", "장", "임", "한", "오", "서", "신", "권", "황", "안", "송", "류", "전"];
|
||||
private static readonly string[] KorFirstNames = ["민준", "서연", "예준", "서현", "도윤", "지우", "시우", "수아", "지호", "하은", "준서", "하린", "건우", "소연", "현우", "지민", "우진", "지유", "연우", "채원"];
|
||||
private static readonly string[] EmailDomains = ["example.com", "test.co.kr", "dummy.net", "sample.org", "mock.io", "placeholder.dev"];
|
||||
|
||||
private static readonly Random Rng = new();
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
var para = GenerateParagraph(false);
|
||||
items.Add(new LauncherItem(
|
||||
"Lorem Ipsum 1단락",
|
||||
para.Length > 80 ? para[..80] + "…" : para,
|
||||
null,
|
||||
("copy", para),
|
||||
Symbol: "\uE8BD"));
|
||||
items.Add(new LauncherItem("lorem 3", "3단락 생성", null, null, Symbol: "\uE8BD"));
|
||||
items.Add(new LauncherItem("lorem words 20", "단어 20개", null, null, Symbol: "\uE8BD"));
|
||||
items.Add(new LauncherItem("lorem sentences 3", "문장 3개", null, null, Symbol: "\uE8BD"));
|
||||
items.Add(new LauncherItem("lorem ko", "한국어 더미 텍스트", null, null, Symbol: "\uE8BD"));
|
||||
items.Add(new LauncherItem("lorem email 5", "더미 이메일 5개", null, null, Symbol: "\uE8BD"));
|
||||
items.Add(new LauncherItem("lorem name 5", "더미 이름 5개", null, null, Symbol: "\uE8BD"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
switch (sub)
|
||||
{
|
||||
case "words":
|
||||
case "word":
|
||||
case "w":
|
||||
{
|
||||
var cnt = parts.Length > 1 && int.TryParse(parts[1], out var n) ? Math.Clamp(n, 1, 500) : 20;
|
||||
var text = GenerateWords(cnt, false);
|
||||
items.Add(new LauncherItem(
|
||||
$"단어 {cnt}개",
|
||||
text.Length > 80 ? text[..80] + "…" : text,
|
||||
null, ("copy", text), Symbol: "\uE8BD"));
|
||||
break;
|
||||
}
|
||||
|
||||
case "sentences":
|
||||
case "sentence":
|
||||
case "s":
|
||||
{
|
||||
var cnt = parts.Length > 1 && int.TryParse(parts[1], out var n) ? Math.Clamp(n, 1, 50) : 5;
|
||||
var text = GenerateSentences(cnt, false);
|
||||
items.Add(new LauncherItem(
|
||||
$"문장 {cnt}개",
|
||||
text.Length > 80 ? text[..80] + "…" : text,
|
||||
null, ("copy", text), Symbol: "\uE8BD"));
|
||||
break;
|
||||
}
|
||||
|
||||
case "ko":
|
||||
case "kor":
|
||||
case "korean":
|
||||
{
|
||||
var cnt = parts.Length > 1 && int.TryParse(parts[1], out var n) ? Math.Clamp(n, 1, 10) : 1;
|
||||
var text = GenerateParagraphs(cnt, true);
|
||||
items.Add(new LauncherItem(
|
||||
$"한국어 더미 텍스트 {cnt}단락",
|
||||
text.Length > 80 ? text[..80] + "…" : text,
|
||||
null, ("copy", text), Symbol: "\uE8BD"));
|
||||
break;
|
||||
}
|
||||
|
||||
case "email":
|
||||
{
|
||||
var cnt = parts.Length > 1 && int.TryParse(parts[1], out var n) ? Math.Clamp(n, 1, 20) : 5;
|
||||
var emails = Enumerable.Range(0, cnt).Select(_ => GenerateEmail()).ToList();
|
||||
var all = string.Join("\n", emails);
|
||||
items.Add(new LauncherItem(
|
||||
$"더미 이메일 {cnt}개",
|
||||
"전체 복사: Enter",
|
||||
null, ("copy", all), Symbol: "\uE8BD"));
|
||||
foreach (var email in emails)
|
||||
items.Add(new LauncherItem(email, "Enter 복사", null, ("copy", email), Symbol: "\uE8BD"));
|
||||
break;
|
||||
}
|
||||
|
||||
case "name":
|
||||
case "names":
|
||||
{
|
||||
var cnt = parts.Length > 1 && int.TryParse(parts[1], out var n) ? Math.Clamp(n, 1, 20) : 5;
|
||||
var names = Enumerable.Range(0, cnt).Select(_ => GenerateKorName()).ToList();
|
||||
var all = string.Join("\n", names);
|
||||
items.Add(new LauncherItem(
|
||||
$"더미 이름 {cnt}개",
|
||||
"전체 복사: Enter",
|
||||
null, ("copy", all), Symbol: "\uE8BD"));
|
||||
foreach (var name in names)
|
||||
items.Add(new LauncherItem(name, "한국어 이름 · Enter 복사", null, ("copy", name), Symbol: "\uE8BD"));
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
{
|
||||
// 숫자 단독 → 단락 수
|
||||
var cnt = int.TryParse(sub, out var n) ? Math.Clamp(n, 1, 10) : 1;
|
||||
var text = GenerateParagraphs(cnt, false);
|
||||
items.Add(new LauncherItem(
|
||||
$"Lorem Ipsum {cnt}단락",
|
||||
text.Length > 80 ? text[..80] + "…" : text,
|
||||
null, ("copy", text), Symbol: "\uE8BD"));
|
||||
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("Lorem", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 생성 헬퍼 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static string GenerateParagraphs(int count, bool korean)
|
||||
{
|
||||
var paras = Enumerable.Range(0, count).Select(_ => GenerateParagraph(korean));
|
||||
return string.Join("\n\n", paras);
|
||||
}
|
||||
|
||||
private static string GenerateParagraph(bool korean)
|
||||
{
|
||||
var sentenceCount = Rng.Next(4, 8);
|
||||
return GenerateSentences(sentenceCount, korean);
|
||||
}
|
||||
|
||||
private static string GenerateSentences(int count, bool korean)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
if (i > 0) sb.Append(' ');
|
||||
sb.Append(GenerateSentence(korean));
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string GenerateSentence(bool korean)
|
||||
{
|
||||
if (korean)
|
||||
{
|
||||
var starter = KorSentenceStarters[Rng.Next(KorSentenceStarters.Length)];
|
||||
var wordCnt = Rng.Next(3, 8);
|
||||
var words = Enumerable.Range(0, wordCnt).Select(_ => KorWords[Rng.Next(KorWords.Length)]);
|
||||
var ender = KorSentenceEnders[Rng.Next(KorSentenceEnders.Length)];
|
||||
return $"{starter} {string.Join(" ", words)} {ender}";
|
||||
}
|
||||
else
|
||||
{
|
||||
var wordCnt = Rng.Next(6, 15);
|
||||
var words = Enumerable.Range(0, wordCnt).Select((_, idx) =>
|
||||
{
|
||||
var w = LoremWords[Rng.Next(LoremWords.Length)];
|
||||
return idx == 0 ? char.ToUpper(w[0]) + w[1..] : w;
|
||||
});
|
||||
return string.Join(" ", words) + ".";
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateWords(int count, bool korean)
|
||||
{
|
||||
var pool = korean ? KorWords : LoremWords;
|
||||
return string.Join(" ", Enumerable.Range(0, count).Select(_ => pool[Rng.Next(pool.Length)]));
|
||||
}
|
||||
|
||||
private static string GenerateEmail()
|
||||
{
|
||||
var first = LoremWords[Rng.Next(LoremWords.Length)];
|
||||
var second = LoremWords[Rng.Next(LoremWords.Length)];
|
||||
var num = Rng.Next(10, 999);
|
||||
var domain = EmailDomains[Rng.Next(EmailDomains.Length)];
|
||||
return $"{first}.{second}{num}@{domain}";
|
||||
}
|
||||
|
||||
private static string GenerateKorName()
|
||||
{
|
||||
var last = KorLastNames[Rng.Next(KorLastNames.Length)];
|
||||
var first = KorFirstNames[Rng.Next(KorFirstNames.Length)];
|
||||
return last + first;
|
||||
}
|
||||
}
|
||||
231
src/AxCopilot/Handlers/MacroHandler.cs
Normal file
231
src/AxCopilot/Handlers/MacroHandler.cs
Normal file
@@ -0,0 +1,231 @@
|
||||
using System.Diagnostics;
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L6-2: 런처 매크로 핸들러. "macro" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: macro → 매크로 목록
|
||||
/// macro 이름 → 이름으로 필터
|
||||
/// macro new → 새 매크로 편집기 열기
|
||||
/// macro edit 이름 → 기존 매크로 편집
|
||||
/// macro del 이름 → 매크로 삭제
|
||||
/// macro play 이름 → 즉시 실행
|
||||
/// Enter로 선택한 매크로를 실행합니다.
|
||||
/// </summary>
|
||||
public class MacroHandler : IActionHandler
|
||||
{
|
||||
private readonly SettingsService _settings;
|
||||
|
||||
public MacroHandler(SettingsService settings) { _settings = settings; }
|
||||
|
||||
public string? Prefix => "macro";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Macro",
|
||||
"런처 매크로 — macro",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
// ─── 항목 목록 ──────────────────────────────────────────────────────────
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var parts = q.Split(' ', 2, StringSplitOptions.TrimEntries);
|
||||
var cmd = parts.Length > 0 ? parts[0].ToLowerInvariant() : "";
|
||||
|
||||
if (cmd == "new")
|
||||
{
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
|
||||
{
|
||||
new LauncherItem("새 매크로 만들기",
|
||||
"편집기에서 단계별 실행 시퀀스를 설정합니다",
|
||||
null, "__new__", Symbol: "\uE710")
|
||||
});
|
||||
}
|
||||
|
||||
if (cmd == "edit" && parts.Length > 1)
|
||||
{
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
|
||||
{
|
||||
new LauncherItem($"'{parts[1]}' 매크로 편집", "편집기 열기",
|
||||
null, $"__edit__{parts[1]}", Symbol: "\uE70F")
|
||||
});
|
||||
}
|
||||
|
||||
if ((cmd == "del" || cmd == "delete") && parts.Length > 1)
|
||||
{
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
|
||||
{
|
||||
new LauncherItem($"'{parts[1]}' 매크로 삭제",
|
||||
"Enter로 삭제 확인",
|
||||
null, $"__del__{parts[1]}", Symbol: Symbols.Delete)
|
||||
});
|
||||
}
|
||||
|
||||
if (cmd == "play" && parts.Length > 1)
|
||||
{
|
||||
var entry = _settings.Settings.Macros
|
||||
.FirstOrDefault(m => m.Name.Equals(parts[1], StringComparison.OrdinalIgnoreCase));
|
||||
if (entry != null)
|
||||
{
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
|
||||
{
|
||||
new LauncherItem($"[{entry.Name}] 매크로 실행",
|
||||
$"{entry.Steps.Count}단계 · Enter로 즉시 실행",
|
||||
null, entry, Symbol: "\uE768")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 목록
|
||||
var macros = _settings.Settings.Macros;
|
||||
var filter = q.ToLowerInvariant();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
foreach (var m in macros)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(filter) &&
|
||||
!m.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) &&
|
||||
!m.Description.Contains(filter, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var preview = m.Steps.Count == 0
|
||||
? "단계 없음"
|
||||
: string.Join(" → ", m.Steps.Take(3).Select(s => string.IsNullOrWhiteSpace(s.Label) ? s.Target : s.Label))
|
||||
+ (m.Steps.Count > 3 ? $" … +{m.Steps.Count - 3}" : "");
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
m.Name,
|
||||
$"{m.Steps.Count}단계 · {preview}",
|
||||
null, m, Symbol: "\uE768"));
|
||||
}
|
||||
|
||||
if (items.Count == 0 && string.IsNullOrEmpty(filter))
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
"등록된 매크로 없음",
|
||||
"'macro new'로 명령 시퀀스를 추가하세요",
|
||||
null, null, Symbol: Symbols.Info));
|
||||
}
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
"새 매크로 만들기",
|
||||
"macro new · 앱·URL·폴더·알림을 순서대로 실행",
|
||||
null, "__new__", Symbol: "\uE710"));
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ─── 실행 ─────────────────────────────────────────────────────────────
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is string s)
|
||||
{
|
||||
if (s == "__new__")
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
var win = new Views.MacroEditorWindow(null, _settings);
|
||||
win.Show();
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (s.StartsWith("__edit__"))
|
||||
{
|
||||
var name = s["__edit__".Length..];
|
||||
var entry = _settings.Settings.Macros
|
||||
.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
var win = new Views.MacroEditorWindow(entry, _settings);
|
||||
win.Show();
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (s.StartsWith("__del__"))
|
||||
{
|
||||
var name = s["__del__".Length..];
|
||||
var entry = _settings.Settings.Macros
|
||||
.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
if (entry != null)
|
||||
{
|
||||
_settings.Settings.Macros.Remove(entry);
|
||||
_settings.Save();
|
||||
NotificationService.Notify("AX Copilot", $"매크로 '{name}' 삭제됨");
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
// 매크로 항목 Enter → 실행
|
||||
if (item.Data is MacroEntry macro)
|
||||
{
|
||||
_ = RunMacroAsync(macro, ct);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ─── 매크로 재생 ──────────────────────────────────────────────────────
|
||||
internal static async Task RunMacroAsync(MacroEntry macro, CancellationToken ct)
|
||||
{
|
||||
int executed = 0;
|
||||
foreach (var step in macro.Steps)
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
|
||||
if (step.DelayMs > 0)
|
||||
await Task.Delay(step.DelayMs, ct).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
switch (step.Type.ToLowerInvariant())
|
||||
{
|
||||
case "app":
|
||||
if (!string.IsNullOrWhiteSpace(step.Target))
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = step.Target,
|
||||
Arguments = step.Args ?? "",
|
||||
UseShellExecute = true
|
||||
});
|
||||
break;
|
||||
|
||||
case "url":
|
||||
case "folder":
|
||||
if (!string.IsNullOrWhiteSpace(step.Target))
|
||||
Process.Start(new ProcessStartInfo(step.Target)
|
||||
{ UseShellExecute = true });
|
||||
break;
|
||||
|
||||
case "notification":
|
||||
var msg = string.IsNullOrWhiteSpace(step.Label) ? step.Target : step.Label;
|
||||
NotificationService.Notify($"[매크로] {macro.Name}", msg);
|
||||
break;
|
||||
|
||||
case "cmd":
|
||||
if (!string.IsNullOrWhiteSpace(step.Target))
|
||||
Process.Start(new ProcessStartInfo("powershell.exe",
|
||||
$"-NoProfile -ExecutionPolicy Bypass -Command \"{step.Target}\"")
|
||||
{ UseShellExecute = false, CreateNoWindow = true });
|
||||
break;
|
||||
}
|
||||
executed++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"매크로 단계 실행 실패 '{step.Label}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
NotificationService.Notify("매크로 완료",
|
||||
$"[{macro.Name}] {executed}/{macro.Steps.Count}단계 실행됨");
|
||||
}
|
||||
}
|
||||
356
src/AxCopilot/Handlers/MdHandler.cs
Normal file
356
src/AxCopilot/Handlers/MdHandler.cs
Normal file
@@ -0,0 +1,356 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L15-4: Markdown 분석기 핸들러. "md" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: md → 클립보드 Markdown 분석 (구조·통계)
|
||||
/// md toc → 목차(TOC) 생성
|
||||
/// md strip → Markdown 기호 제거 → 순수 텍스트
|
||||
/// md count → 단어·줄·코드블록 수 세기
|
||||
/// md links → 링크 목록 추출
|
||||
/// md images → 이미지 목록 추출
|
||||
/// Enter → 결과를 클립보드에 복사.
|
||||
/// </summary>
|
||||
public partial class MdHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "md";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"MD",
|
||||
"Markdown 분석기 — 구조 분석 · TOC · 기호 제거 · 링크 추출",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
// 클립보드에서 텍스트 읽기
|
||||
string? clipboard = null;
|
||||
try
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
if (Clipboard.ContainsText())
|
||||
clipboard = Clipboard.GetText();
|
||||
});
|
||||
}
|
||||
catch { /* 클립보드 접근 실패 */ }
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
items.Add(new LauncherItem("Markdown 분석기",
|
||||
"클립보드 Markdown 분석 · md toc / strip / count / links / images",
|
||||
null, null, Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("md toc", "목차(TOC) 생성", null, null, Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("md strip", "Markdown 기호 제거", null, null, Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("md count", "단어·줄·코드블록 통계", null, null, Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("md links", "링크 목록 추출", null, null, Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("md images", "이미지 목록 추출", null, null, Symbol: "\uE8A5"));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(clipboard))
|
||||
{
|
||||
items.Add(new LauncherItem("클립보드가 비어 있습니다",
|
||||
"Markdown 텍스트를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 간단 미리보기 통계
|
||||
var stat = QuickStat(clipboard);
|
||||
items.Add(new LauncherItem("── 클립보드 미리보기 ──", "", null, null, Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("클립보드 Markdown 분석", stat, null, ("copy", stat), Symbol: "\uE8A5"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 클립보드 없으면 서브커맨드도 안내만
|
||||
if (string.IsNullOrWhiteSpace(clipboard))
|
||||
{
|
||||
items.Add(new LauncherItem("클립보드가 비어 있습니다",
|
||||
"Markdown 텍스트를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var sub = q.Split(' ')[0].ToLowerInvariant();
|
||||
switch (sub)
|
||||
{
|
||||
case "toc":
|
||||
items.AddRange(BuildTocItems(clipboard));
|
||||
break;
|
||||
|
||||
case "strip":
|
||||
case "plain":
|
||||
case "text":
|
||||
items.AddRange(BuildStripItems(clipboard));
|
||||
break;
|
||||
|
||||
case "count":
|
||||
case "stat":
|
||||
case "stats":
|
||||
items.AddRange(BuildCountItems(clipboard));
|
||||
break;
|
||||
|
||||
case "links":
|
||||
case "link":
|
||||
items.AddRange(BuildLinkItems(clipboard));
|
||||
break;
|
||||
|
||||
case "images":
|
||||
case "image":
|
||||
case "img":
|
||||
items.AddRange(BuildImageItems(clipboard));
|
||||
break;
|
||||
|
||||
default:
|
||||
items.Add(new LauncherItem("알 수 없는 서브커맨드",
|
||||
"toc · strip · count · links · images", null, null, Symbol: "\uE783"));
|
||||
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("MD", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 분석 빌더 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static string QuickStat(string md)
|
||||
{
|
||||
var lines = md.Split('\n');
|
||||
var headings = lines.Count(l => HeadingRegex().IsMatch(l));
|
||||
var codeBlocks = CountCodeBlocks(md);
|
||||
var links = LinkRegex().Matches(md).Count;
|
||||
var words = WordCount(md);
|
||||
return $"{lines.Length}줄 · 제목 {headings}개 · 코드블록 {codeBlocks}개 · 링크 {links}개 · 단어 {words}개";
|
||||
}
|
||||
|
||||
private static List<LauncherItem> BuildTocItems(string md)
|
||||
{
|
||||
var items = new List<LauncherItem>();
|
||||
var lines = md.Split('\n');
|
||||
var headings = new List<(int Level, string Text, string Anchor)>();
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var m = HeadingRegex().Match(line);
|
||||
if (!m.Success) continue;
|
||||
var level = m.Groups[1].Value.Length;
|
||||
var text = m.Groups[2].Value.Trim();
|
||||
var anchor = MakeAnchor(text);
|
||||
headings.Add((level, text, anchor));
|
||||
}
|
||||
|
||||
if (headings.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("제목(#)이 없습니다", "Markdown 제목이 없으면 TOC를 생성할 수 없습니다",
|
||||
null, null, Symbol: "\uE946"));
|
||||
return items;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
foreach (var (level, text, anchor) in headings)
|
||||
{
|
||||
var indent = new string(' ', (level - 1) * 2);
|
||||
sb.AppendLine($"{indent}- [{text}](#{anchor})");
|
||||
}
|
||||
var toc = sb.ToString().TrimEnd();
|
||||
|
||||
items.Add(new LauncherItem($"TOC 생성 완료 ({headings.Count}개 제목)",
|
||||
"Enter → 전체 TOC 복사", null, ("copy", toc), Symbol: "\uE8A5"));
|
||||
|
||||
foreach (var (level, text, anchor) in headings.Take(20))
|
||||
{
|
||||
var prefix = new string('#', level) + " ";
|
||||
var entry = $"- [{text}](#{anchor})";
|
||||
items.Add(new LauncherItem($"{prefix}{text}", entry, null, ("copy", entry), Symbol: "\uE8A5"));
|
||||
}
|
||||
if (headings.Count > 20)
|
||||
items.Add(new LauncherItem($"… 외 {headings.Count - 20}개", "전체 복사는 첫 항목 Enter", null, null, Symbol: "\uE8A5"));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private static List<LauncherItem> BuildStripItems(string md)
|
||||
{
|
||||
var items = new List<LauncherItem>();
|
||||
var plain = StripMarkdown(md);
|
||||
var preview = plain.Length > 80 ? plain[..80] + "…" : plain;
|
||||
|
||||
items.Add(new LauncherItem("Markdown 기호 제거 완료",
|
||||
$"Enter → 순수 텍스트 복사 ({plain.Length}자)", null, ("copy", plain), Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("미리보기", preview, null, ("copy", plain), Symbol: "\uE8A5"));
|
||||
return items;
|
||||
}
|
||||
|
||||
private static List<LauncherItem> BuildCountItems(string md)
|
||||
{
|
||||
var items = new List<LauncherItem>();
|
||||
var lines = md.Split('\n');
|
||||
|
||||
var totalLines = lines.Length;
|
||||
var blankLines = lines.Count(l => string.IsNullOrWhiteSpace(l));
|
||||
var codeBlockCount= CountCodeBlocks(md);
|
||||
var headingCount = lines.Count(l => HeadingRegex().IsMatch(l));
|
||||
var listCount = lines.Count(l => ListRegex().IsMatch(l));
|
||||
var linkCount = LinkRegex().Matches(md).Count;
|
||||
var imageCount = ImageRegex().Matches(md).Count;
|
||||
var boldCount = BoldRegex().Matches(md).Count;
|
||||
var words = WordCount(md);
|
||||
var chars = md.Length;
|
||||
var charsNoSpace = md.Replace(" ", "").Replace("\n", "").Replace("\r", "").Length;
|
||||
|
||||
items.Add(new LauncherItem($"Markdown 통계", $"{totalLines}줄 · {words}단어 · {chars}자",
|
||||
null, ("copy", $"줄 {totalLines} · 단어 {words} · 문자 {chars}"), Symbol: "\uE8A5"));
|
||||
|
||||
items.Add(new LauncherItem("전체 줄 수", $"{totalLines}줄 (공백 {blankLines}줄)", null, ("copy", $"{totalLines}"), Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("단어 수", $"{words}단어", null, ("copy", $"{words}"), Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("문자 수", $"{chars}자 (공백 제외 {charsNoSpace}자)", null, ("copy", $"{chars}"), Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("제목(#) 수", $"{headingCount}개", null, ("copy", $"{headingCount}"), Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("코드 블록 수", $"{codeBlockCount}개", null, ("copy", $"{codeBlockCount}"), Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("목록 항목 수", $"{listCount}개", null, ("copy", $"{listCount}"), Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("링크 수", $"{linkCount}개", null, ("copy", $"{linkCount}"), Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("이미지 수", $"{imageCount}개", null, ("copy", $"{imageCount}"), Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("강조(**bold**) 수", $"{boldCount}개", null, ("copy", $"{boldCount}"), Symbol: "\uE8A5"));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private static List<LauncherItem> BuildLinkItems(string md)
|
||||
{
|
||||
var items = new List<LauncherItem>();
|
||||
var matches = LinkRegex().Matches(md);
|
||||
|
||||
if (matches.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("링크 없음", "클립보드 Markdown에 링크([text](url))가 없습니다",
|
||||
null, null, Symbol: "\uE946"));
|
||||
return items;
|
||||
}
|
||||
|
||||
var allUrls = string.Join("\n", matches.Cast<Match>().Select(m => m.Groups[2].Value));
|
||||
items.Add(new LauncherItem($"링크 {matches.Count}개 발견",
|
||||
"Enter → 전체 URL 목록 복사", null, ("copy", allUrls), Symbol: "\uE8A5"));
|
||||
|
||||
foreach (Match m in matches.Cast<Match>().Take(25))
|
||||
{
|
||||
var text = m.Groups[1].Value;
|
||||
var url = m.Groups[2].Value;
|
||||
var display = text.Length > 30 ? text[..30] + "…" : text;
|
||||
items.Add(new LauncherItem(display, url, null, ("copy", url), Symbol: "\uE8A5"));
|
||||
}
|
||||
if (matches.Count > 25)
|
||||
items.Add(new LauncherItem($"… 외 {matches.Count - 25}개", "전체 복사는 첫 항목 Enter", null, null, Symbol: "\uE8A5"));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private static List<LauncherItem> BuildImageItems(string md)
|
||||
{
|
||||
var items = new List<LauncherItem>();
|
||||
var matches = ImageRegex().Matches(md);
|
||||
|
||||
if (matches.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("이미지 없음", "클립보드 Markdown에 이미지()가 없습니다",
|
||||
null, null, Symbol: "\uE946"));
|
||||
return items;
|
||||
}
|
||||
|
||||
var allUrls = string.Join("\n", matches.Cast<Match>().Select(m => m.Groups[2].Value));
|
||||
items.Add(new LauncherItem($"이미지 {matches.Count}개 발견",
|
||||
"Enter → 전체 URL 목록 복사", null, ("copy", allUrls), Symbol: "\uE8A5"));
|
||||
|
||||
foreach (Match m in matches.Cast<Match>().Take(25))
|
||||
{
|
||||
var alt = m.Groups[1].Value;
|
||||
var url = m.Groups[2].Value;
|
||||
var display = string.IsNullOrWhiteSpace(alt) ? "(alt 없음)" : (alt.Length > 30 ? alt[..30] + "…" : alt);
|
||||
items.Add(new LauncherItem(display, url, null, ("copy", url), Symbol: "\uE8A5"));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static string StripMarkdown(string md)
|
||||
{
|
||||
var s = md;
|
||||
// 코드 블록 제거
|
||||
s = Regex.Replace(s, @"```[\s\S]*?```", "", RegexOptions.Multiline);
|
||||
s = Regex.Replace(s, @"`[^`]+`", "");
|
||||
// 제목 기호 제거
|
||||
s = Regex.Replace(s, @"^#{1,6}\s+", "", RegexOptions.Multiline);
|
||||
// 이미지, 링크 → 텍스트만
|
||||
s = Regex.Replace(s, @"!\[([^\]]*)\]\([^\)]*\)", "$1");
|
||||
s = Regex.Replace(s, @"\[([^\]]+)\]\([^\)]+\)", "$1");
|
||||
// 강조 기호 제거
|
||||
s = Regex.Replace(s, @"\*{1,3}([^*]+)\*{1,3}", "$1");
|
||||
s = Regex.Replace(s, @"_{1,3}([^_]+)_{1,3}", "$1");
|
||||
// 인용 기호
|
||||
s = Regex.Replace(s, @"^>\s+", "", RegexOptions.Multiline);
|
||||
// 목록 기호
|
||||
s = Regex.Replace(s, @"^[\-\*\+]\s+", "", RegexOptions.Multiline);
|
||||
s = Regex.Replace(s, @"^\d+\.\s+", "", RegexOptions.Multiline);
|
||||
// 수평선
|
||||
s = Regex.Replace(s, @"^[-*_]{3,}\s*$", "", RegexOptions.Multiline);
|
||||
// 다중 공백 정리
|
||||
s = Regex.Replace(s, @"\n{3,}", "\n\n");
|
||||
return s.Trim();
|
||||
}
|
||||
|
||||
private static string MakeAnchor(string text)
|
||||
{
|
||||
var s = text.ToLowerInvariant();
|
||||
s = Regex.Replace(s, @"[^\w\s\-가-힣]", "");
|
||||
s = Regex.Replace(s, @"\s+", "-");
|
||||
return s;
|
||||
}
|
||||
|
||||
private static int CountCodeBlocks(string md)
|
||||
{
|
||||
var matches = Regex.Matches(md, @"^```", RegexOptions.Multiline);
|
||||
return matches.Count / 2;
|
||||
}
|
||||
|
||||
private static int WordCount(string md)
|
||||
{
|
||||
var plain = StripMarkdown(md);
|
||||
return Regex.Matches(plain, @"\S+").Count;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^(#{1,6})\s+(.+)$", RegexOptions.Multiline)]
|
||||
private static partial Regex HeadingRegex();
|
||||
|
||||
[GeneratedRegex(@"^\s*[\-\*\+]\s+|^\s*\d+\.\s+", RegexOptions.Multiline)]
|
||||
private static partial Regex ListRegex();
|
||||
|
||||
[GeneratedRegex(@"\[([^\]]+)\]\(([^\)]+)\)")]
|
||||
private static partial Regex LinkRegex();
|
||||
|
||||
[GeneratedRegex(@"!\[([^\]]*)\]\(([^\)]+)\)")]
|
||||
private static partial Regex ImageRegex();
|
||||
|
||||
[GeneratedRegex(@"\*{2,3}[^*]+\*{2,3}")]
|
||||
private static partial Regex BoldRegex();
|
||||
}
|
||||
231
src/AxCopilot/Handlers/MeetHandler.cs
Normal file
231
src/AxCopilot/Handlers/MeetHandler.cs
Normal file
@@ -0,0 +1,231 @@
|
||||
using System.IO;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L27-4: 회의 링크 전용 관리 핸들러. "meet" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: meet → 전체 회의 목록
|
||||
/// meet 스탠드업 → 이름 검색
|
||||
/// meet add 스탠드업 https://... → 회의 추가
|
||||
/// meet del 스탠드업 → 회의 삭제
|
||||
/// Enter → 기본 브라우저로 회의 링크 열기.
|
||||
/// 저장: %APPDATA%\AxCopilot\meet.json
|
||||
/// </summary>
|
||||
public class MeetHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "meet";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"회의 링크",
|
||||
"회의 링크 관리 — 추가 · 검색 · 즉시 열기",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private record MeetEntry(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("url")] string Url,
|
||||
[property: JsonPropertyName("service")] string Service);
|
||||
|
||||
private static readonly string DataPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "meet.json");
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpt = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
var meets = Load();
|
||||
|
||||
// ── add 명령 ─────────────────────────────────────────────────────────
|
||||
if (q.StartsWith("add ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var parts = q[4..].Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length < 2 || !Uri.TryCreate(parts[1], UriKind.Absolute, out _))
|
||||
{
|
||||
items.Add(new LauncherItem("사용법: meet add {이름} {URL}",
|
||||
"예: meet add 스탠드업 https://teams.microsoft.com/...",
|
||||
null, null, Symbol: "\uE710"));
|
||||
}
|
||||
else
|
||||
{
|
||||
var name = parts[0];
|
||||
var url = parts[1];
|
||||
var svc = DetectService(url);
|
||||
items.Add(new LauncherItem(
|
||||
$"회의 추가: {name}",
|
||||
$"{svc} · {url}",
|
||||
null, ("add", $"{name}\t{url}\t{svc}"), Symbol: "\uE710"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ── del 명령 ─────────────────────────────────────────────────────────
|
||||
if (q.StartsWith("del ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var name = q[4..].Trim();
|
||||
var found = meets.FirstOrDefault(m =>
|
||||
m.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
if (found != null)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"회의 삭제: {found.Name}",
|
||||
$"{found.Service} · {found.Url}",
|
||||
null, ("del", found.Name), Symbol: "\uE74D"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"'{name}' 회의를 찾을 수 없습니다",
|
||||
"meet del {이름}", null, null, Symbol: "\uE783"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ── 빈 쿼리 → 전체 목록 ──────────────────────────────────────────────
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
if (meets.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("등록된 회의가 없습니다",
|
||||
"meet add {이름} {URL} 로 추가하세요",
|
||||
null, null, Symbol: "\uE8D6"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
$"회의 {meets.Count}개 등록됨",
|
||||
"Enter: 브라우저로 열기 · meet add/del 로 관리",
|
||||
null, null, Symbol: "\uE8D6"));
|
||||
|
||||
foreach (var m in meets)
|
||||
items.Add(MeetItem(m));
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ── 검색 ──────────────────────────────────────────────────────────────
|
||||
var searched = meets.Where(m =>
|
||||
m.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
m.Service.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
m.Url.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (searched.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem($"'{q}' 회의를 찾을 수 없습니다",
|
||||
"meet add {이름} {URL} 로 추가하세요",
|
||||
null, null, Symbol: "\uE783"));
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var m in searched)
|
||||
items.Add(MeetItem(m));
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is ("add", string addData))
|
||||
{
|
||||
var parts = addData.Split('\t');
|
||||
if (parts.Length >= 3)
|
||||
{
|
||||
var meets = Load();
|
||||
meets.RemoveAll(m => m.Name.Equals(parts[0], StringComparison.OrdinalIgnoreCase));
|
||||
meets.Add(new MeetEntry(parts[0], parts[1], parts[2]));
|
||||
Save(meets);
|
||||
NotificationService.Notify("meet", $"'{parts[0]}' 회의가 추가되었습니다.");
|
||||
}
|
||||
}
|
||||
else if (item.Data is ("del", string delName))
|
||||
{
|
||||
var meets = Load();
|
||||
int removed = meets.RemoveAll(m => m.Name.Equals(delName, StringComparison.OrdinalIgnoreCase));
|
||||
Save(meets);
|
||||
if (removed > 0)
|
||||
NotificationService.Notify("meet", $"'{delName}' 회의가 삭제되었습니다.");
|
||||
}
|
||||
else if (item.Data is ("open", string url))
|
||||
{
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(
|
||||
new System.Diagnostics.ProcessStartInfo(url) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
NotificationService.Notify("meet", $"열기 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ─── 헬퍼 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static LauncherItem MeetItem(MeetEntry m)
|
||||
{
|
||||
var icon = m.Service switch
|
||||
{
|
||||
"Zoom" => "\uE774",
|
||||
"Teams" => "\uE8D6",
|
||||
"Google Meet" => "\uE774",
|
||||
"Webex" => "\uE774",
|
||||
_ => "\uE774"
|
||||
};
|
||||
|
||||
return new LauncherItem(
|
||||
$"{m.Name} [{m.Service}]",
|
||||
m.Url,
|
||||
null, ("open", m.Url), Symbol: icon);
|
||||
}
|
||||
|
||||
private static string DetectService(string url)
|
||||
{
|
||||
var lower = url.ToLowerInvariant();
|
||||
if (lower.Contains("zoom.us") || lower.Contains("zoom.com")) return "Zoom";
|
||||
if (lower.Contains("teams.microsoft.com") || lower.Contains("teams.live.com")) return "Teams";
|
||||
if (lower.Contains("meet.google.com")) return "Google Meet";
|
||||
if (lower.Contains("webex.com")) return "Webex";
|
||||
if (lower.Contains("discord.gg") || lower.Contains("discord.com")) return "Discord";
|
||||
if (lower.Contains("slack.com")) return "Slack";
|
||||
return "기타";
|
||||
}
|
||||
|
||||
// ─── JSON 파일 I/O ───────────────────────────────────────────────────────
|
||||
|
||||
private static List<MeetEntry> Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(DataPath)) return [];
|
||||
var json = File.ReadAllText(DataPath);
|
||||
return JsonSerializer.Deserialize<List<MeetEntry>>(json) ?? [];
|
||||
}
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
private static void Save(List<MeetEntry> list)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(DataPath)!;
|
||||
if (!Directory.Exists(dir)) Directory.CreateDirectory(dir);
|
||||
File.WriteAllText(DataPath, JsonSerializer.Serialize(list, JsonOpt));
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
251
src/AxCopilot/Handlers/MorseHandler.cs
Normal file
251
src/AxCopilot/Handlers/MorseHandler.cs
Normal file
@@ -0,0 +1,251 @@
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L12-3: 모스 부호 변환기 핸들러. "morse" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: morse hello → 텍스트 → 모스 부호
|
||||
/// morse .- -... -.-. → 모스 부호 → 텍스트 (공백으로 구분)
|
||||
/// morse SOS → SOS 모스 부호
|
||||
/// morse → 클립보드 자동 감지·변환
|
||||
/// Enter → 결과를 클립보드에 복사.
|
||||
/// </summary>
|
||||
public class MorseHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "morse";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Morse",
|
||||
"모스 부호 변환기 — 텍스트 ↔ 모스 부호",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
// ── 모스 부호 사전 ────────────────────────────────────────────────────────
|
||||
private static readonly Dictionary<char, string> TextToMorse = new()
|
||||
{
|
||||
['A'] = ".-", ['B'] = "-...", ['C'] = "-.-.", ['D'] = "-..",
|
||||
['E'] = ".", ['F'] = "..-.", ['G'] = "--.", ['H'] = "....",
|
||||
['I'] = "..", ['J'] = ".---", ['K'] = "-.-", ['L'] = ".-..",
|
||||
['M'] = "--", ['N'] = "-.", ['O'] = "---", ['P'] = ".--.",
|
||||
['Q'] = "--.-", ['R'] = ".-.", ['S'] = "...", ['T'] = "-",
|
||||
['U'] = "..-", ['V'] = "...-", ['W'] = ".--", ['X'] = "-..-",
|
||||
['Y'] = "-.--", ['Z'] = "--..",
|
||||
['0'] = "-----", ['1'] = ".----", ['2'] = "..---", ['3'] = "...--",
|
||||
['4'] = "....-", ['5'] = ".....", ['6'] = "-....", ['7'] = "--...",
|
||||
['8'] = "---..", ['9'] = "----.",
|
||||
['.'] = ".-.-.-", [','] = "--..--", ['?'] = "..--..", ['!'] = "-.-.--",
|
||||
['/'] = "-..-.", ['-'] = "-....-", ['('] = "-.--.", [')'] = "-.--.-",
|
||||
['@'] = ".--.-.", ['='] = "-...-", ['+'] = ".-.-.", [':'] = "---...",
|
||||
[';'] = "-.-.-.", ['"'] = ".-..-.", ['\''] = ".----.", ['_'] = "..--.-",
|
||||
[' '] = "/",
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, char> MorseToText;
|
||||
|
||||
// 번개 부호 / 대문자 표
|
||||
private static readonly string[] ProsignCodes = ["SOS", "AR", "AS", "BT", "KN", "SK"];
|
||||
private static readonly Dictionary<string, string> ProsignMorse = new()
|
||||
{
|
||||
["SOS"] = "... --- ...",
|
||||
["AR"] = ".-.-.",
|
||||
["AS"] = ".-...",
|
||||
["BT"] = "-...-",
|
||||
["KN"] = "-.--.",
|
||||
["SK"] = "...-.-",
|
||||
};
|
||||
|
||||
static MorseHandler()
|
||||
{
|
||||
MorseToText = TextToMorse
|
||||
.Where(kv => kv.Key != ' ')
|
||||
.ToDictionary(kv => kv.Value, kv => kv.Key);
|
||||
}
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
// 클립보드 자동 감지
|
||||
var clip = GetClipboard();
|
||||
if (!string.IsNullOrWhiteSpace(clip))
|
||||
{
|
||||
if (IsMorseCode(clip))
|
||||
items.AddRange(BuildMorseToText(clip));
|
||||
else if (clip.Length <= 100)
|
||||
items.AddRange(BuildTextToMorse(clip));
|
||||
}
|
||||
|
||||
if (items.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("모스 부호 변환기",
|
||||
"예: morse hello / morse .- -... -.-.",
|
||||
null, null, Symbol: "\uE8C4"));
|
||||
items.Add(new LauncherItem("morse SOS", "SOS 모스 부호", null, null, Symbol: "\uE8C4"));
|
||||
items.Add(new LauncherItem("morse hello", "텍스트 → 모스", null, null, Symbol: "\uE8C4"));
|
||||
items.Add(new LauncherItem("morse .- -...", "모스 → 텍스트", null, null, Symbol: "\uE8C4"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 프로사인 키워드 우선
|
||||
if (ProsignMorse.TryGetValue(q.ToUpperInvariant(), out var psCode))
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"{q.ToUpper()} = {psCode}",
|
||||
"모스 부호 프로사인 · Enter 복사",
|
||||
null, ("copy", psCode), Symbol: "\uE8C4"));
|
||||
items.AddRange(BuildTextToMorse(q.ToUpper()));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 모스 부호 입력 감지
|
||||
if (IsMorseCode(q))
|
||||
{
|
||||
items.AddRange(BuildMorseToText(q));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.AddRange(BuildTextToMorse(q));
|
||||
}
|
||||
|
||||
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("Morse", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 변환 로직 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static IEnumerable<LauncherItem> BuildTextToMorse(string text)
|
||||
{
|
||||
var upper = text.ToUpperInvariant();
|
||||
var words = upper.Split(' ');
|
||||
var morseSb = new StringBuilder();
|
||||
var unknown = new List<char>();
|
||||
|
||||
foreach (var word in words)
|
||||
{
|
||||
if (morseSb.Length > 0) morseSb.Append("/ "); // 단어 구분
|
||||
foreach (var ch in word)
|
||||
{
|
||||
if (TextToMorse.TryGetValue(ch, out var code))
|
||||
morseSb.Append(code + " ");
|
||||
else
|
||||
unknown.Add(ch);
|
||||
}
|
||||
}
|
||||
|
||||
var morseStr = morseSb.ToString().TrimEnd();
|
||||
if (string.IsNullOrEmpty(morseStr))
|
||||
{
|
||||
yield return new LauncherItem("변환 불가", "모스 부호에 없는 문자입니다", null, null, Symbol: "\uE783");
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return new LauncherItem(
|
||||
morseStr.Length > 80 ? morseStr[..80] + "…" : morseStr,
|
||||
$"'{text}' → 모스 부호 · Enter 복사",
|
||||
null,
|
||||
("copy", morseStr),
|
||||
Symbol: "\uE8C4");
|
||||
|
||||
if (unknown.Count > 0)
|
||||
yield return new LauncherItem("변환 불가 문자",
|
||||
string.Join(" ", unknown.Distinct()), null, null, Symbol: "\uE946");
|
||||
|
||||
// 문자별 표 (최대 10자)
|
||||
var displayText = upper.Replace(" ", "");
|
||||
foreach (var ch in displayText.Take(10))
|
||||
{
|
||||
if (TextToMorse.TryGetValue(ch, out var code) && ch != ' ')
|
||||
yield return new LauncherItem($"{ch} = {code}", "문자별 코드", null, ("copy", code), Symbol: "\uE8C4");
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<LauncherItem> BuildMorseToText(string morse)
|
||||
{
|
||||
// "/" 는 단어 구분, 공백은 문자 구분
|
||||
var sb = new StringBuilder();
|
||||
var words = morse.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
var unknown = new List<string>();
|
||||
|
||||
foreach (var word in words)
|
||||
{
|
||||
var codes = word.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var code in codes)
|
||||
{
|
||||
if (MorseToText.TryGetValue(code, out var ch))
|
||||
sb.Append(ch);
|
||||
else
|
||||
unknown.Add(code);
|
||||
}
|
||||
sb.Append(' ');
|
||||
}
|
||||
|
||||
var result = sb.ToString().Trim();
|
||||
if (string.IsNullOrEmpty(result))
|
||||
{
|
||||
yield return new LauncherItem("변환 실패", "인식할 수 없는 모스 부호입니다", null, null, Symbol: "\uE783");
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return new LauncherItem(
|
||||
result,
|
||||
$"모스 부호 → '{result}' · Enter 복사",
|
||||
null,
|
||||
("copy", result),
|
||||
Symbol: "\uE8C4");
|
||||
|
||||
if (unknown.Count > 0)
|
||||
yield return new LauncherItem("인식 불가 코드",
|
||||
string.Join(" ", unknown.Distinct()), null, null, Symbol: "\uE946");
|
||||
|
||||
// 코드별 표 (최대 10개)
|
||||
var codes_ = morse.Trim().Split(new[] { ' ', '/' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var code in codes_.Take(10))
|
||||
{
|
||||
if (MorseToText.TryGetValue(code, out var ch))
|
||||
yield return new LauncherItem($"{code} = {ch}", "코드별 문자", null, ("copy", ch.ToString()), Symbol: "\uE8C4");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static bool IsMorseCode(string s)
|
||||
{
|
||||
// .-/ 문자와 공백만으로 구성되어 있으면 모스 부호로 판단
|
||||
return !string.IsNullOrWhiteSpace(s) &&
|
||||
s.All(c => c is '.' or '-' or '/' or ' ') &&
|
||||
(s.Contains('.') || s.Contains('-'));
|
||||
}
|
||||
|
||||
private static string GetClipboard()
|
||||
{
|
||||
try
|
||||
{
|
||||
return System.Windows.Application.Current.Dispatcher.Invoke(
|
||||
() => Clipboard.ContainsText() ? Clipboard.GetText() : "");
|
||||
}
|
||||
catch { return ""; }
|
||||
}
|
||||
}
|
||||
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 { /* 비핵심 */ }
|
||||
}
|
||||
}
|
||||
139
src/AxCopilot/Handlers/NotifHandler.cs
Normal file
139
src/AxCopilot/Handlers/NotifHandler.cs
Normal file
@@ -0,0 +1,139 @@
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// Phase L3-8: 알림 센터 핸들러. "notif" 프리픽스로 사용합니다.
|
||||
/// AX Copilot이 표시한 최근 알림 이력을 런처에서 조회합니다.
|
||||
///
|
||||
/// 사용법:
|
||||
/// notif → 최근 알림 이력 목록
|
||||
/// notif clear → 알림 이력 초기화
|
||||
/// notif [검색어] → 제목·내용으로 필터링
|
||||
///
|
||||
/// Enter → 알림 내용을 클립보드에 복사.
|
||||
/// </summary>
|
||||
public class NotifHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "notif";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"NotifCenter",
|
||||
"알림 센터 — notif",
|
||||
"1.0",
|
||||
"AX",
|
||||
"AX Copilot 알림 이력을 조회합니다.");
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
|
||||
// clear 명령
|
||||
if (q.Equals("clear", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var count = NotificationCenterService.History.Count;
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(
|
||||
[
|
||||
new LauncherItem(
|
||||
$"알림 이력 초기화 ({count}건)",
|
||||
"Enter를 눌러 전체 삭제",
|
||||
null, "__CLEAR__",
|
||||
Symbol: Symbols.Delete)
|
||||
]);
|
||||
}
|
||||
|
||||
var history = NotificationCenterService.History;
|
||||
|
||||
// 검색어 필터
|
||||
IEnumerable<NotificationEntry> filtered = history;
|
||||
if (!string.IsNullOrEmpty(q))
|
||||
{
|
||||
filtered = history
|
||||
.Where(e => e.Title.Contains(q, StringComparison.OrdinalIgnoreCase)
|
||||
|| e.Message.Contains(q, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
var list = filtered.Take(12).ToList();
|
||||
|
||||
if (list.Count == 0)
|
||||
{
|
||||
var emptyMsg = string.IsNullOrEmpty(q)
|
||||
? "알림 이력이 없습니다"
|
||||
: $"'{q}'에 해당하는 알림 없음";
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(
|
||||
[
|
||||
new LauncherItem(
|
||||
emptyMsg,
|
||||
"AX Copilot 동작 중 발생한 알림이 여기에 표시됩니다",
|
||||
null, null,
|
||||
Symbol: Symbols.Info)
|
||||
]);
|
||||
}
|
||||
|
||||
var items = list
|
||||
.Select(e => new LauncherItem(
|
||||
e.Title,
|
||||
$"{e.Message} · {TimeAgo(e.Timestamp)}",
|
||||
null, e,
|
||||
Symbol: GetSymbol(e)))
|
||||
.ToList<LauncherItem>();
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
// 이력 초기화
|
||||
if (item.Data is string s && s == "__CLEAR__")
|
||||
{
|
||||
NotificationCenterService.ClearHistory();
|
||||
NotificationService.LogOnly("AX Copilot", "알림 이력이 초기화되었습니다.");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// 알림 내용 클립보드 복사
|
||||
if (item.Data is NotificationEntry entry)
|
||||
{
|
||||
try
|
||||
{
|
||||
Application.Current?.Dispatcher.Invoke(() =>
|
||||
Clipboard.SetText($"[{entry.Title}] {entry.Message}"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"[NotifHandler] 클립보드 복사 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ─── 내부 헬퍼 ──────────────────────────────────────────────────────────
|
||||
|
||||
private static string TimeAgo(DateTime timestamp)
|
||||
{
|
||||
var diff = DateTime.Now - timestamp;
|
||||
if (diff.TotalSeconds < 60) return "방금 전";
|
||||
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}분 전";
|
||||
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}시간 전";
|
||||
return $"{(int)diff.TotalDays}일 전";
|
||||
}
|
||||
|
||||
private static string GetSymbol(NotificationEntry entry) => entry.Type switch
|
||||
{
|
||||
NotificationType.Error => Symbols.Error,
|
||||
NotificationType.Warning => Symbols.Warning,
|
||||
NotificationType.Success => Symbols.Favorite,
|
||||
_ => entry.Title switch
|
||||
{
|
||||
var t when t.Contains("태그") => Symbols.Tag,
|
||||
var t when t.Contains("즐겨찾기") => Symbols.Favorite,
|
||||
var t when t.Contains("저장")
|
||||
|| t.Contains("내보내기") => Symbols.Save,
|
||||
_ => Symbols.ReminderBell,
|
||||
}
|
||||
};
|
||||
}
|
||||
342
src/AxCopilot/Handlers/NpmHandler.cs
Normal file
342
src/AxCopilot/Handlers/NpmHandler.cs
Normal file
@@ -0,0 +1,342 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L19-4: npm/yarn/pnpm 패키지 매니저 명령어 생성·실행 핸들러. "npm" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: npm → 자주 쓰는 명령어 목록
|
||||
/// npm init → npm/yarn/pnpm init 명령 비교
|
||||
/// npm install lodash → npm/yarn/pnpm install lodash 명령 비교
|
||||
/// npm i lodash → install 단축키
|
||||
/// npm run dev → npm/yarn/pnpm run dev
|
||||
/// npm uninstall lodash → npm/yarn/pnpm uninstall
|
||||
/// npm update → 패키지 업데이트 명령
|
||||
/// npm list → 패키지 목록 명령
|
||||
/// npm audit → 보안 감사 명령
|
||||
/// npm publish → 배포 명령
|
||||
/// npm scripts → package.json scripts 확인 방법
|
||||
/// npm global → 전역 패키지 목록 명령
|
||||
/// npm clean → 캐시·node_modules 정리 명령
|
||||
/// Enter → 클립보드 복사 (또는 터미널에서 실행).
|
||||
/// </summary>
|
||||
public class NpmHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "npm";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"NPM",
|
||||
"npm/yarn/pnpm 명령어 생성기 — install·run·audit·publish·clean",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private record PmCmd(string Manager, string Cmd, string Description);
|
||||
|
||||
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("npm/yarn/pnpm 명령어 생성기",
|
||||
"npm install / run / init / build / test / audit / publish / clean …",
|
||||
null, null, Symbol: "\uE756"));
|
||||
AddCommandGroups(items);
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
var arg = parts.Length > 1 ? parts[1].Trim() : "";
|
||||
|
||||
// 단축키 정규화
|
||||
sub = sub switch
|
||||
{
|
||||
"i" => "install",
|
||||
"un" => "uninstall",
|
||||
"rm" => "uninstall",
|
||||
"up" => "update",
|
||||
"ls" => "list",
|
||||
_ => sub
|
||||
};
|
||||
|
||||
switch (sub)
|
||||
{
|
||||
case "init":
|
||||
AddSection(items, "프로젝트 초기화", [
|
||||
new("npm", "npm init -y", "기본값으로 package.json 생성"),
|
||||
new("yarn", "yarn init -y", "기본값으로 package.json 생성"),
|
||||
new("pnpm", "pnpm init", "기본값으로 package.json 생성"),
|
||||
]);
|
||||
break;
|
||||
|
||||
case "install" when !string.IsNullOrWhiteSpace(arg):
|
||||
AddSection(items, $"패키지 설치: {arg}", [
|
||||
new("npm", $"npm install {arg}", $"npm으로 {arg} 설치"),
|
||||
new("yarn", $"yarn add {arg}", $"yarn으로 {arg} 설치"),
|
||||
new("pnpm", $"pnpm add {arg}", $"pnpm으로 {arg} 설치"),
|
||||
]);
|
||||
AddSection(items, $"개발 의존성으로 설치: {arg}", [
|
||||
new("npm", $"npm install {arg} --save-dev", "devDependencies에 추가"),
|
||||
new("yarn", $"yarn add {arg} --dev", "devDependencies에 추가"),
|
||||
new("pnpm", $"pnpm add {arg} --save-dev", "devDependencies에 추가"),
|
||||
]);
|
||||
AddSection(items, $"전역 설치: {arg}", [
|
||||
new("npm", $"npm install -g {arg}", "전역 설치"),
|
||||
new("yarn", $"yarn global add {arg}", "전역 설치"),
|
||||
new("pnpm", $"pnpm add -g {arg}", "전역 설치"),
|
||||
]);
|
||||
break;
|
||||
|
||||
case "install":
|
||||
AddSection(items, "의존성 설치 (node_modules)", [
|
||||
new("npm", "npm install", "package.json 의존성 모두 설치"),
|
||||
new("yarn", "yarn install", "package.json 의존성 모두 설치"),
|
||||
new("pnpm", "pnpm install", "package.json 의존성 모두 설치"),
|
||||
]);
|
||||
AddSection(items, "프로덕션 의존성만", [
|
||||
new("npm", "npm install --production", "--production 플래그"),
|
||||
new("yarn", "yarn install --production", "--production 플래그"),
|
||||
new("pnpm", "pnpm install --prod", "--prod 플래그"),
|
||||
]);
|
||||
break;
|
||||
|
||||
case "uninstall" when !string.IsNullOrWhiteSpace(arg):
|
||||
AddSection(items, $"패키지 제거: {arg}", [
|
||||
new("npm", $"npm uninstall {arg}", $"npm으로 {arg} 제거"),
|
||||
new("yarn", $"yarn remove {arg}", $"yarn으로 {arg} 제거"),
|
||||
new("pnpm", $"pnpm remove {arg}", $"pnpm으로 {arg} 제거"),
|
||||
]);
|
||||
break;
|
||||
|
||||
case "run" when !string.IsNullOrWhiteSpace(arg):
|
||||
AddSection(items, $"스크립트 실행: {arg}", [
|
||||
new("npm", $"npm run {arg}", $"npm run {arg}"),
|
||||
new("yarn", $"yarn {arg}", $"yarn {arg}"),
|
||||
new("pnpm", $"pnpm run {arg}", $"pnpm run {arg}"),
|
||||
]);
|
||||
break;
|
||||
|
||||
case "start":
|
||||
case "run":
|
||||
AddSection(items, "주요 스크립트", [
|
||||
new("npm", "npm start", "start 스크립트 실행"),
|
||||
new("npm", "npm run dev", "dev 스크립트 실행"),
|
||||
new("npm", "npm run build", "build 스크립트 실행"),
|
||||
new("npm", "npm test", "test 스크립트 실행"),
|
||||
]);
|
||||
AddSection(items, "yarn 동등 명령", [
|
||||
new("yarn", "yarn start", "start"),
|
||||
new("yarn", "yarn dev", "dev"),
|
||||
new("yarn", "yarn build", "build"),
|
||||
new("yarn", "yarn test", "test"),
|
||||
]);
|
||||
AddSection(items, "pnpm 동등 명령", [
|
||||
new("pnpm", "pnpm start", "start"),
|
||||
new("pnpm", "pnpm run dev", "dev"),
|
||||
new("pnpm", "pnpm run build", "build"),
|
||||
new("pnpm", "pnpm test", "test"),
|
||||
]);
|
||||
break;
|
||||
|
||||
case "build":
|
||||
AddSection(items, "빌드", [
|
||||
new("npm", "npm run build", "build 스크립트"),
|
||||
new("yarn", "yarn build", "build 스크립트"),
|
||||
new("pnpm", "pnpm run build", "build 스크립트"),
|
||||
]);
|
||||
break;
|
||||
|
||||
case "test":
|
||||
AddSection(items, "테스트", [
|
||||
new("npm", "npm test", "test 스크립트"),
|
||||
new("npm", "npm run test:watch", "테스트 와처"),
|
||||
new("yarn", "yarn test", "test 스크립트"),
|
||||
new("pnpm", "pnpm test", "test 스크립트"),
|
||||
]);
|
||||
break;
|
||||
|
||||
case "update" or "upgrade":
|
||||
AddSection(items, "패키지 업데이트", [
|
||||
new("npm", "npm update", "모든 패키지 업데이트"),
|
||||
new("npm", "npm outdated", "오래된 패키지 확인"),
|
||||
new("npm", "npx npm-check-updates", "버전 업그레이드 체크"),
|
||||
new("yarn", "yarn upgrade", "모든 패키지 업그레이드"),
|
||||
new("pnpm", "pnpm update", "모든 패키지 업데이트"),
|
||||
]);
|
||||
break;
|
||||
|
||||
case "list" or "ls":
|
||||
AddSection(items, "패키지 목록", [
|
||||
new("npm", "npm list", "설치된 패키지 목록"),
|
||||
new("npm", "npm list --depth=0", "최상위 패키지만"),
|
||||
new("npm", "npm list -g --depth=0", "전역 패키지 목록"),
|
||||
new("yarn", "yarn list", "설치된 패키지 목록"),
|
||||
new("pnpm", "pnpm list", "설치된 패키지 목록"),
|
||||
]);
|
||||
break;
|
||||
|
||||
case "audit":
|
||||
AddSection(items, "보안 감사", [
|
||||
new("npm", "npm audit", "보안 취약점 스캔"),
|
||||
new("npm", "npm audit fix", "자동 수정"),
|
||||
new("yarn", "yarn audit", "보안 취약점 스캔"),
|
||||
new("pnpm", "pnpm audit", "보안 취약점 스캔"),
|
||||
]);
|
||||
break;
|
||||
|
||||
case "publish":
|
||||
AddSection(items, "패키지 배포", [
|
||||
new("npm", "npm publish", "npm 레지스트리에 배포"),
|
||||
new("npm", "npm publish --access public","공개 패키지로 배포"),
|
||||
new("npm", "npm version patch", "패치 버전 올리기"),
|
||||
new("npm", "npm version minor", "마이너 버전 올리기"),
|
||||
new("npm", "npm version major", "메이저 버전 올리기"),
|
||||
new("yarn", "yarn publish", "yarn으로 배포"),
|
||||
new("pnpm", "pnpm publish", "pnpm으로 배포"),
|
||||
]);
|
||||
break;
|
||||
|
||||
case "scripts":
|
||||
AddSection(items, "package.json 스크립트 확인", [
|
||||
new("npm", "npm run", "스크립트 목록 보기"),
|
||||
new("npm", "cat package.json", "package.json 확인"),
|
||||
new("yarn", "yarn run", "스크립트 목록 보기"),
|
||||
new("pnpm", "pnpm run", "스크립트 목록 보기"),
|
||||
]);
|
||||
break;
|
||||
|
||||
case "global":
|
||||
AddSection(items, "전역 패키지", [
|
||||
new("npm", "npm list -g --depth=0", "전역 패키지 목록"),
|
||||
new("npm", "npm root -g", "전역 node_modules 경로"),
|
||||
new("yarn", "yarn global list", "전역 패키지 목록"),
|
||||
new("pnpm", "pnpm list -g", "전역 패키지 목록"),
|
||||
]);
|
||||
break;
|
||||
|
||||
case "clean":
|
||||
AddSection(items, "캐시 및 모듈 정리", [
|
||||
new("npm", "npm cache clean --force", "npm 캐시 삭제"),
|
||||
new("npm", "Remove-Item -Recurse -Force node_modules", "node_modules 삭제 (PowerShell)"),
|
||||
new("npm", "rm -rf node_modules && npm install", "재설치 (bash)"),
|
||||
new("yarn", "yarn cache clean", "yarn 캐시 삭제"),
|
||||
new("pnpm", "pnpm store prune", "pnpm 스토어 정리"),
|
||||
]);
|
||||
break;
|
||||
|
||||
case "ci":
|
||||
AddSection(items, "CI/CD용 클린 설치", [
|
||||
new("npm", "npm ci", "package-lock.json 기반 클린 설치"),
|
||||
new("yarn", "yarn install --frozen-lockfile", "lockfile 기반 클린 설치"),
|
||||
new("pnpm", "pnpm install --frozen-lockfile", "lockfile 기반 클린 설치"),
|
||||
]);
|
||||
break;
|
||||
|
||||
case "lock":
|
||||
AddSection(items, "lockfile 관리", [
|
||||
new("npm", "npm install --package-lock-only", "lockfile만 갱신"),
|
||||
new("yarn", "yarn import", "package-lock → yarn.lock 변환"),
|
||||
new("pnpm", "pnpm import", "다른 lockfile → pnpm-lock.yaml 변환"),
|
||||
]);
|
||||
break;
|
||||
|
||||
default:
|
||||
items.Add(new LauncherItem($"알 수 없는 명령: '{sub}'",
|
||||
"init · install · uninstall · run · build · test · update · list · audit · publish · clean · global",
|
||||
null, null, Symbol: "\uE783"));
|
||||
AddCommandGroups(items);
|
||||
break;
|
||||
}
|
||||
|
||||
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("NPM", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { }
|
||||
break;
|
||||
|
||||
case ("run", string cmd):
|
||||
RunInTerminal(cmd);
|
||||
break;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 헬퍼 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static void AddCommandGroups(List<LauncherItem> items)
|
||||
{
|
||||
var groups = new (string Title, string[] Keys)[]
|
||||
{
|
||||
("초기화·설치", ["npm init", "npm install", "npm install <패키지>"]),
|
||||
("개발 워크플로우", ["npm start", "npm run dev", "npm run build", "npm test"]),
|
||||
("패키지 관리", ["npm update", "npm uninstall <패키지>", "npm list"]),
|
||||
("보안·배포", ["npm audit", "npm audit fix", "npm publish"]),
|
||||
("캐시·정리", ["npm cache clean --force", "npm ci"]),
|
||||
};
|
||||
foreach (var (title, keys) in groups)
|
||||
items.Add(new LauncherItem(title, string.Join(" / ", keys), null, null, Symbol: "\uE756"));
|
||||
}
|
||||
|
||||
private static void AddSection(List<LauncherItem> items, string title, PmCmd[] cmds)
|
||||
{
|
||||
items.Add(new LauncherItem($"── {title} ──", "", null, null, Symbol: "\uE756"));
|
||||
foreach (var c in cmds)
|
||||
{
|
||||
var icon = c.Manager switch
|
||||
{
|
||||
"yarn" => "🧶",
|
||||
"pnpm" => "📦",
|
||||
_ => "📦"
|
||||
};
|
||||
items.Add(new LauncherItem(c.Cmd, $"{c.Manager} · {c.Description} · Enter 복사",
|
||||
null, ("copy", c.Cmd), Symbol: "\uE756"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void RunInTerminal(string cmd)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Windows Terminal 우선
|
||||
var wtPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
@"Microsoft\WindowsApps\wt.exe");
|
||||
if (File.Exists(wtPath))
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = wtPath,
|
||||
Arguments = $"cmd /k \"{cmd}\"",
|
||||
UseShellExecute = true
|
||||
});
|
||||
return;
|
||||
}
|
||||
// cmd 폴백
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "cmd.exe",
|
||||
Arguments = $"/k {cmd}",
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
288
src/AxCopilot/Handlers/NumHandler.cs
Normal file
288
src/AxCopilot/Handlers/NumHandler.cs
Normal file
@@ -0,0 +1,288 @@
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L17-2: 숫자 포맷·읽기 변환 핸들러. "num" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: num 1234567 → 천단위·한글·영어·진수·과학표기 모두 표시
|
||||
/// num 0xff → 16진수 → 10진수 변환
|
||||
/// num 0b1010 → 2진수 → 10진수 변환
|
||||
/// num 42 ko → 한국어로 읽기 (사십이)
|
||||
/// num 42 en → 영어로 읽기 (forty-two)
|
||||
/// num 1e6 → 과학표기 → 일반 변환
|
||||
/// Enter → 결과 복사.
|
||||
/// </summary>
|
||||
public class NumHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "num";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Num",
|
||||
"숫자 포맷·읽기 변환 — 한글·영어·진수·천단위·과학표기",
|
||||
"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("숫자 포맷·읽기 변환기",
|
||||
"예: num 1234567 / num 0xff / num 42 ko / num 42 en",
|
||||
null, null, Symbol: "\uE8EF"));
|
||||
items.Add(new LauncherItem("num <숫자>", "천단위·한글·영어·진수 변환", null, null, Symbol: "\uE8EF"));
|
||||
items.Add(new LauncherItem("num 0xff", "16진수 입력", null, null, Symbol: "\uE8EF"));
|
||||
items.Add(new LauncherItem("num 0b1010", "2진수 입력", null, null, Symbol: "\uE8EF"));
|
||||
items.Add(new LauncherItem("num 0o17", "8진수 입력", null, null, Symbol: "\uE8EF"));
|
||||
items.Add(new LauncherItem("num 42 ko", "한국어로 읽기", null, null, Symbol: "\uE8EF"));
|
||||
items.Add(new LauncherItem("num 42 en", "영어로 읽기", null, null, Symbol: "\uE8EF"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var numStr = parts[0];
|
||||
var mode = parts.Length > 1 ? parts[1].ToLowerInvariant() : null;
|
||||
|
||||
// 숫자 파싱 (여러 진수 지원)
|
||||
if (!TryParseNumber(numStr, out var value, out var inputBase))
|
||||
{
|
||||
items.Add(new LauncherItem("숫자 형식 오류",
|
||||
"정수 또는 0x/0b/0o 접두사 숫자를 입력하세요", null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 한국어 읽기 전용
|
||||
if (mode is "ko" or "kr" or "한국어" or "한글")
|
||||
{
|
||||
var ko = ToKorean(value);
|
||||
items.Add(new LauncherItem(ko, $"{value:N0} 한국어 읽기", null, ("copy", ko), Symbol: "\uE8EF"));
|
||||
var ko2 = ToKoreanMoney(value);
|
||||
items.Add(new LauncherItem(ko2, "금액 읽기 (원)", null, ("copy", ko2), Symbol: "\uE8EF"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 영어 읽기 전용
|
||||
if (mode is "en" or "english" or "영어")
|
||||
{
|
||||
var en = ToEnglish(value);
|
||||
items.Add(new LauncherItem(en, $"{value:N0} 영어 읽기", null, ("copy", en), Symbol: "\uE8EF"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 전체 변환 표시
|
||||
items.Add(new LauncherItem($"{value:N0}",
|
||||
$"입력값 ({inputBase}진수 → 10진수)",
|
||||
null, ("copy", $"{value}"), Symbol: "\uE8EF"));
|
||||
|
||||
// 천단위 구분
|
||||
var commaSep = $"{value:N0}";
|
||||
items.Add(new LauncherItem(commaSep, "천단위 구분", null, ("copy", commaSep), Symbol: "\uE8EF"));
|
||||
|
||||
// 한글 단위 (만·억·조)
|
||||
var krUnit = ToKoreanUnit(value);
|
||||
items.Add(new LauncherItem(krUnit, "한글 단위", null, ("copy", krUnit), Symbol: "\uE8EF"));
|
||||
|
||||
// 한국어 읽기
|
||||
var koRead = ToKorean(value);
|
||||
items.Add(new LauncherItem(koRead, "한국어 읽기", null, ("copy", koRead), Symbol: "\uE8EF"));
|
||||
|
||||
// 영어 읽기
|
||||
if (Math.Abs(value) < 1_000_000_000_000L)
|
||||
{
|
||||
var enRead = ToEnglish(value);
|
||||
items.Add(new LauncherItem(enRead, "영어 읽기", null, ("copy", enRead), Symbol: "\uE8EF"));
|
||||
}
|
||||
|
||||
// 진수 변환 (정수 범위만)
|
||||
if (value >= 0 && value <= long.MaxValue)
|
||||
{
|
||||
var lv = (long)value;
|
||||
var hex = $"0x{lv:X}";
|
||||
var bin = Convert.ToString(lv, 2);
|
||||
var oct = Convert.ToString(lv, 8);
|
||||
items.Add(new LauncherItem($"── 진수 변환 ──", "", null, null, Symbol: "\uE8EF"));
|
||||
items.Add(new LauncherItem($"16진수 {hex}", $"Hex", null, ("copy", hex), Symbol: "\uE8EF"));
|
||||
items.Add(new LauncherItem($"8진수 0o{oct}", $"Octal", null, ("copy", $"0o{oct}"), Symbol: "\uE8EF"));
|
||||
items.Add(new LauncherItem($"2진수 {bin}", $"Binary ({bin.Length}bit)", null, ("copy", bin), Symbol: "\uE8EF"));
|
||||
}
|
||||
|
||||
// 과학 표기
|
||||
var sci = $"{value:E4}";
|
||||
items.Add(new LauncherItem($"과학 표기 {sci}", "Scientific Notation", null, ("copy", sci), Symbol: "\uE8EF"));
|
||||
|
||||
// 로마 숫자 (1~3999)
|
||||
if (value >= 1 && value <= 3999)
|
||||
{
|
||||
var roman = ToRoman((int)value);
|
||||
items.Add(new LauncherItem($"로마 숫자 {roman}", "Roman Numerals", null, ("copy", roman), Symbol: "\uE8EF"));
|
||||
}
|
||||
|
||||
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("Num", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 파싱 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static bool TryParseNumber(string s, out double value, out int inputBase)
|
||||
{
|
||||
value = 0;
|
||||
inputBase = 10;
|
||||
s = s.Replace(",", "").Trim();
|
||||
|
||||
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ||
|
||||
s.StartsWith("&H", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
inputBase = 16;
|
||||
var hex = s[2..];
|
||||
if (long.TryParse(hex, System.Globalization.NumberStyles.HexNumber,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var lv))
|
||||
{ value = lv; return true; }
|
||||
return false;
|
||||
}
|
||||
if (s.StartsWith("0b", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
inputBase = 2;
|
||||
try { value = System.Convert.ToInt64(s[2..], 2); return true; }
|
||||
catch { return false; }
|
||||
}
|
||||
if (s.StartsWith("0o", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
inputBase = 8;
|
||||
try { value = System.Convert.ToInt64(s[2..], 8); return true; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
// 과학 표기법 포함 일반 double
|
||||
if (double.TryParse(s, System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out value))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── 한글 단위 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static string ToKoreanUnit(double value)
|
||||
{
|
||||
var abs = Math.Abs(value);
|
||||
var sign = value < 0 ? "-" : "";
|
||||
|
||||
if (abs >= 1_0000_0000_0000) return $"{sign}{abs / 1_0000_0000_0000:N2}조";
|
||||
if (abs >= 1_0000_0000) return $"{sign}{abs / 1_0000_0000:N2}억";
|
||||
if (abs >= 1_0000) return $"{sign}{abs / 1_0000:N2}만";
|
||||
return $"{sign}{abs:N0}";
|
||||
}
|
||||
|
||||
private static readonly string[] KoOnes = ["", "일", "이", "삼", "사", "오", "육", "칠", "팔", "구"];
|
||||
private static readonly string[] KoTens = ["", "십", "이십", "삼십", "사십", "오십", "육십", "칠십", "팔십", "구십"];
|
||||
private static readonly string[] KoHundreds= ["", "백", "이백", "삼백", "사백", "오백", "육백", "칠백", "팔백", "구백"];
|
||||
|
||||
private static string ToKorean(double value)
|
||||
{
|
||||
if (value == 0) return "영";
|
||||
var abs = (long)Math.Abs(value);
|
||||
var sign = value < 0 ? "마이너스 " : "";
|
||||
return sign + KoNumber(abs);
|
||||
}
|
||||
|
||||
private static string KoNumber(long n)
|
||||
{
|
||||
if (n == 0) return "";
|
||||
var sb = new StringBuilder();
|
||||
var jo = n / 1_0000_0000_0000L;
|
||||
var eok = n % 1_0000_0000_0000L / 1_0000_0000L;
|
||||
var man = n % 1_0000_0000L / 1_0000L;
|
||||
var rest = n % 1_0000L;
|
||||
|
||||
if (jo > 0) { sb.Append(KoUnder1만(jo)); sb.Append("조 "); }
|
||||
if (eok > 0) { sb.Append(KoUnder1만(eok)); sb.Append("억 "); }
|
||||
if (man > 0) { sb.Append(KoUnder1만(man)); sb.Append("만 "); }
|
||||
if (rest> 0) { sb.Append(KoUnder1만(rest)); }
|
||||
return sb.ToString().Trim();
|
||||
}
|
||||
|
||||
private static string KoUnder1만(long n)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var thou = (int)(n / 1000);
|
||||
var hund = (int)(n % 1000 / 100);
|
||||
var ten = (int)(n % 100 / 10);
|
||||
var one = (int)(n % 10);
|
||||
if (thou > 0) { sb.Append(thou == 1 ? "천" : KoOnes[thou] + "천"); }
|
||||
if (hund > 0) { sb.Append(hund == 1 ? "백" : KoOnes[hund] + "백"); }
|
||||
if (ten > 0) { sb.Append(ten == 1 ? "십" : KoOnes[ten] + "십"); }
|
||||
if (one > 0) { sb.Append(KoOnes[one]); }
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string ToKoreanMoney(double value)
|
||||
{
|
||||
var ko = ToKorean(value);
|
||||
return string.IsNullOrEmpty(ko) ? "영 원" : $"{ko} 원";
|
||||
}
|
||||
|
||||
// ── 영어 읽기 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static readonly string[] EnOnes =
|
||||
["", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
|
||||
"ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen",
|
||||
"sixteen", "seventeen", "eighteen", "nineteen"];
|
||||
private static readonly string[] EnTens =
|
||||
["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"];
|
||||
|
||||
private static string ToEnglish(double value)
|
||||
{
|
||||
if (value == 0) return "zero";
|
||||
var n = (long)Math.Abs(value);
|
||||
var sign = value < 0 ? "negative " : "";
|
||||
return sign + EnNumber(n);
|
||||
}
|
||||
|
||||
private static string EnNumber(long n)
|
||||
{
|
||||
if (n == 0) return "";
|
||||
if (n < 20) return EnOnes[n];
|
||||
if (n < 100) return EnTens[n / 10] + (n % 10 > 0 ? "-" + EnOnes[n % 10] : "");
|
||||
if (n < 1000)
|
||||
{
|
||||
var rest = n % 100 > 0 ? " and " + EnNumber(n % 100) : "";
|
||||
return EnOnes[n / 100] + " hundred" + rest;
|
||||
}
|
||||
if (n < 1_000_000) return EnNumber(n / 1000) + " thousand" + (n % 1000 > 0 ? " " + EnNumber(n % 1000) : "");
|
||||
if (n < 1_000_000_000) return EnNumber(n / 1_000_000) + " million" + (n % 1_000_000 > 0 ? " " + EnNumber(n % 1_000_000) : "");
|
||||
return EnNumber(n / 1_000_000_000) + " billion" + (n % 1_000_000_000 > 0 ? " " + EnNumber(n % 1_000_000_000) : "");
|
||||
}
|
||||
|
||||
// ── 로마 숫자 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static string ToRoman(int n)
|
||||
{
|
||||
var vals = new[] { 1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1 };
|
||||
var syms = new[] { "M","CM","D","CD","C","XC","L","XL","X","IX","V","IV","I" };
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 0; i < vals.Length; i++)
|
||||
while (n >= vals[i]) { sb.Append(syms[i]); n -= vals[i]; }
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
264
src/AxCopilot/Handlers/OcrHandler.cs
Normal file
264
src/AxCopilot/Handlers/OcrHandler.cs
Normal file
@@ -0,0 +1,264 @@
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
using WinBitmapDecoder = Windows.Graphics.Imaging.BitmapDecoder;
|
||||
using WinBitmapPixelFmt = Windows.Graphics.Imaging.BitmapPixelFormat;
|
||||
using WinSoftwareBitmap = Windows.Graphics.Imaging.SoftwareBitmap;
|
||||
using WinOcrEngine = Windows.Media.Ocr.OcrEngine;
|
||||
using WinStorageFile = Windows.Storage.StorageFile;
|
||||
using WinFileAccessMode = Windows.Storage.FileAccessMode;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L5-2: 화면 텍스트 OCR 추출 핸들러.
|
||||
/// 예: ocr → 옵션 목록 (영역 선택 / 클립보드 이미지)
|
||||
/// ocr region → 드래그 영역 선택 후 텍스트 추출
|
||||
/// ocr clip → 클립보드 이미지에서 텍스트 추출
|
||||
/// 결과 텍스트는 클립보드에 복사되고 런처 입력창에 채워집니다.
|
||||
/// </summary>
|
||||
public class OcrHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "ocr";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"OcrExtractor",
|
||||
"화면 텍스트 추출 (OCR)",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private const string DataRegion = "__ocr_region__";
|
||||
private const string DataClipboard = "__ocr_clipboard__";
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim().ToLowerInvariant();
|
||||
|
||||
var items = new List<LauncherItem>
|
||||
{
|
||||
new LauncherItem(
|
||||
"화면 영역 텍스트 추출",
|
||||
"드래그로 영역을 선택하면 텍스트를 자동으로 인식합니다 · F4 단축키 지원",
|
||||
null, DataRegion,
|
||||
Symbol: "\uE8D2"),
|
||||
|
||||
new LauncherItem(
|
||||
"클립보드 이미지 텍스트 추출",
|
||||
"클립보드에 복사된 이미지에서 텍스트를 인식합니다",
|
||||
null, DataClipboard,
|
||||
Symbol: "\uE77F")
|
||||
};
|
||||
|
||||
// 쿼리 필터링
|
||||
if (!string.IsNullOrEmpty(q))
|
||||
{
|
||||
items = items.Where(i =>
|
||||
i.Title.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
i.Subtitle.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
}
|
||||
|
||||
// OCR 미지원 안내
|
||||
if (WinOcrEngine.TryCreateFromUserProfileLanguages() == null)
|
||||
{
|
||||
items.Clear();
|
||||
items.Add(new LauncherItem(
|
||||
"OCR 기능을 사용할 수 없습니다",
|
||||
"Windows 설정 → 언어에서 OCR 지원 언어 팩을 설치하세요",
|
||||
null, null,
|
||||
Symbol: Symbols.Info));
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
switch (item.Data as string)
|
||||
{
|
||||
case DataRegion:
|
||||
await ExecuteRegionOcrAsync(ct);
|
||||
break;
|
||||
case DataClipboard:
|
||||
await ExecuteClipboardOcrAsync(ct);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 영역 선택 OCR ───────────────────────────────────────────────────────
|
||||
|
||||
private static async Task ExecuteRegionOcrAsync(CancellationToken ct)
|
||||
{
|
||||
// 런처가 완전히 사라질 때까지 대기
|
||||
await Task.Delay(180, ct);
|
||||
|
||||
System.Drawing.Rectangle? selected = null;
|
||||
Bitmap? fullBmp = null;
|
||||
|
||||
// UI 스레드에서 오버레이 창 표시
|
||||
await Application.Current.Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
var bounds = GetAllScreenBounds();
|
||||
fullBmp = new Bitmap(bounds.Width, bounds.Height, PixelFormat.Format32bppArgb);
|
||||
using var g = Graphics.FromImage(fullBmp);
|
||||
g.CopyFromScreen(bounds.X, bounds.Y, 0, 0, bounds.Size, CopyPixelOperation.SourceCopy);
|
||||
|
||||
var overlay = new Views.RegionSelectWindow(fullBmp, bounds);
|
||||
overlay.ShowDialog();
|
||||
selected = overlay.SelectedRect;
|
||||
});
|
||||
|
||||
if (selected == null || selected.Value.Width < 8 || selected.Value.Height < 8)
|
||||
{
|
||||
fullBmp?.Dispose();
|
||||
NotificationService.Notify("AX Copilot", "영역 선택이 취소되었습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 선택 영역 크롭
|
||||
var r = selected.Value;
|
||||
using var crop = new Bitmap(r.Width, r.Height, PixelFormat.Format32bppArgb);
|
||||
using (var cg = Graphics.FromImage(crop))
|
||||
cg.DrawImage(fullBmp!, new System.Drawing.Rectangle(0, 0, r.Width, r.Height), r, System.Drawing.GraphicsUnit.Pixel);
|
||||
fullBmp?.Dispose();
|
||||
|
||||
// OCR 실행
|
||||
var text = await RunOcrOnBitmapAsync(crop);
|
||||
|
||||
// 결과 처리
|
||||
HandleOcrResult(text, $"{r.Width}×{r.Height} 영역");
|
||||
}
|
||||
|
||||
// ─── 클립보드 이미지 OCR ─────────────────────────────────────────────────
|
||||
|
||||
private static async Task ExecuteClipboardOcrAsync(CancellationToken ct)
|
||||
{
|
||||
Bitmap? clipBmp = null;
|
||||
|
||||
await Application.Current.Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
if (Clipboard.ContainsImage())
|
||||
{
|
||||
var src = Clipboard.GetImage();
|
||||
if (src != null)
|
||||
{
|
||||
// BitmapSource → System.Drawing.Bitmap 변환
|
||||
var encoder = new System.Windows.Media.Imaging.PngBitmapEncoder();
|
||||
encoder.Frames.Add(System.Windows.Media.Imaging.BitmapFrame.Create(src));
|
||||
using var ms = new MemoryStream();
|
||||
encoder.Save(ms);
|
||||
ms.Position = 0;
|
||||
clipBmp = new Bitmap(ms);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (clipBmp == null)
|
||||
{
|
||||
NotificationService.Notify("AX Copilot — OCR", "클립보드에 이미지가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
using (clipBmp)
|
||||
{
|
||||
var text = await RunOcrOnBitmapAsync(clipBmp);
|
||||
HandleOcrResult(text, "클립보드 이미지");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 공통: Bitmap → OCR ─────────────────────────────────────────────────
|
||||
|
||||
private static async Task<string?> RunOcrOnBitmapAsync(Bitmap bmp)
|
||||
{
|
||||
var engine = WinOcrEngine.TryCreateFromUserProfileLanguages();
|
||||
if (engine == null) return null;
|
||||
|
||||
// Bitmap을 임시 PNG로 저장
|
||||
var tmpPath = Path.Combine(Path.GetTempPath(), $"axocr_{Guid.NewGuid():N}.png");
|
||||
try
|
||||
{
|
||||
bmp.Save(tmpPath, ImageFormat.Png);
|
||||
|
||||
var storageFile = await WinStorageFile.GetFileFromPathAsync(tmpPath);
|
||||
using var stream = await storageFile.OpenAsync(WinFileAccessMode.Read);
|
||||
var decoder = await WinBitmapDecoder.CreateAsync(stream);
|
||||
|
||||
WinSoftwareBitmap? origBitmap = null;
|
||||
WinSoftwareBitmap? ocrBitmap = null;
|
||||
try
|
||||
{
|
||||
origBitmap = await decoder.GetSoftwareBitmapAsync();
|
||||
ocrBitmap = origBitmap.BitmapPixelFormat == WinBitmapPixelFmt.Bgra8
|
||||
? origBitmap
|
||||
: WinSoftwareBitmap.Convert(origBitmap, WinBitmapPixelFmt.Bgra8);
|
||||
|
||||
var result = await engine.RecognizeAsync(ocrBitmap);
|
||||
var text = result.Text?.Trim();
|
||||
if (text?.Length > 5_000) text = text[..5_000];
|
||||
return string.IsNullOrWhiteSpace(text) ? null : text;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!ReferenceEquals(origBitmap, ocrBitmap)) origBitmap?.Dispose();
|
||||
ocrBitmap?.Dispose();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"OCR 실행 오류: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (File.Exists(tmpPath)) File.Delete(tmpPath); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 공통: 결과 처리 ────────────────────────────────────────────────────
|
||||
|
||||
private static void HandleOcrResult(string? text, string source)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
NotificationService.Notify("OCR 완료", $"{source}에서 텍스트를 인식하지 못했습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 클립보드에 복사
|
||||
Application.Current?.Dispatcher.Invoke(() =>
|
||||
{
|
||||
Clipboard.SetText(text);
|
||||
});
|
||||
|
||||
// 런처를 다시 열고 결과 텍스트를 입력창에 채움
|
||||
Application.Current?.Dispatcher.BeginInvoke(() =>
|
||||
{
|
||||
var launcher = Application.Current?.Windows
|
||||
.OfType<Views.LauncherWindow>().FirstOrDefault();
|
||||
if (launcher != null)
|
||||
{
|
||||
launcher.SetInputText(text.Length > 200 ? text[..200] : text);
|
||||
launcher.Show();
|
||||
}
|
||||
}, System.Windows.Threading.DispatcherPriority.Background);
|
||||
|
||||
// 완료 알림
|
||||
var preview = text.Length > 60 ? text[..57].Replace('\n', ' ') + "…" : text.Replace('\n', ' ');
|
||||
NotificationService.Notify("OCR 완료", $"클립보드 복사됨: {preview}");
|
||||
LogService.Info($"OCR 성공 ({source}, {text.Length}자)");
|
||||
}
|
||||
|
||||
// ─── 헬퍼 ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static System.Drawing.Rectangle GetAllScreenBounds()
|
||||
{
|
||||
var bounds = System.Drawing.Rectangle.Empty;
|
||||
foreach (System.Windows.Forms.Screen screen in System.Windows.Forms.Screen.AllScreens)
|
||||
bounds = System.Drawing.Rectangle.Union(bounds, screen.Bounds);
|
||||
return bounds;
|
||||
}
|
||||
}
|
||||
249
src/AxCopilot/Handlers/PasswordGenHandler.cs
Normal file
249
src/AxCopilot/Handlers/PasswordGenHandler.cs
Normal file
@@ -0,0 +1,249 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L9-1: 비밀번호 생성기 핸들러. "pwd" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: pwd → 기본 16자 비밀번호 5개 생성
|
||||
/// pwd 24 → 24자 비밀번호 5개 생성
|
||||
/// pwd 32 strong → 32자 강력 옵션 (대소문자+숫자+특수)
|
||||
/// pwd 16 alpha → 알파벳+숫자만 (특수문자 제외)
|
||||
/// pwd 20 pin → 숫자만 (PIN 코드)
|
||||
/// pwd passphrase → 단어 조합 기억하기 쉬운 패스프레이즈
|
||||
/// Enter → 클립보드에 복사.
|
||||
/// </summary>
|
||||
public class PasswordGenHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "pwd";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"PasswordGen",
|
||||
"비밀번호 생성기 — 길이 · 복잡도 · 패스프레이즈",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private const string LowerChars = "abcdefghijklmnopqrstuvwxyz";
|
||||
private const string UpperChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
private const string DigitChars = "0123456789";
|
||||
private const string SpecialChars = "!@#$%^&*()-_=+[]{}|;:,.<>?";
|
||||
|
||||
// 기억하기 쉬운 단어 목록 (간결한 영단어)
|
||||
private static readonly string[] WordList =
|
||||
[
|
||||
"apple","bridge","cloud","dawn","eagle","flame","grape","harbor",
|
||||
"ivory","jungle","kite","lemon","maple","night","ocean","pearl",
|
||||
"quartz","river","storm","tiger","ultra","violet","water","xenon",
|
||||
"yellow","zenith","amber","blaze","cedar","delta","ember","frost",
|
||||
"glass","honey","iron","jade","knot","lunar","mango","nova",
|
||||
"orbit","prism","quest","range","solar","track","umbra","valor",
|
||||
];
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim().ToLowerInvariant();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
// 패스프레이즈 모드
|
||||
if (q.StartsWith("passphrase") || q.StartsWith("phrase"))
|
||||
{
|
||||
var wordCount = 4;
|
||||
var parts2 = q.Split(' ');
|
||||
if (parts2.Length >= 2 && int.TryParse(parts2[1], out var wc))
|
||||
wordCount = Math.Clamp(wc, 2, 8);
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
"패스프레이즈 생성",
|
||||
$"{wordCount}단어 조합 · 기억하기 쉬운 형식",
|
||||
null, null, Symbol: "\uE8D4"));
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var phrase = GeneratePassphrase(wordCount);
|
||||
var strength = EstimateEntropy(phrase);
|
||||
items.Add(new LauncherItem(
|
||||
phrase,
|
||||
$"엔트로피: ~{strength}bit",
|
||||
null,
|
||||
("copy", phrase),
|
||||
Symbol: "\uE8D4"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 파라미터 파싱: [길이] [모드]
|
||||
int length = 16;
|
||||
string mode = "strong";
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length >= 1 && int.TryParse(parts[0], out var l))
|
||||
{
|
||||
length = Math.Clamp(l, 4, 128);
|
||||
if (parts.Length >= 2) mode = parts[1];
|
||||
}
|
||||
else if (parts.Length >= 1 && parts[0] is "strong" or "alpha" or "pin" or "simple")
|
||||
{
|
||||
mode = parts[0];
|
||||
}
|
||||
|
||||
// 문자 집합 결정
|
||||
var charset = BuildCharset(mode);
|
||||
|
||||
// 강도 표시
|
||||
var modeLabel = mode switch
|
||||
{
|
||||
"alpha" => "알파뉴메릭 (대소문자+숫자)",
|
||||
"pin" => "숫자 PIN",
|
||||
"simple" => "간단 (소문자+숫자)",
|
||||
_ => "강력 (대소문자+숫자+특수)",
|
||||
};
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
$"비밀번호 생성 {length}자",
|
||||
modeLabel,
|
||||
null, null, Symbol: "\uE8D4"));
|
||||
|
||||
// 5개 후보 생성
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var pw = GeneratePassword(charset, length, mode);
|
||||
var strength = GetStrengthLabel(pw);
|
||||
items.Add(new LauncherItem(
|
||||
pw,
|
||||
strength,
|
||||
null,
|
||||
("copy", pw),
|
||||
Symbol: "\uE8D4"));
|
||||
}
|
||||
|
||||
// 옵션 안내
|
||||
items.Add(new LauncherItem(
|
||||
"pwd <길이> alpha",
|
||||
"알파뉴메릭 (특수문자 제외)",
|
||||
null, null, Symbol: "\uE946"));
|
||||
items.Add(new LauncherItem(
|
||||
"pwd <길이> pin",
|
||||
"숫자만 (PIN 코드)",
|
||||
null, null, Symbol: "\uE946"));
|
||||
items.Add(new LauncherItem(
|
||||
"pwd passphrase",
|
||||
"단어 조합 패스프레이즈",
|
||||
null, null, Symbol: "\uE946"));
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is ("copy", string pw))
|
||||
{
|
||||
try
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(
|
||||
() => Clipboard.SetText(pw));
|
||||
NotificationService.Notify("비밀번호", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 헬퍼 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static string BuildCharset(string mode) => mode switch
|
||||
{
|
||||
"pin" => DigitChars,
|
||||
"alpha" => LowerChars + UpperChars + DigitChars,
|
||||
"simple" => LowerChars + DigitChars,
|
||||
_ => LowerChars + UpperChars + DigitChars + SpecialChars,
|
||||
};
|
||||
|
||||
private static string GeneratePassword(string charset, int length, string mode)
|
||||
{
|
||||
// strong 모드: 각 카테고리 최소 1개 보장
|
||||
if (mode == "strong" && length >= 4)
|
||||
{
|
||||
var mandatory = new[]
|
||||
{
|
||||
RandomChar(LowerChars),
|
||||
RandomChar(UpperChars),
|
||||
RandomChar(DigitChars),
|
||||
RandomChar(SpecialChars),
|
||||
};
|
||||
|
||||
var remaining = length - mandatory.Length;
|
||||
var bulk = Enumerable.Range(0, remaining)
|
||||
.Select(_ => RandomChar(charset))
|
||||
.ToList();
|
||||
|
||||
var all = mandatory.Concat(bulk).ToArray();
|
||||
Shuffle(all);
|
||||
return new string(all);
|
||||
}
|
||||
|
||||
// alpha/pin/simple
|
||||
return new string(Enumerable.Range(0, length)
|
||||
.Select(_ => RandomChar(charset))
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
private static string GeneratePassphrase(int wordCount)
|
||||
{
|
||||
var words = Enumerable.Range(0, wordCount)
|
||||
.Select(_ => WordList[RandomInt(WordList.Length)]);
|
||||
var num = RandomInt(9000) + 1000;
|
||||
var sep = new[] { "-", "_", ".", "!" }[RandomInt(4)];
|
||||
return string.Join(sep, words) + sep + num;
|
||||
}
|
||||
|
||||
private static char RandomChar(string charset) =>
|
||||
charset[RandomInt(charset.Length)];
|
||||
|
||||
private static int RandomInt(int max) =>
|
||||
(int)(RandomNumberGenerator.GetInt32(int.MaxValue) % max);
|
||||
|
||||
private static void Shuffle<T>(T[] arr)
|
||||
{
|
||||
for (int i = arr.Length - 1; i > 0; i--)
|
||||
{
|
||||
var j = RandomInt(i + 1);
|
||||
(arr[i], arr[j]) = (arr[j], arr[i]);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetStrengthLabel(string pw)
|
||||
{
|
||||
var hasLower = pw.Any(char.IsLower);
|
||||
var hasUpper = pw.Any(char.IsUpper);
|
||||
var hasDigit = pw.Any(char.IsDigit);
|
||||
var hasSpecial = pw.Any(c => SpecialChars.Contains(c));
|
||||
var types = new[] { hasLower, hasUpper, hasDigit, hasSpecial }.Count(b => b);
|
||||
|
||||
var strength = (pw.Length, types) switch
|
||||
{
|
||||
( >= 24, >= 4) => "매우 강함 🔐",
|
||||
( >= 16, >= 3) => "강함 🔒",
|
||||
( >= 12, >= 2) => "보통 🔑",
|
||||
_ => "약함 ⚠",
|
||||
};
|
||||
return $"{strength} · {pw.Length}자";
|
||||
}
|
||||
|
||||
private static int EstimateEntropy(string pw)
|
||||
{
|
||||
// 간략 엔트로피 추정: log2(charset^length)
|
||||
var hasLower = pw.Any(char.IsLower);
|
||||
var hasUpper = pw.Any(char.IsUpper);
|
||||
var hasDigit = pw.Any(char.IsDigit);
|
||||
var hasSpecial = pw.Any(c => !char.IsLetterOrDigit(c));
|
||||
var pool = (hasLower ? 26 : 0) + (hasUpper ? 26 : 0)
|
||||
+ (hasDigit ? 10 : 0) + (hasSpecial ? 32 : 0);
|
||||
if (pool == 0) pool = 36;
|
||||
return (int)(pw.Length * Math.Log2(pool));
|
||||
}
|
||||
}
|
||||
217
src/AxCopilot/Handlers/PasteHandler.cs
Normal file
217
src/AxCopilot/Handlers/PasteHandler.cs
Normal file
@@ -0,0 +1,217 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L27-6: 클립보드 순차 붙여넣기 핸들러. "paste" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: paste → 번호 매긴 클립보드 히스토리 목록
|
||||
/// paste 3 1 5 → 3번→1번→5번 항목을 순서대로 붙여넣기
|
||||
/// paste all → 전체 히스토리를 순서대로 붙여넣기
|
||||
/// Enter → 이전 창에서 순서대로 Ctrl+V 실행.
|
||||
/// Raycast "Paste Sequentially" 대응.
|
||||
/// </summary>
|
||||
public class PasteHandler : IActionHandler
|
||||
{
|
||||
private readonly ClipboardHistoryService _history;
|
||||
|
||||
public string? Prefix => "paste";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"순차 붙여넣기",
|
||||
"클립보드 히스토리를 순서대로 붙여넣기 (Paste Sequentially)",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
public PasteHandler(ClipboardHistoryService historyService)
|
||||
{
|
||||
_history = historyService;
|
||||
}
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
var history = _history.History.Where(e => e.IsText && !string.IsNullOrEmpty(e.Text)).ToList();
|
||||
|
||||
if (history.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
"클립보드 히스토리가 비어 있습니다",
|
||||
"텍스트를 복사하면 사용할 수 있습니다",
|
||||
null, null, Symbol: Symbols.ClipPaste));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ── 번호 시퀀스 파싱 ──────────────────────────────────────────────────
|
||||
if (!string.IsNullOrWhiteSpace(q) && q != "all")
|
||||
{
|
||||
var nums = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var indices = new List<int>();
|
||||
foreach (var n in nums)
|
||||
{
|
||||
if (int.TryParse(n, out int idx) && idx >= 1 && idx <= history.Count)
|
||||
indices.Add(idx);
|
||||
}
|
||||
|
||||
if (indices.Count > 0)
|
||||
{
|
||||
var preview = string.Join(" → ", indices.Select(i => $"#{i}"));
|
||||
var texts = indices.Select(i => history[i - 1].Text ?? "").ToList();
|
||||
var totalLen = texts.Sum(t => t.Length);
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
$"순차 붙여넣기: {preview}",
|
||||
$"{indices.Count}개 항목 · {totalLen}자 · Enter: 순서대로 붙여넣기",
|
||||
null, ("seq", texts), Symbol: Symbols.ClipPaste));
|
||||
|
||||
// 미리보기
|
||||
for (int i = 0; i < indices.Count; i++)
|
||||
{
|
||||
var entry = history[indices[i] - 1];
|
||||
items.Add(new LauncherItem(
|
||||
$" {i + 1}. #{indices[i]}: {Truncate(entry.Preview, 60)}",
|
||||
entry.RelativeTime,
|
||||
null, null, Symbol: Symbols.History));
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
}
|
||||
|
||||
// ── all 명령 ──────────────────────────────────────────────────────────
|
||||
if (q.Equals("all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var texts = history.Take(20).Select(e => e.Text ?? "").ToList();
|
||||
items.Add(new LauncherItem(
|
||||
$"전체 순차 붙여넣기 ({texts.Count}개)",
|
||||
$"Enter: 최근 {texts.Count}개 항목을 순서대로 붙여넣기",
|
||||
null, ("seq", texts), Symbol: Symbols.ClipPaste));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ── 빈 쿼리 → 번호 매긴 목록 ─────────────────────────────────────────
|
||||
items.Add(new LauncherItem(
|
||||
"순차 붙여넣기 — 번호를 입력하세요",
|
||||
"예: paste 3 1 5 → 3번→1번→5번 순서로 붙여넣기 · paste all → 전체",
|
||||
null, null, Symbol: Symbols.ClipPaste));
|
||||
|
||||
for (int i = 0; i < Math.Min(history.Count, 15); i++)
|
||||
{
|
||||
var entry = history[i];
|
||||
var pinMark = entry.IsPinned ? "\uD83D\uDCCC " : "";
|
||||
items.Add(new LauncherItem(
|
||||
$" #{i + 1} {pinMark}{Truncate(entry.Preview, 50)}",
|
||||
$"{entry.RelativeTime} · {entry.CopiedAt:MM/dd HH:mm}",
|
||||
null, ("single", entry.Text ?? ""), Symbol: Symbols.History));
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is ("single", string singleText))
|
||||
{
|
||||
// 단일 항목 붙여넣기
|
||||
await PasteTexts([singleText], ct);
|
||||
}
|
||||
else if (item.Data is ("seq", List<string> texts))
|
||||
{
|
||||
// 순차 붙여넣기
|
||||
await PasteTexts(texts, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PasteTexts(List<string> texts, CancellationToken ct)
|
||||
{
|
||||
if (texts.Count == 0) return;
|
||||
|
||||
try
|
||||
{
|
||||
var prevWindow = WindowTracker.PreviousWindow;
|
||||
if (prevWindow == IntPtr.Zero) return;
|
||||
|
||||
_history.SuppressNextCapture();
|
||||
|
||||
// 이전 창 포커스 복원 대기
|
||||
await Task.Delay(300, ct);
|
||||
|
||||
var targetThread = GetWindowThreadProcessId(prevWindow, out _);
|
||||
var currentThread = GetCurrentThreadId();
|
||||
AttachThreadInput(currentThread, targetThread, true);
|
||||
SetForegroundWindow(prevWindow);
|
||||
AttachThreadInput(currentThread, targetThread, false);
|
||||
|
||||
await Task.Delay(100, ct);
|
||||
|
||||
foreach (var text in texts)
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
|
||||
_history.SuppressNextCapture();
|
||||
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
|
||||
await Task.Delay(50, ct);
|
||||
|
||||
SendCtrlV();
|
||||
await Task.Delay(200, ct); // 항목 간 간격
|
||||
}
|
||||
|
||||
NotificationService.Notify("paste", $"{texts.Count}개 항목 붙여넣기 완료");
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
NotificationService.Notify("paste", $"붙여넣기 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string Truncate(string s, int max)
|
||||
=> s.Length <= max ? s : s[..max] + "…";
|
||||
|
||||
// ─── Ctrl+V 주입 (ClipboardHistoryHandler와 동일 패턴) ────────────────────
|
||||
|
||||
private static void SendCtrlV()
|
||||
{
|
||||
const uint INPUT_KEYBOARD = 1;
|
||||
const uint KEYEVENTF_KEYUP = 0x0002;
|
||||
const ushort VK_CONTROL = 0x11;
|
||||
const ushort VK_V = 0x56;
|
||||
|
||||
var inputs = new INPUT[4];
|
||||
inputs[0] = new INPUT { Type = INPUT_KEYBOARD, ki = new KEYBDINPUT { wVk = VK_CONTROL } };
|
||||
inputs[1] = new INPUT { Type = INPUT_KEYBOARD, ki = new KEYBDINPUT { wVk = VK_V } };
|
||||
inputs[2] = new INPUT { Type = INPUT_KEYBOARD, ki = new KEYBDINPUT { wVk = VK_V, dwFlags = KEYEVENTF_KEYUP } };
|
||||
inputs[3] = new INPUT { Type = INPUT_KEYBOARD, ki = new KEYBDINPUT { wVk = VK_CONTROL, dwFlags = KEYEVENTF_KEYUP } };
|
||||
SendInput((uint)inputs.Length, inputs, Marshal.SizeOf<INPUT>());
|
||||
}
|
||||
|
||||
// ─── P/Invoke ──────────────────────────────────────────────────────────────
|
||||
|
||||
[StructLayout(LayoutKind.Explicit, Size = 40)]
|
||||
private struct INPUT
|
||||
{
|
||||
[FieldOffset(0)] public uint Type;
|
||||
[FieldOffset(8)] public KEYBDINPUT ki;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct KEYBDINPUT
|
||||
{
|
||||
public ushort wVk;
|
||||
public ushort wScan;
|
||||
public uint dwFlags;
|
||||
public uint time;
|
||||
public IntPtr dwExtraInfo;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
[DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
||||
[DllImport("user32.dll")] private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, [MarshalAs(UnmanagedType.Bool)] bool fAttach);
|
||||
[DllImport("kernel32.dll")] private static extern uint GetCurrentThreadId();
|
||||
[DllImport("user32.dll")] private static extern uint SendInput(uint nInputs, [MarshalAs(UnmanagedType.LPArray)] INPUT[] pInputs, int cbSize);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
277
src/AxCopilot/Handlers/PermHandler.cs
Normal file
277
src/AxCopilot/Handlers/PermHandler.cs
Normal file
@@ -0,0 +1,277 @@
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L20-4: Unix 파일 권한 계산기 핸들러. "perm" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: perm → 주요 권한 목록
|
||||
/// perm 755 → rwxr-xr-x 상세 설명
|
||||
/// perm 644 → rw-r--r-- 상세 설명
|
||||
/// perm rwxr-xr-x → 기호 → 숫자 변환
|
||||
/// perm rw-r--r-- → 기호 → 숫자 변환
|
||||
/// perm +x 644 → 실행 비트 추가
|
||||
/// perm -x 755 → 실행 비트 제거
|
||||
/// perm +w 444 → 쓰기 비트 추가
|
||||
/// perm umask 022 → umask 적용 결과
|
||||
/// perm common → 자주 쓰는 권한 목록
|
||||
/// Enter → 값 복사.
|
||||
/// </summary>
|
||||
public class PermHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "perm";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Perm",
|
||||
"Unix 파일 권한 계산기 — chmod 숫자↔기호·umask·권한 설명",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private record CommonPerm(string Octal, string Symbol, string Description, string UseCase);
|
||||
|
||||
private static readonly CommonPerm[] Common =
|
||||
[
|
||||
new("777", "rwxrwxrwx", "모든 사용자 완전 권한", "임시 스크립트 (보안 주의)"),
|
||||
new("755", "rwxr-xr-x", "소유자 완전·그룹/기타 읽기+실행", "실행 파일, 디렉토리"),
|
||||
new("750", "rwxr-x---", "소유자 완전·그룹 읽기+실행·기타 없음", "그룹 공유 스크립트"),
|
||||
new("700", "rwx------", "소유자만 완전 권한", "개인 스크립트"),
|
||||
new("644", "rw-r--r--", "소유자 읽기+쓰기·그룹/기타 읽기만", "일반 파일"),
|
||||
new("640", "rw-r-----", "소유자 읽기+쓰기·그룹 읽기·기타 없음", "설정 파일"),
|
||||
new("600", "rw-------", "소유자만 읽기+쓰기", "개인 설정·SSH 키"),
|
||||
new("444", "r--r--r--", "모든 사용자 읽기만", "공유 읽기 전용"),
|
||||
new("400", "r--------", "소유자만 읽기", "SSL 인증서·개인 키"),
|
||||
new("666", "rw-rw-rw-", "모든 사용자 읽기+쓰기 (실행 없음)", "임시 파일"),
|
||||
new("664", "rw-rw-r--", "소유자+그룹 읽기+쓰기·기타 읽기", "그룹 협업 파일"),
|
||||
new("660", "rw-rw----", "소유자+그룹 읽기+쓰기·기타 없음", "그룹 협업 파일"),
|
||||
new("775", "rwxrwxr-x", "소유자+그룹 완전·기타 읽기+실행", "그룹 협업 디렉토리"),
|
||||
new("770", "rwxrwx---", "소유자+그룹 완전·기타 없음", "그룹 전용 디렉토리"),
|
||||
];
|
||||
|
||||
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("Unix 파일 권한 계산기",
|
||||
"perm 755 / perm rwxr-xr-x / perm +x 644 / perm umask 022 / perm common",
|
||||
null, null, Symbol: "\uE8A5"));
|
||||
foreach (var p in Common.Take(6))
|
||||
items.Add(new LauncherItem($"{p.Octal} {p.Symbol}",
|
||||
$"{p.Description} ({p.UseCase})",
|
||||
null, ("copy", p.Octal), Symbol: "\uE8A5"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
// common
|
||||
if (sub is "common" or "list" or "목록")
|
||||
{
|
||||
items.Add(new LauncherItem("자주 쓰는 파일 권한 목록", "", null, null, Symbol: "\uE8A5"));
|
||||
foreach (var p in Common)
|
||||
items.Add(new LauncherItem($"{p.Octal} {p.Symbol}",
|
||||
$"{p.Description} · {p.UseCase}",
|
||||
null, ("copy", p.Octal), Symbol: "\uE8A5"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// umask
|
||||
if (sub == "umask" && parts.Length >= 2)
|
||||
{
|
||||
if (TryParseOctal(parts[1], out var umaskVal))
|
||||
items.AddRange(BuildUmask(umaskVal));
|
||||
else
|
||||
items.Add(ErrorItem("umask 값은 3자리 8진수입니다 (예: 022)"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// +x / -x / +w / -w / +r / -r (비트 수정)
|
||||
if (sub.Length == 2 && sub[0] is '+' or '-' && sub[1] is 'r' or 'w' or 'x')
|
||||
{
|
||||
if (parts.Length >= 2 && TryParseOctal(parts[1], out var baseVal))
|
||||
items.AddRange(BuildBitModify(sub[0] == '+', sub[1], baseVal));
|
||||
else
|
||||
items.Add(ErrorItem($"예: perm {sub} 644"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 기호 표기 (rwxr-xr-x)
|
||||
if (sub.Length == 9 && sub.All(c => c is 'r' or 'w' or 'x' or '-'))
|
||||
{
|
||||
items.AddRange(BuildFromSymbol(sub));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 8진수 표기 (755, 644...)
|
||||
if (TryParseOctal(sub, out var octal))
|
||||
{
|
||||
items.AddRange(BuildFromOctal(octal));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
items.Add(new LauncherItem($"인식할 수 없는 입력: '{sub}'",
|
||||
"perm 755 / perm rwxr-xr-x / perm +x 644 / perm umask 022",
|
||||
null, null, Symbol: "\uE783"));
|
||||
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("Perm", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 빌더 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static IEnumerable<LauncherItem> BuildFromOctal(int octal)
|
||||
{
|
||||
var sym = OctalToSymbol(octal);
|
||||
var desc = DescribeOctal(octal);
|
||||
var preset = Common.FirstOrDefault(p => p.Octal == octal.ToString("D3"));
|
||||
|
||||
yield return new LauncherItem($"{octal:D3} → {sym}",
|
||||
preset?.Description ?? desc,
|
||||
null, ("copy", octal.ToString("D3")), Symbol: "\uE8A5");
|
||||
|
||||
yield return CopyItem("8진수", octal.ToString("D3"));
|
||||
yield return CopyItem("기호 표기", sym);
|
||||
yield return CopyItem("chmod 명령", $"chmod {octal:D3} <파일>");
|
||||
|
||||
var parts = OctalToParts(octal);
|
||||
yield return new LauncherItem("소유자 (Owner)",
|
||||
$"{OctetToSymbol(parts[0])} ({BitsDesc(parts[0])})",
|
||||
null, null, Symbol: "\uE8A5");
|
||||
yield return new LauncherItem("그룹 (Group)",
|
||||
$"{OctetToSymbol(parts[1])} ({BitsDesc(parts[1])})",
|
||||
null, null, Symbol: "\uE8A5");
|
||||
yield return new LauncherItem("기타 (Others)",
|
||||
$"{OctetToSymbol(parts[2])} ({BitsDesc(parts[2])})",
|
||||
null, null, Symbol: "\uE8A5");
|
||||
|
||||
if (preset != null)
|
||||
yield return new LauncherItem("용도", preset.UseCase, null, null, Symbol: "\uE8A5");
|
||||
|
||||
// 관련 권한 제안
|
||||
yield return new LauncherItem("── 관련 권한 ──", "", null, null, Symbol: "\uE8A5");
|
||||
var related = Common.Where(p => Math.Abs(int.Parse(p.Octal) - octal) <= 11)
|
||||
.Take(4).ToList();
|
||||
foreach (var r in related)
|
||||
yield return new LauncherItem($"{r.Octal} {r.Symbol}", r.Description,
|
||||
null, ("copy", r.Octal), Symbol: "\uE8A5");
|
||||
}
|
||||
|
||||
private static IEnumerable<LauncherItem> BuildFromSymbol(string sym)
|
||||
{
|
||||
var octal = SymbolToOctal(sym);
|
||||
return BuildFromOctal(octal);
|
||||
}
|
||||
|
||||
private static IEnumerable<LauncherItem> BuildUmask(int umask)
|
||||
{
|
||||
var fileDefault = 0666;
|
||||
var dirDefault = 0777;
|
||||
var fileResult = fileDefault & ~umask;
|
||||
var dirResult = dirDefault & ~umask;
|
||||
|
||||
yield return new LauncherItem($"umask {umask:D3} → 파일: {fileResult:D3} 디렉토리: {dirResult:D3}",
|
||||
"umask 적용 결과", null, null, Symbol: "\uE8A5");
|
||||
yield return CopyItem("umask 값", umask.ToString("D3"));
|
||||
yield return CopyItem("기본 파일 권한", fileResult.ToString("D3"));
|
||||
yield return CopyItem("기본 디렉토리 권한", dirResult.ToString("D3"));
|
||||
yield return CopyItem("파일 기호", OctalToSymbol(fileResult));
|
||||
yield return CopyItem("디렉토리 기호", OctalToSymbol(dirResult));
|
||||
yield return new LauncherItem("설명",
|
||||
$"umask {umask:D3}: 파일={OctalToSymbol(fileResult)}, 디렉토리={OctalToSymbol(dirResult)}",
|
||||
null, null, Symbol: "\uE8A5");
|
||||
}
|
||||
|
||||
private static IEnumerable<LauncherItem> BuildBitModify(bool add, char bit, int baseOctal)
|
||||
{
|
||||
var mask = bit switch
|
||||
{
|
||||
'r' => 0444,
|
||||
'w' => 0222,
|
||||
'x' => 0111,
|
||||
_ => 0
|
||||
};
|
||||
var result = add ? (baseOctal | mask) : (baseOctal & ~mask);
|
||||
result &= 0777;
|
||||
|
||||
var label = add ? $"+{bit}" : $"-{bit}";
|
||||
yield return new LauncherItem($"{baseOctal:D3} {label} → {result:D3} ({OctalToSymbol(result)})",
|
||||
"Enter 복사", null, ("copy", result.ToString("D3")), Symbol: "\uE8A5");
|
||||
yield return CopyItem("변경 전", baseOctal.ToString("D3"));
|
||||
yield return CopyItem("변경 후", result.ToString("D3"));
|
||||
yield return CopyItem("기호", OctalToSymbol(result));
|
||||
yield return CopyItem("chmod", $"chmod {label} <파일> (또는 chmod {result:D3} <파일>)");
|
||||
}
|
||||
|
||||
// ── 변환 헬퍼 ────────────────────────────────────────────────────────────
|
||||
|
||||
private static bool TryParseOctal(string s, out int val)
|
||||
{
|
||||
val = 0;
|
||||
s = s.TrimStart('0');
|
||||
if (string.IsNullOrEmpty(s)) { val = 0; return true; }
|
||||
if (s.Length > 4) return false;
|
||||
try { val = Convert.ToInt32(s, 8); return val <= 0777; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static int[] OctalToParts(int octal) =>
|
||||
[(octal >> 6) & 7, (octal >> 3) & 7, octal & 7];
|
||||
|
||||
private static string OctetToSymbol(int v) =>
|
||||
$"{((v & 4) != 0 ? 'r' : '-')}{((v & 2) != 0 ? 'w' : '-')}{((v & 1) != 0 ? 'x' : '-')}";
|
||||
|
||||
private static string OctalToSymbol(int octal)
|
||||
{
|
||||
var p = OctalToParts(octal);
|
||||
return OctetToSymbol(p[0]) + OctetToSymbol(p[1]) + OctetToSymbol(p[2]);
|
||||
}
|
||||
|
||||
private static int SymbolToOctal(string sym)
|
||||
{
|
||||
int TriBit(int off) =>
|
||||
((sym[off] == 'r' ? 4 : 0) |
|
||||
(sym[off + 1] == 'w' ? 2 : 0) |
|
||||
(sym[off + 2] == 'x' ? 1 : 0));
|
||||
return (TriBit(0) << 6) | (TriBit(3) << 3) | TriBit(6);
|
||||
}
|
||||
|
||||
private static string BitsDesc(int v)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if ((v & 4) != 0) parts.Add("읽기");
|
||||
if ((v & 2) != 0) parts.Add("쓰기");
|
||||
if ((v & 1) != 0) parts.Add("실행");
|
||||
return parts.Count == 0 ? "없음" : string.Join("·", parts);
|
||||
}
|
||||
|
||||
private static string DescribeOctal(int octal)
|
||||
{
|
||||
var p = OctalToParts(octal);
|
||||
return $"소유자:{BitsDesc(p[0])} 그룹:{BitsDesc(p[1])} 기타:{BitsDesc(p[2])}";
|
||||
}
|
||||
|
||||
private static LauncherItem CopyItem(string label, string value) =>
|
||||
new(label, value, null, ("copy", value), Symbol: "\uE8A5");
|
||||
|
||||
private static LauncherItem ErrorItem(string msg) =>
|
||||
new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783");
|
||||
}
|
||||
215
src/AxCopilot/Handlers/PhraseHandler.cs
Normal file
215
src/AxCopilot/Handlers/PhraseHandler.cs
Normal file
@@ -0,0 +1,215 @@
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L25-3: 자주 쓰는 업무 문구 모음. "phrase" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: phrase → 카테고리 목록
|
||||
/// phrase 인사 → 인사 문구 목록
|
||||
/// phrase 보고 → 보고/발표 문구
|
||||
/// phrase 요청 → 요청/협조 문구
|
||||
/// phrase 마무리 → 마무리/감사 문구
|
||||
/// phrase <검색어> → 전체 검색
|
||||
/// Enter → 문구 클립보드 복사
|
||||
/// </summary>
|
||||
public class PhraseHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "phrase";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"업무 문구",
|
||||
"자주 쓰는 업무 문구 — 인사·보고·요청·마무리·승인·회의·사과",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private sealed record PhraseEntry(string Text, string Category, string CategoryKey);
|
||||
|
||||
private static readonly List<PhraseEntry> _phrases = BuildPhrases();
|
||||
|
||||
private static readonly (string Key, string Display, string[] Aliases)[] _categories =
|
||||
[
|
||||
("greeting", "인사", ["인사", "greeting"]),
|
||||
("report", "보고", ["보고", "보고서", "report"]),
|
||||
("request", "요청", ["요청", "협조", "request"]),
|
||||
("closing", "마무리", ["마무리", "감사", "closing"]),
|
||||
("approval", "승인결재", ["승인", "결재", "approval"]),
|
||||
("meeting", "회의", ["회의", "미팅", "meeting"]),
|
||||
("apology", "사과지연", ["사과", "지연", "사죄", "apology"]),
|
||||
];
|
||||
|
||||
private static List<PhraseEntry> BuildPhrases()
|
||||
{
|
||||
var list = new List<PhraseEntry>();
|
||||
|
||||
// 인사 (greeting)
|
||||
void G(string t) => list.Add(new PhraseEntry(t, "인사", "greeting"));
|
||||
G("안녕하세요. [이름]입니다.");
|
||||
G("수고 많으십니다.");
|
||||
G("오랜만에 연락드립니다.");
|
||||
G("처음 뵙겠습니다. 잘 부탁드립니다.");
|
||||
G("바쁘신 와중에 연락드려 죄송합니다.");
|
||||
G("항상 감사드립니다.");
|
||||
G("늦은 시간에 연락드려 죄송합니다.");
|
||||
G("좋은 하루 보내세요.");
|
||||
G("주말 잘 보내세요.");
|
||||
G("휴가 잘 다녀오세요.");
|
||||
|
||||
// 보고 (report)
|
||||
void R(string t) => list.Add(new PhraseEntry(t, "보고", "report"));
|
||||
R("말씀하신 대로 처리하겠습니다.");
|
||||
R("현재 검토 중에 있습니다.");
|
||||
R("확인 후 말씀드리겠습니다.");
|
||||
R("금일 중으로 처리하겠습니다.");
|
||||
R("해당 건은 [날짜]까지 완료 예정입니다.");
|
||||
R("진행 상황을 공유드립니다.");
|
||||
R("결과 보고드립니다.");
|
||||
R("관련하여 추가 검토가 필요합니다.");
|
||||
R("이슈 사항이 발생하여 공유드립니다.");
|
||||
R("문제없이 완료되었습니다.");
|
||||
|
||||
// 요청 (request)
|
||||
void Req(string t) => list.Add(new PhraseEntry(t, "요청", "request"));
|
||||
Req("검토 부탁드립니다.");
|
||||
Req("회신 부탁드립니다.");
|
||||
Req("확인 부탁드립니다.");
|
||||
Req("협조 부탁드립니다.");
|
||||
Req("아래 내용 확인 후 승인 부탁드립니다.");
|
||||
Req("첨부 파일 확인 부탁드립니다.");
|
||||
Req("가능한 빠른 시일 내 처리 부탁드립니다.");
|
||||
Req("[날짜]까지 회신 주시면 감사하겠습니다.");
|
||||
Req("필요한 사항 있으시면 알려주세요.");
|
||||
Req("문의 사항은 언제든지 연락 주세요.");
|
||||
|
||||
// 마무리 (closing)
|
||||
void C(string t) => list.Add(new PhraseEntry(t, "마무리", "closing"));
|
||||
C("감사합니다.");
|
||||
C("수고하셨습니다.");
|
||||
C("도움 주셔서 감사합니다.");
|
||||
C("빠른 처리 감사드립니다.");
|
||||
C("앞으로도 잘 부탁드립니다.");
|
||||
C("이상입니다.");
|
||||
C("이상으로 보고를 마치겠습니다.");
|
||||
C("궁금하신 점은 언제든 문의 주세요.");
|
||||
C("좋은 결과 있기를 바랍니다.");
|
||||
C("다시 한번 감사드립니다.");
|
||||
|
||||
// 승인결재 (approval)
|
||||
void A(string t) => list.Add(new PhraseEntry(t, "승인결재", "approval"));
|
||||
A("검토 후 승인 부탁드립니다.");
|
||||
A("위 내용으로 진행해도 될까요?");
|
||||
A("담당자 확인 후 피드백 주세요.");
|
||||
A("위와 같이 결정되었음을 알려드립니다.");
|
||||
A("아래와 같이 변경합니다.");
|
||||
A("이의 없으시면 그대로 진행하겠습니다.");
|
||||
A("검토 의견 부탁드립니다.");
|
||||
|
||||
// 회의 (meeting)
|
||||
void M(string t) => list.Add(new PhraseEntry(t, "회의", "meeting"));
|
||||
M("오늘 회의 내용을 공유드립니다.");
|
||||
M("다음 회의는 [날짜]에 진행 예정입니다.");
|
||||
M("회의 참석 부탁드립니다.");
|
||||
M("회의 장소 및 일정을 안내드립니다.");
|
||||
M("회의록 공유드립니다.");
|
||||
M("다음 주 [요일] [시간]에 미팅 가능하신가요?");
|
||||
M("일정 조율 부탁드립니다.");
|
||||
M("화상 회의 링크 공유드립니다.");
|
||||
M("회의 아젠다 공유드립니다.");
|
||||
|
||||
// 사과/지연 (apology)
|
||||
void Ap(string t) => list.Add(new PhraseEntry(t, "사과지연", "apology"));
|
||||
Ap("처리가 늦어져 죄송합니다.");
|
||||
Ap("불편을 드려 죄송합니다.");
|
||||
Ap("확인이 늦었습니다. 죄송합니다.");
|
||||
Ap("오류가 발생하여 사과드립니다.");
|
||||
Ap("빠른 처리가 어려운 점 양해 부탁드립니다.");
|
||||
Ap("답변이 늦어 죄송합니다.");
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
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(
|
||||
"업무 문구 카테고리",
|
||||
"phrase <카테고리> 또는 phrase <검색어>",
|
||||
null, null, Symbol: "\uE8A0"));
|
||||
|
||||
foreach (var (key, display, _) in _categories)
|
||||
{
|
||||
var count = _phrases.Count(p => p.CategoryKey == key);
|
||||
items.Add(new LauncherItem(
|
||||
$"{display} ({count}개)",
|
||||
$"phrase {display} → 문구 목록",
|
||||
null, ("category", key), Symbol: "\uE8A0"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var kw = q.ToLowerInvariant();
|
||||
|
||||
// 카테고리 키워드 매칭
|
||||
foreach (var (key, display, aliases) in _categories)
|
||||
{
|
||||
if (aliases.Any(a => a.Equals(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
a.Contains(q, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var catPhrases = _phrases.Where(p => p.CategoryKey == key).ToList();
|
||||
items.Add(new LauncherItem(
|
||||
$"{display} ({catPhrases.Count}개)",
|
||||
"Enter → 클립보드 복사",
|
||||
null, null, Symbol: "\uE8A0"));
|
||||
foreach (var p in catPhrases)
|
||||
items.Add(new LauncherItem(p.Text, p.Category,
|
||||
null, ("copy", p.Text), Symbol: "\uE8A0"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
}
|
||||
|
||||
// 전체 검색
|
||||
var results = _phrases
|
||||
.Where(p => p.Text.Contains(q, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (results.Count > 0)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"'{q}' 검색 결과 {results.Count}개",
|
||||
"Enter → 클립보드 복사",
|
||||
null, null, Symbol: "\uE8A0"));
|
||||
foreach (var p in results)
|
||||
items.Add(new LauncherItem(p.Text, p.Category,
|
||||
null, ("copy", p.Text), Symbol: "\uE8A0"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"'{q}' 검색 결과 없음",
|
||||
"카테고리: 인사 / 보고 / 요청 / 마무리 / 승인결재 / 회의 / 사과지연",
|
||||
null, null, Symbol: "\uE783"));
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is ("copy", string text) && !string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
try
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
|
||||
NotificationService.Notify("문구", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
271
src/AxCopilot/Handlers/PingHandler.cs
Normal file
271
src/AxCopilot/Handlers/PingHandler.cs
Normal file
@@ -0,0 +1,271 @@
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L16-1: ping·tracert 빠른 실행 핸들러. "ping" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: ping 8.8.8.8 → ping 결과 (4회)
|
||||
/// ping google.com → 도메인 ping
|
||||
/// ping trace 8.8.8.8 → tracert 실행
|
||||
/// ping local → 로컬 네트워크 어댑터 정보
|
||||
/// ping scan 192.168.1.0 → 간단 네트워크 스캔 (1~254)
|
||||
/// Enter → 결과 복사 또는 외부 터미널 실행.
|
||||
/// 사내 모드: 외부 IP/도메인 ping 차단.
|
||||
/// </summary>
|
||||
public class PingHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "ping";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Ping",
|
||||
"ping·tracert 빠른 실행 — 네트워크 연결 확인·경로 추적",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private static readonly string[] QuickTargets =
|
||||
["localhost", "8.8.8.8", "1.1.1.1", "google.com", "192.168.1.1"];
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
items.Add(new LauncherItem("ping / tracert 실행기",
|
||||
"예: ping 8.8.8.8 / ping trace 192.168.1.1 / ping local / ping scan 192.168.1",
|
||||
null, null, Symbol: "\uE968"));
|
||||
items.Add(new LauncherItem("── 빠른 대상 ──", "", null, null, Symbol: "\uE968"));
|
||||
foreach (var t in QuickTargets)
|
||||
items.Add(new LauncherItem($"ping {t}", t, null, ("ping", t), Symbol: "\uE968"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
// local → 로컬 어댑터 정보
|
||||
if (sub == "local" || sub == "lo")
|
||||
{
|
||||
items.AddRange(BuildLocalNetworkItems());
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// trace / tracert / traceroute
|
||||
if (sub is "trace" or "tracert" or "traceroute")
|
||||
{
|
||||
var target = parts.Length > 1 ? parts[1] : "";
|
||||
if (string.IsNullOrWhiteSpace(target))
|
||||
{
|
||||
items.Add(new LauncherItem("대상 주소 입력", "예: ping trace 8.8.8.8", null, null, Symbol: "\uE783"));
|
||||
}
|
||||
else
|
||||
{
|
||||
var blocked = CheckInternalMode(target);
|
||||
if (blocked != null) { items.Add(blocked); return Task.FromResult<IEnumerable<LauncherItem>>(items); }
|
||||
items.Add(new LauncherItem($"tracert {target}", "Enter → 터미널에서 tracert 실행",
|
||||
null, ("tracert", target), Symbol: "\uE968"));
|
||||
items.Add(new LauncherItem("터미널 실행", $"tracert {target}",
|
||||
null, ("tracert", target), Symbol: "\uE968"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// scan → 간단 스캔 (비동기 결과는 실행 시 터미널)
|
||||
if (sub == "scan")
|
||||
{
|
||||
var network = parts.Length > 1 ? parts[1] : "192.168.1";
|
||||
items.Add(new LauncherItem($"네트워크 스캔: {network}.1~254",
|
||||
"Enter → 터미널에서 ping 스캔 스크립트 실행",
|
||||
null, ("scan", network), Symbol: "\uE968"));
|
||||
items.Add(new LauncherItem("팁", "결과가 많을 수 있습니다 — 터미널에서 확인하세요",
|
||||
null, null, Symbol: "\uE946"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 직접 ping 대상
|
||||
var host = parts[0];
|
||||
var blocked2 = CheckInternalMode(host);
|
||||
if (blocked2 != null) { items.Add(blocked2); return Task.FromResult<IEnumerable<LauncherItem>>(items); }
|
||||
|
||||
items.Add(new LauncherItem($"ping {host}",
|
||||
"Enter → 비동기 ping (4회) 실행",
|
||||
null, ("ping", host), Symbol: "\uE968"));
|
||||
items.Add(new LauncherItem($"tracert {host}",
|
||||
"Enter → 터미널에서 tracert 실행",
|
||||
null, ("tracert", host), Symbol: "\uE968"));
|
||||
items.Add(new LauncherItem($"ping 연속 {host}",
|
||||
"ping -t (무한 반복) — 터미널",
|
||||
null, ("ping_t", host), Symbol: "\uE968"));
|
||||
|
||||
// 즉시 1회 ping 시도
|
||||
var pingResult = TryPingOnce(host);
|
||||
if (pingResult != null)
|
||||
{
|
||||
items.Insert(0, pingResult);
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
switch (item.Data)
|
||||
{
|
||||
case ("ping", string host):
|
||||
RunInTerminal($"ping {host}");
|
||||
break;
|
||||
|
||||
case ("ping_t", string host):
|
||||
RunInTerminal($"ping -t {host}");
|
||||
break;
|
||||
|
||||
case ("tracert", string host):
|
||||
RunInTerminal($"tracert {host}");
|
||||
break;
|
||||
|
||||
case ("scan", string network):
|
||||
// PowerShell로 간단 스캔
|
||||
var ps = $"1..254 | ForEach-Object {{ $ip = '{network}.$_'; if (Test-Connection $ip -Count 1 -Quiet -TimeoutSeconds 1) {{ Write-Host \"$ip is UP\" }} }}; Read-Host 'Press Enter'";
|
||||
RunInTerminal($"powershell -NoExit -Command \"{ps}\"", usePs: true);
|
||||
break;
|
||||
|
||||
case ("copy", string text):
|
||||
try
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(
|
||||
() => Clipboard.SetText(text));
|
||||
NotificationService.Notify("Ping", "복사됨");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
break;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static List<LauncherItem> BuildLocalNetworkItems()
|
||||
{
|
||||
var items = new List<LauncherItem>();
|
||||
try
|
||||
{
|
||||
var ifaces = NetworkInterface.GetAllNetworkInterfaces()
|
||||
.Where(n => n.OperationalStatus == OperationalStatus.Up &&
|
||||
n.NetworkInterfaceType != NetworkInterfaceType.Loopback)
|
||||
.ToList();
|
||||
|
||||
items.Add(new LauncherItem($"로컬 네트워크 어댑터 {ifaces.Count}개", "", null, null, Symbol: "\uE968"));
|
||||
|
||||
foreach (var iface in ifaces)
|
||||
{
|
||||
var ipProps = iface.GetIPProperties();
|
||||
var ipv4 = ipProps.UnicastAddresses
|
||||
.FirstOrDefault(a => a.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork);
|
||||
var gateway = ipProps.GatewayAddresses.FirstOrDefault()?.Address?.ToString() ?? "없음";
|
||||
|
||||
if (ipv4 == null) continue;
|
||||
|
||||
var ip = ipv4.Address.ToString();
|
||||
var mask = ipv4.IPv4Mask.ToString();
|
||||
var label = $"{iface.Name} {ip}";
|
||||
var sub2 = $"넷마스크 {mask} · 게이트웨이 {gateway}";
|
||||
items.Add(new LauncherItem(label, sub2, null, ("copy", ip), Symbol: "\uE968"));
|
||||
}
|
||||
|
||||
// 외부 IP 안내 (사외 모드에서만)
|
||||
var settings = (System.Windows.Application.Current as App)?.SettingsService?.Settings;
|
||||
if (settings?.InternalModeEnabled == false)
|
||||
items.Add(new LauncherItem("외부 IP 조회", "ping trace 8.8.8.8 으로 경로 확인",
|
||||
null, null, Symbol: "\uE968"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
items.Add(new LauncherItem("네트워크 정보 조회 오류", ex.Message, null, null, Symbol: "\uE783"));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private static LauncherItem? TryPingOnce(string host)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var p = new Ping();
|
||||
var reply = p.Send(host, 1000);
|
||||
if (reply.Status == IPStatus.Success)
|
||||
{
|
||||
var label = $"✓ 응답 {reply.RoundtripTime}ms";
|
||||
return new LauncherItem(label, $"TTL {reply.Options?.Ttl ?? 0} · {host}",
|
||||
null, ("copy", $"{reply.RoundtripTime}ms"), Symbol: "\uE968");
|
||||
}
|
||||
return new LauncherItem($"✗ 응답 없음 ({reply.Status})", host, null, null, Symbol: "\uE783");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null; // 오류 시 무시
|
||||
}
|
||||
}
|
||||
|
||||
private static LauncherItem? CheckInternalMode(string host)
|
||||
{
|
||||
var settings = (System.Windows.Application.Current as App)?.SettingsService?.Settings;
|
||||
if (settings?.InternalModeEnabled != true) return null;
|
||||
|
||||
// 내부 주소는 허용
|
||||
if (host.StartsWith("192.168.", StringComparison.Ordinal) ||
|
||||
host.StartsWith("10.", StringComparison.Ordinal) ||
|
||||
host.StartsWith("172.", StringComparison.Ordinal) ||
|
||||
host.Equals("localhost", StringComparison.OrdinalIgnoreCase) ||
|
||||
host.StartsWith("127.", StringComparison.Ordinal))
|
||||
return null;
|
||||
|
||||
if (IPAddress.TryParse(host, out _))
|
||||
return null; // IP 주소 → 내부로 간주 허용
|
||||
|
||||
return new LauncherItem("사내 모드 — 외부 도메인 차단",
|
||||
"사외 모드에서 외부 주소 ping 가능. 설정에서 변경하세요.",
|
||||
null, null, Symbol: "\uE783");
|
||||
}
|
||||
|
||||
private static void RunInTerminal(string cmd, bool usePs = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
var wtPath = FindExe("wt.exe");
|
||||
if (wtPath != null && !usePs)
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = wtPath, Arguments = $"cmd /K {cmd}", UseShellExecute = false,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = usePs ? "powershell" : "cmd",
|
||||
Arguments = usePs ? $"-NoExit -Command \"{cmd}\"" : $"/K {cmd}",
|
||||
UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
|
||||
private static string? FindExe(string name)
|
||||
{
|
||||
foreach (var dir in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(';'))
|
||||
{
|
||||
var full = System.IO.Path.Combine(dir.Trim(), name);
|
||||
if (System.IO.File.Exists(full)) return full;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
178
src/AxCopilot/Handlers/PipHandler.cs
Normal file
178
src/AxCopilot/Handlers/PipHandler.cs
Normal file
@@ -0,0 +1,178 @@
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L22-3: Python pip 명령 생성기 핸들러. "pip" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: pip → 자주 쓰는 명령 목록
|
||||
/// pip install → 패키지 설치 관련 명령
|
||||
/// pip list → 목록 관련 명령
|
||||
/// pip venv → 가상환경 관련 명령
|
||||
/// pip conda → conda 관련 명령
|
||||
/// pip <검색어> → 명령 검색
|
||||
/// Enter → 명령어 클립보드 복사.
|
||||
/// </summary>
|
||||
public class PipHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "pip";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"pip 명령",
|
||||
"Python pip 명령 생성기 — 설치·관리·가상환경·conda",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private sealed record PipCmd(
|
||||
string Pip2,
|
||||
string Pip3,
|
||||
string Description,
|
||||
string Category);
|
||||
|
||||
private static readonly PipCmd[] Commands =
|
||||
[
|
||||
// ── 설치 (install) ───────────────────────────────────────────────────
|
||||
new("pip install {패키지}", "pip3 install {패키지}", "패키지 설치", "install"),
|
||||
new("pip install {패키지}=={버전}", "pip3 install {패키지}=={버전}", "특정 버전 설치 (예: requests==2.31.0)", "install"),
|
||||
new("pip install -r requirements.txt","pip3 install -r requirements.txt","requirements.txt 일괄 설치", "install"),
|
||||
new("pip install --upgrade {패키지}", "pip3 install --upgrade {패키지}","패키지 업그레이드", "install"),
|
||||
new("pip install --upgrade pip", "pip3 install --upgrade pip", "pip 자체 업그레이드", "install"),
|
||||
new("pip install --user {패키지}", "pip3 install --user {패키지}", "사용자 홈에 설치 (관리자 권한 불필요)", "install"),
|
||||
new("pip install -e .", "pip3 install -e .", "현재 폴더 패키지를 개발 모드로 설치", "install"),
|
||||
new("pip download {패키지}", "pip3 download {패키지}", "오프라인 설치를 위한 패키지 다운로드", "install"),
|
||||
|
||||
// ── 제거 (uninstall) ─────────────────────────────────────────────────
|
||||
new("pip uninstall {패키지}", "pip3 uninstall {패키지}", "패키지 제거", "uninstall"),
|
||||
new("pip uninstall -y {패키지}", "pip3 uninstall -y {패키지}", "확인 없이 패키지 제거", "uninstall"),
|
||||
new("pip uninstall -r requirements.txt -y","pip3 uninstall -r requirements.txt -y","requirements.txt 패키지 일괄 제거", "uninstall"),
|
||||
|
||||
// ── 목록·정보 (list) ─────────────────────────────────────────────────
|
||||
new("pip list", "pip3 list", "설치된 패키지 목록", "list"),
|
||||
new("pip list --outdated", "pip3 list --outdated", "업데이트 가능한 패키지 목록", "list"),
|
||||
new("pip show {패키지}", "pip3 show {패키지}", "패키지 상세 정보 (버전·위치·의존성)", "list"),
|
||||
new("pip freeze", "pip3 freeze", "설치 패키지 버전 고정 출력", "list"),
|
||||
new("pip freeze > requirements.txt", "pip3 freeze > requirements.txt", "requirements.txt 파일 생성", "list"),
|
||||
new("pip check", "pip3 check", "의존성 충돌 검사", "list"),
|
||||
|
||||
// ── 검색·캐시 (search) ───────────────────────────────────────────────
|
||||
new("pip cache list", "pip3 cache list", "캐시 목록 확인", "search"),
|
||||
new("pip cache purge", "pip3 cache purge", "캐시 전체 삭제", "search"),
|
||||
new("pip index versions {패키지}", "pip3 index versions {패키지}", "PyPI에서 사용 가능한 버전 목록 조회", "search"),
|
||||
new("pip config list", "pip3 config list", "pip 설정 목록", "search"),
|
||||
new("pip config set global.index-url {URL}","pip3 config set global.index-url {URL}","사내 PyPI 미러 설정", "search"),
|
||||
|
||||
// ── 가상환경 (venv) ──────────────────────────────────────────────────
|
||||
new("python -m venv .venv", "python3 -m venv .venv", "가상환경 생성 (.venv 폴더)", "venv"),
|
||||
new(".venv\\Scripts\\activate", "source .venv/bin/activate", "가상환경 활성화 (Win / Mac·Linux)", "venv"),
|
||||
new("deactivate", "deactivate", "가상환경 비활성화", "venv"),
|
||||
new("python -m venv .venv --clear", "python3 -m venv .venv --clear", "가상환경 초기화 (재생성)", "venv"),
|
||||
new("pip list --local", "pip3 list --local", "현재 가상환경 패키지만 목록", "venv"),
|
||||
new("python -m site --user-site", "python3 -m site --user-site", "사용자 패키지 설치 경로 확인", "venv"),
|
||||
|
||||
// ── conda ────────────────────────────────────────────────────────────
|
||||
new("conda create -n {환경명} python={버전}","conda create -n {환경명} python={버전}","Conda 환경 생성 (버전 지정)", "conda"),
|
||||
new("conda activate {환경명}", "conda activate {환경명}", "Conda 환경 활성화", "conda"),
|
||||
new("conda deactivate", "conda deactivate", "Conda 환경 비활성화", "conda"),
|
||||
new("conda install {패키지}", "conda install {패키지}", "Conda로 패키지 설치", "conda"),
|
||||
new("conda update {패키지}", "conda update {패키지}", "Conda 패키지 업데이트", "conda"),
|
||||
new("conda list", "conda list", "현재 Conda 환경 패키지 목록", "conda"),
|
||||
new("conda env list", "conda env list", "전체 Conda 환경 목록", "conda"),
|
||||
new("conda env remove -n {환경명}", "conda env remove -n {환경명}", "Conda 환경 삭제", "conda"),
|
||||
new("conda env export > env.yml", "conda env export > env.yml", "환경 설정을 yml 파일로 내보내기", "conda"),
|
||||
new("conda env create -f env.yml", "conda env create -f env.yml", "yml 파일로 환경 복원", "conda"),
|
||||
];
|
||||
|
||||
private static readonly (string Key, string[] Aliases, string Label)[] Categories =
|
||||
[
|
||||
("install", ["install", "설치", "add"], "패키지 설치"),
|
||||
("uninstall", ["uninstall", "remove", "삭제"], "패키지 제거"),
|
||||
("list", ["list", "목록", "show", "freeze"],"목록·정보"),
|
||||
("search", ["search", "cache", "config"], "검색·캐시·설정"),
|
||||
("venv", ["venv", "env", "가상환경"], "가상환경"),
|
||||
("conda", ["conda", "anaconda"], "conda"),
|
||||
];
|
||||
|
||||
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("pip 명령 생성기",
|
||||
"카테고리: install · uninstall · list · venv · conda",
|
||||
null, null, Symbol: "\uE943"));
|
||||
|
||||
foreach (var (key, _, label) in Categories)
|
||||
{
|
||||
var cnt = Commands.Count(c => c.Category == key);
|
||||
items.Add(new LauncherItem($"pip {key}", $"{label} ({cnt}개 명령)",
|
||||
null, ("copy", $"pip {key}"), Symbol: "\uE943"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var kw = q.ToLowerInvariant();
|
||||
|
||||
// 카테고리 일치
|
||||
var cat = Categories.FirstOrDefault(c =>
|
||||
c.Aliases.Any(a => a == kw || kw.StartsWith(a + " ")));
|
||||
|
||||
if (cat.Key != null)
|
||||
{
|
||||
var list = Commands.Where(c => c.Category == cat.Key).ToList();
|
||||
items.Add(new LauncherItem($"{cat.Label} 명령 {list.Count}개",
|
||||
"pip2 / pip3 모두 표시 · Enter: pip3 명령 복사", null, null, Symbol: "\uE943"));
|
||||
foreach (var c in list) items.Add(CmdItem(c));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 검색
|
||||
var searched = Commands.Where(c =>
|
||||
c.Pip2.Contains(kw, StringComparison.OrdinalIgnoreCase) ||
|
||||
c.Pip3.Contains(kw, StringComparison.OrdinalIgnoreCase) ||
|
||||
c.Description.Contains(kw, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (searched.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem($"'{q}' 명령을 찾을 수 없습니다",
|
||||
"카테고리: install · uninstall · list · venv · conda",
|
||||
null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
items.Add(new LauncherItem($"'{q}' 검색 결과 {searched.Count}개",
|
||||
"Enter: pip3 명령 복사", null, null, Symbol: "\uE943"));
|
||||
foreach (var c in searched) items.Add(CmdItem(c));
|
||||
|
||||
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("pip", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static LauncherItem CmdItem(PipCmd c)
|
||||
{
|
||||
var title = c.Pip2 == c.Pip3
|
||||
? c.Pip3
|
||||
: $"{c.Pip3}";
|
||||
var sub = c.Pip2 == c.Pip3
|
||||
? c.Description
|
||||
: $"{c.Description} | pip2: {c.Pip2}";
|
||||
return new LauncherItem(title, sub, null, ("copy", c.Pip3), Symbol: "\uE943");
|
||||
}
|
||||
}
|
||||
238
src/AxCopilot/Handlers/PkgHandler.cs
Normal file
238
src/AxCopilot/Handlers/PkgHandler.cs
Normal file
@@ -0,0 +1,238 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.RegularExpressions;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L28-1: winget 앱 검색·설치·목록 핸들러. "pkg" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: pkg → 사용법 안내
|
||||
/// pkg vscode → winget search vscode
|
||||
/// pkg install {id} → winget install {id}
|
||||
/// pkg list → 설치된 앱 목록
|
||||
/// pkg upgrade → 업그레이드 가능 목록
|
||||
/// winget 미설치 시 안내 메시지 표시.
|
||||
/// </summary>
|
||||
public partial class PkgHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "pkg";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"앱 패키지",
|
||||
"winget 앱 검색·설치·업그레이드",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private static bool? _wingetAvailable;
|
||||
|
||||
public async Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
// winget 설치 여부 체크 (캐시)
|
||||
_wingetAvailable ??= await CheckWingetAsync();
|
||||
if (_wingetAvailable == false)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
"winget이 설치되어 있지 않습니다",
|
||||
"Windows Package Manager는 Windows 10 1709+ 에서 사용 가능합니다",
|
||||
null, null, Symbol: Symbols.Warning));
|
||||
return items;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
items.Add(new LauncherItem("winget 앱 패키지 관리",
|
||||
"pkg {검색어} · pkg install {id} · pkg list · pkg upgrade",
|
||||
null, null, Symbol: "\uECAA"));
|
||||
return items;
|
||||
}
|
||||
|
||||
// ── list 명령 ─────────────────────────────────────────────────────────
|
||||
if (q.Equals("list", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
items.Add(new LauncherItem("설치된 앱 목록 조회 중...",
|
||||
"winget list 실행", null, ("list", ""), Symbol: "\uECAA"));
|
||||
// 실행 시 터미널에서 보여주기
|
||||
return items;
|
||||
}
|
||||
|
||||
// ── upgrade 명령 ──────────────────────────────────────────────────────
|
||||
if (q.Equals("upgrade", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
items.Add(new LauncherItem("업그레이드 가능 앱 확인",
|
||||
"Enter: winget upgrade 실행", null, ("upgrade", ""), Symbol: "\uE777"));
|
||||
return items;
|
||||
}
|
||||
|
||||
// ── install 명령 ──────────────────────────────────────────────────────
|
||||
if (q.StartsWith("install ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var id = q[8..].Trim();
|
||||
if (!string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"앱 설치: {id}",
|
||||
$"Enter: winget install --id {id}",
|
||||
null, ("install", id), Symbol: "\uE896"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem("사용법: pkg install {앱ID}",
|
||||
"예: pkg install Microsoft.VisualStudioCode",
|
||||
null, null, Symbol: Symbols.Info));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// ── 검색 ──────────────────────────────────────────────────────────────
|
||||
try
|
||||
{
|
||||
var results = await SearchAsync(q, ct);
|
||||
if (results.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem($"'{q}' 검색 결과 없음",
|
||||
"다른 검색어를 시도하세요", null, null, Symbol: Symbols.Search));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"검색 결과: {results.Count}개",
|
||||
"Enter: winget install --id {ID}", null, null, Symbol: Symbols.Search));
|
||||
|
||||
foreach (var r in results.Take(10))
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"{r.Name} [{r.Version}]",
|
||||
$"{r.Id} · {r.Source}",
|
||||
null, ("install", r.Id), Symbol: "\uECAA"));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
items.Add(new LauncherItem("검색 오류", ex.Message, null, null, Symbol: Symbols.Error));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is ("install", string id) && !string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
RunWingetInTerminal($"install --id \"{id}\" --accept-source-agreements --accept-package-agreements");
|
||||
NotificationService.Notify("pkg", $"설치 시작: {id}");
|
||||
}
|
||||
else if (item.Data is ("list", _))
|
||||
{
|
||||
RunWingetInTerminal("list");
|
||||
}
|
||||
else if (item.Data is ("upgrade", _))
|
||||
{
|
||||
RunWingetInTerminal("upgrade --include-unknown");
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ─── winget 검색 ──────────────────────────────────────────────────────────
|
||||
|
||||
private record PkgResult(string Name, string Id, string Version, string Source);
|
||||
|
||||
private static async Task<List<PkgResult>> SearchAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var output = await RunWingetAsync($"search \"{query}\" --accept-source-agreements", ct);
|
||||
return ParseWingetOutput(output);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^(.+?)\s{2,}(\S+)\s{2,}(\S+)\s{2,}(\S+)\s*$")]
|
||||
private static partial Regex WingetLineRegex();
|
||||
|
||||
private static List<PkgResult> ParseWingetOutput(string output)
|
||||
{
|
||||
var results = new List<PkgResult>();
|
||||
var lines = output.Split('\n');
|
||||
bool pastHeader = false;
|
||||
|
||||
foreach (var rawLine in lines)
|
||||
{
|
||||
var line = rawLine.TrimEnd();
|
||||
|
||||
// 헤더 구분선 (---) 이후부터 데이터
|
||||
if (line.StartsWith("---") || line.StartsWith("───"))
|
||||
{
|
||||
pastHeader = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!pastHeader || string.IsNullOrWhiteSpace(line)) continue;
|
||||
|
||||
var match = WingetLineRegex().Match(line);
|
||||
if (match.Success)
|
||||
{
|
||||
results.Add(new PkgResult(
|
||||
match.Groups[1].Value.Trim(),
|
||||
match.Groups[2].Value.Trim(),
|
||||
match.Groups[3].Value.Trim(),
|
||||
match.Groups[4].Value.Trim()));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── winget 실행 ──────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<bool> CheckWingetAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var output = await RunWingetAsync("--version", CancellationToken.None);
|
||||
return output.TrimStart().StartsWith('v');
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static async Task<string> RunWingetAsync(string args, CancellationToken ct)
|
||||
{
|
||||
using var proc = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "winget",
|
||||
Arguments = args,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = System.Text.Encoding.UTF8
|
||||
}
|
||||
};
|
||||
proc.Start();
|
||||
var output = await proc.StandardOutput.ReadToEndAsync(ct);
|
||||
await proc.WaitForExitAsync(ct);
|
||||
return output;
|
||||
}
|
||||
|
||||
private static void RunWingetInTerminal(string args)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 사용자에게 진행 상황이 보이도록 터미널 창으로 실행
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "cmd.exe",
|
||||
Arguments = $"/k winget {args}",
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"winget 실행 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
130
src/AxCopilot/Handlers/PomoHandler.cs
Normal file
130
src/AxCopilot/Handlers/PomoHandler.cs
Normal file
@@ -0,0 +1,130 @@
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// Phase L3-9: 뽀모도로 타이머 핸들러. "pomo" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 사용법:
|
||||
/// pomo → 현재 타이머 상태 표시
|
||||
/// pomo start → 집중 타이머 시작 (25분)
|
||||
/// pomo break → 휴식 타이머 시작 (5분)
|
||||
/// pomo stop → 타이머 중지
|
||||
/// pomo reset → 타이머 초기화
|
||||
///
|
||||
/// Enter → 해당 명령 실행.
|
||||
/// </summary>
|
||||
public class PomoHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "pomo";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Pomodoro",
|
||||
"뽀모도로 타이머 — pomo",
|
||||
"1.0",
|
||||
"AX",
|
||||
"집중/휴식 타이머를 관리합니다.");
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim().ToLowerInvariant();
|
||||
var svc = PomodoroService.Instance;
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
// ─── 명령 분기 ────────────────────────────────────────────────────────
|
||||
|
||||
if (q is "start" or "focus")
|
||||
{
|
||||
items.Add(new LauncherItem("집중 타이머 시작",
|
||||
$"{svc.FocusMinutes}분 집중 모드 시작 · Enter로 실행",
|
||||
null, "__START__", Symbol: Symbols.Timer));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
if (q is "break" or "rest")
|
||||
{
|
||||
items.Add(new LauncherItem("휴식 타이머 시작",
|
||||
$"{svc.BreakMinutes}분 휴식 모드 시작 · Enter로 실행",
|
||||
null, "__BREAK__", Symbol: Symbols.Timer));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
if (q is "stop" or "pause")
|
||||
{
|
||||
items.Add(new LauncherItem("타이머 중지",
|
||||
"현재 타이머를 중지합니다 · Enter로 실행",
|
||||
null, "__STOP__", Symbol: Symbols.MediaPlay));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
if (q == "reset")
|
||||
{
|
||||
items.Add(new LauncherItem("타이머 초기화",
|
||||
"타이머를 처음 상태로 초기화합니다 · Enter로 실행",
|
||||
null, "__RESET__", Symbol: Symbols.Restart));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ─── 기본: 현재 상태 + 명령 목록 ─────────────────────────────────────
|
||||
|
||||
var remaining = svc.Remaining;
|
||||
var timeStr = $"{(int)remaining.TotalMinutes:D2}:{remaining.Seconds:D2}";
|
||||
var stateStr = svc.Mode switch
|
||||
{
|
||||
PomodoroMode.Focus => svc.IsRunning ? $"집중 중 {timeStr} 남음" : $"집중 일시정지 {timeStr} 남음",
|
||||
PomodoroMode.Break => svc.IsRunning ? $"휴식 중 {timeStr} 남음" : $"휴식 일시정지 {timeStr} 남음",
|
||||
_ => "대기 중",
|
||||
};
|
||||
|
||||
// 상태 카드
|
||||
items.Add(new LauncherItem(
|
||||
"🍅 뽀모도로 타이머",
|
||||
stateStr,
|
||||
null, null, Symbol: Symbols.Timer));
|
||||
|
||||
// 빠른 명령들
|
||||
if (!svc.IsRunning)
|
||||
{
|
||||
items.Add(new LauncherItem("집중 시작",
|
||||
$"{svc.FocusMinutes}분 집중 모드 시작 · Enter로 실행",
|
||||
null, "__START__", Symbol: Symbols.Timer));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem("타이머 중지",
|
||||
"현재 타이머를 중지합니다 · Enter로 실행",
|
||||
null, "__STOP__", Symbol: Symbols.MediaPlay));
|
||||
}
|
||||
|
||||
if (svc.Mode == PomodoroMode.Focus && svc.IsRunning)
|
||||
{
|
||||
items.Add(new LauncherItem("휴식 시작",
|
||||
$"{svc.BreakMinutes}분 휴식 모드로 전환 · Enter로 실행",
|
||||
null, "__BREAK__", Symbol: Symbols.Timer));
|
||||
}
|
||||
|
||||
items.Add(new LauncherItem("타이머 초기화",
|
||||
"타이머를 처음 상태로 초기화합니다 · Enter로 실행",
|
||||
null, "__RESET__", Symbol: Symbols.Restart));
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is not string cmd) return Task.CompletedTask;
|
||||
|
||||
var svc = PomodoroService.Instance;
|
||||
switch (cmd)
|
||||
{
|
||||
case "__START__": svc.StartFocus(); break;
|
||||
case "__BREAK__": svc.StartBreak(); break;
|
||||
case "__STOP__": svc.Stop(); break;
|
||||
case "__RESET__": svc.Reset(); break;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
202
src/AxCopilot/Handlers/ProcHandler.cs
Normal file
202
src/AxCopilot/Handlers/ProcHandler.cs
Normal file
@@ -0,0 +1,202 @@
|
||||
using System.Diagnostics;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L22-1: 프로세스 상세 조회·정리 핸들러. "proc" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: proc → CPU 사용량 상위 프로세스 목록
|
||||
/// proc top → CPU 상위 15개
|
||||
/// proc mem → 메모리 상위 15개
|
||||
/// proc <이름> → 이름 검색 (부분 일치)
|
||||
/// proc kill <이름> → 이름으로 종료 (첫 번째 일치)
|
||||
/// proc stats → 전체 통계 (수·CPU합·메모리합)
|
||||
/// Enter → 프로세스 이름 복사.
|
||||
/// </summary>
|
||||
public class ProcHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "proc";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"프로세스 관리",
|
||||
"실행 중인 프로세스 조회·정리 — CPU·메모리 정렬·검색·종료",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts.Length > 0 ? parts[0].ToLowerInvariant() : "";
|
||||
|
||||
// kill 서브커맨드
|
||||
if (sub is "kill" or "종료" or "stop")
|
||||
{
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
items.Add(ErrorItem("예: proc kill <프로세스명>"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
var target = parts[1];
|
||||
var killed = KillProcess(target);
|
||||
items.Add(killed
|
||||
? new LauncherItem($"✓ '{target}' 종료 완료", "프로세스가 종료되었습니다", null, null, Symbol: "\uE74D")
|
||||
: new LauncherItem($"'{target}' 프로세스를 찾을 수 없습니다", "실행 중인지 확인하세요", null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 프로세스 목록 수집
|
||||
Process[] procs;
|
||||
try { procs = Process.GetProcesses(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
items.Add(ErrorItem($"프로세스 조회 실패: {ex.Message}"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// stats
|
||||
if (sub is "stats" or "stat")
|
||||
{
|
||||
var totalMem = procs.Sum(p => SafeWorkingSet(p));
|
||||
var totalCpu = CountHighCpu(procs);
|
||||
items.Add(new LauncherItem("프로세스 통계", "", null, null, Symbol: "\uE9D9"));
|
||||
items.Add(CopyItem("전체 프로세스 수", procs.Length.ToString()));
|
||||
items.Add(CopyItem("전체 메모리 사용", FormatBytes(totalMem)));
|
||||
items.Add(CopyItem("CPU 10%+ 프로세스", $"{totalCpu}개"));
|
||||
items.Add(CopyItem("고유 프로세스 종류", procs.Select(p => p.ProcessName).Distinct().Count().ToString()));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 이름 검색
|
||||
if (!string.IsNullOrWhiteSpace(sub) && sub is not "top" and not "mem" and not "all")
|
||||
{
|
||||
var matched = procs
|
||||
.Where(p => p.ProcessName.Contains(sub, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(p => SafeWorkingSet(p))
|
||||
.Take(20)
|
||||
.ToList();
|
||||
|
||||
if (matched.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem($"'{sub}' 프로세스 없음", "실행 중인 프로세스를 검색합니다", null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
items.Add(new LauncherItem($"'{sub}' 검색 결과 {matched.Count}개",
|
||||
"Enter: 이름 복사 · proc kill <이름>으로 종료", null, null, Symbol: "\uE721"));
|
||||
|
||||
foreach (var p in matched)
|
||||
items.Add(BuildItem(p));
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// mem: 메모리 정렬
|
||||
if (sub is "mem" or "memory" or "메모리")
|
||||
{
|
||||
var top = procs.OrderByDescending(p => SafeWorkingSet(p)).Take(15).ToList();
|
||||
items.Add(new LauncherItem($"메모리 상위 {top.Count}개 프로세스",
|
||||
"메모리 사용량 내림차순", null, null, Symbol: "\uE9D9"));
|
||||
foreach (var p in top) items.Add(BuildItem(p));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// top / 기본: CPU 정렬 (WorkingSet 근사치 사용, CPU%는 샘플링 필요)
|
||||
{
|
||||
var top = procs
|
||||
.OrderByDescending(p => SafeWorkingSet(p))
|
||||
.Take(15)
|
||||
.ToList();
|
||||
|
||||
var label = sub is "top" ? $"CPU/메모리 상위 {top.Count}개" : $"실행 중 프로세스 상위 {top.Count}개";
|
||||
items.Add(new LauncherItem(label,
|
||||
$"전체 {procs.Length}개 실행 중 · proc mem / proc <검색어> / proc kill <이름>",
|
||||
null, null, Symbol: "\uE9D9"));
|
||||
|
||||
foreach (var p in top) items.Add(BuildItem(p));
|
||||
}
|
||||
|
||||
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("프로세스", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 헬퍼 ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static LauncherItem BuildItem(Process p)
|
||||
{
|
||||
var mem = SafeWorkingSet(p);
|
||||
var name = p.ProcessName;
|
||||
var pid = p.Id;
|
||||
var sub = $"PID {pid} · {FormatBytes(mem)}";
|
||||
return new LauncherItem(name, sub, null, ("copy", name), Symbol: "\uE9D9");
|
||||
}
|
||||
|
||||
private static long SafeWorkingSet(Process p)
|
||||
{
|
||||
try { return p.WorkingSet64; }
|
||||
catch { return 0; }
|
||||
}
|
||||
|
||||
private static int CountHighCpu(Process[] procs)
|
||||
{
|
||||
// CPU 퍼센트를 정확히 측정하려면 2회 샘플링이 필요하므로
|
||||
// 여기서는 스레드 수 > 5 를 기준으로 근사
|
||||
int count = 0;
|
||||
foreach (var p in procs)
|
||||
{
|
||||
try { if (p.Threads.Count > 5) count++; }
|
||||
catch { }
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static bool KillProcess(string name)
|
||||
{
|
||||
var targets = Process.GetProcessesByName(name);
|
||||
if (targets.Length == 0)
|
||||
{
|
||||
// 확장자 없이 시도
|
||||
var noExt = System.IO.Path.GetFileNameWithoutExtension(name);
|
||||
targets = Process.GetProcessesByName(noExt);
|
||||
}
|
||||
if (targets.Length == 0) return false;
|
||||
foreach (var p in targets)
|
||||
{
|
||||
try { p.Kill(); } catch { }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string FormatBytes(long bytes)
|
||||
{
|
||||
if (bytes >= 1_073_741_824) return $"{bytes / 1_073_741_824.0:F1} GB";
|
||||
if (bytes >= 1_048_576) return $"{bytes / 1_048_576.0:F0} MB";
|
||||
if (bytes >= 1_024) return $"{bytes / 1_024.0:F0} KB";
|
||||
return $"{bytes} B";
|
||||
}
|
||||
|
||||
private static LauncherItem CopyItem(string label, string value) =>
|
||||
new(label, value, null, ("copy", value), Symbol: "\uE9D9");
|
||||
|
||||
private static LauncherItem ErrorItem(string msg) =>
|
||||
new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783");
|
||||
}
|
||||
268
src/AxCopilot/Handlers/PsHandler.cs
Normal file
268
src/AxCopilot/Handlers/PsHandler.cs
Normal file
@@ -0,0 +1,268 @@
|
||||
using System.Diagnostics;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L21-3: PowerShell 명령 생성기 핸들러. "ps" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: ps → 카테고리별 자주 쓰는 명령 목록
|
||||
/// ps <키워드> → 명령 검색 (예: ps process, ps service, ps file)
|
||||
/// ps file → 파일/디렉토리 명령 목록
|
||||
/// ps process → 프로세스 명령 목록
|
||||
/// ps network → 네트워크 명령 목록
|
||||
/// ps service → 서비스 명령 목록
|
||||
/// ps registry → 레지스트리 명령 목록
|
||||
/// ps string → 문자열 처리 명령 목록
|
||||
/// ps date → 날짜/시간 명령 목록
|
||||
/// ps pipe → 파이프라인 예시
|
||||
/// ps run <명령> → 직접 PowerShell 실행
|
||||
/// Enter → 클립보드 복사 (또는 PS 터미널 실행).
|
||||
/// </summary>
|
||||
public class PsHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "ps";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"PS",
|
||||
"PowerShell 명령 생성기 — 파일·프로세스·네트워크·서비스·레지스트리",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private record PsCmd(string Cmd, string Description, string Category);
|
||||
|
||||
private static readonly PsCmd[] Commands =
|
||||
[
|
||||
// 파일/디렉토리
|
||||
new("Get-ChildItem -Path . -Recurse", "현재 폴더 재귀 목록", "file"),
|
||||
new("Get-ChildItem -Filter *.log -Recurse", "*.log 파일 재귀 검색", "file"),
|
||||
new("Copy-Item src.txt dst.txt", "파일 복사", "file"),
|
||||
new("Move-Item src.txt dst.txt", "파일 이동/이름변경", "file"),
|
||||
new("Remove-Item -Path file.txt -Force", "파일 강제 삭제", "file"),
|
||||
new("New-Item -ItemType Directory -Path .\\NewFolder", "새 폴더 생성", "file"),
|
||||
new("Get-Content file.txt", "파일 내용 출력", "file"),
|
||||
new("Get-Content file.txt -Tail 20", "파일 마지막 20줄 (tail)", "file"),
|
||||
new("Set-Content file.txt \"내용\"", "파일 쓰기", "file"),
|
||||
new("Add-Content file.txt \"추가 내용\"", "파일에 내용 추가", "file"),
|
||||
new("Test-Path C:\\path\\to\\file", "경로 존재 여부 확인", "file"),
|
||||
new("Resolve-Path .\\relative\\path", "상대 경로 → 절대 경로", "file"),
|
||||
new("Get-Item file.txt | Select-Object Length,LastWriteTime", "파일 크기·수정일 조회", "file"),
|
||||
new("(Get-ChildItem -Recurse | Measure-Object -Property Length -Sum).Sum / 1MB", "폴더 크기 계산 (MB)", "file"),
|
||||
new("Compress-Archive -Path .\\folder -DestinationPath out.zip","폴더 압축 (.zip)", "file"),
|
||||
new("Expand-Archive -Path file.zip -DestinationPath .\\out", "zip 압축 해제", "file"),
|
||||
|
||||
// 프로세스
|
||||
new("Get-Process", "실행 중인 프로세스 목록", "process"),
|
||||
new("Get-Process -Name chrome | Sort-Object CPU -Desc", "Chrome 프로세스 CPU 순 정렬", "process"),
|
||||
new("Stop-Process -Name notepad -Force", "notepad 강제 종료", "process"),
|
||||
new("Stop-Process -Id 1234 -Force", "PID로 프로세스 종료", "process"),
|
||||
new("Start-Process chrome.exe", "프로세스 실행", "process"),
|
||||
new("Start-Process notepad -Verb RunAs", "관리자 권한으로 실행", "process"),
|
||||
new("Get-Process | Where-Object {$_.CPU -gt 10} | Sort-Object CPU -Desc | Select -First 5", "CPU 10% 이상 상위 5개", "process"),
|
||||
new("Get-WmiObject Win32_Process | Select-Object Name,ProcessId,CommandLine", "프로세스 명령줄 조회", "process"),
|
||||
new("(Get-Process -Id $PID).Path", "현재 PowerShell 실행 경로", "process"),
|
||||
|
||||
// 서비스
|
||||
new("Get-Service", "모든 서비스 목록", "service"),
|
||||
new("Get-Service | Where-Object {$_.Status -eq 'Running'}", "실행 중인 서비스만", "service"),
|
||||
new("Start-Service -Name wuauserv", "Windows Update 서비스 시작", "service"),
|
||||
new("Stop-Service -Name wuauserv", "서비스 중지", "service"),
|
||||
new("Restart-Service -Name Spooler", "인쇄 스풀러 재시작", "service"),
|
||||
new("Set-Service -Name Fax -StartupType Disabled", "서비스 시작 유형 변경 (비활성화)", "service"),
|
||||
new("Get-Service -Name sshd", "SSH 서비스 상태 확인", "service"),
|
||||
|
||||
// 네트워크
|
||||
new("Test-NetConnection google.com -Port 443", "도메인+포트 연결 테스트", "network"),
|
||||
new("Test-NetConnection 8.8.8.8", "IP ping 테스트", "network"),
|
||||
new("Get-NetIPAddress -AddressFamily IPv4", "IPv4 주소 목록", "network"),
|
||||
new("Get-NetAdapter | Where-Object {$_.Status -eq 'Up'}", "활성 네트워크 어댑터 목록", "network"),
|
||||
new("Get-NetTCPConnection -State Listen", "Listen 상태 포트 목록", "network"),
|
||||
new("Get-NetTCPConnection | Where-Object {$_.LocalPort -eq 80}","포트 80 연결 조회", "network"),
|
||||
new("Resolve-DnsName google.com", "DNS 조회", "network"),
|
||||
new("(Invoke-WebRequest -Uri 'https://api.ipify.org').Content", "공인 IP 주소 확인", "network"),
|
||||
new("Invoke-WebRequest -Uri 'http://localhost:8080/health'", "HTTP GET 요청", "network"),
|
||||
|
||||
// 레지스트리
|
||||
new("Get-ItemProperty HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", "시작 프로그램 확인", "registry"),
|
||||
new("Get-ChildItem HKLM:\\SOFTWARE", "HKLM\\SOFTWARE 하위 키 목록", "registry"),
|
||||
new("New-ItemProperty -Path HKCU:\\Software -Name MyVal -Value 1 -PropertyType DWORD", "레지스트리 값 쓰기", "registry"),
|
||||
new("Remove-ItemProperty -Path HKCU:\\Software -Name MyVal", "레지스트리 값 삭제", "registry"),
|
||||
|
||||
// 문자열·데이터
|
||||
new("\"hello world\" -replace 'world','PowerShell'", "문자열 치환", "string"),
|
||||
new("\" text \".Trim()", "문자열 앞뒤 공백 제거", "string"),
|
||||
new("[System.Web.HttpUtility]::UrlEncode('hello world')", "URL 인코딩", "string"),
|
||||
new("[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes('hello'))", "Base64 인코딩", "string"),
|
||||
new("[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('aGVsbG8='))", "Base64 디코딩", "string"),
|
||||
new("Get-FileHash file.txt -Algorithm SHA256", "파일 SHA256 해시", "string"),
|
||||
new("$text | ConvertTo-Json", "객체 → JSON 변환", "string"),
|
||||
new("Get-Content file.json | ConvertFrom-Json", "JSON 파일 파싱", "string"),
|
||||
new("Import-Csv data.csv", "CSV 파일 파싱", "string"),
|
||||
new("Export-Csv -Path out.csv -NoTypeInformation", "CSV 파일 내보내기", "string"),
|
||||
|
||||
// 날짜/시간
|
||||
new("Get-Date", "현재 날짜·시간", "date"),
|
||||
new("Get-Date -Format 'yyyy-MM-dd HH:mm:ss'", "날짜 포맷 지정", "date"),
|
||||
new("(Get-Date).AddDays(30)", "30일 후 날짜", "date"),
|
||||
new("(Get-Date) - (Get-Date '2024-01-01')", "날짜 차이 계산", "date"),
|
||||
new("[datetime]::UtcNow", "UTC 현재 시각", "date"),
|
||||
new("[DateTimeOffset]::UtcNow.ToUnixTimeSeconds()", "Unix 타임스탬프 (초)", "date"),
|
||||
new("[DateTimeOffset]::FromUnixTimeSeconds(1700000000).LocalDateTime", "Unix 타임스탬프 → 날짜", "date"),
|
||||
|
||||
// 파이프라인
|
||||
new("Get-Process | Sort-Object CPU -Descending | Select-Object -First 10 | Format-Table", "상위 10 프로세스 테이블 출력", "pipe"),
|
||||
new("Get-ChildItem *.txt | ForEach-Object { $_.Name }", "txt 파일 이름만 추출", "pipe"),
|
||||
new("1..10 | ForEach-Object { $_ * $_ }", "1~10 제곱수 생성", "pipe"),
|
||||
new("'a','b','c' | Where-Object { $_ -ne 'b' }", "배열 필터링", "pipe"),
|
||||
new("Get-Content log.txt | Select-String 'ERROR'", "파일에서 ERROR 줄 검색", "pipe"),
|
||||
new("Get-EventLog -LogName System -Newest 50 | Where-Object {$_.EntryType -eq 'Error'}", "시스템 이벤트 오류 조회", "pipe"),
|
||||
];
|
||||
|
||||
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("PowerShell 명령 생성기",
|
||||
"ps file · process · network · service · registry · string · date · pipe · run <명령>",
|
||||
null, null, Symbol: "\uE756"));
|
||||
AddCategoryOverview(items);
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
// ps run <명령>
|
||||
if (sub == "run" && parts.Length >= 2)
|
||||
{
|
||||
var cmd = parts[1];
|
||||
items.Add(new LauncherItem(cmd, "Enter → PowerShell에서 실행",
|
||||
null, ("run", cmd), Symbol: "\uE756"));
|
||||
items.Add(new LauncherItem("클립보드 복사", cmd, null, ("copy", cmd), Symbol: "\uE756"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 카테고리 조회
|
||||
var catMatch = Commands.Where(c => c.Category.Equals(sub, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
if (catMatch.Count > 0)
|
||||
{
|
||||
var catName = sub switch
|
||||
{
|
||||
"file" => "파일/디렉토리",
|
||||
"process" => "프로세스",
|
||||
"service" => "서비스",
|
||||
"network" => "네트워크",
|
||||
"registry" => "레지스트리",
|
||||
"string" => "문자열·데이터",
|
||||
"date" => "날짜/시간",
|
||||
"pipe" => "파이프라인 예시",
|
||||
_ => sub
|
||||
};
|
||||
items.Add(new LauncherItem($"── {catName} ──", $"{catMatch.Count}개 명령", null, null, Symbol: "\uE756"));
|
||||
foreach (var c in catMatch)
|
||||
items.Add(new LauncherItem(TruncateStr(c.Cmd, 70), c.Description,
|
||||
null, ("copy", c.Cmd), Symbol: "\uE756"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 키워드 검색
|
||||
var keyword = string.Join(" ", parts);
|
||||
var found = Commands.Where(c =>
|
||||
c.Cmd.Contains(keyword, StringComparison.OrdinalIgnoreCase) ||
|
||||
c.Description.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (found.Count > 0)
|
||||
{
|
||||
items.Add(new LauncherItem($"'{keyword}' 검색 결과: {found.Count}개", "", null, null, Symbol: "\uE756"));
|
||||
foreach (var c in found)
|
||||
items.Add(new LauncherItem(TruncateStr(c.Cmd, 70), c.Description,
|
||||
null, ("copy", c.Cmd), Symbol: "\uE756"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"'{keyword}' 결과 없음",
|
||||
"ps file · process · network · service · registry · string · date · pipe",
|
||||
null, null, Symbol: "\uE783"));
|
||||
AddCategoryOverview(items);
|
||||
}
|
||||
|
||||
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("PS", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { }
|
||||
break;
|
||||
|
||||
case ("run", string cmd):
|
||||
RunInPowerShell(cmd);
|
||||
break;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 헬퍼 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static void AddCategoryOverview(List<LauncherItem> items)
|
||||
{
|
||||
var cats = new (string Key, string Label)[]
|
||||
{
|
||||
("file", "파일/디렉토리 — Get-ChildItem · Copy-Item · Remove-Item · Compress-Archive"),
|
||||
("process", "프로세스 — Get-Process · Stop-Process · Start-Process"),
|
||||
("service", "서비스 — Get-Service · Start-Service · Stop-Service"),
|
||||
("network", "네트워크 — Test-NetConnection · Get-NetIPAddress · Resolve-DnsName"),
|
||||
("registry", "레지스트리 — Get-ItemProperty · New-ItemProperty · Remove-ItemProperty"),
|
||||
("string", "문자열·데이터 — ConvertTo-Json · Get-FileHash · Import-Csv"),
|
||||
("date", "날짜/시간 — Get-Date · AddDays · UnixTimeSeconds"),
|
||||
("pipe", "파이프라인 — Sort-Object · Where-Object · ForEach-Object"),
|
||||
};
|
||||
foreach (var (key, label) in cats)
|
||||
items.Add(new LauncherItem($"ps {key}", label, null, null, Symbol: "\uE756"));
|
||||
}
|
||||
|
||||
private static void RunInPowerShell(string cmd)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Windows Terminal 우선
|
||||
var wtPath = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
@"Microsoft\WindowsApps\wt.exe");
|
||||
if (System.IO.File.Exists(wtPath))
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = wtPath,
|
||||
Arguments = $"PowerShell -NoExit -Command \"{cmd.Replace("\"", "\\\"")}\"",
|
||||
UseShellExecute = true
|
||||
});
|
||||
return;
|
||||
}
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "powershell.exe",
|
||||
Arguments = $"-NoExit -Command \"{cmd.Replace("\"", "\\\"")}\"",
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static string TruncateStr(string s, int max) =>
|
||||
s.Length <= max ? s : s[..max] + "…";
|
||||
}
|
||||
127
src/AxCopilot/Handlers/QrHandler.cs
Normal file
127
src/AxCopilot/Handlers/QrHandler.cs
Normal file
@@ -0,0 +1,127 @@
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Media.Imaging;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using QRCoder;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L27-3: QR 코드 생성 핸들러. "qr" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: qr https://google.com → QR 코드 생성 (Enter: 클립보드 복사)
|
||||
/// qr 안녕하세요 → 한국어 텍스트 QR 생성
|
||||
/// qr save https://... → QR PNG 파일 저장
|
||||
/// Enter → QR 이미지를 클립보드에 복사 (붙여넣기로 사용).
|
||||
/// </summary>
|
||||
public class QrHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "qr";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"QR 코드",
|
||||
"QR 코드 생성 — 텍스트/URL → 클립보드 PNG 복사",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private const int PixelsPerModule = 10;
|
||||
|
||||
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(
|
||||
"QR 코드 생성",
|
||||
"qr {텍스트 또는 URL} → Enter: QR PNG 클립보드 복사",
|
||||
null, null, Symbol: "\uE8C8"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// save 명령 감지
|
||||
bool saveToFile = false;
|
||||
var text = q;
|
||||
if (q.StartsWith("save ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
saveToFile = true;
|
||||
text = q[5..].Trim();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
items.Add(new LauncherItem("QR 생성할 텍스트를 입력하세요",
|
||||
"qr {텍스트} 또는 qr save {텍스트}", null, null, Symbol: "\uE8C8"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
int byteLen = System.Text.Encoding.UTF8.GetByteCount(text);
|
||||
var typeHint = Uri.TryCreate(text, UriKind.Absolute, out _) ? "URL" : "텍스트";
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
$"QR 생성: {(text.Length > 60 ? text[..60] + "…" : text)}",
|
||||
saveToFile
|
||||
? $"{typeHint} · {byteLen}바이트 · Enter: PNG 파일 저장"
|
||||
: $"{typeHint} · {byteLen}바이트 · Enter: PNG 클립보드 복사",
|
||||
null, (saveToFile ? "save" : "copy", text), Symbol: "\uE8C8"));
|
||||
|
||||
if (!saveToFile)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
"qr save ...",
|
||||
"PNG 파일로 저장하려면 qr save {텍스트} 입력",
|
||||
null, null, Symbol: "\uE74E"));
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is not (string action, string text)) return Task.CompletedTask;
|
||||
|
||||
try
|
||||
{
|
||||
byte[] png = GenerateQrPng(text);
|
||||
|
||||
if (action == "save")
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"qr_{DateTime.Now:yyyyMMdd_HHmmss}.png");
|
||||
File.WriteAllBytes(path, png);
|
||||
// 탐색기에서 파일 선택 상태로 열기
|
||||
System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{path}\"");
|
||||
NotificationService.Notify("qr", $"QR 저장: {path}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// PNG → BitmapImage → 클립보드
|
||||
using var ms = new MemoryStream(png);
|
||||
var bitmap = new BitmapImage();
|
||||
bitmap.BeginInit();
|
||||
bitmap.CacheOption = BitmapCacheOption.OnLoad;
|
||||
bitmap.StreamSource = ms;
|
||||
bitmap.EndInit();
|
||||
bitmap.Freeze();
|
||||
|
||||
Application.Current.Dispatcher.Invoke(() => Clipboard.SetImage(bitmap));
|
||||
NotificationService.Notify("qr", "QR 이미지가 클립보드에 복사되었습니다.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
NotificationService.Notify("qr", $"QR 생성 실패: {ex.Message}");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static byte[] GenerateQrPng(string text)
|
||||
{
|
||||
using var generator = new QRCodeGenerator();
|
||||
using var data = generator.CreateQrCode(text, QRCodeGenerator.ECCLevel.M);
|
||||
using var code = new PngByteQRCode(data);
|
||||
return code.GetGraphic(PixelsPerModule);
|
||||
}
|
||||
}
|
||||
127
src/AxCopilot/Handlers/QuickLinkHandler.cs
Normal file
127
src/AxCopilot/Handlers/QuickLinkHandler.cs
Normal file
@@ -0,0 +1,127 @@
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// Phase L3-4: 파라미터 퀵링크 핸들러. "ql" 예약어로 사용합니다.
|
||||
/// 예: ql maps 강남역 → "maps" 키워드 URL에 "강남역" 치환 후 열기
|
||||
/// ql jira PROJ-1234 → "jira" 키워드 URL에 티켓 번호 치환
|
||||
/// ql (목록) → 등록된 퀵링크 목록 표시
|
||||
///
|
||||
/// 퀵링크는 설정 → 일반 → 퀵링크 탭에서 등록합니다.
|
||||
/// </summary>
|
||||
public class QuickLinkHandler : IActionHandler
|
||||
{
|
||||
private readonly SettingsService _settings;
|
||||
|
||||
public string? Prefix => "ql";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"QuickLink",
|
||||
"파라미터 퀵링크 — ql [키워드] [인자]",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
public QuickLinkHandler(SettingsService settings) => _settings = settings;
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var links = _settings.Settings.QuickLinks;
|
||||
|
||||
// 등록된 퀵링크 없음
|
||||
if (links.Count == 0)
|
||||
{
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(
|
||||
[
|
||||
new LauncherItem(
|
||||
"등록된 퀵링크 없음",
|
||||
"설정 → 일반 → 퀵링크에서 추가하세요. 예: keyword=maps, url=https://map.naver.com/p/search/{0}",
|
||||
null, null, Symbol: Symbols.Globe)
|
||||
]);
|
||||
}
|
||||
|
||||
var items = new List<LauncherItem>();
|
||||
var parts = query.Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
if (parts.Length == 0)
|
||||
{
|
||||
// 쿼리 없음 — 전체 목록 표시
|
||||
foreach (var link in links)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
link.Name.Length > 0 ? link.Name : link.Keyword,
|
||||
$"ql {link.Keyword} [인자] · {link.Description} · {link.UrlTemplate}",
|
||||
null, null, Symbol: Symbols.Globe));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var keyword = parts[0].ToLowerInvariant();
|
||||
var argQuery = parts.Length > 1 ? parts[1] : "";
|
||||
|
||||
// 키워드로 정확 일치 검색
|
||||
var matched = links.Where(l => l.Keyword.ToLowerInvariant() == keyword).ToList();
|
||||
|
||||
if (matched.Count > 0 && !string.IsNullOrWhiteSpace(argQuery))
|
||||
{
|
||||
// 인자가 있으면 URL 치환 후 실행 항목 생성
|
||||
foreach (var link in matched)
|
||||
{
|
||||
var url = UrlTemplateEngine.ExpandFromQuery(link.UrlTemplate, argQuery);
|
||||
items.Add(new LauncherItem(
|
||||
$"{(link.Name.Length > 0 ? link.Name : link.Keyword)}: {argQuery}",
|
||||
url,
|
||||
null, url, Symbol: Symbols.Globe));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 키워드 퍼지 검색 (부분 일치)
|
||||
var fuzzy = links
|
||||
.Where(l => l.Keyword.Contains(keyword, StringComparison.OrdinalIgnoreCase) ||
|
||||
l.Name.Contains(keyword, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (fuzzy.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"'{keyword}'에 해당하는 퀵링크 없음",
|
||||
"설정에서 새 퀵링크를 추가하세요",
|
||||
null, null, Symbol: Symbols.Globe));
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var link in fuzzy)
|
||||
{
|
||||
var hint = UrlTemplateEngine.GetPlaceholders(link.UrlTemplate);
|
||||
var ph = hint.Count > 0 ? $" · 인자: {string.Join(", ", hint)}" : "";
|
||||
items.Add(new LauncherItem(
|
||||
$"ql {link.Keyword}{ph}",
|
||||
link.Description.Length > 0 ? link.Description : link.UrlTemplate,
|
||||
null, null, Symbol: Symbols.Globe));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is string url && !string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(
|
||||
new System.Diagnostics.ProcessStartInfo(url) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"퀵링크 열기 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
324
src/AxCopilot/Handlers/RandHandler.cs
Normal file
324
src/AxCopilot/Handlers/RandHandler.cs
Normal file
@@ -0,0 +1,324 @@
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L20-2: 랜덤 생성기 핸들러. "rand" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: rand → 사용법 목록
|
||||
/// rand → 1~100 기본 난수
|
||||
/// rand 50 → 1~50 난수
|
||||
/// rand 10 99 → 10~99 난수
|
||||
/// rand str → 랜덤 문자열 (12자, 영숫자)
|
||||
/// rand str 20 → 20자 랜덤 문자열
|
||||
/// rand str 16 alpha → 16자 알파벳만
|
||||
/// rand str 8 num → 8자 숫자만
|
||||
/// rand str 12 hex → 12자 hex 문자열
|
||||
/// rand str 16 special → 특수문자 포함
|
||||
/// rand color → 랜덤 HEX 색상
|
||||
/// rand pick 항목1 항목2 → 목록에서 무작위 선택
|
||||
/// rand dice → 주사위 1d6
|
||||
/// rand dice 2d6 → 2개 주사위
|
||||
/// rand dice 1d20 → 20면체 주사위
|
||||
/// rand coin → 동전 던지기
|
||||
/// rand shuffle a b c d → 목록 셔플
|
||||
/// rand uuid → UUID v4
|
||||
/// rand token → 보안 토큰 (32자 hex)
|
||||
/// rand pin → 6자리 PIN
|
||||
/// rand pin 4 → 4자리 PIN
|
||||
/// Enter → 결과 복사.
|
||||
/// </summary>
|
||||
public class RandHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "rand";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Rand",
|
||||
"랜덤 생성기 — 숫자·문자열·색상·주사위·UUID·토큰·PIN·셔플",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private static readonly Random _rng = Random.Shared;
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
// 기본: 1~100 난수 + 사용법
|
||||
var n = _rng.Next(1, 101);
|
||||
items.Add(new LauncherItem($"{n}",
|
||||
"1~100 랜덤 숫자 · Enter 복사", null, ("copy", n.ToString()), Symbol: "\uE8D0"));
|
||||
items.Add(new LauncherItem("사용법",
|
||||
"rand <max> / rand <min> <max> / rand str / rand color / rand dice / rand pick …",
|
||||
null, null, Symbol: "\uE8D0"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
switch (sub)
|
||||
{
|
||||
// rand str [length] [charset]
|
||||
case "str" or "string" or "문자열":
|
||||
{
|
||||
int len = 12;
|
||||
if (parts.Length >= 2 && int.TryParse(parts[1], out var l)) len = Math.Clamp(l, 1, 256);
|
||||
var charset = parts.Length >= 3 ? parts[2].ToLowerInvariant() : "alnum";
|
||||
if (parts.Length == 2 && !int.TryParse(parts[1], out _)) charset = parts[1].ToLowerInvariant();
|
||||
var cs = GetCharset(charset);
|
||||
if (string.IsNullOrEmpty(cs)) { items.Add(ErrorItem("charset: alpha/num/alnum/hex/special")); break; }
|
||||
var result = RandStr(len, cs);
|
||||
items.Add(new LauncherItem(result, $"{len}자 랜덤 문자열 ({charset}) · Enter 복사",
|
||||
null, ("copy", result), Symbol: "\uE8D0"));
|
||||
// 다른 자릿수 미리 생성
|
||||
foreach (var altLen in new[] { 8, 16, 32 }.Where(x => x != len))
|
||||
items.Add(new LauncherItem(RandStr(altLen, cs), $"{altLen}자 ({charset})",
|
||||
null, ("copy", RandStr(altLen, cs)), Symbol: "\uE8D0"));
|
||||
break;
|
||||
}
|
||||
|
||||
// rand color
|
||||
case "color" or "색상" or "colour":
|
||||
{
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var r = _rng.Next(256); var g = _rng.Next(256); var b = _rng.Next(256);
|
||||
var hex = $"#{r:X2}{g:X2}{b:X2}";
|
||||
var rgb = $"rgb({r}, {g}, {b})";
|
||||
var hsl = RgbToHsl(r, g, b);
|
||||
items.Add(new LauncherItem(hex, $"{rgb} {hsl} · Enter 복사",
|
||||
null, ("copy", hex), Symbol: "\uE8D0"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// rand dice [NdS]
|
||||
case "dice" or "주사위":
|
||||
{
|
||||
var spec = parts.Length >= 2 ? parts[1].ToLowerInvariant() : "1d6";
|
||||
if (!TryParseDice(spec, out var dCount, out var dSides))
|
||||
{ items.Add(ErrorItem("형식: 1d6 / 2d10 / 1d20")); break; }
|
||||
var rolls = Enumerable.Range(0, dCount).Select(_ => _rng.Next(1, dSides + 1)).ToList();
|
||||
var total = rolls.Sum();
|
||||
var detail = string.Join(" + ", rolls);
|
||||
items.Add(new LauncherItem($"{dCount}d{dSides} → {total}",
|
||||
$"각 주사위: {detail} · Enter 복사", null, ("copy", total.ToString()), Symbol: "\uE8D0"));
|
||||
if (dCount > 1)
|
||||
{
|
||||
items.Add(CopyItem("합계", total.ToString()));
|
||||
items.Add(CopyItem("상세", detail));
|
||||
items.Add(CopyItem("최솟값", rolls.Min().ToString()));
|
||||
items.Add(CopyItem("최댓값", rolls.Max().ToString()));
|
||||
}
|
||||
// 다시 굴리기 미리보기
|
||||
items.Add(new LauncherItem("── 다시 굴리기 (미리보기) ──", "", null, null, Symbol: "\uE8D0"));
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var r2 = Enumerable.Range(0, dCount).Select(_ => _rng.Next(1, dSides + 1)).ToList();
|
||||
items.Add(new LauncherItem($"{r2.Sum()}", string.Join(" + ", r2),
|
||||
null, ("copy", r2.Sum().ToString()), Symbol: "\uE8D0"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// rand coin
|
||||
case "coin" or "동전":
|
||||
{
|
||||
var result = _rng.Next(2) == 0 ? "앞면 (Head)" : "뒷면 (Tail)";
|
||||
items.Add(new LauncherItem(result, "동전 던지기 · Enter 복사",
|
||||
null, ("copy", result), Symbol: "\uE8D0"));
|
||||
// 연속 5회
|
||||
items.Add(new LauncherItem("── 연속 5회 ──", "", null, null, Symbol: "\uE8D0"));
|
||||
for (int i = 0; i < 5; i++)
|
||||
items.Add(new LauncherItem(_rng.Next(2) == 0 ? "앞면 ⬤" : "뒷면 ○",
|
||||
$"#{i + 1}", null, null, Symbol: "\uE8D0"));
|
||||
break;
|
||||
}
|
||||
|
||||
// rand pick item1 item2 ...
|
||||
case "pick" or "선택":
|
||||
{
|
||||
if (parts.Length < 2) { items.Add(ErrorItem("예: rand pick 치킨 피자 짜장면")); break; }
|
||||
var pool = parts[1..];
|
||||
var picked = pool[_rng.Next(pool.Length)];
|
||||
items.Add(new LauncherItem(picked,
|
||||
$"{pool.Length}개 중 선택 · Enter 복사",
|
||||
null, ("copy", picked), Symbol: "\uE8D0"));
|
||||
items.Add(new LauncherItem("── 다시 선택 ──", "", null, null, Symbol: "\uE8D0"));
|
||||
for (int i = 0; i < Math.Min(3, pool.Length); i++)
|
||||
items.Add(new LauncherItem(pool[_rng.Next(pool.Length)],
|
||||
$"후보 {i + 1}", null, null, Symbol: "\uE8D0"));
|
||||
break;
|
||||
}
|
||||
|
||||
// rand shuffle item1 item2 ...
|
||||
case "shuffle" or "섞기":
|
||||
{
|
||||
if (parts.Length < 2) { items.Add(ErrorItem("예: rand shuffle a b c d e")); break; }
|
||||
var list = parts[1..].ToList();
|
||||
for (int i = list.Count - 1; i > 0; i--)
|
||||
{
|
||||
var j = _rng.Next(i + 1);
|
||||
(list[i], list[j]) = (list[j], list[i]);
|
||||
}
|
||||
var joined = string.Join(" ", list);
|
||||
items.Add(new LauncherItem(joined,
|
||||
$"{list.Count}개 셔플 · Enter 복사",
|
||||
null, ("copy", joined), Symbol: "\uE8D0"));
|
||||
items.Add(CopyItem("쉼표 구분", string.Join(", ", list)));
|
||||
for (int r = 0; r < list.Count; r++)
|
||||
items.Add(new LauncherItem($"#{r + 1} {list[r]}", "", null, null, Symbol: "\uE8D0"));
|
||||
break;
|
||||
}
|
||||
|
||||
// rand uuid
|
||||
case "uuid":
|
||||
{
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var guid = Guid.NewGuid().ToString();
|
||||
items.Add(new LauncherItem(guid, $"UUID v4 · Enter 복사",
|
||||
null, ("copy", guid), Symbol: "\uE8D0"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// rand token
|
||||
case "token" or "secret" or "key":
|
||||
{
|
||||
int tLen = 32;
|
||||
if (parts.Length >= 2 && int.TryParse(parts[1], out var tl)) tLen = Math.Clamp(tl, 8, 128);
|
||||
var tokenBytes = new byte[tLen];
|
||||
System.Security.Cryptography.RandomNumberGenerator.Fill(tokenBytes);
|
||||
var tokenHex = BitConverter.ToString(tokenBytes).Replace("-", "").ToLowerInvariant();
|
||||
var tokenB64 = Convert.ToBase64String(tokenBytes);
|
||||
items.Add(new LauncherItem(tokenHex, $"{tLen}바이트 보안 토큰 (hex) · Enter 복사",
|
||||
null, ("copy", tokenHex), Symbol: "\uE8D0"));
|
||||
items.Add(CopyItem("Hex", tokenHex));
|
||||
items.Add(CopyItem("Base64", tokenB64));
|
||||
break;
|
||||
}
|
||||
|
||||
// rand pin [length]
|
||||
case "pin":
|
||||
{
|
||||
int pLen = 6;
|
||||
if (parts.Length >= 2 && int.TryParse(parts[1], out var pl)) pLen = Math.Clamp(pl, 4, 12);
|
||||
var pin = string.Concat(Enumerable.Range(0, pLen).Select(_ => _rng.Next(10)));
|
||||
items.Add(new LauncherItem(pin, $"{pLen}자리 PIN · Enter 복사",
|
||||
null, ("copy", pin), Symbol: "\uE8D0"));
|
||||
items.Add(new LauncherItem("── 추가 PIN ──", "", null, null, Symbol: "\uE8D0"));
|
||||
for (int i = 0; i < 4; i++)
|
||||
items.Add(new LauncherItem(
|
||||
string.Concat(Enumerable.Range(0, pLen).Select(_ => _rng.Next(10))),
|
||||
$"{pLen}자리", null, null, Symbol: "\uE8D0"));
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
{
|
||||
// rand <max> 또는 rand <min> <max>
|
||||
if (int.TryParse(sub, out var max))
|
||||
{
|
||||
int min = 1;
|
||||
if (parts.Length >= 2 && int.TryParse(parts[1], out var mx)) { min = max; max = mx; }
|
||||
if (min > max) (min, max) = (max, min);
|
||||
var n = _rng.Next(min, max + 1);
|
||||
items.Add(new LauncherItem($"{n}",
|
||||
$"{min}~{max} 랜덤 숫자 · Enter 복사",
|
||||
null, ("copy", n.ToString()), Symbol: "\uE8D0"));
|
||||
items.Add(new LauncherItem("── 추가 결과 ──", "", null, null, Symbol: "\uE8D0"));
|
||||
for (int i = 0; i < 4; i++)
|
||||
items.Add(new LauncherItem(_rng.Next(min, max + 1).ToString(),
|
||||
$"{min}~{max}", null, null, Symbol: "\uE8D0"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"알 수 없는 서브커맨드: '{sub}'",
|
||||
"rand / rand <n> / rand str / rand color / rand dice / rand pick / rand uuid / rand token / rand pin",
|
||||
null, null, Symbol: "\uE783"));
|
||||
}
|
||||
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("Rand", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 헬퍼 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static string GetCharset(string name) => name switch
|
||||
{
|
||||
"alpha" or "알파" => "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||
"num" or "숫자" => "0123456789",
|
||||
"alnum" or "영숫자" => "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
|
||||
"hex" => "0123456789abcdef",
|
||||
"lower" => "abcdefghijklmnopqrstuvwxyz",
|
||||
"upper" => "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||
"special" => "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*",
|
||||
_ => "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
};
|
||||
|
||||
private static string RandStr(int len, string charset)
|
||||
{
|
||||
var sb = new StringBuilder(len);
|
||||
for (int i = 0; i < len; i++) sb.Append(charset[_rng.Next(charset.Length)]);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static bool TryParseDice(string s, out int count, out int sides)
|
||||
{
|
||||
count = sides = 0;
|
||||
var d = s.IndexOf('d');
|
||||
if (d < 0) return false;
|
||||
var left = d == 0 ? "1" : s[..d];
|
||||
var right = s[(d + 1)..];
|
||||
return int.TryParse(left, out count) && count >= 1 && count <= 100 &&
|
||||
int.TryParse(right, out sides) && sides >= 2 && sides <= 10000;
|
||||
}
|
||||
|
||||
private static string RgbToHsl(int r, int g, int b)
|
||||
{
|
||||
var rf = r / 255.0; var gf = g / 255.0; var bf = b / 255.0;
|
||||
var max = Math.Max(rf, Math.Max(gf, bf));
|
||||
var min = Math.Min(rf, Math.Min(gf, bf));
|
||||
var l = (max + min) / 2;
|
||||
if (max == min) return $"hsl(0, 0%, {l:P0})";
|
||||
var d = max - min;
|
||||
var s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
var h = max == rf ? (gf - bf) / d + (gf < bf ? 6 : 0)
|
||||
: max == gf ? (bf - rf) / d + 2
|
||||
: (rf - gf) / d + 4;
|
||||
h /= 6;
|
||||
return $"hsl({h * 360:F0}, {s:P0}, {l:P0})";
|
||||
}
|
||||
|
||||
private static LauncherItem CopyItem(string label, string value) =>
|
||||
new(label, value, null, ("copy", value), Symbol: "\uE8D0");
|
||||
|
||||
private static LauncherItem ErrorItem(string msg) =>
|
||||
new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783");
|
||||
}
|
||||
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() ?? "",
|
||||
};
|
||||
}
|
||||
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 { /* 비핵심 */ }
|
||||
}
|
||||
}
|
||||
288
src/AxCopilot/Handlers/RemindHandler.cs
Normal file
288
src/AxCopilot/Handlers/RemindHandler.cs
Normal file
@@ -0,0 +1,288 @@
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L25-2: 오늘 특정 시각 알림. "remind" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: remind → 오늘 등록된 알림 목록
|
||||
/// remind 15:00 보고서 제출 → 오후 3시에 알림
|
||||
/// remind 오후3시 팀장보고 → 한국어 시각 파싱
|
||||
/// remind del 1 → 1번 알림 취소
|
||||
/// remind clear → 지난 알림 정리
|
||||
/// </summary>
|
||||
public class RemindHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "remind";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"알림",
|
||||
"오늘 특정 시각 알림 — 시간 설정 · 취소 · 목록",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private sealed class RemindEntry
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public DateTime Time { get; set; }
|
||||
public string Message { get; set; } = "";
|
||||
public bool Fired { get; set; }
|
||||
public CancellationTokenSource? Cts { get; set; }
|
||||
}
|
||||
|
||||
private static readonly List<RemindEntry> _reminders = [];
|
||||
private static readonly Dictionary<int, CancellationTokenSource> _ctsDic = [];
|
||||
private static readonly object _lock = new();
|
||||
private static int _nextId = 1;
|
||||
|
||||
// 외부(TodayHandler)에서 오늘 알림 목록 조회용
|
||||
internal static List<(DateTime Time, string Message)> GetTodayReminders()
|
||||
{
|
||||
var today = DateTime.Today;
|
||||
lock (_lock)
|
||||
{
|
||||
return _reminders
|
||||
.Where(r => !r.Fired && r.Time.Date == today)
|
||||
.Select(r => (r.Time, r.Message))
|
||||
.OrderBy(r => r.Time)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
List<RemindEntry> pending;
|
||||
lock (_lock)
|
||||
pending = _reminders.Where(r => !r.Fired).OrderBy(r => r.Time).ToList();
|
||||
|
||||
if (pending.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("등록된 알림 없음",
|
||||
"remind HH:mm 메시지 또는 remind 오후3시 메시지",
|
||||
null, null, Symbol: "\uE787"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"알림 {pending.Count}개",
|
||||
"remind <시각> <메시지> / remind del <번호>",
|
||||
null, null, Symbol: "\uE787"));
|
||||
foreach (var r in pending)
|
||||
{
|
||||
var left = r.Time > DateTime.Now
|
||||
? FormatLeft(r.Time - DateTime.Now)
|
||||
: "시간 지남";
|
||||
items.Add(new LauncherItem(
|
||||
$"#{r.Id} {r.Time:HH:mm} — {r.Message}",
|
||||
$"남은 시간: {left} · Enter로 취소",
|
||||
null, ("del", r.Id.ToString()), Symbol: "\uE787"));
|
||||
}
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
// del 명령
|
||||
if (sub is "del" or "cancel" or "취소")
|
||||
{
|
||||
var idStr = parts.Length > 1 ? parts[1].Trim() : "";
|
||||
if (!int.TryParse(idStr, out var delId))
|
||||
{
|
||||
items.Add(new LauncherItem("번호를 입력하세요",
|
||||
"예: remind del 1",
|
||||
null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
RemindEntry? target;
|
||||
lock (_lock) target = _reminders.FirstOrDefault(r => r.Id == delId);
|
||||
if (target == null)
|
||||
items.Add(new LauncherItem($"#{delId} 알림을 찾을 수 없습니다", "",
|
||||
null, null, Symbol: "\uE783"));
|
||||
else
|
||||
items.Add(new LauncherItem(
|
||||
$"#{delId} 알림 취소: {target.Time:HH:mm} {target.Message}",
|
||||
"Enter로 취소합니다",
|
||||
null, ("del", delId.ToString()), Symbol: "\uE787"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// clear 명령
|
||||
if (sub == "clear")
|
||||
{
|
||||
items.Add(new LauncherItem("지난 알림 정리",
|
||||
"발화된 알림을 목록에서 제거합니다 · Enter 실행",
|
||||
null, ("clear", ""), Symbol: "\uE787"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 시각 파싱 시도
|
||||
var (timeStr, msgStr) = SplitTimeAndMessage(q);
|
||||
if (!string.IsNullOrWhiteSpace(timeStr) && TryParseTime(timeStr, out var alarmTime))
|
||||
{
|
||||
var leftText = alarmTime > DateTime.Now
|
||||
? FormatLeft(alarmTime - DateTime.Now)
|
||||
: "이미 지난 시각 (내일로 설정됩니다)";
|
||||
var message = string.IsNullOrWhiteSpace(msgStr) ? "(메시지 없음)" : msgStr;
|
||||
var encoded = $"{alarmTime:O}|{message}";
|
||||
items.Add(new LauncherItem(
|
||||
$"알림 설정: {alarmTime:HH:mm} — {message}",
|
||||
$"{leftText} · Enter로 설정",
|
||||
null, ("set", encoded), Symbol: "\uE787"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"시각 파싱 실패: '{q}'",
|
||||
"예: remind 15:00 보고서 제출 / remind 오후3시 팀장보고",
|
||||
null, null, Symbol: "\uE783"));
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
switch (item.Data)
|
||||
{
|
||||
case ("set", string encoded):
|
||||
{
|
||||
var idx = encoded.IndexOf('|');
|
||||
if (idx < 0) break;
|
||||
var timeIso = encoded[..idx];
|
||||
var message = encoded[(idx + 1)..];
|
||||
if (!DateTime.TryParse(timeIso, null,
|
||||
System.Globalization.DateTimeStyles.RoundtripKind, out var alarmTime))
|
||||
break;
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
int id;
|
||||
lock (_lock)
|
||||
{
|
||||
id = _nextId++;
|
||||
var entry = new RemindEntry { Id = id, Time = alarmTime, Message = message, Cts = cts };
|
||||
_reminders.Add(entry);
|
||||
_ctsDic[id] = cts;
|
||||
}
|
||||
NotificationService.Notify("알림", $"#{id} {alarmTime:HH:mm} {message} 설정됨");
|
||||
_ = RunReminderAsync(id, alarmTime, message, cts.Token);
|
||||
break;
|
||||
}
|
||||
|
||||
case ("del", string idStr) when int.TryParse(idStr, out var delId):
|
||||
{
|
||||
RemindEntry? entry;
|
||||
lock (_lock)
|
||||
{
|
||||
entry = _reminders.FirstOrDefault(r => r.Id == delId);
|
||||
if (entry != null) _reminders.Remove(entry);
|
||||
if (_ctsDic.TryGetValue(delId, out var c)) { c.Cancel(); _ctsDic.Remove(delId); }
|
||||
}
|
||||
if (entry != null)
|
||||
NotificationService.Notify("알림", $"#{delId} 알림 취소됨");
|
||||
break;
|
||||
}
|
||||
|
||||
case ("clear", _):
|
||||
{
|
||||
int cleared;
|
||||
lock (_lock)
|
||||
{
|
||||
cleared = _reminders.RemoveAll(r => r.Fired);
|
||||
}
|
||||
NotificationService.Notify("알림", $"지난 알림 {cleared}개 정리됨");
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 알림 실행 ────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task RunReminderAsync(int id, DateTime at, string message, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var delay = at - DateTime.Now;
|
||||
if (delay > TimeSpan.Zero)
|
||||
await Task.Delay(delay, token);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
var entry = _reminders.FirstOrDefault(r => r.Id == id);
|
||||
if (entry != null) entry.Fired = true;
|
||||
_ctsDic.Remove(id);
|
||||
}
|
||||
NotificationService.Notify("⏰ 알림", $"{at:HH:mm} {message}");
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
// ── 파싱 헬퍼 ────────────────────────────────────────────────────────────
|
||||
|
||||
private static (string timeStr, string message) SplitTimeAndMessage(string q)
|
||||
{
|
||||
var sp = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (sp.Length == 0) return ("", "");
|
||||
return (sp[0], sp.Length > 1 ? sp[1] : "");
|
||||
}
|
||||
|
||||
private static bool TryParseTime(string s, out DateTime result)
|
||||
{
|
||||
result = DateTime.MinValue;
|
||||
s = s.Trim();
|
||||
|
||||
// HH:mm 또는 H:mm
|
||||
if (System.Text.RegularExpressions.Regex.IsMatch(s, @"^\d{1,2}:\d{2}$"))
|
||||
{
|
||||
var colonParts = s.Split(':');
|
||||
if (int.TryParse(colonParts[0], out var h) &&
|
||||
int.TryParse(colonParts[1], out var m) &&
|
||||
h is >= 0 and <= 23 && m is >= 0 and <= 59)
|
||||
{
|
||||
result = DateTime.Today.AddHours(h).AddMinutes(m);
|
||||
if (result <= DateTime.Now) result = result.AddDays(1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 한국어 시각: 오전N시M분, 오후N시M분, N시M분, 오전N시, 오후N시, N시
|
||||
var korMatch = System.Text.RegularExpressions.Regex.Match(s,
|
||||
@"^(오전|오후)?(\d{1,2})시((\d{1,2})분)?$");
|
||||
if (korMatch.Success)
|
||||
{
|
||||
var meridiem = korMatch.Groups[1].Value;
|
||||
if (!int.TryParse(korMatch.Groups[2].Value, out var h)) return false;
|
||||
var minStr = korMatch.Groups[4].Value;
|
||||
var m = string.IsNullOrEmpty(minStr) ? 0 : int.TryParse(minStr, out var mp) ? mp : 0;
|
||||
|
||||
if (meridiem == "오후" && h < 12) h += 12;
|
||||
if (meridiem == "오전" && h == 12) h = 0;
|
||||
|
||||
if (h is < 0 or > 23 || m is < 0 or > 59) return false;
|
||||
|
||||
result = DateTime.Today.AddHours(h).AddMinutes(m);
|
||||
if (result <= DateTime.Now) result = result.AddDays(1);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string FormatLeft(TimeSpan ts)
|
||||
{
|
||||
if (ts.TotalSeconds < 60)
|
||||
return $"{(int)ts.TotalSeconds}초 후";
|
||||
if (ts.TotalMinutes < 60)
|
||||
return $"{(int)ts.TotalMinutes}분 후";
|
||||
var h = (int)ts.TotalHours;
|
||||
var m = (int)(ts.TotalMinutes - h * 60);
|
||||
return m > 0 ? $"{h}시간 {m}분 후" : $"{h}시간 후";
|
||||
}
|
||||
}
|
||||
171
src/AxCopilot/Handlers/ScheduleHandler.cs
Normal file
171
src/AxCopilot/Handlers/ScheduleHandler.cs
Normal file
@@ -0,0 +1,171 @@
|
||||
using System.IO;
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L5-6: 자동화 스케줄 핸들러. "sched" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: sched → 등록된 스케줄 목록
|
||||
/// sched 이름 → 이름으로 필터
|
||||
/// sched new → 새 스케줄 편집기 열기
|
||||
/// sched edit 이름 → 기존 스케줄 편집
|
||||
/// sched del 이름 → 스케줄 삭제
|
||||
/// sched toggle 이름 → 활성/비활성 전환 (Enter)
|
||||
/// </summary>
|
||||
public class ScheduleHandler : IActionHandler
|
||||
{
|
||||
private readonly SettingsService _settings;
|
||||
|
||||
public ScheduleHandler(SettingsService settings) { _settings = settings; }
|
||||
|
||||
public string? Prefix => "sched";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Scheduler",
|
||||
"자동화 스케줄 — sched",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
// ─── 항목 목록 ──────────────────────────────────────────────────────────
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var parts = q.Split(' ', 2, StringSplitOptions.TrimEntries);
|
||||
var cmd = parts.Length > 0 ? parts[0].ToLowerInvariant() : "";
|
||||
|
||||
// "new" — 새 스케줄
|
||||
if (cmd == "new")
|
||||
{
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
|
||||
{
|
||||
new LauncherItem("새 스케줄 만들기",
|
||||
"편집기에서 트리거 시각과 실행 액션을 설정합니다",
|
||||
null, "__new__", Symbol: "\uE710")
|
||||
});
|
||||
}
|
||||
|
||||
// "edit 이름"
|
||||
if (cmd == "edit" && parts.Length > 1)
|
||||
{
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
|
||||
{
|
||||
new LauncherItem($"'{parts[1]}' 스케줄 편집", "편집기 열기",
|
||||
null, $"__edit__{parts[1]}", Symbol: "\uE70F")
|
||||
});
|
||||
}
|
||||
|
||||
// "del 이름" or "delete 이름"
|
||||
if ((cmd == "del" || cmd == "delete") && parts.Length > 1)
|
||||
{
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
|
||||
{
|
||||
new LauncherItem($"'{parts[1]}' 스케줄 삭제",
|
||||
"Enter로 삭제 확인",
|
||||
null, $"__del__{parts[1]}", Symbol: Symbols.Delete)
|
||||
});
|
||||
}
|
||||
|
||||
// 목록 표시
|
||||
var schedules = _settings.Settings.Schedules;
|
||||
var filter = q.ToLowerInvariant();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
foreach (var s in schedules)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(filter) &&
|
||||
!s.Name.Contains(filter, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var nextRun = SchedulerService.ComputeNextRun(s);
|
||||
var nextStr = nextRun.HasValue ? nextRun.Value.ToString("MM/dd HH:mm") : "─";
|
||||
var trigger = SchedulerService.TriggerLabel(s);
|
||||
var symbol = s.Enabled ? "\uE916" : "\uE8D8"; // 타이머 / 멈춤
|
||||
|
||||
var actionIcon = s.ActionType == "notification" ? "🔔" : "▶";
|
||||
var actionName = s.ActionType == "notification"
|
||||
? s.ActionTarget
|
||||
: Path.GetFileNameWithoutExtension(s.ActionTarget);
|
||||
|
||||
var subtitle = s.Enabled
|
||||
? $"{trigger} {s.TriggerTime} · {actionIcon} {actionName} · 다음: {nextStr}"
|
||||
: $"[비활성] {trigger} {s.TriggerTime} · {actionIcon} {actionName}";
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
s.Name, subtitle, null, s, Symbol: symbol));
|
||||
}
|
||||
|
||||
if (items.Count == 0 && string.IsNullOrEmpty(filter))
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
"등록된 스케줄 없음",
|
||||
"'sched new'로 자동화 스케줄을 추가하세요",
|
||||
null, null, Symbol: Symbols.Info));
|
||||
}
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
"새 스케줄 만들기",
|
||||
"sched new · 시각·요일 기반 앱 실행 / 알림 자동화",
|
||||
null, "__new__", Symbol: "\uE710"));
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ─── 실행 ─────────────────────────────────────────────────────────────
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is string s)
|
||||
{
|
||||
if (s == "__new__")
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
var win = new Views.ScheduleEditorWindow(null, _settings);
|
||||
win.Show();
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (s.StartsWith("__edit__"))
|
||||
{
|
||||
var name = s["__edit__".Length..];
|
||||
var entry = _settings.Settings.Schedules
|
||||
.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
var win = new Views.ScheduleEditorWindow(entry, _settings);
|
||||
win.Show();
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (s.StartsWith("__del__"))
|
||||
{
|
||||
var name = s["__del__".Length..];
|
||||
var entry = _settings.Settings.Schedules
|
||||
.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
if (entry != null)
|
||||
{
|
||||
_settings.Settings.Schedules.Remove(entry);
|
||||
_settings.Save();
|
||||
NotificationService.Notify("AX Copilot", $"스케줄 '{name}' 삭제됨");
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
// 스케줄 항목 Enter → 활성/비활성 토글
|
||||
if (item.Data is ScheduleEntry se)
|
||||
{
|
||||
se.Enabled = !se.Enabled;
|
||||
_settings.Save();
|
||||
var state = se.Enabled ? "활성화" : "비활성화";
|
||||
NotificationService.Notify("AX Copilot", $"스케줄 '{se.Name}' {state}됨");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
301
src/AxCopilot/Handlers/SessionHandler.cs
Normal file
301
src/AxCopilot/Handlers/SessionHandler.cs
Normal file
@@ -0,0 +1,301 @@
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L5-4: 앱 세션 스냅 핸들러. "session" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: session → 저장된 세션 목록
|
||||
/// session 개발환경 → 해당 세션 실행 (앱 + 스냅 레이아웃 적용)
|
||||
/// session new 이름 → 새 세션 편집기 열기
|
||||
/// session edit 이름 → 기존 세션 편집기 열기
|
||||
/// session del 이름 → 세션 삭제
|
||||
///
|
||||
/// 세션 = [앱 경로 + 스냅 위치] 목록. 한 번에 모든 앱을 지정 레이아웃으로 실행합니다.
|
||||
/// </summary>
|
||||
public class SessionHandler : IActionHandler
|
||||
{
|
||||
private readonly SettingsService _settings;
|
||||
|
||||
public SessionHandler(SettingsService settings) { _settings = settings; }
|
||||
|
||||
public string? Prefix => "session";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"AppSession",
|
||||
"앱 세션 스냅 — session",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
// ─── 항목 목록 ──────────────────────────────────────────────────────────
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var parts = q.Split(' ', 2, StringSplitOptions.TrimEntries);
|
||||
var cmd = parts.Length > 0 ? parts[0].ToLowerInvariant() : "";
|
||||
|
||||
// "new [이름]" — 새 세션 만들기
|
||||
if (cmd == "new")
|
||||
{
|
||||
var name = parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1]) ? parts[1] : "새 세션";
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
|
||||
{
|
||||
new LauncherItem($"'{name}' 세션 만들기",
|
||||
"편집기에서 앱 목록과 스냅 레이아웃을 설정합니다",
|
||||
null, $"__new__{name}", Symbol: "\uE710")
|
||||
});
|
||||
}
|
||||
|
||||
// "edit 이름" — 기존 세션 편집
|
||||
if (cmd == "edit" && parts.Length > 1)
|
||||
{
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
|
||||
{
|
||||
new LauncherItem($"'{parts[1]}' 세션 편집",
|
||||
"편집기 열기",
|
||||
null, $"__edit__{parts[1]}", Symbol: "\uE70F")
|
||||
});
|
||||
}
|
||||
|
||||
// "del 이름" or "delete 이름" — 세션 삭제
|
||||
if ((cmd == "del" || cmd == "delete") && parts.Length > 1)
|
||||
{
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
|
||||
{
|
||||
new LauncherItem($"'{parts[1]}' 세션 삭제",
|
||||
"Enter로 삭제 확인 (되돌릴 수 없습니다)",
|
||||
null, $"__del__{parts[1]}", Symbol: Symbols.Delete)
|
||||
});
|
||||
}
|
||||
|
||||
// 세션 목록 (필터 적용)
|
||||
var sessions = _settings.Settings.AppSessions;
|
||||
var filter = q.ToLowerInvariant();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
foreach (var s in sessions)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(filter) &&
|
||||
!s.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) &&
|
||||
!s.Description.Contains(filter, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var appNames = string.Join(", ",
|
||||
s.Apps.Take(3).Select(a =>
|
||||
string.IsNullOrEmpty(a.Label)
|
||||
? Path.GetFileNameWithoutExtension(a.Path)
|
||||
: a.Label));
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
s.Name,
|
||||
$"{s.Apps.Count}개 앱 · {appNames}",
|
||||
null, s,
|
||||
Symbol: "\uE8A1")); // 창 레이아웃 아이콘
|
||||
}
|
||||
|
||||
if (items.Count == 0 && string.IsNullOrEmpty(filter))
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
"저장된 세션 없음",
|
||||
"'session new 이름'으로 앱 세션을 만드세요",
|
||||
null, null,
|
||||
Symbol: Symbols.Info));
|
||||
}
|
||||
|
||||
// 새 세션 만들기 (항상 표시)
|
||||
items.Add(new LauncherItem(
|
||||
"새 세션 만들기",
|
||||
"session new [이름] · 앱 목록 + 스냅 레이아웃 지정",
|
||||
null, "__new__새 세션",
|
||||
Symbol: "\uE710"));
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ─── 실행 ─────────────────────────────────────────────────────────────
|
||||
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is string s)
|
||||
{
|
||||
if (s.StartsWith("__new__"))
|
||||
{
|
||||
var name = s["__new__".Length..];
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
var win = new Views.SessionEditorWindow(null, _settings);
|
||||
win.InitialName = name;
|
||||
win.Show();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (s.StartsWith("__edit__"))
|
||||
{
|
||||
var name = s["__edit__".Length..];
|
||||
var session = _settings.Settings.AppSessions
|
||||
.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
var win = new Views.SessionEditorWindow(session, _settings);
|
||||
win.Show();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (s.StartsWith("__del__"))
|
||||
{
|
||||
var name = s["__del__".Length..];
|
||||
var session = _settings.Settings.AppSessions
|
||||
.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
if (session != null)
|
||||
{
|
||||
_settings.Settings.AppSessions.Remove(session);
|
||||
_settings.Save();
|
||||
NotificationService.Notify("AX Copilot", $"세션 '{name}' 삭제됨");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (item.Data is Models.AppSession appSession)
|
||||
await LaunchSessionAsync(appSession, ct);
|
||||
}
|
||||
|
||||
// ─── 세션 실행 로직 ───────────────────────────────────────────────────
|
||||
private static async Task LaunchSessionAsync(Models.AppSession session, CancellationToken ct)
|
||||
{
|
||||
NotificationService.Notify("AX Copilot", $"'{session.Name}' 세션 시작...");
|
||||
LogService.Info($"세션 실행 시작: {session.Name} ({session.Apps.Count}개 앱)");
|
||||
|
||||
int launched = 0, failed = 0;
|
||||
|
||||
foreach (var app in session.Apps)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (app.DelayMs > 0)
|
||||
await Task.Delay(app.DelayMs, ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(app.Path)) continue;
|
||||
|
||||
System.Diagnostics.Process? proc = null;
|
||||
try
|
||||
{
|
||||
proc = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = app.Path,
|
||||
Arguments = app.Arguments ?? "",
|
||||
UseShellExecute = true
|
||||
});
|
||||
launched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"세션 앱 실행 실패: {app.Path} — {ex.Message}");
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 스냅 위치 없거나 none → 그냥 실행
|
||||
if (proc == null || string.IsNullOrEmpty(app.SnapPosition) || app.SnapPosition == "none")
|
||||
continue;
|
||||
|
||||
// 창이 나타날 때까지 대기 (최대 6초)
|
||||
var hWnd = IntPtr.Zero;
|
||||
var deadline = DateTime.UtcNow.AddSeconds(6);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
try { proc.Refresh(); } catch { break; }
|
||||
hWnd = proc.MainWindowHandle;
|
||||
if (hWnd != IntPtr.Zero) break;
|
||||
await Task.Delay(200, ct);
|
||||
}
|
||||
|
||||
if (hWnd == IntPtr.Zero)
|
||||
{
|
||||
LogService.Warn($"세션: 창 핸들 획득 실패 ({app.Path})");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 창이 완전히 렌더링될 시간 허용
|
||||
await Task.Delay(250, ct);
|
||||
ApplySnapToWindow(hWnd, app.SnapPosition);
|
||||
}
|
||||
|
||||
var msg = failed > 0
|
||||
? $"'{session.Name}' 실행 완료 ({launched}개 성공, {failed}개 실패)"
|
||||
: $"'{session.Name}' 실행 완료 ({launched}개 앱)";
|
||||
NotificationService.Notify("AX Copilot", msg);
|
||||
LogService.Info($"세션 실행 완료: {msg}");
|
||||
}
|
||||
|
||||
// ─── 스냅 적용 (SnapHandler와 동일한 좌표 계산) ──────────────────────
|
||||
private static void ApplySnapToWindow(IntPtr hwnd, string snapKey)
|
||||
{
|
||||
if (snapKey == "full")
|
||||
{
|
||||
ShowWindow(hwnd, SW_MAXIMIZE);
|
||||
return;
|
||||
}
|
||||
|
||||
var hMonitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
|
||||
var mi = new MONITORINFO { cbSize = Marshal.SizeOf<MONITORINFO>() };
|
||||
if (!GetMonitorInfo(hMonitor, ref mi)) return;
|
||||
|
||||
var w = mi.rcWork;
|
||||
int mw = w.right - w.left;
|
||||
int mh = w.bottom - w.top;
|
||||
int mx = w.left;
|
||||
int my = w.top;
|
||||
|
||||
var (x, y, cw, ch) = snapKey switch
|
||||
{
|
||||
"left" => (mx, my, mw / 2, mh),
|
||||
"right" => (mx + mw / 2, my, mw / 2, mh),
|
||||
"top" => (mx, my, mw, mh / 2),
|
||||
"bottom" => (mx, my + mh / 2, mw, mh / 2),
|
||||
"tl" => (mx, my, mw / 2, mh / 2),
|
||||
"tr" => (mx + mw / 2, my, mw / 2, mh / 2),
|
||||
"bl" => (mx, my + mh / 2, mw / 2, mh / 2),
|
||||
"br" => (mx + mw / 2, my + mh / 2, mw / 2, mh / 2),
|
||||
"third-l" => (mx, my, mw / 3, mh),
|
||||
"third-c" => (mx + mw / 3, my, mw / 3, mh),
|
||||
"third-r" => (mx + mw * 2 / 3,my, mw / 3, mh),
|
||||
"two3-l" => (mx, my, mw * 2 / 3, mh),
|
||||
"two3-r" => (mx + mw / 3, my, mw * 2 / 3, mh),
|
||||
"center" => (mx + mw / 10, my + mh / 10, mw * 8 / 10, mh * 8 / 10),
|
||||
_ => (mx, my, mw, mh)
|
||||
};
|
||||
|
||||
ShowWindow(hwnd, SW_RESTORE);
|
||||
SetWindowPos(hwnd, IntPtr.Zero, x, y, cw, ch, SWP_SHOWWINDOW | SWP_NOZORDER);
|
||||
}
|
||||
|
||||
// ─── P/Invoke ─────────────────────────────────────────────────────────
|
||||
private const uint SWP_SHOWWINDOW = 0x0040;
|
||||
private const uint SWP_NOZORDER = 0x0004;
|
||||
private const uint MONITOR_DEFAULTTONEAREST = 0x00000002;
|
||||
private const int SW_RESTORE = 9;
|
||||
private const int SW_MAXIMIZE = 3;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct RECT { public int left, top, right, bottom; }
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct MONITORINFO
|
||||
{
|
||||
public int cbSize;
|
||||
public RECT rcMonitor, rcWork;
|
||||
public uint dwFlags;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")] private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint uFlags);
|
||||
[DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
||||
[DllImport("user32.dll")] private static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint dwFlags);
|
||||
[DllImport("user32.dll")] private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi);
|
||||
}
|
||||
408
src/AxCopilot/Handlers/SpellHandler.cs
Normal file
408
src/AxCopilot/Handlers/SpellHandler.cs
Normal file
@@ -0,0 +1,408 @@
|
||||
using System.IO;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L24-3: 자주 틀리는 한국어 맞춤법 핸들러. "spell" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: spell → 클립보드 텍스트 맞춤법 검사
|
||||
/// spell 되 → "되/돼" 관련 오류 목록
|
||||
/// spell <단어> → 해당 단어 맞춤법 확인
|
||||
/// spell list → 전체 오류 목록 카테고리
|
||||
/// Enter → 올바른 표현 클립보드 복사
|
||||
/// </summary>
|
||||
public class SpellHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "spell";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"맞춤법",
|
||||
"자주 틀리는 한국어 맞춤법 참조 — 되/돼·안/않·혼동어·외래어 등",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private sealed record SpellEntry(string Wrong, string Correct, string Explanation, string Category);
|
||||
|
||||
private static readonly SpellEntry[] Entries =
|
||||
[
|
||||
// ── 되/돼 ────────────────────────────────────────────────────────────
|
||||
new("됬다", "됐다", "됐다(되었다의 준말)", "되/돼"),
|
||||
new("됬어", "됐어", "됐어(되었어의 준말)", "되/돼"),
|
||||
new("됬나요", "됐나요", "됐나요(되었나요의 준말)", "되/돼"),
|
||||
new("됬습니다", "됐습니다", "됐습니다(되었습니다의 준말)", "되/돼"),
|
||||
new("돼었다", "됐다", "됐다(되었다의 준말)", "되/돼"),
|
||||
new("안됬어", "안 됐어", "됐다(되었다의 준말), 안은 띄어씀", "되/돼"),
|
||||
new("어떻게됬나요", "어떻게 됐나요","됐나요(되었나요의 준말)", "되/돼"),
|
||||
new("잘됬다", "잘됐다", "잘됐다(잘 되었다의 준말)", "되/돼"),
|
||||
new("해됬다", "해 됐다", "됐다(되었다의 준말)", "되/돼"),
|
||||
new("이렇게됬다", "이렇게 됐다", "됐다(되었다의 준말), 띄어씀 주의", "되/돼"),
|
||||
|
||||
// ── 안/않 ────────────────────────────────────────────────────────────
|
||||
new("안되다", "안 되다", "'안'은 부사이므로 띄어씀", "안/않"),
|
||||
new("안하다", "안 하다", "'안'은 부사이므로 띄어씀", "안/않"),
|
||||
new("않되다", "안 되다", "'않다'는 '아니하다'의 준말로 용언", "안/않"),
|
||||
new("하지않다", "하지 않다", "'않다'는 용언, 앞 단어와 띄어씀", "안/않"),
|
||||
new("하지않고", "하지 않고", "'않고'는 용언, 앞 단어와 띄어씀", "안/않"),
|
||||
new("하지않아", "하지 않아", "'않아'는 용언, 앞 단어와 띄어씀", "안/않"),
|
||||
new("되지않다", "되지 않다", "'않다'는 용언, 앞 단어와 띄어씀", "안/않"),
|
||||
new("않됩니다", "안 됩니다", "'않다'는 '아니하다', '안'은 부사", "안/않"),
|
||||
|
||||
// ── 혼동어 ──────────────────────────────────────────────────────────
|
||||
new("로서/로써", "로서(자격), 로써(수단)", "의미 구분: 학생으로서(자격), 말로써(수단)", "혼동어"),
|
||||
new("낫다/낳다", "낫다(회복), 낳다(출산)", "의미 구분: 병이 낫다 / 아이를 낳다", "혼동어"),
|
||||
new("맞히다/맞추다","맞히다(정답), 맞추다(조합)","의미 구분: 문제를 맞히다 / 퍼즐을 맞추다", "혼동어"),
|
||||
new("반드시/반듯이","반드시(꼭), 반듯이(곧게)", "의미 구분: 반드시 와라 / 반듯이 서라", "혼동어"),
|
||||
new("부치다/붙이다","부치다(편지), 붙이다(접착)","의미 구분: 편지를 부치다 / 우표를 붙이다", "혼동어"),
|
||||
new("이따가/있다가","이따가(나중에), 있다가(머물다가)","의미 구분: 이따가 와라 / 집에 있다가 나와", "혼동어"),
|
||||
new("웬/왠", "웬(어떤), 왠지(왜인지)", "의미 구분: 웬 일이니 / 왠지 모르게", "혼동어"),
|
||||
new("어떻게/어떡해","어떻게(방법), 어떡해(어떻게 해)","의미 구분: 어떻게 해야 하나 / 이걸 어떡해", "혼동어"),
|
||||
new("바라다/바래다","바라다(희망), 바래다(색이 변함)","의미 구분: 합격을 바란다 / 색이 바랬다", "혼동어"),
|
||||
new("~던지/~든지", "~던지(과거경험), ~든지(선택)","의미 구분: 얼마나 좋았던지 / 뭐든지 해라", "혼동어"),
|
||||
new("틀리다/다르다","틀리다(오답), 다르다(차이)", "의미 구분: 답이 틀렸다 / 나와 다르다", "혼동어"),
|
||||
|
||||
// ── 맞춤법 ──────────────────────────────────────────────────────────
|
||||
new("설레임", "설렘", "표준어는 '설렘'", "맞춤법"),
|
||||
new("오랫만에", "오랜만에", "표준어는 '오랜만에'", "맞춤법"),
|
||||
new("왠만하면", "웬만하면", "표준어는 '웬만하면'", "맞춤법"),
|
||||
new("몇일", "며칠", "표준어는 '며칠'", "맞춤법"),
|
||||
new("어의없다", "어이없다", "표준어는 '어이없다'", "맞춤법"),
|
||||
new("내노라하는", "내로라하는", "표준어는 '내로라하는'", "맞춤법"),
|
||||
new("금새", "금세", "표준어는 '금세' ('금시에'의 준말)", "맞춤법"),
|
||||
new("새벽녁", "새벽녘", "표준어는 '새벽녘'", "맞춤법"),
|
||||
new("무릎쓰고", "무릅쓰고", "표준어는 '무릅쓰고'", "맞춤법"),
|
||||
new("짜집기", "짜깁기", "표준어는 '짜깁기'", "맞춤법"),
|
||||
new("역활", "역할", "표준어는 '역할'", "맞춤법"),
|
||||
new("희안하다", "희한하다", "표준어는 '희한하다'", "맞춤법"),
|
||||
new("요컨데", "요컨대", "표준어는 '요컨대'", "맞춤법"),
|
||||
new("알맞는", "알맞은", "형용사이므로 '알맞은'이 맞음", "맞춤법"),
|
||||
new("예쁘다/이쁘다","예쁘다가 표준어","'이쁘다'는 비표준어", "맞춤법"),
|
||||
new("이따위", "이따위", "표준어 (맞음)", "맞춤법"),
|
||||
new("구렛나루", "구레나룻", "표준어는 '구레나룻'", "맞춤법"),
|
||||
new("꼭두각시", "꼭두각시", "표준어 (맞음)", "맞춤법"),
|
||||
|
||||
// ── 띄어쓰기 ────────────────────────────────────────────────────────
|
||||
new("할수있다", "할 수 있다", "의존명사 '수'는 띄어씀", "띄어쓰기"),
|
||||
new("해야한다", "해야 한다", "'한다'는 독립 서술어, 띄어씀", "띄어쓰기"),
|
||||
new("것같다", "것 같다", "의존명사 '것'은 띄어씀", "띄어쓰기"),
|
||||
new("수밖에없다", "수밖에 없다", "'없다'는 독립 서술어, 띄어씀", "띄어쓰기"),
|
||||
new("한번", "한 번(횟수), 한번(시도)","횟수=한 번 / 시도=한번", "띄어쓰기"),
|
||||
new("이상한거같다", "이상한 것 같다","'것'은 의존명사로 띄어씀", "띄어쓰기"),
|
||||
new("될것같다", "될 것 같다", "'것'은 의존명사로 띄어씀", "띄어쓰기"),
|
||||
new("할것이다", "할 것이다", "'것'은 의존명사로 띄어씀", "띄어쓰기"),
|
||||
new("있을때", "있을 때", "'때'는 의존명사로 띄어씀", "띄어쓰기"),
|
||||
new("모를때", "모를 때", "'때'는 의존명사로 띄어씀", "띄어쓰기"),
|
||||
|
||||
// ── 외래어 ──────────────────────────────────────────────────────────
|
||||
new("리더쉽", "리더십", "표준 외래어: ~ship은 '십'으로", "외래어"),
|
||||
new("멤버쉽", "멤버십", "표준 외래어: ~ship은 '십'으로", "외래어"),
|
||||
new("파트너쉽", "파트너십", "표준 외래어: ~ship은 '십'으로", "외래어"),
|
||||
new("인턴쉽", "인턴십", "표준 외래어: ~ship은 '십'으로", "외래어"),
|
||||
new("메세지", "메시지", "표준 외래어: message → 메시지", "외래어"),
|
||||
new("써비스", "서비스", "표준 외래어: service → 서비스", "외래어"),
|
||||
new("케릭터", "캐릭터", "표준 외래어: character → 캐릭터", "외래어"),
|
||||
new("컨텐츠", "콘텐츠", "표준 외래어: contents → 콘텐츠", "외래어"),
|
||||
new("엑기스", "엑스", "표준 외래어: extract → 엑기스 (비표준)","외래어"),
|
||||
new("렌트카", "렌터카", "표준 외래어: rent-a-car → 렌터카", "외래어"),
|
||||
new("쥬스", "주스", "표준 외래어: juice → 주스", "외래어"),
|
||||
new("후라이", "프라이", "표준 외래어: fry → 프라이", "외래어"),
|
||||
new("후라이팬", "프라이팬", "표준 외래어: frying pan → 프라이팬", "외래어"),
|
||||
new("비스켓", "비스킷", "표준 외래어: biscuit → 비스킷", "외래어"),
|
||||
new("떼레비", "텔레비전", "표준 외래어: television → 텔레비전", "외래어"),
|
||||
];
|
||||
|
||||
private static readonly string[] CategoryOrder = ["되/돼", "안/않", "혼동어", "맞춤법", "띄어쓰기", "외래어", "사용자"];
|
||||
|
||||
// ─── 사용자 정의 항목 (L29-3) ────────────────────────────────────────────
|
||||
private sealed record CustomSpell(
|
||||
[property: JsonPropertyName("wrong")] string Wrong,
|
||||
[property: JsonPropertyName("correct")] string Correct,
|
||||
[property: JsonPropertyName("desc")] string Desc);
|
||||
|
||||
private static readonly string CustomPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "spell_custom.json");
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpt = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
|
||||
private static List<CustomSpell> LoadCustom()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(CustomPath)) return [];
|
||||
return JsonSerializer.Deserialize<List<CustomSpell>>(File.ReadAllText(CustomPath)) ?? [];
|
||||
}
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
private static void SaveCustom(List<CustomSpell> list)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(CustomPath)!;
|
||||
if (!Directory.Exists(dir)) Directory.CreateDirectory(dir);
|
||||
File.WriteAllText(CustomPath, JsonSerializer.Serialize(list, JsonOpt));
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private IEnumerable<SpellEntry> AllEntries()
|
||||
{
|
||||
foreach (var e in Entries) yield return e;
|
||||
foreach (var c in LoadCustom())
|
||||
yield return new SpellEntry(c.Wrong, c.Correct, c.Desc, "사용자");
|
||||
}
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
// ── add 명령 (L29-3) ─────────────────────────────────────────────────
|
||||
if (q.StartsWith("add ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var parts = q[4..].Trim().Split(' ', 3, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
items.Add(new LauncherItem("사용법: spell add {틀린표현} {올바른표현} [설명]",
|
||||
"예: spell add 어의없다 어이없다 어이(기가 막혀)가 올바른 표현",
|
||||
null, null, Symbol: "\uE710"));
|
||||
}
|
||||
else
|
||||
{
|
||||
var wrong = parts[0];
|
||||
var correct = parts[1];
|
||||
var desc = parts.Length >= 3 ? parts[2] : "사용자 추가 항목";
|
||||
items.Add(new LauncherItem(
|
||||
$"맞춤법 추가: ❌ {wrong} → ✅ {correct}",
|
||||
desc,
|
||||
null, ("add", $"{wrong}\t{correct}\t{desc}"), Symbol: "\uE710"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ── del 명령 (L29-3) ─────────────────────────────────────────────────
|
||||
if (q.StartsWith("del ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var wrong = q[4..].Trim();
|
||||
var custom = LoadCustom();
|
||||
var found = custom.FirstOrDefault(c => c.Wrong.Equals(wrong, StringComparison.OrdinalIgnoreCase));
|
||||
if (found != null)
|
||||
{
|
||||
items.Add(new LauncherItem($"사용자 항목 삭제: {found.Wrong} → {found.Correct}",
|
||||
found.Desc, null, ("del", found.Wrong), Symbol: "\uE74D"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"'{wrong}' 사용자 항목을 찾을 수 없습니다",
|
||||
"기본 항목은 삭제할 수 없습니다", null, null, Symbol: "\uE783"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ── custom 명령 (사용자 항목만 보기) ──────────────────────────────────
|
||||
if (q.Equals("custom", StringComparison.OrdinalIgnoreCase) ||
|
||||
q.Equals("사용자", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var custom = LoadCustom();
|
||||
if (custom.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("사용자 항목이 없습니다",
|
||||
"spell add {틀린표현} {올바른표현} [설명] 으로 추가하세요",
|
||||
null, null, Symbol: "\uE7BA"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"사용자 맞춤법 항목 {custom.Count}개", "",
|
||||
null, null, Symbol: "\uE7BA"));
|
||||
foreach (var c in custom)
|
||||
items.Add(MakeSpellItem(new SpellEntry(c.Wrong, c.Correct, c.Desc, "사용자")));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
// 클립보드 텍스트 맞춤법 검사
|
||||
string clipText = "";
|
||||
try
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
if (Clipboard.ContainsText())
|
||||
clipText = Clipboard.GetText();
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(clipText))
|
||||
{
|
||||
var found = AllEntries().Where(e =>
|
||||
clipText.Contains(e.Wrong, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (found.Count > 0)
|
||||
{
|
||||
items.Add(new LauncherItem($"클립보드에서 맞춤법 오류 {found.Count}개 발견",
|
||||
"Enter: 올바른 표현 복사", null, null, Symbol: "\uE7BA"));
|
||||
foreach (var e in found)
|
||||
items.Add(MakeSpellItem(e));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem("클립보드 텍스트에서 오류를 찾지 못했습니다",
|
||||
"카테고리: 되/돼 · 안/않 · 맞춤법 · 띄어쓰기 · 혼동어 · 외래어",
|
||||
null, null, Symbol: "\uE7BA"));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"한국어 맞춤법 참조 {Entries.Length}개",
|
||||
"카테고리: 되/돼 · 안/않 · 맞춤법 · 띄어쓰기 · 혼동어 · 외래어",
|
||||
null, null, Symbol: "\uE7BA"));
|
||||
}
|
||||
|
||||
// 카테고리 목록
|
||||
foreach (var cat in CategoryOrder)
|
||||
{
|
||||
var cnt = Entries.Count(e => e.Category == cat);
|
||||
items.Add(new LauncherItem($"spell {cat}", $"{cat} ({cnt}개)",
|
||||
null, null, Symbol: "\uE7BA"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var kw = q.ToLowerInvariant();
|
||||
|
||||
// "list" → 카테고리별 개수
|
||||
if (kw == "list")
|
||||
{
|
||||
var all = AllEntries().ToList();
|
||||
items.Add(new LauncherItem($"맞춤법 오류 목록 총 {all.Count}개", "", null, null, Symbol: "\uE7BA"));
|
||||
foreach (var cat in CategoryOrder)
|
||||
{
|
||||
var cnt = all.Count(e => e.Category == cat);
|
||||
if (cnt == 0) continue;
|
||||
items.Add(new LauncherItem(cat, $"{cnt}개 · spell {cat}로 목록 보기",
|
||||
null, null, Symbol: "\uE7BA"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 카테고리 키워드 매핑
|
||||
var catMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["되"] = "되/돼",
|
||||
["돼"] = "됩니다",
|
||||
["됐"] = "됩니다",
|
||||
["됩"] = "됩니다",
|
||||
["되/돼"] = "됩니다",
|
||||
["안"] = "안/않",
|
||||
["않"] = "안/않",
|
||||
["안/않"] = "안/않",
|
||||
["혼동"] = "혼동어",
|
||||
["혼동어"] = "혼동어",
|
||||
["맞춤법"] = "맞춤법",
|
||||
["맞춤"] = "맞춤법",
|
||||
["띄어"] = "띄어쓰기",
|
||||
["띄어쓰기"] = "띄어쓰기",
|
||||
["외래어"] = "외래어",
|
||||
["외래"] = "외래어",
|
||||
};
|
||||
|
||||
// 카테고리 직접 매핑
|
||||
foreach (var cat in CategoryOrder)
|
||||
{
|
||||
if (cat.Equals(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
catMap.TryGetValue(q, out var mappedCat) && mappedCat == cat)
|
||||
{
|
||||
var catList = Entries.Where(e => e.Category == cat).ToList();
|
||||
items.Add(new LauncherItem($"{cat} {catList.Count}개",
|
||||
"Enter: 올바른 표현 복사", null, null, Symbol: "\uE7BA"));
|
||||
foreach (var e in catList)
|
||||
items.Add(MakeSpellItem(e));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
}
|
||||
|
||||
// catMap으로 카테고리 찾기
|
||||
if (catMap.TryGetValue(q, out var foundCat) && CategoryOrder.Contains(foundCat))
|
||||
{
|
||||
var catList = Entries.Where(e => e.Category == foundCat).ToList();
|
||||
items.Add(new LauncherItem($"{foundCat} {catList.Count}개",
|
||||
"Enter: 올바른 표현 복사", null, null, Symbol: "\uE7BA"));
|
||||
foreach (var e in catList)
|
||||
items.Add(MakeSpellItem(e));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 키워드 검색 (내장 + 사용자 항목)
|
||||
var searched = AllEntries().Where(e =>
|
||||
e.Wrong.Contains(kw, StringComparison.OrdinalIgnoreCase) ||
|
||||
e.Correct.Contains(kw, StringComparison.OrdinalIgnoreCase) ||
|
||||
e.Explanation.Contains(kw, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (searched.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem($"'{q}' 검색 결과 없음",
|
||||
"카테고리: 되/돼 · 안/않 · 맞춤법 · 띄어쓰기 · 혼동어 · 외래어",
|
||||
null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
items.Add(new LauncherItem($"'{q}' 검색 결과 {searched.Count}개",
|
||||
"Enter: 올바른 표현 복사", null, null, Symbol: "\uE7BA"));
|
||||
foreach (var e in searched)
|
||||
items.Add(MakeSpellItem(e));
|
||||
|
||||
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("맞춤법", "올바른 표현을 복사했습니다.");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
else if (item.Data is ("add", string addData))
|
||||
{
|
||||
var parts = addData.Split('\t');
|
||||
if (parts.Length >= 3)
|
||||
{
|
||||
var custom = LoadCustom();
|
||||
custom.RemoveAll(c => c.Wrong.Equals(parts[0], StringComparison.OrdinalIgnoreCase));
|
||||
custom.Add(new CustomSpell(parts[0], parts[1], parts[2]));
|
||||
SaveCustom(custom);
|
||||
NotificationService.Notify("맞춤법", $"'{parts[0]} → {parts[1]}' 항목이 추가되었습니다.");
|
||||
}
|
||||
}
|
||||
else if (item.Data is ("del", string delWrong))
|
||||
{
|
||||
var custom = LoadCustom();
|
||||
custom.RemoveAll(c => c.Wrong.Equals(delWrong, StringComparison.OrdinalIgnoreCase));
|
||||
SaveCustom(custom);
|
||||
NotificationService.Notify("맞춤법", $"'{delWrong}' 항목이 삭제되었습니다.");
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static LauncherItem MakeSpellItem(SpellEntry e) =>
|
||||
new($"❌ {e.Wrong} → ✅ {e.Correct}",
|
||||
e.Explanation,
|
||||
null, ("copy", e.Correct), Symbol: "\uE7BA");
|
||||
}
|
||||
467
src/AxCopilot/Handlers/SqlHandler.cs
Normal file
467
src/AxCopilot/Handlers/SqlHandler.cs
Normal file
@@ -0,0 +1,467 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L18-1: SQL 포맷터·분석기 핸들러. "sql" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: sql → 클립보드 SQL 포맷 (들여쓰기 정렬)
|
||||
/// sql mini → SQL 미니파이 (공백·줄바꿈 제거)
|
||||
/// sql upper → 키워드 대문자로 변환
|
||||
/// sql lower → 키워드 소문자로 변환
|
||||
/// sql stats → 테이블·컬럼·조건 수 분석
|
||||
/// sql tables → FROM/JOIN 테이블 목록 추출
|
||||
/// sql select <table> → SELECT * FROM <table> 생성
|
||||
/// Enter → 결과 복사.
|
||||
/// 외부 라이브러리 없이 순수 구현.
|
||||
/// </summary>
|
||||
public partial class SqlHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "sql";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"SQL",
|
||||
"SQL 포맷터·분석기 — 들여쓰기 · 미니파이 · 키워드 · 테이블 추출",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
// SQL 키워드 목록
|
||||
private static readonly string[] Keywords =
|
||||
[
|
||||
"SELECT", "FROM", "WHERE", "JOIN", "LEFT JOIN", "RIGHT JOIN", "INNER JOIN",
|
||||
"OUTER JOIN", "FULL JOIN", "CROSS JOIN", "ON", "AND", "OR", "NOT",
|
||||
"INSERT INTO", "VALUES", "UPDATE", "SET", "DELETE FROM", "DELETE",
|
||||
"CREATE TABLE", "CREATE INDEX", "CREATE VIEW", "DROP TABLE", "DROP INDEX",
|
||||
"ALTER TABLE", "ADD COLUMN", "DROP COLUMN", "RENAME TO",
|
||||
"GROUP BY", "ORDER BY", "HAVING", "LIMIT", "OFFSET", "DISTINCT",
|
||||
"UNION", "UNION ALL", "INTERSECT", "EXCEPT",
|
||||
"CASE", "WHEN", "THEN", "ELSE", "END",
|
||||
"IN", "NOT IN", "EXISTS", "NOT EXISTS", "BETWEEN", "LIKE", "IS NULL", "IS NOT NULL",
|
||||
"AS", "WITH", "RECURSIVE",
|
||||
"COUNT", "SUM", "AVG", "MIN", "MAX", "COALESCE", "NULLIF", "CAST",
|
||||
"SUBSTRING", "TRIM", "UPPER", "LOWER", "LENGTH", "REPLACE",
|
||||
"NOW", "CURRENT_DATE", "CURRENT_TIMESTAMP", "DATE_FORMAT",
|
||||
"BEGIN", "COMMIT", "ROLLBACK", "TRANSACTION",
|
||||
"PRIMARY KEY", "FOREIGN KEY", "REFERENCES", "UNIQUE", "NOT NULL", "DEFAULT",
|
||||
"INDEX", "CONSTRAINT",
|
||||
];
|
||||
|
||||
// 새 줄 시작 키워드
|
||||
private static readonly HashSet<string> NewlineKeywords = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"SELECT", "FROM", "WHERE", "JOIN", "LEFT JOIN", "RIGHT JOIN", "INNER JOIN",
|
||||
"OUTER JOIN", "FULL JOIN", "CROSS JOIN", "ON",
|
||||
"GROUP BY", "ORDER BY", "HAVING", "LIMIT", "OFFSET",
|
||||
"UNION", "UNION ALL", "INTERSECT", "EXCEPT",
|
||||
"INSERT INTO", "VALUES", "UPDATE", "SET", "DELETE FROM", "DELETE",
|
||||
"CREATE TABLE", "CREATE INDEX", "CREATE VIEW",
|
||||
"DROP TABLE", "ALTER TABLE",
|
||||
"WITH", "AND", "OR",
|
||||
};
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
string? clipboard = null;
|
||||
try
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
if (Clipboard.ContainsText())
|
||||
clipboard = Clipboard.GetText();
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
items.Add(new LauncherItem("SQL 포맷터·분석기",
|
||||
"클립보드 SQL 포맷 · sql mini / upper / lower / stats / tables",
|
||||
null, null, Symbol: "\uE8F1"));
|
||||
items.Add(new LauncherItem("sql", "들여쓰기 포맷", null, null, Symbol: "\uE8F1"));
|
||||
items.Add(new LauncherItem("sql mini", "미니파이 (한 줄)", null, null, Symbol: "\uE8F1"));
|
||||
items.Add(new LauncherItem("sql upper", "키워드 대문자", null, null, Symbol: "\uE8F1"));
|
||||
items.Add(new LauncherItem("sql lower", "키워드 소문자", null, null, Symbol: "\uE8F1"));
|
||||
items.Add(new LauncherItem("sql stats", "테이블·컬럼·조건 분석", null, null, Symbol: "\uE8F1"));
|
||||
items.Add(new LauncherItem("sql tables", "FROM/JOIN 테이블 목록", null, null, Symbol: "\uE8F1"));
|
||||
items.Add(new LauncherItem("sql select T", "SELECT 쿼리 빠른 생성", null, null, Symbol: "\uE8F1"));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(clipboard))
|
||||
{
|
||||
items.Add(new LauncherItem("클립보드가 비어 있습니다",
|
||||
"SQL 쿼리를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 미리보기: 기본 포맷
|
||||
var preview = Format(clipboard);
|
||||
var prevLine = preview.Split('\n').FirstOrDefault() ?? "";
|
||||
items.Add(new LauncherItem("클립보드 SQL 포맷",
|
||||
prevLine.Length > 60 ? prevLine[..60] + "…" : prevLine,
|
||||
null, ("copy", preview), Symbol: "\uE8F1"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
// sql select <table> — 클립보드 없이도 동작
|
||||
if (sub == "select")
|
||||
{
|
||||
var table = parts.Length > 1 ? parts[1].Trim() : "your_table";
|
||||
var generated = BuildSelectTemplate(table);
|
||||
items.Add(new LauncherItem($"SELECT * FROM {table}",
|
||||
"Enter → 복사", null, ("copy", generated), Symbol: "\uE8F1"));
|
||||
foreach (var line in generated.Split('\n').Take(8))
|
||||
items.Add(new LauncherItem(line, "", null, null, Symbol: "\uE8F1"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(clipboard))
|
||||
{
|
||||
items.Add(new LauncherItem("클립보드가 비어 있습니다",
|
||||
"SQL 쿼리를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
switch (sub)
|
||||
{
|
||||
case "format":
|
||||
case "fmt":
|
||||
case "pretty":
|
||||
{
|
||||
var result = Format(clipboard);
|
||||
items.Add(new LauncherItem("SQL 포맷 완료",
|
||||
$"{result.Split('\n').Length}줄 · Enter 복사", null, ("copy", result), Symbol: "\uE8F1"));
|
||||
AddPreview(items, result, 8);
|
||||
break;
|
||||
}
|
||||
|
||||
case "mini":
|
||||
case "minify":
|
||||
case "compact":
|
||||
{
|
||||
var result = Minify(clipboard);
|
||||
items.Add(new LauncherItem("SQL 미니파이 완료",
|
||||
$"{result.Length}자 · Enter 복사", null, ("copy", result), Symbol: "\uE8F1"));
|
||||
var prev = result.Length > 80 ? result[..80] + "…" : result;
|
||||
items.Add(new LauncherItem(prev, "", null, ("copy", result), Symbol: "\uE8F1"));
|
||||
break;
|
||||
}
|
||||
|
||||
case "upper":
|
||||
{
|
||||
var result = TransformKeywords(clipboard, upper: true);
|
||||
items.Add(new LauncherItem("키워드 대문자 변환",
|
||||
"Enter → 복사", null, ("copy", result), Symbol: "\uE8F1"));
|
||||
AddPreview(items, result, 6);
|
||||
break;
|
||||
}
|
||||
|
||||
case "lower":
|
||||
{
|
||||
var result = TransformKeywords(clipboard, upper: false);
|
||||
items.Add(new LauncherItem("키워드 소문자 변환",
|
||||
"Enter → 복사", null, ("copy", result), Symbol: "\uE8F1"));
|
||||
AddPreview(items, result, 6);
|
||||
break;
|
||||
}
|
||||
|
||||
case "stats":
|
||||
case "stat":
|
||||
case "analyze":
|
||||
{
|
||||
items.AddRange(BuildStatsItems(clipboard));
|
||||
break;
|
||||
}
|
||||
|
||||
case "tables":
|
||||
case "table":
|
||||
{
|
||||
var tables = ExtractTables(clipboard);
|
||||
items.Add(new LauncherItem($"테이블 {tables.Count}개",
|
||||
"FROM / JOIN 에서 추출", null, null, Symbol: "\uE8F1"));
|
||||
foreach (var t in tables)
|
||||
items.Add(new LauncherItem(t, "", null, ("copy", t), Symbol: "\uE8F1"));
|
||||
if (tables.Count == 0)
|
||||
items.Add(new LauncherItem("테이블 없음", "FROM 절이 없습니다", null, null, Symbol: "\uE946"));
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
{
|
||||
// 기본 동작: 포맷
|
||||
var result = Format(clipboard);
|
||||
items.Add(new LauncherItem("SQL 포맷 완료",
|
||||
$"{result.Split('\n').Length}줄", null, ("copy", result), Symbol: "\uE8F1"));
|
||||
AddPreview(items, result, 8);
|
||||
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("SQL", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── SQL 포맷 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static string Format(string sql)
|
||||
{
|
||||
// 정규화: 여러 공백 → 1개, 개행 제거
|
||||
var flat = WhitespaceRegex().Replace(sql.Replace('\n', ' ').Replace('\r', ' '), " ").Trim();
|
||||
|
||||
var sb = new StringBuilder();
|
||||
var indent = 0;
|
||||
var tokens = Tokenize(flat);
|
||||
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
var upper = token.ToUpperInvariant();
|
||||
|
||||
// 닫기 괄호 → 들여쓰기 감소
|
||||
if (upper == ")")
|
||||
{
|
||||
indent = Math.Max(0, indent - 1);
|
||||
if (sb.Length > 0 && sb[^1] != '\n')
|
||||
sb.AppendLine();
|
||||
sb.Append(new string(' ', indent * 2));
|
||||
sb.Append(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 새 줄 시작 키워드
|
||||
if (NewlineKeywords.Contains(upper))
|
||||
{
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.Append(new string(' ', indent * 2));
|
||||
}
|
||||
sb.Append(token.ToUpperInvariant());
|
||||
sb.Append(' ');
|
||||
continue;
|
||||
}
|
||||
|
||||
// 열기 괄호 → 들여쓰기 증가
|
||||
if (upper == "(")
|
||||
{
|
||||
sb.Append(token);
|
||||
indent++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 쉼표 → 뒤에 공백
|
||||
if (upper == ",")
|
||||
{
|
||||
sb.Append(',');
|
||||
sb.AppendLine();
|
||||
sb.Append(new string(' ', indent * 2 + 2));
|
||||
continue;
|
||||
}
|
||||
|
||||
sb.Append(token);
|
||||
sb.Append(' ');
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static string Minify(string sql)
|
||||
{
|
||||
var flat = WhitespaceRegex().Replace(sql.Replace('\n', ' ').Replace('\r', ' '), " ").Trim();
|
||||
// 괄호 주변 공백 제거
|
||||
flat = SpaceAroundParensRegex().Replace(flat, "$1");
|
||||
flat = SpaceBeforeCommaRegex().Replace(flat, ",");
|
||||
return flat;
|
||||
}
|
||||
|
||||
private static string TransformKeywords(string sql, bool upper)
|
||||
{
|
||||
var result = sql;
|
||||
// 긴 키워드부터 처리 (LEFT JOIN before JOIN 등)
|
||||
foreach (var kw in Keywords.OrderByDescending(k => k.Length))
|
||||
{
|
||||
var pattern = $@"\b{Regex.Escape(kw)}\b";
|
||||
var replacement = upper ? kw.ToUpperInvariant() : kw.ToLowerInvariant();
|
||||
result = Regex.Replace(result, pattern, replacement, RegexOptions.IgnoreCase);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<LauncherItem> BuildStatsItems(string sql)
|
||||
{
|
||||
var items = new List<LauncherItem>();
|
||||
var upper = sql.ToUpperInvariant();
|
||||
var tables = ExtractTables(sql);
|
||||
var selCols = ExtractSelectColumns(sql);
|
||||
var wheres = CountConditions(sql);
|
||||
var joins = CountMatches(upper, @"\bJOIN\b");
|
||||
var subqs = CountMatches(upper, @"\bSELECT\b") - 1;
|
||||
var orderBy = upper.Contains("ORDER BY");
|
||||
var groupBy = upper.Contains("GROUP BY");
|
||||
var hasLimit = upper.Contains("LIMIT");
|
||||
var dml = DetectDml(upper);
|
||||
|
||||
items.Add(new LauncherItem($"SQL 분석 [{dml}]",
|
||||
$"테이블 {tables.Count}개 · JOIN {joins}개 · WHERE 조건 {wheres}개",
|
||||
null, null, Symbol: "\uE8F1"));
|
||||
|
||||
items.Add(new LauncherItem("DML 유형", dml, null, ("copy", dml), Symbol: "\uE8F1"));
|
||||
items.Add(new LauncherItem("테이블 수", $"{tables.Count}개", null, ("copy", $"{tables.Count}"), Symbol: "\uE8F1"));
|
||||
items.Add(new LauncherItem("JOIN 수", $"{joins}개", null, ("copy", $"{joins}"), Symbol: "\uE8F1"));
|
||||
items.Add(new LauncherItem("WHERE 조건 수", $"{wheres}개", null, ("copy", $"{wheres}"), Symbol: "\uE8F1"));
|
||||
if (selCols.Count > 0)
|
||||
items.Add(new LauncherItem("SELECT 컬럼", $"{selCols.Count}개", null, null, Symbol: "\uE8F1"));
|
||||
items.Add(new LauncherItem("서브쿼리", $"{Math.Max(0, subqs)}개", null, null, Symbol: "\uE8F1"));
|
||||
items.Add(new LauncherItem("GROUP BY", groupBy ? "있음" : "없음", null, null, Symbol: "\uE8F1"));
|
||||
items.Add(new LauncherItem("ORDER BY", orderBy ? "있음" : "없음", null, null, Symbol: "\uE8F1"));
|
||||
items.Add(new LauncherItem("LIMIT", hasLimit ? "있음" : "없음", null, null, Symbol: "\uE8F1"));
|
||||
items.Add(new LauncherItem("전체 길이", $"{sql.Length}자", null, null, Symbol: "\uE8F1"));
|
||||
return items;
|
||||
}
|
||||
|
||||
private static List<string> ExtractTables(string sql)
|
||||
{
|
||||
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var matches = TableRegex().Matches(sql);
|
||||
foreach (Match m in matches)
|
||||
{
|
||||
var t = m.Groups[1].Value.Trim().Trim('[', ']', '`', '"');
|
||||
if (!string.IsNullOrWhiteSpace(t) && !IsKeyword(t))
|
||||
result.Add(t);
|
||||
}
|
||||
return result.ToList();
|
||||
}
|
||||
|
||||
private static List<string> ExtractSelectColumns(string sql)
|
||||
{
|
||||
var m = SelectColsRegex().Match(sql);
|
||||
if (!m.Success) return new List<string>();
|
||||
var cols = m.Groups[1].Value;
|
||||
return cols.Split(',').Select(c => c.Trim()).Where(c => !string.IsNullOrEmpty(c)).ToList();
|
||||
}
|
||||
|
||||
private static int CountConditions(string sql)
|
||||
{
|
||||
var upper = sql.ToUpperInvariant();
|
||||
var idx = upper.IndexOf("WHERE", StringComparison.Ordinal);
|
||||
if (idx < 0) return 0;
|
||||
var wherePart = upper[idx..];
|
||||
// AND/OR 수 + 1
|
||||
return Regex.Matches(wherePart, @"\b(AND|OR)\b").Count + 1;
|
||||
}
|
||||
|
||||
private static int CountMatches(string text, string pattern) =>
|
||||
Regex.Matches(text, pattern, RegexOptions.IgnoreCase).Count;
|
||||
|
||||
private static string DetectDml(string upper)
|
||||
{
|
||||
if (upper.TrimStart().StartsWith("SELECT")) return "SELECT";
|
||||
if (upper.TrimStart().StartsWith("INSERT")) return "INSERT";
|
||||
if (upper.TrimStart().StartsWith("UPDATE")) return "UPDATE";
|
||||
if (upper.TrimStart().StartsWith("DELETE")) return "DELETE";
|
||||
if (upper.TrimStart().StartsWith("CREATE")) return "CREATE";
|
||||
if (upper.TrimStart().StartsWith("ALTER")) return "ALTER";
|
||||
if (upper.TrimStart().StartsWith("DROP")) return "DROP";
|
||||
if (upper.TrimStart().StartsWith("WITH")) return "CTE/WITH";
|
||||
return "기타";
|
||||
}
|
||||
|
||||
private static bool IsKeyword(string s) =>
|
||||
Keywords.Any(k => k.Equals(s, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static List<string> Tokenize(string sql)
|
||||
{
|
||||
var tokens = new List<string>();
|
||||
var current = new StringBuilder();
|
||||
var inStr = false;
|
||||
var strChar = ' ';
|
||||
|
||||
for (var i = 0; i < sql.Length; i++)
|
||||
{
|
||||
var c = sql[i];
|
||||
|
||||
if (inStr)
|
||||
{
|
||||
current.Append(c);
|
||||
if (c == strChar) inStr = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c is '\'' or '"' or '`')
|
||||
{
|
||||
if (current.Length > 0) { tokens.Add(current.ToString()); current.Clear(); }
|
||||
current.Append(c);
|
||||
inStr = true; strChar = c;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c is '(' or ')' or ',')
|
||||
{
|
||||
if (current.Length > 0) { tokens.Add(current.ToString().Trim()); current.Clear(); }
|
||||
tokens.Add(c.ToString());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == ' ')
|
||||
{
|
||||
if (current.Length > 0) { tokens.Add(current.ToString().Trim()); current.Clear(); }
|
||||
continue;
|
||||
}
|
||||
|
||||
current.Append(c);
|
||||
}
|
||||
|
||||
if (current.Length > 0) tokens.Add(current.ToString().Trim());
|
||||
return tokens.Where(t => !string.IsNullOrEmpty(t)).ToList();
|
||||
}
|
||||
|
||||
private static string BuildSelectTemplate(string table) =>
|
||||
$"SELECT\n *\nFROM\n {table}\nWHERE\n 1 = 1\nLIMIT 100;";
|
||||
|
||||
private static void AddPreview(List<LauncherItem> items, string text, int maxLines)
|
||||
{
|
||||
foreach (var line in text.Split('\n').Take(maxLines))
|
||||
{
|
||||
var t = line.TrimEnd();
|
||||
if (!string.IsNullOrWhiteSpace(t))
|
||||
items.Add(new LauncherItem(t, "", null, null, Symbol: "\uE8F1"));
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\s+")]
|
||||
private static partial Regex WhitespaceRegex();
|
||||
|
||||
[GeneratedRegex(@"\s*([(),])\s*")]
|
||||
private static partial Regex SpaceAroundParensRegex();
|
||||
|
||||
[GeneratedRegex(@"\s+,")]
|
||||
private static partial Regex SpaceBeforeCommaRegex();
|
||||
|
||||
[GeneratedRegex(@"(?:FROM|JOIN)\s+([\w\.\[\]`""]+)", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex TableRegex();
|
||||
|
||||
[GeneratedRegex(@"SELECT\s+(.*?)\s+FROM", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
|
||||
private static partial Regex SelectColsRegex();
|
||||
}
|
||||
341
src/AxCopilot/Handlers/SshHandler.cs
Normal file
341
src/AxCopilot/Handlers/SshHandler.cs
Normal file
@@ -0,0 +1,341 @@
|
||||
using System.Diagnostics;
|
||||
using System.Windows;
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L8-4: SSH 퀵 커넥트 핸들러. "ssh" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: ssh → 저장된 SSH 호스트 목록
|
||||
/// ssh dev → 이름/호스트에 "dev" 포함된 항목 필터
|
||||
/// ssh add user@host → 빠른 호스트 추가 (이름 = host, 포트 22)
|
||||
/// ssh add name user@host:22 → 이름 지정하여 추가
|
||||
/// ssh del <이름> → 호스트 삭제
|
||||
/// Enter → Windows Terminal(ssh) 또는 PuTTY로 연결.
|
||||
/// 사내 모드에서도 항상 사용 가능 (SSH는 내부 서버 접속이 주 용도).
|
||||
/// </summary>
|
||||
public class SshHandler : IActionHandler
|
||||
{
|
||||
private readonly SettingsService _settings;
|
||||
|
||||
public SshHandler(SettingsService settings) { _settings = settings; }
|
||||
|
||||
public string? Prefix => "ssh";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"SSH",
|
||||
"SSH 퀵 커넥트 — 호스트 저장 · 빠른 연결",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
var hosts = _settings.Settings.SshHosts;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
if (hosts.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
"SSH 호스트 없음",
|
||||
"ssh add user@host 또는 ssh add 이름 user@host:22",
|
||||
null, null, Symbol: "\uE968"));
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var h in hosts)
|
||||
items.Add(MakeHostItem(h));
|
||||
}
|
||||
items.Add(new LauncherItem(
|
||||
"ssh add user@host",
|
||||
"새 호스트 추가",
|
||||
null, null, Symbol: "\uE710"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
// ── add ─────────────────────────────────────────────────────────
|
||||
if (sub == "add")
|
||||
{
|
||||
SshHostEntry? entry = null;
|
||||
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
// ssh add user@host[:port]
|
||||
entry = ParseUserHost(parts[1]);
|
||||
if (entry != null) entry.Name = entry.Host;
|
||||
}
|
||||
else if (parts.Length == 3)
|
||||
{
|
||||
// ssh add <name> user@host[:port]
|
||||
entry = ParseUserHost(parts[2]);
|
||||
if (entry != null) entry.Name = parts[1];
|
||||
}
|
||||
|
||||
if (entry != null)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"추가: {entry.Name}",
|
||||
$"{entry.User}@{entry.Host}:{entry.Port}",
|
||||
null,
|
||||
("add", entry),
|
||||
Symbol: "\uE710"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
"형식: ssh add user@host[:port]",
|
||||
"또는: ssh add 이름 user@host:22",
|
||||
null, null, Symbol: "\uE783"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ── del ─────────────────────────────────────────────────────────
|
||||
if (sub == "del" || sub == "delete" || sub == "rm")
|
||||
{
|
||||
var nameQuery = parts.Length >= 2 ? parts[1].ToLowerInvariant() : "";
|
||||
var toDelete = hosts
|
||||
.Where(h => h.Name.Contains(nameQuery, StringComparison.OrdinalIgnoreCase)
|
||||
|| h.Host.Contains(nameQuery, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (toDelete.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("삭제 대상 없음", nameQuery, null, null, Symbol: "\uE783"));
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var h in toDelete)
|
||||
items.Add(new LauncherItem(
|
||||
$"삭제: {h.Name}",
|
||||
$"{h.User}@{h.Host}:{h.Port}",
|
||||
null,
|
||||
("del", h.Id),
|
||||
Symbol: "\uE74D"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ── 검색 (이름 / 호스트 / 사용자 / 메모) ─────────────────────────
|
||||
var filtered = hosts.Where(h =>
|
||||
h.Name.Contains(q, StringComparison.OrdinalIgnoreCase)
|
||||
|| h.Host.Contains(q, StringComparison.OrdinalIgnoreCase)
|
||||
|| h.User.Contains(q, StringComparison.OrdinalIgnoreCase)
|
||||
|| h.Note.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (filtered.Count > 0)
|
||||
{
|
||||
foreach (var h in filtered)
|
||||
items.Add(MakeHostItem(h));
|
||||
}
|
||||
else
|
||||
{
|
||||
// 직접 접속 시도 (user@host 형식)
|
||||
var entry = ParseUserHost(q);
|
||||
if (entry != null)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"연결: {entry.User}@{entry.Host}:{entry.Port}",
|
||||
"Enter → Windows Terminal로 연결",
|
||||
null,
|
||||
("connect", entry),
|
||||
Symbol: "\uE968"));
|
||||
items.Add(new LauncherItem(
|
||||
$"저장 후 연결",
|
||||
$"이름: {entry.Host}",
|
||||
null,
|
||||
("add_connect", entry),
|
||||
Symbol: "\uE710"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem("호스트를 찾을 수 없음", q, null, null, Symbol: "\uE783"));
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
switch (item.Data)
|
||||
{
|
||||
case ("connect", SshHostEntry h):
|
||||
ConnectSsh(h);
|
||||
break;
|
||||
|
||||
case ("add", SshHostEntry h):
|
||||
_settings.Settings.SshHosts.RemoveAll(e =>
|
||||
e.Host.Equals(h.Host, StringComparison.OrdinalIgnoreCase)
|
||||
&& e.User.Equals(h.User, StringComparison.OrdinalIgnoreCase)
|
||||
&& e.Port == h.Port);
|
||||
_settings.Settings.SshHosts.Add(h);
|
||||
_settings.Save();
|
||||
NotificationService.Notify("SSH", $"'{h.Name}' 호스트를 저장했습니다.");
|
||||
break;
|
||||
|
||||
case ("add_connect", SshHostEntry h):
|
||||
if (string.IsNullOrEmpty(h.Name)) h.Name = h.Host;
|
||||
_settings.Settings.SshHosts.Add(h);
|
||||
_settings.Save();
|
||||
ConnectSsh(h);
|
||||
break;
|
||||
|
||||
case ("del", string id):
|
||||
var removed = _settings.Settings.SshHosts
|
||||
.RemoveAll(e => e.Id == id);
|
||||
if (removed > 0)
|
||||
{
|
||||
_settings.Save();
|
||||
NotificationService.Notify("SSH", "호스트를 삭제했습니다.");
|
||||
}
|
||||
break;
|
||||
|
||||
// 호스트 항목 직접 Enter
|
||||
case SshHostEntry host:
|
||||
ConnectSsh(host);
|
||||
break;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 헬퍼 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static LauncherItem MakeHostItem(SshHostEntry h)
|
||||
{
|
||||
var portStr = h.Port != 22 ? $":{h.Port}" : "";
|
||||
return new LauncherItem(
|
||||
h.Name,
|
||||
$"{h.User}@{h.Host}{portStr}{(string.IsNullOrEmpty(h.Note) ? "" : " · " + h.Note)}",
|
||||
null,
|
||||
h,
|
||||
Symbol: "\uE968");
|
||||
}
|
||||
|
||||
/// <summary>Windows Terminal 또는 PuTTY로 SSH 연결을 시작합니다.</summary>
|
||||
private static void ConnectSsh(SshHostEntry h)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Windows Terminal (wt.exe) 우선
|
||||
var wtPath = FindExecutable("wt.exe");
|
||||
if (!string.IsNullOrEmpty(wtPath))
|
||||
{
|
||||
var portArgs = h.Port != 22 ? $" -p {h.Port}" : "";
|
||||
var userHost = string.IsNullOrEmpty(h.User)
|
||||
? h.Host : $"{h.User}@{h.Host}";
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = wtPath,
|
||||
Arguments = $"ssh {userHost}{portArgs}",
|
||||
UseShellExecute = true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// PuTTY 대체
|
||||
var puttyPath = FindExecutable("putty.exe");
|
||||
if (!string.IsNullOrEmpty(puttyPath))
|
||||
{
|
||||
var userHost = string.IsNullOrEmpty(h.User)
|
||||
? h.Host : $"{h.User}@{h.Host}";
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = puttyPath,
|
||||
Arguments = $"-ssh {userHost} -P {h.Port}",
|
||||
UseShellExecute = true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// PowerShell 폴백
|
||||
var portArgs2 = h.Port != 22 ? $" -p {h.Port}" : "";
|
||||
var cmd = string.IsNullOrEmpty(h.User)
|
||||
? $"ssh {h.Host}{portArgs2}"
|
||||
: $"ssh {h.User}@{h.Host}{portArgs2}";
|
||||
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "powershell.exe",
|
||||
Arguments = $"-NoExit -Command \"{cmd}\"",
|
||||
UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
NotificationService.Notify("SSH 오류", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>PATH 및 일반 설치 경로에서 실행 파일을 찾습니다.</summary>
|
||||
private static string? FindExecutable(string exe)
|
||||
{
|
||||
// PATH 검색
|
||||
var envPath = Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||
foreach (var dir in envPath.Split(';', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var full = System.IO.Path.Combine(dir.Trim(), exe);
|
||||
if (System.IO.File.Exists(full)) return full;
|
||||
}
|
||||
|
||||
// 일반 설치 경로
|
||||
var candidates = new[]
|
||||
{
|
||||
System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Microsoft\\WindowsApps", exe),
|
||||
System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), exe),
|
||||
System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), exe),
|
||||
};
|
||||
|
||||
return candidates.FirstOrDefault(System.IO.File.Exists);
|
||||
}
|
||||
|
||||
/// <summary>user@host:port 형식을 파싱합니다.</summary>
|
||||
private static SshHostEntry? ParseUserHost(string s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s)) return null;
|
||||
|
||||
string user = "";
|
||||
string host = s;
|
||||
int port = 22;
|
||||
|
||||
// user@host 파싱
|
||||
var atIdx = s.IndexOf('@');
|
||||
if (atIdx >= 0)
|
||||
{
|
||||
user = s[..atIdx];
|
||||
host = s[(atIdx + 1)..];
|
||||
}
|
||||
|
||||
// host:port 파싱
|
||||
var colonIdx = host.LastIndexOf(':');
|
||||
if (colonIdx >= 0 && int.TryParse(host[(colonIdx + 1)..], out var p))
|
||||
{
|
||||
port = p;
|
||||
host = host[..colonIdx];
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(host)) return null;
|
||||
|
||||
return new SshHostEntry
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
Name = host,
|
||||
Host = host,
|
||||
Port = port,
|
||||
User = user,
|
||||
};
|
||||
}
|
||||
}
|
||||
230
src/AxCopilot/Handlers/StartupHandler.cs
Normal file
230
src/AxCopilot/Handlers/StartupHandler.cs
Normal file
@@ -0,0 +1,230 @@
|
||||
using Microsoft.Win32;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L12-4: 시작 프로그램 조회 핸들러. "startup" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: startup → 전체 시작 프로그램 목록
|
||||
/// startup search ms → "ms" 포함 항목 필터
|
||||
/// startup folder → 시작 프로그램 폴더 열기
|
||||
/// Enter → 항목 경로를 클립보드에 복사.
|
||||
/// </summary>
|
||||
public class StartupHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "startup";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Startup",
|
||||
"시작 프로그램 조회 — 레지스트리 · 폴더 · 필터",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
// 조회할 레지스트리 키 경로들
|
||||
private static readonly (string Path, string Scope)[] RegKeys =
|
||||
[
|
||||
(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Run", "현재 사용자"),
|
||||
(@"SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce", "현재 사용자 (1회)"),
|
||||
(@"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run", "현재 사용자 (32bit)"),
|
||||
];
|
||||
|
||||
private static readonly (string Path, string Scope)[] RegKeysHKLM =
|
||||
[
|
||||
(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Run", "모든 사용자"),
|
||||
(@"SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce", "모든 사용자 (1회)"),
|
||||
(@"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run", "모든 사용자 (32bit)"),
|
||||
];
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
var allEntries = CollectAllEntries();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"시작 프로그램 {allEntries.Count}개",
|
||||
"레지스트리 + 시작 폴더",
|
||||
null, null, Symbol: "\uE7FC"));
|
||||
|
||||
items.Add(new LauncherItem("startup folder", "시작 폴더 열기", null, ("open_folder", ""), Symbol: "\uE7FC"));
|
||||
|
||||
// 그룹별 표시
|
||||
var byScope = allEntries.GroupBy(e => e.Scope).OrderBy(g => g.Key);
|
||||
foreach (var group in byScope)
|
||||
{
|
||||
items.Add(new LauncherItem($"── {group.Key} ({group.Count()}개) ──", "", null, null, Symbol: "\uE7FC"));
|
||||
foreach (var e in group.Take(10))
|
||||
items.Add(MakeEntryItem(e));
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
switch (sub)
|
||||
{
|
||||
case "folder":
|
||||
{
|
||||
items.Add(new LauncherItem("시작 폴더 열기 (현재 사용자)", GetStartupFolderUser(),
|
||||
null, ("open_folder", GetStartupFolderUser()), Symbol: "\uE7FC"));
|
||||
items.Add(new LauncherItem("시작 폴더 열기 (모든 사용자)", GetStartupFolderCommon(),
|
||||
null, ("open_folder", GetStartupFolderCommon()), Symbol: "\uE7FC"));
|
||||
break;
|
||||
}
|
||||
|
||||
case "search":
|
||||
case "find":
|
||||
{
|
||||
var keyword = parts.Length > 1 ? parts[1].ToLowerInvariant() : "";
|
||||
if (string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
items.Add(new LauncherItem("검색어 입력", "예: startup search teams", null, null, Symbol: "\uE783"));
|
||||
break;
|
||||
}
|
||||
var filtered = allEntries.Where(e =>
|
||||
e.Name.Contains(keyword, StringComparison.OrdinalIgnoreCase) ||
|
||||
e.Command.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (filtered.Count == 0)
|
||||
items.Add(new LauncherItem("결과 없음", $"'{keyword}' 항목 없음", null, null, Symbol: "\uE946"));
|
||||
else
|
||||
foreach (var e in filtered)
|
||||
items.Add(MakeEntryItem(e));
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
{
|
||||
// 검색어로 처리
|
||||
var keyword = q.ToLowerInvariant();
|
||||
var filtered = allEntries.Where(e =>
|
||||
e.Name.Contains(keyword, StringComparison.OrdinalIgnoreCase) ||
|
||||
e.Command.Contains(keyword, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (filtered.Count == 0)
|
||||
items.Add(new LauncherItem("결과 없음", $"'{q}' 항목 없음", null, null, Symbol: "\uE946"));
|
||||
else
|
||||
foreach (var e in filtered.Take(15))
|
||||
items.Add(MakeEntryItem(e));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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("Startup", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
break;
|
||||
|
||||
case ("open_folder", string path):
|
||||
var folderPath = string.IsNullOrEmpty(path) ? GetStartupFolderUser() : path;
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = folderPath,
|
||||
UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
NotificationService.Notify("Startup", $"폴더 열기 실패: {ex.Message}");
|
||||
}
|
||||
break;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 레지스트리 수집 ────────────────────────────────────────────────────────
|
||||
|
||||
private record StartupEntry(string Name, string Command, string Scope, string Source);
|
||||
|
||||
private static List<StartupEntry> CollectAllEntries()
|
||||
{
|
||||
var result = new List<StartupEntry>();
|
||||
|
||||
// HKCU
|
||||
foreach (var (regPath, scope) in RegKeys)
|
||||
result.AddRange(ReadRegistryRun(Registry.CurrentUser, regPath, scope));
|
||||
|
||||
// HKLM
|
||||
foreach (var (regPath, scope) in RegKeysHKLM)
|
||||
result.AddRange(ReadRegistryRun(Registry.LocalMachine, regPath, scope));
|
||||
|
||||
// 시작 폴더 (현재 사용자)
|
||||
result.AddRange(ReadStartupFolder(GetStartupFolderUser(), "현재 사용자 (폴더)"));
|
||||
|
||||
// 시작 폴더 (모든 사용자)
|
||||
result.AddRange(ReadStartupFolder(GetStartupFolderCommon(), "모든 사용자 (폴더)"));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IEnumerable<StartupEntry> ReadRegistryRun(RegistryKey hive, string path, string scope)
|
||||
{
|
||||
RegistryKey? key;
|
||||
try { key = hive.OpenSubKey(path, writable: false); }
|
||||
catch { yield break; }
|
||||
|
||||
if (key == null) yield break;
|
||||
|
||||
using (key)
|
||||
{
|
||||
foreach (var name in key.GetValueNames())
|
||||
{
|
||||
var cmd = key.GetValue(name)?.ToString() ?? "";
|
||||
yield return new StartupEntry(name, cmd, scope, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<StartupEntry> ReadStartupFolder(string folderPath, string scope)
|
||||
{
|
||||
if (!Directory.Exists(folderPath)) yield break;
|
||||
foreach (var file in Directory.EnumerateFiles(folderPath, "*.lnk"))
|
||||
{
|
||||
yield return new StartupEntry(
|
||||
Path.GetFileNameWithoutExtension(file),
|
||||
file,
|
||||
scope,
|
||||
folderPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetStartupFolderUser() =>
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.Startup);
|
||||
|
||||
private static string GetStartupFolderCommon() =>
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonStartup);
|
||||
|
||||
private static LauncherItem MakeEntryItem(StartupEntry e)
|
||||
{
|
||||
var cmdShort = e.Command.Length > 70 ? e.Command[..70] + "…" : e.Command;
|
||||
return new LauncherItem(
|
||||
e.Name,
|
||||
$"{e.Scope} · {cmdShort}",
|
||||
null,
|
||||
("copy", e.Command),
|
||||
Symbol: "\uE7FC");
|
||||
}
|
||||
}
|
||||
459
src/AxCopilot/Handlers/StrHandler.cs
Normal file
459
src/AxCopilot/Handlers/StrHandler.cs
Normal file
@@ -0,0 +1,459 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Web;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L20-3: 문자열 조작 도구 핸들러. "str" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: str → 클립보드 텍스트 조작 메뉴
|
||||
/// str escape html → HTML 특수문자 이스케이프
|
||||
/// str unescape html → HTML 이스케이프 해제
|
||||
/// str escape url → URL 인코딩 (퍼센트)
|
||||
/// str unescape url → URL 디코딩
|
||||
/// str escape json → JSON 문자열 이스케이프
|
||||
/// str escape regex → 정규식 이스케이프
|
||||
/// str repeat 3 → 클립보드 텍스트 3회 반복
|
||||
/// str repeat 5 , → 쉼표 구분 5회 반복
|
||||
/// str pad 20 → 20자 우측 공백 패딩
|
||||
/// str pad 20 left → 좌측 패딩
|
||||
/// str pad 20 * right → 지정 문자로 우측 패딩
|
||||
/// str wrap 80 → 80자 줄바꿈
|
||||
/// str lines → 줄 수·단어·문자 통계
|
||||
/// str sort → 줄 정렬 (오름차순)
|
||||
/// str sort desc → 줄 정렬 (내림차순)
|
||||
/// str unique → 중복 줄 제거
|
||||
/// str join , → 여러 줄 → 쉼표 구분 한 줄
|
||||
/// str split , → 쉼표 구분 → 여러 줄
|
||||
/// str replace a b → 텍스트 내 a를 b로 교체
|
||||
/// str extract email → 이메일 주소 추출
|
||||
/// str extract url → URL 추출
|
||||
/// str extract number → 숫자 추출
|
||||
/// Enter → 결과 복사.
|
||||
/// </summary>
|
||||
public partial class StrHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "str";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Str",
|
||||
"문자열 조작 도구 — HTML/URL/JSON 이스케이프·반복·패딩·줄 정렬·추출",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
// 클립보드 읽기
|
||||
string? clipboard = null;
|
||||
try
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
if (Clipboard.ContainsText()) clipboard = Clipboard.GetText();
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
items.Add(new LauncherItem("문자열 조작 도구",
|
||||
"str escape/unescape / str repeat / str pad / str sort / str extract …",
|
||||
null, null, Symbol: "\uE8AB"));
|
||||
if (!string.IsNullOrWhiteSpace(clipboard))
|
||||
{
|
||||
var preview = clipboard!.Length > 40 ? clipboard[..40] + "…" : clipboard;
|
||||
items.Add(new LauncherItem($"클립보드: \"{preview}\"",
|
||||
$"{clipboard.Length}자 · 아래 서브커맨드로 조작", null, null, Symbol: "\uE8AB"));
|
||||
BuildQuickMenu(items, clipboard!);
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem("클립보드가 비어 있습니다", "텍스트를 복사한 뒤 사용하세요",
|
||||
null, null, Symbol: "\uE946"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
var text = parts.Length > 1
|
||||
? string.Join(" ", parts[1..]) // 인라인 텍스트 (없으면 클립보드)
|
||||
: clipboard ?? "";
|
||||
|
||||
// escape / unescape
|
||||
if (sub is "escape" or "esc" or "unescape" or "unesc")
|
||||
{
|
||||
var isEscape = sub is "escape" or "esc";
|
||||
var target = parts.Length >= 2 ? parts[1].ToLowerInvariant() : "html";
|
||||
var src = parts.Length >= 3 ? string.Join(" ", parts[2..]) : clipboard ?? "";
|
||||
|
||||
if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("처리할 텍스트가 없습니다")); }
|
||||
else
|
||||
{
|
||||
var (html, url, json, reg) = (
|
||||
isEscape ? HtmlEncode(src) : HtmlDecode(src),
|
||||
isEscape ? Uri.EscapeDataString(src) : Uri.UnescapeDataString(src),
|
||||
isEscape ? JsonEscape(src) : JsonUnescape(src),
|
||||
isEscape ? RegexEscape(src) : src
|
||||
);
|
||||
var label = isEscape ? "이스케이프" : "이스케이프 해제";
|
||||
items.Add(new LauncherItem($"── {label} ──", "", null, null, Symbol: "\uE8AB"));
|
||||
items.Add(CopyItem("HTML", isEscape ? HtmlEncode(src) : HtmlDecode(src)));
|
||||
items.Add(CopyItem("URL", isEscape ? Uri.EscapeDataString(src) : Uri.UnescapeDataString(src)));
|
||||
items.Add(CopyItem("JSON", isEscape ? JsonEscape(src) : JsonUnescape(src)));
|
||||
if (isEscape) items.Add(CopyItem("Regex", RegexEscape(src)));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// repeat
|
||||
if (sub == "repeat")
|
||||
{
|
||||
int count = 3;
|
||||
string sep = "";
|
||||
string src = clipboard ?? "";
|
||||
|
||||
if (parts.Length >= 2 && int.TryParse(parts[1], out var n)) count = Math.Clamp(n, 1, 100);
|
||||
if (parts.Length >= 3) sep = parts[2] == "\\n" ? "\n" : parts[2] == "\\t" ? "\t" : parts[2];
|
||||
if (parts.Length >= 4) src = string.Join(" ", parts[3..]);
|
||||
|
||||
if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("반복할 텍스트가 없습니다")); }
|
||||
else
|
||||
{
|
||||
var result = string.Join(sep, Enumerable.Repeat(src, count));
|
||||
items.Add(new LauncherItem(result.Length > 60 ? result[..60] + "…" : result,
|
||||
$"{count}회 반복 · Enter 복사", null, ("copy", result), Symbol: "\uE8AB"));
|
||||
items.Add(CopyItem("결과", result));
|
||||
items.Add(CopyItem("결과 길이", $"{result.Length}자"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// pad
|
||||
if (sub == "pad")
|
||||
{
|
||||
if (parts.Length < 2 || !int.TryParse(parts[1], out var width))
|
||||
{ items.Add(ErrorItem("예: str pad 20 / str pad 20 left / str pad 20 * right")); }
|
||||
else
|
||||
{
|
||||
var side = parts.Length >= 3 ? parts[2].ToLowerInvariant() : "right";
|
||||
var padChar = ' ';
|
||||
if (parts.Length >= 4 && parts[2].Length == 1) { padChar = parts[2][0]; side = parts[3].ToLowerInvariant(); }
|
||||
if (parts.Length >= 3 && parts[2].Length == 1 && !"left right both".Contains(parts[2])) padChar = parts[2][0];
|
||||
|
||||
var src = clipboard ?? "";
|
||||
if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("클립보드에 텍스트가 없습니다")); }
|
||||
else
|
||||
{
|
||||
var result = side switch
|
||||
{
|
||||
"left" => src.PadLeft(width, padChar),
|
||||
"both" => src.PadLeft((src.Length + width) / 2, padChar).PadRight(width, padChar),
|
||||
_ => src.PadRight(width, padChar)
|
||||
};
|
||||
items.Add(new LauncherItem($"\"{result}\"", $"{side} 패딩 {width}자 · Enter 복사",
|
||||
null, ("copy", result), Symbol: "\uE8AB"));
|
||||
items.Add(CopyItem("결과", result));
|
||||
}
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// wrap
|
||||
if (sub == "wrap")
|
||||
{
|
||||
if (parts.Length < 2 || !int.TryParse(parts[1], out var cols))
|
||||
{ items.Add(ErrorItem("예: str wrap 80")); }
|
||||
else
|
||||
{
|
||||
var src = parts.Length >= 3 ? string.Join(" ", parts[2..]) : clipboard ?? "";
|
||||
if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("줄바꿈할 텍스트가 없습니다")); }
|
||||
else
|
||||
{
|
||||
var result = WordWrap(src, cols);
|
||||
var preview = result.Split('\n').Take(5);
|
||||
items.Add(new LauncherItem($"{cols}자 줄바꿈",
|
||||
$"{result.Split('\n').Length}줄 · Enter 복사", null, ("copy", result), Symbol: "\uE8AB"));
|
||||
foreach (var line in preview)
|
||||
items.Add(new LauncherItem(line.Length > 60 ? line[..60] + "…" : line, "",
|
||||
null, null, Symbol: "\uE8AB"));
|
||||
}
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// sort
|
||||
if (sub == "sort")
|
||||
{
|
||||
var src = parts.Length >= 3 ? string.Join(" ", parts[2..]) : clipboard ?? "";
|
||||
var desc = parts.Length >= 2 && parts[1].ToLowerInvariant() is "desc" or "d";
|
||||
if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("정렬할 텍스트가 없습니다")); }
|
||||
else
|
||||
{
|
||||
var lines = src.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sorted = desc ? lines.OrderByDescending(l => l).ToArray()
|
||||
: lines.OrderBy(l => l).ToArray();
|
||||
var result = string.Join("\n", sorted);
|
||||
items.Add(new LauncherItem($"{sorted.Length}줄 {(desc ? "내림차순" : "오름차순")} 정렬",
|
||||
"Enter 복사", null, ("copy", result), Symbol: "\uE8AB"));
|
||||
foreach (var line in sorted.Take(6))
|
||||
items.Add(new LauncherItem(line.Length > 60 ? line[..60] : line, "",
|
||||
null, null, Symbol: "\uE8AB"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// unique
|
||||
if (sub is "unique" or "dedup")
|
||||
{
|
||||
var src = parts.Length >= 2 ? string.Join(" ", parts[1..]) : clipboard ?? "";
|
||||
if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("중복 제거할 텍스트가 없습니다")); }
|
||||
else
|
||||
{
|
||||
var lines = src.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
var unique = lines.Distinct(StringComparer.Ordinal).ToArray();
|
||||
var result = string.Join("\n", unique);
|
||||
items.Add(new LauncherItem($"{lines.Length}줄 → {unique.Length}줄 (중복 {lines.Length - unique.Length}개 제거)",
|
||||
"Enter 복사", null, ("copy", result), Symbol: "\uE8AB"));
|
||||
foreach (var line in unique.Take(6))
|
||||
items.Add(new LauncherItem(line.Length > 60 ? line[..60] : line, "",
|
||||
null, null, Symbol: "\uE8AB"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// join
|
||||
if (sub == "join")
|
||||
{
|
||||
var sep = parts.Length >= 2 ? parts[1] : ",";
|
||||
if (sep == "\\n") sep = "\n"; else if (sep == "\\t") sep = "\t";
|
||||
var src = clipboard ?? "";
|
||||
if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("연결할 텍스트가 없습니다")); }
|
||||
else
|
||||
{
|
||||
var lines = src.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
var result = string.Join(sep, lines);
|
||||
items.Add(new LauncherItem(result.Length > 60 ? result[..60] + "…" : result,
|
||||
$"{lines.Length}줄 연결 · Enter 복사", null, ("copy", result), Symbol: "\uE8AB"));
|
||||
items.Add(CopyItem("결과", result));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// split
|
||||
if (sub == "split")
|
||||
{
|
||||
var sep = parts.Length >= 2 ? parts[1] : ",";
|
||||
if (sep == "\\n") sep = "\n"; else if (sep == "\\t") sep = "\t";
|
||||
var src = parts.Length >= 3 ? string.Join(" ", parts[2..]) : clipboard ?? "";
|
||||
if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("분리할 텍스트가 없습니다")); }
|
||||
else
|
||||
{
|
||||
var splitted = src.Split(sep, StringSplitOptions.None);
|
||||
var result = string.Join("\n", splitted);
|
||||
items.Add(new LauncherItem($"'{sep}'로 분리 → {splitted.Length}개",
|
||||
"Enter 복사", null, ("copy", result), Symbol: "\uE8AB"));
|
||||
foreach (var item in splitted.Take(8))
|
||||
items.Add(new LauncherItem(item.Length > 60 ? item[..60] : item, "",
|
||||
null, null, Symbol: "\uE8AB"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// replace
|
||||
if (sub == "replace")
|
||||
{
|
||||
if (parts.Length < 3) { items.Add(ErrorItem("예: str replace 찾을텍스트 바꿀텍스트")); }
|
||||
else
|
||||
{
|
||||
var from = parts[1];
|
||||
var to = parts[2];
|
||||
var src = parts.Length >= 4 ? string.Join(" ", parts[3..]) : clipboard ?? "";
|
||||
if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("처리할 텍스트가 없습니다")); }
|
||||
else
|
||||
{
|
||||
var count = 0;
|
||||
var result = ReplaceCount(src, from, to, out count);
|
||||
items.Add(new LauncherItem($"'{from}' → '{to}' ({count}개 교체)",
|
||||
"Enter 복사", null, ("copy", result), Symbol: "\uE8AB"));
|
||||
items.Add(CopyItem("결과", result));
|
||||
}
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// extract
|
||||
if (sub == "extract")
|
||||
{
|
||||
var target = parts.Length >= 2 ? parts[1].ToLowerInvariant() : "url";
|
||||
var src = parts.Length >= 3 ? string.Join(" ", parts[2..]) : clipboard ?? "";
|
||||
if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("추출할 텍스트가 없습니다")); }
|
||||
else
|
||||
{
|
||||
var matches = target switch
|
||||
{
|
||||
"email" => EmailRegex().Matches(src).Select(m => m.Value).Distinct().ToList(),
|
||||
"url" => UrlRegex().Matches(src).Select(m => m.Value).Distinct().ToList(),
|
||||
"num" or "number" or "숫자" =>
|
||||
NumberRegex().Matches(src).Select(m => m.Value).ToList(),
|
||||
"ip" => IpRegex().Matches(src).Select(m => m.Value).Distinct().ToList(),
|
||||
_ => new List<string>()
|
||||
};
|
||||
if (matches.Count == 0)
|
||||
items.Add(new LauncherItem($"'{target}' 패턴 없음", src.Length > 40 ? src[..40] : src,
|
||||
null, null, Symbol: "\uE8AB"));
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"{target} {matches.Count}개 추출",
|
||||
"Enter로 전체 복사", null, ("copy", string.Join("\n", matches)), Symbol: "\uE8AB"));
|
||||
foreach (var m in matches.Take(10))
|
||||
items.Add(new LauncherItem(m, "", null, ("copy", m), Symbol: "\uE8AB"));
|
||||
}
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// lines
|
||||
if (sub is "lines" or "info" or "count")
|
||||
{
|
||||
var src = clipboard ?? "";
|
||||
if (string.IsNullOrEmpty(src)) { items.Add(ErrorItem("분석할 텍스트가 없습니다")); }
|
||||
else
|
||||
{
|
||||
var lineArr = src.Split('\n');
|
||||
var words = src.Split(new[] {' ','\t','\n','\r'}, StringSplitOptions.RemoveEmptyEntries);
|
||||
items.Add(new LauncherItem($"{lineArr.Length}줄 · {words.Length}단어 · {src.Length}자",
|
||||
"텍스트 분석", null, null, Symbol: "\uE8AB"));
|
||||
items.Add(CopyItem("전체 줄 수", lineArr.Length.ToString()));
|
||||
items.Add(CopyItem("빈 줄 수", lineArr.Count(l => string.IsNullOrWhiteSpace(l)).ToString()));
|
||||
items.Add(CopyItem("단어 수", words.Length.ToString()));
|
||||
items.Add(CopyItem("전체 문자 수", src.Length.ToString()));
|
||||
items.Add(CopyItem("공백 제외 문자", src.Count(c => !char.IsWhiteSpace(c)).ToString()));
|
||||
items.Add(CopyItem("바이트 (UTF-8)", Encoding.UTF8.GetByteCount(src).ToString()));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 알 수 없는 커맨드
|
||||
items.Add(new LauncherItem($"알 수 없는 서브커맨드: '{sub}'",
|
||||
"escape · unescape · repeat · pad · wrap · sort · unique · join · split · replace · extract · lines",
|
||||
null, null, Symbol: "\uE783"));
|
||||
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("Str", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 헬퍼 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static void BuildQuickMenu(List<LauncherItem> items, string src)
|
||||
{
|
||||
items.Add(CopyItem("HTML 이스케이프", HtmlEncode(src)));
|
||||
items.Add(CopyItem("URL 인코딩", Uri.EscapeDataString(src)));
|
||||
items.Add(CopyItem("JSON 이스케이프", JsonEscape(src)));
|
||||
}
|
||||
|
||||
private static string HtmlEncode(string s) => s
|
||||
.Replace("&", "&").Replace("<", "<").Replace(">", ">")
|
||||
.Replace("\"", """).Replace("'", "'");
|
||||
|
||||
private static string HtmlDecode(string s) =>
|
||||
HttpUtility.HtmlDecode(s);
|
||||
|
||||
private static string JsonEscape(string s)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var c in s)
|
||||
{
|
||||
switch (c)
|
||||
{
|
||||
case '"': sb.Append("\\\""); break;
|
||||
case '\\': sb.Append("\\\\"); break;
|
||||
case '\n': sb.Append("\\n"); break;
|
||||
case '\r': sb.Append("\\r"); break;
|
||||
case '\t': sb.Append("\\t"); break;
|
||||
default:
|
||||
if (c < 0x20) sb.Append($"\\u{(int)c:X4}");
|
||||
else sb.Append(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string JsonUnescape(string s) =>
|
||||
s.Replace("\\\"", "\"").Replace("\\\\", "\\")
|
||||
.Replace("\\n", "\n").Replace("\\r", "\r").Replace("\\t", "\t");
|
||||
|
||||
private static string RegexEscape(string s) => Regex.Escape(s);
|
||||
|
||||
private static string WordWrap(string text, int cols)
|
||||
{
|
||||
var words = text.Split(' ');
|
||||
var sb = new StringBuilder();
|
||||
int colPos = 0;
|
||||
foreach (var word in words)
|
||||
{
|
||||
if (colPos + word.Length + 1 > cols && colPos > 0) { sb.Append('\n'); colPos = 0; }
|
||||
else if (colPos > 0) { sb.Append(' '); colPos++; }
|
||||
sb.Append(word);
|
||||
colPos += word.Length;
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string ReplaceCount(string src, string from, string to, out int count)
|
||||
{
|
||||
count = 0;
|
||||
var sb = new StringBuilder();
|
||||
int pos = 0;
|
||||
while (true)
|
||||
{
|
||||
var idx = src.IndexOf(from, pos, StringComparison.Ordinal);
|
||||
if (idx < 0) { sb.Append(src[pos..]); break; }
|
||||
sb.Append(src[pos..idx]);
|
||||
sb.Append(to);
|
||||
count++;
|
||||
pos = idx + from.Length;
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static LauncherItem CopyItem(string label, string value) =>
|
||||
new(label, value, null, ("copy", value), Symbol: "\uE8AB");
|
||||
|
||||
private static LauncherItem ErrorItem(string msg) =>
|
||||
new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783");
|
||||
|
||||
[GeneratedRegex(@"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}")]
|
||||
private static partial Regex EmailRegex();
|
||||
|
||||
[GeneratedRegex(@"https?://[^\s""'<>]+")]
|
||||
private static partial Regex UrlRegex();
|
||||
|
||||
[GeneratedRegex(@"-?\d+(\.\d+)?")]
|
||||
private static partial Regex NumberRegex();
|
||||
|
||||
[GeneratedRegex(@"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b")]
|
||||
private static partial Regex IpRegex();
|
||||
}
|
||||
281
src/AxCopilot/Handlers/SubnetHandler.cs
Normal file
281
src/AxCopilot/Handlers/SubnetHandler.cs
Normal file
@@ -0,0 +1,281 @@
|
||||
using System.Net;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L9-2: IP 서브넷 계산기 핸들러. "subnet" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: subnet 192.168.1.0/24 → 네트워크 정보 전체 표시
|
||||
/// subnet 10.0.0.5/24 → 해당 IP가 속한 서브넷 분석
|
||||
/// subnet 255.255.255.0 → 서브넷 마스크 → CIDR 변환
|
||||
/// subnet 192.168.1.0 24 → 슬래시 없이 입력 가능
|
||||
/// subnet range 192.168.1.10-50 → IP 범위 정보
|
||||
/// Enter → 결과를 클립보드에 복사.
|
||||
/// </summary>
|
||||
public class SubnetHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "subnet";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Subnet",
|
||||
"IP 서브넷 계산기 — CIDR · 네트워크 · 호스트 범위",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
// 자주 쓰는 CIDR 참조표
|
||||
private static readonly (int Prefix, int Hosts, string Desc)[] CidrRef =
|
||||
[
|
||||
(24, 254, "/24 — 클래스 C (254 호스트)"),
|
||||
(25, 126, "/25 — 128개 분할 (126 호스트)"),
|
||||
(26, 62, "/26 — 64개 분할 (62 호스트)"),
|
||||
(27, 30, "/27 — 32개 분할 (30 호스트)"),
|
||||
(28, 14, "/28 — 16개 분할 (14 호스트)"),
|
||||
(29, 6, "/29 — 8개 분할 (6 호스트)"),
|
||||
(30, 2, "/30 — 포인트-투-포인트 (2 호스트)"),
|
||||
(23, 510, "/23 — 512개 블록 (510 호스트)"),
|
||||
(22, 1022, "/22 — 1024개 블록 (1022 호스트)"),
|
||||
(20, 4094, "/20 — 4096개 블록 (4094 호스트)"),
|
||||
(16, 65534, "/16 — 클래스 B (65534 호스트)"),
|
||||
(8, 16777214, "/8 — 클래스 A (16M 호스트)"),
|
||||
];
|
||||
|
||||
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(
|
||||
"서브넷 계산기",
|
||||
"예: subnet 192.168.1.0/24 또는 subnet 10.0.0.5 24",
|
||||
null, null, Symbol: "\uE968"));
|
||||
|
||||
// CIDR 참조표 일부
|
||||
foreach (var (cidrLen, hosts, desc) in CidrRef.Take(6))
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
desc,
|
||||
$"호스트: {hosts:N0}개",
|
||||
null,
|
||||
("copy", desc),
|
||||
Symbol: "\uE968"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// "range" 서브커맨드
|
||||
if (q.StartsWith("range ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var rangeStr = q[6..].Trim();
|
||||
items.AddRange(BuildRangeItems(rangeStr));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 서브넷 마스크 입력 (예: 255.255.255.0)
|
||||
if (TryParseMask(q, out var cidrFromMask))
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"CIDR: /{cidrFromMask}",
|
||||
$"서브넷 마스크 {q} = /{cidrFromMask}",
|
||||
null,
|
||||
("copy", $"/{cidrFromMask}"),
|
||||
Symbol: "\uE968"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// CIDR 파싱: "IP/prefix" 또는 "IP prefix"
|
||||
if (!TryParseCidr(q, out var ip, out var prefix))
|
||||
{
|
||||
items.Add(new LauncherItem("형식 오류", "예: 192.168.1.0/24", null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
items.AddRange(BuildSubnetItems(ip, prefix));
|
||||
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("Subnet", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 서브넷 계산 ──────────────────────────────────────────────────────────
|
||||
|
||||
private static IEnumerable<LauncherItem> BuildSubnetItems(uint ip, int prefix)
|
||||
{
|
||||
var mask = prefix == 0 ? 0u : ~0u << (32 - prefix);
|
||||
var network = ip & mask;
|
||||
var broadcast = network | ~mask;
|
||||
var firstHost = prefix < 31 ? network + 1 : network;
|
||||
var lastHost = prefix < 31 ? broadcast - 1 : broadcast;
|
||||
var hostCount = prefix >= 31 ? (1u << (32 - prefix))
|
||||
: (broadcast - network - 1);
|
||||
|
||||
var summaryText = $"""
|
||||
네트워크: {ToIp(network)}/{prefix}
|
||||
서브넷마스크: {ToIp(mask)}
|
||||
첫 호스트: {ToIp(firstHost)}
|
||||
마지막 호스트: {ToIp(lastHost)}
|
||||
브로드캐스트: {ToIp(broadcast)}
|
||||
사용 가능 호스트: {hostCount:N0}개
|
||||
""";
|
||||
|
||||
yield return new LauncherItem(
|
||||
$"{ToIp(network)}/{prefix}",
|
||||
$"호스트 {hostCount:N0}개 · 전체 복사: Enter",
|
||||
null,
|
||||
("copy", summaryText),
|
||||
Symbol: "\uE968");
|
||||
|
||||
yield return new LauncherItem("서브넷 마스크", ToIp(mask), null, ("copy", ToIp(mask)), Symbol: "\uE968");
|
||||
yield return new LauncherItem("네트워크 주소", ToIp(network), null, ("copy", ToIp(network)), Symbol: "\uE968");
|
||||
yield return new LauncherItem("브로드캐스트", ToIp(broadcast), null, ("copy", ToIp(broadcast)), Symbol: "\uE968");
|
||||
yield return new LauncherItem("첫 호스트", ToIp(firstHost), null, ("copy", ToIp(firstHost)), Symbol: "\uE968");
|
||||
yield return new LauncherItem("마지막 호스트", ToIp(lastHost), null, ("copy", ToIp(lastHost)), Symbol: "\uE968");
|
||||
yield return new LauncherItem(
|
||||
"사용 가능 호스트",
|
||||
$"{hostCount:N0}개",
|
||||
null,
|
||||
("copy", hostCount.ToString()),
|
||||
Symbol: "\uE968");
|
||||
|
||||
// 입력 IP가 이 서브넷에 속하는지 확인
|
||||
if ((ip & mask) == network && ip != network && ip != broadcast)
|
||||
{
|
||||
yield return new LauncherItem(
|
||||
$"입력 IP {ToIp(ip)}",
|
||||
"이 서브넷에 속함 ✓",
|
||||
null, null, Symbol: "\uE73E");
|
||||
}
|
||||
|
||||
// 이진 표현
|
||||
yield return new LauncherItem(
|
||||
"이진 마스크",
|
||||
ToBinary(mask),
|
||||
null,
|
||||
("copy", ToBinary(mask)),
|
||||
Symbol: "\uE8C4");
|
||||
}
|
||||
|
||||
private static IEnumerable<LauncherItem> BuildRangeItems(string rangeStr)
|
||||
{
|
||||
// "192.168.1.10-50" 또는 "192.168.1.10-192.168.1.50"
|
||||
var parts = rangeStr.Split('-', 2);
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
yield return new LauncherItem("형식 오류", "예: 192.168.1.10-50", null, null, Symbol: "\uE783");
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (!TryParseIp(parts[0].Trim(), out var startIp))
|
||||
{
|
||||
yield return new LauncherItem("IP 형식 오류", parts[0], null, null, Symbol: "\uE783");
|
||||
yield break;
|
||||
}
|
||||
|
||||
uint endIp;
|
||||
if (uint.TryParse(parts[1].Trim(), out var lastOctet))
|
||||
{
|
||||
endIp = (startIp & 0xFFFFFF00) | lastOctet;
|
||||
}
|
||||
else if (!TryParseIp(parts[1].Trim(), out endIp))
|
||||
{
|
||||
yield return new LauncherItem("IP 형식 오류", parts[1], null, null, Symbol: "\uE783");
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (endIp < startIp)
|
||||
{
|
||||
yield return new LauncherItem("오류", "끝 IP가 시작 IP보다 작습니다", null, null, Symbol: "\uE783");
|
||||
yield break;
|
||||
}
|
||||
|
||||
var count = endIp - startIp + 1;
|
||||
yield return new LauncherItem(
|
||||
$"{ToIp(startIp)} — {ToIp(endIp)}",
|
||||
$"{count}개 IP",
|
||||
null,
|
||||
("copy", $"{ToIp(startIp)} - {ToIp(endIp)} ({count}개)"),
|
||||
Symbol: "\uE968");
|
||||
|
||||
yield return new LauncherItem("시작 IP", ToIp(startIp), null, ("copy", ToIp(startIp)), Symbol: "\uE968");
|
||||
yield return new LauncherItem("끝 IP", ToIp(endIp), null, ("copy", ToIp(endIp)), Symbol: "\uE968");
|
||||
yield return new LauncherItem("IP 수", $"{count:N0}개", null, ("copy", count.ToString()), Symbol: "\uE968");
|
||||
}
|
||||
|
||||
// ── 파싱 헬퍼 ────────────────────────────────────────────────────────────
|
||||
|
||||
private static bool TryParseCidr(string s, out uint ip, out int prefix)
|
||||
{
|
||||
ip = 0; prefix = 24;
|
||||
|
||||
// "IP/prefix" 형식
|
||||
var slashIdx = s.IndexOf('/');
|
||||
if (slashIdx >= 0)
|
||||
{
|
||||
if (!TryParseIp(s[..slashIdx].Trim(), out ip)) return false;
|
||||
if (!int.TryParse(s[(slashIdx + 1)..].Trim(), out prefix)) return false;
|
||||
prefix = Math.Clamp(prefix, 0, 32);
|
||||
return true;
|
||||
}
|
||||
|
||||
// "IP prefix" 형식 (공백 구분)
|
||||
var spaceIdx = s.LastIndexOf(' ');
|
||||
if (spaceIdx >= 0 && int.TryParse(s[(spaceIdx + 1)..].Trim(), out var p))
|
||||
{
|
||||
prefix = Math.Clamp(p, 0, 32);
|
||||
return TryParseIp(s[..spaceIdx].Trim(), out ip);
|
||||
}
|
||||
|
||||
// IP만 입력 → /24 기본
|
||||
return TryParseIp(s.Trim(), out ip);
|
||||
}
|
||||
|
||||
private static bool TryParseIp(string s, out uint ip)
|
||||
{
|
||||
ip = 0;
|
||||
if (!IPAddress.TryParse(s, out var addr)) return false;
|
||||
if (addr.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) return false;
|
||||
var bytes = addr.GetAddressBytes();
|
||||
ip = ((uint)bytes[0] << 24) | ((uint)bytes[1] << 16)
|
||||
| ((uint)bytes[2] << 8) | (uint)bytes[3];
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseMask(string s, out int prefix)
|
||||
{
|
||||
prefix = 0;
|
||||
if (!TryParseIp(s, out var mask)) return false;
|
||||
if (mask == 0) { prefix = 0; return true; }
|
||||
|
||||
// 유효한 서브넷 마스크인지 확인 (연속된 1 뒤에 0)
|
||||
var inverted = ~mask;
|
||||
if ((inverted & (inverted + 1)) != 0) return false;
|
||||
|
||||
prefix = 0;
|
||||
var tmp = mask;
|
||||
while ((tmp & 0x80000000) != 0) { prefix++; tmp <<= 1; }
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ToIp(uint ip) =>
|
||||
$"{ip >> 24}.{(ip >> 16) & 0xFF}.{(ip >> 8) & 0xFF}.{ip & 0xFF}";
|
||||
|
||||
private static string ToBinary(uint val) =>
|
||||
$"{Convert.ToString((int)(val >> 16), 2).PadLeft(16, '0')} {Convert.ToString((int)(val & 0xFFFF), 2).PadLeft(16, '0')}";
|
||||
}
|
||||
376
src/AxCopilot/Handlers/TableHandler.cs
Normal file
376
src/AxCopilot/Handlers/TableHandler.cs
Normal file
@@ -0,0 +1,376 @@
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L16-4: 텍스트 → 표 변환 핸들러. "table" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 클립보드 텍스트(탭 구분, CSV, 공백 정렬)를 표로 변환합니다.
|
||||
///
|
||||
/// 예: table → 클립보드 → 마크다운 표 변환
|
||||
/// table csv → 마크다운 → CSV 변환
|
||||
/// table html → 마크다운 → HTML 테이블 변환
|
||||
/// table flip → 행·열 전치(transpose)
|
||||
/// table sort 2 → 2번 열 기준 정렬
|
||||
/// table add <헤더> → 새 행 추가 (탭 구분)
|
||||
/// Enter → 결과를 클립보드에 복사.
|
||||
/// </summary>
|
||||
public class TableHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "table";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Table",
|
||||
"텍스트·CSV → 마크다운·HTML 표 변환 — 전치 · 정렬 · 추가",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
// 클립보드 읽기
|
||||
string? clipboard = null;
|
||||
try
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
if (Clipboard.ContainsText())
|
||||
clipboard = Clipboard.GetText();
|
||||
});
|
||||
}
|
||||
catch { /* 클립보드 접근 실패 */ }
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
items.Add(new LauncherItem("텍스트 → 표 변환기",
|
||||
"클립보드 탭·CSV·공백 구분 텍스트를 표로 변환 · table csv / html / flip / sort N",
|
||||
null, null, Symbol: "\uE81E"));
|
||||
items.Add(new LauncherItem("table", "→ 마크다운 표", null, null, Symbol: "\uE81E"));
|
||||
items.Add(new LauncherItem("table csv", "→ CSV 변환", null, null, Symbol: "\uE81E"));
|
||||
items.Add(new LauncherItem("table html", "→ HTML 테이블", null, null, Symbol: "\uE81E"));
|
||||
items.Add(new LauncherItem("table flip", "행·열 전치 (transpose)", null, null, Symbol: "\uE81E"));
|
||||
items.Add(new LauncherItem("table sort 2","2열 기준 정렬", null, null, Symbol: "\uE81E"));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(clipboard))
|
||||
{
|
||||
items.Add(new LauncherItem("클립보드가 비어 있습니다",
|
||||
"탭·쉼표·공백 구분 표 데이터를 복사한 뒤 사용하세요",
|
||||
null, null, Symbol: "\uE946"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 미리보기
|
||||
var previewRows = ParseTable(clipboard);
|
||||
if (previewRows.Count > 0)
|
||||
{
|
||||
var md = ToMarkdown(previewRows);
|
||||
items.Add(new LauncherItem($"마크다운 표 변환 ({previewRows.Count}행 × {previewRows[0].Count}열)",
|
||||
"Enter → 변환 결과 복사", null, ("copy", md), Symbol: "\uE81E"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(clipboard))
|
||||
{
|
||||
items.Add(new LauncherItem("클립보드가 비어 있습니다",
|
||||
"표 데이터를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
var rows = ParseTable(clipboard);
|
||||
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("표로 변환할 수 없습니다",
|
||||
"탭·쉼표·공백 구분 데이터가 아닌 것 같습니다",
|
||||
null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
switch (sub)
|
||||
{
|
||||
case "csv":
|
||||
{
|
||||
var csv = ToCsv(rows);
|
||||
items.Add(new LauncherItem($"CSV 변환 ({rows.Count}행 × {rows[0].Count}열)",
|
||||
"Enter → CSV 복사", null, ("copy", csv), Symbol: "\uE81E"));
|
||||
AddPreview(items, csv);
|
||||
break;
|
||||
}
|
||||
|
||||
case "html":
|
||||
{
|
||||
var html = ToHtml(rows);
|
||||
items.Add(new LauncherItem($"HTML 테이블 ({rows.Count}행 × {rows[0].Count}열)",
|
||||
"Enter → HTML 복사", null, ("copy", html), Symbol: "\uE81E"));
|
||||
AddPreview(items, html);
|
||||
break;
|
||||
}
|
||||
|
||||
case "md":
|
||||
case "markdown":
|
||||
{
|
||||
var md = ToMarkdown(rows);
|
||||
items.Add(new LauncherItem($"마크다운 표 ({rows.Count}행 × {rows[0].Count}열)",
|
||||
"Enter → 복사", null, ("copy", md), Symbol: "\uE81E"));
|
||||
AddPreview(items, md);
|
||||
break;
|
||||
}
|
||||
|
||||
case "flip":
|
||||
case "transpose":
|
||||
{
|
||||
var flipped = Transpose(rows);
|
||||
var md = ToMarkdown(flipped);
|
||||
items.Add(new LauncherItem($"전치됨 ({flipped.Count}행 × {flipped[0].Count}열)",
|
||||
"Enter → 마크다운 복사", null, ("copy", md), Symbol: "\uE81E"));
|
||||
var csv = ToCsv(flipped);
|
||||
items.Add(new LauncherItem("CSV로 복사", $"{flipped.Count}행 × {flipped[0].Count}열",
|
||||
null, ("copy", csv), Symbol: "\uE81E"));
|
||||
break;
|
||||
}
|
||||
|
||||
case "sort":
|
||||
{
|
||||
var colIdx = parts.Length > 1 && int.TryParse(parts[1], out var ci) ? ci - 1 : 0;
|
||||
var sorted = SortByColumn(rows, colIdx);
|
||||
var md = ToMarkdown(sorted);
|
||||
items.Add(new LauncherItem($"{colIdx + 1}열 기준 정렬",
|
||||
"Enter → 마크다운 복사", null, ("copy", md), Symbol: "\uE81E"));
|
||||
AddPreview(items, md);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
{
|
||||
// 기본: 마크다운 변환
|
||||
var md = ToMarkdown(rows);
|
||||
items.Add(new LauncherItem($"마크다운 표 ({rows.Count}행 × {rows[0].Count}열)",
|
||||
"Enter → 복사", null, ("copy", md), Symbol: "\uE81E"));
|
||||
var csv = ToCsv(rows);
|
||||
items.Add(new LauncherItem("CSV로 복사", $"{rows.Count}행",
|
||||
null, ("copy", csv), Symbol: "\uE81E"));
|
||||
var html = ToHtml(rows);
|
||||
items.Add(new LauncherItem("HTML로 복사", "테이블 태그",
|
||||
null, ("copy", html), Symbol: "\uE81E"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is ("copy", string text))
|
||||
{
|
||||
try
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(
|
||||
() => Clipboard.SetText(text));
|
||||
NotificationService.Notify("Table", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 파서 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>탭, 쉼표(CSV), 연속 공백 순으로 자동 감지하여 표로 파싱</summary>
|
||||
private static List<List<string>> ParseTable(string text)
|
||||
{
|
||||
var lines = text.Split('\n')
|
||||
.Select(l => l.TrimEnd('\r'))
|
||||
.Where(l => !string.IsNullOrWhiteSpace(l))
|
||||
.ToList();
|
||||
|
||||
if (lines.Count == 0) return new List<List<string>>();
|
||||
|
||||
// 구분자 자동 감지
|
||||
var tabCount = lines[0].Count(c => c == '\t');
|
||||
var commaCount = lines[0].Count(c => c == ',');
|
||||
|
||||
char delimiter;
|
||||
if (tabCount > 0) delimiter = '\t';
|
||||
else if (commaCount > 0) delimiter = ',';
|
||||
else delimiter = '\t'; // 공백 구분은 단순 분할
|
||||
|
||||
var rows = new List<List<string>>();
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var cols = delimiter == ','
|
||||
? ParseCsvLine(line)
|
||||
: line.Split(delimiter).Select(c => c.Trim()).ToList();
|
||||
rows.Add(cols);
|
||||
}
|
||||
|
||||
// 열 수 통일 (가장 많은 열 수 기준 패딩)
|
||||
var maxCols = rows.Max(r => r.Count);
|
||||
foreach (var row in rows)
|
||||
while (row.Count < maxCols) row.Add("");
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static List<string> ParseCsvLine(string line)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var current = new StringBuilder();
|
||||
var inQuote = false;
|
||||
|
||||
for (var i = 0; i < line.Length; i++)
|
||||
{
|
||||
var c = line[i];
|
||||
if (c == '"')
|
||||
{
|
||||
if (inQuote && i + 1 < line.Length && line[i + 1] == '"') { current.Append('"'); i++; }
|
||||
else inQuote = !inQuote;
|
||||
}
|
||||
else if (c == ',' && !inQuote)
|
||||
{
|
||||
result.Add(current.ToString());
|
||||
current.Clear();
|
||||
}
|
||||
else current.Append(c);
|
||||
}
|
||||
result.Add(current.ToString());
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── 변환 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static string ToMarkdown(List<List<string>> rows)
|
||||
{
|
||||
if (rows.Count == 0) return "";
|
||||
var sb = new StringBuilder();
|
||||
var maxCols = rows.Max(r => r.Count);
|
||||
|
||||
// 각 열 최대 너비
|
||||
var widths = new int[maxCols];
|
||||
for (var c = 0; c < maxCols; c++)
|
||||
widths[c] = rows.Max(r => c < r.Count ? r[c].Length : 0);
|
||||
|
||||
// 헤더
|
||||
sb.Append('|');
|
||||
for (var c = 0; c < maxCols; c++)
|
||||
sb.Append($" {rows[0][c].PadRight(widths[c])} |");
|
||||
sb.AppendLine();
|
||||
|
||||
// 구분선
|
||||
sb.Append('|');
|
||||
for (var c = 0; c < maxCols; c++)
|
||||
sb.Append($" {new string('-', Math.Max(widths[c], 3))} |");
|
||||
sb.AppendLine();
|
||||
|
||||
// 데이터 행
|
||||
for (var r = 1; r < rows.Count; r++)
|
||||
{
|
||||
sb.Append('|');
|
||||
for (var c = 0; c < maxCols; c++)
|
||||
{
|
||||
var cell = c < rows[r].Count ? rows[r][c] : "";
|
||||
sb.Append($" {cell.PadRight(widths[c])} |");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static string ToCsv(List<List<string>> rows)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
sb.AppendLine(string.Join(",", row.Select(c =>
|
||||
c.Contains(',') || c.Contains('"') || c.Contains('\n')
|
||||
? $"\"{c.Replace("\"", "\"\"")}\"" : c)));
|
||||
}
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static string ToHtml(List<List<string>> rows)
|
||||
{
|
||||
if (rows.Count == 0) return "";
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("<table>");
|
||||
|
||||
// 헤더
|
||||
sb.AppendLine(" <thead><tr>");
|
||||
foreach (var cell in rows[0])
|
||||
sb.AppendLine($" <th>{EscHtml(cell)}</th>");
|
||||
sb.AppendLine(" </tr></thead>");
|
||||
|
||||
// 바디
|
||||
sb.AppendLine(" <tbody>");
|
||||
for (var r = 1; r < rows.Count; r++)
|
||||
{
|
||||
sb.AppendLine(" <tr>");
|
||||
foreach (var cell in rows[r])
|
||||
sb.AppendLine($" <td>{EscHtml(cell)}</td>");
|
||||
sb.AppendLine(" </tr>");
|
||||
}
|
||||
sb.AppendLine(" </tbody>");
|
||||
sb.Append("</table>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static List<List<string>> Transpose(List<List<string>> rows)
|
||||
{
|
||||
if (rows.Count == 0) return rows;
|
||||
var maxCols = rows.Max(r => r.Count);
|
||||
var result = new List<List<string>>();
|
||||
for (var c = 0; c < maxCols; c++)
|
||||
{
|
||||
var row = new List<string>();
|
||||
for (var r = 0; r < rows.Count; r++)
|
||||
row.Add(c < rows[r].Count ? rows[r][c] : "");
|
||||
result.Add(row);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<List<string>> SortByColumn(List<List<string>> rows, int colIdx)
|
||||
{
|
||||
if (rows.Count <= 1) return rows;
|
||||
var header = rows[0];
|
||||
var data = rows.Skip(1).ToList();
|
||||
data.Sort((a, b) =>
|
||||
{
|
||||
var va = colIdx < a.Count ? a[colIdx] : "";
|
||||
var vb = colIdx < b.Count ? b[colIdx] : "";
|
||||
// 숫자이면 숫자 비교
|
||||
if (double.TryParse(va, out var na) && double.TryParse(vb, out var nb))
|
||||
return na.CompareTo(nb);
|
||||
return string.Compare(va, vb, StringComparison.OrdinalIgnoreCase);
|
||||
});
|
||||
var result = new List<List<string>> { header };
|
||||
result.AddRange(data);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static void AddPreview(List<LauncherItem> items, string text)
|
||||
{
|
||||
var lines = text.Split('\n').Take(3);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var t = line.Length > 60 ? line[..60] + "…" : line;
|
||||
if (!string.IsNullOrWhiteSpace(t))
|
||||
items.Add(new LauncherItem(t, "", null, null, Symbol: "\uE81E"));
|
||||
}
|
||||
}
|
||||
|
||||
private static string EscHtml(string s) =>
|
||||
s.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """);
|
||||
}
|
||||
264
src/AxCopilot/Handlers/TagHandler.cs
Normal file
264
src/AxCopilot/Handlers/TagHandler.cs
Normal file
@@ -0,0 +1,264 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// Phase L3-5: 파일 태그 핸들러. "tag" 프리픽스로 사용합니다.
|
||||
/// 파일·폴더에 사용자 태그를 부여하고 태그 기반으로 검색합니다.
|
||||
///
|
||||
/// 사용법:
|
||||
/// tag → 전체 태그 목록 (태그명 + 파일 수)
|
||||
/// tag work → "work" 태그가 부여된 파일 목록
|
||||
/// tag add work C:\path\file.docx → 파일에 "work" 태그 추가
|
||||
/// tag del work C:\path\file.docx → 파일에서 "work" 태그 제거
|
||||
/// tag clear C:\path\file.docx → 파일의 모든 태그 제거
|
||||
/// </summary>
|
||||
public class TagHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "tag";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"FileTag",
|
||||
"파일 태그 — tag",
|
||||
"1.0",
|
||||
"AX",
|
||||
"파일·폴더에 사용자 태그를 부여하고 태그 기반으로 검색합니다.");
|
||||
|
||||
private static FileTagService Tags => FileTagService.Instance;
|
||||
|
||||
// ─── GetItemsAsync ───────────────────────────────────────────────────────
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
|
||||
// ── add 명령: tag add <태그> <경로> ────────────────────────────────
|
||||
if (q.StartsWith("add ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var rest = q[4..].Trim();
|
||||
var spaceIdx = rest.IndexOf(' ');
|
||||
if (spaceIdx > 0)
|
||||
{
|
||||
var tag = rest[..spaceIdx].Trim();
|
||||
var path = rest[(spaceIdx + 1)..].Trim();
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(
|
||||
[
|
||||
new LauncherItem(
|
||||
$"태그 추가: [{tag}] → {Path.GetFileName(path)}",
|
||||
$"{path} · Enter로 추가",
|
||||
null,
|
||||
ValueTuple.Create("__TAG_ADD__", tag, path),
|
||||
Symbol: Symbols.Tag)
|
||||
]);
|
||||
}
|
||||
return HelpItems("tag add [태그] [경로]", "예: tag add work C:\\project\\report.docx");
|
||||
}
|
||||
|
||||
// ── del 명령: tag del <태그> <경로> ────────────────────────────────
|
||||
if (q.StartsWith("del ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var rest = q[4..].Trim();
|
||||
var spaceIdx = rest.IndexOf(' ');
|
||||
if (spaceIdx > 0)
|
||||
{
|
||||
var tag = rest[..spaceIdx].Trim();
|
||||
var path = rest[(spaceIdx + 1)..].Trim();
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(
|
||||
[
|
||||
new LauncherItem(
|
||||
$"태그 제거: [{tag}] ← {Path.GetFileName(path)}",
|
||||
$"{path} · Enter로 제거",
|
||||
null,
|
||||
ValueTuple.Create("__TAG_DEL__", tag, path),
|
||||
Symbol: Symbols.Delete)
|
||||
]);
|
||||
}
|
||||
return HelpItems("tag del [태그] [경로]", "예: tag del work C:\\project\\report.docx");
|
||||
}
|
||||
|
||||
// ── clear 명령: tag clear <경로> ───────────────────────────────────
|
||||
if (q.StartsWith("clear ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var path = q[6..].Trim();
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
var currentTags = Tags.GetTags(path);
|
||||
var tagList = currentTags.Count > 0
|
||||
? string.Join(", ", currentTags.Select(t => $"[{t}]"))
|
||||
: "태그 없음";
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(
|
||||
[
|
||||
new LauncherItem(
|
||||
$"태그 전체 제거: {Path.GetFileName(path)}",
|
||||
$"{tagList} · Enter로 모두 삭제",
|
||||
null,
|
||||
ValueTuple.Create("__TAG_CLEAR__", path),
|
||||
Symbol: Symbols.Delete)
|
||||
]);
|
||||
}
|
||||
return HelpItems("tag clear [경로]", "예: tag clear C:\\project\\report.docx");
|
||||
}
|
||||
|
||||
// ── 태그 검색 또는 전체 목록 ─────────────────────────────────────────
|
||||
return string.IsNullOrEmpty(q)
|
||||
? BuildTagListItems()
|
||||
: BuildTagSearchItems(q);
|
||||
}
|
||||
|
||||
// ─── ExecuteAsync ────────────────────────────────────────────────────────
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
// 태그 추가
|
||||
if (item.Data is ValueTuple<string, string, string> addCmd &&
|
||||
addCmd.Item1 == "__TAG_ADD__")
|
||||
{
|
||||
Tags.AddTag(addCmd.Item3, addCmd.Item2);
|
||||
NotificationService.Notify("AX Copilot",
|
||||
$"태그 추가: [{addCmd.Item2}] → {Path.GetFileName(addCmd.Item3)}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// 태그 제거
|
||||
if (item.Data is ValueTuple<string, string, string> delCmd &&
|
||||
delCmd.Item1 == "__TAG_DEL__")
|
||||
{
|
||||
Tags.RemoveTag(delCmd.Item3, delCmd.Item2);
|
||||
NotificationService.Notify("AX Copilot",
|
||||
$"태그 제거: [{delCmd.Item2}] ← {Path.GetFileName(delCmd.Item3)}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// 전체 태그 제거
|
||||
if (item.Data is ValueTuple<string, string> clearCmd &&
|
||||
clearCmd.Item1 == "__TAG_CLEAR__")
|
||||
{
|
||||
Tags.ClearTags(clearCmd.Item2);
|
||||
NotificationService.Notify("AX Copilot",
|
||||
$"태그 전체 제거: {Path.GetFileName(clearCmd.Item2)}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// 파일/폴더 열기
|
||||
if (item.Data is string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"[TagHandler] 파일 열기 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ─── 내부 헬퍼 ──────────────────────────────────────────────────────────
|
||||
|
||||
private static Task<IEnumerable<LauncherItem>> BuildTagListItems()
|
||||
{
|
||||
var allTags = Tags.GetAllTags();
|
||||
if (allTags.Count == 0)
|
||||
{
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(
|
||||
[
|
||||
new LauncherItem(
|
||||
"등록된 태그가 없습니다",
|
||||
"tag add [태그] [경로]로 태그를 추가하세요",
|
||||
null, null, Symbol: Symbols.Info),
|
||||
new LauncherItem(
|
||||
"사용법 보기",
|
||||
"tag add work C:\\path\\file.docx / tag work / tag clear C:\\path",
|
||||
null, null, Symbol: Symbols.Info),
|
||||
]);
|
||||
}
|
||||
|
||||
var items = allTags
|
||||
.OrderByDescending(kv => kv.Value)
|
||||
.ThenBy(kv => kv.Key)
|
||||
.Take(12)
|
||||
.Select(kv => new LauncherItem(
|
||||
$"[{kv.Key}]",
|
||||
$"{kv.Value}개 파일 · tag {kv.Key}로 파일 목록 보기",
|
||||
null, null,
|
||||
Symbol: Symbols.Tag))
|
||||
.ToList<LauncherItem>();
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
private static Task<IEnumerable<LauncherItem>> BuildTagSearchItems(string q)
|
||||
{
|
||||
var allTags = Tags.GetAllTags();
|
||||
|
||||
// 입력 q와 prefix match되는 태그 먼저, 그 다음 contains 순
|
||||
var matchedTags = allTags.Keys
|
||||
.Where(t => t.Contains(q, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(t => t.StartsWith(q, StringComparison.OrdinalIgnoreCase) ? 0 : 1)
|
||||
.ThenBy(t => t)
|
||||
.ToList();
|
||||
|
||||
if (!matchedTags.Any())
|
||||
{
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(
|
||||
[
|
||||
new LauncherItem(
|
||||
$"[{q}] 태그에 해당하는 파일 없음",
|
||||
"tag add [태그] [경로]로 태그를 추가하세요",
|
||||
null, null, Symbol: Symbols.Info)
|
||||
]);
|
||||
}
|
||||
|
||||
var items = new List<LauncherItem>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var tag in matchedTags.Take(2))
|
||||
{
|
||||
var files = Tags.GetFilesByTag(tag);
|
||||
foreach (var path in files.Take(6))
|
||||
{
|
||||
if (!seen.Add(path)) continue;
|
||||
|
||||
var isDir = Directory.Exists(path);
|
||||
var isFile = File.Exists(path);
|
||||
var symbol = isDir ? Symbols.Folder
|
||||
: isFile ? Symbols.File
|
||||
: Symbols.Warning;
|
||||
var hint = isDir ? "폴더 열기"
|
||||
: isFile ? "파일 열기"
|
||||
: "경로를 찾을 수 없음";
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
Path.GetFileName(path),
|
||||
$"[{tag}] · {path} · {hint}",
|
||||
null, path,
|
||||
Symbol: symbol));
|
||||
}
|
||||
}
|
||||
|
||||
if (!items.Any())
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"[{q}] 태그 파일 접근 불가",
|
||||
"파일이 삭제되었거나 경로가 변경되었을 수 있습니다",
|
||||
null, null, Symbol: Symbols.Warning));
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
private static Task<IEnumerable<LauncherItem>> HelpItems(string usage, string example) =>
|
||||
Task.FromResult<IEnumerable<LauncherItem>>(
|
||||
[
|
||||
new LauncherItem(
|
||||
$"사용법: {usage}",
|
||||
example,
|
||||
null, null, Symbol: Symbols.Info)
|
||||
]);
|
||||
}
|
||||
259
src/AxCopilot/Handlers/TextCaseHandler.cs
Normal file
259
src/AxCopilot/Handlers/TextCaseHandler.cs
Normal file
@@ -0,0 +1,259 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L18-2: 텍스트 케이스 변환 핸들러. "text" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: text → 클립보드 텍스트 모든 케이스 변환 목록
|
||||
/// text camel → camelCase
|
||||
/// text pascal → PascalCase
|
||||
/// text snake → snake_case
|
||||
/// text kebab → kebab-case
|
||||
/// text slug → url-slug (소문자 + 하이픈)
|
||||
/// text upper → UPPER CASE
|
||||
/// text lower → lower case
|
||||
/// text title → Title Case
|
||||
/// text sentence → Sentence case
|
||||
/// text const → SCREAMING_SNAKE_CASE
|
||||
/// text dot → dot.case
|
||||
/// text reverse → 문자 순서 뒤집기
|
||||
/// text trim → 앞뒤 공백·줄바꿈 제거
|
||||
/// Enter → 결과 복사.
|
||||
/// </summary>
|
||||
public partial class TextCaseHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "text";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Text",
|
||||
"텍스트 케이스 변환 — camelCase · snake_case · PascalCase · slug 등",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private record CaseItem(string Name, string Key, Func<string, string> Convert);
|
||||
|
||||
private static readonly CaseItem[] Cases =
|
||||
[
|
||||
new("camelCase", "camel", ToCamel),
|
||||
new("PascalCase", "pascal", ToPascal),
|
||||
new("snake_case", "snake", ToSnake),
|
||||
new("SCREAMING_SNAKE_CASE", "const", ToConst),
|
||||
new("kebab-case", "kebab", ToKebab),
|
||||
new("URL slug", "slug", ToSlug),
|
||||
new("dot.case", "dot", ToDot),
|
||||
new("UPPER CASE", "upper", s => s.ToUpperInvariant()),
|
||||
new("lower case", "lower", s => s.ToLowerInvariant()),
|
||||
new("Title Case", "title", ToTitle),
|
||||
new("Sentence case", "sentence", ToSentence),
|
||||
new("뒤집기 (reverse)", "reverse", s => new string(s.Reverse().ToArray())),
|
||||
new("공백 정리 (trim)", "trim", s => s.Trim()),
|
||||
];
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
string? clipboard = null;
|
||||
try
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
if (Clipboard.ContainsText())
|
||||
clipboard = Clipboard.GetText().Trim();
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
items.Add(new LauncherItem("텍스트 케이스 변환기",
|
||||
"클립보드 텍스트를 다양한 케이스로 변환 · text camel / snake / pascal / kebab…",
|
||||
null, null, Symbol: "\uE8AB"));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(clipboard))
|
||||
{
|
||||
items.Add(new LauncherItem("클립보드가 비어 있습니다",
|
||||
"텍스트를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946"));
|
||||
// 케이스 목록만 안내
|
||||
foreach (var c in Cases)
|
||||
items.Add(new LauncherItem($"text {c.Key}", c.Name, null, null, Symbol: "\uE8AB"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 클립보드 텍스트 → 모든 케이스 변환 목록
|
||||
var preview = clipboard.Length > 30 ? clipboard[..30] + "…" : clipboard;
|
||||
items.Add(new LauncherItem($"입력: \"{preview}\"", $"{clipboard.Length}자 · 아래에서 선택",
|
||||
null, null, Symbol: "\uE8AB"));
|
||||
|
||||
foreach (var c in Cases)
|
||||
{
|
||||
var result = TrySafeConvert(c.Convert, clipboard);
|
||||
items.Add(new LauncherItem(result, c.Name,
|
||||
null, ("copy", result), Symbol: "\uE8AB"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 서브커맨드로 특정 케이스 변환
|
||||
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
// 인라인 텍스트 입력 지원: text camel hello world → helloWorld
|
||||
var inlineText = parts.Length > 1 ? parts[1] : clipboard;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(inlineText))
|
||||
{
|
||||
items.Add(new LauncherItem("텍스트가 없습니다",
|
||||
"클립보드에 텍스트를 복사하거나 text camel <직접입력> 형식으로 사용하세요",
|
||||
null, null, Symbol: "\uE946"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var caseItem = Cases.FirstOrDefault(c =>
|
||||
c.Key.Equals(sub, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (caseItem != null)
|
||||
{
|
||||
var result = TrySafeConvert(caseItem.Convert, inlineText);
|
||||
var sourceLabel = parts.Length > 1 ? $"입력: \"{inlineText}\"" : $"클립보드: \"{(inlineText.Length > 30 ? inlineText[..30] + "…" : inlineText)}\"";
|
||||
items.Add(new LauncherItem(result,
|
||||
$"{caseItem.Name} · Enter 복사", null, ("copy", result), Symbol: "\uE8AB"));
|
||||
items.Add(new LauncherItem(sourceLabel, "원본", null, ("copy", inlineText), Symbol: "\uE8AB"));
|
||||
|
||||
// 다른 케이스도 함께 표시
|
||||
items.Add(new LauncherItem("── 다른 케이스 ──", "", null, null, Symbol: "\uE8AB"));
|
||||
foreach (var c in Cases.Where(c => c.Key != sub))
|
||||
{
|
||||
var r = TrySafeConvert(c.Convert, inlineText);
|
||||
if (r != result)
|
||||
items.Add(new LauncherItem(r, c.Name, null, ("copy", r), Symbol: "\uE8AB"));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 알 수 없는 서브커맨드 → 모든 케이스 변환
|
||||
items.Add(new LauncherItem($"알 수 없는 케이스: '{sub}'",
|
||||
"camel · pascal · snake · const · kebab · slug · dot · upper · lower · title · sentence · reverse · trim",
|
||||
null, null, Symbol: "\uE783"));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(clipboard))
|
||||
{
|
||||
foreach (var c in Cases)
|
||||
{
|
||||
var r = TrySafeConvert(c.Convert, clipboard);
|
||||
items.Add(new LauncherItem(r, c.Name, null, ("copy", r), Symbol: "\uE8AB"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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("Text", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 변환 함수 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static string TrySafeConvert(Func<string, string> fn, string input)
|
||||
{
|
||||
try { return fn(input); }
|
||||
catch { return input; }
|
||||
}
|
||||
|
||||
/// <summary>입력을 단어 토큰 배열로 분리 (공백, 언더스코어, 하이픈, 대문자 경계)</summary>
|
||||
private static string[] Tokenize(string s)
|
||||
{
|
||||
// camelCase/PascalCase 분리
|
||||
var withSpaces = CamelBoundaryRegex().Replace(s, "$1 $2");
|
||||
// 구분자 → 공백
|
||||
var normalized = SeparatorRegex().Replace(withSpaces, " ");
|
||||
return normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
|
||||
private static string ToCamel(string s)
|
||||
{
|
||||
var words = Tokenize(s);
|
||||
if (words.Length == 0) return s;
|
||||
var sb = new StringBuilder(words[0].ToLowerInvariant());
|
||||
for (var i = 1; i < words.Length; i++)
|
||||
sb.Append(char.ToUpperInvariant(words[i][0]) + words[i][1..].ToLowerInvariant());
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string ToPascal(string s)
|
||||
{
|
||||
var words = Tokenize(s);
|
||||
var sb = new StringBuilder();
|
||||
foreach (var w in words)
|
||||
sb.Append(char.ToUpperInvariant(w[0]) + w[1..].ToLowerInvariant());
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string ToSnake(string s) =>
|
||||
string.Join("_", Tokenize(s).Select(w => w.ToLowerInvariant()));
|
||||
|
||||
private static string ToConst(string s) =>
|
||||
string.Join("_", Tokenize(s).Select(w => w.ToUpperInvariant()));
|
||||
|
||||
private static string ToKebab(string s) =>
|
||||
string.Join("-", Tokenize(s).Select(w => w.ToLowerInvariant()));
|
||||
|
||||
private static string ToSlug(string s)
|
||||
{
|
||||
var normalized = s.Normalize(NormalizationForm.FormD);
|
||||
var ascii = new StringBuilder();
|
||||
foreach (var c in normalized)
|
||||
if (c < 128) ascii.Append(c);
|
||||
var slug = SeparatorRegex().Replace(ascii.ToString().ToLowerInvariant(), "-");
|
||||
slug = NonSlugRegex().Replace(slug, "");
|
||||
slug = MultipleDashRegex().Replace(slug, "-");
|
||||
return slug.Trim('-');
|
||||
}
|
||||
|
||||
private static string ToDot(string s) =>
|
||||
string.Join(".", Tokenize(s).Select(w => w.ToLowerInvariant()));
|
||||
|
||||
private static string ToTitle(string s)
|
||||
{
|
||||
var words = s.Split(' ');
|
||||
return string.Join(" ", words.Select(w =>
|
||||
w.Length == 0 ? w : char.ToUpperInvariant(w[0]) + w[1..].ToLowerInvariant()));
|
||||
}
|
||||
|
||||
private static string ToSentence(string s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s)) return s;
|
||||
var lower = s.ToLowerInvariant();
|
||||
return char.ToUpperInvariant(lower[0]) + lower[1..];
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"([a-z])([A-Z])")]
|
||||
private static partial Regex CamelBoundaryRegex();
|
||||
|
||||
[GeneratedRegex(@"[\s\-_./\\]+")]
|
||||
private static partial Regex SeparatorRegex();
|
||||
|
||||
[GeneratedRegex(@"[^a-z0-9\-]")]
|
||||
private static partial Regex NonSlugRegex();
|
||||
|
||||
[GeneratedRegex(@"-{2,}")]
|
||||
private static partial Regex MultipleDashRegex();
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
270
src/AxCopilot/Handlers/TimerHandler.cs
Normal file
270
src/AxCopilot/Handlers/TimerHandler.cs
Normal file
@@ -0,0 +1,270 @@
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L19-2: 타이머·알람 핸들러. "timer" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: timer → 사용법 + 실행 중인 타이머 목록
|
||||
/// timer 30 → 30초 타이머 (Enter로 시작)
|
||||
/// timer 5m → 5분 타이머
|
||||
/// timer 1h30m → 1시간 30분 타이머
|
||||
/// timer 2h → 2시간 타이머
|
||||
/// timer 10m30s → 10분 30초 타이머
|
||||
/// timer stop → 모든 타이머 취소
|
||||
/// timer stop <id> → 특정 타이머 취소
|
||||
/// Enter → 타이머 시작 (또는 중단).
|
||||
/// </summary>
|
||||
public class TimerHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "timer";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Timer",
|
||||
"타이머·알람 — 초/분/시간 단위 백그라운드 타이머",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
// 타이머 레코드
|
||||
private record TimerEntry(int Id, string Label, TimeSpan Duration, DateTime StartAt, CancellationTokenSource Cts);
|
||||
|
||||
// 정적 타이머 레지스트리
|
||||
private static readonly List<TimerEntry> _timers = [];
|
||||
private static readonly object _lock = new();
|
||||
private static int _nextId = 1;
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
items.Add(new LauncherItem("타이머·알람",
|
||||
"timer 30 / timer 5m / timer 1h30m / timer stop",
|
||||
null, null, Symbol: "\uE916"));
|
||||
items.Add(new LauncherItem("사용법", "timer <시간> · 예: 30(초) / 5m / 1h30m / 2h", null, null, Symbol: "\uE916"));
|
||||
AddRunningTimers(items);
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
// stop 명령
|
||||
if (sub is "stop" or "cancel" or "취소")
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_timers.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("실행 중인 타이머 없음", "취소할 타이머가 없습니다",
|
||||
null, null, Symbol: "\uE916"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 특정 ID 취소
|
||||
if (parts.Length >= 2 && int.TryParse(parts[1], out var stopId))
|
||||
{
|
||||
var t = _timers.FirstOrDefault(x => x.Id == stopId);
|
||||
if (t != null)
|
||||
items.Add(new LauncherItem($"타이머 #{t.Id} '{t.Label}' 취소",
|
||||
$"Enter로 취소합니다", null, ("stop", stopId.ToString()), Symbol: "\uE916"));
|
||||
else
|
||||
items.Add(new LauncherItem($"타이머 #{stopId}를 찾을 수 없습니다",
|
||||
"timer 명령으로 목록을 확인하세요", null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 전체 취소
|
||||
items.Add(new LauncherItem($"모든 타이머 취소 ({_timers.Count}개)",
|
||||
"Enter로 모두 취소합니다", null, ("stop_all", ""), Symbol: "\uE916"));
|
||||
foreach (var t in _timers)
|
||||
items.Add(new LauncherItem($" #{t.Id} {t.Label}",
|
||||
$"시작: {t.StartAt:HH:mm:ss} 남은: {Remaining(t)}",
|
||||
null, ("stop", t.Id.ToString()), Symbol: "\uE916"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 시간 파싱 시도
|
||||
if (TryParseTime(sub, out var duration) && duration > TimeSpan.Zero)
|
||||
{
|
||||
var label = FormatDuration(duration);
|
||||
var endTime = DateTime.Now.Add(duration);
|
||||
items.Add(new LauncherItem($"⏱ {label} 타이머 시작",
|
||||
$"완료: {endTime:HH:mm:ss} · Enter로 시작",
|
||||
null, ("start", DurationToSeconds(duration).ToString()), Symbol: "\uE916"));
|
||||
items.Add(new LauncherItem($"완료 예정", $"{endTime:HH:mm:ss}", null, null, Symbol: "\uE916"));
|
||||
items.Add(new LauncherItem($"경과 시간", label, null, null, Symbol: "\uE916"));
|
||||
|
||||
// 실행 중 타이머 표시
|
||||
AddRunningTimers(items);
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"형식 오류: '{q}'",
|
||||
"예: timer 30 / timer 5m / timer 1h30m / timer stop",
|
||||
null, null, Symbol: "\uE783"));
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
switch (item.Data)
|
||||
{
|
||||
case ("start", string secStr) when long.TryParse(secStr, out var sec):
|
||||
{
|
||||
var duration = TimeSpan.FromSeconds(sec);
|
||||
var label = FormatDuration(duration);
|
||||
var cts = new CancellationTokenSource();
|
||||
int id;
|
||||
lock (_lock)
|
||||
{
|
||||
id = _nextId++;
|
||||
_timers.Add(new TimerEntry(id, label, duration, DateTime.Now, cts));
|
||||
}
|
||||
NotificationService.Notify("Timer", $"⏱ #{id} {label} 타이머 시작됩니다.");
|
||||
_ = RunTimerAsync(id, label, duration, cts.Token);
|
||||
break;
|
||||
}
|
||||
|
||||
case ("stop", string idStr) when int.TryParse(idStr, out var stopId):
|
||||
{
|
||||
TimerEntry? entry = null;
|
||||
lock (_lock)
|
||||
{
|
||||
entry = _timers.FirstOrDefault(t => t.Id == stopId);
|
||||
if (entry != null) _timers.Remove(entry);
|
||||
}
|
||||
if (entry != null)
|
||||
{
|
||||
entry.Cts.Cancel();
|
||||
NotificationService.Notify("Timer", $"⏹ #{entry.Id} '{entry.Label}' 타이머가 취소되었습니다.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case ("stop_all", _):
|
||||
{
|
||||
List<TimerEntry> all;
|
||||
lock (_lock)
|
||||
{
|
||||
all = [.._timers];
|
||||
_timers.Clear();
|
||||
}
|
||||
foreach (var t in all) t.Cts.Cancel();
|
||||
NotificationService.Notify("Timer", $"⏹ 타이머 {all.Count}개 모두 취소됐습니다.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 타이머 실행 ──────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task RunTimerAsync(int id, string label, TimeSpan duration, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(duration, token);
|
||||
// 완료 — 레지스트리에서 제거
|
||||
lock (_lock) { _timers.RemoveAll(t => t.Id == id); }
|
||||
NotificationService.Notify("⏰ 타이머 완료", $"#{id} {label} 타이머가 종료되었습니다!");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 취소됨 — 이미 처리됨
|
||||
}
|
||||
}
|
||||
|
||||
// ── 파싱 헬퍼 ────────────────────────────────────────────────────────────
|
||||
|
||||
private static bool TryParseTime(string s, out TimeSpan ts)
|
||||
{
|
||||
ts = TimeSpan.Zero;
|
||||
s = s.ToLowerInvariant().Trim();
|
||||
|
||||
// 숫자만 → 초
|
||||
if (long.TryParse(s, out var sec))
|
||||
{
|
||||
ts = TimeSpan.FromSeconds(sec);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 복합 형식: 1h30m20s
|
||||
long hours = 0, minutes = 0, seconds = 0;
|
||||
var rem = s;
|
||||
bool found = false;
|
||||
|
||||
if (TryExtractUnit(ref rem, 'h', out var h)) { hours = h; found = true; }
|
||||
if (TryExtractUnit(ref rem, 'm', out var m)) { minutes = m; found = true; }
|
||||
if (TryExtractUnit(ref rem, 's', out var sv)){ seconds = sv; found = true; }
|
||||
|
||||
if (found && rem.Length == 0)
|
||||
{
|
||||
ts = TimeSpan.FromSeconds(hours * 3600 + minutes * 60 + seconds);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryExtractUnit(ref string s, char unit, out long value)
|
||||
{
|
||||
value = 0;
|
||||
var idx = s.IndexOf(unit);
|
||||
if (idx <= 0) return false;
|
||||
var numStr = s[..idx];
|
||||
if (!long.TryParse(numStr, out value)) return false;
|
||||
s = s[(idx + 1)..];
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string FormatDuration(TimeSpan ts)
|
||||
{
|
||||
if (ts.TotalSeconds < 60) return $"{(long)ts.TotalSeconds}초";
|
||||
if (ts.TotalMinutes < 60)
|
||||
{
|
||||
var mins = (long)ts.TotalMinutes;
|
||||
var secs = (long)(ts.TotalSeconds - mins * 60);
|
||||
return secs > 0 ? $"{mins}분 {secs}초" : $"{mins}분";
|
||||
}
|
||||
var h = (long)ts.TotalHours;
|
||||
var m = (long)(ts.TotalMinutes - h * 60);
|
||||
var s = (long)(ts.TotalSeconds - h * 3600 - m * 60);
|
||||
var result = $"{h}시간";
|
||||
if (m > 0) result += $" {m}분";
|
||||
if (s > 0) result += $" {s}초";
|
||||
return result;
|
||||
}
|
||||
|
||||
private static long DurationToSeconds(TimeSpan ts) => (long)ts.TotalSeconds;
|
||||
|
||||
private static string Remaining(TimerEntry t)
|
||||
{
|
||||
var elapsed = DateTime.Now - t.StartAt;
|
||||
var left = t.Duration - elapsed;
|
||||
if (left <= TimeSpan.Zero) return "완료";
|
||||
return FormatDuration(left);
|
||||
}
|
||||
|
||||
private static void AddRunningTimers(List<LauncherItem> items)
|
||||
{
|
||||
List<TimerEntry> running;
|
||||
lock (_lock) { running = [.._timers]; }
|
||||
if (running.Count == 0) return;
|
||||
|
||||
items.Add(new LauncherItem($"── 실행 중인 타이머 {running.Count}개 ──", "", null, null, Symbol: "\uE916"));
|
||||
foreach (var t in running)
|
||||
{
|
||||
var left = Remaining(t);
|
||||
items.Add(new LauncherItem($"#{t.Id} {t.Label}", $"남은 시간: {left} · 시작: {t.StartAt:HH:mm:ss}",
|
||||
null, ("stop", t.Id.ToString()), Symbol: "\uE916"));
|
||||
}
|
||||
}
|
||||
}
|
||||
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}원";
|
||||
}
|
||||
}
|
||||
250
src/AxCopilot/Handlers/TodayHandler.cs
Normal file
250
src/AxCopilot/Handlers/TodayHandler.cs
Normal file
@@ -0,0 +1,250 @@
|
||||
using System.Text.Json;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L25-4: 오늘 업무 통합 뷰. "today" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: today → 오늘 날짜/요일/공휴일 + 할일 + 알림 + 공휴일 현황
|
||||
/// </summary>
|
||||
public class TodayHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "today";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"오늘",
|
||||
"오늘 업무 통합 뷰 — 날짜·할일·알림·공휴일",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private static readonly string TodoPath = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "todos.json");
|
||||
|
||||
private static readonly string[] DayNames = ["일", "월", "화", "수", "목", "금", "토"];
|
||||
|
||||
// 2025~2027 주요 공휴일 (CalHandler에 직접 접근 불가하므로 독립 정의)
|
||||
private static readonly Dictionary<DateOnly, string> Holidays = new()
|
||||
{
|
||||
// 2025
|
||||
{ new DateOnly(2025, 1, 1), "신정" },
|
||||
{ new DateOnly(2025, 1, 28), "설날연휴" },
|
||||
{ new DateOnly(2025, 1, 29), "설날" },
|
||||
{ new DateOnly(2025, 1, 30), "설날연휴" },
|
||||
{ new DateOnly(2025, 3, 1), "삼일절" },
|
||||
{ new DateOnly(2025, 3, 3), "대체공휴일" },
|
||||
{ new DateOnly(2025, 5, 5), "어린이날" },
|
||||
{ new DateOnly(2025, 5, 6), "부처님오신날" },
|
||||
{ new DateOnly(2025, 6, 6), "현충일" },
|
||||
{ new DateOnly(2025, 8, 15), "광복절" },
|
||||
{ new DateOnly(2025, 10, 3), "개천절" },
|
||||
{ new DateOnly(2025, 10, 5), "추석연휴" },
|
||||
{ new DateOnly(2025, 10, 6), "추석" },
|
||||
{ new DateOnly(2025, 10, 7), "추석연휴" },
|
||||
{ new DateOnly(2025, 10, 8), "대체공휴일" },
|
||||
{ new DateOnly(2025, 10, 9), "한글날" },
|
||||
{ new DateOnly(2025, 12, 25), "크리스마스" },
|
||||
|
||||
// 2026
|
||||
{ new DateOnly(2026, 1, 1), "신정" },
|
||||
{ new DateOnly(2026, 2, 17), "설날연휴" },
|
||||
{ new DateOnly(2026, 2, 18), "설날" },
|
||||
{ new DateOnly(2026, 2, 19), "설날연휴" },
|
||||
{ new DateOnly(2026, 3, 1), "삼일절" },
|
||||
{ new DateOnly(2026, 3, 2), "대체공휴일" },
|
||||
{ new DateOnly(2026, 5, 5), "어린이날" },
|
||||
{ new DateOnly(2026, 5, 24), "부처님오신날" },
|
||||
{ new DateOnly(2026, 5, 25), "대체공휴일" },
|
||||
{ new DateOnly(2026, 6, 6), "현충일" },
|
||||
{ new DateOnly(2026, 6, 8), "대체공휴일" },
|
||||
{ new DateOnly(2026, 8, 15), "광복절" },
|
||||
{ new DateOnly(2026, 8, 17), "대체공휴일" },
|
||||
{ new DateOnly(2026, 9, 24), "추석연휴" },
|
||||
{ new DateOnly(2026, 9, 25), "추석" },
|
||||
{ new DateOnly(2026, 9, 26), "추석연휴" },
|
||||
{ new DateOnly(2026, 10, 3), "개천절" },
|
||||
{ new DateOnly(2026, 10, 5), "대체공휴일" },
|
||||
{ new DateOnly(2026, 10, 9), "한글날" },
|
||||
{ new DateOnly(2026, 12, 25), "크리스마스" },
|
||||
|
||||
// 2027
|
||||
{ new DateOnly(2027, 1, 1), "신정" },
|
||||
{ new DateOnly(2027, 2, 7), "설날연휴" },
|
||||
{ new DateOnly(2027, 2, 8), "설날" },
|
||||
{ new DateOnly(2027, 2, 9), "설날연휴" },
|
||||
{ new DateOnly(2027, 3, 1), "삼일절" },
|
||||
{ new DateOnly(2027, 5, 5), "어린이날" },
|
||||
{ new DateOnly(2027, 5, 13), "부처님오신날" },
|
||||
{ new DateOnly(2027, 6, 6), "현충일" },
|
||||
{ new DateOnly(2027, 6, 7), "대체공휴일" },
|
||||
{ new DateOnly(2027, 8, 15), "광복절" },
|
||||
{ new DateOnly(2027, 8, 16), "대체공휴일" },
|
||||
{ new DateOnly(2027, 9, 13), "추석연휴" },
|
||||
{ new DateOnly(2027, 9, 14), "추석" },
|
||||
{ new DateOnly(2027, 9, 15), "추석연휴" },
|
||||
{ new DateOnly(2027, 10, 3), "개천절" },
|
||||
{ new DateOnly(2027, 10, 4), "대체공휴일" },
|
||||
{ new DateOnly(2027, 10, 9), "한글날" },
|
||||
{ new DateOnly(2027, 12, 25), "크리스마스" },
|
||||
{ new DateOnly(2027, 12, 27), "대체공휴일" },
|
||||
};
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var items = new List<LauncherItem>();
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var now = DateTime.Now;
|
||||
|
||||
// ─── 항목1: 날짜 헤더 ────────────────────────────────────────────────
|
||||
var dow = DayNames[(int)today.DayOfWeek];
|
||||
var isHol = Holidays.TryGetValue(today, out var holName);
|
||||
var isWknd = today.DayOfWeek == DayOfWeek.Saturday || today.DayOfWeek == DayOfWeek.Sunday;
|
||||
string status;
|
||||
if (isHol) status = $"공휴일 — {holName}";
|
||||
else if (isWknd) status = "주말";
|
||||
else status = "평일 (업무일)";
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
$"{today:yyyy년 MM월 dd일} ({dow}요일)",
|
||||
status,
|
||||
null, ("copy", $"{today:yyyy-MM-dd} ({dow}) {status}"),
|
||||
Symbol: "\uE8BF"));
|
||||
|
||||
// ─── 항목2: 할일 ─────────────────────────────────────────────────────
|
||||
var (pendingCount, recentTitles) = LoadPendingTodos();
|
||||
var todoSub = pendingCount == 0
|
||||
? "미완료 할일 없음"
|
||||
: string.Join(" / ", recentTitles.Take(3));
|
||||
items.Add(new LauncherItem(
|
||||
$"미완료 할일 {pendingCount}건",
|
||||
todoSub,
|
||||
null, null, Symbol: "\uE762"));
|
||||
|
||||
// ─── 항목3: 알림 ─────────────────────────────────────────────────────
|
||||
var todayReminders = RemindHandler.GetTodayReminders();
|
||||
if (todayReminders.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("오늘 알림 없음",
|
||||
"remind HH:mm 메시지 로 알림을 설정하세요",
|
||||
null, null, Symbol: "\uE787"));
|
||||
}
|
||||
else
|
||||
{
|
||||
var remindSub = string.Join(" / ",
|
||||
todayReminders.Take(3).Select(r => $"{r.Time:HH:mm} {r.Message}"));
|
||||
items.Add(new LauncherItem(
|
||||
$"오늘 알림 {todayReminders.Count}건",
|
||||
remindSub,
|
||||
null, null, Symbol: "\uE787"));
|
||||
}
|
||||
|
||||
// ─── 항목4: 다음 공휴일 ──────────────────────────────────────────────
|
||||
var nextHol = Holidays.Keys
|
||||
.Where(d => d > today)
|
||||
.OrderBy(d => d)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (nextHol != default)
|
||||
{
|
||||
var diff = nextHol.DayNumber - today.DayNumber;
|
||||
var nextDow = DayNames[(int)nextHol.DayOfWeek];
|
||||
items.Add(new LauncherItem(
|
||||
$"다음 공휴일: {nextHol:MM/dd} ({nextDow}) {Holidays[nextHol]}",
|
||||
$"D-{diff}일",
|
||||
null, ("copy", $"{nextHol:yyyy-MM-dd} {Holidays[nextHol]} D-{diff}"),
|
||||
Symbol: "\uE787"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem("다음 공휴일 정보 없음", "",
|
||||
null, null, Symbol: "\uE787"));
|
||||
}
|
||||
|
||||
// ─── 항목5: 이번달 잔여 업무일 ───────────────────────────────────────
|
||||
var lastDay = new DateOnly(today.Year, today.Month,
|
||||
DateTime.DaysInMonth(today.Year, today.Month));
|
||||
var remaining = CountWorkdaysFrom(today, lastDay);
|
||||
var total = CountWorkdays(today.Year, today.Month);
|
||||
items.Add(new LauncherItem(
|
||||
$"이번달 잔여 업무일 {remaining}일",
|
||||
$"{today.Year}년 {today.Month}월 총 업무일: {total}일",
|
||||
null, null, Symbol: "\uE8BF"));
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is ("copy", string text) && !string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
try
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
|
||||
NotificationService.Notify("오늘", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 할일 파싱 ────────────────────────────────────────────────────────────
|
||||
|
||||
private static (int pending, List<string> titles) LoadPendingTodos()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!System.IO.File.Exists(TodoPath)) return (0, []);
|
||||
var json = System.IO.File.ReadAllText(TodoPath, System.Text.Encoding.UTF8);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Array) return (0, []);
|
||||
|
||||
var pending = 0;
|
||||
var titles = new List<string>();
|
||||
foreach (var el in root.EnumerateArray())
|
||||
{
|
||||
var done = false;
|
||||
if (el.TryGetProperty("done", out var doneProp))
|
||||
done = doneProp.GetBoolean();
|
||||
if (done) continue;
|
||||
|
||||
pending++;
|
||||
if (titles.Count < 3 && el.TryGetProperty("text", out var textProp))
|
||||
titles.Add(textProp.GetString() ?? "");
|
||||
}
|
||||
return (pending, titles);
|
||||
}
|
||||
catch { return (0, []); }
|
||||
}
|
||||
|
||||
// ── 업무일 계산 ──────────────────────────────────────────────────────────
|
||||
|
||||
private static bool IsHoliday(DateOnly d) =>
|
||||
Holidays.ContainsKey(d) ||
|
||||
d.DayOfWeek == DayOfWeek.Saturday ||
|
||||
d.DayOfWeek == DayOfWeek.Sunday;
|
||||
|
||||
private static int CountWorkdays(int year, int month)
|
||||
{
|
||||
var days = DateTime.DaysInMonth(year, month);
|
||||
var count = 0;
|
||||
for (var i = 1; i <= days; i++)
|
||||
if (!IsHoliday(new DateOnly(year, month, i))) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
private static int CountWorkdaysFrom(DateOnly from, DateOnly to)
|
||||
{
|
||||
var count = 0;
|
||||
var cur = from;
|
||||
while (cur <= to)
|
||||
{
|
||||
if (!IsHoliday(cur)) count++;
|
||||
cur = cur.AddDays(1);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
260
src/AxCopilot/Handlers/TodoHandler.cs
Normal file
260
src/AxCopilot/Handlers/TodoHandler.cs
Normal file
@@ -0,0 +1,260 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L16-3: 간단 할 일 목록 핸들러. "todo" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: todo → 전체 할 일 목록
|
||||
/// todo 보고서 작성 → 새 항목 추가
|
||||
/// todo done 1 → 1번 항목 완료 처리
|
||||
/// todo del 1 → 1번 항목 삭제
|
||||
/// todo clear → 완료 항목 모두 삭제
|
||||
/// todo clear all → 전체 삭제
|
||||
/// todo <검색어> → 키워드 필터
|
||||
/// Enter → 완료 토글 또는 항목 삭제.
|
||||
/// 저장: %APPDATA%\AxCopilot\todos.json
|
||||
/// </summary>
|
||||
public class TodoHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "todo";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Todo",
|
||||
"할 일 목록 — 추가 · 완료 · 삭제 · 검색",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private static readonly string DataPath = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "todos.json");
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
private record TodoItem(
|
||||
[property: JsonPropertyName("id")] int Id,
|
||||
[property: JsonPropertyName("text")] string Text,
|
||||
[property: JsonPropertyName("done")] bool Done,
|
||||
[property: JsonPropertyName("at")] string CreatedAt);
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
var todos = LoadTodos();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
var pending = todos.Count(t => !t.Done);
|
||||
var completed = todos.Count(t => t.Done);
|
||||
items.Add(new LauncherItem(
|
||||
$"할 일 {pending}개 완료 {completed}개",
|
||||
"todo <내용> → 추가 / todo done <번호> → 완료 / todo del <번호> → 삭제",
|
||||
null, null, Symbol: "\uE762"));
|
||||
|
||||
if (todos.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("할 일이 없습니다", "todo <내용> 을 입력하면 추가됩니다",
|
||||
null, null, Symbol: "\uE946"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 미완료 먼저, 완료 항목은 하단
|
||||
foreach (var t in todos.Where(t => !t.Done))
|
||||
items.Add(MakeTodoItem(t));
|
||||
|
||||
if (completed > 0)
|
||||
{
|
||||
items.Add(new LauncherItem("── 완료됨 ──", $"{completed}개 / todo clear → 정리",
|
||||
null, ("clear_done", ""), Symbol: "\uE762"));
|
||||
foreach (var t in todos.Where(t => t.Done))
|
||||
items.Add(MakeTodoItem(t));
|
||||
}
|
||||
|
||||
items.Add(new LauncherItem("완료 항목 삭제", "todo clear — 완료 항목 정리",
|
||||
null, ("clear_done", ""), Symbol: "\uE762"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
// done / check / complete
|
||||
if (sub is "done" or "check" or "complete" or "✓")
|
||||
{
|
||||
var num = parts.Length > 1 && int.TryParse(parts[1].Trim(), out var n) ? n : -1;
|
||||
if (num < 0)
|
||||
{
|
||||
items.Add(new LauncherItem("번호를 입력하세요", "예: todo done 2", null, null, Symbol: "\uE783"));
|
||||
}
|
||||
else
|
||||
{
|
||||
var target = todos.FirstOrDefault(t => t.Id == num);
|
||||
if (target == null)
|
||||
items.Add(new LauncherItem("없는 항목", $"#{num} 항목이 없습니다", null, null, Symbol: "\uE783"));
|
||||
else
|
||||
items.Add(new LauncherItem(
|
||||
target.Done ? $"#{num} 미완료로 되돌리기" : $"#{num} 완료 처리",
|
||||
target.Text, null, ("toggle", num.ToString()), Symbol: "\uE762"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// del / delete / remove / rm
|
||||
if (sub is "del" or "delete" or "remove" or "rm")
|
||||
{
|
||||
var num = parts.Length > 1 && int.TryParse(parts[1].Trim(), out var n) ? n : -1;
|
||||
if (num < 0)
|
||||
{
|
||||
items.Add(new LauncherItem("번호를 입력하세요", "예: todo del 3", null, null, Symbol: "\uE783"));
|
||||
}
|
||||
else
|
||||
{
|
||||
var target = todos.FirstOrDefault(t => t.Id == num);
|
||||
if (target == null)
|
||||
items.Add(new LauncherItem("없는 항목", $"#{num} 항목이 없습니다", null, null, Symbol: "\uE783"));
|
||||
else
|
||||
items.Add(new LauncherItem($"#{num} 삭제",
|
||||
target.Text, null, ("delete", num.ToString()), Symbol: "\uE762"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// clear — 완료 항목 삭제 / clear all → 전체 삭제
|
||||
if (sub == "clear")
|
||||
{
|
||||
var isAll = parts.Length > 1 && parts[1].ToLowerInvariant() == "all";
|
||||
if (isAll)
|
||||
items.Add(new LauncherItem("전체 삭제", $"할 일 {todos.Count}개 모두 삭제 · Enter 실행",
|
||||
null, ("clear_all", ""), Symbol: "\uE762"));
|
||||
else
|
||||
items.Add(new LauncherItem("완료 항목 삭제",
|
||||
$"완료 {todos.Count(t => t.Done)}개 삭제 · Enter 실행",
|
||||
null, ("clear_done", ""), Symbol: "\uE762"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 숫자만 → 완료 토글 단축
|
||||
if (int.TryParse(q, out var idNum))
|
||||
{
|
||||
var target = todos.FirstOrDefault(t => t.Id == idNum);
|
||||
if (target != null)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
target.Done ? $"#{idNum} 미완료로 되돌리기" : $"#{idNum} 완료 처리",
|
||||
target.Text, null, ("toggle", idNum.ToString()), Symbol: "\uE762"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
}
|
||||
|
||||
// 검색 또는 새 항목 추가
|
||||
var filtered = todos.Where(t => t.Text.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
if (filtered.Count > 0)
|
||||
{
|
||||
items.Add(new LauncherItem($"'{q}' 검색 결과 {filtered.Count}개", "", null, null, Symbol: "\uE762"));
|
||||
foreach (var t in filtered)
|
||||
items.Add(MakeTodoItem(t));
|
||||
}
|
||||
|
||||
// 새 항목 추가 제안
|
||||
items.Add(new LauncherItem($"새 할 일 추가: {q}",
|
||||
"Enter → 목록에 추가",
|
||||
null, ("add", q), Symbol: "\uE710"));
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
var todos = LoadTodos();
|
||||
|
||||
switch (item.Data)
|
||||
{
|
||||
case ("add", string text):
|
||||
var nextId = todos.Count > 0 ? todos.Max(t => t.Id) + 1 : 1;
|
||||
todos.Add(new TodoItem(nextId, text, false,
|
||||
DateTime.Now.ToString("yyyy-MM-dd HH:mm")));
|
||||
SaveTodos(todos);
|
||||
NotificationService.Notify("Todo", $"추가됨: {text}");
|
||||
break;
|
||||
|
||||
case ("toggle", string idStr) when int.TryParse(idStr, out var id):
|
||||
var idx = todos.FindIndex(t => t.Id == id);
|
||||
if (idx >= 0)
|
||||
{
|
||||
todos[idx] = todos[idx] with { Done = !todos[idx].Done };
|
||||
SaveTodos(todos);
|
||||
var state = todos[idx].Done ? "완료" : "미완료";
|
||||
NotificationService.Notify("Todo", $"#{id} {state}");
|
||||
}
|
||||
break;
|
||||
|
||||
case ("delete", string idStr) when int.TryParse(idStr, out var id):
|
||||
var before = todos.Count;
|
||||
todos.RemoveAll(t => t.Id == id);
|
||||
if (todos.Count < before)
|
||||
{
|
||||
SaveTodos(todos);
|
||||
NotificationService.Notify("Todo", $"#{id} 삭제됨");
|
||||
}
|
||||
break;
|
||||
|
||||
case ("clear_done", _):
|
||||
var doneCount = todos.RemoveAll(t => t.Done);
|
||||
SaveTodos(todos);
|
||||
NotificationService.Notify("Todo", $"완료 항목 {doneCount}개 삭제됨");
|
||||
break;
|
||||
|
||||
case ("clear_all", _):
|
||||
SaveTodos(new List<TodoItem>());
|
||||
NotificationService.Notify("Todo", "전체 삭제됨");
|
||||
break;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 저장/불러오기 ─────────────────────────────────────────────────────────
|
||||
|
||||
private static List<TodoItem> LoadTodos()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!System.IO.File.Exists(DataPath)) return new List<TodoItem>();
|
||||
var json = System.IO.File.ReadAllText(DataPath, System.Text.Encoding.UTF8);
|
||||
return JsonSerializer.Deserialize<List<TodoItem>>(json, JsonOpts) ?? new List<TodoItem>();
|
||||
}
|
||||
catch { return new List<TodoItem>(); }
|
||||
}
|
||||
|
||||
private static void SaveTodos(List<TodoItem> todos)
|
||||
{
|
||||
try
|
||||
{
|
||||
System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(DataPath)!);
|
||||
System.IO.File.WriteAllText(DataPath,
|
||||
JsonSerializer.Serialize(todos, JsonOpts),
|
||||
System.Text.Encoding.UTF8);
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
|
||||
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static LauncherItem MakeTodoItem(TodoItem t)
|
||||
{
|
||||
var icon = t.Done ? "\uE73E" : "\uECC5";
|
||||
var prefix = t.Done ? $"[✓] #{t.Id}" : $"[ ] #{t.Id}";
|
||||
var subtitle = $"{t.CreatedAt} · done {t.Id} = 완료 / del {t.Id} = 삭제";
|
||||
return new LauncherItem($"{prefix} {t.Text}", subtitle,
|
||||
null, ("toggle", t.Id.ToString()), Symbol: icon);
|
||||
}
|
||||
}
|
||||
372
src/AxCopilot/Handlers/TomlHandler.cs
Normal file
372
src/AxCopilot/Handlers/TomlHandler.cs
Normal file
@@ -0,0 +1,372 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L21-1: TOML 파서·분석기 핸들러. "toml" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: toml → 클립보드 TOML 전체 키 목록
|
||||
/// toml validate → 유효성 검사
|
||||
/// toml keys → 최상위 키 목록
|
||||
/// toml get key → 특정 키 값 조회
|
||||
/// toml get server.port → 점 표기법 중첩 키 조회
|
||||
/// toml stats → 줄·키·섹션·배열 통계
|
||||
/// toml flat → 점 표기법 평탄화 (모든 키·값)
|
||||
/// toml sections → [section] 목록
|
||||
/// Enter → 값 복사.
|
||||
/// </summary>
|
||||
public partial class TomlHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "toml";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"TOML",
|
||||
"TOML 파서·분석기 — 키 조회·유효성 검사·평탄화",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
// TOML 노드 (경량 표현)
|
||||
private sealed class TomlTable : Dictionary<string, object?> { }
|
||||
private sealed class TomlArray : List<object?> { }
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
string? clipboard = null;
|
||||
try
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
if (Clipboard.ContainsText()) clipboard = Clipboard.GetText().Trim();
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(clipboard))
|
||||
{
|
||||
items.Add(new LauncherItem("TOML 파서·분석기",
|
||||
"클립보드에 TOML을 복사하세요 · toml validate / keys / get / stats / flat",
|
||||
null, null, Symbol: "\uE8EC"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
// 기본: 유효성 확인 + 최상위 키
|
||||
var (tbl, err) = ParseToml(clipboard!);
|
||||
if (err != null)
|
||||
{
|
||||
items.Add(ErrorItem($"TOML 파싱 오류: {err}"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
items.Add(new LauncherItem("TOML 파싱 성공 ✓",
|
||||
$"최상위 키 {tbl!.Count}개 · toml get / flat / stats", null, null, Symbol: "\uE8EC"));
|
||||
BuildTopKeys(items, tbl!);
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
var src = parts.Length > 1 && (sub != "get")
|
||||
? string.Join(" ", parts[1..])
|
||||
: clipboard ?? "";
|
||||
|
||||
// validate
|
||||
if (sub is "validate" or "check" or "검사")
|
||||
{
|
||||
var text = clipboard ?? "";
|
||||
var (_, verr) = ParseToml(text);
|
||||
if (verr != null)
|
||||
items.Add(ErrorItem($"유효성 오류: {verr}"));
|
||||
else
|
||||
{
|
||||
var (vt, _) = ParseToml(text);
|
||||
items.Add(new LauncherItem("✓ 유효한 TOML", $"최상위 키 {vt!.Count}개", null, null, Symbol: "\uE8EC"));
|
||||
BuildTopKeys(items, vt!);
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var (table, parseErr) = ParseToml(clipboard ?? "");
|
||||
if (parseErr != null && sub != "validate")
|
||||
{
|
||||
items.Add(ErrorItem($"TOML 파싱 오류: {parseErr}"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
switch (sub)
|
||||
{
|
||||
case "keys" or "key":
|
||||
items.Add(new LauncherItem("최상위 키 목록", $"{table!.Count}개", null, null, Symbol: "\uE8EC"));
|
||||
BuildTopKeys(items, table!);
|
||||
break;
|
||||
|
||||
case "sections" or "section":
|
||||
{
|
||||
var secs = table!.Where(kv => kv.Value is TomlTable).Select(kv => kv.Key).ToList();
|
||||
items.Add(new LauncherItem($"섹션 {secs.Count}개", "", null, null, Symbol: "\uE8EC"));
|
||||
foreach (var s in secs)
|
||||
{
|
||||
var secCount = table![s] is TomlTable st ? st.Count : 0;
|
||||
items.Add(new LauncherItem($"[{s}]",
|
||||
$"{secCount}개 키", null, ("copy", s), Symbol: "\uE8EC"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "get":
|
||||
{
|
||||
if (parts.Length < 2) { items.Add(ErrorItem("예: toml get server.port")); break; }
|
||||
var keyPath = parts[1];
|
||||
var val = GetByPath(table!, keyPath);
|
||||
if (val == null)
|
||||
items.Add(new LauncherItem($"'{keyPath}' 키를 찾을 수 없습니다", "", null, null, Symbol: "\uE8EC"));
|
||||
else
|
||||
{
|
||||
var strVal = TomlValueToString(val);
|
||||
items.Add(new LauncherItem($"{keyPath} = {TruncateStr(strVal, 60)}",
|
||||
"Enter 복사", null, ("copy", strVal), Symbol: "\uE8EC"));
|
||||
items.Add(CopyItem("값", strVal));
|
||||
items.Add(CopyItem("키 경로", keyPath));
|
||||
items.Add(CopyItem("타입", GetTomlType(val)));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "stats":
|
||||
{
|
||||
var flat = new Dictionary<string, object?>();
|
||||
FlattenTable(table!, "", flat);
|
||||
var lines = (clipboard ?? "").Split('\n').Length;
|
||||
var arrays = flat.Values.Count(v => v is TomlArray);
|
||||
var tables2 = flat.Values.Count(v => v is TomlTable);
|
||||
var scalars = flat.Count - arrays - tables2;
|
||||
|
||||
items.Add(new LauncherItem("TOML 통계", "", null, null, Symbol: "\uE8EC"));
|
||||
items.Add(CopyItem("전체 줄", lines.ToString()));
|
||||
items.Add(CopyItem("최상위 키", table!.Count.ToString()));
|
||||
items.Add(CopyItem("전체 키(flat)", flat.Count.ToString()));
|
||||
items.Add(CopyItem("스칼라 값", scalars.ToString()));
|
||||
items.Add(CopyItem("배열", arrays.ToString()));
|
||||
items.Add(CopyItem("섹션(테이블)", table.Values.Count(v => v is TomlTable).ToString()));
|
||||
break;
|
||||
}
|
||||
|
||||
case "flat" or "flatten":
|
||||
{
|
||||
var flat = new Dictionary<string, object?>();
|
||||
FlattenTable(table!, "", flat);
|
||||
var all = string.Join("\n", flat.Select(kv => $"{kv.Key} = {TomlValueToString(kv.Value)}"));
|
||||
items.Add(new LauncherItem($"평탄화 결과 {flat.Count}개",
|
||||
"전체 복사 → Enter", null, ("copy", all), Symbol: "\uE8EC"));
|
||||
foreach (var (k, v) in flat.Take(20))
|
||||
items.Add(new LauncherItem($"{k} = {TruncateStr(TomlValueToString(v), 50)}",
|
||||
GetTomlType(v), null, ("copy", TomlValueToString(v)), Symbol: "\uE8EC"));
|
||||
if (flat.Count > 20)
|
||||
items.Add(new LauncherItem($"... ({flat.Count - 20}개 더)", "전체는 Enter로 복사",
|
||||
null, null, Symbol: "\uE8EC"));
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
items.Add(new LauncherItem($"알 수 없는 서브커맨드: '{sub}'",
|
||||
"validate · keys · sections · get <key> · stats · flat",
|
||||
null, null, Symbol: "\uE783"));
|
||||
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("TOML", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 경량 TOML 파서 ────────────────────────────────────────────────────
|
||||
|
||||
private static (TomlTable? table, string? error) ParseToml(string src)
|
||||
{
|
||||
var root = new TomlTable();
|
||||
var current = root;
|
||||
var lines = src.Split('\n');
|
||||
string? parseError = null;
|
||||
|
||||
for (int lineNo = 0; lineNo < lines.Length; lineNo++)
|
||||
{
|
||||
var raw = lines[lineNo];
|
||||
var line = StripComment(raw).Trim();
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
|
||||
// [section] 또는 [[array-of-tables]]
|
||||
if (line.StartsWith("[["))
|
||||
{
|
||||
var name = line.Trim('[', ']').Trim();
|
||||
// 배열 섹션 처리 (간략화: 마지막 테이블만 유지)
|
||||
var parts = name.Split('.');
|
||||
current = root;
|
||||
foreach (var p in parts)
|
||||
{
|
||||
if (!current.TryGetValue(p, out var existing) || existing is not TomlTable child)
|
||||
{ child = new TomlTable(); current[p] = child; }
|
||||
current = (TomlTable)current[p]!;
|
||||
}
|
||||
}
|
||||
else if (line.StartsWith("["))
|
||||
{
|
||||
var name = line.Trim('[', ']').Trim();
|
||||
var parts = name.Split('.');
|
||||
current = root;
|
||||
foreach (var p in parts)
|
||||
{
|
||||
if (!current.TryGetValue(p, out var existing) || existing is not TomlTable child)
|
||||
{ child = new TomlTable(); current[p] = child; }
|
||||
current = (TomlTable)current[p]!;
|
||||
}
|
||||
}
|
||||
// key = value
|
||||
else if (line.Contains('='))
|
||||
{
|
||||
var eqIdx = line.IndexOf('=');
|
||||
var key = line[..eqIdx].Trim().Trim('"');
|
||||
var val = line[(eqIdx + 1)..].Trim();
|
||||
try { current[key] = ParseTomlValue(val); }
|
||||
catch (Exception ex)
|
||||
{ parseError ??= $"줄 {lineNo + 1}: {ex.Message}"; }
|
||||
}
|
||||
}
|
||||
return parseError != null ? (null, parseError) : (root, null);
|
||||
}
|
||||
|
||||
private static object? ParseTomlValue(string val)
|
||||
{
|
||||
if (val.StartsWith('"') || val.StartsWith('\''))
|
||||
return val.Trim('"', '\'');
|
||||
if (val.StartsWith('['))
|
||||
{
|
||||
var arr = new TomlArray();
|
||||
var inner = val.Trim('[', ']');
|
||||
foreach (var item in inner.Split(','))
|
||||
{
|
||||
var t = item.Trim();
|
||||
if (!string.IsNullOrEmpty(t)) arr.Add(ParseTomlValue(t));
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
if (val.StartsWith('{'))
|
||||
{
|
||||
var tbl = new TomlTable();
|
||||
var inner = val.Trim('{', '}');
|
||||
foreach (var pair in inner.Split(','))
|
||||
{
|
||||
var parts = pair.Split('=', 2);
|
||||
if (parts.Length == 2)
|
||||
tbl[parts[0].Trim().Trim('"')] = ParseTomlValue(parts[1].Trim());
|
||||
}
|
||||
return tbl;
|
||||
}
|
||||
if (val is "true") return true;
|
||||
if (val is "false") return false;
|
||||
if (long.TryParse(val, out var lv)) return lv;
|
||||
if (double.TryParse(val, System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var dv)) return dv;
|
||||
return val.Trim('"', '\'');
|
||||
}
|
||||
|
||||
private static string StripComment(string line)
|
||||
{
|
||||
// '#' 앞에 따옴표가 홀수개인 경우 내부 → 유지, 아니면 제거
|
||||
bool inStr = false;
|
||||
char quote = '"';
|
||||
for (int i = 0; i < line.Length; i++)
|
||||
{
|
||||
var c = line[i];
|
||||
if (!inStr && (c == '"' || c == '\'')) { inStr = true; quote = c; }
|
||||
else if (inStr && c == quote) inStr = false;
|
||||
else if (!inStr && c == '#') return line[..i];
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
private static object? GetByPath(TomlTable table, string path)
|
||||
{
|
||||
var parts = path.Split('.');
|
||||
object? cur = table;
|
||||
foreach (var p in parts)
|
||||
{
|
||||
if (cur is TomlTable t && t.TryGetValue(p, out var next)) cur = next;
|
||||
else return null;
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
private static void FlattenTable(TomlTable table, string prefix, Dictionary<string, object?> result)
|
||||
{
|
||||
foreach (var (k, v) in table)
|
||||
{
|
||||
var key = string.IsNullOrEmpty(prefix) ? k : $"{prefix}.{k}";
|
||||
if (v is TomlTable child) FlattenTable(child, key, result);
|
||||
else result[key] = v;
|
||||
}
|
||||
}
|
||||
|
||||
private static void BuildTopKeys(List<LauncherItem> items, TomlTable table)
|
||||
{
|
||||
foreach (var (k, v) in table)
|
||||
{
|
||||
var type = GetTomlType(v);
|
||||
var disp = v is TomlTable t ? $"{{ {t.Count}개 키 }}" :
|
||||
v is TomlArray a ? $"[ {a.Count}개 항목 ]" :
|
||||
TruncateStr(TomlValueToString(v), 50);
|
||||
items.Add(new LauncherItem($"{k} = {disp}", type,
|
||||
null, ("copy", TomlValueToString(v)), Symbol: "\uE8EC"));
|
||||
}
|
||||
}
|
||||
|
||||
private static string TomlValueToString(object? v) => v switch
|
||||
{
|
||||
null => "null",
|
||||
bool b => b ? "true" : "false",
|
||||
TomlTable t => "{" + string.Join(", ", t.Select(kv => $"{kv.Key} = {TomlValueToString(kv.Value)}")) + "}",
|
||||
TomlArray a => "[" + string.Join(", ", a.Select(TomlValueToString)) + "]",
|
||||
_ => v.ToString() ?? ""
|
||||
};
|
||||
|
||||
private static string GetTomlType(object? v) => v switch
|
||||
{
|
||||
null => "null",
|
||||
bool => "Boolean",
|
||||
long => "Integer",
|
||||
double => "Float",
|
||||
string => "String",
|
||||
TomlTable => "Table",
|
||||
TomlArray => "Array",
|
||||
_ => v.GetType().Name
|
||||
};
|
||||
|
||||
private static string TruncateStr(string s, int max) =>
|
||||
s.Length <= max ? s : s[..max] + "…";
|
||||
|
||||
private static LauncherItem CopyItem(string label, string value) =>
|
||||
new(label, value, null, ("copy", value), Symbol: "\uE8EC");
|
||||
|
||||
private static LauncherItem ErrorItem(string msg) =>
|
||||
new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783");
|
||||
}
|
||||
344
src/AxCopilot/Handlers/UnicodeHandler.cs
Normal file
344
src/AxCopilot/Handlers/UnicodeHandler.cs
Normal file
@@ -0,0 +1,344 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L11-4: 유니코드 문자 조회 핸들러. "unicode" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: unicode A → 문자 'A'의 코드포인트·카테고리·이름 조회
|
||||
/// unicode U+1F600 → 코드포인트로 문자 조회
|
||||
/// unicode 0x1F600 → 16진수 코드포인트
|
||||
/// unicode 128512 → 10진수 코드포인트
|
||||
/// unicode 가 → 한글 문자 분석
|
||||
/// unicode smile → 문자 설명으로 검색 (이모지 이름 포함)
|
||||
/// Enter → 문자를 클립보드에 복사.
|
||||
/// </summary>
|
||||
public class UnicodeHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "unicode";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Unicode",
|
||||
"유니코드 문자 조회 — 코드포인트 · 카테고리 · 블록",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
// 주요 유니코드 블록 범위
|
||||
private static readonly (int Start, int End, string Name)[] UnicodeBlocks =
|
||||
[
|
||||
(0x0000, 0x007F, "Basic Latin"),
|
||||
(0x0080, 0x00FF, "Latin-1 Supplement"),
|
||||
(0x0100, 0x017F, "Latin Extended-A"),
|
||||
(0x0370, 0x03FF, "Greek and Coptic"),
|
||||
(0x0400, 0x04FF, "Cyrillic"),
|
||||
(0x0600, 0x06FF, "Arabic"),
|
||||
(0x0900, 0x097F, "Devanagari"),
|
||||
(0x1100, 0x11FF, "Hangul Jamo"),
|
||||
(0x2000, 0x206F, "General Punctuation"),
|
||||
(0x2100, 0x214F, "Letterlike Symbols"),
|
||||
(0x2200, 0x22FF, "Mathematical Operators"),
|
||||
(0x2300, 0x23FF, "Miscellaneous Technical"),
|
||||
(0x2600, 0x26FF, "Miscellaneous Symbols"),
|
||||
(0x2700, 0x27BF, "Dingbats"),
|
||||
(0x3000, 0x303F, "CJK Symbols and Punctuation"),
|
||||
(0x3040, 0x309F, "Hiragana"),
|
||||
(0x30A0, 0x30FF, "Katakana"),
|
||||
(0x4E00, 0x9FFF, "CJK Unified Ideographs"),
|
||||
(0xAC00, 0xD7AF, "Hangul Syllables"),
|
||||
(0xE000, 0xF8FF, "Private Use Area"),
|
||||
(0xF000, 0xF0FF, "Segoe MDL2 Assets (PUA)"),
|
||||
(0x1F300, 0x1F5FF, "Miscellaneous Symbols and Pictographs"),
|
||||
(0x1F600, 0x1F64F, "Emoticons"),
|
||||
(0x1F680, 0x1F6FF, "Transport and Map Symbols"),
|
||||
(0x1F900, 0x1F9FF, "Supplemental Symbols and Pictographs"),
|
||||
];
|
||||
|
||||
// 자주 쓰는 특수 문자 예제
|
||||
private static readonly (string Char, string Desc)[] QuickChars =
|
||||
[
|
||||
("©", "Copyright Sign (U+00A9)"),
|
||||
("®", "Registered Sign (U+00AE)"),
|
||||
("™", "Trade Mark Sign (U+2122)"),
|
||||
("•", "Bullet (U+2022)"),
|
||||
("→", "Rightwards Arrow (U+2192)"),
|
||||
("←", "Leftwards Arrow (U+2190)"),
|
||||
("✓", "Check Mark (U+2713)"),
|
||||
("✗", "Ballot X (U+2717)"),
|
||||
("★", "Black Star (U+2605)"),
|
||||
("♥", "Black Heart Suit (U+2665)"),
|
||||
("😀", "Grinning Face (U+1F600)"),
|
||||
("한", "Korean Syllable (AC00~D7AF)"),
|
||||
];
|
||||
|
||||
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("유니코드 문자 조회",
|
||||
"예: unicode A / unicode U+1F600 / unicode 가 / unicode 0x2665",
|
||||
null, null, Symbol: "\uE8D2"));
|
||||
foreach (var (ch, desc) in QuickChars.Take(8))
|
||||
items.Add(new LauncherItem(ch, desc, null, ("copy", ch), Symbol: "\uE8D2"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// U+XXXX 형식
|
||||
if (q.StartsWith("U+", StringComparison.OrdinalIgnoreCase) ||
|
||||
q.StartsWith("u+", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (int.TryParse(q[2..], NumberStyles.HexNumber, null, out var cp))
|
||||
items.AddRange(BuildCodePointItems(cp));
|
||||
else
|
||||
items.Add(new LauncherItem("형식 오류", "예: U+1F600", null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 0x 16진수
|
||||
if (q.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ||
|
||||
q.StartsWith("0X", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (int.TryParse(q[2..], NumberStyles.HexNumber, null, out var cp))
|
||||
items.AddRange(BuildCodePointItems(cp));
|
||||
else
|
||||
items.Add(new LauncherItem("형식 오류", "예: 0x1F600", null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 순수 10진수 코드포인트
|
||||
if (int.TryParse(q, out var decCp) && decCp >= 0)
|
||||
{
|
||||
items.AddRange(BuildCodePointItems(decCp));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 문자(1~2자) 직접 입력 → 분석
|
||||
var codePoints = GetCodePoints(q);
|
||||
if (codePoints.Count > 0 && codePoints.Count <= 6)
|
||||
{
|
||||
if (codePoints.Count == 1)
|
||||
{
|
||||
items.AddRange(BuildCodePointItems(codePoints[0]));
|
||||
}
|
||||
else
|
||||
{
|
||||
// 여러 문자 일괄 분석
|
||||
items.Add(new LauncherItem($"'{q}' {codePoints.Count}개 코드포인트", "전체 분석", null, null, Symbol: "\uE8D2"));
|
||||
foreach (var cp in codePoints)
|
||||
items.AddRange(BuildCodePointItems(cp));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 6자 초과 → 통계만
|
||||
if (codePoints.Count > 0)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"'{(q.Length > 10 ? q[..10] + "…" : q)}' {codePoints.Count}개 코드포인트",
|
||||
$"범위: U+{codePoints.Min():X4} ~ U+{codePoints.Max():X4}",
|
||||
null,
|
||||
("copy", string.Join(" ", codePoints.Select(c => $"U+{c:X4}"))),
|
||||
Symbol: "\uE8D2"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem("조회 실패",
|
||||
$"'{q}'을(를) 인식할 수 없습니다. 예: unicode A / unicode U+1F600",
|
||||
null, null, Symbol: "\uE783"));
|
||||
}
|
||||
|
||||
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("Unicode", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 코드포인트 분석 ────────────────────────────────────────────────────────
|
||||
|
||||
private static IEnumerable<LauncherItem> BuildCodePointItems(int codePoint)
|
||||
{
|
||||
if (codePoint < 0 || codePoint > 0x10FFFF)
|
||||
{
|
||||
yield return new LauncherItem("범위 초과", "유효한 유니코드 범위: U+0000 ~ U+10FFFF", null, null, Symbol: "\uE783");
|
||||
yield break;
|
||||
}
|
||||
|
||||
var charStr = char.ConvertFromUtf32(codePoint);
|
||||
var category = GetCategoryName(charStr);
|
||||
var block = GetBlock(codePoint);
|
||||
var name = GetCharName(codePoint);
|
||||
var display = codePoint < 32 || (codePoint >= 127 && codePoint < 160) ? $"(제어문자 U+{codePoint:X4})" : charStr;
|
||||
|
||||
yield return new LauncherItem(
|
||||
display,
|
||||
$"U+{codePoint:X4} · {name}",
|
||||
null,
|
||||
("copy", charStr),
|
||||
Symbol: "\uE8D2");
|
||||
|
||||
yield return new LauncherItem("코드포인트", $"U+{codePoint:X4}", null, ("copy", $"U+{codePoint:X4}"), Symbol: "\uE8D2");
|
||||
yield return new LauncherItem("10진수", $"{codePoint}", null, ("copy", codePoint.ToString()), Symbol: "\uE8D2");
|
||||
yield return new LauncherItem("HTML 엔티티", $"&#{codePoint};", null, ("copy", $"&#{codePoint};"), Symbol: "\uE8D2");
|
||||
yield return new LauncherItem("HTML Hex", $"&#x{codePoint:X};", null, ("copy", $"&#x{codePoint:X};"), Symbol: "\uE8D2");
|
||||
|
||||
// UTF-8 바이트
|
||||
var utf8 = System.Text.Encoding.UTF8.GetBytes(charStr);
|
||||
var utf8Hex = string.Join(" ", utf8.Select(b => $"{b:X2}"));
|
||||
yield return new LauncherItem("UTF-8", utf8Hex, null, ("copy", utf8Hex), Symbol: "\uE8D2");
|
||||
|
||||
// UTF-16
|
||||
var utf16 = System.Text.Encoding.Unicode.GetBytes(charStr);
|
||||
var utf16Hex = string.Join(" ", utf16.Select(b => $"{b:X2}"));
|
||||
yield return new LauncherItem("UTF-16 LE", utf16Hex, null, ("copy", utf16Hex), Symbol: "\uE8D2");
|
||||
|
||||
yield return new LauncherItem("카테고리", category, null, null, Symbol: "\uE8D2");
|
||||
yield return new LauncherItem("블록", block, null, null, Symbol: "\uE8D2");
|
||||
|
||||
// 한글 음절이면 분해
|
||||
if (codePoint >= 0xAC00 && codePoint <= 0xD7A3)
|
||||
{
|
||||
var (initial, vowel, final) = DecomposeHangul(codePoint);
|
||||
yield return new LauncherItem("초성", initial, null, ("copy", initial), Symbol: "\uE8D2");
|
||||
yield return new LauncherItem("중성", vowel, null, ("copy", vowel), Symbol: "\uE8D2");
|
||||
if (!string.IsNullOrEmpty(final))
|
||||
yield return new LauncherItem("종성", final, null, ("copy", final), Symbol: "\uE8D2");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static List<int> GetCodePoints(string s)
|
||||
{
|
||||
var result = new List<int>();
|
||||
for (var i = 0; i < s.Length; )
|
||||
{
|
||||
var cp = char.ConvertToUtf32(s, i);
|
||||
result.Add(cp);
|
||||
i += char.IsSurrogatePair(s, i) ? 2 : 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string GetCategoryName(string charStr)
|
||||
{
|
||||
if (string.IsNullOrEmpty(charStr)) return "Unknown";
|
||||
var cat = char.GetUnicodeCategory(charStr, 0);
|
||||
return cat switch
|
||||
{
|
||||
UnicodeCategory.UppercaseLetter => "대문자 (Lu)",
|
||||
UnicodeCategory.LowercaseLetter => "소문자 (Ll)",
|
||||
UnicodeCategory.TitlecaseLetter => "타이틀케이스 (Lt)",
|
||||
UnicodeCategory.ModifierLetter => "수정 문자 (Lm)",
|
||||
UnicodeCategory.OtherLetter => "기타 문자 (Lo)",
|
||||
UnicodeCategory.DecimalDigitNumber => "10진수 숫자 (Nd)",
|
||||
UnicodeCategory.LetterNumber => "문자 숫자 (Nl)",
|
||||
UnicodeCategory.OtherNumber => "기타 숫자 (No)",
|
||||
UnicodeCategory.SpaceSeparator => "공백 (Zs)",
|
||||
UnicodeCategory.LineSeparator => "줄 구분자 (Zl)",
|
||||
UnicodeCategory.ParagraphSeparator => "단락 구분자 (Zp)",
|
||||
UnicodeCategory.Control => "제어 문자 (Cc)",
|
||||
UnicodeCategory.MathSymbol => "수학 기호 (Sm)",
|
||||
UnicodeCategory.CurrencySymbol => "통화 기호 (Sc)",
|
||||
UnicodeCategory.ModifierSymbol => "수정 기호 (Sk)",
|
||||
UnicodeCategory.OtherSymbol => "기타 기호 (So)",
|
||||
UnicodeCategory.OpenPunctuation => "여는 구두점 (Ps)",
|
||||
UnicodeCategory.ClosePunctuation => "닫는 구두점 (Pe)",
|
||||
UnicodeCategory.DashPunctuation => "대시 구두점 (Pd)",
|
||||
UnicodeCategory.ConnectorPunctuation => "연결 구두점 (Pc)",
|
||||
UnicodeCategory.OtherPunctuation => "기타 구두점 (Po)",
|
||||
_ => cat.ToString(),
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetBlock(int cp)
|
||||
{
|
||||
foreach (var (start, end, name) in UnicodeBlocks)
|
||||
if (cp >= start && cp <= end) return $"{name} (U+{start:X4}~U+{end:X4})";
|
||||
if (cp >= 0x10000) return $"Supplementary Planes (U+{cp:X4})";
|
||||
return $"U+{cp:X4} 범위 불명";
|
||||
}
|
||||
|
||||
private static string GetCharName(int cp) => cp switch
|
||||
{
|
||||
0x0020 => "Space",
|
||||
0x0021 => "Exclamation Mark",
|
||||
0x0022 => "Quotation Mark",
|
||||
0x0023 => "Number Sign",
|
||||
0x0024 => "Dollar Sign",
|
||||
0x0025 => "Percent Sign",
|
||||
0x0026 => "Ampersand",
|
||||
0x0027 => "Apostrophe",
|
||||
0x0028 => "Left Parenthesis",
|
||||
0x0029 => "Right Parenthesis",
|
||||
0x002A => "Asterisk",
|
||||
0x002B => "Plus Sign",
|
||||
0x002C => "Comma",
|
||||
0x002D => "Hyphen-Minus",
|
||||
0x002E => "Full Stop",
|
||||
0x002F => "Solidus",
|
||||
>= 0x0030 and <= 0x0039 => $"Digit {(char)cp}",
|
||||
>= 0x0041 and <= 0x005A => $"Latin Capital Letter {(char)cp}",
|
||||
>= 0x0061 and <= 0x007A => $"Latin Small Letter {(char)cp}",
|
||||
0x00A9 => "Copyright Sign",
|
||||
0x00AE => "Registered Sign",
|
||||
0x2122 => "Trade Mark Sign",
|
||||
0x2022 => "Bullet",
|
||||
0x2192 => "Rightwards Arrow",
|
||||
0x2190 => "Leftwards Arrow",
|
||||
0x2191 => "Upwards Arrow",
|
||||
0x2193 => "Downwards Arrow",
|
||||
0x2713 => "Check Mark",
|
||||
0x2717 => "Ballot X",
|
||||
0x2605 => "Black Star",
|
||||
0x2606 => "White Star",
|
||||
0x2665 => "Black Heart Suit",
|
||||
0x2764 => "Heavy Black Heart",
|
||||
0x1F600 => "Grinning Face",
|
||||
0x1F601 => "Grinning Face With Smiling Eyes",
|
||||
0x1F602 => "Face With Tears of Joy",
|
||||
0x1F603 => "Smiling Face With Open Mouth",
|
||||
0x1F609 => "Winking Face",
|
||||
0x1F60D => "Smiling Face With Heart-Eyes",
|
||||
0x1F621 => "Pouting Face",
|
||||
0x1F625 => "Disappointed but Relieved Face",
|
||||
>= 0xAC00 and <= 0xD7A3 => "Hangul Syllable",
|
||||
>= 0x1100 and <= 0x11FF => "Hangul Jamo",
|
||||
>= 0x3131 and <= 0x318E => "Hangul Compatibility Jamo",
|
||||
>= 0x4E00 and <= 0x9FFF => "CJK Unified Ideograph",
|
||||
>= 0x3040 and <= 0x309F => "Hiragana",
|
||||
>= 0x30A0 and <= 0x30FF => "Katakana",
|
||||
_ => $"U+{cp:X4}",
|
||||
};
|
||||
|
||||
private static (string Initial, string Vowel, string Final) DecomposeHangul(int cp)
|
||||
{
|
||||
string[] initials = ["ㄱ","ㄲ","ㄴ","ㄷ","ㄸ","ㄹ","ㅁ","ㅂ","ㅃ","ㅅ","ㅆ","ㅇ","ㅈ","ㅉ","ㅊ","ㅋ","ㅌ","ㅍ","ㅎ"];
|
||||
string[] vowels = ["ㅏ","ㅐ","ㅑ","ㅒ","ㅓ","ㅔ","ㅕ","ㅖ","ㅗ","ㅘ","ㅙ","ㅚ","ㅛ","ㅜ","ㅝ","ㅞ","ㅟ","ㅠ","ㅡ","ㅢ","ㅣ"];
|
||||
string[] finals = ["", "ㄱ","ㄲ","ㄳ","ㄴ","ㄵ","ㄶ","ㄷ","ㄹ","ㄺ","ㄻ","ㄼ","ㄽ","ㄾ","ㄿ","ㅀ","ㅁ","ㅂ","ㅄ","ㅅ","ㅆ","ㅇ","ㅈ","ㅊ","ㅋ","ㅌ","ㅍ","ㅎ"];
|
||||
|
||||
var offset = cp - 0xAC00;
|
||||
var finIdx = offset % 28;
|
||||
var vowIdx = (offset / 28) % 21;
|
||||
var iniIdx = offset / 28 / 21;
|
||||
|
||||
return (initials[iniIdx], vowels[vowIdx], finals[finIdx]);
|
||||
}
|
||||
}
|
||||
284
src/AxCopilot/Handlers/UnitHandler.cs
Normal file
284
src/AxCopilot/Handlers/UnitHandler.cs
Normal file
@@ -0,0 +1,284 @@
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L17-1: 단위 변환기 핸들러. "unit" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: unit 100 km m → 100km → m
|
||||
/// unit 72 f c → 화씨 72°F → 섭씨
|
||||
/// unit 5 kg lb → 5kg → 파운드
|
||||
/// unit 1 gb mb → 1GB → MB
|
||||
/// unit 60 mph kmh → 속도 변환
|
||||
/// unit length → 길이 단위 목록
|
||||
/// unit weight / temp / area / speed / data → 카테고리 목록
|
||||
/// Enter → 결과 복사.
|
||||
/// </summary>
|
||||
public class UnitHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "unit";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Unit",
|
||||
"단위 변환기 — 길이·무게·온도·넓이·속도·데이터",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
// ── 단위 정의 (기준 단위 → SI 변환 계수) ─────────────────────────────────
|
||||
// 온도는 별도 처리 (비선형)
|
||||
|
||||
private enum UnitCategory { Length, Weight, Area, Speed, Data, Temperature, Pressure, Volume }
|
||||
|
||||
private record UnitDef(string[] Names, double ToBase, UnitCategory Cat, string Display);
|
||||
|
||||
private static readonly UnitDef[] Units =
|
||||
[
|
||||
// 길이 (기준: m)
|
||||
new(["km","킬로미터"], 1000, UnitCategory.Length, "킬로미터 (km)"),
|
||||
new(["m","미터"], 1, UnitCategory.Length, "미터 (m)"),
|
||||
new(["cm","센티미터"], 0.01, UnitCategory.Length, "센티미터 (cm)"),
|
||||
new(["mm","밀리미터"], 0.001, UnitCategory.Length, "밀리미터 (mm)"),
|
||||
new(["mi","mile","마일"], 1609.344, UnitCategory.Length, "마일 (mi)"),
|
||||
new(["yd","yard","야드"], 0.9144, UnitCategory.Length, "야드 (yd)"),
|
||||
new(["ft","feet","foot","피트"], 0.3048, UnitCategory.Length, "피트 (ft)"),
|
||||
new(["in","inch","인치"], 0.0254, UnitCategory.Length, "인치 (in)"),
|
||||
new(["nm","해리"], 1852, UnitCategory.Length, "해리 (nm)"),
|
||||
|
||||
// 무게 (기준: kg)
|
||||
new(["t","ton","톤"], 1000, UnitCategory.Weight, "톤 (t)"),
|
||||
new(["kg","킬로그램"], 1, UnitCategory.Weight, "킬로그램 (kg)"),
|
||||
new(["g","그램"], 0.001, UnitCategory.Weight, "그램 (g)"),
|
||||
new(["mg","밀리그램"], 1e-6, UnitCategory.Weight, "밀리그램 (mg)"),
|
||||
new(["lb","lbs","파운드"], 0.453592, UnitCategory.Weight, "파운드 (lb)"),
|
||||
new(["oz","온스"], 0.0283495, UnitCategory.Weight, "온스 (oz)"),
|
||||
new(["근"], 0.6, UnitCategory.Weight, "근 (600g)"),
|
||||
|
||||
// 넓이 (기준: m²)
|
||||
new(["km2","km²"], 1e6, UnitCategory.Area, "제곱킬로미터 (km²)"),
|
||||
new(["m2","m²","sqm"], 1, UnitCategory.Area, "제곱미터 (m²)"),
|
||||
new(["cm2","cm²"], 0.0001, UnitCategory.Area, "제곱센티미터 (cm²)"),
|
||||
new(["ha","헥타르"], 10000, UnitCategory.Area, "헥타르 (ha)"),
|
||||
new(["a","아르"], 100, UnitCategory.Area, "아르 (a)"),
|
||||
new(["acre","에이커"], 4046.856, UnitCategory.Area, "에이커 (acre)"),
|
||||
new(["ft2","ft²","sqft"], 0.092903, UnitCategory.Area, "제곱피트 (ft²)"),
|
||||
new(["평"], 3.30579, UnitCategory.Area, "평 (3.3058m²)"),
|
||||
|
||||
// 속도 (기준: m/s)
|
||||
new(["mps","m/s"], 1, UnitCategory.Speed, "미터/초 (m/s)"),
|
||||
new(["kph","kmh","km/h","kmph"], 0.277778, UnitCategory.Speed, "킬로미터/시 (km/h)"),
|
||||
new(["mph","mi/h"], 0.44704, UnitCategory.Speed, "마일/시 (mph)"),
|
||||
new(["knot","kn","노트"], 0.514444, UnitCategory.Speed, "노트 (kn)"),
|
||||
new(["fps","ft/s"], 0.3048, UnitCategory.Speed, "피트/초 (ft/s)"),
|
||||
|
||||
// 데이터 (기준: byte)
|
||||
new(["b","bit","비트"], 0.125, UnitCategory.Data, "비트 (bit)"),
|
||||
new(["byte","바이트"], 1, UnitCategory.Data, "바이트 (byte)"),
|
||||
new(["kb","킬로바이트"], 1024, UnitCategory.Data, "킬로바이트 (KB)"),
|
||||
new(["mb","메가바이트"], 1048576, UnitCategory.Data, "메가바이트 (MB)"),
|
||||
new(["gb","기가바이트"], 1073741824, UnitCategory.Data, "기가바이트 (GB)"),
|
||||
new(["tb","테라바이트"], 1099511627776,UnitCategory.Data, "테라바이트 (TB)"),
|
||||
new(["pb","페타바이트"], 1.12589990684e15, UnitCategory.Data, "페타바이트 (PB)"),
|
||||
|
||||
// 온도 (기준: °C, 변환은 특수 처리)
|
||||
new(["c","°c","celsius","섭씨"], 1, UnitCategory.Temperature, "섭씨 (°C)"),
|
||||
new(["f","°f","fahrenheit","화씨"],1, UnitCategory.Temperature, "화씨 (°F)"),
|
||||
new(["k","kelvin","켈빈"], 1, UnitCategory.Temperature, "켈빈 (K)"),
|
||||
|
||||
// 압력 (기준: Pa)
|
||||
new(["pa","파스칼"], 1, UnitCategory.Pressure, "파스칼 (Pa)"),
|
||||
new(["kpa"], 1000, UnitCategory.Pressure, "킬로파스칼 (kPa)"),
|
||||
new(["mpa"], 1e6, UnitCategory.Pressure, "메가파스칼 (MPa)"),
|
||||
new(["atm","기압"], 101325, UnitCategory.Pressure, "기압 (atm)"),
|
||||
new(["bar","바"], 100000, UnitCategory.Pressure, "바 (bar)"),
|
||||
new(["psi"], 6894.757, UnitCategory.Pressure, "PSI (psi)"),
|
||||
|
||||
// 부피 (기준: L)
|
||||
new(["l","liter","리터"], 1, UnitCategory.Volume, "리터 (L)"),
|
||||
new(["ml","밀리리터"], 0.001, UnitCategory.Volume, "밀리리터 (mL)"),
|
||||
new(["m3","m³","cbm"], 1000, UnitCategory.Volume, "세제곱미터 (m³)"),
|
||||
new(["cm3","cm³","cc"], 0.001, UnitCategory.Volume, "세제곱센티미터 (cc)"),
|
||||
new(["gallon","gal","갤런"], 3.78541, UnitCategory.Volume, "갤런 (US, gal)"),
|
||||
new(["floz","fl.oz"], 0.0295735, UnitCategory.Volume, "액량온스 (fl.oz)"),
|
||||
new(["cup","컵"], 0.236588, UnitCategory.Volume, "컵 (cup)"),
|
||||
];
|
||||
|
||||
private static readonly Dictionary<string, UnitCategory> CategoryKeywords =
|
||||
new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["length"] = UnitCategory.Length, ["길이"] = UnitCategory.Length,
|
||||
["weight"] = UnitCategory.Weight, ["무게"] = UnitCategory.Weight,
|
||||
["mass"] = UnitCategory.Weight,
|
||||
["area"] = UnitCategory.Area, ["넓이"] = UnitCategory.Area,
|
||||
["speed"] = UnitCategory.Speed, ["속도"] = UnitCategory.Speed,
|
||||
["data"] = UnitCategory.Data, ["데이터"]= UnitCategory.Data,
|
||||
["temp"] = UnitCategory.Temperature, ["온도"] = UnitCategory.Temperature,
|
||||
["temperature"] = UnitCategory.Temperature,
|
||||
["pressure"] = UnitCategory.Pressure, ["압력"] = UnitCategory.Pressure,
|
||||
["volume"] = UnitCategory.Volume, ["부피"] = UnitCategory.Volume,
|
||||
};
|
||||
|
||||
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("단위 변환기",
|
||||
"예: unit 100 km m / unit 72 f c / unit 5 kg lb / unit 1 gb mb",
|
||||
null, null, Symbol: "\uE8EF"));
|
||||
items.Add(new LauncherItem("── 카테고리 ──", "", null, null, Symbol: "\uE8EF"));
|
||||
items.Add(new LauncherItem("unit length", "길이 단위 (km·m·cm·ft·in·mi)", null, null, Symbol: "\uE8EF"));
|
||||
items.Add(new LauncherItem("unit weight", "무게 단위 (kg·g·lb·oz·근)", null, null, Symbol: "\uE8EF"));
|
||||
items.Add(new LauncherItem("unit temp", "온도 단위 (°C·°F·K)", null, null, Symbol: "\uE8EF"));
|
||||
items.Add(new LauncherItem("unit area", "넓이 단위 (m²·ha·acre·평)", null, null, Symbol: "\uE8EF"));
|
||||
items.Add(new LauncherItem("unit speed", "속도 단위 (km/h·mph·m/s·knot)", null, null, Symbol: "\uE8EF"));
|
||||
items.Add(new LauncherItem("unit data", "데이터 단위 (bit·B·KB·MB·GB·TB)", null, null, Symbol: "\uE8EF"));
|
||||
items.Add(new LauncherItem("unit pressure", "압력 단위 (Pa·atm·bar·psi)", null, null, Symbol: "\uE8EF"));
|
||||
items.Add(new LauncherItem("unit volume", "부피 단위 (L·mL·m³·gallon·cup)", null, null, Symbol: "\uE8EF"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// 카테고리 목록
|
||||
if (parts.Length == 1 && CategoryKeywords.TryGetValue(parts[0], out var cat))
|
||||
{
|
||||
var catUnits = Units.Where(u => u.Cat == cat).ToList();
|
||||
items.Add(new LauncherItem($"{cat} 단위 {catUnits.Count}개",
|
||||
"예: unit 100 km m", null, null, Symbol: "\uE8EF"));
|
||||
foreach (var u in catUnits)
|
||||
items.Add(new LauncherItem(u.Display, string.Join(", ", u.Names), null, null, Symbol: "\uE8EF"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 변환: unit <값> <from> <to>
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
items.Add(new LauncherItem("입력 형식",
|
||||
"unit <값> <단위> [대상단위] 예: unit 100 km m", null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
if (!double.TryParse(parts[0].Replace(",", ""), System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var value))
|
||||
{
|
||||
items.Add(new LauncherItem("숫자 형식 오류",
|
||||
"첫 번째 값이 숫자여야 합니다 예: unit 100 km m", null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var fromKey = parts[1].ToLowerInvariant();
|
||||
var fromDef = FindUnit(fromKey);
|
||||
if (fromDef == null)
|
||||
{
|
||||
items.Add(new LauncherItem($"'{parts[1]}' 단위를 찾을 수 없습니다",
|
||||
"unit length / weight / temp / area / speed / data 로 단위 목록 확인",
|
||||
null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 대상 단위 지정
|
||||
if (parts.Length >= 3)
|
||||
{
|
||||
var toKey = parts[2].ToLowerInvariant();
|
||||
var toDef = FindUnit(toKey);
|
||||
if (toDef == null)
|
||||
{
|
||||
items.Add(new LauncherItem($"'{parts[2]}' 단위를 찾을 수 없습니다",
|
||||
"", null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
if (fromDef.Cat != toDef.Cat)
|
||||
{
|
||||
items.Add(new LauncherItem("카테고리 불일치",
|
||||
$"{fromDef.Cat} ≠ {toDef.Cat} — 같은 종류끼리만 변환 가능",
|
||||
null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var result = Convert(value, fromDef, toDef);
|
||||
var label = $"{FormatNum(value)} {fromDef.Names[0].ToUpper()} = {FormatNum(result)} {toDef.Names[0].ToUpper()}";
|
||||
items.Add(new LauncherItem(label, "Enter → 복사", null, ("copy", label), Symbol: "\uE8EF"));
|
||||
items.Add(new LauncherItem($"{FormatNum(result)} {toDef.Names[0].ToUpper()}", toDef.Display,
|
||||
null, ("copy", FormatNum(result)), Symbol: "\uE8EF"));
|
||||
}
|
||||
else
|
||||
{
|
||||
// 같은 카테고리 모든 단위로 변환
|
||||
var sameCat = Units.Where(u => u.Cat == fromDef.Cat && u != fromDef).ToList();
|
||||
items.Add(new LauncherItem($"{FormatNum(value)} {fromDef.Names[0].ToUpper()} 변환 결과",
|
||||
fromDef.Display, null, null, Symbol: "\uE8EF"));
|
||||
foreach (var toDef in sameCat)
|
||||
{
|
||||
var result = Convert(value, fromDef, toDef);
|
||||
var label = $"{FormatNum(result)} {toDef.Names[0].ToUpper()}";
|
||||
items.Add(new LauncherItem(label, toDef.Display, null, ("copy", FormatNum(result)), Symbol: "\uE8EF"));
|
||||
}
|
||||
}
|
||||
|
||||
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("Unit", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 변환 로직 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static double Convert(double value, UnitDef from, UnitDef to)
|
||||
{
|
||||
if (from.Cat == UnitCategory.Temperature)
|
||||
return ConvertTemp(value, from.Names[0].ToLowerInvariant(), to.Names[0].ToLowerInvariant());
|
||||
|
||||
// 선형 변환: value × from.ToBase / to.ToBase
|
||||
return value * from.ToBase / to.ToBase;
|
||||
}
|
||||
|
||||
private static double ConvertTemp(double value, string from, string to)
|
||||
{
|
||||
// 먼저 °C로
|
||||
var celsius = from switch
|
||||
{
|
||||
"c" or "°c" => value,
|
||||
"f" or "°f" => (value - 32) * 5 / 9,
|
||||
"k" => value - 273.15,
|
||||
_ => value,
|
||||
};
|
||||
// °C에서 목표로
|
||||
return to switch
|
||||
{
|
||||
"c" or "°c" => celsius,
|
||||
"f" or "°f" => celsius * 9 / 5 + 32,
|
||||
"k" => celsius + 273.15,
|
||||
_ => celsius,
|
||||
};
|
||||
}
|
||||
|
||||
private static UnitDef? FindUnit(string key) =>
|
||||
Units.FirstOrDefault(u => u.Names.Any(n => n.Equals(key, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
private static string FormatNum(double v)
|
||||
{
|
||||
if (double.IsNaN(v) || double.IsInfinity(v)) return v.ToString();
|
||||
if (Math.Abs(v) >= 1e12 || (Math.Abs(v) < 1e-4 && v != 0))
|
||||
return v.ToString("E3", System.Globalization.CultureInfo.InvariantCulture);
|
||||
if (v == Math.Floor(v) && Math.Abs(v) < 1e9)
|
||||
return $"{v:N0}";
|
||||
return v.ToString("G6", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
300
src/AxCopilot/Handlers/UuidHandler.cs
Normal file
300
src/AxCopilot/Handlers/UuidHandler.cs
Normal file
@@ -0,0 +1,300 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L10-2: UUID/GUID 생성기 핸들러. "uuid" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: uuid → UUID v4 1개 생성
|
||||
/// uuid 5 → UUID v4 5개 생성
|
||||
/// uuid upper → 대문자 UUID 생성
|
||||
/// uuid v4 → UUID v4 (랜덤)
|
||||
/// uuid seq → 순차 UUID (시간 기반, 정렬 가능)
|
||||
/// uuid short → 짧은 고유 ID (8자리 hex)
|
||||
/// uuid nil → Nil UUID (00000000-…)
|
||||
/// uuid parse <uuid> → UUID 분석 (버전, 타임스탬프 등)
|
||||
/// Enter → 결과를 클립보드에 복사.
|
||||
/// </summary>
|
||||
public class UuidHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "uuid";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"UUID",
|
||||
"UUID/GUID 생성기 — v4 · 순차 · 짧은 ID · 분석",
|
||||
"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))
|
||||
{
|
||||
// 기본: v4 1개 생성
|
||||
var uuid = Guid.NewGuid().ToString();
|
||||
items.Add(new LauncherItem(
|
||||
uuid,
|
||||
"UUID v4 (랜덤) · Enter 복사",
|
||||
null,
|
||||
("copy", uuid),
|
||||
Symbol: "\uF0E2"));
|
||||
|
||||
items.Add(new LauncherItem("uuid 5", "5개 생성", null, null, Symbol: "\uF0E2"));
|
||||
items.Add(new LauncherItem("uuid upper", "대문자 UUID", null, null, Symbol: "\uF0E2"));
|
||||
items.Add(new LauncherItem("uuid seq", "순차 UUID (정렬가능)", null, null, Symbol: "\uF0E2"));
|
||||
items.Add(new LauncherItem("uuid short", "짧은 ID (8자리)", null, null, Symbol: "\uF0E2"));
|
||||
items.Add(new LauncherItem("uuid parse …", "UUID 분석", null, null, Symbol: "\uF0E2"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
// "uuid parse <value>"
|
||||
if (sub == "parse" && parts.Length >= 2)
|
||||
{
|
||||
items.AddRange(ParseUuid(string.Join(" ", parts.Skip(1))));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// "uuid nil"
|
||||
if (sub == "nil")
|
||||
{
|
||||
var nil = "00000000-0000-0000-0000-000000000000";
|
||||
items.Add(new LauncherItem(nil, "Nil UUID", null, ("copy", nil), Symbol: "\uF0E2"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// "uuid seq"
|
||||
if (sub == "seq")
|
||||
{
|
||||
items.AddRange(GenerateSequential(5));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// "uuid short"
|
||||
if (sub == "short")
|
||||
{
|
||||
items.AddRange(GenerateShort(5));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// "uuid upper"
|
||||
if (sub == "upper")
|
||||
{
|
||||
var upper = Guid.NewGuid().ToString().ToUpperInvariant();
|
||||
items.Add(new LauncherItem(upper, "대문자 UUID v4 · Enter 복사", null, ("copy", upper), Symbol: "\uF0E2"));
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
var u = Guid.NewGuid().ToString().ToUpperInvariant();
|
||||
items.Add(new LauncherItem(u, "대문자 UUID v4", null, ("copy", u), Symbol: "\uF0E2"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// "uuid v4"
|
||||
if (sub == "v4")
|
||||
{
|
||||
items.AddRange(GenerateV4(5));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// "uuid <숫자>" — N개 생성
|
||||
if (int.TryParse(sub, out var count))
|
||||
{
|
||||
count = Math.Clamp(count, 1, 20);
|
||||
items.AddRange(GenerateV4(count));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// UUID 자체를 입력한 경우 → parse
|
||||
if (Guid.TryParse(q, out _))
|
||||
{
|
||||
items.AddRange(ParseUuid(q));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
items.Add(new LauncherItem("알 수 없는 명령",
|
||||
"예: uuid / uuid 5 / uuid upper / uuid seq / uuid short / uuid parse",
|
||||
null, null, Symbol: "\uE783"));
|
||||
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("UUID", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 생성 헬퍼 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static IEnumerable<LauncherItem> GenerateV4(int count)
|
||||
{
|
||||
var all = new List<string>();
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var uuid = Guid.NewGuid().ToString();
|
||||
all.Add(uuid);
|
||||
}
|
||||
|
||||
if (count > 1)
|
||||
{
|
||||
yield return new LauncherItem(
|
||||
$"UUID v4 {count}개",
|
||||
"전체 복사: Enter",
|
||||
null,
|
||||
("copy", string.Join("\n", all)),
|
||||
Symbol: "\uF0E2");
|
||||
}
|
||||
|
||||
foreach (var uuid in all)
|
||||
yield return new LauncherItem(uuid, "UUID v4 · Enter 복사", null, ("copy", uuid), Symbol: "\uF0E2");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 시간 기반 순차 UUID (UUIDv7 스타일: 밀리초 타임스탬프 + 랜덤 비트).
|
||||
/// 정렬 가능하고 시간 정보 포함.
|
||||
/// </summary>
|
||||
private static IEnumerable<LauncherItem> GenerateSequential(int count)
|
||||
{
|
||||
var all = new List<string>();
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
if (i > 0) System.Threading.Thread.Sleep(1); // 밀리초 차이 보장
|
||||
var uuid = NewSequentialGuid();
|
||||
all.Add(uuid);
|
||||
}
|
||||
|
||||
yield return new LauncherItem(
|
||||
$"순차 UUID {count}개",
|
||||
"시간 기반 정렬 가능 · 전체 복사: Enter",
|
||||
null,
|
||||
("copy", string.Join("\n", all)),
|
||||
Symbol: "\uF0E2");
|
||||
|
||||
foreach (var uuid in all)
|
||||
yield return new LauncherItem(uuid, "순차 UUID · Enter 복사", null, ("copy", uuid), Symbol: "\uF0E2");
|
||||
}
|
||||
|
||||
private static IEnumerable<LauncherItem> GenerateShort(int count)
|
||||
{
|
||||
var all = new List<string>();
|
||||
for (var i = 0; i < count; i++)
|
||||
all.Add(NewShortId());
|
||||
|
||||
yield return new LauncherItem(
|
||||
$"짧은 ID {count}개",
|
||||
"8자리 hex · 전체 복사: Enter",
|
||||
null,
|
||||
("copy", string.Join("\n", all)),
|
||||
Symbol: "\uF0E2");
|
||||
|
||||
foreach (var id in all)
|
||||
yield return new LauncherItem(id, "짧은 ID (8자리) · Enter 복사", null, ("copy", id), Symbol: "\uF0E2");
|
||||
}
|
||||
|
||||
private static IEnumerable<LauncherItem> ParseUuid(string raw)
|
||||
{
|
||||
raw = raw.Trim();
|
||||
if (!Guid.TryParse(raw, out var guid))
|
||||
{
|
||||
yield return new LauncherItem("UUID 파싱 실패", $"'{raw}'은 유효한 UUID가 아닙니다", null, null, Symbol: "\uE783");
|
||||
yield break;
|
||||
}
|
||||
|
||||
var bytes = guid.ToByteArray();
|
||||
var version = (bytes[7] >> 4) & 0x0F;
|
||||
var variant = (bytes[8] >> 6) & 0x03;
|
||||
|
||||
yield return new LauncherItem(
|
||||
guid.ToString(),
|
||||
$"버전 {version} · 변형 {variant}",
|
||||
null,
|
||||
("copy", guid.ToString()),
|
||||
Symbol: "\uF0E2");
|
||||
|
||||
yield return new LauncherItem("소문자", guid.ToString(), null, ("copy", guid.ToString()), Symbol: "\uF0E2");
|
||||
yield return new LauncherItem("대문자", guid.ToString().ToUpper(), null, ("copy", guid.ToString().ToUpper()), Symbol: "\uF0E2");
|
||||
yield return new LauncherItem("중괄호", $"{{{guid}}}", null, ("copy", $"{{{guid}}}"), Symbol: "\uF0E2");
|
||||
yield return new LauncherItem("대시 없음", guid.ToString("N"), null, ("copy", guid.ToString("N")), Symbol: "\uF0E2");
|
||||
yield return new LauncherItem("버전", $"UUID v{version}", null, null, Symbol: "\uF0E2");
|
||||
yield return new LauncherItem("변형", variant == 2 ? "RFC 4122" : variant == 3 ? "Microsoft" : $"변형 {variant}", null, null, Symbol: "\uF0E2");
|
||||
|
||||
// 시간 기반 버전 (v1)이면 타임스탬프 복원 시도
|
||||
if (version == 1)
|
||||
{
|
||||
var ts = ExtractV1Timestamp(bytes);
|
||||
if (ts.HasValue)
|
||||
yield return new LauncherItem("타임스탬프", ts.Value.ToString("yyyy-MM-dd HH:mm:ss.fff UTC"), null, null, Symbol: "\uF0E2");
|
||||
}
|
||||
}
|
||||
|
||||
// ── UUID 생성 구현 ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>UUIDv7 스타일 순차 GUID: 상위 48비트 = Unix ms 타임스탬프, 하위 = 랜덤</summary>
|
||||
private static string NewSequentialGuid()
|
||||
{
|
||||
var ms = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
var rand = RandomNumberGenerator.GetBytes(10);
|
||||
var bytes = new byte[16];
|
||||
|
||||
// 상위 6바이트 = 타임스탬프 (big-endian ms)
|
||||
bytes[0] = (byte)(ms >> 40);
|
||||
bytes[1] = (byte)(ms >> 32);
|
||||
bytes[2] = (byte)(ms >> 24);
|
||||
bytes[3] = (byte)(ms >> 16);
|
||||
bytes[4] = (byte)(ms >> 8);
|
||||
bytes[5] = (byte)(ms);
|
||||
|
||||
// 버전 비트 (v7 = 0111)
|
||||
bytes[6] = (byte)((rand[0] & 0x0F) | 0x70);
|
||||
bytes[7] = rand[1];
|
||||
|
||||
// 변형 비트 (RFC 4122 = 10xx)
|
||||
bytes[8] = (byte)((rand[2] & 0x3F) | 0x80);
|
||||
bytes[9] = rand[3];
|
||||
|
||||
// 나머지 랜덤
|
||||
Array.Copy(rand, 4, bytes, 10, 6);
|
||||
|
||||
return new Guid(bytes).ToString();
|
||||
}
|
||||
|
||||
private static string NewShortId()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(4);
|
||||
return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static DateTime? ExtractV1Timestamp(byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
// UUID v1: time_low(4) + time_mid(2) + time_hi_version(2)
|
||||
var timeLow = (long)((uint)((bytes[3] << 24) | (bytes[2] << 16) | (bytes[1] << 8) | bytes[0]));
|
||||
var timeMid = (long)((ushort)((bytes[5] << 8) | bytes[4]));
|
||||
var timeHigh = (long)((ushort)((bytes[7] << 8) | bytes[6]) & 0x0FFF);
|
||||
var ticks = (timeHigh << 48) | (timeMid << 32) | timeLow;
|
||||
// UUID epoch = Oct 15, 1582
|
||||
var uuidEpoch = new DateTime(1582, 10, 15, 0, 0, 0, DateTimeKind.Utc);
|
||||
return uuidEpoch.AddTicks(ticks);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
211
src/AxCopilot/Handlers/VolHandler.cs
Normal file
211
src/AxCopilot/Handlers/VolHandler.cs
Normal file
@@ -0,0 +1,211 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L27-2: 시스템 볼륨 제어 핸들러. "vol" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: vol → 현재 볼륨 표시
|
||||
/// vol 50 → 볼륨 50% 설정
|
||||
/// vol up / down → ±10% 조절
|
||||
/// vol mute → 음소거 토글
|
||||
/// Enter → 해당 명령 실행.
|
||||
/// Windows Core Audio API (IAudioEndpointVolume) COM 인터페이스 사용.
|
||||
/// </summary>
|
||||
public class VolHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "vol";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"볼륨 제어",
|
||||
"시스템 볼륨 조절 — 설정·증감·음소거",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim().ToLowerInvariant();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
// 현재 볼륨 읽기
|
||||
float curLevel;
|
||||
bool curMuted;
|
||||
try
|
||||
{
|
||||
using var audio = AudioEndpoint.GetDefault();
|
||||
curLevel = audio.GetVolume();
|
||||
curMuted = audio.GetMute();
|
||||
}
|
||||
catch
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
"오디오 장치를 찾을 수 없습니다",
|
||||
"기본 재생 장치가 연결되어 있는지 확인하세요",
|
||||
null, null, Symbol: Symbols.Warning));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
int pct = (int)Math.Round(curLevel * 100);
|
||||
var bar = VolumeBar(pct);
|
||||
var muteLabel = curMuted ? " 🔇 음소거" : "";
|
||||
var symbol = curMuted ? Symbols.VolumeMute
|
||||
: pct == 0 ? Symbols.VolumeMute
|
||||
: pct < 50 ? Symbols.VolumeDown
|
||||
: Symbols.VolumeUp;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"현재 볼륨: {pct}%{muteLabel}",
|
||||
$"{bar} · vol 50 / vol up / vol down / vol mute",
|
||||
null, null, Symbol: symbol));
|
||||
|
||||
items.Add(new LauncherItem("vol up", "볼륨 +10%", null, ("set", Math.Min(pct + 10, 100)), Symbol: Symbols.VolumeUp));
|
||||
items.Add(new LauncherItem("vol down", "볼륨 −10%", null, ("set", Math.Max(pct - 10, 0)), Symbol: Symbols.VolumeDown));
|
||||
items.Add(new LauncherItem("vol mute", curMuted ? "음소거 해제" : "음소거", null, ("mute", !curMuted), Symbol: Symbols.VolumeMute));
|
||||
items.Add(new LauncherItem("vol 0", "볼륨 0% (무음)", null, ("set", 0), Symbol: Symbols.VolumeMute));
|
||||
items.Add(new LauncherItem("vol 50", "볼륨 50%", null, ("set", 50), Symbol: Symbols.VolumeDown));
|
||||
items.Add(new LauncherItem("vol 100", "볼륨 100%", null, ("set", 100), Symbol: Symbols.VolumeUp));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 명령 파싱
|
||||
if (q is "up" or "올려" or "+")
|
||||
{
|
||||
var target = Math.Min(pct + 10, 100);
|
||||
items.Add(new LauncherItem($"볼륨 +10% → {target}%", $"{VolumeBar(target)}", null, ("set", target), Symbol: Symbols.VolumeUp));
|
||||
}
|
||||
else if (q is "down" or "내려" or "-")
|
||||
{
|
||||
var target = Math.Max(pct - 10, 0);
|
||||
items.Add(new LauncherItem($"볼륨 −10% → {target}%", $"{VolumeBar(target)}", null, ("set", target), Symbol: Symbols.VolumeDown));
|
||||
}
|
||||
else if (q is "mute" or "음소거" or "m")
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
curMuted ? "음소거 해제" : "음소거 설정",
|
||||
$"현재: {pct}%{muteLabel}",
|
||||
null, ("mute", !curMuted), Symbol: Symbols.VolumeMute));
|
||||
}
|
||||
else if (int.TryParse(q, out int val) && val is >= 0 and <= 100)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"볼륨 {val}% 설정",
|
||||
$"{VolumeBar(val)} (현재 {pct}%)",
|
||||
null, ("set", val), Symbol: val > pct ? Symbols.VolumeUp : Symbols.VolumeDown));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"'{query}' — 알 수 없는 명령",
|
||||
"사용법: vol 50 / vol up / vol down / vol mute",
|
||||
null, null, Symbol: Symbols.Warning));
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var audio = AudioEndpoint.GetDefault();
|
||||
|
||||
if (item.Data is ("set", int level))
|
||||
{
|
||||
audio.SetVolume(level / 100f);
|
||||
if (audio.GetMute()) audio.SetMute(false);
|
||||
NotificationService.Notify("vol", $"볼륨 {level}%");
|
||||
}
|
||||
else if (item.Data is ("mute", bool mute))
|
||||
{
|
||||
audio.SetMute(mute);
|
||||
NotificationService.Notify("vol", mute ? "음소거" : "음소거 해제");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
NotificationService.Notify("vol", $"오류: {ex.Message}");
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string VolumeBar(int pct)
|
||||
{
|
||||
int filled = pct / 5; // 0~20
|
||||
return "[" + new string('█', filled) + new string('░', 20 - filled) + "]";
|
||||
}
|
||||
|
||||
// ─── Core Audio API COM 래퍼 ─────────────────────────────────────────────
|
||||
|
||||
private sealed class AudioEndpoint : IDisposable
|
||||
{
|
||||
private readonly IAudioEndpointVolume _vol;
|
||||
|
||||
private AudioEndpoint(IAudioEndpointVolume vol) => _vol = vol;
|
||||
|
||||
public static AudioEndpoint GetDefault()
|
||||
{
|
||||
var enumerator = (IMMDeviceEnumerator)new MMDeviceEnumeratorClass();
|
||||
enumerator.GetDefaultAudioEndpoint(0 /*eRender*/, 1 /*eMultimedia*/, out var device);
|
||||
var iid = typeof(IAudioEndpointVolume).GUID;
|
||||
device.Activate(ref iid, 1 /*CLSCTX_ALL*/, IntPtr.Zero, out var obj);
|
||||
return new AudioEndpoint((IAudioEndpointVolume)obj);
|
||||
}
|
||||
|
||||
public float GetVolume() { _vol.GetMasterVolumeLevelScalar(out float l); return l; }
|
||||
public bool GetMute() { _vol.GetMute(out bool m); return m; }
|
||||
public void SetVolume(float level) { var g = Guid.Empty; _vol.SetMasterVolumeLevelScalar(Math.Clamp(level, 0f, 1f), ref g); }
|
||||
public void SetMute(bool mute) { var g = Guid.Empty; _vol.SetMute(mute, ref g); }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_vol is IDisposable d) d.Dispose();
|
||||
if (_vol != null) Marshal.ReleaseComObject(_vol);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── COM 인터페이스 정의 (Windows Core Audio API) ──────────────────────
|
||||
|
||||
[ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")]
|
||||
private class MMDeviceEnumeratorClass { }
|
||||
|
||||
[Guid("A95664D2-9614-4F35-A746-DE8DB63617E6")]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IMMDeviceEnumerator
|
||||
{
|
||||
int EnumAudioEndpoints(int dataFlow, uint stateMask, out IntPtr devices);
|
||||
int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice device);
|
||||
}
|
||||
|
||||
[Guid("D666063F-1587-4E43-81F1-B948E807363F")]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IMMDevice
|
||||
{
|
||||
int Activate(ref Guid iid, uint clsCtx, IntPtr activationParams,
|
||||
[MarshalAs(UnmanagedType.IUnknown)] out object ppInterface);
|
||||
}
|
||||
|
||||
[Guid("5CDF2C82-841E-4546-9722-0CF74078229A")]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IAudioEndpointVolume
|
||||
{
|
||||
int RegisterControlChangeNotify(IntPtr pNotify);
|
||||
int UnregisterControlChangeNotify(IntPtr pNotify);
|
||||
int GetChannelCount(out uint pnChannelCount);
|
||||
int SetMasterVolumeLevel(float fLevelDB, ref Guid pguidEventContext);
|
||||
int SetMasterVolumeLevelScalar(float fLevel, ref Guid pguidEventContext);
|
||||
int GetMasterVolumeLevel(out float pfLevelDB);
|
||||
int GetMasterVolumeLevelScalar(out float pfLevel);
|
||||
int SetChannelVolumeLevel(uint nChannel, float fLevelDB, ref Guid pguidEventContext);
|
||||
int SetChannelVolumeLevelScalar(uint nChannel, float fLevel, ref Guid pguidEventContext);
|
||||
int GetChannelVolumeLevel(uint nChannel, out float pfLevelDB);
|
||||
int GetChannelVolumeLevelScalar(uint nChannel, out float pfLevel);
|
||||
int SetMute([MarshalAs(UnmanagedType.Bool)] bool bMute, ref Guid pguidEventContext);
|
||||
int GetMute([MarshalAs(UnmanagedType.Bool)] out bool pbMute);
|
||||
}
|
||||
}
|
||||
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 { /* 비핵심 */ }
|
||||
}
|
||||
}
|
||||
241
src/AxCopilot/Handlers/WorkTimeHandler.cs
Normal file
241
src/AxCopilot/Handlers/WorkTimeHandler.cs
Normal file
@@ -0,0 +1,241 @@
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L23-3: 근무 시간·급여 계산. "work" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: work 09:00 18:30 → 근무시간·초과근무 (점심 1시간 자동 제외)
|
||||
/// work 09:00 18:30 -30 → 점심 30분 제외
|
||||
/// work 09:00 18:30 -0 → 점심 제외 없음
|
||||
/// work 09:00 18:30 pay 15000 → 급여 함께 계산
|
||||
/// work pay 15000 → 이전 계산 재활용 급여 산출
|
||||
/// work week 45.5 → 주간 근무시간 입력 → 초과 계산
|
||||
/// Enter → 결과 복사
|
||||
/// </summary>
|
||||
public class WorkTimeHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "work";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"근무시간 계산",
|
||||
"출퇴근 시간 입력 → 근무시간·초과근무·급여 계산",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
// 마지막 계산 캐시 (pay 재활용용)
|
||||
private static double _lastWorkedHours;
|
||||
|
||||
// ── 파서 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private static bool TryParseTime(string s, out TimeSpan result)
|
||||
{
|
||||
result = default;
|
||||
s = s.Trim();
|
||||
// HH:mm or H:mm
|
||||
if (s.Contains(':'))
|
||||
{
|
||||
var parts = s.Split(':');
|
||||
if (parts.Length == 2 &&
|
||||
int.TryParse(parts[0], out var h) &&
|
||||
int.TryParse(parts[1], out var m) &&
|
||||
h >= 0 && h <= 47 && m >= 0 && m < 60)
|
||||
{
|
||||
result = new TimeSpan(h, m, 0);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// HHmm (4자리)
|
||||
if (s.Length == 4 &&
|
||||
int.TryParse(s[..2], out var hh) &&
|
||||
int.TryParse(s[2..], out var mm) &&
|
||||
hh >= 0 && hh <= 23 && mm >= 0 && mm < 60)
|
||||
{
|
||||
result = new TimeSpan(hh, mm, 0);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryParseWorkTime(string q,
|
||||
out TimeSpan start, out TimeSpan end,
|
||||
out double lunchMinutes, out double payWage)
|
||||
{
|
||||
start = default;
|
||||
end = default;
|
||||
lunchMinutes = 60;
|
||||
payWage = 0;
|
||||
|
||||
var tokens = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (tokens.Length < 2) return false;
|
||||
if (!TryParseTime(tokens[0], out start)) return false;
|
||||
if (!TryParseTime(tokens[1], out end)) return false;
|
||||
|
||||
for (var i = 2; i < tokens.Length; i++)
|
||||
{
|
||||
var t = tokens[i];
|
||||
// -N → 점심 제외 분
|
||||
if (t.StartsWith('-') && double.TryParse(t[1..],
|
||||
System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var lm))
|
||||
{
|
||||
lunchMinutes = lm;
|
||||
}
|
||||
// pay N
|
||||
else if (t.Equals("pay", StringComparison.OrdinalIgnoreCase) && i + 1 < tokens.Length)
|
||||
{
|
||||
if (double.TryParse(tokens[i + 1],
|
||||
System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var pw))
|
||||
{
|
||||
payWage = pw;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static (double stdPay, double otPay, double total) CalcPay(double wage, double workedHours)
|
||||
{
|
||||
var stdHours = Math.Min(workedHours, 8.0);
|
||||
var otHours = Math.Max(0, workedHours - 8.0);
|
||||
var stdPay = stdHours * wage;
|
||||
var otPay = otHours * wage * 1.5;
|
||||
return (stdPay, otPay, stdPay + otPay);
|
||||
}
|
||||
|
||||
// ── GetItemsAsync ─────────────────────────────────────────────────────────
|
||||
|
||||
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("근무시간 계산기",
|
||||
"work 09:00 18:30 → 근무시간 / work 09:00 18:30 -30 → 점심 30분 / work 09:00 18:30 pay 15000 → 급여",
|
||||
null, null, Symbol: "\uE916"));
|
||||
items.Add(new LauncherItem("work 09:00 18:30", "점심 1시간 자동 제외", null, null, Symbol: "\uE916"));
|
||||
items.Add(new LauncherItem("work 09:00 18:30 -30", "점심 30분 제외", null, null, Symbol: "\uE916"));
|
||||
items.Add(new LauncherItem("work pay 15000", "이전 계산 재활용 급여 산출", null, null, Symbol: "\uE916"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var tokens = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = tokens[0].ToLowerInvariant();
|
||||
|
||||
// pay N → 이전 계산 재활용
|
||||
if (sub == "pay")
|
||||
{
|
||||
if (tokens.Length >= 2 && double.TryParse(tokens[1],
|
||||
System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var wage))
|
||||
{
|
||||
if (_lastWorkedHours <= 0)
|
||||
{
|
||||
items.Add(new LauncherItem("이전 계산 없음", "먼저 시간을 계산하세요: work HH:mm HH:mm",
|
||||
null, null, Symbol: "\uE783"));
|
||||
}
|
||||
else
|
||||
{
|
||||
var (stdPay, otPay, total) = CalcPay(wage, _lastWorkedHours);
|
||||
var result = $"근무 {_lastWorkedHours:F1}시간 | 시급 {wage:#,0}원 | 기본 {stdPay:#,0}원 | 초과 {otPay:#,0}원 | 합계 {total:#,0}원";
|
||||
items.Add(new LauncherItem(
|
||||
$"급여: {total:#,0}원",
|
||||
$"기본 {stdPay:#,0}원 + 초과(1.5배) {otPay:#,0}원 · Enter: 복사",
|
||||
null, ("copy", result), Symbol: "\uE916"));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem("시급을 입력하세요", "예: work pay 15000", null, null, Symbol: "\uE783"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// week N → 주간 근무시간
|
||||
if (sub == "week")
|
||||
{
|
||||
if (tokens.Length >= 2 && double.TryParse(tokens[1],
|
||||
System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var weekHours))
|
||||
{
|
||||
var std = 40.0;
|
||||
var ot = Math.Max(0, weekHours - std);
|
||||
var label = $"주간 근무 {weekHours:F1}시간 초과근무 {ot:F1}시간 (기준 {std}h)";
|
||||
items.Add(new LauncherItem(label, "Enter: 복사",
|
||||
null, ("copy", label), Symbol: "\uE916"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem("시간을 입력하세요", "예: work week 45.5", null, null, Symbol: "\uE783"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 시각 파싱 시도
|
||||
if (!TryParseWorkTime(q, out var start, out var end, out var lunch, out var pay))
|
||||
{
|
||||
items.Add(new LauncherItem("시간 형식 오류",
|
||||
"예: work 09:00 18:30 또는 work 09:00 18:30 -30 pay 15000",
|
||||
null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 야간 처리
|
||||
if (end <= start) end = end.Add(TimeSpan.FromHours(24));
|
||||
|
||||
var totalSpan = end - start - TimeSpan.FromMinutes(lunch);
|
||||
var workedHours = totalSpan.TotalHours;
|
||||
if (workedHours < 0) workedHours = 0;
|
||||
|
||||
_lastWorkedHours = workedHours;
|
||||
|
||||
var wh = (int)workedHours;
|
||||
var wm = (int)Math.Round((workedHours - wh) * 60);
|
||||
var otHours = Math.Max(0, workedHours - 8.0);
|
||||
|
||||
var summaryLine = $"근무 {workedHours:F1}시간 ({wh}시간 {wm}분) 초과 {otHours:F1}시간";
|
||||
items.Add(new LauncherItem(
|
||||
$"근무시간: {workedHours:F1}시간 ({wh}시간 {wm}분)",
|
||||
$"초과근무: {otHours:F1}시간 · Enter: 복사",
|
||||
null, ("copy", summaryLine), Symbol: "\uE916"));
|
||||
items.Add(new LauncherItem(
|
||||
$"초과근무: {otHours:F1}시간",
|
||||
$"기준 8시간 초과분 / 점심 제외: {lunch}분",
|
||||
null, ("copy", $"초과근무: {otHours:F1}시간"), Symbol: "\uE916"));
|
||||
|
||||
if (pay > 0)
|
||||
{
|
||||
var (stdP, otP, totalP) = CalcPay(pay, workedHours);
|
||||
var payLine = $"급여: {totalP:#,0}원 (기본 {stdP:#,0}원 + 초과 {otP:#,0}원) | 시급 {pay:#,0}원";
|
||||
items.Add(new LauncherItem(
|
||||
$"급여: {totalP:#,0}원",
|
||||
$"기본 {stdP:#,0}원 + 초과(1.5배) {otP:#,0}원 · Enter: 복사",
|
||||
null, ("copy", payLine), Symbol: "\uE916"));
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ── ExecuteAsync ──────────────────────────────────────────────────────────
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is ("copy", string text))
|
||||
{
|
||||
try
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
|
||||
NotificationService.Notify("근무시간", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
274
src/AxCopilot/Handlers/WslHandler.cs
Normal file
274
src/AxCopilot/Handlers/WslHandler.cs
Normal file
@@ -0,0 +1,274 @@
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L15-1: WSL(Windows Subsystem for Linux) 관리 핸들러. "wsl" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: wsl → 설치된 distro 목록 + 상태
|
||||
/// wsl ubuntu → Ubuntu 실행 (새 터미널)
|
||||
/// wsl stop ubuntu → 특정 distro 종료
|
||||
/// wsl stop all → 전체 WSL 종료
|
||||
/// wsl default ubuntu → 기본 distro 변경
|
||||
/// Enter → distro 실행 또는 명령 실행.
|
||||
/// </summary>
|
||||
public class WslHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "wsl";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"WSL",
|
||||
"WSL 관리 — distro 목록 · 실행 · 종료",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
var distros = GetDistros();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
if (distros.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("WSL 없음",
|
||||
"WSL이 설치되지 않았거나 distro가 없습니다", null, null, Symbol: "\uE756"));
|
||||
items.Add(new LauncherItem("WSL 설치",
|
||||
"Microsoft Store에서 Ubuntu 등 설치", null,
|
||||
("open_url", "ms-windows-store://search/?query=linux"), Symbol: "\uE756"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
$"WSL distro {distros.Count}개",
|
||||
"Enter → 실행 / wsl stop all → 전체 종료",
|
||||
null, null, Symbol: "\uE756"));
|
||||
|
||||
foreach (var d in distros)
|
||||
items.Add(MakeDistroItem(d));
|
||||
|
||||
items.Add(new LauncherItem("wsl stop all", "전체 WSL 종료 (wsl --shutdown)", null,
|
||||
("shutdown", ""), Symbol: "\uE756"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
switch (sub)
|
||||
{
|
||||
case "stop":
|
||||
case "shutdown":
|
||||
case "kill":
|
||||
{
|
||||
var target = parts.Length > 1 ? parts[1].ToLowerInvariant() : "all";
|
||||
if (target == "all" || target == "--all")
|
||||
{
|
||||
items.Add(new LauncherItem("WSL 전체 종료", "wsl --shutdown · Enter 실행",
|
||||
null, ("shutdown", ""), Symbol: "\uE756"));
|
||||
}
|
||||
else
|
||||
{
|
||||
var found = distros.FirstOrDefault(d =>
|
||||
d.Name.Contains(target, StringComparison.OrdinalIgnoreCase));
|
||||
if (found == null)
|
||||
items.Add(new LauncherItem("없는 distro", $"'{target}'를 찾을 수 없습니다", null, null, Symbol: "\uE783"));
|
||||
else
|
||||
items.Add(new LauncherItem($"{found.Name} 종료", $"wsl --terminate {found.Name}",
|
||||
null, ("terminate", found.Name), Symbol: "\uE756"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "default":
|
||||
case "set-default":
|
||||
{
|
||||
var target = parts.Length > 1 ? parts[1] : "";
|
||||
if (string.IsNullOrWhiteSpace(target))
|
||||
{
|
||||
items.Add(new LauncherItem("distro 이름 입력", "예: wsl default Ubuntu", null, null, Symbol: "\uE783"));
|
||||
break;
|
||||
}
|
||||
items.Add(new LauncherItem($"기본 distro: {target}",
|
||||
$"wsl --set-default {target} · Enter 실행",
|
||||
null, ("set_default", target), Symbol: "\uE756"));
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
{
|
||||
// distro 이름 검색 → 실행
|
||||
var found = distros.Where(d =>
|
||||
d.Name.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (found.Count > 0)
|
||||
foreach (var d in found)
|
||||
items.Add(MakeDistroItem(d));
|
||||
else
|
||||
items.Add(new LauncherItem($"'{q}' distro 없음",
|
||||
"wsl 입력으로 전체 목록 확인", null, null, Symbol: "\uE946"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
switch (item.Data)
|
||||
{
|
||||
case ("launch", string distro):
|
||||
RunWsl($"-d \"{distro}\"");
|
||||
break;
|
||||
|
||||
case ("shutdown", _):
|
||||
RunWslSilent("--shutdown");
|
||||
NotificationService.Notify("WSL", "WSL 전체 종료 요청됨");
|
||||
break;
|
||||
|
||||
case ("terminate", string distro):
|
||||
RunWslSilent($"--terminate \"{distro}\"");
|
||||
NotificationService.Notify("WSL", $"{distro} 종료됨");
|
||||
break;
|
||||
|
||||
case ("set_default", string distro):
|
||||
RunWslSilent($"--set-default \"{distro}\"");
|
||||
NotificationService.Notify("WSL", $"기본 distro → {distro}");
|
||||
break;
|
||||
|
||||
case ("open_url", string url):
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = url, UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
break;
|
||||
|
||||
case ("copy", string text):
|
||||
try
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
|
||||
NotificationService.Notify("WSL", "복사됨");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
break;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── WSL 조회 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private record WslDistro(string Name, string State, string Version, bool IsDefault);
|
||||
|
||||
private static List<WslDistro> GetDistros()
|
||||
{
|
||||
var result = new List<WslDistro>();
|
||||
try
|
||||
{
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "wsl",
|
||||
Arguments = "--list --verbose",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.Unicode, // WSL outputs UTF-16
|
||||
};
|
||||
using var proc = System.Diagnostics.Process.Start(psi);
|
||||
if (proc == null) return result;
|
||||
|
||||
var output = proc.StandardOutput.ReadToEnd();
|
||||
proc.WaitForExit(3000);
|
||||
|
||||
foreach (var line in output.Split('\n').Skip(1)) // 첫 줄은 헤더
|
||||
{
|
||||
var trimmed = line.Trim().TrimEnd('\r');
|
||||
if (string.IsNullOrWhiteSpace(trimmed)) continue;
|
||||
|
||||
var isDefault = trimmed.StartsWith('*');
|
||||
trimmed = trimmed.TrimStart('*').Trim();
|
||||
|
||||
var parts = trimmed.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length < 2) continue;
|
||||
|
||||
result.Add(new WslDistro(
|
||||
Name: parts[0],
|
||||
State: parts.Length > 1 ? parts[1] : "Unknown",
|
||||
Version: parts.Length > 2 ? parts[2] : "?",
|
||||
IsDefault: isDefault));
|
||||
}
|
||||
}
|
||||
catch { /* WSL 없음 */ }
|
||||
return result;
|
||||
}
|
||||
|
||||
private static LauncherItem MakeDistroItem(WslDistro d)
|
||||
{
|
||||
var icon = d.State.Equals("Running", StringComparison.OrdinalIgnoreCase) ? "\uE768" : "\uE756";
|
||||
var label = d.IsDefault ? $"★ {d.Name}" : d.Name;
|
||||
var subtitle = $"{d.State} · WSL {d.Version}" + (d.IsDefault ? " (기본)" : "");
|
||||
return new LauncherItem(label, subtitle, null, ("launch", d.Name), Symbol: icon);
|
||||
}
|
||||
|
||||
private static void RunWsl(string args)
|
||||
{
|
||||
// 터미널에서 실행 (wt 또는 powershell 폴백)
|
||||
var wtPath = FindExe("wt.exe");
|
||||
if (wtPath != null)
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = wtPath,
|
||||
Arguments = $"wsl {args}",
|
||||
UseShellExecute = false,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "wsl",
|
||||
Arguments = args,
|
||||
UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void RunWslSilent(string args)
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "wsl",
|
||||
Arguments = args,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
using var proc = System.Diagnostics.Process.Start(psi);
|
||||
proc?.WaitForExit(5000);
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
|
||||
private static string? FindExe(string name)
|
||||
{
|
||||
var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||
foreach (var dir in pathEnv.Split(';'))
|
||||
{
|
||||
var full = System.IO.Path.Combine(dir.Trim(), name);
|
||||
if (System.IO.File.Exists(full)) return full;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
227
src/AxCopilot/Handlers/XlHandler.cs
Normal file
227
src/AxCopilot/Handlers/XlHandler.cs
Normal file
@@ -0,0 +1,227 @@
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L22-2: Excel 함수 레퍼런스 핸들러. "xl" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: xl → 카테고리 목록
|
||||
/// xl lookup → 찾기·참조 함수 (VLOOKUP, INDEX, MATCH 등)
|
||||
/// xl if → 논리 함수 (IF, IFS, IFERROR 등)
|
||||
/// xl sum → 합산 함수 (SUM, SUMIF, SUMIFS 등)
|
||||
/// xl count → 개수 함수 (COUNT, COUNTA, COUNTIF 등)
|
||||
/// xl text → 텍스트 함수 (LEFT, RIGHT, MID, TRIM 등)
|
||||
/// xl date → 날짜 함수 (TODAY, DATEDIF, EDATE 등)
|
||||
/// xl math → 수학 함수 (ROUND, INT, MOD, ABS 등)
|
||||
/// xl stat → 통계 함수 (AVERAGE, MAX, MIN, RANK 등)
|
||||
/// xl <검색어> → 함수명·설명 검색
|
||||
/// Enter → 함수 이름 복사.
|
||||
/// </summary>
|
||||
public class XlHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "xl";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Excel 함수",
|
||||
"Excel 함수 레퍼런스 — 조회·논리·텍스트·날짜·수학·통계",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private sealed record XlFunc(string Name, string Syntax, string Description, string Category);
|
||||
|
||||
private static readonly XlFunc[] Funcs =
|
||||
[
|
||||
// ── 조회·참조 (lookup) ──────────────────────────────────────────────
|
||||
new("VLOOKUP", "VLOOKUP(값, 범위, 열번호, [일치유형])", "세로 방향 조회 — 열에서 값 검색 후 지정 열 반환", "lookup"),
|
||||
new("HLOOKUP", "HLOOKUP(값, 범위, 행번호, [일치유형])", "가로 방향 조회 — 행에서 값 검색 후 지정 행 반환", "lookup"),
|
||||
new("XLOOKUP", "XLOOKUP(값, 찾기범위, 반환범위, [없을때])", "유연한 조회 — 방향 무관, 정확/근사/와일드카드", "lookup"),
|
||||
new("INDEX", "INDEX(범위, 행번호, [열번호])", "범위에서 특정 위치의 값 반환", "lookup"),
|
||||
new("MATCH", "MATCH(값, 범위, [일치유형])", "범위에서 값의 상대 위치(순번) 반환", "lookup"),
|
||||
new("OFFSET", "OFFSET(기준, 행이동, 열이동, [높이], [너비])","기준 셀에서 이동한 범위 반환", "lookup"),
|
||||
new("INDIRECT", "INDIRECT(참조문자열, [A1형식])", "문자열로 표현된 참조를 실제 참조로 변환", "lookup"),
|
||||
new("CHOOSE", "CHOOSE(인덱스, 값1, 값2, ...)", "인덱스 번호에 해당하는 값 반환", "lookup"),
|
||||
new("ADDRESS", "ADDRESS(행번호, 열번호, [참조형식])", "셀 주소 문자열 생성 (예: \"$A$1\")", "lookup"),
|
||||
|
||||
// ── 논리 (if) ────────────────────────────────────────────────────────
|
||||
new("IF", "IF(조건, 참값, 거짓값)", "조건이 참이면 참값, 거짓이면 거짓값 반환", "if"),
|
||||
new("IFS", "IFS(조건1, 값1, 조건2, 값2, ...)", "여러 조건을 순서대로 검사하여 첫 번째 참 반환", "if"),
|
||||
new("IFERROR", "IFERROR(식, 오류시값)", "식이 오류이면 오류시값, 아니면 식 결과 반환", "if"),
|
||||
new("IFNA", "IFNA(식, NA시값)", "#N/A 오류 시 NA시값 반환", "if"),
|
||||
new("AND", "AND(조건1, 조건2, ...)", "모든 조건이 참이면 TRUE", "if"),
|
||||
new("OR", "OR(조건1, 조건2, ...)", "하나 이상 조건이 참이면 TRUE", "if"),
|
||||
new("NOT", "NOT(조건)", "논리값 반전", "if"),
|
||||
new("SWITCH", "SWITCH(식, 값1, 결과1, [값2, 결과2], ...)", "식을 여러 값과 비교하여 일치하는 결과 반환", "if"),
|
||||
|
||||
// ── 합산 (sum) ────────────────────────────────────────────────────────
|
||||
new("SUM", "SUM(범위1, [범위2], ...)", "합계", "sum"),
|
||||
new("SUMIF", "SUMIF(조건범위, 조건, [합산범위])", "조건에 맞는 셀의 합계", "sum"),
|
||||
new("SUMIFS", "SUMIFS(합산범위, 조건범위1, 조건1, ...)", "여러 조건에 맞는 셀의 합계", "sum"),
|
||||
new("SUMPRODUCT","SUMPRODUCT(배열1, [배열2], ...)", "배열 요소를 곱한 후 합계", "sum"),
|
||||
new("SUBTOTAL", "SUBTOTAL(함수번호, 범위)", "필터링·숨긴 행 제외한 부분합", "sum"),
|
||||
new("AGGREGATE", "AGGREGATE(함수번호, 옵션, 범위)", "오류·숨긴 행 무시하고 집계", "sum"),
|
||||
|
||||
// ── 개수 (count) ──────────────────────────────────────────────────────
|
||||
new("COUNT", "COUNT(값1, [값2], ...)", "숫자가 있는 셀 개수", "count"),
|
||||
new("COUNTA", "COUNTA(값1, [값2], ...)", "비어있지 않은 셀 개수", "count"),
|
||||
new("COUNTBLANK","COUNTBLANK(범위)", "빈 셀 개수", "count"),
|
||||
new("COUNTIF", "COUNTIF(범위, 조건)", "조건에 맞는 셀 개수", "count"),
|
||||
new("COUNTIFS", "COUNTIFS(범위1, 조건1, [범위2, 조건2], ...)", "여러 조건에 맞는 셀 개수", "count"),
|
||||
|
||||
// ── 텍스트 (text) ──────────────────────────────────────────────────────
|
||||
new("LEFT", "LEFT(텍스트, 문자수)", "왼쪽에서 N자 추출", "text"),
|
||||
new("RIGHT", "RIGHT(텍스트, 문자수)", "오른쪽에서 N자 추출", "text"),
|
||||
new("MID", "MID(텍스트, 시작, 문자수)", "중간 N자 추출", "text"),
|
||||
new("LEN", "LEN(텍스트)", "문자 수 반환", "text"),
|
||||
new("FIND", "FIND(찾기, 텍스트, [시작위치])", "대소문자 구분하여 위치 반환", "text"),
|
||||
new("SEARCH", "SEARCH(찾기, 텍스트, [시작위치])", "대소문자 무시하여 위치 반환", "text"),
|
||||
new("SUBSTITUTE","SUBSTITUTE(텍스트, 찾기, 바꾸기, [번째])", "특정 문자열을 다른 문자열로 치환", "text"),
|
||||
new("REPLACE", "REPLACE(텍스트, 시작, 문자수, 새텍스트)", "위치 기반으로 문자열 교체", "text"),
|
||||
new("TRIM", "TRIM(텍스트)", "앞뒤·중복 공백 제거", "text"),
|
||||
new("CLEAN", "CLEAN(텍스트)", "인쇄 불가 문자 제거", "text"),
|
||||
new("UPPER", "UPPER(텍스트)", "대문자로 변환", "text"),
|
||||
new("LOWER", "LOWER(텍스트)", "소문자로 변환", "text"),
|
||||
new("PROPER", "PROPER(텍스트)", "각 단어 첫 글자만 대문자", "text"),
|
||||
new("CONCAT", "CONCAT(텍스트1, 텍스트2, ...)", "텍스트 연결 (CONCATENATE 최신 버전)", "text"),
|
||||
new("TEXTJOIN", "TEXTJOIN(구분자, 빈셀무시, 텍스트1, ...)", "구분자를 포함하여 텍스트 연결", "text"),
|
||||
new("TEXT", "TEXT(값, 서식)", "숫자를 서식 문자열로 변환 (예: \"#,##0\")", "text"),
|
||||
new("VALUE", "VALUE(텍스트)", "텍스트를 숫자로 변환", "text"),
|
||||
new("NUMBERVALUE","NUMBERVALUE(텍스트, 소수점, 그룹구분)", "로캘 독립적 텍스트→숫자 변환", "text"),
|
||||
|
||||
// ── 날짜 (date) ──────────────────────────────────────────────────────
|
||||
new("TODAY", "TODAY()", "오늘 날짜 반환", "date"),
|
||||
new("NOW", "NOW()", "현재 날짜와 시간 반환", "date"),
|
||||
new("DATE", "DATE(년, 월, 일)", "날짜 값 생성", "date"),
|
||||
new("DATEDIF", "DATEDIF(시작일, 종료일, 단위)", "두 날짜 간 차이 — 단위: Y M D YM YD MD", "date"),
|
||||
new("EDATE", "EDATE(시작일, 개월수)", "N개월 후/전 날짜", "date"),
|
||||
new("EOMONTH", "EOMONTH(시작일, 개월수)", "N개월 후 월 말일", "date"),
|
||||
new("WORKDAY", "WORKDAY(시작일, 일수, [공휴일])", "영업일 N일 후 날짜", "date"),
|
||||
new("NETWORKDAYS","NETWORKDAYS(시작일, 종료일, [공휴일])", "두 날짜 사이 영업일 수", "date"),
|
||||
new("YEAR", "YEAR(날짜)", "연도 추출", "date"),
|
||||
new("MONTH", "MONTH(날짜)", "월 추출", "date"),
|
||||
new("DAY", "DAY(날짜)", "일 추출", "date"),
|
||||
new("WEEKDAY", "WEEKDAY(날짜, [반환형식])", "요일 번호 반환 (1=일요일)", "date"),
|
||||
new("WEEKNUM", "WEEKNUM(날짜, [반환형식])", "해당 날짜의 주 번호", "date"),
|
||||
|
||||
// ── 수학 (math) ──────────────────────────────────────────────────────
|
||||
new("ROUND", "ROUND(숫자, 자릿수)", "반올림", "math"),
|
||||
new("ROUNDUP", "ROUNDUP(숫자, 자릿수)", "올림", "math"),
|
||||
new("ROUNDDOWN", "ROUNDDOWN(숫자, 자릿수)", "내림", "math"),
|
||||
new("INT", "INT(숫자)", "정수 부분만 반환 (내림)", "math"),
|
||||
new("MOD", "MOD(숫자, 제수)", "나머지 반환", "math"),
|
||||
new("ABS", "ABS(숫자)", "절대값", "math"),
|
||||
new("POWER", "POWER(숫자, 지수)", "거듭제곱", "math"),
|
||||
new("SQRT", "SQRT(숫자)", "제곱근", "math"),
|
||||
new("CEILING", "CEILING(숫자, 기준)", "기준의 배수로 올림", "math"),
|
||||
new("FLOOR", "FLOOR(숫자, 기준)", "기준의 배수로 내림", "math"),
|
||||
new("RAND", "RAND()", "0~1 사이 난수", "math"),
|
||||
new("RANDBETWEEN","RANDBETWEEN(최소, 최대)", "정수 난수", "math"),
|
||||
new("LARGE", "LARGE(범위, 순위)", "N번째로 큰 값", "math"),
|
||||
new("SMALL", "SMALL(범위, 순위)", "N번째로 작은 값", "math"),
|
||||
|
||||
// ── 통계 (stat) ──────────────────────────────────────────────────────
|
||||
new("AVERAGE", "AVERAGE(값1, [값2], ...)", "평균", "stat"),
|
||||
new("AVERAGEIF", "AVERAGEIF(범위, 조건, [평균범위])", "조건에 맞는 셀의 평균", "stat"),
|
||||
new("AVERAGEIFS","AVERAGEIFS(평균범위, 범위1, 조건1, ...)", "여러 조건에 맞는 셀의 평균", "stat"),
|
||||
new("MAX", "MAX(값1, [값2], ...)", "최대값", "stat"),
|
||||
new("MIN", "MIN(값1, [값2], ...)", "최소값", "stat"),
|
||||
new("MAXIFS", "MAXIFS(최대범위, 조건범위1, 조건1, ...)", "조건에 맞는 최대값", "stat"),
|
||||
new("MINIFS", "MINIFS(최소범위, 조건범위1, 조건1, ...)", "조건에 맞는 최소값", "stat"),
|
||||
new("MEDIAN", "MEDIAN(값1, [값2], ...)", "중앙값", "stat"),
|
||||
new("MODE", "MODE(값1, [값2], ...)", "최빈값", "stat"),
|
||||
new("RANK", "RANK(숫자, 범위, [정렬])", "순위 반환 (0=내림차순)", "stat"),
|
||||
new("STDEV", "STDEV(값1, [값2], ...)", "표준편차 (샘플)", "stat"),
|
||||
new("VAR", "VAR(값1, [값2], ...)", "분산 (샘플)", "stat"),
|
||||
new("CORREL", "CORREL(배열1, 배열2)", "두 배열의 상관계수", "stat"),
|
||||
new("PERCENTILE","PERCENTILE(배열, k)", "백분위수 (k: 0~1)", "stat"),
|
||||
];
|
||||
|
||||
private static readonly (string Key, string[] Aliases, string Label)[] Categories =
|
||||
[
|
||||
("lookup", ["lookup", "find", "조회", "참조", "찾기"], "조회·참조"),
|
||||
("if", ["if", "logic", "논리", "조건"], "논리·조건"),
|
||||
("sum", ["sum", "합계", "sumif", "더하기"], "합산"),
|
||||
("count", ["count", "개수", "countif"], "개수"),
|
||||
("text", ["text", "텍스트", "문자", "string"], "텍스트"),
|
||||
("date", ["date", "날짜", "time", "시간"], "날짜·시간"),
|
||||
("math", ["math", "수학", "round", "수식"], "수학"),
|
||||
("stat", ["stat", "통계", "average", "max"], "통계"),
|
||||
];
|
||||
|
||||
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("Excel 함수 레퍼런스",
|
||||
"카테고리: lookup · if · sum · count · text · date · math · stat",
|
||||
null, null, Symbol: "\uE9D2"));
|
||||
|
||||
foreach (var (key, _, label) in Categories)
|
||||
{
|
||||
var cnt = Funcs.Count(f => f.Category == key);
|
||||
items.Add(new LauncherItem($"xl {key}", $"{label} ({cnt}개)", null, ("copy", $"xl {key}"), Symbol: "\uE9D2"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var kw = q.ToLowerInvariant();
|
||||
|
||||
// 카테고리 일치 확인
|
||||
var cat = Categories.FirstOrDefault(c => c.Aliases.Any(a => a == kw || kw.StartsWith(a)));
|
||||
|
||||
if (cat.Key != null)
|
||||
{
|
||||
var list = Funcs.Where(f => f.Category == cat.Key).ToList();
|
||||
items.Add(new LauncherItem($"{cat.Label} 함수 {list.Count}개",
|
||||
"Enter: 함수명 복사", null, null, Symbol: "\uE9D2"));
|
||||
foreach (var f in list)
|
||||
items.Add(FuncItem(f));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 검색어 매칭
|
||||
var search = Funcs
|
||||
.Where(f => f.Name.Contains(kw, StringComparison.OrdinalIgnoreCase)
|
||||
|| f.Description.Contains(kw, StringComparison.OrdinalIgnoreCase)
|
||||
|| f.Syntax.Contains(kw, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (search.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem($"'{q}' 함수를 찾을 수 없습니다",
|
||||
"카테고리: lookup · if · sum · count · text · date · math · stat",
|
||||
null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
items.Add(new LauncherItem($"'{q}' 검색 결과 {search.Count}개",
|
||||
"Enter: 함수명 복사", null, null, Symbol: "\uE9D2"));
|
||||
foreach (var f in search)
|
||||
items.Add(FuncItem(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("Excel 함수", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static LauncherItem FuncItem(XlFunc f) =>
|
||||
new(f.Name, $"{f.Description} | {f.Syntax}",
|
||||
null, ("copy", f.Name), Symbol: "\uE9D2");
|
||||
}
|
||||
345
src/AxCopilot/Handlers/XmlHandler.cs
Normal file
345
src/AxCopilot/Handlers/XmlHandler.cs
Normal file
@@ -0,0 +1,345 @@
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using System.Xml;
|
||||
using System.Xml.XPath;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L10-1: XML 포맷터·검증기·XPath 쿼리 핸들러. "xml" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: xml → 클립보드의 XML 자동 포맷
|
||||
/// xml <root><a>1</a> → 인라인 XML 포맷
|
||||
/// xml compact → 클립보드 XML 압축 (공백 제거)
|
||||
/// xml xpath //a → 클립보드 XML에 XPath 쿼리
|
||||
/// xml validate → XML 유효성 검증
|
||||
/// xml attr → XML 속성 목록 추출
|
||||
/// xml minify → compact 와 동일
|
||||
/// Enter → 결과를 클립보드에 복사.
|
||||
/// </summary>
|
||||
public class XmlHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "xml";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"XML",
|
||||
"XML 포맷터 · 압축 · 검증 · XPath 쿼리",
|
||||
"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 clip = GetClipboard();
|
||||
if (!string.IsNullOrWhiteSpace(clip) && clip.TrimStart().StartsWith('<'))
|
||||
{
|
||||
// 클립보드에 XML이 있으면 즉시 포맷 미리보기
|
||||
items.AddRange(BuildXmlItems(clip, "클립보드"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem("XML 도구",
|
||||
"예: xml <root>… / xml compact / xml xpath //path / xml validate",
|
||||
null, null, Symbol: "\uE943"));
|
||||
items.Add(new LauncherItem("xml compact", "XML 압축 (공백 제거)", null, null, Symbol: "\uE943"));
|
||||
items.Add(new LauncherItem("xml validate", "XML 유효성 검증", null, null, Symbol: "\uE943"));
|
||||
items.Add(new LauncherItem("xml xpath //", "XPath 쿼리", null, null, Symbol: "\uE943"));
|
||||
items.Add(new LauncherItem("xml attr", "속성 목록 추출", null, null, Symbol: "\uE943"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
switch (sub)
|
||||
{
|
||||
case "compact":
|
||||
case "minify":
|
||||
{
|
||||
var src = GetClipboard();
|
||||
if (TryMinify(src, out var mini))
|
||||
items.Add(new LauncherItem(mini.Length > 80 ? mini[..80] + "…" : mini,
|
||||
$"압축됨 {src.Length} → {mini.Length} 글자 · Enter 복사",
|
||||
null, ("copy", mini), Symbol: "\uE943"));
|
||||
else
|
||||
items.Add(new LauncherItem("XML 파싱 오류", "클립보드에 유효한 XML이 없습니다", null, null, Symbol: "\uE783"));
|
||||
break;
|
||||
}
|
||||
case "validate":
|
||||
{
|
||||
var src = GetClipboard();
|
||||
if (string.IsNullOrWhiteSpace(src))
|
||||
{
|
||||
items.Add(new LauncherItem("클립보드 비어 있음", "XML을 클립보드에 복사 후 시도하세요", null, null, Symbol: "\uE783"));
|
||||
break;
|
||||
}
|
||||
var (ok, err) = ValidateXml(src);
|
||||
items.Add(ok
|
||||
? new LauncherItem("✔ 유효한 XML", $"{src.Length:N0}자 · 잘 형식화된 XML입니다", null, null, Symbol: "\uE73E")
|
||||
: new LauncherItem("✘ XML 오류", err ?? "알 수 없는 오류", null, null, Symbol: "\uE783"));
|
||||
break;
|
||||
}
|
||||
case "xpath":
|
||||
{
|
||||
var xpathExpr = parts.Length > 1 ? parts[1].Trim() : "";
|
||||
if (string.IsNullOrWhiteSpace(xpathExpr))
|
||||
{
|
||||
items.Add(new LauncherItem("XPath 식을 입력하세요", "예: xml xpath //item/title", null, null, Symbol: "\uE783"));
|
||||
break;
|
||||
}
|
||||
var src = GetClipboard();
|
||||
items.AddRange(RunXPath(src, xpathExpr));
|
||||
break;
|
||||
}
|
||||
case "attr":
|
||||
{
|
||||
var src = GetClipboard();
|
||||
items.AddRange(ExtractAttributes(src));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
{
|
||||
// 인라인 XML 입력 또는 전체 포맷
|
||||
var xmlSrc = q.TrimStart().StartsWith('<') ? q : GetClipboard();
|
||||
items.AddRange(BuildXmlItems(xmlSrc, q.TrimStart().StartsWith('<') ? "입력" : "클립보드"));
|
||||
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("XML", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 빌더 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static IEnumerable<LauncherItem> BuildXmlItems(string xml, string source)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(xml))
|
||||
{
|
||||
yield return new LauncherItem("XML 없음", "클립보드에 XML을 복사해주세요", null, null, Symbol: "\uE783");
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (TryFormat(xml, out var formatted))
|
||||
{
|
||||
var preview = formatted.Length > 100 ? formatted[..100].Replace("\n", " ") + "…" : formatted.Replace("\n", " ");
|
||||
yield return new LauncherItem(
|
||||
$"포맷된 XML ({source})",
|
||||
preview,
|
||||
null,
|
||||
("copy", formatted),
|
||||
Symbol: "\uE943");
|
||||
|
||||
// 루트 요소 이름
|
||||
if (TryGetRootElement(xml, out var root))
|
||||
yield return new LauncherItem("루트 요소", $"<{root}>", null, ("copy", root), Symbol: "\uE943");
|
||||
|
||||
// 요소 수
|
||||
var elemCount = CountElements(xml);
|
||||
yield return new LauncherItem("요소 수", $"{elemCount:N0}개 요소", null, null, Symbol: "\uE943");
|
||||
|
||||
// 압축 버전
|
||||
if (TryMinify(xml, out var mini))
|
||||
yield return new LauncherItem(
|
||||
"압축 XML",
|
||||
$"{xml.Length:N0} → {mini.Length:N0} 글자",
|
||||
null,
|
||||
("copy", mini),
|
||||
Symbol: "\uE943");
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return new LauncherItem("XML 파싱 오류", "유효한 XML을 입력해주세요", null, null, Symbol: "\uE783");
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<LauncherItem> RunXPath(string xml, string xpath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(xml))
|
||||
return [new LauncherItem("XML 없음", "클립보드에 XML을 복사해주세요", null, null, Symbol: "\uE783")];
|
||||
|
||||
XPathDocument doc;
|
||||
try { doc = new XPathDocument(new System.IO.StringReader(xml)); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
return [new LauncherItem("XML 파싱 오류", ex.Message, null, null, Symbol: "\uE783")];
|
||||
}
|
||||
|
||||
XPathNodeIterator iter;
|
||||
try
|
||||
{
|
||||
var nav = doc.CreateNavigator();
|
||||
iter = nav.Select(xpath);
|
||||
}
|
||||
catch (XPathException ex)
|
||||
{
|
||||
return [new LauncherItem("XPath 오류", ex.Message, null, null, Symbol: "\uE783")];
|
||||
}
|
||||
|
||||
var results = new List<string>();
|
||||
while (iter.MoveNext() && results.Count < 20)
|
||||
results.Add(iter.Current!.OuterXml);
|
||||
|
||||
if (results.Count == 0)
|
||||
return [new LauncherItem("결과 없음", $"XPath '{xpath}' — 일치하는 노드 없음", null, null, Symbol: "\uE946")];
|
||||
|
||||
var items = new List<LauncherItem>();
|
||||
var joined = string.Join("\n", results);
|
||||
items.Add(new LauncherItem(
|
||||
$"XPath 결과 {results.Count}개",
|
||||
results[0].Length > 80 ? results[0][..80] + "…" : results[0],
|
||||
null, ("copy", joined), Symbol: "\uE943"));
|
||||
|
||||
foreach (var r in results.Take(10))
|
||||
{
|
||||
var disp = r.Length > 80 ? r[..80] + "…" : r;
|
||||
items.Add(new LauncherItem(disp, "Enter 복사", null, ("copy", r), Symbol: "\uE943"));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private static IEnumerable<LauncherItem> ExtractAttributes(string xml)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(xml))
|
||||
return [new LauncherItem("XML 없음", "클립보드에 XML을 복사해주세요", null, null, Symbol: "\uE783")];
|
||||
|
||||
try
|
||||
{
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
var attrs = new List<(string Element, string Attr, string Value)>();
|
||||
|
||||
foreach (XmlNode node in doc.SelectNodes("//*")!)
|
||||
{
|
||||
if (node.Attributes == null) continue;
|
||||
foreach (XmlAttribute attr in node.Attributes)
|
||||
attrs.Add((node.Name, attr.Name, attr.Value));
|
||||
}
|
||||
|
||||
if (attrs.Count == 0)
|
||||
return [new LauncherItem("속성 없음", "XML에 속성(attribute)이 없습니다", null, null, Symbol: "\uE946")];
|
||||
|
||||
var items = new List<LauncherItem>
|
||||
{
|
||||
new($"속성 {attrs.Count}개", "전체 복사: Enter", null,
|
||||
("copy", string.Join("\n", attrs.Select(a => $"{a.Element}@{a.Attr}={a.Value}"))),
|
||||
Symbol: "\uE943"),
|
||||
};
|
||||
items.AddRange(attrs.Take(15).Select(a =>
|
||||
new LauncherItem($"{a.Element}@{a.Attr}", a.Value, null, ("copy", a.Value), Symbol: "\uE943")));
|
||||
return items;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return [new LauncherItem("XML 오류", ex.Message, null, null, Symbol: "\uE783")];
|
||||
}
|
||||
}
|
||||
|
||||
// ── XML 헬퍼 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static bool TryFormat(string xml, out string result)
|
||||
{
|
||||
result = "";
|
||||
try
|
||||
{
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
var sb = new StringBuilder();
|
||||
var st = new XmlWriterSettings { Indent = true, IndentChars = " ", NewLineChars = "\n" };
|
||||
using var writer = XmlWriter.Create(sb, st);
|
||||
doc.WriteTo(writer);
|
||||
writer.Flush();
|
||||
result = sb.ToString();
|
||||
return true;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static bool TryMinify(string xml, out string result)
|
||||
{
|
||||
result = "";
|
||||
try
|
||||
{
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
var sb = new StringBuilder();
|
||||
var st = new XmlWriterSettings { Indent = false, NewLineHandling = NewLineHandling.None };
|
||||
using var writer = XmlWriter.Create(sb, st);
|
||||
doc.WriteTo(writer);
|
||||
writer.Flush();
|
||||
result = sb.ToString();
|
||||
return true;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static (bool Ok, string? Error) ValidateXml(string xml)
|
||||
{
|
||||
try
|
||||
{
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
return (true, null);
|
||||
}
|
||||
catch (XmlException ex)
|
||||
{
|
||||
return (false, $"줄 {ex.LineNumber}, 열 {ex.LinePosition}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetRootElement(string xml, out string root)
|
||||
{
|
||||
root = "";
|
||||
try
|
||||
{
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
root = doc.DocumentElement?.Name ?? "";
|
||||
return !string.IsNullOrEmpty(root);
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static int CountElements(string xml)
|
||||
{
|
||||
try
|
||||
{
|
||||
var doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
return doc.SelectNodes("//*")?.Count ?? 0;
|
||||
}
|
||||
catch { return 0; }
|
||||
}
|
||||
|
||||
private static string GetClipboard()
|
||||
{
|
||||
try
|
||||
{
|
||||
return System.Windows.Application.Current.Dispatcher.Invoke(
|
||||
() => Clipboard.ContainsText() ? Clipboard.GetText() : "");
|
||||
}
|
||||
catch { return ""; }
|
||||
}
|
||||
}
|
||||
410
src/AxCopilot/Handlers/YamlHandler.cs
Normal file
410
src/AxCopilot/Handlers/YamlHandler.cs
Normal file
@@ -0,0 +1,410 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L17-3: YAML 파서·포맷터·검증 핸들러. "yaml" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: yaml → 클립보드 YAML 구조 분석
|
||||
/// yaml validate → YAML 유효성 검사
|
||||
/// yaml keys → 최상위 키 목록
|
||||
/// yaml get key.subkey → 특정 경로 값 조회 (점 표기법)
|
||||
/// yaml stats → 줄 수·키 수·깊이 통계
|
||||
/// yaml flat → 점 표기법으로 평탄화
|
||||
/// Enter → 결과 복사.
|
||||
/// 외부 라이브러리 없이 순수 파싱 구현 (기본 YAML 스펙 지원).
|
||||
/// </summary>
|
||||
public partial class YamlHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "yaml";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"YAML",
|
||||
"YAML 파서·검증 — 키 조회 · 구조 분석 · 평탄화",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
string? clipboard = null;
|
||||
try
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
if (Clipboard.ContainsText())
|
||||
clipboard = Clipboard.GetText();
|
||||
});
|
||||
}
|
||||
catch { /* 클립보드 접근 실패 */ }
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
items.Add(new LauncherItem("YAML 파서·분석기",
|
||||
"클립보드 YAML 분석 · yaml validate / keys / get key / stats / flat",
|
||||
null, null, Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("yaml validate", "YAML 유효성 검사", null, null, Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("yaml keys", "최상위 키 목록", null, null, Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("yaml get key", "특정 키 값 조회", null, null, Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("yaml stats", "줄·키·깊이 통계", null, null, Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("yaml flat", "점 표기법 평탄화", null, null, Symbol: "\uE8A5"));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(clipboard))
|
||||
{
|
||||
items.Add(new LauncherItem("클립보드가 비어 있습니다",
|
||||
"YAML 텍스트를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var stat = QuickStat(clipboard);
|
||||
items.Add(new LauncherItem("── 클립보드 미리보기 ──", stat, null, ("copy", stat), Symbol: "\uE8A5"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(clipboard))
|
||||
{
|
||||
items.Add(new LauncherItem("클립보드가 비어 있습니다",
|
||||
"YAML 텍스트를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
// 파싱
|
||||
var (yamlObj, parseError) = ParseYaml(clipboard);
|
||||
|
||||
switch (sub)
|
||||
{
|
||||
case "validate":
|
||||
case "check":
|
||||
case "lint":
|
||||
{
|
||||
if (parseError != null)
|
||||
{
|
||||
items.Add(new LauncherItem("❌ YAML 오류", parseError, null, null, Symbol: "\uE783"));
|
||||
}
|
||||
else
|
||||
{
|
||||
var linesCount = clipboard.Split('\n').Length;
|
||||
var keyCount = CountKeys(yamlObj);
|
||||
items.Add(new LauncherItem("✓ 유효한 YAML",
|
||||
$"{linesCount}줄 · 키 {keyCount}개", null, ("copy", "Valid YAML"), Symbol: "\uE8A5"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "keys":
|
||||
case "key":
|
||||
{
|
||||
if (parseError != null)
|
||||
{
|
||||
items.Add(new LauncherItem("YAML 파싱 오류", parseError, null, null, Symbol: "\uE783"));
|
||||
break;
|
||||
}
|
||||
if (yamlObj is Dictionary<string, object?> dict)
|
||||
{
|
||||
items.Add(new LauncherItem($"최상위 키 {dict.Count}개", "", null, null, Symbol: "\uE8A5"));
|
||||
foreach (var (k, v) in dict)
|
||||
{
|
||||
var valStr = FormatValue(v);
|
||||
items.Add(new LauncherItem(k, valStr.Length > 60 ? valStr[..60] + "…" : valStr,
|
||||
null, ("copy", k), Symbol: "\uE8A5"));
|
||||
}
|
||||
}
|
||||
else items.Add(new LauncherItem("최상위가 매핑이 아닙니다", "배열 또는 스칼라 값", null, null, Symbol: "\uE946"));
|
||||
break;
|
||||
}
|
||||
|
||||
case "get":
|
||||
{
|
||||
var keyPath = parts.Length > 1 ? parts[1].Trim() : "";
|
||||
if (string.IsNullOrWhiteSpace(keyPath))
|
||||
{
|
||||
items.Add(new LauncherItem("키 경로 입력", "예: yaml get server.port", null, null, Symbol: "\uE783"));
|
||||
break;
|
||||
}
|
||||
if (parseError != null)
|
||||
{
|
||||
items.Add(new LauncherItem("YAML 파싱 오류", parseError, null, null, Symbol: "\uE783"));
|
||||
break;
|
||||
}
|
||||
var found = GetByPath(yamlObj, keyPath.Split('.'));
|
||||
if (found == null)
|
||||
items.Add(new LauncherItem($"'{keyPath}' 없음", "경로가 존재하지 않습니다", null, null, Symbol: "\uE946"));
|
||||
else
|
||||
{
|
||||
var valStr = FormatValue(found);
|
||||
items.Add(new LauncherItem(keyPath, valStr, null, ("copy", valStr), Symbol: "\uE8A5"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "stats":
|
||||
case "stat":
|
||||
{
|
||||
var lines = clipboard.Split('\n');
|
||||
var blank = lines.Count(l => string.IsNullOrWhiteSpace(l));
|
||||
var comments = lines.Count(l => l.TrimStart().StartsWith('#'));
|
||||
var keyLines = lines.Count(l => KeyLineRegex().IsMatch(l));
|
||||
var maxDepth = GetMaxDepth(clipboard);
|
||||
var keyCount = parseError == null ? CountKeys(yamlObj) : -1;
|
||||
|
||||
items.Add(new LauncherItem($"YAML 통계", $"{lines.Length}줄 · 키 {keyCount}개 · 깊이 {maxDepth}",
|
||||
null, null, Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("전체 줄", $"{lines.Length}줄", null, ("copy", $"{lines.Length}"), Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("빈 줄", $"{blank}줄", null, ("copy", $"{blank}"), Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("주석 줄", $"{comments}줄", null, ("copy", $"{comments}"), Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("키 줄", $"{keyLines}줄", null, ("copy", $"{keyLines}"), Symbol: "\uE8A5"));
|
||||
items.Add(new LauncherItem("최대 들여쓰기 깊이", $"{maxDepth}단계", null, ("copy", $"{maxDepth}"), Symbol: "\uE8A5"));
|
||||
if (parseError == null && keyCount >= 0)
|
||||
items.Add(new LauncherItem("전체 키 수 (재귀)", $"{keyCount}개", null, ("copy", $"{keyCount}"), Symbol: "\uE8A5"));
|
||||
if (parseError != null)
|
||||
items.Add(new LauncherItem("파싱 오류", parseError, null, null, Symbol: "\uE783"));
|
||||
break;
|
||||
}
|
||||
|
||||
case "flat":
|
||||
case "flatten":
|
||||
{
|
||||
if (parseError != null)
|
||||
{
|
||||
items.Add(new LauncherItem("YAML 파싱 오류", parseError, null, null, Symbol: "\uE783"));
|
||||
break;
|
||||
}
|
||||
var flat = new List<(string Key, string Value)>();
|
||||
Flatten(yamlObj, "", flat);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
foreach (var (k, v) in flat) sb.AppendLine($"{k}: {v}");
|
||||
var result = sb.ToString().TrimEnd();
|
||||
|
||||
items.Add(new LauncherItem($"평탄화 ({flat.Count}개 키)",
|
||||
"Enter → 전체 복사", null, ("copy", result), Symbol: "\uE8A5"));
|
||||
foreach (var (k, v) in flat.Take(25))
|
||||
{
|
||||
var disp = v.Length > 50 ? v[..50] + "…" : v;
|
||||
items.Add(new LauncherItem(k, disp, null, ("copy", v), Symbol: "\uE8A5"));
|
||||
}
|
||||
if (flat.Count > 25)
|
||||
items.Add(new LauncherItem($"… 외 {flat.Count - 25}개", "전체 복사는 첫 항목 Enter", null, null, Symbol: "\uE8A5"));
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
{
|
||||
items.Add(new LauncherItem("알 수 없는 서브커맨드",
|
||||
"validate · keys · get key · stats · flat", null, null, Symbol: "\uE783"));
|
||||
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("YAML", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── YAML 파서 (경량, 기본 스펙만) ────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 경량 YAML 파서. 지원: 스칼라, 매핑(들여쓰기 기반), 시퀀스(- 표기).
|
||||
/// 멀티라인/앵커/태그/복잡 흐름 스타일 미지원.
|
||||
/// </summary>
|
||||
private static (object? Value, string? Error) ParseYaml(string yaml)
|
||||
{
|
||||
var error = "";
|
||||
var lines = yaml.Split('\n').Select(l => l.TrimEnd('\r')).ToList();
|
||||
|
||||
// 기본 유효성: 들여쓰기 일관성 확인
|
||||
int? indentUnit = null;
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#')) continue;
|
||||
var indent = line.Length - line.TrimStart().Length;
|
||||
if (indent > 0)
|
||||
{
|
||||
indentUnit ??= indent;
|
||||
// 허용 범위 내 들여쓰기인지 간단 확인
|
||||
}
|
||||
}
|
||||
|
||||
// 간단 구조 파싱
|
||||
var result = ParseBlock(lines, 0, ref error, out _);
|
||||
return (result, string.IsNullOrEmpty(error) ? null : error);
|
||||
}
|
||||
|
||||
private static object? ParseBlock(List<string> lines, int baseIndent, ref string error, out int consumed)
|
||||
{
|
||||
consumed = 0;
|
||||
var dict = new Dictionary<string, object?>();
|
||||
var list = new List<object?>();
|
||||
var isSeq = false;
|
||||
var isMap = false;
|
||||
|
||||
var i = 0;
|
||||
while (i < lines.Count)
|
||||
{
|
||||
var line = lines[i];
|
||||
if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#')) { i++; continue; }
|
||||
|
||||
var indent = line.Length - line.TrimStart().Length;
|
||||
if (indent < baseIndent) break;
|
||||
|
||||
var trimmed = line.TrimStart();
|
||||
|
||||
// 시퀀스 항목
|
||||
if (trimmed.StartsWith("- "))
|
||||
{
|
||||
isSeq = true;
|
||||
var valStr = trimmed[2..].Trim();
|
||||
if (string.IsNullOrWhiteSpace(valStr))
|
||||
{
|
||||
// 다음 줄이 하위 블록
|
||||
var sub = lines.Skip(i + 1).TakeWhile(l =>
|
||||
string.IsNullOrWhiteSpace(l) || l.Length - l.TrimStart().Length > indent).ToList();
|
||||
var innerErr = "";
|
||||
var child = ParseBlock(sub, indent + 2, ref innerErr, out var childConsumed);
|
||||
list.Add(child);
|
||||
i += 1 + childConsumed;
|
||||
}
|
||||
else list.Add(ParseScalar(valStr));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 매핑 항목 (key: value)
|
||||
var colonIdx = trimmed.IndexOf(':');
|
||||
if (colonIdx > 0)
|
||||
{
|
||||
isMap = true;
|
||||
var key = trimmed[..colonIdx].Trim().Trim('"', '\'');
|
||||
var rest = trimmed[(colonIdx + 1)..].Trim();
|
||||
|
||||
if (string.IsNullOrEmpty(rest) || rest.StartsWith('#'))
|
||||
{
|
||||
// 다음 줄이 하위 블록
|
||||
var sub = lines.Skip(i + 1).TakeWhile(l =>
|
||||
string.IsNullOrWhiteSpace(l) || l.Length - l.TrimStart().Length > indent).ToList();
|
||||
var innerErr = "";
|
||||
var child = ParseBlock(sub, indent + 2, ref innerErr, out var childConsumed);
|
||||
dict[key] = child;
|
||||
i += 1 + childConsumed;
|
||||
}
|
||||
else
|
||||
{
|
||||
dict[key] = ParseScalar(rest.Split('#')[0].Trim());
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
consumed = i;
|
||||
if (isSeq) return list;
|
||||
if (isMap) return dict;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static object? ParseScalar(string s)
|
||||
{
|
||||
if (s is "true" or "True" or "TRUE" or "yes" or "Yes") return true;
|
||||
if (s is "false" or "False" or "FALSE" or "no" or "No") return false;
|
||||
if (s is "null" or "~" or "Null" or "NULL") return null;
|
||||
if (long.TryParse(s, out var l)) return l;
|
||||
if (double.TryParse(s, System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var d)) return d;
|
||||
return s.Trim('"', '\'');
|
||||
}
|
||||
|
||||
// ── 헬퍼 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static string QuickStat(string yaml)
|
||||
{
|
||||
var lines = yaml.Split('\n').Length;
|
||||
var keyLines = yaml.Split('\n').Count(l => KeyLineRegex().IsMatch(l));
|
||||
var depth = GetMaxDepth(yaml);
|
||||
return $"{lines}줄 · 키 {keyLines}개 · 최대 깊이 {depth}단계";
|
||||
}
|
||||
|
||||
private static int GetMaxDepth(string yaml)
|
||||
{
|
||||
var maxIndent = yaml.Split('\n')
|
||||
.Where(l => !string.IsNullOrWhiteSpace(l) && !l.TrimStart().StartsWith('#'))
|
||||
.Select(l => l.Length - l.TrimStart().Length)
|
||||
.DefaultIfEmpty(0).Max();
|
||||
return maxIndent / 2 + 1;
|
||||
}
|
||||
|
||||
private static int CountKeys(object? node) => node switch
|
||||
{
|
||||
Dictionary<string, object?> d => d.Count + d.Values.Sum(v => CountKeys(v)),
|
||||
List<object?> l => l.Sum(v => CountKeys(v)),
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
private static object? GetByPath(object? node, string[] parts)
|
||||
{
|
||||
if (parts.Length == 0) return node;
|
||||
if (node is Dictionary<string, object?> dict)
|
||||
{
|
||||
var key = parts[0];
|
||||
if (!dict.TryGetValue(key, out var child)) return null;
|
||||
return GetByPath(child, parts[1..]);
|
||||
}
|
||||
if (node is List<object?> list && int.TryParse(parts[0], out var idx) && idx < list.Count)
|
||||
return GetByPath(list[idx], parts[1..]);
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void Flatten(object? node, string prefix, List<(string, string)> result)
|
||||
{
|
||||
switch (node)
|
||||
{
|
||||
case Dictionary<string, object?> dict:
|
||||
foreach (var (k, v) in dict)
|
||||
Flatten(v, string.IsNullOrEmpty(prefix) ? k : $"{prefix}.{k}", result);
|
||||
break;
|
||||
case List<object?> list:
|
||||
for (var i = 0; i < list.Count; i++)
|
||||
Flatten(list[i], $"{prefix}[{i}]", result);
|
||||
break;
|
||||
default:
|
||||
result.Add((prefix, FormatValue(node)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatValue(object? v) => v switch
|
||||
{
|
||||
null => "(null)",
|
||||
bool b => b ? "true" : "false",
|
||||
Dictionary<string, object?> d => $"{{...{d.Count}개 키}}",
|
||||
List<object?> l => $"[...{l.Count}개 항목]",
|
||||
_ => v.ToString() ?? "",
|
||||
};
|
||||
|
||||
[GeneratedRegex(@"^\s*[\w\-""']+\s*:")]
|
||||
private static partial Regex KeyLineRegex();
|
||||
}
|
||||
303
src/AxCopilot/Handlers/ZipHandler.cs
Normal file
303
src/AxCopilot/Handlers/ZipHandler.cs
Normal file
@@ -0,0 +1,303 @@
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L8-2: 아카이브 관리 핸들러. "zip" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: zip → 사용법 안내
|
||||
/// zip C:\archive.zip → zip 내 파일 목록 미리보기
|
||||
/// zip list C:\archive.zip → 파일 목록 (클립보드 복사)
|
||||
/// zip extract C:\archive.zip → 같은 폴더에 압축 해제
|
||||
/// zip extract C:\a.zip C:\target → 지정 폴더에 압축 해제
|
||||
/// zip folder C:\MyFolder → 폴더를 zip으로 압축
|
||||
/// 경로 미입력 시 클립보드 경로 자동 감지.
|
||||
/// </summary>
|
||||
public class ZipHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "zip";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Zip",
|
||||
"아카이브 관리 — zip 목록 · 압축 해제 · 폴더 압축",
|
||||
"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 clipPath = GetClipboardPath();
|
||||
if (!string.IsNullOrEmpty(clipPath))
|
||||
{
|
||||
var isZip = clipPath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase);
|
||||
var isFolder = Directory.Exists(clipPath);
|
||||
|
||||
if (isZip && File.Exists(clipPath))
|
||||
{
|
||||
var info = GetZipInfo(clipPath);
|
||||
items.Add(new LauncherItem(
|
||||
Path.GetFileName(clipPath),
|
||||
$"{info.Count}개 파일 · {info.TotalSizeMb:F1} MB (압축 전)",
|
||||
null, null, Symbol: "\uE8B7"));
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
"zip list",
|
||||
"파일 목록 표시 및 클립보드 복사",
|
||||
null, ("list", clipPath), Symbol: "\uE8A4"));
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
"zip extract",
|
||||
$"압축 해제 → {Path.GetDirectoryName(clipPath)}",
|
||||
null, ("extract", clipPath, ""), Symbol: "\uE8B7"));
|
||||
}
|
||||
else if (isFolder)
|
||||
{
|
||||
var outputPath = clipPath.TrimEnd('\\', '/') + ".zip";
|
||||
items.Add(new LauncherItem(
|
||||
"zip folder",
|
||||
$"{Path.GetFileName(clipPath)} → {Path.GetFileName(outputPath)}",
|
||||
null, ("compress", clipPath, outputPath), Symbol: "\uE8B7"));
|
||||
}
|
||||
}
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
"zip <경로>",
|
||||
"zip 파일 목록 미리보기",
|
||||
null, null, Symbol: "\uE8B7"));
|
||||
items.Add(new LauncherItem(
|
||||
"zip extract <경로>",
|
||||
"압축 해제",
|
||||
null, null, Symbol: "\uE8B7"));
|
||||
items.Add(new LauncherItem(
|
||||
"zip folder <폴더>",
|
||||
"폴더를 zip으로 압축",
|
||||
null, null, Symbol: "\uE8B7"));
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 서브커맨드 파싱
|
||||
var parts = q.Split(' ', 3);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
// "extract" 서브커맨드
|
||||
if (sub == "extract" || sub == "unzip")
|
||||
{
|
||||
var zipPath = parts.Length >= 2 ? parts[1].Trim('"') : "";
|
||||
var targetDir = parts.Length >= 3 ? parts[2].Trim('"') : "";
|
||||
|
||||
if (string.IsNullOrEmpty(zipPath))
|
||||
{
|
||||
var clip = GetClipboardPath();
|
||||
if (!string.IsNullOrEmpty(clip) && File.Exists(clip)) zipPath = clip;
|
||||
}
|
||||
|
||||
if (!File.Exists(zipPath))
|
||||
{
|
||||
items.Add(new LauncherItem("파일을 찾을 수 없음", zipPath, null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var dest = string.IsNullOrEmpty(targetDir)
|
||||
? Path.Combine(Path.GetDirectoryName(zipPath)!,
|
||||
Path.GetFileNameWithoutExtension(zipPath))
|
||||
: targetDir;
|
||||
|
||||
var info = GetZipInfo(zipPath);
|
||||
items.Add(new LauncherItem(
|
||||
$"압축 해제 — {info.Count}개 파일",
|
||||
$"→ {dest}",
|
||||
null,
|
||||
("extract", zipPath, dest),
|
||||
Symbol: "\uE8B7"));
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// "folder" 또는 "compress" 서브커맨드
|
||||
if (sub == "folder" || sub == "compress")
|
||||
{
|
||||
var srcFolder = parts.Length >= 2 ? parts[1].Trim('"') : "";
|
||||
if (!Directory.Exists(srcFolder))
|
||||
{
|
||||
var clip = GetClipboardPath();
|
||||
if (!string.IsNullOrEmpty(clip) && Directory.Exists(clip)) srcFolder = clip;
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem("폴더를 찾을 수 없음", srcFolder, null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
}
|
||||
|
||||
var outputZip = parts.Length >= 3
|
||||
? parts[2].Trim('"')
|
||||
: srcFolder.TrimEnd('\\', '/') + ".zip";
|
||||
|
||||
var fileCount = Directory.GetFiles(srcFolder, "*", SearchOption.AllDirectories).Length;
|
||||
items.Add(new LauncherItem(
|
||||
$"압축 — {fileCount}개 파일",
|
||||
$"{Path.GetFileName(srcFolder)} → {Path.GetFileName(outputZip)}",
|
||||
null,
|
||||
("compress", srcFolder, outputZip),
|
||||
Symbol: "\uE8B7"));
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// "list" 서브커맨드 또는 직접 경로 입력
|
||||
var zipFilePath = (sub == "list" && parts.Length >= 2)
|
||||
? parts[1].Trim('"')
|
||||
: q.Trim('"');
|
||||
|
||||
if (!File.Exists(zipFilePath))
|
||||
{
|
||||
var clip = GetClipboardPath();
|
||||
zipFilePath = (!string.IsNullOrEmpty(clip) && File.Exists(clip)) ? clip : zipFilePath;
|
||||
}
|
||||
|
||||
if (!File.Exists(zipFilePath))
|
||||
{
|
||||
items.Add(new LauncherItem("파일을 찾을 수 없음", zipFilePath, null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 목록 미리보기
|
||||
try
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(zipFilePath);
|
||||
var entries = archive.Entries.OrderBy(e => e.FullName).ToList();
|
||||
var totalSize = entries.Sum(e => e.Length);
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
Path.GetFileName(zipFilePath),
|
||||
$"{entries.Count}개 항목 · {totalSize / 1024.0 / 1024.0:F1} MB",
|
||||
null,
|
||||
("list", zipFilePath),
|
||||
Symbol: "\uE8B7"));
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
"압축 해제 →",
|
||||
Path.Combine(Path.GetDirectoryName(zipFilePath)!,
|
||||
Path.GetFileNameWithoutExtension(zipFilePath)),
|
||||
null,
|
||||
("extract", zipFilePath, ""),
|
||||
Symbol: "\uE8B7"));
|
||||
|
||||
foreach (var entry in entries.Take(20))
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
entry.FullName,
|
||||
$"{entry.Length / 1024.0:F0} KB",
|
||||
null,
|
||||
("copy_entry", entry.FullName),
|
||||
Symbol: entry.FullName.EndsWith('/') ? "\uED25" : "\uE8A5"));
|
||||
}
|
||||
|
||||
if (entries.Count > 20)
|
||||
items.Add(new LauncherItem(
|
||||
$"… +{entries.Count - 20}개 더",
|
||||
"전체 목록: 첫 항목 Enter → 클립보드 복사",
|
||||
null, null, Symbol: "\uE712"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
items.Add(new LauncherItem("zip 읽기 실패", ex.Message, null, null, Symbol: "\uE783"));
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
switch (item.Data)
|
||||
{
|
||||
case ("list", string zipPath):
|
||||
try
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(zipPath);
|
||||
var list = string.Join("\n", archive.Entries.Select(e => e.FullName));
|
||||
TryCopyToClipboard(list);
|
||||
NotificationService.Notify("Zip", $"{archive.Entries.Count}개 항목을 클립보드에 복사했습니다.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
NotificationService.Notify("Zip 오류", ex.Message);
|
||||
}
|
||||
break;
|
||||
|
||||
case ("extract", string zipPath, string targetDir):
|
||||
await Task.Run(() =>
|
||||
{
|
||||
var dest = string.IsNullOrEmpty(targetDir)
|
||||
? Path.Combine(Path.GetDirectoryName(zipPath)!,
|
||||
Path.GetFileNameWithoutExtension(zipPath))
|
||||
: targetDir;
|
||||
|
||||
ZipFile.ExtractToDirectory(zipPath, dest, overwriteFiles: true);
|
||||
NotificationService.Notify("압축 해제 완료", dest);
|
||||
}, ct);
|
||||
break;
|
||||
|
||||
case ("compress", string srcFolder, string outputZip):
|
||||
await Task.Run(() =>
|
||||
{
|
||||
if (File.Exists(outputZip)) File.Delete(outputZip);
|
||||
ZipFile.CreateFromDirectory(srcFolder, outputZip);
|
||||
var sizeMb = new FileInfo(outputZip).Length / 1024.0 / 1024.0;
|
||||
NotificationService.Notify(
|
||||
"압축 완료",
|
||||
$"{Path.GetFileName(outputZip)} ({sizeMb:F1} MB)");
|
||||
}, ct);
|
||||
break;
|
||||
|
||||
case ("copy_entry", string entryName):
|
||||
TryCopyToClipboard(entryName);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 헬퍼 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static (int Count, double TotalSizeMb) GetZipInfo(string zipPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(zipPath);
|
||||
return (archive.Entries.Count, archive.Entries.Sum(e => e.Length) / 1024.0 / 1024.0);
|
||||
}
|
||||
catch { return (0, 0); }
|
||||
}
|
||||
|
||||
private static string? GetClipboardPath()
|
||||
{
|
||||
try
|
||||
{
|
||||
string? text = null;
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
if (Clipboard.ContainsText())
|
||||
text = Clipboard.GetText()?.Trim().Trim('"');
|
||||
});
|
||||
return text;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static void TryCopyToClipboard(string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ public class AppSettings
|
||||
[JsonPropertyName("operationMode")]
|
||||
public string OperationMode { get; set; } = "internal";
|
||||
|
||||
[JsonIgnore]
|
||||
public bool InternalModeEnabled => string.Equals(OperationMode, "internal", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
[JsonPropertyName("hotkey")]
|
||||
public string Hotkey { get; set; } = "Alt+Space";
|
||||
|
||||
@@ -88,6 +91,9 @@ public class AppSettings
|
||||
[JsonPropertyName("snippets")]
|
||||
public List<SnippetEntry> Snippets { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("quickLinks")]
|
||||
public List<QuickLinkEntry> QuickLinks { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("clipboardHistory")]
|
||||
public ClipboardHistorySettings ClipboardHistory { get; set; } = new();
|
||||
|
||||
@@ -100,6 +106,21 @@ public class AppSettings
|
||||
[JsonPropertyName("reminder")]
|
||||
public ReminderSettings Reminder { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("customHotkeys")]
|
||||
public List<HotkeyAssignment> CustomHotkeys { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("appSessions")]
|
||||
public List<AppSession> AppSessions { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("schedules")]
|
||||
public List<ScheduleEntry> Schedules { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("macros")]
|
||||
public List<MacroEntry> Macros { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("ssh_hosts")]
|
||||
public List<SshHostEntry> SshHosts { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("llm")]
|
||||
public LlmSettings Llm { get; set; } = new();
|
||||
}
|
||||
@@ -245,6 +266,18 @@ public class LauncherSettings
|
||||
/// <summary>모니터별 독 바 위치. key=디바이스명, value=[left, top]</summary>
|
||||
[JsonPropertyName("monitorDockPositions")]
|
||||
public Dictionary<string, List<double>> MonitorDockPositions { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>런처 마지막 위치 기억 여부. 기본 false.</summary>
|
||||
[JsonPropertyName("rememberPosition")]
|
||||
public bool RememberPosition { get; set; } = false;
|
||||
|
||||
/// <summary>런처 마지막 Left 좌표. -1이면 기본 위치.</summary>
|
||||
[JsonPropertyName("lastLeft")]
|
||||
public double LastLeft { get; set; } = -1;
|
||||
|
||||
/// <summary>런처 마지막 Top 좌표. -1이면 기본 위치.</summary>
|
||||
[JsonPropertyName("lastTop")]
|
||||
public double LastTop { get; set; } = -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -432,6 +465,168 @@ public class SnippetEntry
|
||||
public string Content { get; set; } = ""; // 확장될 전체 텍스트
|
||||
}
|
||||
|
||||
public class QuickLinkEntry
|
||||
{
|
||||
[JsonPropertyName("keyword")]
|
||||
public string Keyword { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("urlTemplate")]
|
||||
public string UrlTemplate { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; set; } = "";
|
||||
}
|
||||
|
||||
public class HotkeyAssignment
|
||||
{
|
||||
[JsonPropertyName("hotkey")]
|
||||
public string Hotkey { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("target")]
|
||||
public string Target { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("label")]
|
||||
public string Label { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } = "app";
|
||||
}
|
||||
|
||||
public class AppSession
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("apps")]
|
||||
public List<SessionApp> Apps { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
||||
}
|
||||
|
||||
public class SessionApp
|
||||
{
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("args")]
|
||||
public string Arguments { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("label")]
|
||||
public string Label { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("snap")]
|
||||
public string SnapPosition { get; set; } = "full";
|
||||
|
||||
[JsonPropertyName("delayMs")]
|
||||
public int DelayMs { get; set; } = 0;
|
||||
}
|
||||
|
||||
public class ScheduleEntry
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("enabled")]
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("triggerType")]
|
||||
public string TriggerType { get; set; } = "daily";
|
||||
|
||||
[JsonPropertyName("triggerTime")]
|
||||
public string TriggerTime { get; set; } = "09:00";
|
||||
|
||||
[JsonPropertyName("weekDays")]
|
||||
public List<int> WeekDays { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("triggerDate")]
|
||||
public string? TriggerDate { get; set; }
|
||||
|
||||
[JsonPropertyName("actionType")]
|
||||
public string ActionType { get; set; } = "app";
|
||||
|
||||
[JsonPropertyName("actionTarget")]
|
||||
public string ActionTarget { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("actionArgs")]
|
||||
public string ActionArgs { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("lastRun")]
|
||||
public DateTime? LastRun { get; set; }
|
||||
|
||||
[JsonPropertyName("conditionProcess")]
|
||||
public string ConditionProcess { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("conditionProcessMustRun")]
|
||||
public bool ConditionProcessMustRun { get; set; } = true;
|
||||
}
|
||||
|
||||
public class MacroEntry
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("steps")]
|
||||
public List<MacroStep> Steps { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
||||
}
|
||||
|
||||
public class MacroStep
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } = "app";
|
||||
|
||||
[JsonPropertyName("target")]
|
||||
public string Target { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("args")]
|
||||
public string Args { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("label")]
|
||||
public string Label { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("delayMs")]
|
||||
public int DelayMs { get; set; } = 500;
|
||||
}
|
||||
|
||||
public class SshHostEntry
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("host")]
|
||||
public string Host { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("port")]
|
||||
public int Port { get; set; } = 22;
|
||||
|
||||
[JsonPropertyName("user")]
|
||||
public string User { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("note")]
|
||||
public string Note { get; set; } = "";
|
||||
}
|
||||
|
||||
// ─── 클립보드 히스토리 ────────────────────────────────────────────────────────
|
||||
|
||||
public class ClipboardHistorySettings
|
||||
|
||||
155
src/AxCopilot/Services/FileTagService.cs
Normal file
155
src/AxCopilot/Services/FileTagService.cs
Normal file
@@ -0,0 +1,155 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Phase L3-5: 파일 태그 시스템 서비스.
|
||||
/// 파일·폴더에 사용자 정의 태그를 부여하고 태그 기반 검색을 지원합니다.
|
||||
/// 데이터는 %APPDATA%\AxCopilot\file_tags.json에 저장됩니다.
|
||||
/// </summary>
|
||||
public class FileTagService
|
||||
{
|
||||
private static readonly string TagFile = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "file_tags.json");
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
/// <summary>key = 정규화된 파일 경로, value = 태그 집합</summary>
|
||||
private Dictionary<string, HashSet<string>> _data =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private bool _loaded;
|
||||
|
||||
// ─── 싱글턴 ─────────────────────────────────────────────────────────────
|
||||
private static FileTagService? _instance;
|
||||
public static FileTagService Instance => _instance ??= new FileTagService();
|
||||
private FileTagService() { }
|
||||
|
||||
// ─── 공개 API ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>파일에 태그를 추가합니다.</summary>
|
||||
public void AddTag(string path, string tag)
|
||||
{
|
||||
EnsureLoaded();
|
||||
path = NormalizePath(path);
|
||||
tag = NormalizeTag(tag);
|
||||
if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(tag)) return;
|
||||
|
||||
if (!_data.TryGetValue(path, out var tags))
|
||||
{
|
||||
tags = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
_data[path] = tags;
|
||||
}
|
||||
tags.Add(tag);
|
||||
Save();
|
||||
}
|
||||
|
||||
/// <summary>파일에서 태그를 제거합니다.</summary>
|
||||
public void RemoveTag(string path, string tag)
|
||||
{
|
||||
EnsureLoaded();
|
||||
path = NormalizePath(path);
|
||||
tag = NormalizeTag(tag);
|
||||
if (!_data.TryGetValue(path, out var tags)) return;
|
||||
tags.Remove(tag);
|
||||
if (tags.Count == 0) _data.Remove(path);
|
||||
Save();
|
||||
}
|
||||
|
||||
/// <summary>파일의 모든 태그를 제거합니다.</summary>
|
||||
public void ClearTags(string path)
|
||||
{
|
||||
EnsureLoaded();
|
||||
path = NormalizePath(path);
|
||||
_data.Remove(path);
|
||||
Save();
|
||||
}
|
||||
|
||||
/// <summary>파일의 태그 목록을 반환합니다.</summary>
|
||||
public IReadOnlyList<string> GetTags(string path)
|
||||
{
|
||||
EnsureLoaded();
|
||||
path = NormalizePath(path);
|
||||
return _data.TryGetValue(path, out var tags)
|
||||
? tags.OrderBy(t => t).ToList()
|
||||
: Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>특정 태그가 부여된 파일 경로 목록을 반환합니다.</summary>
|
||||
public IReadOnlyList<string> GetFilesByTag(string tag)
|
||||
{
|
||||
EnsureLoaded();
|
||||
tag = NormalizeTag(tag);
|
||||
return _data
|
||||
.Where(kv => kv.Value.Contains(tag))
|
||||
.Select(kv => kv.Key)
|
||||
.OrderBy(p => p)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>등록된 모든 태그와 각 파일 수를 반환합니다.</summary>
|
||||
public IReadOnlyDictionary<string, int> GetAllTags()
|
||||
{
|
||||
EnsureLoaded();
|
||||
return _data
|
||||
.SelectMany(kv => kv.Value)
|
||||
.GroupBy(t => t, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
}
|
||||
|
||||
/// <summary>경로에 태그가 하나 이상 있는지 확인합니다.</summary>
|
||||
public bool HasTags(string path)
|
||||
{
|
||||
EnsureLoaded();
|
||||
path = NormalizePath(path);
|
||||
return _data.TryGetValue(path, out var tags) && tags.Count > 0;
|
||||
}
|
||||
|
||||
// ─── 내부 헬퍼 ──────────────────────────────────────────────────────────
|
||||
|
||||
private void EnsureLoaded()
|
||||
{
|
||||
if (_loaded) return;
|
||||
_loaded = true;
|
||||
try
|
||||
{
|
||||
if (!File.Exists(TagFile)) return;
|
||||
var raw = JsonSerializer.Deserialize<Dictionary<string, List<string>>>(
|
||||
File.ReadAllText(TagFile), JsonOpts);
|
||||
if (raw != null)
|
||||
{
|
||||
_data = raw.ToDictionary(
|
||||
kv => kv.Key,
|
||||
kv => new HashSet<string>(kv.Value, StringComparer.OrdinalIgnoreCase),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { LogService.Warn($"[FileTagService] 로드 실패: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private void Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(TagFile)!);
|
||||
var raw = _data.ToDictionary(
|
||||
kv => kv.Key,
|
||||
kv => kv.Value.OrderBy(t => t).ToList());
|
||||
File.WriteAllText(TagFile, JsonSerializer.Serialize(raw, JsonOpts));
|
||||
}
|
||||
catch (Exception ex) { LogService.Warn($"[FileTagService] 저장 실패: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static string NormalizePath(string path)
|
||||
=> path.Trim().TrimEnd('\\', '/');
|
||||
|
||||
private static string NormalizeTag(string tag)
|
||||
=> tag.Trim().ToLowerInvariant().Replace(" ", "-");
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user