diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md index b4b12cd..9c5a385 100644 --- a/docs/LAUNCHER_ROADMAP.md +++ b/docs/LAUNCHER_ROADMAP.md @@ -360,3 +360,16 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label | L17-2 | **숫자 포맷·읽기** ✅ | `num` 프리픽스. `num 1234567` → 천단위·한글 단위(만·억·조)·한국어 읽기·영어 읽기·16진수·8진수·2진수·과학표기·로마 숫자 일괄 표시. `0x/0b/0o` 접두사 진수 입력. `num 42 ko` 한국어 읽기만. `num 42 en` 영어 읽기만. 1~3999 로마 숫자 변환. ToKorean(): 조·억·만 단위 재귀 분해 | 높음 | | L17-3 | **YAML 파서·분석기** ✅ | `yaml` 프리픽스. 클립보드 자동 읽기. 외부 라이브러리 없이 순수 구현(경량 파서). `yaml validate` 유효성 검사. `yaml keys` 최상위 키 목록. `yaml get key.sub` 점 표기법 경로 조회. `yaml stats` 줄·키·깊이 통계. `yaml flat` 점 표기법 평탄화(flatten). [GeneratedRegex] 소스 생성기 | 높음 | | L17-4 | **.gitignore 생성기** ✅ | `gitignore` 프리픽스. Node/Python/C#(.NET)/Java/Go/Rust/React(Next.js·Vite·Vue)/Flutter/Android/iOS/Unity/Windows/macOS/Linux 14개 내장 템플릿. 별칭(nodejs·npm·dotnet·net·maven·golang·cargo·nextjs·swift 등) 지원. 여러 템플릿 명 입력 시 자동 병합. 미리보기 12줄 표시. Enter → 클립보드 복사 | 높음 | + +--- + +## Phase L18 — SQL·TextCase·Aspect·Abbr 도구 (v2.1.0) ✅ 완료 + +> **방향**: 개발자 텍스트 처리·분석 도구 강화 — SQL 포맷, 텍스트 케이스, 해상도 계산, 약어 사전. + +| # | 기능 | 설명 | 우선순위 | +|---|------|------|----------| +| L18-1 | **SQL 포맷터·분석기** ✅ | `sql` 프리픽스. 클립보드 SQL 자동 읽기. 키워드 기반 들여쓰기 포맷(새 줄 시작 키워드 집합). `sql mini` 미니파이. `sql upper/lower` 키워드 대소문자 변환. `sql stats` 테이블·JOIN·WHERE 조건·서브쿼리·DML 유형 분석. `sql tables` FROM/JOIN 테이블 추출. `sql select ` SELECT 쿼리 템플릿 생성. [GeneratedRegex] 소스 생성기 | 높음 | +| L18-2 | **텍스트 케이스 변환기** ✅ | `text` 프리픽스. 클립보드 자동 읽기. 13가지 케이스 일괄 표시: camelCase·PascalCase·snake_case·SCREAMING_SNAKE·kebab-case·URL slug·dot.case·UPPER·lower·Title·Sentence·뒤집기·trim. 인라인 입력(`text camel hello world`) 지원. Tokenize(): camelCase 경계 분리 + 구분자 정규화. ToSlug(): 유니코드 정규화(NFC→ASCII). [GeneratedRegex] 소스 생성기 | 높음 | +| L18-3 | **화면 비율·해상도 계산기** ✅ | `aspect` 프리픽스. 9개 비율 프리셋 내장(16:9·4:3·21:9·1:1·9:16·3:2·2:1·5:4·2.35:1). `aspect 1920 1080` → GCD 약분 비율 계산·MP 표시. `aspect 16:9 1280` 너비 기준 높이 계산. `aspect 16:9 h 720` 높이 기준 너비 계산. `aspect crop 1920 1080 4:3` 크롭 영역+FFmpeg crop 파라미터. 소수 비율(2.35:1) 지원 | 높음 | +| L18-4 | **IT·개발 약어 사전** ✅ | `abbr` 프리픽스. 150개+ 내장 약어(웹/네트워크·개발·DB·보안·클라우드·AI·데이터형식·협업 8개 카테고리). 정확 일치 → 약어/원문/설명/카테고리 상세 표시. 부분 일치 → 목록 표시. `abbr 클라우드` 카테고리별 필터. `abbr jwt` → JWT 상세. API/CRUD/REST/JWT/MCP/SOLID/CAP/ACID/OWASP 등 포함 | 높음 | diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index 91856c7..c98f70c 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -307,6 +307,16 @@ public partial class App : System.Windows.Application // L17-4: .gitignore 생성기 (prefix=gitignore) commandResolver.RegisterHandler(new GitignoreHandler()); + // ─── Phase L18 핸들러 ───────────────────────────────────────────────── + // L18-1: SQL 포맷터·분석기 (prefix=sql) + commandResolver.RegisterHandler(new SqlHandler()); + // L18-2: 텍스트 케이스 변환기 (prefix=text) + commandResolver.RegisterHandler(new TextCaseHandler()); + // L18-3: 화면 비율·해상도 계산기 (prefix=aspect) + commandResolver.RegisterHandler(new AspectHandler()); + // L18-4: IT·개발 약어 사전 (prefix=abbr) + commandResolver.RegisterHandler(new AbbrHandler()); + // ─── 플러그인 로드 ──────────────────────────────────────────────────── var pluginHost = new PluginHost(settings, commandResolver); pluginHost.LoadAll(); diff --git a/src/AxCopilot/Handlers/AbbrHandler.cs b/src/AxCopilot/Handlers/AbbrHandler.cs new file mode 100644 index 0000000..0cadc7e --- /dev/null +++ b/src/AxCopilot/Handlers/AbbrHandler.cs @@ -0,0 +1,303 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L18-4: IT·개발 약어 사전 핸들러. "abbr" 프리픽스로 사용합니다. +/// +/// 예: abbr → 주요 약어 목록 +/// abbr api → API 뜻 + 설명 +/// abbr crud → CRUD 약어 풀이 +/// abbr rest → REST 설명 +/// abbr http → HTTP 약어 목록 +/// Enter → 약어 또는 뜻을 클립보드에 복사. +/// +public class AbbrHandler : IActionHandler +{ + public string? Prefix => "abbr"; + + public PluginMetadata Metadata => new( + "Abbr", + "IT·개발 약어 사전 — API · CRUD · REST · HTTP · SQL 등 내장", + "1.0", + "AX"); + + private record AbbrEntry(string Short, string Full, string Description, string Category); + + // ── 내장 약어 사전 (~150개) ─────────────────────────────────────────────── + private static readonly AbbrEntry[] Entries = + [ + // 웹/네트워크 + new("API", "Application Programming Interface", "소프트웨어 간 통신 규격", "웹/네트워크"), + new("REST", "Representational State Transfer", "HTTP 기반 아키텍처 스타일", "웹/네트워크"), + new("HTTP", "HyperText Transfer Protocol", "웹 통신 프로토콜", "웹/네트워크"), + new("HTTPS", "HTTP Secure", "TLS/SSL로 암호화된 HTTP", "웹/네트워크"), + new("HTML", "HyperText Markup Language", "웹 페이지 마크업 언어", "웹/네트워크"), + new("CSS", "Cascading Style Sheets", "웹 스타일 시트 언어", "웹/네트워크"), + new("URL", "Uniform Resource Locator", "인터넷 자원 주소", "웹/네트워크"), + new("URI", "Uniform Resource Identifier", "자원 식별자 (URL의 상위 개념)", "웹/네트워크"), + new("DNS", "Domain Name System", "도메인 ↔ IP 변환 시스템", "웹/네트워크"), + new("IP", "Internet Protocol", "인터넷 패킷 전송 프로토콜", "웹/네트워크"), + new("TCP", "Transmission Control Protocol", "신뢰성 연결형 전송 프로토콜", "웹/네트워크"), + new("UDP", "User Datagram Protocol", "비연결형 전송 프로토콜", "웹/네트워크"), + new("SSH", "Secure Shell", "원격 보안 접속 프로토콜", "웹/네트워크"), + new("FTP", "File Transfer Protocol", "파일 전송 프로토콜", "웹/네트워크"), + new("SMTP", "Simple Mail Transfer Protocol", "이메일 전송 프로토콜", "웹/네트워크"), + new("CDN", "Content Delivery Network", "콘텐츠 분산 배포 네트워크", "웹/네트워크"), + new("VPN", "Virtual Private Network", "가상 사설 네트워크", "웹/네트워크"), + new("NAT", "Network Address Translation", "사설↔공인 IP 주소 변환", "웹/네트워크"), + new("CORS", "Cross-Origin Resource Sharing", "브라우저 교차 출처 리소스 공유", "웹/네트워크"), + new("SSE", "Server-Sent Events", "서버→클라이언트 단방향 스트림", "웹/네트워크"), + new("WebSocket","WebSocket Protocol", "양방향 실시간 통신 프로토콜", "웹/네트워크"), + new("gRPC", "Google Remote Procedure Call", "프로토콜 버퍼 기반 RPC 프레임워크", "웹/네트워크"), + new("GraphQL", "Graph Query Language", "API 쿼리 언어 (Facebook 개발)", "웹/네트워크"), + new("SOAP", "Simple Object Access Protocol", "XML 기반 웹 서비스 프로토콜", "웹/네트워크"), + new("WSDL", "Web Services Description Language", "웹 서비스 인터페이스 설명 언어", "웹/네트워크"), + new("TLS", "Transport Layer Security", "암호화 통신 프로토콜 (SSL 후속)", "웹/네트워크"), + new("SSL", "Secure Sockets Layer", "암호화 통신 프로토콜 (TLS의 전신)", "웹/네트워크"), + new("OAuth", "Open Authorization", "위임 인증 표준 프로토콜", "웹/네트워크"), + new("MIME", "Multipurpose Internet Mail Extensions", "이메일·웹 콘텐츠 타입 표준", "웹/네트워크"), + + // 개발/프로그래밍 + new("CRUD", "Create Read Update Delete", "기본 데이터 처리 4가지 연산", "개발"), + new("OOP", "Object-Oriented Programming", "객체 지향 프로그래밍", "개발"), + new("SOLID", "Single responsibility Open/closed Liskov Interface-segregation Dependency-inversion", + "객체지향 5가지 설계 원칙", "개발"), + new("DRY", "Don't Repeat Yourself", "코드 중복 최소화 원칙", "개발"), + new("KISS", "Keep It Simple, Stupid", "단순하게 유지하라 원칙", "개발"), + new("YAGNI", "You Aren't Gonna Need It", "필요할 때만 구현하라 원칙", "개발"), + new("TDD", "Test-Driven Development", "테스트 주도 개발 방법론", "개발"), + new("BDD", "Behavior-Driven Development", "행동 주도 개발 방법론", "개발"), + new("DDD", "Domain-Driven Design", "도메인 주도 설계", "개발"), + new("MVC", "Model-View-Controller", "UI 아키텍처 패턴", "개발"), + new("MVP", "Model-View-Presenter", "MVC의 변형, Presenter가 View와 Model 중재", "개발"), + new("MVVM", "Model-View-ViewModel", "WPF·모바일 아키텍처 패턴", "개발"), + new("SRP", "Single Responsibility Principle", "단일 책임 원칙 (SOLID의 S)", "개발"), + new("OCP", "Open/Closed Principle", "개방/폐쇄 원칙 (SOLID의 O)", "개발"), + new("LSP", "Liskov Substitution Principle", "리스코프 치환 원칙 (SOLID의 L)", "개발"), + new("ISP", "Interface Segregation Principle", "인터페이스 분리 원칙 (SOLID의 I)", "개발"), + new("DIP", "Dependency Inversion Principle", "의존성 역전 원칙 (SOLID의 D)", "개발"), + new("IoC", "Inversion of Control", "제어 역전 (프레임워크 핵심 개념)", "개발"), + new("DI", "Dependency Injection", "의존성 주입 패턴", "개발"), + new("AOP", "Aspect-Oriented Programming", "관점 지향 프로그래밍", "개발"), + new("FP", "Functional Programming", "함수형 프로그래밍", "개발"), + new("DSL", "Domain-Specific Language", "특정 도메인 전용 언어", "개발"), + new("SDK", "Software Development Kit", "소프트웨어 개발 도구 모음", "개발"), + new("IDE", "Integrated Development Environment", "통합 개발 환경", "개발"), + new("CLI", "Command Line Interface", "명령줄 인터페이스", "개발"), + new("GUI", "Graphical User Interface", "그래픽 사용자 인터페이스", "개발"), + new("UI", "User Interface", "사용자 인터페이스", "개발"), + new("UX", "User Experience", "사용자 경험", "개발"), + new("SPA", "Single Page Application", "단일 페이지 애플리케이션", "개발"), + new("SSR", "Server-Side Rendering", "서버 사이드 렌더링", "개발"), + new("CSR", "Client-Side Rendering", "클라이언트 사이드 렌더링", "개발"), + new("PWA", "Progressive Web App", "프로그레시브 웹 앱", "개발"), + new("CDK", "Cloud Development Kit", "클라우드 인프라 코드 개발 도구", "개발"), + new("ORM", "Object-Relational Mapping", "객체-관계형 데이터베이스 매핑", "개발"), + new("REPL", "Read-Eval-Print Loop", "대화형 프로그래밍 환경", "개발"), + new("GC", "Garbage Collection", "자동 메모리 회수", "개발"), + new("JIT", "Just-In-Time Compilation", "실행 시점 컴파일", "개발"), + new("AOT", "Ahead-Of-Time Compilation", "사전 컴파일", "개발"), + new("WASM", "WebAssembly", "브라우저용 바이너리 형식", "개발"), + new("AST", "Abstract Syntax Tree", "추상 구문 트리", "개발"), + new("LSP", "Language Server Protocol", "언어 분석 서버 표준 프로토콜", "개발"), + + // 데이터베이스 + new("SQL", "Structured Query Language", "관계형 DB 쿼리 언어", "DB"), + new("NoSQL", "Not Only SQL", "비관계형 데이터베이스 총칭", "DB"), + new("ACID", "Atomicity Consistency Isolation Durability", "DB 트랜잭션 4가지 성질", "DB"), + new("CAP", "Consistency Availability Partition-tolerance","분산 DB 설계 이론 (셋 중 2개만 보장)", "DB"), + new("ETL", "Extract Transform Load", "데이터 추출·변환·적재 프로세스", "DB"), + new("OLTP", "Online Transaction Processing", "트랜잭션 처리 중심 DB", "DB"), + new("OLAP", "Online Analytical Processing", "분석 처리 중심 DB (데이터 웨어하우스)", "DB"), + new("DDL", "Data Definition Language", "DB 구조 정의 SQL (CREATE/ALTER/DROP)", "DB"), + new("DML", "Data Manipulation Language", "데이터 조작 SQL (SELECT/INSERT/UPDATE/DELETE)", "DB"), + new("DCL", "Data Control Language", "접근 권한 SQL (GRANT/REVOKE)", "DB"), + + // 보안 + new("JWT", "JSON Web Token", "JSON 기반 인증 토큰 표준", "보안"), + new("RBAC", "Role-Based Access Control", "역할 기반 접근 제어", "보안"), + new("ABAC", "Attribute-Based Access Control", "속성 기반 접근 제어", "보안"), + new("MFA", "Multi-Factor Authentication", "다중 인증", "보안"), + new("2FA", "Two-Factor Authentication", "이중 인증", "보안"), + new("XSS", "Cross-Site Scripting", "악성 스크립트 삽입 공격", "보안"), + new("CSRF", "Cross-Site Request Forgery", "교차 사이트 요청 위조 공격", "보안"), + new("SQL Injection", "SQL Injection Attack", "SQL 쿼리 삽입 공격", "보안"), + new("OWASP", "Open Web Application Security Project", "웹 보안 가이드라인 기관", "보안"), + new("CVE", "Common Vulnerabilities and Exposures", "공개 보안 취약점 DB", "보안"), + new("HTTPS", "HTTP Secure", "TLS로 암호화된 HTTP", "보안"), + new("AES", "Advanced Encryption Standard", "대칭키 암호화 표준 (128/192/256bit)", "보안"), + new("RSA", "Rivest–Shamir–Adleman", "공개키 비대칭 암호화 알고리즘", "보안"), + new("HMAC", "Hash-based Message Authentication Code", "해시 기반 메시지 인증 코드", "보안"), + new("PKI", "Public Key Infrastructure", "공개키 인프라", "보안"), + + // 클라우드/인프라 + new("AWS", "Amazon Web Services", "아마존 클라우드 서비스", "클라우드"), + new("GCP", "Google Cloud Platform", "구글 클라우드 서비스", "클라우드"), + new("SaaS", "Software as a Service", "구독형 소프트웨어 서비스", "클라우드"), + new("PaaS", "Platform as a Service", "플랫폼 서비스", "클라우드"), + new("IaaS", "Infrastructure as a Service", "인프라 서비스", "클라우드"), + new("FaaS", "Function as a Service", "서버리스 함수 서비스", "클라우드"), + new("K8s", "Kubernetes", "컨테이너 오케스트레이션 플랫폼", "클라우드"), + new("CI/CD", "Continuous Integration/Continuous Delivery","지속적 통합/배포 파이프라인", "클라우드"), + new("IaC", "Infrastructure as Code", "코드로 관리하는 인프라", "클라우드"), + new("VPC", "Virtual Private Cloud", "가상 사설 클라우드 네트워크", "클라우드"), + new("SLA", "Service Level Agreement", "서비스 수준 계약 (가용성 보장)", "클라우드"), + new("SLO", "Service Level Objective", "서비스 수준 목표", "클라우드"), + new("RTO", "Recovery Time Objective", "복구 목표 시간", "클라우드"), + new("RPO", "Recovery Point Objective", "복구 목표 지점", "클라우드"), + new("CDN", "Content Delivery Network", "콘텐츠 분산 배포 네트워크", "클라우드"), + new("ECS", "Elastic Container Service", "AWS 컨테이너 관리 서비스", "클라우드"), + new("ECR", "Elastic Container Registry", "AWS 컨테이너 이미지 저장소", "클라우드"), + new("EKS", "Elastic Kubernetes Service", "AWS 관리형 Kubernetes", "클라우드"), + + // AI/ML + new("AI", "Artificial Intelligence", "인공지능", "AI/ML"), + new("ML", "Machine Learning", "머신러닝", "AI/ML"), + new("DL", "Deep Learning", "딥러닝", "AI/ML"), + new("LLM", "Large Language Model", "대규모 언어 모델", "AI/ML"), + new("NLP", "Natural Language Processing", "자연어 처리", "AI/ML"), + new("CNN", "Convolutional Neural Network", "합성곱 신경망", "AI/ML"), + new("RNN", "Recurrent Neural Network", "순환 신경망", "AI/ML"), + new("GAN", "Generative Adversarial Network", "생성적 적대 신경망", "AI/ML"), + new("RAG", "Retrieval-Augmented Generation", "검색 증강 생성 (LLM + 검색)", "AI/ML"), + new("RLHF", "Reinforcement Learning from Human Feedback","인간 피드백 강화학습", "AI/ML"), + new("GPT", "Generative Pre-trained Transformer", "생성형 사전학습 변환기 (OpenAI)", "AI/ML"), + new("BERT", "Bidirectional Encoder Representations from Transformers","양방향 트랜스포머 언어 모델", "AI/ML"), + new("SFT", "Supervised Fine-Tuning", "지도 학습 파인튜닝", "AI/ML"), + new("LoRA", "Low-Rank Adaptation", "저순위 행렬 파인튜닝 기법", "AI/ML"), + new("MCP", "Model Context Protocol", "LLM 외부 도구 연결 표준 프로토콜", "AI/ML"), + new("CoT", "Chain of Thought", "사고 단계 명시 프롬프팅", "AI/ML"), + + // 데이터 형식 + new("JSON", "JavaScript Object Notation", "경량 데이터 교환 형식", "데이터형식"), + new("XML", "Extensible Markup Language", "확장 가능 마크업 언어", "데이터형식"), + new("YAML", "YAML Ain't Markup Language", "사람이 읽기 쉬운 데이터 직렬화 형식", "데이터형식"), + new("TOML", "Tom's Obvious, Minimal Language", "설정 파일 전용 경량 형식", "데이터형식"), + new("CSV", "Comma-Separated Values", "쉼표 구분 데이터 형식", "데이터형식"), + new("TSV", "Tab-Separated Values", "탭 구분 데이터 형식", "데이터형식"), + new("Protobuf","Protocol Buffers", "구글 이진 직렬화 형식", "데이터형식"), + new("Avro", "Apache Avro", "Hadoop 생태계 이진 직렬화", "데이터형식"), + new("Parquet", "Apache Parquet", "열 지향 이진 데이터 형식", "데이터형식"), + new("Base64", "Base 64 encoding", "이진 데이터 텍스트 인코딩 (64진수)", "데이터형식"), + + // 버전 관리/협업 + new("VCS", "Version Control System", "버전 관리 시스템", "협업"), + new("SCM", "Source Code Management", "소스 코드 관리", "협업"), + new("PR", "Pull Request", "코드 병합 요청 (GitHub 용어)", "협업"), + new("MR", "Merge Request", "코드 병합 요청 (GitLab 용어)", "협업"), + new("LGTM", "Looks Good To Me", "코드 리뷰 승인 표현", "협업"), + new("WIP", "Work In Progress", "진행 중 작업", "협업"), + new("RFC", "Request for Comments", "의견 요청 문서 (표준화 프로세스)", "협업"), + new("POC", "Proof of Concept", "개념 검증", "협업"), + new("MVP", "Minimum Viable Product", "최소 기능 제품", "협업"), + new("KPI", "Key Performance Indicator", "핵심 성과 지표", "협업"), + new("OKR", "Objectives and Key Results", "목표 및 핵심 결과", "협업"), + new("SOP", "Standard Operating Procedure", "표준 운영 절차", "협업"), + ]; + + private static readonly string[] Categories = Entries.Select(e => e.Category).Distinct().ToArray(); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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("── 카테고리 ──", "", 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>(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>(items); + } + + // 약어 검색 (정확 일치 우선, 그 다음 부분 일치) + var exact = Entries.Where(e => + e.Short.Equals(q, StringComparison.OrdinalIgnoreCase)).ToList(); + + var partial = Entries.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>(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>(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("Abbr", "클립보드에 복사했습니다."); + } + catch { } + } + return Task.CompletedTask; + } + + private static LauncherItem MakeAbbrItem(AbbrEntry e) => + new(e.Short, + $"{e.Full} · {e.Description}", + null, ("copy", $"{e.Short}: {e.Full}"), Symbol: "\uE82D"); +} diff --git a/src/AxCopilot/Handlers/AspectHandler.cs b/src/AxCopilot/Handlers/AspectHandler.cs new file mode 100644 index 0000000..f9b42a5 --- /dev/null +++ b/src/AxCopilot/Handlers/AspectHandler.cs @@ -0,0 +1,297 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// 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 → 해상도 복사. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(items); + } + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + // aspect → 비율 계산 + 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>(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>(items); + } + } + + // aspect 16:9 → 주요 해상도 목록 + items.AddRange(BuildFromRatio(rw, rh)); + return Task.FromResult>(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>(items); + } + } + + items.Add(new LauncherItem("형식 오류", + "예: aspect 1920 1080 / aspect 16:9 1280 / aspect 16:9 h 720", + null, null, Symbol: "\uE783")); + return Task.FromResult>(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 BuildFromResolution(int w, int h) + { + var items = new List(); + 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 BuildFromRatio(int rw, int rh) + { + var items = new List(); + 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 BuildFromRatioAndDim(int rw, int rh, int w, int h) + { + var items = new List(); + 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 BuildCropItems(int srcW, int srcH, int cRw, int cRh) + { + var items = new List(); + // 크롭 방향 결정 + 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)"; + } +} diff --git a/src/AxCopilot/Handlers/SqlHandler.cs b/src/AxCopilot/Handlers/SqlHandler.cs new file mode 100644 index 0000000..521fe21 --- /dev/null +++ b/src/AxCopilot/Handlers/SqlHandler.cs @@ -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; + +/// +/// L18-1: SQL 포맷터·분석기 핸들러. "sql" 프리픽스로 사용합니다. +/// +/// 예: sql → 클립보드 SQL 포맷 (들여쓰기 정렬) +/// sql mini → SQL 미니파이 (공백·줄바꿈 제거) +/// sql upper → 키워드 대문자로 변환 +/// sql lower → 키워드 소문자로 변환 +/// sql stats → 테이블·컬럼·조건 수 분석 +/// sql tables → FROM/JOIN 테이블 목록 추출 +/// sql select
→ SELECT * FROM
생성 +/// Enter → 결과 복사. +/// 외부 라이브러리 없이 순수 구현. +/// +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 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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(items); + } + + var parts = q.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + // sql select
— 클립보드 없이도 동작 + 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>(items); + } + + if (string.IsNullOrWhiteSpace(clipboard)) + { + items.Add(new LauncherItem("클립보드가 비어 있습니다", + "SQL 쿼리를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946")); + return Task.FromResult>(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>(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 BuildStatsItems(string sql) + { + var items = new List(); + 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 ExtractTables(string sql) + { + var result = new HashSet(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 ExtractSelectColumns(string sql) + { + var m = SelectColsRegex().Match(sql); + if (!m.Success) return new List(); + 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 Tokenize(string sql) + { + var tokens = new List(); + 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 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(); +} diff --git a/src/AxCopilot/Handlers/TextCaseHandler.cs b/src/AxCopilot/Handlers/TextCaseHandler.cs new file mode 100644 index 0000000..d025fa7 --- /dev/null +++ b/src/AxCopilot/Handlers/TextCaseHandler.cs @@ -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; + +/// +/// 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 → 결과 복사. +/// +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 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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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>(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>(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 fn, string input) + { + try { return fn(input); } + catch { return input; } + } + + /// 입력을 단어 토큰 배열로 분리 (공백, 언더스코어, 하이픈, 대문자 경계) + 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(); +}