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:
2026-04-05 00:59:45 +09:00
parent 0929778ca7
commit 0336904258
115 changed files with 30749 additions and 1 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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();

View File

@@ -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" />

View 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", "RivestShamirAdleman", "공개키 비대칭 암호화 알고리즘", "보안"),
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");
}

View 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 => "일요일",
_ => "",
};
}

View 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입니다."; }
}
}

View 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)";
}
}

View 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";
}
}

View 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;
}
}

View 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단계", "🔴"),
};
}

View 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;
}
}

View 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;
}
}

View 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);
}

View 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; } = "";
}
}

View 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",
};
}

View 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 { }
}
}

View 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());
}
}

View 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}일 후";
}
}

View 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 ""; }
}
}

View 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}";
}
}

View 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;
}
}

View 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;
}
}

View 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 { /* 비핵심 */ }
}
}

View 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",
};
}

View 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}
""";
}

View 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);

View 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];
}

View 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;
}
}

View 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 { }
}
}

View 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");
}

File diff suppressed because it is too large Load Diff

View 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}줄)";
}
}

View 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;
}
}

View 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");
}

View 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);
}
}

View 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}");
}
}
}

View 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;
}

View 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");
}

View 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 ""; }
}
}

View 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"));
}
}

View 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 { }
}
}

View 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();
}

View 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;
}
}

View 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}단계 실행됨");
}
}

View 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에 이미지(![alt](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 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();
}

View 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 { }
}
}

View 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 ""; }
}
}

View 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 { /* 비핵심 */ }
}
}

View 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,
}
};
}

View 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 { }
}
}

View 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();
}
}

View 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;
}
}

View 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));
}
}

View 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);
}

View 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);
}
}

View 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");
}

View 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;
}
}

View 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;
}
}

View 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");
}
}

View 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}");
}
}
}

View 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;
}
}

View 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");
}

View 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] + "…";
}

View 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);
}
}

View 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;
}
}

View 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");
}

View 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() ?? "",
};
}

View 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 { /* 비핵심 */ }
}
}

View 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}시간 후";
}
}

View 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;
}
}

View 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);
}

View 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");
}

View 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();
}

View 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,
};
}
}

View 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");
}
}

View 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("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;")
.Replace("\"", "&quot;").Replace("'", "&#39;");
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();
}

View 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')}";
}

View 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("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;").Replace("\"", "&quot;");
}

View 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)
]);
}

View 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();
}

View 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;
}
}

View 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"));
}
}
}

View 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}원";
}
}

View 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;
}
}

View 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);
}
}

View 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");
}

View 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]);
}
}

View 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);
}
}

View 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; }
}
}

View 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);
}
}

View 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 { /* 비핵심 */ }
}
}

View 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;
}
}

View 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;
}
}

View 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");
}

View 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 ""; }
}
}

View 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();
}

View 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 { /* 비핵심 */ }
}
}

View File

@@ -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

View 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