From 033690425867dbb11e3389322cdc30231e2f5481 Mon Sep 17 00:00:00 2001 From: lacvet Date: Sun, 5 Apr 2026 00:59:45 +0900 Subject: [PATCH] =?UTF-8?q?AX=20Commander=20=EB=B9=84=EA=B5=90=EB=B3=B8=20?= =?UTF-8?q?=EB=9F=B0=EC=B2=98=20=EA=B8=B0=EB=8A=A5=20=EB=8C=80=EB=9F=89=20?= =?UTF-8?q?=EC=9D=B4=EC=8B=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경 목적: 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개를 확인했습니다. --- README.md | 5 + docs/DEVELOPMENT.md | 2 + src/AxCopilot/App.xaml.cs | 98 ++ src/AxCopilot/AxCopilot.csproj | 3 +- src/AxCopilot/Handlers/AbbrHandler.cs | 427 +++++++ src/AxCopilot/Handlers/AgeHandler.cs | 284 +++++ src/AxCopilot/Handlers/ApHandler.cs | 260 ++++ src/AxCopilot/Handlers/AspectHandler.cs | 297 +++++ src/AxCopilot/Handlers/BaseConvertHandler.cs | 235 ++++ src/AxCopilot/Handlers/BatchRenameHandler.cs | 86 ++ src/AxCopilot/Handlers/BmiHandler.cs | 219 ++++ src/AxCopilot/Handlers/BrightHandler.cs | 153 +++ src/AxCopilot/Handlers/CalHandler.cs | 349 ++++++ src/AxCopilot/Handlers/CalcHandler.cs | 288 +++++ src/AxCopilot/Handlers/CertHandler.cs | 253 ++++ src/AxCopilot/Handlers/CleanHandler.cs | 320 +++++ src/AxCopilot/Handlers/ContactHandler.cs | 311 +++++ src/AxCopilot/Handlers/ContextHandler.cs | 193 +++ src/AxCopilot/Handlers/CronHandler.cs | 340 +++++ src/AxCopilot/Handlers/CsvHandler.cs | 338 +++++ src/AxCopilot/Handlers/CurrencyHandler.cs | 213 ++++ src/AxCopilot/Handlers/DictHandler.cs | 201 +++ src/AxCopilot/Handlers/DnsQueryHandler.cs | 264 ++++ src/AxCopilot/Handlers/DockerHandler.cs | 375 ++++++ src/AxCopilot/Handlers/DriveHandler.cs | 202 +++ src/AxCopilot/Handlers/EventLogHandler.cs | 157 +++ src/AxCopilot/Handlers/FileBrowserHandler.cs | 186 +++ src/AxCopilot/Handlers/FileHashHandler.cs | 273 ++++ src/AxCopilot/Handlers/FixHandler.cs | 539 ++++++++ src/AxCopilot/Handlers/FlowHandler.cs | 237 ++++ src/AxCopilot/Handlers/FontHandler.cs | 136 ++ src/AxCopilot/Handlers/FormHandler.cs | 1113 +++++++++++++++++ src/AxCopilot/Handlers/GitHandler.cs | 251 ++++ src/AxCopilot/Handlers/GitignoreHandler.cs | 536 ++++++++ src/AxCopilot/Handlers/HexHandler.cs | 315 +++++ src/AxCopilot/Handlers/HostsHandler.cs | 253 ++++ src/AxCopilot/Handlers/HotkeyHandler.cs | 155 +++ src/AxCopilot/Handlers/HttpTesterHandler.cs | 191 +++ src/AxCopilot/Handlers/IpInfoHandler.cs | 367 ++++++ src/AxCopilot/Handlers/JwtHandler.cs | 298 +++++ src/AxCopilot/Handlers/KeyHandler.cs | 391 ++++++ src/AxCopilot/Handlers/LeaveHandler.cs | 323 +++++ src/AxCopilot/Handlers/LogHandler.cs | 402 ++++++ src/AxCopilot/Handlers/LoremHandler.cs | 284 +++++ src/AxCopilot/Handlers/MacroHandler.cs | 231 ++++ src/AxCopilot/Handlers/MdHandler.cs | 356 ++++++ src/AxCopilot/Handlers/MeetHandler.cs | 231 ++++ src/AxCopilot/Handlers/MorseHandler.cs | 251 ++++ src/AxCopilot/Handlers/NetDiagHandler.cs | 365 ++++++ src/AxCopilot/Handlers/NotifHandler.cs | 139 ++ src/AxCopilot/Handlers/NpmHandler.cs | 342 +++++ src/AxCopilot/Handlers/NumHandler.cs | 288 +++++ src/AxCopilot/Handlers/OcrHandler.cs | 264 ++++ src/AxCopilot/Handlers/PasswordGenHandler.cs | 249 ++++ src/AxCopilot/Handlers/PasteHandler.cs | 217 ++++ src/AxCopilot/Handlers/PathHandler.cs | 219 ++++ src/AxCopilot/Handlers/PermHandler.cs | 277 ++++ src/AxCopilot/Handlers/PhraseHandler.cs | 215 ++++ src/AxCopilot/Handlers/PingHandler.cs | 271 ++++ src/AxCopilot/Handlers/PipHandler.cs | 178 +++ src/AxCopilot/Handlers/PkgHandler.cs | 238 ++++ src/AxCopilot/Handlers/PomoHandler.cs | 130 ++ src/AxCopilot/Handlers/ProcHandler.cs | 202 +++ src/AxCopilot/Handlers/PsHandler.cs | 268 ++++ src/AxCopilot/Handlers/QrHandler.cs | 127 ++ src/AxCopilot/Handlers/QuickLinkHandler.cs | 127 ++ src/AxCopilot/Handlers/RandHandler.cs | 324 +++++ src/AxCopilot/Handlers/RegHandler.cs | 205 +++ src/AxCopilot/Handlers/RegexHandler.cs | 340 +++++ src/AxCopilot/Handlers/RemindHandler.cs | 288 +++++ src/AxCopilot/Handlers/ScheduleHandler.cs | 171 +++ src/AxCopilot/Handlers/SessionHandler.cs | 301 +++++ src/AxCopilot/Handlers/SpellHandler.cs | 408 ++++++ src/AxCopilot/Handlers/SqlHandler.cs | 467 +++++++ src/AxCopilot/Handlers/SshHandler.cs | 341 +++++ src/AxCopilot/Handlers/StartupHandler.cs | 230 ++++ src/AxCopilot/Handlers/StrHandler.cs | 459 +++++++ src/AxCopilot/Handlers/SubnetHandler.cs | 281 +++++ src/AxCopilot/Handlers/TableHandler.cs | 376 ++++++ src/AxCopilot/Handlers/TagHandler.cs | 264 ++++ src/AxCopilot/Handlers/TextCaseHandler.cs | 259 ++++ src/AxCopilot/Handlers/TimeZoneHandler.cs | 259 ++++ src/AxCopilot/Handlers/TimerHandler.cs | 270 ++++ src/AxCopilot/Handlers/TipHandler.cs | 242 ++++ src/AxCopilot/Handlers/TodayHandler.cs | 250 ++++ src/AxCopilot/Handlers/TodoHandler.cs | 260 ++++ src/AxCopilot/Handlers/TomlHandler.cs | 372 ++++++ src/AxCopilot/Handlers/UnicodeHandler.cs | 344 +++++ src/AxCopilot/Handlers/UnitHandler.cs | 284 +++++ src/AxCopilot/Handlers/UuidHandler.cs | 300 +++++ src/AxCopilot/Handlers/VolHandler.cs | 211 ++++ src/AxCopilot/Handlers/WolHandler.cs | 260 ++++ src/AxCopilot/Handlers/WorkTimeHandler.cs | 241 ++++ src/AxCopilot/Handlers/WslHandler.cs | 274 ++++ src/AxCopilot/Handlers/XlHandler.cs | 227 ++++ src/AxCopilot/Handlers/XmlHandler.cs | 345 +++++ src/AxCopilot/Handlers/YamlHandler.cs | 410 ++++++ src/AxCopilot/Handlers/ZipHandler.cs | 303 +++++ src/AxCopilot/Models/AppSettings.cs | 195 +++ src/AxCopilot/Services/FileTagService.cs | 155 +++ src/AxCopilot/Services/IconCacheService.cs | 192 +++ .../Services/NotificationCenterService.cs | 86 ++ src/AxCopilot/Services/NotificationService.cs | 9 + src/AxCopilot/Services/PomodoroService.cs | 179 +++ src/AxCopilot/Services/SchedulerService.cs | 214 ++++ src/AxCopilot/Services/UrlTemplateEngine.cs | 56 + src/AxCopilot/Themes/Symbols.cs | 1 + src/AxCopilot/Views/BatchRenameWindow.xaml | 546 ++++++++ src/AxCopilot/Views/BatchRenameWindow.xaml.cs | 426 +++++++ src/AxCopilot/Views/MacroEditorWindow.xaml | 179 +++ src/AxCopilot/Views/MacroEditorWindow.xaml.cs | 320 +++++ src/AxCopilot/Views/ScheduleEditorWindow.xaml | 343 +++++ .../Views/ScheduleEditorWindow.xaml.cs | 338 +++++ src/AxCopilot/Views/SessionEditorWindow.xaml | 251 ++++ .../Views/SessionEditorWindow.xaml.cs | 386 ++++++ 115 files changed, 30749 insertions(+), 1 deletion(-) create mode 100644 src/AxCopilot/Handlers/AbbrHandler.cs create mode 100644 src/AxCopilot/Handlers/AgeHandler.cs create mode 100644 src/AxCopilot/Handlers/ApHandler.cs create mode 100644 src/AxCopilot/Handlers/AspectHandler.cs create mode 100644 src/AxCopilot/Handlers/BaseConvertHandler.cs create mode 100644 src/AxCopilot/Handlers/BatchRenameHandler.cs create mode 100644 src/AxCopilot/Handlers/BmiHandler.cs create mode 100644 src/AxCopilot/Handlers/BrightHandler.cs create mode 100644 src/AxCopilot/Handlers/CalHandler.cs create mode 100644 src/AxCopilot/Handlers/CalcHandler.cs create mode 100644 src/AxCopilot/Handlers/CertHandler.cs create mode 100644 src/AxCopilot/Handlers/CleanHandler.cs create mode 100644 src/AxCopilot/Handlers/ContactHandler.cs create mode 100644 src/AxCopilot/Handlers/ContextHandler.cs create mode 100644 src/AxCopilot/Handlers/CronHandler.cs create mode 100644 src/AxCopilot/Handlers/CsvHandler.cs create mode 100644 src/AxCopilot/Handlers/CurrencyHandler.cs create mode 100644 src/AxCopilot/Handlers/DictHandler.cs create mode 100644 src/AxCopilot/Handlers/DnsQueryHandler.cs create mode 100644 src/AxCopilot/Handlers/DockerHandler.cs create mode 100644 src/AxCopilot/Handlers/DriveHandler.cs create mode 100644 src/AxCopilot/Handlers/EventLogHandler.cs create mode 100644 src/AxCopilot/Handlers/FileBrowserHandler.cs create mode 100644 src/AxCopilot/Handlers/FileHashHandler.cs create mode 100644 src/AxCopilot/Handlers/FixHandler.cs create mode 100644 src/AxCopilot/Handlers/FlowHandler.cs create mode 100644 src/AxCopilot/Handlers/FontHandler.cs create mode 100644 src/AxCopilot/Handlers/FormHandler.cs create mode 100644 src/AxCopilot/Handlers/GitHandler.cs create mode 100644 src/AxCopilot/Handlers/GitignoreHandler.cs create mode 100644 src/AxCopilot/Handlers/HexHandler.cs create mode 100644 src/AxCopilot/Handlers/HostsHandler.cs create mode 100644 src/AxCopilot/Handlers/HotkeyHandler.cs create mode 100644 src/AxCopilot/Handlers/HttpTesterHandler.cs create mode 100644 src/AxCopilot/Handlers/IpInfoHandler.cs create mode 100644 src/AxCopilot/Handlers/JwtHandler.cs create mode 100644 src/AxCopilot/Handlers/KeyHandler.cs create mode 100644 src/AxCopilot/Handlers/LeaveHandler.cs create mode 100644 src/AxCopilot/Handlers/LogHandler.cs create mode 100644 src/AxCopilot/Handlers/LoremHandler.cs create mode 100644 src/AxCopilot/Handlers/MacroHandler.cs create mode 100644 src/AxCopilot/Handlers/MdHandler.cs create mode 100644 src/AxCopilot/Handlers/MeetHandler.cs create mode 100644 src/AxCopilot/Handlers/MorseHandler.cs create mode 100644 src/AxCopilot/Handlers/NetDiagHandler.cs create mode 100644 src/AxCopilot/Handlers/NotifHandler.cs create mode 100644 src/AxCopilot/Handlers/NpmHandler.cs create mode 100644 src/AxCopilot/Handlers/NumHandler.cs create mode 100644 src/AxCopilot/Handlers/OcrHandler.cs create mode 100644 src/AxCopilot/Handlers/PasswordGenHandler.cs create mode 100644 src/AxCopilot/Handlers/PasteHandler.cs create mode 100644 src/AxCopilot/Handlers/PathHandler.cs create mode 100644 src/AxCopilot/Handlers/PermHandler.cs create mode 100644 src/AxCopilot/Handlers/PhraseHandler.cs create mode 100644 src/AxCopilot/Handlers/PingHandler.cs create mode 100644 src/AxCopilot/Handlers/PipHandler.cs create mode 100644 src/AxCopilot/Handlers/PkgHandler.cs create mode 100644 src/AxCopilot/Handlers/PomoHandler.cs create mode 100644 src/AxCopilot/Handlers/ProcHandler.cs create mode 100644 src/AxCopilot/Handlers/PsHandler.cs create mode 100644 src/AxCopilot/Handlers/QrHandler.cs create mode 100644 src/AxCopilot/Handlers/QuickLinkHandler.cs create mode 100644 src/AxCopilot/Handlers/RandHandler.cs create mode 100644 src/AxCopilot/Handlers/RegHandler.cs create mode 100644 src/AxCopilot/Handlers/RegexHandler.cs create mode 100644 src/AxCopilot/Handlers/RemindHandler.cs create mode 100644 src/AxCopilot/Handlers/ScheduleHandler.cs create mode 100644 src/AxCopilot/Handlers/SessionHandler.cs create mode 100644 src/AxCopilot/Handlers/SpellHandler.cs create mode 100644 src/AxCopilot/Handlers/SqlHandler.cs create mode 100644 src/AxCopilot/Handlers/SshHandler.cs create mode 100644 src/AxCopilot/Handlers/StartupHandler.cs create mode 100644 src/AxCopilot/Handlers/StrHandler.cs create mode 100644 src/AxCopilot/Handlers/SubnetHandler.cs create mode 100644 src/AxCopilot/Handlers/TableHandler.cs create mode 100644 src/AxCopilot/Handlers/TagHandler.cs create mode 100644 src/AxCopilot/Handlers/TextCaseHandler.cs create mode 100644 src/AxCopilot/Handlers/TimeZoneHandler.cs create mode 100644 src/AxCopilot/Handlers/TimerHandler.cs create mode 100644 src/AxCopilot/Handlers/TipHandler.cs create mode 100644 src/AxCopilot/Handlers/TodayHandler.cs create mode 100644 src/AxCopilot/Handlers/TodoHandler.cs create mode 100644 src/AxCopilot/Handlers/TomlHandler.cs create mode 100644 src/AxCopilot/Handlers/UnicodeHandler.cs create mode 100644 src/AxCopilot/Handlers/UnitHandler.cs create mode 100644 src/AxCopilot/Handlers/UuidHandler.cs create mode 100644 src/AxCopilot/Handlers/VolHandler.cs create mode 100644 src/AxCopilot/Handlers/WolHandler.cs create mode 100644 src/AxCopilot/Handlers/WorkTimeHandler.cs create mode 100644 src/AxCopilot/Handlers/WslHandler.cs create mode 100644 src/AxCopilot/Handlers/XlHandler.cs create mode 100644 src/AxCopilot/Handlers/XmlHandler.cs create mode 100644 src/AxCopilot/Handlers/YamlHandler.cs create mode 100644 src/AxCopilot/Handlers/ZipHandler.cs create mode 100644 src/AxCopilot/Services/FileTagService.cs create mode 100644 src/AxCopilot/Services/IconCacheService.cs create mode 100644 src/AxCopilot/Services/NotificationCenterService.cs create mode 100644 src/AxCopilot/Services/PomodoroService.cs create mode 100644 src/AxCopilot/Services/SchedulerService.cs create mode 100644 src/AxCopilot/Services/UrlTemplateEngine.cs create mode 100644 src/AxCopilot/Views/BatchRenameWindow.xaml create mode 100644 src/AxCopilot/Views/BatchRenameWindow.xaml.cs create mode 100644 src/AxCopilot/Views/MacroEditorWindow.xaml create mode 100644 src/AxCopilot/Views/MacroEditorWindow.xaml.cs create mode 100644 src/AxCopilot/Views/ScheduleEditorWindow.xaml create mode 100644 src/AxCopilot/Views/ScheduleEditorWindow.xaml.cs create mode 100644 src/AxCopilot/Views/SessionEditorWindow.xaml create mode 100644 src/AxCopilot/Views/SessionEditorWindow.xaml.cs diff --git a/README.md b/README.md index 109333f..22ac72e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index c612f9a..f159034 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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. diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index 43c029d..1e6fe3c 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -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(); diff --git a/src/AxCopilot/AxCopilot.csproj b/src/AxCopilot/AxCopilot.csproj index e30044d..179d5d2 100644 --- a/src/AxCopilot/AxCopilot.csproj +++ b/src/AxCopilot/AxCopilot.csproj @@ -1,7 +1,7 @@  WinExe - net8.0-windows + net8.0-windows10.0.17763.0 enable enable true @@ -66,6 +66,7 @@ + diff --git a/src/AxCopilot/Handlers/AbbrHandler.cs b/src/AxCopilot/Handlers/AbbrHandler.cs new file mode 100644 index 0000000..0092f67 --- /dev/null +++ b/src/AxCopilot/Handlers/AbbrHandler.cs @@ -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; + +/// +/// 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(); + + // ── 커스텀 약어 ─────────────────────────────────────────────────────────── + 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 LoadCustom() + { + try { + if (System.IO.File.Exists(CustomPath)) + return JsonSerializer.Deserialize>( + System.IO.File.ReadAllText(CustomPath)) ?? []; + } catch { } + return []; + } + + private static void SaveCustom(List 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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + // ── 서브커맨드 처리 ─────────────────────────────────────────────────── + // 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>(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>(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>(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>(items); + } + items.Add(new LauncherItem($"약어 삭제: {abbr}", + "Enter: 삭제 확인", + null, ("del", abbr), Symbol: "\uE82D")); + return Task.FromResult>(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>(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 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>(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) + { + 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"); +} diff --git a/src/AxCopilot/Handlers/AgeHandler.cs b/src/AxCopilot/Handlers/AgeHandler.cs new file mode 100644 index 0000000..178d522 --- /dev/null +++ b/src/AxCopilot/Handlers/AgeHandler.cs @@ -0,0 +1,284 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// 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 → 결과를 클립보드에 복사. +/// +public class AgeHandler : IActionHandler +{ + public string? Prefix => "age"; + + public PluginMetadata Metadata => new( + "Age", + "나이·D-day 계산기 — 만 나이 · 한국 나이 · D-day", + "1.0", + "AX"); + + // 특수 날짜 키워드 + private static readonly Dictionary> 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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + 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>(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>(items); + } + } + + // 요일 키워드: "next monday" + if (TryParseNextWeekday(q, out var weekdayDate)) + { + items.AddRange(BuildDdayItems(weekdayDate, q, today)); + return Task.FromResult>(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>(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>(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 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 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 => "일요일", + _ => "", + }; +} diff --git a/src/AxCopilot/Handlers/ApHandler.cs b/src/AxCopilot/Handlers/ApHandler.cs new file mode 100644 index 0000000..22bdaef --- /dev/null +++ b/src/AxCopilot/Handlers/ApHandler.cs @@ -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; + +/// +/// 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 → 변환된 텍스트를 클립보드에 복사. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim().ToLowerInvariant(); + var items = new List(); + + // 클립보드 텍스트 읽기 + 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>(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>(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>(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>(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입니다."; } + } +} 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/BaseConvertHandler.cs b/src/AxCopilot/Handlers/BaseConvertHandler.cs new file mode 100644 index 0000000..bab8a28 --- /dev/null +++ b/src/AxCopilot/Handlers/BaseConvertHandler.cs @@ -0,0 +1,235 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// 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 → 결과를 클립보드에 복사. +/// +public class BaseConvertHandler : IActionHandler +{ + public string? Prefix => "base"; + + public PluginMetadata Metadata => new( + "BaseConvert", + "진수 변환기 — 2 · 8 · 10 · 16진수 · ASCII", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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>(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>(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 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 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"; + } +} diff --git a/src/AxCopilot/Handlers/BatchRenameHandler.cs b/src/AxCopilot/Handlers/BatchRenameHandler.cs new file mode 100644 index 0000000..20f28ca --- /dev/null +++ b/src/AxCopilot/Handlers/BatchRenameHandler.cs @@ -0,0 +1,86 @@ +using System.IO; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L5-5: 배치 파일 이름변경 핸들러. "batchren" 프리픽스로 사용합니다. +/// 예: batchren → 기능 소개 + 창 열기 +/// batchren C:\work\*.xlsx → 해당 패턴 파일을 창에 미리 로드 +/// +public class BatchRenameHandler : IActionHandler +{ + public string? Prefix => "batchren"; + + public PluginMetadata Metadata => new( + "BatchRename", + "배치 파일 이름변경 — batchren", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + + var items = new List + { + 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>(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; + } +} diff --git a/src/AxCopilot/Handlers/BmiHandler.cs b/src/AxCopilot/Handlers/BmiHandler.cs new file mode 100644 index 0000000..6d1f868 --- /dev/null +++ b/src/AxCopilot/Handlers/BmiHandler.cs @@ -0,0 +1,219 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// 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 → 결과를 클립보드에 복사. +/// +public class BmiHandler : IActionHandler +{ + public string? Prefix => "bmi"; + + public PluginMetadata Metadata => new( + "BMI", + "BMI·건강 계산기 — BMI · 적정 체중 · 기초대사량 · 칼로리", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(items); + } + items.AddRange(BuildIdealItems(ht)); + return Task.FromResult>(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>(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>(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>(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 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 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단계", "🔴"), + }; +} diff --git a/src/AxCopilot/Handlers/BrightHandler.cs b/src/AxCopilot/Handlers/BrightHandler.cs new file mode 100644 index 0000000..60665d2 --- /dev/null +++ b/src/AxCopilot/Handlers/BrightHandler.cs @@ -0,0 +1,153 @@ +using System.Diagnostics; +using AxCopilot.SDK; +using AxCopilot.Services; + +namespace AxCopilot.Handlers; + +/// +/// L27-5: 화면 밝기 제어 핸들러. "bright" 프리픽스로 사용합니다. +/// +/// 예: bright → 현재 밝기 표시 +/// bright 70 → 밝기 70% 설정 +/// bright up / down → ±10% 조절 +/// Enter → 해당 밝기로 설정. +/// WMI (WmiMonitorBrightness) 사용 — 노트북 내장 디스플레이 대상. +/// +public class BrightHandler : IActionHandler +{ + public string? Prefix => "bright"; + + public PluginMetadata Metadata => new( + "밝기 제어", + "화면 밝기 조절 — 설정·증감 (노트북)", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim().ToLowerInvariant(); + var items = new List(); + + // 현재 밝기 읽기 + int current = GetCurrentBrightness(); + if (current < 0) + { + items.Add(new LauncherItem( + "밝기 센서를 찾을 수 없습니다", + "노트북 내장 디스플레이에서만 동작합니다 (외장 모니터 미지원)", + null, null, Symbol: "\uE7BA")); + return Task.FromResult>(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>(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>(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; + } +} diff --git a/src/AxCopilot/Handlers/CalHandler.cs b/src/AxCopilot/Handlers/CalHandler.cs new file mode 100644 index 0000000..1d7d938 --- /dev/null +++ b/src/AxCopilot/Handlers/CalHandler.cs @@ -0,0 +1,349 @@ +using System.Globalization; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; + +namespace AxCopilot.Handlers; + +/// +/// L23-1: 한국 공휴일·업무일 달력. "cal" 프리픽스로 사용합니다. +/// +/// 예: cal → 이번달 달력·공휴일 +/// cal next → 다음 공휴일 D-day (5개) +/// cal workdays → 이번달 업무일 수·잔여 업무일 +/// cal today → 오늘 공휴일 여부 +/// cal 2026-05 → 특정 월 조회 +/// cal 2026-04-10 → 특정 날짜 조회 +/// Enter → 복사 +/// +public class CalHandler : IActionHandler +{ + public string? Prefix => "cal"; + + public PluginMetadata Metadata => new( + "한국 달력", + "공휴일·업무일 달력 — 이번달 · 다음 공휴일 · 업무일 계산", + "1.0", + "AX"); + + // ── 공휴일 데이터 ───────────────────────────────────────────────────────── + private static readonly Dictionary 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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var today = DateOnly.FromDateTime(DateTime.Today); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + BuildMonthItems(today.Year, today.Month, today, items); + return Task.FromResult>(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>(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>(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>(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>(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>(items); + } + + // 기본 — 이번달 + BuildMonthItems(today.Year, today.Month, today, items); + return Task.FromResult>(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 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; + } +} diff --git a/src/AxCopilot/Handlers/CalcHandler.cs b/src/AxCopilot/Handlers/CalcHandler.cs new file mode 100644 index 0000000..72c1bff --- /dev/null +++ b/src/AxCopilot/Handlers/CalcHandler.cs @@ -0,0 +1,288 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// 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 → 결과 복사. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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>(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>(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>(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>(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>(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> Result( + List 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>(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); +} diff --git a/src/AxCopilot/Handlers/CertHandler.cs b/src/AxCopilot/Handlers/CertHandler.cs new file mode 100644 index 0000000..2af3185 --- /dev/null +++ b/src/AxCopilot/Handlers/CertHandler.cs @@ -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; + +/// +/// L10-3: SSL/TLS 인증서 체커 핸들러. "cert" 프리픽스로 사용합니다. +/// +/// 예: cert google.com → google.com의 인증서 정보 조회 +/// cert github.com 443 → 포트 지정 +/// cert https://example.com → URL 형식도 지원 +/// Enter → 결과를 클립보드에 복사. +/// +/// ⚠ 외부 인터넷 접속 필요. 사내 모드에서는 내부 호스트만 조회 가능. +/// +public class CertHandler : IActionHandler +{ + public string? Prefix => "cert"; + + public PluginMetadata Metadata => new( + "Cert", + "SSL/TLS 인증서 체커 — 만료일 · 발급자 · SANs", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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>(items); + } + + items.Add(new LauncherItem( + $"{host}:{port} 인증서 조회", + "Enter를 눌러 조회하세요", + null, + ("check", $"{host}:{port}"), + Symbol: "\uE72E")); + + return Task.FromResult>(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 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(); + 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 Sans { get; init; } = new(); + public string Thumbprint { get; init; } = ""; + public string Status { get; init; } = ""; + public string StatusLine { get; init; } = ""; + } +} diff --git a/src/AxCopilot/Handlers/CleanHandler.cs b/src/AxCopilot/Handlers/CleanHandler.cs new file mode 100644 index 0000000..7b4fabe --- /dev/null +++ b/src/AxCopilot/Handlers/CleanHandler.cs @@ -0,0 +1,320 @@ +using System.IO; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L9-3: 시스템 정리 핸들러. "clean" 프리픽스로 사용합니다. +/// +/// 예: clean → 정리 가능한 항목 목록 + 예상 용량 +/// clean temp → Windows 임시 파일 정리 (%TEMP%) +/// clean recycle → 휴지통 비우기 +/// clean downloads → 다운로드 폴더 오래된 파일 목록 (30일 이상) +/// clean logs → 앱 로그 폴더 정리 (%APPDATA%\AxCopilot\logs) +/// clean all → temp + recycle + logs 한 번에 정리 +/// +public class CleanHandler : IActionHandler +{ + public string? Prefix => "clean"; + + public PluginMetadata Metadata => new( + "Clean", + "시스템 정리 — 임시 파일 · 휴지통 · 다운로드 · 로그", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim().ToLowerInvariant(); + var items = new List(); + + 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>(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>(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 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", + }; +} diff --git a/src/AxCopilot/Handlers/ContactHandler.cs b/src/AxCopilot/Handlers/ContactHandler.cs new file mode 100644 index 0000000..124260a --- /dev/null +++ b/src/AxCopilot/Handlers/ContactHandler.cs @@ -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; + +/// +/// L25-1: 로컬 연락처 관리. "contact" 프리픽스로 사용합니다. +/// +/// 예: contact → 전체 연락처 목록 +/// contact 홍길동 → 이름 검색 +/// contact add 홍길동 개발팀 010-1234-5678 hong@company.com → 추가 +/// contact del 홍길동 → 삭제 +/// contact <부서명> → 부서 필터 +/// Enter → 이메일 또는 전화번호 클립보드 복사 +/// 저장: %APPDATA%\AxCopilot\contacts.json +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + 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>(items); + } + + foreach (var c in contacts) + items.Add(MakeContactItem(c)); + + return Task.FromResult>(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>(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>(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>(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>(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>(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>(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 LoadContacts() + { + try + { + if (!System.IO.File.Exists(DataPath)) return new List(); + var json = System.IO.File.ReadAllText(DataPath, System.Text.Encoding.UTF8); + return JsonSerializer.Deserialize>(json, JsonOpts) ?? new List(); + } + catch { return new List(); } + } + + private static void SaveContacts(List 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(); + + 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 { } + } +} diff --git a/src/AxCopilot/Handlers/ContextHandler.cs b/src/AxCopilot/Handlers/ContextHandler.cs new file mode 100644 index 0000000..3ed3a87 --- /dev/null +++ b/src/AxCopilot/Handlers/ContextHandler.cs @@ -0,0 +1,193 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L6-3: 컨텍스트 감지 자동완성 핸들러. "ctx" 프리픽스로 사용합니다. +/// +/// 현재 포커스된 앱을 감지하여 상황별 런처 명령을 제안합니다. +/// 예: ctx → 현재 Chrome 사용 중이면 북마크/웹 검색 명령 추천 +/// 현재 VS Code 사용 중이면 파일/스니펫/git 명령 추천 +/// +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 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> GetItemsAsync(string query, CancellationToken ct) + { + var (procName, appDisplayName, suggestions) = GetContextInfo(); + + var items = new List(); + + 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>(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() + .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 + { + 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()); + } +} diff --git a/src/AxCopilot/Handlers/CronHandler.cs b/src/AxCopilot/Handlers/CronHandler.cs new file mode 100644 index 0000000..210b49e --- /dev/null +++ b/src/AxCopilot/Handlers/CronHandler.cs @@ -0,0 +1,340 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// 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) +/// +public class CronHandler : IActionHandler +{ + public string? Prefix => "cron"; + + public PluginMetadata Metadata => new( + "Cron", + "Cron 표현식 설명기 — 다음 실행 시간 · 한국어 설명", + "1.0", + "AX"); + + // 특수 키워드 + private static readonly Dictionary 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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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>(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 GetNextRuns(CronExpr cron, DateTime from, int count) + { + var results = new List(); + // 다음 분부터 시작 + 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(); + + // 분 + 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}일 후"; + } +} diff --git a/src/AxCopilot/Handlers/CsvHandler.cs b/src/AxCopilot/Handlers/CsvHandler.cs new file mode 100644 index 0000000..1df44d9 --- /dev/null +++ b/src/AxCopilot/Handlers/CsvHandler.cs @@ -0,0 +1,338 @@ +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L11-1: CSV 뷰어·파서 핸들러. "csv" 프리픽스로 사용합니다. +/// +/// 예: csv → 클립보드 CSV 파싱 (헤더·행수·컬럼수) +/// csv col 2 → 2번째 컬럼 값 목록 추출 +/// csv row 3 → 3번째 행 출력 +/// csv stats → 숫자 컬럼 합계·평균·최대·최소 +/// csv head → 헤더 컬럼명 목록 +/// csv tsv → CSV → TSV(탭 구분) 변환 +/// Enter → 결과를 클립보드에 복사. +/// +public class CsvHandler : IActionHandler +{ + public string? Prefix => "csv"; + + public PluginMetadata Metadata => new( + "CSV", + "CSV 뷰어·파서 — 컬럼 추출 · 통계 · 형식 변환", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(items); + } + + var rows = ParseCsv(clip); + if (rows.Count == 0) + { + items.Add(new LauncherItem("파싱 실패", "유효한 CSV가 아닙니다", null, null, Symbol: "\uE783")); + return Task.FromResult>(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>(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 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 ExtractColumn(List> 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 ExtractRow(List> 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 BuildStatsItems(List> 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> 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> ParseCsv(string text) + { + var result = new List>(); + // 구분자 자동 감지 (탭 또는 쉼표) + 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 ParseCsvLine(string line, char delim) + { + var fields = new List(); + 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 ""; } + } +} diff --git a/src/AxCopilot/Handlers/CurrencyHandler.cs b/src/AxCopilot/Handlers/CurrencyHandler.cs new file mode 100644 index 0000000..ffc6ff3 --- /dev/null +++ b/src/AxCopilot/Handlers/CurrencyHandler.cs @@ -0,0 +1,213 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// 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 → 결과를 클립보드에 복사. +/// +/// 내장 기준환율 사용 (사내 모드). 사외 모드에서는 동일하게 동작. +/// +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 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 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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(items); + } + + // 금액 파싱 + if (!TryParseAmount(parts[0], out var amount)) + { + items.Add(new LauncherItem("금액 형식 오류", + "예: currency 100 usd", null, null, Symbol: "\uE783")); + return Task.FromResult>(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>(items); + } + + if (!Rates.TryGetValue(fromCode, out var fromInfo)) + { + items.Add(new LauncherItem("지원하지 않는 통화", fromCode, null, null, Symbol: "\uE783")); + return Task.FromResult>(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>(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>(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}"; + } +} diff --git a/src/AxCopilot/Handlers/DictHandler.cs b/src/AxCopilot/Handlers/DictHandler.cs new file mode 100644 index 0000000..d6ec6c4 --- /dev/null +++ b/src/AxCopilot/Handlers/DictHandler.cs @@ -0,0 +1,201 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; + +namespace AxCopilot.Handlers; + +/// +/// L29-1: 오프라인 국어사전 핸들러. "dict" 프리픽스로 사용합니다. +/// +/// 예: dict → 사용법 안내 +/// dict 부럽다 → "부럽다" 검색 (표제어·뜻풀이) +/// dict en hello → 영한 사전 검색 +/// dict 유의어 돕다 → 유의어 검색 +/// Enter → 뜻풀이 클립보드 복사. +/// 내장 데이터 기반 (인터넷 불필요). 혼동어·유의어·반의어 중심. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem("국어·영한 사전", + $"국어 {KorDict.Length}개 · 영한 {EngDict.Length}개 · dict {{단어}} / dict en {{word}}", + null, null, Symbol: "\uE82D")); + return Task.FromResult>(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>(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>(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>(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; + } +} diff --git a/src/AxCopilot/Handlers/DnsQueryHandler.cs b/src/AxCopilot/Handlers/DnsQueryHandler.cs new file mode 100644 index 0000000..8b6abef --- /dev/null +++ b/src/AxCopilot/Handlers/DnsQueryHandler.cs @@ -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; + +/// +/// 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 → 결과를 클립보드에 복사. +/// +/// ⚠ 사내 모드: 내부 호스트만 조회 허용. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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>(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> 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> 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> 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}"]; + } + } + + /// MX/TXT/NS/CNAME: nslookup 프로세스 실행으로 조회 + private static async Task> 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 ParseNslookupOutput(string output, string type) + { + var results = new List(); + 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; + } +} diff --git a/src/AxCopilot/Handlers/DockerHandler.cs b/src/AxCopilot/Handlers/DockerHandler.cs new file mode 100644 index 0000000..1aed989 --- /dev/null +++ b/src/AxCopilot/Handlers/DockerHandler.cs @@ -0,0 +1,375 @@ +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L16-2: Docker 컨테이너·이미지 조회 핸들러. "docker" 프리픽스로 사용합니다. +/// +/// 예: docker → 실행 중 컨테이너 목록 +/// docker all → 모든 컨테이너 (중지 포함) +/// docker images → 로컬 이미지 목록 +/// docker ps → 컨테이너 목록 (docker ps 동일) +/// docker stop → 컨테이너 중지 +/// docker start → 컨테이너 시작 +/// docker logs → 컨테이너 로그 (터미널) +/// docker shell → 컨테이너 shell 접속 +/// Enter → 명령 실행 또는 컨테이너 ID 복사. +/// +public class DockerHandler : IActionHandler +{ + public string? Prefix => "docker"; + + public PluginMetadata Metadata => new( + "Docker", + "Docker 컨테이너·이미지 조회 — 시작·중지·로그·쉘", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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>(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 GetContainers(bool running = true, bool stopped = false, bool all = false) + { + var result = new List(); + 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 GetImages() + { + var result = new List(); + 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 == "" ? 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 { /* 비핵심 */ } + } +} diff --git a/src/AxCopilot/Handlers/DriveHandler.cs b/src/AxCopilot/Handlers/DriveHandler.cs new file mode 100644 index 0000000..e639ad7 --- /dev/null +++ b/src/AxCopilot/Handlers/DriveHandler.cs @@ -0,0 +1,202 @@ +using System.IO; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L13-3: 드라이브 정보 핸들러. "drive" 프리픽스로 사용합니다. +/// +/// 예: drive → 전체 드라이브 목록 + 용량 요약 +/// drive C → C 드라이브 상세 정보 +/// drive C:\ → 경로 형식도 지원 +/// drive large → 사용량 많은 순서로 정렬 +/// Enter → 드라이브 정보를 클립보드에 복사. +/// +public class DriveHandler : IActionHandler +{ + public string? Prefix => "drive"; + + public PluginMetadata Metadata => new( + "Drive", + "드라이브 정보 — 용량 · 파일시스템 · 여유공간", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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>(items); + } + + items.AddRange(BuildDetailItems(target)); + 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("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 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 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", + }; +} diff --git a/src/AxCopilot/Handlers/EventLogHandler.cs b/src/AxCopilot/Handlers/EventLogHandler.cs new file mode 100644 index 0000000..efece55 --- /dev/null +++ b/src/AxCopilot/Handlers/EventLogHandler.cs @@ -0,0 +1,157 @@ +using System.Diagnostics; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L8-3: 시스템 이벤트 로그 핸들러. "evt" 프리픽스로 사용합니다. +/// +/// 예: evt → 최근 오류/경고 이벤트 (System + Application 합산) +/// evt error → 오류(Error) 이벤트만 +/// evt warn → 경고(Warning) 이벤트만 +/// evt app → Application 로그 +/// evt sys → System 로그 +/// evt <키워드> → 소스 또는 메시지에 키워드 포함된 이벤트 +/// Enter → 이벤트 내용을 클립보드에 복사. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim().ToLowerInvariant(); + var items = new List(); + + 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(); + 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>(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>(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} + """; +} diff --git a/src/AxCopilot/Handlers/FileBrowserHandler.cs b/src/AxCopilot/Handlers/FileBrowserHandler.cs new file mode 100644 index 0000000..d3485d0 --- /dev/null +++ b/src/AxCopilot/Handlers/FileBrowserHandler.cs @@ -0,0 +1,186 @@ +using System.IO; +using System.Text.RegularExpressions; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L4-1: 인라인 파일 탐색기 핸들러. +/// 입력이 파일시스템 경로처럼 보이면 (예: C:\, D:\Users\) 해당 폴더의 내용을 런처 목록으로 표시합니다. +/// → 키로 하위 폴더 진입, ← 키 또는 Backspace로 상위 폴더 이동. +/// prefix=null: 일반 쿼리 파이프라인에서 경로 감지 후 동작. +/// +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); + + /// 쿼리가 파일시스템 경로처럼 보이는지 빠르게 판별합니다. + public static bool IsPathQuery(string query) + => !string.IsNullOrEmpty(query) && PathPattern.IsMatch(query.Trim()); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = ExpandPath(query.Trim()); + + // 경로가 아닌 쿼리는 빈 결과 반환 (다른 핸들러가 처리) + if (!IsPathQuery(query.Trim())) + return Task.FromResult>(Array.Empty()); + + // 입력이 존재하는 디렉터리이면 그 내용 표시 + 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>(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 ListDirectory(string dir, string filter = "") + { + var items = new List(); + + // 상위 폴더 항목 (루트가 아닐 때) + 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", + }; +} + +/// 파일 탐색기 핸들러에서 사용하는 항목 데이터 +public record FileBrowserEntry(string Path, bool IsFolder); diff --git a/src/AxCopilot/Handlers/FileHashHandler.cs b/src/AxCopilot/Handlers/FileHashHandler.cs new file mode 100644 index 0000000..8761435 --- /dev/null +++ b/src/AxCopilot/Handlers/FileHashHandler.cs @@ -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; + +/// +/// 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 → 해시 결과를 클립보드에 복사. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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 " — 클립보드 해시 비교 + 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 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]; +} diff --git a/src/AxCopilot/Handlers/FixHandler.cs b/src/AxCopilot/Handlers/FixHandler.cs new file mode 100644 index 0000000..f202fc9 --- /dev/null +++ b/src/AxCopilot/Handlers/FixHandler.cs @@ -0,0 +1,539 @@ +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; + +namespace AxCopilot.Handlers; + +/// +/// L23-4: 한/영 타이핑 오류 교정. "fix" 프리픽스로 사용합니다. +/// +/// 예: fix gksrmf → 안녕 (영타→한글 변환) +/// fix → 클립보드 텍스트 자동 교정 +/// Enter → 교정 결과 클립보드 복사 +/// +public class FixHandler : IActionHandler +{ + public string? Prefix => "fix"; + + public PluginMetadata Metadata => new( + "타이핑 교정", + "영타→한글 변환 — 잘못 입력된 영문 타이핑을 한글로 교정", + "1.0", + "AX"); + + // ── 두벌식 영→자모 매핑 ────────────────────────────────────────────────── + + // 소문자 + private static readonly Dictionary 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 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(); + 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(); + 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 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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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; + } +} diff --git a/src/AxCopilot/Handlers/FlowHandler.cs b/src/AxCopilot/Handlers/FlowHandler.cs new file mode 100644 index 0000000..b83b3b6 --- /dev/null +++ b/src/AxCopilot/Handlers/FlowHandler.cs @@ -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; + +/// +/// L29-2: 명령 체인(워크플로우) 핸들러. "flow" 프리픽스로 사용합니다. +/// +/// 예: flow → 등록된 플로우 목록 +/// flow add 출근준비 "remind 09:00 회의" > "today" > "todo list" → 플로우 추가 +/// flow 출근준비 → 플로우 실행 +/// flow del 출근준비 → 플로우 삭제 +/// flow edit 출근준비 → 플로우 명령 목록 표시 (클립보드 복사) +/// Enter → 저장된 명령들을 순서대로 런처에 실행 (클립보드에 명령 목록 복사). +/// Alfred 워크플로우 경량 대응. +/// 저장: %APPDATA%\AxCopilot\flows.json +/// +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 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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + 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>(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>(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>(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>(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>(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>(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>(items); + } + + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + if (item.Data is ("add", string name, List 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 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 Load() + { + try + { + if (!File.Exists(DataPath)) return []; + var json = File.ReadAllText(DataPath); + return JsonSerializer.Deserialize>(json) ?? []; + } + catch { return []; } + } + + private static void Save(List list) + { + try + { + var dir = Path.GetDirectoryName(DataPath)!; + if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); + File.WriteAllText(DataPath, JsonSerializer.Serialize(list, JsonOpt)); + } + catch { } + } +} diff --git a/src/AxCopilot/Handlers/FontHandler.cs b/src/AxCopilot/Handlers/FontHandler.cs new file mode 100644 index 0000000..4db0d8a --- /dev/null +++ b/src/AxCopilot/Handlers/FontHandler.cs @@ -0,0 +1,136 @@ +using System.Windows; +using System.Windows.Media; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L14-4: 시스템 폰트 목록·검색 핸들러. "font" 프리픽스로 사용합니다. +/// +/// 예: font → 설치된 폰트 전체 목록 +/// font 맑은 → "맑은" 포함 폰트 검색 +/// font malgun → 영문 이름으로 검색 +/// font mono → "mono" 포함 폰트 목록 +/// font nanum → 나눔 폰트 목록 +/// Enter → 폰트 이름을 클립보드에 복사. +/// +public class FontHandler : IActionHandler +{ + public string? Prefix => "font"; + + public PluginMetadata Metadata => new( + "Font", + "시스템 폰트 목록 — 검색 · 이름 복사", + "1.0", + "AX"); + + // 폰트 목록 캐시 (최초 1회 로드) + private static List? _fontCache; + private static readonly object _lock = new(); + + private static List 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(); + } + return _fontCache; + } + } + + // 주목할 만한 폰트 그룹 키워드 + private static readonly (string Label, string Keyword)[] FontGroups = + [ + ("한글 폰트", "malgun"), + ("나눔 폰트", "nanum"), + ("코딩용 폰트", "mono"), + ("Arial 계열", "arial"), + ("Times 계열", "times"), + ("Consolas", "consolas"), + ]; + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + 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>(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>(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"); +} diff --git a/src/AxCopilot/Handlers/FormHandler.cs b/src/AxCopilot/Handlers/FormHandler.cs new file mode 100644 index 0000000..9ea2f1f --- /dev/null +++ b/src/AxCopilot/Handlers/FormHandler.cs @@ -0,0 +1,1113 @@ +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; + +namespace AxCopilot.Handlers; + +/// +/// L22-4: 업무 양식·문서 구조 템플릿 핸들러. "form" 프리픽스로 사용합니다. +/// +/// 예: form → 전체 양식 카테고리 +/// form meeting → 회의록 양식 +/// form report → 주간/월간 보고서 양식 +/// form email → 이메일 템플릿 (업무·사과·안내·요청) +/// form project → 프로젝트 계획서 양식 +/// form review → 코드리뷰·성과평가 양식 +/// form onboard → 온보딩 체크리스트 +/// form <검색어> → 양식 검색 +/// Enter → 양식 전체를 클립보드에 복사. +/// +public class FormHandler : IActionHandler +{ + public string? Prefix => "form"; + + public PluginMetadata Metadata => new( + "업무 양식", + "업무 문서 템플릿 — 회의록·보고서·이메일·프로젝트 양식", + "1.0", + "AX"); + + private sealed record FormTemplate( + string Name, + string Description, + string Category, + string[] Tags, + Func Build); + + private static readonly FormTemplate[] Templates = + [ + // ── 회의록 (meeting) ───────────────────────────────────────────────── + new("회의록 (기본)", + "날짜·참석자·안건·결정사항·액션아이템 포함 기본 회의록", + "meeting", ["meeting", "회의", "minutes", "미팅"], + () => BuildMeeting()), + + new("주간 팀 회의록", + "주간 스탠드업·스프린트 팀 회의에 적합한 형식", + "meeting", ["meeting", "weekly", "주간", "팀"], + () => BuildWeeklyMeeting()), + + // ── 보고서 (report) ────────────────────────────────────────────────── + new("주간 업무 보고서", + "이번 주 완료·진행·예정·이슈 항목 중심 보고서", + "report", ["report", "주간", "weekly", "보고"], + () => BuildWeeklyReport()), + + new("월간 업무 보고서", + "월간 성과·지표·이슈·다음 달 계획 포함", + "report", ["report", "월간", "monthly", "보고"], + () => BuildMonthlyReport()), + + new("업무 현황 보고서", + "프로젝트/업무 진행 상황 보고", + "report", ["report", "현황", "status"], + () => BuildStatusReport()), + + // ── 이메일 (email) ─────────────────────────────────────────────────── + new("업무 요청 이메일", + "협조 요청·업무 의뢰 이메일 기본 양식", + "email", ["email", "이메일", "mail", "요청"], + () => BuildRequestEmail()), + + new("사과·사안 처리 이메일", + "오류·지연 등 사안 발생 시 사과 및 대응 안내 이메일", + "email", ["email", "이메일", "사과", "sorry"], + () => BuildApologyEmail()), + + new("공지·안내 이메일", + "팀·전사 공지 및 안내 이메일 양식", + "email", ["email", "공지", "notice", "안내"], + () => BuildNoticeEmail()), + + // ── 프로젝트 (project) ─────────────────────────────────────────────── + new("프로젝트 계획서", + "목표·범위·일정·역할·리스크 포함 프로젝트 킥오프 문서", + "project", ["project", "프로젝트", "plan", "계획"], + () => BuildProjectPlan()), + + new("프로젝트 완료 보고서", + "결과·성과·교훈·후속조치 중심 완료 보고서", + "project", ["project", "완료", "close", "closure"], + () => BuildProjectClose()), + + // ── 리뷰·평가 (review) ────────────────────────────────────────────── + new("성과 자기평가서", + "반기·연간 성과평가 자기 서술 양식", + "review", ["review", "평가", "self", "성과"], + () => BuildSelfReview()), + + new("코드 리뷰 체크리스트", + "PR·코드리뷰 시 확인사항 체크리스트", + "review", ["review", "code", "코드", "pr", "checklist"], + () => BuildCodeReview()), + + // ── 온보딩 (onboard) ───────────────────────────────────────────────── + new("신규 입사자 온보딩 체크리스트", + "첫 1·2·4주 온보딩 단계별 체크리스트", + "onboard", ["onboard", "온보딩", "신입", "입사"], + () => BuildOnboard()), + + // ── 인수인계 (handover) ────────────────────────────────────────────── + new("업무 인수인계서", + "퇴직·이동 시 업무 인수인계 목록 및 주의사항", + "handover", ["handover", "인수인계", "transfer", "인계"], + () => BuildHandover()), + + new("업무 지시서", + "담당자·기한·우선순위 포함 업무 지시 양식", + "handover", ["handover", "지시", "task", "업무지시"], + () => BuildTaskOrder()), + + new("품의서", + "결재 요청을 위한 품의서 양식", + "handover", ["handover", "품의", "approval", "결재"], + () => BuildApproval()), + + // ── 일지 (daily) ──────────────────────────────────────────────────── + new("업무 일지", + "일별 업무 처리 현황 기록 양식", + "daily", ["daily", "일지", "업무일지", "log"], + () => BuildWorkLog()), + + new("일일 업무 보고", + "당일 완료·진행·내일 예정 업무 보고 양식", + "daily", ["daily", "일일보고", "daily report", "eod"], + () => BuildDailyReport()), + + new("주요 업무 계획표", + "월간·분기 주요 업무 계획 및 일정표", + "daily", ["daily", "계획표", "plan", "일정"], + () => BuildWorkPlan()), + ]; + + private static readonly (string Key, string[] Aliases, string Label)[] Categories = + [ + ("meeting", ["meeting", "회의", "미팅"], "회의록"), + ("report", ["report", "보고", "보고서"], "보고서"), + ("email", ["email", "이메일", "mail"], "이메일 템플릿"), + ("project", ["project", "프로젝트"], "프로젝트 문서"), + ("review", ["review", "리뷰", "평가"], "리뷰·평가"), + ("onboard", ["onboard", "온보딩", "입사"], "온보딩"), + ("handover", ["handover", "인수인계", "지시", "품의"], "인수인계·지시·품의"), + ("daily", ["daily", "일지", "일일", "계획"], "업무 일지·계획"), + ]; + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem("업무 양식 템플릿", + "카테고리: meeting · report · email · project · review · onboard · handover · daily", + null, null, Symbol: "\uE8A5")); + + foreach (var (key, _, label) in Categories) + { + var cnt = Templates.Count(t => t.Category == key); + items.Add(new LauncherItem($"form {key}", $"{label} ({cnt}개 양식)", + null, ("copy", $"form {key}"), Symbol: "\uE8A5")); + } + return Task.FromResult>(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 = Templates.Where(t => t.Category == cat.Key).ToList(); + items.Add(new LauncherItem($"{cat.Label} {list.Count}개", + "Enter: 양식 전체를 클립보드에 복사", null, null, Symbol: "\uE8A5")); + foreach (var t in list) items.Add(TmplItem(t)); + return Task.FromResult>(items); + } + + // 검색 + var searched = Templates.Where(t => + t.Name.Contains(kw, StringComparison.OrdinalIgnoreCase) || + t.Description.Contains(kw, StringComparison.OrdinalIgnoreCase) || + t.Tags.Any(tag => tag.Contains(kw, StringComparison.OrdinalIgnoreCase))).ToList(); + + if (searched.Count == 0) + { + items.Add(new LauncherItem($"'{q}' 양식을 찾을 수 없습니다", + "카테고리: meeting · report · email · project · review · onboard · handover · daily", + null, null, Symbol: "\uE783")); + return Task.FromResult>(items); + } + + items.Add(new LauncherItem($"'{q}' 검색 결과 {searched.Count}개", + "Enter: 양식 전체를 클립보드에 복사", null, null, Symbol: "\uE8A5")); + foreach (var t in searched) items.Add(TmplItem(t)); + + 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("업무 양식", "양식을 클립보드에 복사했습니다."); + } + catch { } + } + return Task.CompletedTask; + } + + private static LauncherItem TmplItem(FormTemplate t) => + new(t.Name, t.Description, null, ("copy", t.Build()), Symbol: "\uE8A5"); + + // ── 양식 빌더 ──────────────────────────────────────────────────────────── + + private static string BuildMeeting() + { + var today = DateTime.Today.ToString("yyyy-MM-dd"); + return +$""" +■ 회의록 + +▸ 일시: {today} ( : ~ : ) +▸ 장소: +▸ 참석자: +▸ 작성자: + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +▸ 안건 + +1. +2. +3. + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +▸ 논의 내용 + +▸ 안건 1. + - + - + +▸ 안건 2. + - + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +▸ 결정사항 + +1. +2. + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +▸ 액션아이템 (Action Items) + +담당자 | 내용 | 기한 +───────────────────────────────────────── + | | + | | + +▸ 다음 회의: +"""; + } + + private static string BuildWeeklyMeeting() + { + var today = DateTime.Today.ToString("yyyy-MM-dd"); + return +$""" +■ 주간 팀 회의록 — {today} + +▸ 참석: + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +▸ 지난 주 회고 (Done) + - 완료: + - 미완료 및 이유: + +▸ 이번 주 목표 (Plan) + - + - + +▸ 이슈 / 블로킹 사항 + - + +▸ 공유 사항 + - + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +▸ 액션아이템 + +담당자 | 내용 | 기한 +───────────────────────────────────────── + | | +"""; + } + + private static string BuildWeeklyReport() + { + var today = DateTime.Today; + var mon = today.AddDays(-(int)today.DayOfWeek + 1); + var fri = mon.AddDays(4); + return +$""" +■ 주간 업무 보고서 + +▸ 기간: {mon:yyyy-MM-dd} ~ {fri:yyyy-MM-dd} +▸ 보고자: +▸ 소속: + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +1. 이번 주 완료 업무 + + □ + □ + □ + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +2. 진행 중 업무 (진척률) + + □ 업무명 진척: 0% + - 현황: + + □ 업무명 진척: 0% + - 현황: + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +3. 다음 주 예정 업무 + + □ + □ + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +4. 이슈 / 요청 사항 + + - +"""; + } + + private static string BuildMonthlyReport() + { + var today = DateTime.Today; + return +$""" +■ 월간 업무 보고서 + +▸ 기간: {today.Year}년 {today.Month}월 +▸ 보고자: +▸ 소속: + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +1. 이달의 주요 성과 + + · + · + · + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +2. 핵심 지표 (KPI) + + 항목 | 목표 | 실적 | 달성률 + ──────────────────────────────────────────────── + | | | + | | | + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +3. 이슈 및 리스크 + + · 이슈: + · 대응: + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +4. 다음 달 계획 + + · + · + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +5. 지원 요청 + + · +"""; + } + + private static string BuildStatusReport() + { + var today = DateTime.Today.ToString("yyyy-MM-dd"); + return +$""" +■ 업무 현황 보고서 + +▸ 기준일: {today} +▸ 보고자: +▸ 프로젝트/업무명: + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +▸ 전체 진행률: [ ] 0% + +▸ 단계별 현황 + + 단계 | 시작일 | 완료예정 | 상태 + ──────────────────────────────────────────────── + 계획 | | | 완료 + 개발/실행 | | | 진행중 + 검토 | | | 예정 + 완료 | | | 예정 + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +▸ 주요 완료 항목 + - + +▸ 현재 진행 항목 + - + +▸ 이슈 / 리스크 + - 이슈: + - 영향도: 상 / 중 / 하 + - 대응방안: + +▸ 다음 주요 마일스톤 + - 목표: 기한: +"""; + } + + private static string BuildRequestEmail() + { + var today = DateTime.Today.ToString("yyyy년 MM월 dd일"); + return +$""" +수신: +참조: +제목: [협조 요청] + +안녕하십니까, [발신자 소속/이름]입니다. + +[인사말 또는 감사 인사] + +다름이 아니라, [요청 배경 및 목적]과 관련하여 아래와 같이 협조를 요청드립니다. + +───────────────────────────────────── +▸ 요청 내용 + +1. +2. + +▸ 요청 기한: {today} 기준 __ 영업일 이내 +▸ 담당자: +▸ 관련 자료: (별첨 참조) +───────────────────────────────────── + +바쁘신 중에 협조 부탁드리며, 문의사항은 언제든지 연락 주시기 바랍니다. + +감사합니다. + +[이름] 드림 +[소속] | [연락처] | [이메일] +"""; + } + + private static string BuildApologyEmail() + { + var today = DateTime.Today.ToString("yyyy년 MM월 dd일"); + return +$""" +수신: +참조: +제목: [사과] + +안녕하십니까, [발신자 소속/이름]입니다. + +{today} [사안 명칭]과 관련하여 [불편·피해] 사항이 발생한 것에 대해 +진심으로 사과의 말씀을 드립니다. + +───────────────────────────────────── +▸ 발생 경위 + + [사안 발생 일시·상황 간략 설명] + +▸ 영향 범위 + + [영향 받은 대상·범위] + +▸ 원인 분석 + + [근본 원인] + +▸ 즉시 조치 내용 + + · + · + +▸ 재발 방지 대책 + + · + · +───────────────────────────────────── + +다시 한번 불편을 드린 점 진심으로 사과드리며, +추가 문의사항은 아래 연락처로 알려 주시면 신속히 처리하겠습니다. + +[이름] 드림 +[소속] | [연락처] | [이메일] +"""; + } + + private static string BuildNoticeEmail() + { + var today = DateTime.Today.ToString("yyyy년 MM월 dd일"); + return +$""" +수신: 전체 / 해당팀 +참조: +제목: [공지] + +안녕하십니까. + +[소속/담당자]에서 아래와 같이 안내드립니다. + +───────────────────────────────────── +▸ 공지 제목: +▸ 적용 일시: {today} 시 분 ~ +▸ 대상: + +▸ 주요 내용 + +1. +2. +3. + +▸ 참고 사항 + + - +───────────────────────────────────── + +문의사항은 [담당자명] ([연락처])으로 연락 부탁드립니다. + +감사합니다. +[소속] [발신자명] 드림 +"""; + } + + private static string BuildProjectPlan() + { + var today = DateTime.Today.ToString("yyyy-MM-dd"); + return +$""" +■ 프로젝트 계획서 + +▸ 프로젝트명: +▸ 작성일: {today} +▸ PM / 담당팀: +▸ 문서 버전: v0.1 + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +1. 배경 및 목적 + + [프로젝트 추진 배경, 해결하려는 문제, 기대 효과] + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +2. 범위 (Scope) + + ▸ 포함 (In-Scope) + - + - + + ▸ 제외 (Out-of-Scope) + - + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +3. 주요 일정 (Milestone) + + 마일스톤 | 목표일 | 담당 + ────────────────────────────────────────── + 킥오프 | {today} | + 요구사항 확정 | | + 개발 완료 | | + 테스트 완료 | | + 오픈 / 완료 | | + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +4. 역할 및 담당자 (RACI) + + 역할 | 담당자 | 책임 범위 + ────────────────────────────────────────────── + PM | | + 개발 | | + QA | | + 이해관계자 | | + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +5. 리스크 관리 + + 리스크 | 영향도 | 대응방안 + ────────────────────────────────────── + | 상/중/하| + | 상/중/하| + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +6. 성공 기준 (Success Criteria) + + · + · +"""; + } + + private static string BuildProjectClose() + { + var today = DateTime.Today.ToString("yyyy-MM-dd"); + return +$""" +■ 프로젝트 완료 보고서 + +▸ 프로젝트명: +▸ 완료일: {today} +▸ PM: + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +1. 최종 결과 요약 + + [1~2문장으로 프로젝트 완료 내용 요약] + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +2. 성과 vs 목표 + + 항목 | 목표 | 실적 | 비고 + ──────────────────────────────────────────────── + 일정 | | | + 예산 | | | + 품질 기준 | | | + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +3. 주요 교훈 (Lessons Learned) + + ▸ 잘 된 점: + - + + ▸ 개선이 필요한 점: + - + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +4. 후속 조치 + + 항목 | 담당자 | 기한 + ────────────────────────────────────── + | | +"""; + } + + private static string BuildSelfReview() + { + var today = DateTime.Today; + return +$""" +■ 성과 자기평가서 + +▸ 평가 기간: {today.Year}년 상반기 / 하반기 / 연간 +▸ 성명: +▸ 소속·직책: +▸ 작성일: {today:yyyy-MM-dd} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +1. 기간 내 주요 업무 및 성과 + + ▸ 업무/프로젝트명: + - 내용: + - 기여도: + - 정량적 성과 (수치 포함): + + ▸ 업무/프로젝트명: + - 내용: + - 기여도: + - 정량적 성과: + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +2. 역량 발전 사항 + + ▸ 향상된 기술·역량: + - + + ▸ 이수한 교육·자격: + - + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +3. 미달 사항 및 원인·개선 계획 + + ▸ 미달 항목: + - 원인: + - 개선 방향: + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +4. 차기 목표 + + ▸ 업무 목표: + - + + ▸ 역량 개발 계획: + - + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +5. 지원 요청 사항 + + - +"""; + } + + private static string BuildCodeReview() + { + return +""" +■ 코드 리뷰 체크리스트 + +▸ PR 번호: # +▸ 리뷰어: +▸ 작성자: +▸ 리뷰 일자: + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +▸ 기능·로직 + + [ ] 요구사항을 올바르게 구현했는가 + [ ] 엣지 케이스를 처리했는가 + [ ] 버그 또는 논리적 오류가 없는가 + [ ] 비즈니스 규칙을 준수했는가 + +▸ 코드 품질 + + [ ] 변수/함수명이 의미를 명확히 전달하는가 + [ ] 코드 중복이 없는가 (DRY 원칙) + [ ] 단일 책임 원칙을 지키는가 + [ ] 매직 넘버/문자열을 상수로 분리했는가 + +▸ 성능·보안 + + [ ] 불필요한 DB·API 호출이 없는가 + [ ] 민감 정보가 코드에 노출되지 않는가 + [ ] SQL 인젝션·XSS 등 취약점이 없는가 + [ ] 메모리 누수 위험 요소가 없는가 + +▸ 테스트 + + [ ] 핵심 로직의 단위 테스트가 있는가 + [ ] 테스트 커버리지가 충분한가 + [ ] 기존 테스트가 모두 통과하는가 + +▸ 문서·주석 + + [ ] 복잡한 로직에 주석이 있는가 + [ ] API 문서가 업데이트되었는가 + [ ] README 또는 CHANGELOG가 갱신되었는가 + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +▸ 코멘트 + + - + - + +▸ 결론: ☐ 승인 ☐ 수정 요청 ☐ 추가 논의 필요 +"""; + } + + private static string BuildOnboard() + { + return +""" +■ 신규 입사자 온보딩 체크리스트 + +▸ 입사자: +▸ 소속·직책: +▸ 입사일: +▸ 담당 멘토: + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +▸ 입사 첫날 (D-Day) + + [ ] 사원증·출입 카드 수령 + [ ] PC / 업무 장비 수령 + [ ] 사내 계정 생성 (이메일·그룹웨어·VPN) + [ ] 팀 소개 및 자리 배치 + [ ] 보안 서약서 서명 + [ ] 사내 규정·복무 안내 확인 + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +▸ 첫째 주 (1~5일) + + [ ] 팀 업무·프로세스 소개 + [ ] 주요 시스템·툴 접근 권한 설정 + [ ] 관련 부서 담당자 소개 + [ ] 업무 목표 초안 협의 + [ ] 사내 교육 이수 (필수 과정) + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +▸ 첫째 달 (2~4주) + + [ ] 주요 프로젝트·업무 파악 + [ ] 첫 독립 업무 수행 + [ ] 1차 멘토·팀장 면담 + [ ] 개인 업무 목표 확정 + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +▸ 메모 + + - +"""; + } + + private static string BuildHandover() + { + var today = DateTime.Today; + return $""" +업무 인수인계서 + +인계자: ___________ 인수자: ___________ +인계일: {today:yyyy년 MM월 dd일} 완료 예정일: ___________ +부서: ___________ + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +1. 담당 업무 목록 + + 번호 | 업무명 | 진행상태 | 담당기간 | 비고 + -----|--------|----------|----------|---- + 1 | | | | + 2 | | | | + 3 | | | | + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +2. 진행 중인 업무 상세 + + [업무명] + - 현재 진행 단계: + - 다음 처리 내용: + - 관련 담당자: + - 위치/경로: (파일/시스템/폴더) + - 주의사항: + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +3. 관련 시스템·계정 + + 시스템명 | ID | 비고 + ---------|----|---- + | | + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +4. 주요 연락처 + + 이름 | 소속 | 연락처 | 관계 + -----|------|--------|---- + | | | + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +5. 인수인계 특이사항 및 요청사항 + + - + +인계자 서명: ___________ 인수자 서명: ___________ +"""; + } + + private static string BuildTaskOrder() + { + var today = DateTime.Today; + return $""" +업무 지시서 + +지시일: {today:yyyy년 MM월 dd일} +지시자: ___________ 수신자: ___________ + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +■ 업무 개요 + + 업무명: + 우선순위: □ 긴급 □ 높음 □ 보통 □ 낮음 + 완료 기한: + 관련 프로젝트: + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +■ 업무 내용 및 지시 사항 + + 1. + 2. + 3. + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +■ 세부 요구사항 + + - + - + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +■ 산출물·보고 방법 + + 산출물: + 보고 방법: □ 구두 □ 이메일 □ 문서 □ 시스템 등록 + 보고 기한: + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +■ 참고 자료 + + - + +수신 확인: ___________ 확인일: ___________ +"""; + } + + private static string BuildApproval() + { + var today = DateTime.Today; + return $""" +품 의 서 + +기안일: {today:yyyy년 MM월 dd일} +기안자: ___________ 부서: ___________ + + 결재 | 담당 | 팀장 | 부장 | 이사 + -----|------|------|------|---- + | | | | + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +■ 제목: + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +■ 목적 및 배경 + + - + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +■ 추진 내용 + + 1. + 2. + 3. + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +■ 예산 현황 (해당 시) + + 항목 | 금액 | 비고 + -----|------|---- + | | + 합계 | | + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +■ 일정 + + 착수: ___________ 완료: ___________ + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +■ 기대 효과 + + - + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +■ 첨부 자료 + + □ 없음 □ 견적서 □ 계획서 □ 기타: ___________ +"""; + } + + private static string BuildWorkLog() + { + var today = DateTime.Today; + var dow = today.DayOfWeek switch { + DayOfWeek.Monday => "월", DayOfWeek.Tuesday => "화", + DayOfWeek.Wednesday => "수", DayOfWeek.Thursday => "목", + DayOfWeek.Friday => "금", DayOfWeek.Saturday => "토", _ => "일" + }; + return $""" +업무 일지 + +날짜: {today:yyyy년 MM월 dd일} ({dow}요일) 작성자: ___________ + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +■ 오늘 처리한 업무 + + 번호 | 업무 내용 | 소요 시간 | 완료 여부 + -----|-----------|-----------|---------- + 1 | | | □ + 2 | | | □ + 3 | | | □ + 4 | | | □ + 5 | | | □ + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +■ 미완료·이월 업무 + + - + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +■ 협의·회의 사항 + + - + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +■ 특이사항 / 이슈 + + - + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +■ 내일 예정 업무 + + 1. + 2. + 3. +"""; + } + + private static string BuildDailyReport() + { + var today = DateTime.Today; + return $""" +일일 업무 보고 + +날짜: {today:yyyy년 MM월 dd일} 보고자: ___________ 팀: ___________ + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +■ 금일 완료 업무 + + 1. + 2. + 3. + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +■ 진행 중 업무 + + 업무명 | 진행률 | 완료 예정일 | 비고 + -------|--------|-------------|---- + | % | | + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +■ 내일 예정 업무 + + 1. + 2. + 3. + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +■ 이슈 / 협조 요청 + + □ 없음 + - + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +■ 기타 공유 사항 + + - +"""; + } + + private static string BuildWorkPlan() + { + var today = DateTime.Today; + var firstDay = new DateTime(today.Year, today.Month, 1); + return $""" +주요 업무 계획표 + +기간: {today:yyyy년 MM월} 작성자: ___________ 부서: ___________ +작성일: {today:yyyy-MM-dd} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +■ 월간 주요 목표 + + 1. + 2. + 3. + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +■ 주차별 업무 계획 + + ▶ 1주차 ({firstDay:MM/dd} ~) + - + + ▶ 2주차 ({firstDay.AddDays(7):MM/dd} ~) + - + + ▶ 3주차 ({firstDay.AddDays(14):MM/dd} ~) + - + + ▶ 4주차 ({firstDay.AddDays(21):MM/dd} ~) + - + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +■ 프로젝트·업무별 세부 계획 + + 번호 | 업무명 | 담당자 | 시작일 | 완료 예정일 | 우선순위 | 진행률 + -----|--------|--------|--------|-------------|----------|------- + 1 | | | | | | % + 2 | | | | | | % + 3 | | | | | | % + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +■ 리스크 및 이슈 + + - + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +■ 협조 필요 사항 + + - +"""; + } +} diff --git a/src/AxCopilot/Handlers/GitHandler.cs b/src/AxCopilot/Handlers/GitHandler.cs new file mode 100644 index 0000000..5c0ea41 --- /dev/null +++ b/src/AxCopilot/Handlers/GitHandler.cs @@ -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; + +/// +/// 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 → 결과를 클립보드에 복사. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim().ToLowerInvariant(); + var items = new List(); + + // 작업 디렉토리 결정 + 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 ) + 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 { /* 비핵심 */ } + } + } + + // ── 헬퍼 ──────────────────────────────────────────────────────────────── + + /// 현재 앱 설정의 작업 폴더에서 .git root를 찾습니다. + 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; + } + + /// git 명령을 비동기로 실행하고 출력을 반환합니다. + private static async Task 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}"; + } + } + + /// 긴 출력을 maxLines줄로 자릅니다. + 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}줄)"; + } +} diff --git a/src/AxCopilot/Handlers/GitignoreHandler.cs b/src/AxCopilot/Handlers/GitignoreHandler.cs new file mode 100644 index 0000000..049b276 --- /dev/null +++ b/src/AxCopilot/Handlers/GitignoreHandler.cs @@ -0,0 +1,536 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// 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 내용을 클립보드에 복사. +/// +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 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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(items); + } + + // 여러 키워드 → 병합 + var keywords = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var matched = new List(); // 템플릿 키 목록 + + 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>(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>(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; + } +} diff --git a/src/AxCopilot/Handlers/HexHandler.cs b/src/AxCopilot/Handlers/HexHandler.cs new file mode 100644 index 0000000..865c43f --- /dev/null +++ b/src/AxCopilot/Handlers/HexHandler.cs @@ -0,0 +1,315 @@ +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// 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바이트 크기 단위 표시 (KB·MB·GB) +/// Enter → 결과 복사. +/// +public class HexHandler : IActionHandler +{ + public string? Prefix => "hex"; + + public PluginMetadata Metadata => new( + "Hex", + "16진수·바이트 변환기 — 텍스트↔hex·덤프·비트연산·크기 단위", + "1.0", + "AX"); + + 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("16진수·바이트 변환기", + "hex <텍스트> / hex / hex dump / hex 0xFF / hex bytes ", + null, null, Symbol: "\uE8EF")); + if (!string.IsNullOrWhiteSpace(clipboard)) + items.AddRange(BuildFromText(clipboard!, brief: true)); + return Task.FromResult>(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>(items); + } + + // hex bytes + if (sub == "bytes" && parts.Length >= 2 && + long.TryParse(parts[1], out var byteCount)) + { + items.AddRange(BuildByteSize(byteCount)); + return Task.FromResult>(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>(items); + } + + // 단일 hex 값 (0xFF, 0xAB, FF, AB...) + if (TryParseHexValue(parts[0], out var hexVal)) + { + items.AddRange(BuildFromHexValue(hexVal, parts[0])); + return Task.FromResult>(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>(items); + } + + // 일반 텍스트 → hex 변환 + var input = string.Join(" ", parts); + items.AddRange(BuildFromText(input, brief: false)); + 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("Hex", "클립보드에 복사했습니다."); + } + catch { } + } + return Task.CompletedTask; + } + + // ── 빌더 ──────────────────────────────────────────────────────────────── + + private static IEnumerable 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 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 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 BuildDump(string text) + { + var bytes = Encoding.UTF8.GetBytes(text); + var lines = new List(); + 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 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 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"); +} diff --git a/src/AxCopilot/Handlers/HostsHandler.cs b/src/AxCopilot/Handlers/HostsHandler.cs new file mode 100644 index 0000000..6126f0e --- /dev/null +++ b/src/AxCopilot/Handlers/HostsHandler.cs @@ -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; + +/// +/// L12-2: Windows hosts 파일 관리 핸들러. "hosts" 프리픽스로 사용합니다. +/// +/// 예: hosts → 현재 hosts 파일 항목 목록 +/// hosts search dev → "dev" 포함 항목 필터 +/// hosts open → hosts 파일을 메모장으로 열기 +/// hosts copy → 전체 hosts 내용 클립보드 복사 +/// Enter → 해당 항목을 클립보드에 복사. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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 ReadHostsEntries() + { + var result = new List(); + 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); + } +} diff --git a/src/AxCopilot/Handlers/HotkeyHandler.cs b/src/AxCopilot/Handlers/HotkeyHandler.cs new file mode 100644 index 0000000..62a34bd --- /dev/null +++ b/src/AxCopilot/Handlers/HotkeyHandler.cs @@ -0,0 +1,155 @@ +using AxCopilot.Core; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L5-1: 전용 핫키 관리 핸들러. +/// 예: hotkey → 등록된 전용 핫키 목록 표시 +/// hotkey 1doc → 라벨 또는 대상에 "1doc" 포함 항목 필터 +/// Enter 시 해당 핫키 항목의 대상을 실행합니다. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var items = new List(); + var hotkeys = _settings?.Settings.CustomHotkeys ?? new List(); + + if (hotkeys.Count == 0) + { + items.Add(new LauncherItem( + "등록된 전용 핫키 없음", + "설정 → 전용 핫키 탭에서 항목별 글로벌 단축키를 등록하세요", + null, + "__open_settings__", + Symbol: "\uE713")); + return Task.FromResult>(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>(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; + } + + /// 전용 핫키 대상을 타입에 따라 실행합니다. + 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}"); + } + } +} diff --git a/src/AxCopilot/Handlers/HttpTesterHandler.cs b/src/AxCopilot/Handlers/HttpTesterHandler.cs new file mode 100644 index 0000000..3d5039e --- /dev/null +++ b/src/AxCopilot/Handlers/HttpTesterHandler.cs @@ -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; + +/// +/// 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.)는 허용. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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>(items); + } + + items.Add(new LauncherItem( + $"{method} {TruncateUrl(url)}", + "Enter를 눌러 요청 실행", + null, + ("request", $"{method}|{url}"), + Symbol: "\uE774")); + + return Task.FromResult>(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; +} diff --git a/src/AxCopilot/Handlers/IpInfoHandler.cs b/src/AxCopilot/Handlers/IpInfoHandler.cs new file mode 100644 index 0000000..ed77b7f --- /dev/null +++ b/src/AxCopilot/Handlers/IpInfoHandler.cs @@ -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; + +/// +/// 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 → 값 복사. +/// +public class IpInfoHandler : IActionHandler +{ + public string? Prefix => "ip"; + + public PluginMetadata Metadata => new( + "IP", + "IP 주소 유틸리티 — 분류·CIDR·이진·16진·정수 변환", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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 ", + null, null, Symbol: "\uE968")); + BuildLocalIpItems(items, brief: true); + return Task.FromResult>(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>(items); + } + + // ip range + 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>(items); + } + + // ip from + 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>(items); + } + + // ip bin/hex/int + 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>(items); + } + + // CIDR: 10.0.0.0/8 + if (parts[0].Contains('/')) + { + BuildCidrItems(items, parts[0]); + return Task.FromResult>(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>(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 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 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 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 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 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"); +} diff --git a/src/AxCopilot/Handlers/JwtHandler.cs b/src/AxCopilot/Handlers/JwtHandler.cs new file mode 100644 index 0000000..ec5089b --- /dev/null +++ b/src/AxCopilot/Handlers/JwtHandler.cs @@ -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; + +/// +/// L11-2: JWT 토큰 디코더 핸들러. "jwt" 프리픽스로 사용합니다. +/// +/// 예: jwt → 클립보드의 JWT 자동 분석 +/// jwt eyJhbGci... → 토큰 직접 입력 +/// jwt header → 클립보드 JWT 헤더만 표시 +/// jwt payload → 클립보드 JWT 페이로드만 표시 +/// Enter → 결과를 클립보드에 복사. +/// 주의: 서명(signature) 검증은 수행하지 않음 — 분석 전용. +/// +public class JwtHandler : IActionHandler +{ + public string? Prefix => "jwt"; + + public PluginMetadata Metadata => new( + "JWT", + "JWT 토큰 디코더 — 헤더 · 페이로드 · 만료일 분석", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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 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 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 ""; } + } +} diff --git a/src/AxCopilot/Handlers/KeyHandler.cs b/src/AxCopilot/Handlers/KeyHandler.cs new file mode 100644 index 0000000..335aec3 --- /dev/null +++ b/src/AxCopilot/Handlers/KeyHandler.cs @@ -0,0 +1,391 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// 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 → 단축키 복사. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(items); + } + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + // 앱 카테고리 조회 + var catKeys = new Dictionary + { + ["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>(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>(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 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")); + } +} diff --git a/src/AxCopilot/Handlers/LeaveHandler.cs b/src/AxCopilot/Handlers/LeaveHandler.cs new file mode 100644 index 0000000..fde3a21 --- /dev/null +++ b/src/AxCopilot/Handlers/LeaveHandler.cs @@ -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; + +/// +/// 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 +/// +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 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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + 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>(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>(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>(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>(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>(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>(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>(items); + } + + // remaining + if (sub == "remaining") + { + items.Add(new LauncherItem( + $"잔여 연차: {left}일 (연간 {data.AnnualDays}일 - 사용 {used}일)", + "Enter: 클립보드 복사", + null, ("copy", $"잔여 연차: {left}일"), Symbol: "\uE716")); + return Task.FromResult>(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>(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>(items); + } + + // 기본 — 안내 + items.Add(new LauncherItem($"'{q}' — 알 수 없는 명령", + "leave set · use · del · remaining · list · clear", + null, null, Symbol: "\uE783")); + return Task.FromResult>(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(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 { } + } +} diff --git a/src/AxCopilot/Handlers/LogHandler.cs b/src/AxCopilot/Handlers/LogHandler.cs new file mode 100644 index 0000000..ce4c49f --- /dev/null +++ b/src/AxCopilot/Handlers/LogHandler.cs @@ -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; + +/// +/// 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 → 값 복사. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + // 클립보드 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>(items); + } + srcLabel = "클립보드"; + BuildSummary(items, src!, srcLabel); + return Task.FromResult>(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>(items); } + src = content; + srcLabel = Path.GetFileName(parts[0]); + if (parts.Length == 1) + { + BuildSummary(items, src!, srcLabel); + return Task.FromResult>(items); + } + // 파일 경로 + 서브커맨드 + sub = parts[1].ToLowerInvariant(); + parts = parts[1..]; + } + else + { + srcLabel = "클립보드"; + } + + var logSrc = src ?? ""; + if (string.IsNullOrWhiteSpace(logSrc)) + { + items.Add(ErrorItem("분석할 로그가 없습니다. 클립보드에 로그를 복사하세요.")); + return Task.FromResult>(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>(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 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 items, List 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 items, List lines, string src, string label) + { + var counts = new Dictionary + { + ["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(); + 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 ExtractExceptions(string src) + { + var blocks = new List(); + 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 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(); +} diff --git a/src/AxCopilot/Handlers/LoremHandler.cs b/src/AxCopilot/Handlers/LoremHandler.cs new file mode 100644 index 0000000..5a26c54 --- /dev/null +++ b/src/AxCopilot/Handlers/LoremHandler.cs @@ -0,0 +1,284 @@ +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// 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 → 결과를 클립보드에 복사. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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; + } +} diff --git a/src/AxCopilot/Handlers/MacroHandler.cs b/src/AxCopilot/Handlers/MacroHandler.cs new file mode 100644 index 0000000..eec4d8f --- /dev/null +++ b/src/AxCopilot/Handlers/MacroHandler.cs @@ -0,0 +1,231 @@ +using System.Diagnostics; +using AxCopilot.Models; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L6-2: 런처 매크로 핸들러. "macro" 프리픽스로 사용합니다. +/// +/// 예: macro → 매크로 목록 +/// macro 이름 → 이름으로 필터 +/// macro new → 새 매크로 편집기 열기 +/// macro edit 이름 → 기존 매크로 편집 +/// macro del 이름 → 매크로 삭제 +/// macro play 이름 → 즉시 실행 +/// Enter로 선택한 매크로를 실행합니다. +/// +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> 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>(new[] + { + new LauncherItem("새 매크로 만들기", + "편집기에서 단계별 실행 시퀀스를 설정합니다", + null, "__new__", Symbol: "\uE710") + }); + } + + if (cmd == "edit" && parts.Length > 1) + { + return Task.FromResult>(new[] + { + new LauncherItem($"'{parts[1]}' 매크로 편집", "편집기 열기", + null, $"__edit__{parts[1]}", Symbol: "\uE70F") + }); + } + + if ((cmd == "del" || cmd == "delete") && parts.Length > 1) + { + return Task.FromResult>(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>(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(); + + 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>(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}단계 실행됨"); + } +} diff --git a/src/AxCopilot/Handlers/MdHandler.cs b/src/AxCopilot/Handlers/MdHandler.cs new file mode 100644 index 0000000..37bf2d4 --- /dev/null +++ b/src/AxCopilot/Handlers/MdHandler.cs @@ -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; + +/// +/// L15-4: Markdown 분석기 핸들러. "md" 프리픽스로 사용합니다. +/// +/// 예: md → 클립보드 Markdown 분석 (구조·통계) +/// md toc → 목차(TOC) 생성 +/// md strip → Markdown 기호 제거 → 순수 텍스트 +/// md count → 단어·줄·코드블록 수 세기 +/// md links → 링크 목록 추출 +/// md images → 이미지 목록 추출 +/// Enter → 결과를 클립보드에 복사. +/// +public partial class MdHandler : IActionHandler +{ + public string? Prefix => "md"; + + public PluginMetadata Metadata => new( + "MD", + "Markdown 분석기 — 구조 분석 · TOC · 기호 제거 · 링크 추출", + "1.0", + "AX"); + + 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("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>(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>(items); + } + + // 클립보드 없으면 서브커맨드도 안내만 + if (string.IsNullOrWhiteSpace(clipboard)) + { + items.Add(new LauncherItem("클립보드가 비어 있습니다", + "Markdown 텍스트를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946")); + return Task.FromResult>(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>(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 BuildTocItems(string md) + { + var items = new List(); + 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 BuildStripItems(string md) + { + var items = new List(); + 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 BuildCountItems(string md) + { + var items = new List(); + 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 BuildLinkItems(string md) + { + var items = new List(); + 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().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().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 BuildImageItems(string md) + { + var items = new List(); + 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().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().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(); +} diff --git a/src/AxCopilot/Handlers/MeetHandler.cs b/src/AxCopilot/Handlers/MeetHandler.cs new file mode 100644 index 0000000..157c680 --- /dev/null +++ b/src/AxCopilot/Handlers/MeetHandler.cs @@ -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; + +/// +/// L27-4: 회의 링크 전용 관리 핸들러. "meet" 프리픽스로 사용합니다. +/// +/// 예: meet → 전체 회의 목록 +/// meet 스탠드업 → 이름 검색 +/// meet add 스탠드업 https://... → 회의 추가 +/// meet del 스탠드업 → 회의 삭제 +/// Enter → 기본 브라우저로 회의 링크 열기. +/// 저장: %APPDATA%\AxCopilot\meet.json +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + 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>(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>(items); + } + + // ── 빈 쿼리 → 전체 목록 ────────────────────────────────────────────── + if (string.IsNullOrWhiteSpace(q)) + { + if (meets.Count == 0) + { + items.Add(new LauncherItem("등록된 회의가 없습니다", + "meet add {이름} {URL} 로 추가하세요", + null, null, Symbol: "\uE8D6")); + return Task.FromResult>(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>(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>(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 Load() + { + try + { + if (!File.Exists(DataPath)) return []; + var json = File.ReadAllText(DataPath); + return JsonSerializer.Deserialize>(json) ?? []; + } + catch { return []; } + } + + private static void Save(List list) + { + try + { + var dir = Path.GetDirectoryName(DataPath)!; + if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); + File.WriteAllText(DataPath, JsonSerializer.Serialize(list, JsonOpt)); + } + catch { } + } +} diff --git a/src/AxCopilot/Handlers/MorseHandler.cs b/src/AxCopilot/Handlers/MorseHandler.cs new file mode 100644 index 0000000..df96edd --- /dev/null +++ b/src/AxCopilot/Handlers/MorseHandler.cs @@ -0,0 +1,251 @@ +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L12-3: 모스 부호 변환기 핸들러. "morse" 프리픽스로 사용합니다. +/// +/// 예: morse hello → 텍스트 → 모스 부호 +/// morse .- -... -.-. → 모스 부호 → 텍스트 (공백으로 구분) +/// morse SOS → SOS 모스 부호 +/// morse → 클립보드 자동 감지·변환 +/// Enter → 결과를 클립보드에 복사. +/// +public class MorseHandler : IActionHandler +{ + public string? Prefix => "morse"; + + public PluginMetadata Metadata => new( + "Morse", + "모스 부호 변환기 — 텍스트 ↔ 모스 부호", + "1.0", + "AX"); + + // ── 모스 부호 사전 ──────────────────────────────────────────────────────── + private static readonly Dictionary 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 MorseToText; + + // 번개 부호 / 대문자 표 + private static readonly string[] ProsignCodes = ["SOS", "AR", "AS", "BT", "KN", "SK"]; + private static readonly Dictionary 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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(items); + } + + // 모스 부호 입력 감지 + if (IsMorseCode(q)) + { + items.AddRange(BuildMorseToText(q)); + } + else + { + items.AddRange(BuildTextToMorse(q)); + } + + 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("Morse", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + return Task.CompletedTask; + } + + // ── 변환 로직 ───────────────────────────────────────────────────────────── + + private static IEnumerable BuildTextToMorse(string text) + { + var upper = text.ToUpperInvariant(); + var words = upper.Split(' '); + var morseSb = new StringBuilder(); + var unknown = new List(); + + 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 BuildMorseToText(string morse) + { + // "/" 는 단어 구분, 공백은 문자 구분 + var sb = new StringBuilder(); + var words = morse.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + var unknown = new List(); + + 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 ""; } + } +} diff --git a/src/AxCopilot/Handlers/NetDiagHandler.cs b/src/AxCopilot/Handlers/NetDiagHandler.cs new file mode 100644 index 0000000..0d47bd6 --- /dev/null +++ b/src/AxCopilot/Handlers/NetDiagHandler.cs @@ -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; + +/// +/// 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 → 결과를 클립보드에 복사. +/// +public class NetDiagHandler : IActionHandler +{ + public string? Prefix => "net"; + + public PluginMetadata Metadata => new( + "NetDiag", + "네트워크 진단 — IP · ping · DNS 조회 · 어댑터 상태", + "1.0", + "AX"); + + public async Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim().ToLowerInvariant(); + var items = new List(); + + 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> PingAsync(string host, CancellationToken ct) + { + var items = new List(); + 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; + } + + /// 사내 모드에서 외부 호스트 차단 여부 확인. + 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 { /* 비핵심 */ } + } +} diff --git a/src/AxCopilot/Handlers/NotifHandler.cs b/src/AxCopilot/Handlers/NotifHandler.cs new file mode 100644 index 0000000..ece8852 --- /dev/null +++ b/src/AxCopilot/Handlers/NotifHandler.cs @@ -0,0 +1,139 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// Phase L3-8: 알림 센터 핸들러. "notif" 프리픽스로 사용합니다. +/// AX Copilot이 표시한 최근 알림 이력을 런처에서 조회합니다. +/// +/// 사용법: +/// notif → 최근 알림 이력 목록 +/// notif clear → 알림 이력 초기화 +/// notif [검색어] → 제목·내용으로 필터링 +/// +/// Enter → 알림 내용을 클립보드에 복사. +/// +public class NotifHandler : IActionHandler +{ + public string? Prefix => "notif"; + + public PluginMetadata Metadata => new( + "NotifCenter", + "알림 센터 — notif", + "1.0", + "AX", + "AX Copilot 알림 이력을 조회합니다."); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + + // clear 명령 + if (q.Equals("clear", StringComparison.OrdinalIgnoreCase)) + { + var count = NotificationCenterService.History.Count; + return Task.FromResult>( + [ + new LauncherItem( + $"알림 이력 초기화 ({count}건)", + "Enter를 눌러 전체 삭제", + null, "__CLEAR__", + Symbol: Symbols.Delete) + ]); + } + + var history = NotificationCenterService.History; + + // 검색어 필터 + IEnumerable 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>( + [ + 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(); + + return Task.FromResult>(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, + } + }; +} diff --git a/src/AxCopilot/Handlers/NpmHandler.cs b/src/AxCopilot/Handlers/NpmHandler.cs new file mode 100644 index 0000000..db6cd49 --- /dev/null +++ b/src/AxCopilot/Handlers/NpmHandler.cs @@ -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; + +/// +/// 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 → 클립보드 복사 (또는 터미널에서 실행). +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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 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 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 { } + } +} diff --git a/src/AxCopilot/Handlers/NumHandler.cs b/src/AxCopilot/Handlers/NumHandler.cs new file mode 100644 index 0000000..8dd4356 --- /dev/null +++ b/src/AxCopilot/Handlers/NumHandler.cs @@ -0,0 +1,288 @@ +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L17-2: 숫자 포맷·읽기 변환 핸들러. "num" 프리픽스로 사용합니다. +/// +/// 예: num 1234567 → 천단위·한글·영어·진수·과학표기 모두 표시 +/// num 0xff → 16진수 → 10진수 변환 +/// num 0b1010 → 2진수 → 10진수 변환 +/// num 42 ko → 한국어로 읽기 (사십이) +/// num 42 en → 영어로 읽기 (forty-two) +/// num 1e6 → 과학표기 → 일반 변환 +/// Enter → 결과 복사. +/// +public class NumHandler : IActionHandler +{ + public string? Prefix => "num"; + + public PluginMetadata Metadata => new( + "Num", + "숫자 포맷·읽기 변환 — 한글·영어·진수·천단위·과학표기", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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>(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>(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>(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(); + } +} diff --git a/src/AxCopilot/Handlers/OcrHandler.cs b/src/AxCopilot/Handlers/OcrHandler.cs new file mode 100644 index 0000000..e1ee6f6 --- /dev/null +++ b/src/AxCopilot/Handlers/OcrHandler.cs @@ -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; + +/// +/// L5-2: 화면 텍스트 OCR 추출 핸들러. +/// 예: ocr → 옵션 목록 (영역 선택 / 클립보드 이미지) +/// ocr region → 드래그 영역 선택 후 텍스트 추출 +/// ocr clip → 클립보드 이미지에서 텍스트 추출 +/// 결과 텍스트는 클립보드에 복사되고 런처 입력창에 채워집니다. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim().ToLowerInvariant(); + + var items = new List + { + 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>(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 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().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; + } +} diff --git a/src/AxCopilot/Handlers/PasswordGenHandler.cs b/src/AxCopilot/Handlers/PasswordGenHandler.cs new file mode 100644 index 0000000..7dc7a75 --- /dev/null +++ b/src/AxCopilot/Handlers/PasswordGenHandler.cs @@ -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; + +/// +/// L9-1: 비밀번호 생성기 핸들러. "pwd" 프리픽스로 사용합니다. +/// +/// 예: pwd → 기본 16자 비밀번호 5개 생성 +/// pwd 24 → 24자 비밀번호 5개 생성 +/// pwd 32 strong → 32자 강력 옵션 (대소문자+숫자+특수) +/// pwd 16 alpha → 알파벳+숫자만 (특수문자 제외) +/// pwd 20 pin → 숫자만 (PIN 코드) +/// pwd passphrase → 단어 조합 기억하기 쉬운 패스프레이즈 +/// Enter → 클립보드에 복사. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim().ToLowerInvariant(); + var items = new List(); + + // 패스프레이즈 모드 + 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>(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>(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[] 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)); + } +} diff --git a/src/AxCopilot/Handlers/PasteHandler.cs b/src/AxCopilot/Handlers/PasteHandler.cs new file mode 100644 index 0000000..4c095a8 --- /dev/null +++ b/src/AxCopilot/Handlers/PasteHandler.cs @@ -0,0 +1,217 @@ +using System.Runtime.InteropServices; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L27-6: 클립보드 순차 붙여넣기 핸들러. "paste" 프리픽스로 사용합니다. +/// +/// 예: paste → 번호 매긴 클립보드 히스토리 목록 +/// paste 3 1 5 → 3번→1번→5번 항목을 순서대로 붙여넣기 +/// paste all → 전체 히스토리를 순서대로 붙여넣기 +/// Enter → 이전 창에서 순서대로 Ctrl+V 실행. +/// Raycast "Paste Sequentially" 대응. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + 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>(items); + } + + // ── 번호 시퀀스 파싱 ────────────────────────────────────────────────── + if (!string.IsNullOrWhiteSpace(q) && q != "all") + { + var nums = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var indices = new List(); + 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>(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>(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>(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 texts)) + { + // 순차 붙여넣기 + await PasteTexts(texts, ct); + } + } + + private async Task PasteTexts(List 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()); + } + + // ─── 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); +} diff --git a/src/AxCopilot/Handlers/PathHandler.cs b/src/AxCopilot/Handlers/PathHandler.cs new file mode 100644 index 0000000..615e31d --- /dev/null +++ b/src/AxCopilot/Handlers/PathHandler.cs @@ -0,0 +1,219 @@ +using System.IO; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// 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 → 경로를 클립보드에 복사. +/// +public class PathHandler : IActionHandler +{ + public string? Prefix => "path"; + + public PluginMetadata Metadata => new( + "Path", + "PATH 환경변수 뷰어 — 경로 목록 · 검색 · which", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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 GetAllPaths() + { + var result = new List(); + result.AddRange(GetPaths(EnvironmentVariableTarget.Process)); + return result.DistinctBy(p => p.Directory.ToLowerInvariant()).ToList(); + } + + private static List 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 FindExecutable(string name) + { + var extensions = new[] { "", ".exe", ".cmd", ".bat", ".ps1", ".com" }; + var paths = GetAllPaths(); + var found = new List(); + + 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); + } +} diff --git a/src/AxCopilot/Handlers/PermHandler.cs b/src/AxCopilot/Handlers/PermHandler.cs new file mode 100644 index 0000000..c16a213 --- /dev/null +++ b/src/AxCopilot/Handlers/PermHandler.cs @@ -0,0 +1,277 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// 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 → 값 복사. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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>(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>(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>(items); + } + + // 8진수 표기 (755, 644...) + if (TryParseOctal(sub, out var octal)) + { + items.AddRange(BuildFromOctal(octal)); + return Task.FromResult>(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>(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 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 BuildFromSymbol(string sym) + { + var octal = SymbolToOctal(sym); + return BuildFromOctal(octal); + } + + private static IEnumerable 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 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(); + 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"); +} diff --git a/src/AxCopilot/Handlers/PhraseHandler.cs b/src/AxCopilot/Handlers/PhraseHandler.cs new file mode 100644 index 0000000..d2b6de7 --- /dev/null +++ b/src/AxCopilot/Handlers/PhraseHandler.cs @@ -0,0 +1,215 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; + +namespace AxCopilot.Handlers; + +/// +/// L25-3: 자주 쓰는 업무 문구 모음. "phrase" 프리픽스로 사용합니다. +/// +/// 예: phrase → 카테고리 목록 +/// phrase 인사 → 인사 문구 목록 +/// phrase 보고 → 보고/발표 문구 +/// phrase 요청 → 요청/협조 문구 +/// phrase 마무리 → 마무리/감사 문구 +/// phrase <검색어> → 전체 검색 +/// Enter → 문구 클립보드 복사 +/// +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 _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 BuildPhrases() + { + var list = new List(); + + // 인사 (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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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>(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; + } +} diff --git a/src/AxCopilot/Handlers/PingHandler.cs b/src/AxCopilot/Handlers/PingHandler.cs new file mode 100644 index 0000000..24335e5 --- /dev/null +++ b/src/AxCopilot/Handlers/PingHandler.cs @@ -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; + +/// +/// 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 차단. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(items); + } + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + // local → 로컬 어댑터 정보 + if (sub == "local" || sub == "lo") + { + items.AddRange(BuildLocalNetworkItems()); + return Task.FromResult>(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>(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>(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>(items); + } + + // 직접 ping 대상 + var host = parts[0]; + var blocked2 = CheckInternalMode(host); + if (blocked2 != null) { items.Add(blocked2); return Task.FromResult>(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>(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 BuildLocalNetworkItems() + { + var items = new List(); + 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; + } +} diff --git a/src/AxCopilot/Handlers/PipHandler.cs b/src/AxCopilot/Handlers/PipHandler.cs new file mode 100644 index 0000000..1685ddb --- /dev/null +++ b/src/AxCopilot/Handlers/PipHandler.cs @@ -0,0 +1,178 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; + +namespace AxCopilot.Handlers; + +/// +/// L22-3: Python pip 명령 생성기 핸들러. "pip" 프리픽스로 사용합니다. +/// +/// 예: pip → 자주 쓰는 명령 목록 +/// pip install → 패키지 설치 관련 명령 +/// pip list → 목록 관련 명령 +/// pip venv → 가상환경 관련 명령 +/// pip conda → conda 관련 명령 +/// pip <검색어> → 명령 검색 +/// Enter → 명령어 클립보드 복사. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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>(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>(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"); + } +} diff --git a/src/AxCopilot/Handlers/PkgHandler.cs b/src/AxCopilot/Handlers/PkgHandler.cs new file mode 100644 index 0000000..9fb487b --- /dev/null +++ b/src/AxCopilot/Handlers/PkgHandler.cs @@ -0,0 +1,238 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L28-1: winget 앱 검색·설치·목록 핸들러. "pkg" 프리픽스로 사용합니다. +/// +/// 예: pkg → 사용법 안내 +/// pkg vscode → winget search vscode +/// pkg install {id} → winget install {id} +/// pkg list → 설치된 앱 목록 +/// pkg upgrade → 업그레이드 가능 목록 +/// winget 미설치 시 안내 메시지 표시. +/// +public partial class PkgHandler : IActionHandler +{ + public string? Prefix => "pkg"; + + public PluginMetadata Metadata => new( + "앱 패키지", + "winget 앱 검색·설치·업그레이드", + "1.0", + "AX"); + + private static bool? _wingetAvailable; + + public async Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + // 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> 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 ParseWingetOutput(string output) + { + var results = new List(); + 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 CheckWingetAsync() + { + try + { + var output = await RunWingetAsync("--version", CancellationToken.None); + return output.TrimStart().StartsWith('v'); + } + catch { return false; } + } + + private static async Task 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}"); + } + } +} diff --git a/src/AxCopilot/Handlers/PomoHandler.cs b/src/AxCopilot/Handlers/PomoHandler.cs new file mode 100644 index 0000000..06790cf --- /dev/null +++ b/src/AxCopilot/Handlers/PomoHandler.cs @@ -0,0 +1,130 @@ +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// Phase L3-9: 뽀모도로 타이머 핸들러. "pomo" 프리픽스로 사용합니다. +/// +/// 사용법: +/// pomo → 현재 타이머 상태 표시 +/// pomo start → 집중 타이머 시작 (25분) +/// pomo break → 휴식 타이머 시작 (5분) +/// pomo stop → 타이머 중지 +/// pomo reset → 타이머 초기화 +/// +/// Enter → 해당 명령 실행. +/// +public class PomoHandler : IActionHandler +{ + public string? Prefix => "pomo"; + + public PluginMetadata Metadata => new( + "Pomodoro", + "뽀모도로 타이머 — pomo", + "1.0", + "AX", + "집중/휴식 타이머를 관리합니다."); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim().ToLowerInvariant(); + var svc = PomodoroService.Instance; + var items = new List(); + + // ─── 명령 분기 ──────────────────────────────────────────────────────── + + if (q is "start" or "focus") + { + items.Add(new LauncherItem("집중 타이머 시작", + $"{svc.FocusMinutes}분 집중 모드 시작 · Enter로 실행", + null, "__START__", Symbol: Symbols.Timer)); + return Task.FromResult>(items); + } + + if (q is "break" or "rest") + { + items.Add(new LauncherItem("휴식 타이머 시작", + $"{svc.BreakMinutes}분 휴식 모드 시작 · Enter로 실행", + null, "__BREAK__", Symbol: Symbols.Timer)); + return Task.FromResult>(items); + } + + if (q is "stop" or "pause") + { + items.Add(new LauncherItem("타이머 중지", + "현재 타이머를 중지합니다 · Enter로 실행", + null, "__STOP__", Symbol: Symbols.MediaPlay)); + return Task.FromResult>(items); + } + + if (q == "reset") + { + items.Add(new LauncherItem("타이머 초기화", + "타이머를 처음 상태로 초기화합니다 · Enter로 실행", + null, "__RESET__", Symbol: Symbols.Restart)); + return Task.FromResult>(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>(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; + } +} diff --git a/src/AxCopilot/Handlers/ProcHandler.cs b/src/AxCopilot/Handlers/ProcHandler.cs new file mode 100644 index 0000000..844061e --- /dev/null +++ b/src/AxCopilot/Handlers/ProcHandler.cs @@ -0,0 +1,202 @@ +using System.Diagnostics; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; + +namespace AxCopilot.Handlers; + +/// +/// L22-1: 프로세스 상세 조회·정리 핸들러. "proc" 프리픽스로 사용합니다. +/// +/// 예: proc → CPU 사용량 상위 프로세스 목록 +/// proc top → CPU 상위 15개 +/// proc mem → 메모리 상위 15개 +/// proc <이름> → 이름 검색 (부분 일치) +/// proc kill <이름> → 이름으로 종료 (첫 번째 일치) +/// proc stats → 전체 통계 (수·CPU합·메모리합) +/// Enter → 프로세스 이름 복사. +/// +public class ProcHandler : IActionHandler +{ + public string? Prefix => "proc"; + + public PluginMetadata Metadata => new( + "프로세스 관리", + "실행 중인 프로세스 조회·정리 — CPU·메모리 정렬·검색·종료", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(items); + } + + // 프로세스 목록 수집 + Process[] procs; + try { procs = Process.GetProcesses(); } + catch (Exception ex) + { + items.Add(ErrorItem($"프로세스 조회 실패: {ex.Message}")); + return Task.FromResult>(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>(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>(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>(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>(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>(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"); +} diff --git a/src/AxCopilot/Handlers/PsHandler.cs b/src/AxCopilot/Handlers/PsHandler.cs new file mode 100644 index 0000000..539f1ed --- /dev/null +++ b/src/AxCopilot/Handlers/PsHandler.cs @@ -0,0 +1,268 @@ +using System.Diagnostics; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// 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 터미널 실행). +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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>(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>(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 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] + "…"; +} diff --git a/src/AxCopilot/Handlers/QrHandler.cs b/src/AxCopilot/Handlers/QrHandler.cs new file mode 100644 index 0000000..f6825be --- /dev/null +++ b/src/AxCopilot/Handlers/QrHandler.cs @@ -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; + +/// +/// L27-3: QR 코드 생성 핸들러. "qr" 프리픽스로 사용합니다. +/// +/// 예: qr https://google.com → QR 코드 생성 (Enter: 클립보드 복사) +/// qr 안녕하세요 → 한국어 텍스트 QR 생성 +/// qr save https://... → QR PNG 파일 저장 +/// Enter → QR 이미지를 클립보드에 복사 (붙여넣기로 사용). +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + items.Add(new LauncherItem( + "QR 코드 생성", + "qr {텍스트 또는 URL} → Enter: QR PNG 클립보드 복사", + null, null, Symbol: "\uE8C8")); + return Task.FromResult>(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>(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>(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); + } +} diff --git a/src/AxCopilot/Handlers/QuickLinkHandler.cs b/src/AxCopilot/Handlers/QuickLinkHandler.cs new file mode 100644 index 0000000..c739ee7 --- /dev/null +++ b/src/AxCopilot/Handlers/QuickLinkHandler.cs @@ -0,0 +1,127 @@ +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// Phase L3-4: 파라미터 퀵링크 핸들러. "ql" 예약어로 사용합니다. +/// 예: ql maps 강남역 → "maps" 키워드 URL에 "강남역" 치환 후 열기 +/// ql jira PROJ-1234 → "jira" 키워드 URL에 티켓 번호 치환 +/// ql (목록) → 등록된 퀵링크 목록 표시 +/// +/// 퀵링크는 설정 → 일반 → 퀵링크 탭에서 등록합니다. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var links = _settings.Settings.QuickLinks; + + // 등록된 퀵링크 없음 + if (links.Count == 0) + { + return Task.FromResult>( + [ + new LauncherItem( + "등록된 퀵링크 없음", + "설정 → 일반 → 퀵링크에서 추가하세요. 예: keyword=maps, url=https://map.naver.com/p/search/{0}", + null, null, Symbol: Symbols.Globe) + ]); + } + + var items = new List(); + 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>(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>(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; + } +} diff --git a/src/AxCopilot/Handlers/RandHandler.cs b/src/AxCopilot/Handlers/RandHandler.cs new file mode 100644 index 0000000..2ca25ea --- /dev/null +++ b/src/AxCopilot/Handlers/RandHandler.cs @@ -0,0 +1,324 @@ +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// 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 → 결과 복사. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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 / rand / rand str / rand color / rand dice / rand pick …", + null, null, Symbol: "\uE8D0")); + return Task.FromResult>(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 또는 rand + 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 / rand str / rand color / rand dice / rand pick / rand uuid / rand token / rand pin", + null, null, Symbol: "\uE783")); + } + 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("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"); +} diff --git a/src/AxCopilot/Handlers/RegHandler.cs b/src/AxCopilot/Handlers/RegHandler.cs new file mode 100644 index 0000000..785f2d9 --- /dev/null +++ b/src/AxCopilot/Handlers/RegHandler.cs @@ -0,0 +1,205 @@ +using Microsoft.Win32; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L14-2: Windows 레지스트리 빠른 조회 핸들러. "reg" 프리픽스로 사용합니다. +/// +/// 예: reg HKCU\Software\Microsoft → 키 하위 값 목록 +/// reg HKLM\SOFTWARE\Microsoft → HKLM 조회 +/// reg search DisplayName → 값 이름으로 검색 (제한적) +/// reg HKCU\...\Run → 실행 항목 조회 +/// Enter → 값을 클립보드에 복사. +/// +/// 쓰기/삭제 기능 없음 — 조회 전용. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(items); + } + + // 레지스트리 경로 조회 + var regPath = q; + items.AddRange(QueryRegistry(regPath)); + return Task.FromResult>(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 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() ?? "", + }; +} diff --git a/src/AxCopilot/Handlers/RegexHandler.cs b/src/AxCopilot/Handlers/RegexHandler.cs new file mode 100644 index 0000000..6bd7208 --- /dev/null +++ b/src/AxCopilot/Handlers/RegexHandler.cs @@ -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; + +/// +/// L7-2: 정규식 테스터 핸들러. "re" 프리픽스로 사용합니다. +/// +/// 예: re \d+ → 클립보드 텍스트에서 숫자 패턴 매치 +/// re /old/new/ → 치환 모드 (결과 클립보드 복사) +/// re patterns → 공통 패턴 라이브러리 표시 +/// re flags:i \w+ → 플래그 지정 (i=무시대소, m=멀티라인, s=점이개행) +/// Enter → 매치 결과 또는 치환 결과를 클립보드에 복사. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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>(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().Select(m => m.Value))), + Symbol: "\uE773")); + + // 개별 매치 항목 (최대 15개) + foreach (Match m in matches.Cast().Take(15)) + { + var groupInfo = m.Groups.Count > 1 + ? " · 그룹: " + string.Join(", ", m.Groups.Cast().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>(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().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() + .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 { /* 비핵심 */ } + } +} diff --git a/src/AxCopilot/Handlers/RemindHandler.cs b/src/AxCopilot/Handlers/RemindHandler.cs new file mode 100644 index 0000000..1e1af86 --- /dev/null +++ b/src/AxCopilot/Handlers/RemindHandler.cs @@ -0,0 +1,288 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; + +namespace AxCopilot.Handlers; + +/// +/// L25-2: 오늘 특정 시각 알림. "remind" 프리픽스로 사용합니다. +/// +/// 예: remind → 오늘 등록된 알림 목록 +/// remind 15:00 보고서 제출 → 오후 3시에 알림 +/// remind 오후3시 팀장보고 → 한국어 시각 파싱 +/// remind del 1 → 1번 알림 취소 +/// remind clear → 지난 알림 정리 +/// +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 _reminders = []; + private static readonly Dictionary _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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + if (string.IsNullOrWhiteSpace(q)) + { + List 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>(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>(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>(items); + } + + // clear 명령 + if (sub == "clear") + { + items.Add(new LauncherItem("지난 알림 정리", + "발화된 알림을 목록에서 제거합니다 · Enter 실행", + null, ("clear", ""), Symbol: "\uE787")); + return Task.FromResult>(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>(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}시간 후"; + } +} diff --git a/src/AxCopilot/Handlers/ScheduleHandler.cs b/src/AxCopilot/Handlers/ScheduleHandler.cs new file mode 100644 index 0000000..8aaf343 --- /dev/null +++ b/src/AxCopilot/Handlers/ScheduleHandler.cs @@ -0,0 +1,171 @@ +using System.IO; +using AxCopilot.Models; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L5-6: 자동화 스케줄 핸들러. "sched" 프리픽스로 사용합니다. +/// +/// 예: sched → 등록된 스케줄 목록 +/// sched 이름 → 이름으로 필터 +/// sched new → 새 스케줄 편집기 열기 +/// sched edit 이름 → 기존 스케줄 편집 +/// sched del 이름 → 스케줄 삭제 +/// sched toggle 이름 → 활성/비활성 전환 (Enter) +/// +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> 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>(new[] + { + new LauncherItem("새 스케줄 만들기", + "편집기에서 트리거 시각과 실행 액션을 설정합니다", + null, "__new__", Symbol: "\uE710") + }); + } + + // "edit 이름" + if (cmd == "edit" && parts.Length > 1) + { + return Task.FromResult>(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>(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(); + + 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>(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; + } +} diff --git a/src/AxCopilot/Handlers/SessionHandler.cs b/src/AxCopilot/Handlers/SessionHandler.cs new file mode 100644 index 0000000..a3298b4 --- /dev/null +++ b/src/AxCopilot/Handlers/SessionHandler.cs @@ -0,0 +1,301 @@ +using System.IO; +using System.Runtime.InteropServices; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L5-4: 앱 세션 스냅 핸들러. "session" 프리픽스로 사용합니다. +/// +/// 예: session → 저장된 세션 목록 +/// session 개발환경 → 해당 세션 실행 (앱 + 스냅 레이아웃 적용) +/// session new 이름 → 새 세션 편집기 열기 +/// session edit 이름 → 기존 세션 편집기 열기 +/// session del 이름 → 세션 삭제 +/// +/// 세션 = [앱 경로 + 스냅 위치] 목록. 한 번에 모든 앱을 지정 레이아웃으로 실행합니다. +/// +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> 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>(new[] + { + new LauncherItem($"'{name}' 세션 만들기", + "편집기에서 앱 목록과 스냅 레이아웃을 설정합니다", + null, $"__new__{name}", Symbol: "\uE710") + }); + } + + // "edit 이름" — 기존 세션 편집 + if (cmd == "edit" && parts.Length > 1) + { + return Task.FromResult>(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>(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(); + + 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>(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() }; + 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); +} diff --git a/src/AxCopilot/Handlers/SpellHandler.cs b/src/AxCopilot/Handlers/SpellHandler.cs new file mode 100644 index 0000000..a255350 --- /dev/null +++ b/src/AxCopilot/Handlers/SpellHandler.cs @@ -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; + +/// +/// L24-3: 자주 틀리는 한국어 맞춤법 핸들러. "spell" 프리픽스로 사용합니다. +/// +/// 예: spell → 클립보드 텍스트 맞춤법 검사 +/// spell 되 → "되/돼" 관련 오류 목록 +/// spell <단어> → 해당 단어 맞춤법 확인 +/// spell list → 전체 오류 목록 카테고리 +/// Enter → 올바른 표현 클립보드 복사 +/// +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 LoadCustom() + { + try + { + if (!File.Exists(CustomPath)) return []; + return JsonSerializer.Deserialize>(File.ReadAllText(CustomPath)) ?? []; + } + catch { return []; } + } + + private static void SaveCustom(List list) + { + try + { + var dir = Path.GetDirectoryName(CustomPath)!; + if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); + File.WriteAllText(CustomPath, JsonSerializer.Serialize(list, JsonOpt)); + } + catch { } + } + + private IEnumerable 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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + // ── 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>(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>(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>(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>(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>(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>(items); + } + + // 카테고리 키워드 매핑 + var catMap = new Dictionary(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>(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>(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>(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>(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"); +} 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/SshHandler.cs b/src/AxCopilot/Handlers/SshHandler.cs new file mode 100644 index 0000000..ee94856 --- /dev/null +++ b/src/AxCopilot/Handlers/SshHandler.cs @@ -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; + +/// +/// 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는 내부 서버 접속이 주 용도). +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + 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>(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 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>(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>(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>(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"); + } + + /// Windows Terminal 또는 PuTTY로 SSH 연결을 시작합니다. + 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); + } + } + + /// PATH 및 일반 설치 경로에서 실행 파일을 찾습니다. + 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); + } + + /// user@host:port 형식을 파싱합니다. + 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, + }; + } +} diff --git a/src/AxCopilot/Handlers/StartupHandler.cs b/src/AxCopilot/Handlers/StartupHandler.cs new file mode 100644 index 0000000..9bf3a76 --- /dev/null +++ b/src/AxCopilot/Handlers/StartupHandler.cs @@ -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; + +/// +/// L12-4: 시작 프로그램 조회 핸들러. "startup" 프리픽스로 사용합니다. +/// +/// 예: startup → 전체 시작 프로그램 목록 +/// startup search ms → "ms" 포함 항목 필터 +/// startup folder → 시작 프로그램 폴더 열기 +/// Enter → 항목 경로를 클립보드에 복사. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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 CollectAllEntries() + { + var result = new List(); + + // 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 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 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"); + } +} diff --git a/src/AxCopilot/Handlers/StrHandler.cs b/src/AxCopilot/Handlers/StrHandler.cs new file mode 100644 index 0000000..6b29b5f --- /dev/null +++ b/src/AxCopilot/Handlers/StrHandler.cs @@ -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; + +/// +/// 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 → 결과 복사. +/// +public partial class StrHandler : IActionHandler +{ + public string? Prefix => "str"; + + public PluginMetadata Metadata => new( + "Str", + "문자열 조작 도구 — HTML/URL/JSON 이스케이프·반복·패딩·줄 정렬·추출", + "1.0", + "AX"); + + 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("문자열 조작 도구", + "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>(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>(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>(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>(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>(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>(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>(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>(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>(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>(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() + }; + 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>(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>(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>(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 items, string src) + { + items.Add(CopyItem("HTML 이스케이프", HtmlEncode(src))); + items.Add(CopyItem("URL 인코딩", Uri.EscapeDataString(src))); + items.Add(CopyItem("JSON 이스케이프", JsonEscape(src))); + } + + private static string HtmlEncode(string s) => s + .Replace("&", "&").Replace("<", "<").Replace(">", ">") + .Replace("\"", """).Replace("'", "'"); + + private static string HtmlDecode(string s) => + HttpUtility.HtmlDecode(s); + + private static string JsonEscape(string s) + { + var sb = new StringBuilder(); + foreach (var c in s) + { + switch (c) + { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + default: + if (c < 0x20) sb.Append($"\\u{(int)c:X4}"); + else sb.Append(c); + break; + } + } + return sb.ToString(); + } + + private static string JsonUnescape(string s) => + s.Replace("\\\"", "\"").Replace("\\\\", "\\") + .Replace("\\n", "\n").Replace("\\r", "\r").Replace("\\t", "\t"); + + private static string RegexEscape(string s) => Regex.Escape(s); + + private static string WordWrap(string text, int cols) + { + var words = text.Split(' '); + var sb = new StringBuilder(); + int colPos = 0; + foreach (var word in words) + { + if (colPos + word.Length + 1 > cols && colPos > 0) { sb.Append('\n'); colPos = 0; } + else if (colPos > 0) { sb.Append(' '); colPos++; } + sb.Append(word); + colPos += word.Length; + } + return sb.ToString(); + } + + private static string ReplaceCount(string src, string from, string to, out int count) + { + count = 0; + var sb = new StringBuilder(); + int pos = 0; + while (true) + { + var idx = src.IndexOf(from, pos, StringComparison.Ordinal); + if (idx < 0) { sb.Append(src[pos..]); break; } + sb.Append(src[pos..idx]); + sb.Append(to); + count++; + pos = idx + from.Length; + } + return sb.ToString(); + } + + private static LauncherItem CopyItem(string label, string value) => + new(label, value, null, ("copy", value), Symbol: "\uE8AB"); + + private static LauncherItem ErrorItem(string msg) => + new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783"); + + [GeneratedRegex(@"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}")] + private static partial Regex EmailRegex(); + + [GeneratedRegex(@"https?://[^\s""'<>]+")] + private static partial Regex UrlRegex(); + + [GeneratedRegex(@"-?\d+(\.\d+)?")] + private static partial Regex NumberRegex(); + + [GeneratedRegex(@"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b")] + private static partial Regex IpRegex(); +} diff --git a/src/AxCopilot/Handlers/SubnetHandler.cs b/src/AxCopilot/Handlers/SubnetHandler.cs new file mode 100644 index 0000000..8af33c1 --- /dev/null +++ b/src/AxCopilot/Handlers/SubnetHandler.cs @@ -0,0 +1,281 @@ +using System.Net; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// 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 → 결과를 클립보드에 복사. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(items); + } + + // "range" 서브커맨드 + if (q.StartsWith("range ", StringComparison.OrdinalIgnoreCase)) + { + var rangeStr = q[6..].Trim(); + items.AddRange(BuildRangeItems(rangeStr)); + return Task.FromResult>(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>(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>(items); + } + + items.AddRange(BuildSubnetItems(ip, prefix)); + 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("Subnet", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + return Task.CompletedTask; + } + + // ── 서브넷 계산 ────────────────────────────────────────────────────────── + + private static IEnumerable 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 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')}"; +} diff --git a/src/AxCopilot/Handlers/TableHandler.cs b/src/AxCopilot/Handlers/TableHandler.cs new file mode 100644 index 0000000..b718b2f --- /dev/null +++ b/src/AxCopilot/Handlers/TableHandler.cs @@ -0,0 +1,376 @@ +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L16-4: 텍스트 → 표 변환 핸들러. "table" 프리픽스로 사용합니다. +/// +/// 클립보드 텍스트(탭 구분, CSV, 공백 정렬)를 표로 변환합니다. +/// +/// 예: table → 클립보드 → 마크다운 표 변환 +/// table csv → 마크다운 → CSV 변환 +/// table html → 마크다운 → HTML 테이블 변환 +/// table flip → 행·열 전치(transpose) +/// table sort 2 → 2번 열 기준 정렬 +/// table add <헤더> → 새 행 추가 (탭 구분) +/// Enter → 결과를 클립보드에 복사. +/// +public class TableHandler : IActionHandler +{ + public string? Prefix => "table"; + + public PluginMetadata Metadata => new( + "Table", + "텍스트·CSV → 마크다운·HTML 표 변환 — 전치 · 정렬 · 추가", + "1.0", + "AX"); + + 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("텍스트 → 표 변환기", + "클립보드 탭·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>(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>(items); + } + + if (string.IsNullOrWhiteSpace(clipboard)) + { + items.Add(new LauncherItem("클립보드가 비어 있습니다", + "표 데이터를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946")); + return Task.FromResult>(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>(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>(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; + } + + // ── 파서 ───────────────────────────────────────────────────────────────── + + /// 탭, 쉼표(CSV), 연속 공백 순으로 자동 감지하여 표로 파싱 + private static List> 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>(); + + // 구분자 자동 감지 + 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>(); + 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 ParseCsvLine(string line) + { + var result = new List(); + 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> 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> 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> rows) + { + if (rows.Count == 0) return ""; + var sb = new StringBuilder(); + sb.AppendLine("
"); + + // 헤더 + sb.AppendLine(" "); + foreach (var cell in rows[0]) + sb.AppendLine($" "); + sb.AppendLine(" "); + + // 바디 + sb.AppendLine(" "); + for (var r = 1; r < rows.Count; r++) + { + sb.AppendLine(" "); + foreach (var cell in rows[r]) + sb.AppendLine($" "); + sb.AppendLine(" "); + } + sb.AppendLine(" "); + sb.Append("
{EscHtml(cell)}
{EscHtml(cell)}
"); + return sb.ToString(); + } + + private static List> Transpose(List> rows) + { + if (rows.Count == 0) return rows; + var maxCols = rows.Max(r => r.Count); + var result = new List>(); + for (var c = 0; c < maxCols; c++) + { + var row = new List(); + 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> SortByColumn(List> 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> { header }; + result.AddRange(data); + return result; + } + + // ── 헬퍼 ───────────────────────────────────────────────────────────────── + + private static void AddPreview(List items, string text) + { + var lines = text.Split('\n').Take(3); + foreach (var line in lines) + { + var t = line.Length > 60 ? line[..60] + "…" : line; + if (!string.IsNullOrWhiteSpace(t)) + items.Add(new LauncherItem(t, "", null, null, Symbol: "\uE81E")); + } + } + + private static string EscHtml(string s) => + s.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """); +} diff --git a/src/AxCopilot/Handlers/TagHandler.cs b/src/AxCopilot/Handlers/TagHandler.cs new file mode 100644 index 0000000..41809d2 --- /dev/null +++ b/src/AxCopilot/Handlers/TagHandler.cs @@ -0,0 +1,264 @@ +using System.Diagnostics; +using System.IO; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// 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 → 파일의 모든 태그 제거 +/// +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> 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>( + [ + 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>( + [ + 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>( + [ + 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 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 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 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> BuildTagListItems() + { + var allTags = Tags.GetAllTags(); + if (allTags.Count == 0) + { + return Task.FromResult>( + [ + 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(); + + return Task.FromResult>(items); + } + + private static Task> 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>( + [ + new LauncherItem( + $"[{q}] 태그에 해당하는 파일 없음", + "tag add [태그] [경로]로 태그를 추가하세요", + null, null, Symbol: Symbols.Info) + ]); + } + + var items = new List(); + var seen = new HashSet(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>(items); + } + + private static Task> HelpItems(string usage, string example) => + Task.FromResult>( + [ + new LauncherItem( + $"사용법: {usage}", + example, + null, null, Symbol: Symbols.Info) + ]); +} 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(); +} diff --git a/src/AxCopilot/Handlers/TimeZoneHandler.cs b/src/AxCopilot/Handlers/TimeZoneHandler.cs new file mode 100644 index 0000000..2a4867c --- /dev/null +++ b/src/AxCopilot/Handlers/TimeZoneHandler.cs @@ -0,0 +1,259 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L7-3: 시간대 변환기 핸들러. "tz" 프리픽스로 사용합니다. +/// +/// 예: tz → 주요 도시 현재 시각 목록 +/// tz seoul → 서울 현재 시각 +/// tz new york → 뉴욕 현재 시각 +/// tz 14:30 to la → 현재 시간대(KST) 14:30을 LA 시각으로 변환 +/// tz meeting 09:00 → 서울 기준 9시 = 주요 도시별 동일 시각 표시 +/// Enter → 결과를 클립보드에 복사. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim().ToLowerInvariant(); + var items = new List(); + 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>(items); + } + + // "meeting HH:mm" 모드 — 서울 기준 특정 시각을 전 도시 변환 + if (q.StartsWith("meeting ") || q.StartsWith("미팅 ")) + { + var timePart = q.Contains(' ') ? q[(q.IndexOf(' ') + 1)..].Trim() : ""; + return Task.FromResult>( + BuildMeetingItems(timePart, now)); + } + + // "HH:mm to " 또는 "HH:mm " 변환 모드 + var convResult = TryParseConversion(q, now); + if (convResult != null) + return Task.FromResult>(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>(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 BuildMeetingItems(string timePart, DateTimeOffset utcNow) + { + var items = new List(); + + 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? TryParseConversion(string q, DateTimeOffset utcNow) + { + // "HH:mm to " 또는 "HH:mm " 패턴 + 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(); + // 서울 기준으로 입력 시각 해석 + 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; + } +} diff --git a/src/AxCopilot/Handlers/TimerHandler.cs b/src/AxCopilot/Handlers/TimerHandler.cs new file mode 100644 index 0000000..4776c20 --- /dev/null +++ b/src/AxCopilot/Handlers/TimerHandler.cs @@ -0,0 +1,270 @@ +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// 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 → 특정 타이머 취소 +/// Enter → 타이머 시작 (또는 중단). +/// +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 _timers = []; + private static readonly object _lock = new(); + private static int _nextId = 1; + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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>(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>(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>(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 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 items) + { + List 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")); + } + } +} diff --git a/src/AxCopilot/Handlers/TipHandler.cs b/src/AxCopilot/Handlers/TipHandler.cs new file mode 100644 index 0000000..c95b63b --- /dev/null +++ b/src/AxCopilot/Handlers/TipHandler.cs @@ -0,0 +1,242 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// 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 → 결과를 클립보드에 복사. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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>(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>(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>(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>(items); + } + } + + // 기본: 팁 퍼센트별 목록 + items.AddRange(BuildDefaultTipItems(amount)); + 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("Tip", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + return Task.CompletedTask; + } + + // ── 계산 빌더 ───────────────────────────────────────────────────────────── + + private static IEnumerable 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 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 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 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 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}원"; + } +} diff --git a/src/AxCopilot/Handlers/TodayHandler.cs b/src/AxCopilot/Handlers/TodayHandler.cs new file mode 100644 index 0000000..68a6579 --- /dev/null +++ b/src/AxCopilot/Handlers/TodayHandler.cs @@ -0,0 +1,250 @@ +using System.Text.Json; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; + +namespace AxCopilot.Handlers; + +/// +/// L25-4: 오늘 업무 통합 뷰. "today" 프리픽스로 사용합니다. +/// +/// 예: today → 오늘 날짜/요일/공휴일 + 할일 + 알림 + 공휴일 현황 +/// +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 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> GetItemsAsync(string query, CancellationToken ct) + { + var items = new List(); + 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>(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 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(); + 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; + } +} diff --git a/src/AxCopilot/Handlers/TodoHandler.cs b/src/AxCopilot/Handlers/TodoHandler.cs new file mode 100644 index 0000000..ca185c0 --- /dev/null +++ b/src/AxCopilot/Handlers/TodoHandler.cs @@ -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; + +/// +/// L16-3: 간단 할 일 목록 핸들러. "todo" 프리픽스로 사용합니다. +/// +/// 예: todo → 전체 할 일 목록 +/// todo 보고서 작성 → 새 항목 추가 +/// todo done 1 → 1번 항목 완료 처리 +/// todo del 1 → 1번 항목 삭제 +/// todo clear → 완료 항목 모두 삭제 +/// todo clear all → 전체 삭제 +/// todo <검색어> → 키워드 필터 +/// Enter → 완료 토글 또는 항목 삭제. +/// 저장: %APPDATA%\AxCopilot\todos.json +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + 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>(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>(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>(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>(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>(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>(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>(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()); + NotificationService.Notify("Todo", "전체 삭제됨"); + break; + } + return Task.CompletedTask; + } + + // ── 저장/불러오기 ───────────────────────────────────────────────────────── + + private static List LoadTodos() + { + try + { + if (!System.IO.File.Exists(DataPath)) return new List(); + var json = System.IO.File.ReadAllText(DataPath, System.Text.Encoding.UTF8); + return JsonSerializer.Deserialize>(json, JsonOpts) ?? new List(); + } + catch { return new List(); } + } + + private static void SaveTodos(List 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); + } +} diff --git a/src/AxCopilot/Handlers/TomlHandler.cs b/src/AxCopilot/Handlers/TomlHandler.cs new file mode 100644 index 0000000..8cf3ea7 --- /dev/null +++ b/src/AxCopilot/Handlers/TomlHandler.cs @@ -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; + +/// +/// 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 → 값 복사. +/// +public partial class TomlHandler : IActionHandler +{ + public string? Prefix => "toml"; + + public PluginMetadata Metadata => new( + "TOML", + "TOML 파서·분석기 — 키 조회·유효성 검사·평탄화", + "1.0", + "AX"); + + // TOML 노드 (경량 표현) + private sealed class TomlTable : Dictionary { } + private sealed class TomlArray : List { } + + 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)) + { + if (string.IsNullOrWhiteSpace(clipboard)) + { + items.Add(new LauncherItem("TOML 파서·분석기", + "클립보드에 TOML을 복사하세요 · toml validate / keys / get / stats / flat", + null, null, Symbol: "\uE8EC")); + return Task.FromResult>(items); + } + // 기본: 유효성 확인 + 최상위 키 + var (tbl, err) = ParseToml(clipboard!); + if (err != null) + { + items.Add(ErrorItem($"TOML 파싱 오류: {err}")); + return Task.FromResult>(items); + } + items.Add(new LauncherItem("TOML 파싱 성공 ✓", + $"최상위 키 {tbl!.Count}개 · toml get / flat / stats", null, null, Symbol: "\uE8EC")); + BuildTopKeys(items, tbl!); + return Task.FromResult>(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>(items); + } + + var (table, parseErr) = ParseToml(clipboard ?? ""); + if (parseErr != null && sub != "validate") + { + items.Add(ErrorItem($"TOML 파싱 오류: {parseErr}")); + return Task.FromResult>(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(); + 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(); + 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 · stats · flat", + null, null, Symbol: "\uE783")); + 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("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 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 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"); +} diff --git a/src/AxCopilot/Handlers/UnicodeHandler.cs b/src/AxCopilot/Handlers/UnicodeHandler.cs new file mode 100644 index 0000000..b25d30d --- /dev/null +++ b/src/AxCopilot/Handlers/UnicodeHandler.cs @@ -0,0 +1,344 @@ +using System.Globalization; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L11-4: 유니코드 문자 조회 핸들러. "unicode" 프리픽스로 사용합니다. +/// +/// 예: unicode A → 문자 'A'의 코드포인트·카테고리·이름 조회 +/// unicode U+1F600 → 코드포인트로 문자 조회 +/// unicode 0x1F600 → 16진수 코드포인트 +/// unicode 128512 → 10진수 코드포인트 +/// unicode 가 → 한글 문자 분석 +/// unicode smile → 문자 설명으로 검색 (이모지 이름 포함) +/// Enter → 문자를 클립보드에 복사. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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>(items); + } + + // 순수 10진수 코드포인트 + if (int.TryParse(q, out var decCp) && decCp >= 0) + { + items.AddRange(BuildCodePointItems(decCp)); + return Task.FromResult>(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>(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>(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 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 GetCodePoints(string s) + { + var result = new List(); + 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]); + } +} diff --git a/src/AxCopilot/Handlers/UnitHandler.cs b/src/AxCopilot/Handlers/UnitHandler.cs new file mode 100644 index 0000000..316bbcf --- /dev/null +++ b/src/AxCopilot/Handlers/UnitHandler.cs @@ -0,0 +1,284 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// 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 → 결과 복사. +/// +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 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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(items); + } + + // 변환: unit <값> + if (parts.Length < 2) + { + items.Add(new LauncherItem("입력 형식", + "unit <값> <단위> [대상단위] 예: unit 100 km m", null, null, Symbol: "\uE783")); + return Task.FromResult>(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>(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>(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>(items); + } + if (fromDef.Cat != toDef.Cat) + { + items.Add(new LauncherItem("카테고리 불일치", + $"{fromDef.Cat} ≠ {toDef.Cat} — 같은 종류끼리만 변환 가능", + null, null, Symbol: "\uE783")); + return Task.FromResult>(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>(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); + } +} diff --git a/src/AxCopilot/Handlers/UuidHandler.cs b/src/AxCopilot/Handlers/UuidHandler.cs new file mode 100644 index 0000000..74a3ba6 --- /dev/null +++ b/src/AxCopilot/Handlers/UuidHandler.cs @@ -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; + +/// +/// 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 분석 (버전, 타임스탬프 등) +/// Enter → 결과를 클립보드에 복사. +/// +public class UuidHandler : IActionHandler +{ + public string? Prefix => "uuid"; + + public PluginMetadata Metadata => new( + "UUID", + "UUID/GUID 생성기 — v4 · 순차 · 짧은 ID · 분석", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(items); + } + + var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var sub = parts[0].ToLowerInvariant(); + + // "uuid parse " + if (sub == "parse" && parts.Length >= 2) + { + items.AddRange(ParseUuid(string.Join(" ", parts.Skip(1)))); + return Task.FromResult>(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>(items); + } + + // "uuid seq" + if (sub == "seq") + { + items.AddRange(GenerateSequential(5)); + return Task.FromResult>(items); + } + + // "uuid short" + if (sub == "short") + { + items.AddRange(GenerateShort(5)); + return Task.FromResult>(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>(items); + } + + // "uuid v4" + if (sub == "v4") + { + items.AddRange(GenerateV4(5)); + return Task.FromResult>(items); + } + + // "uuid <숫자>" — N개 생성 + if (int.TryParse(sub, out var count)) + { + count = Math.Clamp(count, 1, 20); + items.AddRange(GenerateV4(count)); + return Task.FromResult>(items); + } + + // UUID 자체를 입력한 경우 → parse + if (Guid.TryParse(q, out _)) + { + items.AddRange(ParseUuid(q)); + return Task.FromResult>(items); + } + + items.Add(new LauncherItem("알 수 없는 명령", + "예: uuid / uuid 5 / uuid upper / uuid seq / uuid short / uuid parse", + 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("UUID", "클립보드에 복사했습니다."); + } + catch { /* 비핵심 */ } + } + return Task.CompletedTask; + } + + // ── 생성 헬퍼 ───────────────────────────────────────────────────────────── + + private static IEnumerable GenerateV4(int count) + { + var all = new List(); + 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"); + } + + /// + /// 시간 기반 순차 UUID (UUIDv7 스타일: 밀리초 타임스탬프 + 랜덤 비트). + /// 정렬 가능하고 시간 정보 포함. + /// + private static IEnumerable GenerateSequential(int count) + { + var all = new List(); + 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 GenerateShort(int count) + { + var all = new List(); + 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 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 생성 구현 ──────────────────────────────────────────────────────── + + /// UUIDv7 스타일 순차 GUID: 상위 48비트 = Unix ms 타임스탬프, 하위 = 랜덤 + 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; } + } +} diff --git a/src/AxCopilot/Handlers/VolHandler.cs b/src/AxCopilot/Handlers/VolHandler.cs new file mode 100644 index 0000000..3cfe459 --- /dev/null +++ b/src/AxCopilot/Handlers/VolHandler.cs @@ -0,0 +1,211 @@ +using System.Runtime.InteropServices; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L27-2: 시스템 볼륨 제어 핸들러. "vol" 프리픽스로 사용합니다. +/// +/// 예: vol → 현재 볼륨 표시 +/// vol 50 → 볼륨 50% 설정 +/// vol up / down → ±10% 조절 +/// vol mute → 음소거 토글 +/// Enter → 해당 명령 실행. +/// Windows Core Audio API (IAudioEndpointVolume) COM 인터페이스 사용. +/// +public class VolHandler : IActionHandler +{ + public string? Prefix => "vol"; + + public PluginMetadata Metadata => new( + "볼륨 제어", + "시스템 볼륨 조절 — 설정·증감·음소거", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim().ToLowerInvariant(); + var items = new List(); + + // 현재 볼륨 읽기 + 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>(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>(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>(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); + } +} diff --git a/src/AxCopilot/Handlers/WolHandler.cs b/src/AxCopilot/Handlers/WolHandler.cs new file mode 100644 index 0000000..68845ff --- /dev/null +++ b/src/AxCopilot/Handlers/WolHandler.cs @@ -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; + +/// +/// 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 → 매직 패킷 전송. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + 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>(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>(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 LoadHosts() + { + try + { + if (!System.IO.File.Exists(StorePath)) return new(); + var json = System.IO.File.ReadAllText(StorePath); + return JsonSerializer.Deserialize>(json) ?? new(); + } + catch { return new(); } + } + + private static void SaveHosts(List hosts) + { + try + { + System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(StorePath)!); + System.IO.File.WriteAllText(StorePath, + JsonSerializer.Serialize(hosts, new JsonSerializerOptions { WriteIndented = true })); + } + catch { /* 비핵심 */ } + } +} diff --git a/src/AxCopilot/Handlers/WorkTimeHandler.cs b/src/AxCopilot/Handlers/WorkTimeHandler.cs new file mode 100644 index 0000000..cf4d64a --- /dev/null +++ b/src/AxCopilot/Handlers/WorkTimeHandler.cs @@ -0,0 +1,241 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; + +namespace AxCopilot.Handlers; + +/// +/// 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 → 결과 복사 +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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>(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>(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>(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; + } +} diff --git a/src/AxCopilot/Handlers/WslHandler.cs b/src/AxCopilot/Handlers/WslHandler.cs new file mode 100644 index 0000000..937b94a --- /dev/null +++ b/src/AxCopilot/Handlers/WslHandler.cs @@ -0,0 +1,274 @@ +using System.Text; +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// 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 실행 또는 명령 실행. +/// +public class WslHandler : IActionHandler +{ + public string? Prefix => "wsl"; + + public PluginMetadata Metadata => new( + "WSL", + "WSL 관리 — distro 목록 · 실행 · 종료", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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>(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 GetDistros() + { + var result = new List(); + 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; + } +} diff --git a/src/AxCopilot/Handlers/XlHandler.cs b/src/AxCopilot/Handlers/XlHandler.cs new file mode 100644 index 0000000..6bf0e07 --- /dev/null +++ b/src/AxCopilot/Handlers/XlHandler.cs @@ -0,0 +1,227 @@ +using System.Windows; +using AxCopilot.SDK; +using AxCopilot.Services; + +namespace AxCopilot.Handlers; + +/// +/// 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 → 함수 이름 복사. +/// +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> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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>(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>(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"); +} diff --git a/src/AxCopilot/Handlers/XmlHandler.cs b/src/AxCopilot/Handlers/XmlHandler.cs new file mode 100644 index 0000000..1b2ce07 --- /dev/null +++ b/src/AxCopilot/Handlers/XmlHandler.cs @@ -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; + +/// +/// L10-1: XML 포맷터·검증기·XPath 쿼리 핸들러. "xml" 프리픽스로 사용합니다. +/// +/// 예: xml → 클립보드의 XML 자동 포맷 +/// xml 1 → 인라인 XML 포맷 +/// xml compact → 클립보드 XML 압축 (공백 제거) +/// xml xpath //a → 클립보드 XML에 XPath 쿼리 +/// xml validate → XML 유효성 검증 +/// xml attr → XML 속성 목록 추출 +/// xml minify → compact 와 동일 +/// Enter → 결과를 클립보드에 복사. +/// +public class XmlHandler : IActionHandler +{ + public string? Prefix => "xml"; + + public PluginMetadata Metadata => new( + "XML", + "XML 포맷터 · 압축 · 검증 · XPath 쿼리", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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 … / 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>(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>(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 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 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(); + 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(); + 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 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 + { + 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 ""; } + } +} diff --git a/src/AxCopilot/Handlers/YamlHandler.cs b/src/AxCopilot/Handlers/YamlHandler.cs new file mode 100644 index 0000000..da9e88f --- /dev/null +++ b/src/AxCopilot/Handlers/YamlHandler.cs @@ -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; + +/// +/// L17-3: YAML 파서·포맷터·검증 핸들러. "yaml" 프리픽스로 사용합니다. +/// +/// 예: yaml → 클립보드 YAML 구조 분석 +/// yaml validate → YAML 유효성 검사 +/// yaml keys → 최상위 키 목록 +/// yaml get key.subkey → 특정 경로 값 조회 (점 표기법) +/// yaml stats → 줄 수·키 수·깊이 통계 +/// yaml flat → 점 표기법으로 평탄화 +/// Enter → 결과 복사. +/// 외부 라이브러리 없이 순수 파싱 구현 (기본 YAML 스펙 지원). +/// +public partial class YamlHandler : IActionHandler +{ + public string? Prefix => "yaml"; + + public PluginMetadata Metadata => new( + "YAML", + "YAML 파서·검증 — 키 조회 · 구조 분석 · 평탄화", + "1.0", + "AX"); + + 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("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>(items); + } + + var stat = QuickStat(clipboard); + items.Add(new LauncherItem("── 클립보드 미리보기 ──", stat, null, ("copy", stat), Symbol: "\uE8A5")); + return Task.FromResult>(items); + } + + if (string.IsNullOrWhiteSpace(clipboard)) + { + items.Add(new LauncherItem("클립보드가 비어 있습니다", + "YAML 텍스트를 복사한 뒤 사용하세요", null, null, Symbol: "\uE946")); + return Task.FromResult>(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 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>(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 파서 (경량, 기본 스펙만) ──────────────────────────────────────── + + /// + /// 경량 YAML 파서. 지원: 스칼라, 매핑(들여쓰기 기반), 시퀀스(- 표기). + /// 멀티라인/앵커/태그/복잡 흐름 스타일 미지원. + /// + 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 lines, int baseIndent, ref string error, out int consumed) + { + consumed = 0; + var dict = new Dictionary(); + var list = new List(); + 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 d => d.Count + d.Values.Sum(v => CountKeys(v)), + List 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 dict) + { + var key = parts[0]; + if (!dict.TryGetValue(key, out var child)) return null; + return GetByPath(child, parts[1..]); + } + if (node is List 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 dict: + foreach (var (k, v) in dict) + Flatten(v, string.IsNullOrEmpty(prefix) ? k : $"{prefix}.{k}", result); + break; + case List 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 d => $"{{...{d.Count}개 키}}", + List l => $"[...{l.Count}개 항목]", + _ => v.ToString() ?? "", + }; + + [GeneratedRegex(@"^\s*[\w\-""']+\s*:")] + private static partial Regex KeyLineRegex(); +} diff --git a/src/AxCopilot/Handlers/ZipHandler.cs b/src/AxCopilot/Handlers/ZipHandler.cs new file mode 100644 index 0000000..7b97c94 --- /dev/null +++ b/src/AxCopilot/Handlers/ZipHandler.cs @@ -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; + +/// +/// 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으로 압축 +/// 경로 미입력 시 클립보드 경로 자동 감지. +/// +public class ZipHandler : IActionHandler +{ + public string? Prefix => "zip"; + + public PluginMetadata Metadata => new( + "Zip", + "아카이브 관리 — zip 목록 · 압축 해제 · 폴더 압축", + "1.0", + "AX"); + + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var items = new List(); + + 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>(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>(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>(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>(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>(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>(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>(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 { /* 비핵심 */ } + } +} diff --git a/src/AxCopilot/Models/AppSettings.cs b/src/AxCopilot/Models/AppSettings.cs index dc6ce92..c482563 100644 --- a/src/AxCopilot/Models/AppSettings.cs +++ b/src/AxCopilot/Models/AppSettings.cs @@ -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 Snippets { get; set; } = new(); + [JsonPropertyName("quickLinks")] + public List 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 CustomHotkeys { get; set; } = new(); + + [JsonPropertyName("appSessions")] + public List AppSessions { get; set; } = new(); + + [JsonPropertyName("schedules")] + public List Schedules { get; set; } = new(); + + [JsonPropertyName("macros")] + public List Macros { get; set; } = new(); + + [JsonPropertyName("ssh_hosts")] + public List SshHosts { get; set; } = new(); + [JsonPropertyName("llm")] public LlmSettings Llm { get; set; } = new(); } @@ -245,6 +266,18 @@ public class LauncherSettings /// 모니터별 독 바 위치. key=디바이스명, value=[left, top] [JsonPropertyName("monitorDockPositions")] public Dictionary> MonitorDockPositions { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + /// 런처 마지막 위치 기억 여부. 기본 false. + [JsonPropertyName("rememberPosition")] + public bool RememberPosition { get; set; } = false; + + /// 런처 마지막 Left 좌표. -1이면 기본 위치. + [JsonPropertyName("lastLeft")] + public double LastLeft { get; set; } = -1; + + /// 런처 마지막 Top 좌표. -1이면 기본 위치. + [JsonPropertyName("lastTop")] + public double LastTop { get; set; } = -1; } /// @@ -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 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 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 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 diff --git a/src/AxCopilot/Services/FileTagService.cs b/src/AxCopilot/Services/FileTagService.cs new file mode 100644 index 0000000..dfa4f11 --- /dev/null +++ b/src/AxCopilot/Services/FileTagService.cs @@ -0,0 +1,155 @@ +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AxCopilot.Services; + +/// +/// Phase L3-5: 파일 태그 시스템 서비스. +/// 파일·폴더에 사용자 정의 태그를 부여하고 태그 기반 검색을 지원합니다. +/// 데이터는 %APPDATA%\AxCopilot\file_tags.json에 저장됩니다. +/// +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, + }; + + /// key = 정규화된 파일 경로, value = 태그 집합 + private Dictionary> _data = + new(StringComparer.OrdinalIgnoreCase); + + private bool _loaded; + + // ─── 싱글턴 ───────────────────────────────────────────────────────────── + private static FileTagService? _instance; + public static FileTagService Instance => _instance ??= new FileTagService(); + private FileTagService() { } + + // ─── 공개 API ──────────────────────────────────────────────────────────── + + /// 파일에 태그를 추가합니다. + 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(StringComparer.OrdinalIgnoreCase); + _data[path] = tags; + } + tags.Add(tag); + Save(); + } + + /// 파일에서 태그를 제거합니다. + 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(); + } + + /// 파일의 모든 태그를 제거합니다. + public void ClearTags(string path) + { + EnsureLoaded(); + path = NormalizePath(path); + _data.Remove(path); + Save(); + } + + /// 파일의 태그 목록을 반환합니다. + public IReadOnlyList GetTags(string path) + { + EnsureLoaded(); + path = NormalizePath(path); + return _data.TryGetValue(path, out var tags) + ? tags.OrderBy(t => t).ToList() + : Array.Empty(); + } + + /// 특정 태그가 부여된 파일 경로 목록을 반환합니다. + public IReadOnlyList GetFilesByTag(string tag) + { + EnsureLoaded(); + tag = NormalizeTag(tag); + return _data + .Where(kv => kv.Value.Contains(tag)) + .Select(kv => kv.Key) + .OrderBy(p => p) + .ToList(); + } + + /// 등록된 모든 태그와 각 파일 수를 반환합니다. + public IReadOnlyDictionary GetAllTags() + { + EnsureLoaded(); + return _data + .SelectMany(kv => kv.Value) + .GroupBy(t => t, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.Count()); + } + + /// 경로에 태그가 하나 이상 있는지 확인합니다. + 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>>( + File.ReadAllText(TagFile), JsonOpts); + if (raw != null) + { + _data = raw.ToDictionary( + kv => kv.Key, + kv => new HashSet(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(" ", "-"); +} diff --git a/src/AxCopilot/Services/IconCacheService.cs b/src/AxCopilot/Services/IconCacheService.cs new file mode 100644 index 0000000..e87ce8e --- /dev/null +++ b/src/AxCopilot/Services/IconCacheService.cs @@ -0,0 +1,192 @@ +using System.Collections.Concurrent; +using System.IO; +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Interop; +using System.Windows.Media.Imaging; + +namespace AxCopilot.Services; + +/// +/// Shell32 SHGetFileInfo를 사용하여 파일/폴더의 Windows 아이콘을 추출·캐시합니다. +/// 캐시 위치: %LOCALAPPDATA%\AxCopilot\IconCache\{확장자}.png +/// GetIconPath()는 캐시 미스 시 null을 반환하고 백그라운드에서 추출을 시작합니다. +/// WarmUp()을 앱 시작 시 호출하면 자주 쓰는 확장자를 미리 준비합니다. +/// +internal static class IconCacheService +{ + private static readonly string _cacheDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "AxCopilot", "IconCache"); + + /// key(확장자 또는 "folder") → PNG 캐시 파일 경로 (null = 추출 실패) + private static readonly ConcurrentDictionary _cache = + new(StringComparer.OrdinalIgnoreCase); + + private static volatile bool _warmupDone; + + // ─── Win32 ────────────────────────────────────────────────────────────── + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] + private struct SHFILEINFO + { + public IntPtr hIcon; + public int iIcon; + public uint dwAttributes; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string szDisplayName; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 80)] public string szTypeName; + } + + [DllImport("shell32.dll", CharSet = CharSet.Auto)] + private static extern IntPtr SHGetFileInfo( + string pszPath, uint dwFileAttributes, + ref SHFILEINFO psfi, uint cbFileInfo, uint uFlags); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool DestroyIcon(IntPtr hIcon); + + private const uint SHGFI_ICON = 0x000000100; + private const uint SHGFI_LARGEICON = 0x000000000; + private const uint SHGFI_USEFILEATTRIBUTES = 0x000000010; + private const uint FILE_ATTRIBUTE_NORMAL = 0x00000080; + private const uint FILE_ATTRIBUTE_DIRECTORY = 0x00000010; + + // ─── 공개 API ─────────────────────────────────────────────────────────── + + /// + /// 파일 확장자 기반 아이콘 PNG 경로를 반환합니다. + /// 캐시가 없으면 백그라운드에서 추출을 시작하고 null을 반환합니다. + /// + public static string? GetIconPath(string filePath, bool isDirectory = false) + { + var key = isDirectory ? "folder" : GetExtKey(filePath); + if (_cache.TryGetValue(key, out var cached)) return cached; + + _ = ExtractAsync(filePath, key, isDirectory); + return null; + } + + /// + /// 앱 시작 시 자주 쓰는 파일 형식의 아이콘을 미리 추출합니다. + /// 중복 실행은 자동으로 건너뜁니다. + /// + public static void WarmUp() + { + if (_warmupDone) return; + _warmupDone = true; + + // 캐시 디렉터리에 이미 저장된 PNG 로드 (재시작 시 빠른 복원) + _ = Task.Run(() => + { + try + { + Directory.CreateDirectory(_cacheDir); + foreach (var file in Directory.GetFiles(_cacheDir, "*.png")) + { + var key = Path.GetFileNameWithoutExtension(file); + _cache.TryAdd(key, file); + } + } + catch { } + }); + + // 자주 쓰는 확장자 사전 추출 (UI 스레드 부하를 분산하기 위해 순차 실행) + _ = Task.Run(async () => + { + await Task.Delay(2000); // 앱 초기화 완료 후 추출 시작 + var common = new (string Path, string Key, bool IsDir)[] + { + ("dummy.exe", "exe", false), + ("dummy.lnk", "lnk", false), + ("dummy.pdf", "pdf", false), + ("dummy.docx", "docx", false), + ("dummy.xlsx", "xlsx", false), + ("dummy.pptx", "pptx", false), + ("dummy.txt", "txt", false), + ("dummy.png", "png", false), + ("dummy.zip", "zip", false), + ("dummy.mp4", "mp4", false), + ("C:\\", "folder", true), + }; + foreach (var (path, key, isDir) in common) + { + if (_cache.ContainsKey(key)) continue; + await ExtractAsync(path, key, isDir); + await Task.Delay(80); // UI 스레드 부하 분산 + } + }); + } + + // ─── 내부 ─────────────────────────────────────────────────────────────── + + private static string GetExtKey(string filePath) + { + var ext = Path.GetExtension(filePath).ToLowerInvariant(); + return string.IsNullOrEmpty(ext) ? "noext" : ext.TrimStart('.'); + } + + private static async Task ExtractAsync(string filePath, string key, bool isDirectory) + { + if (_cache.ContainsKey(key)) return; + + try + { + Directory.CreateDirectory(_cacheDir); + var cachePath = Path.Combine(_cacheDir, key + ".png"); + + // 파일이 이미 캐시에 있으면 메모리 캐시에 등록만 + if (File.Exists(cachePath)) + { + _cache.TryAdd(key, cachePath); + return; + } + + // SHGetFileInfo + HICON → PNG 변환은 반드시 STA(Dispatcher) 스레드에서 + var dispatcher = Application.Current?.Dispatcher; + if (dispatcher == null) return; + + var saved = await dispatcher.InvokeAsync(() => + TryExtractAndSave(filePath, cachePath, isDirectory)); + + _cache.TryAdd(key, saved ? cachePath : null); + } + catch (Exception ex) + { + LogService.Warn($"[IconCache] '{key}' 추출 실패: {ex.Message}"); + _cache.TryAdd(key, null); + } + } + + /// Dispatcher(STA) 스레드에서 HICON을 PNG로 변환하여 저장합니다. + private static bool TryExtractAndSave(string filePath, string cachePath, bool isDirectory) + { + var info = new SHFILEINFO(); + uint flags = SHGFI_ICON | SHGFI_LARGEICON | SHGFI_USEFILEATTRIBUTES; + uint attrs = isDirectory ? FILE_ATTRIBUTE_DIRECTORY : FILE_ATTRIBUTE_NORMAL; + + SHGetFileInfo(filePath, attrs, ref info, (uint)Marshal.SizeOf(info), flags); + if (info.hIcon == IntPtr.Zero) return false; + + try + { + var bitmap = Imaging.CreateBitmapSourceFromHIcon( + info.hIcon, Int32Rect.Empty, + BitmapSizeOptions.FromEmptyOptions()); + bitmap.Freeze(); + + var encoder = new PngBitmapEncoder(); + encoder.Frames.Add(BitmapFrame.Create(bitmap)); + using var stream = File.OpenWrite(cachePath); + encoder.Save(stream); + return true; + } + catch + { + return false; + } + finally + { + DestroyIcon(info.hIcon); + } + } +} diff --git a/src/AxCopilot/Services/NotificationCenterService.cs b/src/AxCopilot/Services/NotificationCenterService.cs new file mode 100644 index 0000000..8938000 --- /dev/null +++ b/src/AxCopilot/Services/NotificationCenterService.cs @@ -0,0 +1,86 @@ +using System.Collections.Concurrent; + +namespace AxCopilot.Services; + +/// 알림 타입. +public enum NotificationType { Info, Success, Warning, Error } + +/// 알림 항목. +public record NotificationEntry( + string Title, + string Message, + NotificationType Type, + DateTime Timestamp); + +/// +/// Phase L3-7: 알림 센터 — 앱 전체에서 알림을 발행하고 이력을 관리합니다. +/// 트레이 BalloonTip + 인앱 알림 큐를 결합합니다. +/// +public static class NotificationCenterService +{ + private const int MaxHistory = 50; + + private static readonly ConcurrentQueue _history = new(); + + /// 새 알림 발행 이벤트. UI 계층에서 구독하여 인앱 알림 표시에 활용합니다. + public static event EventHandler? NotificationRaised; + + /// 최근 알림 이력 (최대 50건, 최신 순). + public static IReadOnlyList History + { + get + { + var list = _history.ToArray(); + Array.Reverse(list); + return list; + } + } + + /// 알림을 발행합니다. 트레이 BalloonTip + 이벤트를 동시에 발화합니다. + public static void Show(string title, string message, NotificationType type = NotificationType.Info) + { + var entry = Record(title, message, type); + NotificationService.NotifyBalloonOnly(title, message); + TryRaise(entry); + } + + /// 성공 알림. + public static void ShowSuccess(string title, string message) + => Show(title, message, NotificationType.Success); + + /// 경고 알림. + public static void ShowWarning(string title, string message) + => Show(title, message, NotificationType.Warning); + + /// 오류 알림. + public static void ShowError(string title, string message) + => Show(title, message, NotificationType.Error); + + /// 에이전트 작업 완료 알림 (BackgroundAgentService 전용 편의 메서드). + public static void NotifyAgentCompleted(string agentType, string? result) + { + var preview = result?.Length > 80 ? result[..77] + "…" : result ?? "완료"; + Show($"{agentType} 에이전트 완료", preview, NotificationType.Success); + } + + /// 이력을 초기화합니다. + public static void ClearHistory() + { + while (_history.TryDequeue(out _)) { } + } + + internal static NotificationEntry Record(string title, string message, NotificationType type) + { + var entry = new NotificationEntry(title, message, type, DateTime.Now); + _history.Enqueue(entry); + while (_history.Count > MaxHistory && _history.TryDequeue(out _)) { } + TryRaise(entry); + return entry; + } + + private static void TryRaise(NotificationEntry entry) + { + try { NotificationRaised?.Invoke(null, entry); } + catch (Exception) { } + } +} diff --git a/src/AxCopilot/Services/NotificationService.cs b/src/AxCopilot/Services/NotificationService.cs index 4b8f57a..4b55bac 100644 --- a/src/AxCopilot/Services/NotificationService.cs +++ b/src/AxCopilot/Services/NotificationService.cs @@ -14,5 +14,14 @@ internal static class NotificationService /// 트레이 풍선 알림을 표시합니다. public static void Notify(string title, string message) + { + NotifyBalloonOnly(title, message); + NotificationCenterService.Record(title, message, NotificationType.Info); + } + + internal static void NotifyBalloonOnly(string title, string message) => _showBalloon?.Invoke(title, message); + + public static void LogOnly(string title, string message) + => NotificationCenterService.Record(title, message, NotificationType.Info); } diff --git a/src/AxCopilot/Services/PomodoroService.cs b/src/AxCopilot/Services/PomodoroService.cs new file mode 100644 index 0000000..aeafffe --- /dev/null +++ b/src/AxCopilot/Services/PomodoroService.cs @@ -0,0 +1,179 @@ +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AxCopilot.Services; + +public enum PomodoroMode { Idle, Focus, Break } + +/// +/// Phase L3-9: 뽀모도로 타이머 서비스. +/// 집중(기본 25분) / 휴식(기본 5분) 모드를 관리하며 AppData에 상태를 저장합니다. +/// +internal sealed class PomodoroService +{ + public static readonly PomodoroService Instance = new(); + + private static readonly string StateFile = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "AxCopilot", "pomodoro.json"); + + // ─── 설정 ──────────────────────────────────────────────────────────────── + + public int FocusMinutes { get; set; } = 25; + public int BreakMinutes { get; set; } = 5; + + // ─── 상태 ──────────────────────────────────────────────────────────────── + + public PomodoroMode Mode { get; private set; } = PomodoroMode.Idle; + public bool IsRunning { get; private set; } + public TimeSpan Remaining { get; private set; } + + public event EventHandler? StateChanged; + + // ─── 내부 ──────────────────────────────────────────────────────────────── + + private System.Threading.Timer? _ticker; + + private PomodoroService() + { + LoadState(); + // 앱 재시작 후 진행 중이던 타이머가 있으면 재개 + if (IsRunning && Remaining > TimeSpan.Zero) + StartTicker(); + else + IsRunning = false; + } + + // ─── 공개 API ──────────────────────────────────────────────────────────── + + public void StartFocus() => StartMode(PomodoroMode.Focus); + public void StartBreak() => StartMode(PomodoroMode.Break); + + public void Stop() + { + StopTicker(); + IsRunning = false; + SaveState(); + StateChanged?.Invoke(this, EventArgs.Empty); + } + + public void Reset() + { + StopTicker(); + Mode = PomodoroMode.Idle; + IsRunning = false; + Remaining = TimeSpan.FromMinutes(FocusMinutes); + SaveState(); + StateChanged?.Invoke(this, EventArgs.Empty); + } + + public void Toggle() + { + if (IsRunning) Stop(); + else if (Mode == PomodoroMode.Break) StartBreak(); + else StartFocus(); + } + + // ─── 내부 ──────────────────────────────────────────────────────────────── + + private void StartMode(PomodoroMode mode) + { + StopTicker(); + Mode = mode; + IsRunning = true; + if (Remaining <= TimeSpan.Zero || Mode != mode) + Remaining = TimeSpan.FromMinutes(mode == PomodoroMode.Focus ? FocusMinutes : BreakMinutes); + StartTicker(); + SaveState(); + StateChanged?.Invoke(this, EventArgs.Empty); + } + + private void StartTicker() + { + _ticker = new System.Threading.Timer(_ => Tick(), null, 1000, 1000); + } + + private void StopTicker() + { + _ticker?.Dispose(); + _ticker = null; + } + + private void Tick() + { + if (Remaining <= TimeSpan.Zero) + { + // 타이머 종료 + Stop(); + var modeLabel = Mode == PomodoroMode.Focus ? "집중" : "휴식"; + NotificationCenterService.Show("뽀모도로 타이머", $"{modeLabel} 시간이 종료되었습니다.", NotificationType.Info); + return; + } + + Remaining -= TimeSpan.FromSeconds(1); + StateChanged?.Invoke(this, EventArgs.Empty); + } + + // ─── 영속성 ────────────────────────────────────────────────────────────── + + private void SaveState() + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(StateFile)!); + var dto = new PomodoroStateDto + { + Mode = Mode.ToString(), + IsRunning = IsRunning, + RemainingSeconds = (int)Remaining.TotalSeconds, + FocusMinutes = FocusMinutes, + BreakMinutes = BreakMinutes, + SavedAt = DateTime.Now, + }; + File.WriteAllText(StateFile, JsonSerializer.Serialize(dto)); + } + catch { /* 무시 */ } + } + + private void LoadState() + { + try + { + if (!File.Exists(StateFile)) goto defaults; + var json = File.ReadAllText(StateFile); + var dto = JsonSerializer.Deserialize(json); + if (dto == null) goto defaults; + + FocusMinutes = dto.FocusMinutes > 0 ? dto.FocusMinutes : 25; + BreakMinutes = dto.BreakMinutes > 0 ? dto.BreakMinutes : 5; + Mode = Enum.TryParse(dto.Mode, out var m) ? m : PomodoroMode.Idle; + IsRunning = dto.IsRunning; + + // 앱이 꺼진 동안 경과 시간 보정 + var elapsed = DateTime.Now - dto.SavedAt; + var saved = TimeSpan.FromSeconds(dto.RemainingSeconds); + Remaining = saved - elapsed; + if (Remaining < TimeSpan.Zero) { Remaining = TimeSpan.Zero; IsRunning = false; } + return; + } + catch { /* fall through */ } + + defaults: + FocusMinutes = 25; + BreakMinutes = 5; + Mode = PomodoroMode.Idle; + IsRunning = false; + Remaining = TimeSpan.FromMinutes(FocusMinutes); + } + + private class PomodoroStateDto + { + [JsonPropertyName("mode")] public string Mode { get; set; } = "Idle"; + [JsonPropertyName("isRunning")] public bool IsRunning { get; set; } + [JsonPropertyName("remainingSeconds")] public int RemainingSeconds { get; set; } + [JsonPropertyName("focusMinutes")] public int FocusMinutes { get; set; } = 25; + [JsonPropertyName("breakMinutes")] public int BreakMinutes { get; set; } = 5; + [JsonPropertyName("savedAt")] public DateTime SavedAt { get; set; } = DateTime.Now; + } +} diff --git a/src/AxCopilot/Services/SchedulerService.cs b/src/AxCopilot/Services/SchedulerService.cs new file mode 100644 index 0000000..c31740e --- /dev/null +++ b/src/AxCopilot/Services/SchedulerService.cs @@ -0,0 +1,214 @@ +using System.Diagnostics; +using System.IO; +using System.Windows; +using AxCopilot.Models; +using System.Linq; + +namespace AxCopilot.Services; + +/// +/// L5-6: 자동화 스케줄 백그라운드 서비스. +/// 30초 간격으로 활성 스케줄을 검사하여 해당 시각에 액션을 실행합니다. +/// +public sealed class SchedulerService : IDisposable +{ + private readonly SettingsService _settings; + private Timer? _timer; + private bool _disposed; + + public SchedulerService(SettingsService settings) + { + _settings = settings; + } + + // ─── 시작 / 중지 ───────────────────────────────────────────────────── + public void Start() + { + // 30초 간격 체크 (즉시 1회 실행 후) + _timer = new Timer(OnTick, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)); + LogService.Info("SchedulerService 시작"); + } + + public void Stop() + { + _timer?.Change(Timeout.Infinite, Timeout.Infinite); + LogService.Info("SchedulerService 중지"); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _timer?.Dispose(); + _timer = null; + } + + // ─── 트리거 검사 ───────────────────────────────────────────────────── + private void OnTick(object? _) + { + try + { + var now = DateTime.Now; + var schedules = _settings.Settings.Schedules; + bool dirty = false; + + foreach (var entry in schedules.ToList()) // ToList: 반복 중 수정 방지 + { + if (!ShouldFire(entry, now)) continue; + + LogService.Info($"스케줄 실행: '{entry.Name}' ({entry.TriggerType} {entry.TriggerTime})"); + ExecuteAction(entry); + + entry.LastRun = now; + dirty = true; + + // once 트리거는 실행 후 비활성화 + if (entry.TriggerType == "once") + entry.Enabled = false; + } + + if (dirty) _settings.Save(); + } + catch (Exception ex) + { + LogService.Error($"SchedulerService 오류: {ex.Message}"); + } + } + + // ─── 트리거 조건 검사 ───────────────────────────────────────────────── + private static bool ShouldFire(ScheduleEntry entry, DateTime now) + { + if (!entry.Enabled) return false; + if (!TimeSpan.TryParse(entry.TriggerTime, out var triggerTime)) return false; + + // 트리거 시각과 ±1분 이내인지 확인 + var targetDt = now.Date + triggerTime; + if (Math.Abs((now - targetDt).TotalMinutes) > 1.0) return false; + + // 오늘 이미 실행했는지 확인 (once 제외) + if (entry.TriggerType != "once" && + entry.LastRun.HasValue && + entry.LastRun.Value.Date == now.Date) + return false; + + bool typeMatch = entry.TriggerType switch + { + "daily" => true, + "weekdays" => now.DayOfWeek >= DayOfWeek.Monday && + now.DayOfWeek <= DayOfWeek.Friday, + "weekly" => entry.WeekDays.Count > 0 && + entry.WeekDays.Contains((int)now.DayOfWeek), + "once" => !entry.LastRun.HasValue && + entry.TriggerDate != null && + DateTime.TryParse(entry.TriggerDate, out var d) && + now.Date == d.Date, + _ => false + }; + + if (!typeMatch) return false; + + // ─── L6-4: 프로세스 조건 검사 ───────────────────────────────────── + if (!string.IsNullOrWhiteSpace(entry.ConditionProcess)) + { + var procName = entry.ConditionProcess.Trim() + .Replace(".exe", "", StringComparison.OrdinalIgnoreCase); + + bool isRunning = Process.GetProcessesByName(procName).Length > 0; + + if (entry.ConditionProcessMustRun && !isRunning) return false; + if (!entry.ConditionProcessMustRun && isRunning) return false; + } + + return true; + } + + // ─── 액션 실행 ──────────────────────────────────────────────────────── + private static void ExecuteAction(ScheduleEntry entry) + { + try + { + switch (entry.ActionType) + { + case "app": + if (!string.IsNullOrWhiteSpace(entry.ActionTarget)) + Process.Start(new ProcessStartInfo + { + FileName = entry.ActionTarget, + Arguments = entry.ActionArgs ?? "", + UseShellExecute = true + }); + break; + + case "notification": + var msg = string.IsNullOrWhiteSpace(entry.ActionTarget) + ? entry.Name + : entry.ActionTarget; + Application.Current?.Dispatcher.Invoke(() => + NotificationService.Notify($"[스케줄] {entry.Name}", msg)); + break; + } + } + catch (Exception ex) + { + LogService.Warn($"스케줄 액션 실행 실패 '{entry.Name}': {ex.Message}"); + } + } + + // ─── 유틸리티 (핸들러·편집기에서 공유) ────────────────────────────── + + /// 지정 스케줄의 다음 실행 예정 시각을 계산합니다. + public static DateTime? ComputeNextRun(ScheduleEntry entry) + { + if (!entry.Enabled) return null; + if (!TimeSpan.TryParse(entry.TriggerTime, out var t)) return null; + + var now = DateTime.Now; + + return entry.TriggerType switch + { + "once" when + !entry.LastRun.HasValue && + entry.TriggerDate != null && + DateTime.TryParse(entry.TriggerDate, out var d) && + (now.Date + t) >= now => + d.Date + t, + + "daily" => NextOccurrence(now, t, _ => true), + + "weekdays" => NextOccurrence(now, t, dt => + dt.DayOfWeek >= DayOfWeek.Monday && dt.DayOfWeek <= DayOfWeek.Friday), + + "weekly" when entry.WeekDays.Count > 0 => NextOccurrence(now, t, dt => + entry.WeekDays.Contains((int)dt.DayOfWeek)), + + _ => null + }; + } + + private static DateTime? NextOccurrence(DateTime now, TimeSpan t, Func dayFilter) + { + for (int i = 0; i <= 7; i++) + { + var candidate = now.Date.AddDays(i) + t; + if (candidate > now && dayFilter(candidate)) + return candidate; + } + return null; + } + + /// 트리거 유형 표시 이름. + public static string TriggerLabel(ScheduleEntry e) => e.TriggerType switch + { + "daily" => "매일", + "weekdays" => "주중(월~금)", + "weekly" => WeekDayLabel(e.WeekDays), + "once" => $"한번({e.TriggerDate})", + _ => e.TriggerType + }; + + private static readonly string[] DayShort = ["일", "월", "화", "수", "목", "금", "토"]; + + private static string WeekDayLabel(List days) => + days.Count == 0 ? "매주(요일 미지정)" : + "매주 " + string.Join("·", days.OrderBy(d => d).Select(d => DayShort[d])); +} diff --git a/src/AxCopilot/Services/UrlTemplateEngine.cs b/src/AxCopilot/Services/UrlTemplateEngine.cs new file mode 100644 index 0000000..015090f --- /dev/null +++ b/src/AxCopilot/Services/UrlTemplateEngine.cs @@ -0,0 +1,56 @@ +using System.Text.RegularExpressions; + +namespace AxCopilot.Services; + +/// +/// Phase L3-4: URL 템플릿 엔진. +/// {0}, {1}, {query}, {param} 등의 플레이스홀더를 인자로 치환합니다. +/// +public static class UrlTemplateEngine +{ + private static readonly Regex NamedPlaceholder = new(@"\{(\w+)\}", RegexOptions.Compiled); + private static readonly Regex IndexedPlaceholder = new(@"\{(\d+)\}", RegexOptions.Compiled); + + /// + /// URL 템플릿의 플레이스홀더를 args 배열로 치환합니다. + /// {0}, {1} — 순서 기반 | {query}, {param} 등 — 첫 번째 인자로 치환 + /// + public static string Expand(string urlTemplate, params string[] args) + { + if (string.IsNullOrEmpty(urlTemplate)) return urlTemplate; + + var encoded = args.Select(Uri.EscapeDataString).ToArray(); + + // 인덱스 기반: {0}, {1}… + var result = IndexedPlaceholder.Replace(urlTemplate, m => + { + if (int.TryParse(m.Groups[1].Value, out var idx) && idx < encoded.Length) + return encoded[idx]; + return m.Value; + }); + + // 이름 기반: {query}, {param}… → 첫 번째 인자로 치환 + result = NamedPlaceholder.Replace(result, m => + { + return encoded.Length > 0 ? encoded[0] : m.Value; + }); + + return result; + } + + /// 공백으로 구분된 쿼리 문자열을 파싱하여 템플릿에 적용합니다. + public static string ExpandFromQuery(string urlTemplate, string query) + { + var parts = query.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + return Expand(urlTemplate, parts); + } + + /// 템플릿에 포함된 플레이스홀더 목록을 반환합니다. + public static IReadOnlyList GetPlaceholders(string urlTemplate) + { + var result = new List(); + foreach (Match m in NamedPlaceholder.Matches(urlTemplate)) + result.Add(m.Groups[1].Value); + return result; + } +} diff --git a/src/AxCopilot/Themes/Symbols.cs b/src/AxCopilot/Themes/Symbols.cs index c361c44..722abeb 100644 --- a/src/AxCopilot/Themes/Symbols.cs +++ b/src/AxCopilot/Themes/Symbols.cs @@ -130,4 +130,5 @@ internal static class Symbols public const string RenameIcon = "\uE8AC"; // 이름 변경 (Rename와 동일) public const string MonitorIcon = "\uE7F4"; // 시스템 모니터 (Computer와 동일) public const string ScaffoldIcon = "\uE8F1"; // 스캐폴딩 (프로젝트 구조) + public const string Tag = "\uEAB4"; // 파일 태그 } diff --git a/src/AxCopilot/Views/BatchRenameWindow.xaml b/src/AxCopilot/Views/BatchRenameWindow.xaml new file mode 100644 index 0000000..4549b6e --- /dev/null +++ b/src/AxCopilot/Views/BatchRenameWindow.xaml @@ -0,0 +1,546 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AxCopilot/Views/BatchRenameWindow.xaml.cs b/src/AxCopilot/Views/BatchRenameWindow.xaml.cs new file mode 100644 index 0000000..8f403b5 --- /dev/null +++ b/src/AxCopilot/Views/BatchRenameWindow.xaml.cs @@ -0,0 +1,426 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.IO; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using System.Windows; +using System.Windows.Input; +using AxCopilot.Services; + +namespace AxCopilot.Views; + +/// +/// L5-5: 배치 파일 이름변경 창. +/// 여러 파일을 변수 패턴 또는 정규식으로 미리보기 후 일괄 적용합니다. +/// +public partial class BatchRenameWindow : Window +{ + private readonly ObservableCollection _entries = new(); + private bool _keepExt = true; + private bool _regexMode = false; + private int _startNumber = 1; + + // ────────────────────────────────────────────────────────────────────── + public BatchRenameWindow() + { + InitializeComponent(); + PreviewGrid.ItemsSource = _entries; + + // 항목 변경 → 카운트 배지 + 빈 상태 갱신 + _entries.CollectionChanged += (_, _) => RefreshUI(); + + // 드래그 앤 드롭 + Drop += Window_Drop; + DragOver += Window_DragOver; + DragLeave += (_, _) => DropHintOverlay.Visibility = Visibility.Collapsed; + } + + // ─── 외부에서 파일 목록 주입 ───────────────────────────────────────── + public void AddFiles(IEnumerable paths) + { + foreach (var p in paths) + { + if (!File.Exists(p)) continue; + if (_entries.Any(e => string.Equals(e.OriginalPath, p, StringComparison.OrdinalIgnoreCase))) continue; + _entries.Add(new RenameEntry + { + OriginalPath = p, + OriginalName = Path.GetFileName(p) + }); + } + UpdatePreviews(); + } + + // ─── 패턴 엔진 ─────────────────────────────────────────────────────── + private static string ApplyPattern( + string pattern, + string originalName, + string ext, + int index, + int startNum, + bool keepExt, + bool regexMode) + { + if (regexMode) + { + // /regex/replacement/ 형식 + var m = Regex.Match(pattern, @"^/(.+)/([^/]*)/?$"); + if (m.Success) + { + var rxPat = m.Groups[1].Value; + var repl = m.Groups[2].Value; + try + { + var result = Regex.Replace(originalName, rxPat, repl); + if (keepExt && !string.IsNullOrEmpty(ext) && + !result.EndsWith(ext, StringComparison.OrdinalIgnoreCase)) + result += ext; + return result; + } + catch { return "⚠ 정규식 오류"; } + } + // 패턴 불완전 → 원본 그대로 + return originalName + ext; + } + + // 변수 모드 + var n = index + startNum; + var newName = pattern; + + // {n:자릿수} — 자릿수 패딩 + newName = Regex.Replace(newName, @"\{n:(\d+)\}", rm => + { + if (int.TryParse(rm.Groups[1].Value, out var digits)) + return n.ToString($"D{digits}"); + return n.ToString(); + }); + + // {date:format} + newName = Regex.Replace(newName, @"\{date:([^}]+)\}", rm => + { + try { return DateTime.Today.ToString(rm.Groups[1].Value); } + catch { return DateTime.Today.ToString("yyyy-MM-dd"); } + }); + + newName = newName + .Replace("{n}", n.ToString()) + .Replace("{name}", originalName) + .Replace("{orig}", originalName) + .Replace("{date}", DateTime.Today.ToString("yyyy-MM-dd")) + .Replace("{ext}", ext.TrimStart('.')); + + // 확장자 유지 + if (keepExt && !string.IsNullOrEmpty(ext)) + { + if (!Path.HasExtension(newName)) + newName += ext; + } + + return newName; + } + + private void UpdatePreviews() + { + var pattern = PatternBox?.Text ?? "{n}_{name}"; + var startNum = _startNumber; + var keepExt = _keepExt; + var regexMode = _regexMode; + + // 새 이름 계산 + for (int i = 0; i < _entries.Count; i++) + { + var entry = _entries[i]; + var ext = Path.GetExtension(entry.OriginalPath); + var nameNoExt = Path.GetFileNameWithoutExtension(entry.OriginalPath); + entry.NewName = ApplyPattern(pattern, nameNoExt, ext, i, startNum, keepExt, regexMode); + } + + // 충돌 감지 (같은 폴더 내 같은 새 이름 OR 기존 파일) + var grouped = _entries + .GroupBy(e => Path.Combine( + Path.GetDirectoryName(e.OriginalPath) ?? "", + e.NewName), + StringComparer.OrdinalIgnoreCase); + + var conflictPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var g in grouped) + { + if (g.Count() > 1) + foreach (var e in g) conflictPaths.Add(e.OriginalPath); + } + + foreach (var entry in _entries) + { + var destPath = Path.Combine( + Path.GetDirectoryName(entry.OriginalPath) ?? "", + entry.NewName); + + // 충돌: 동명 중복 OR 목적지 파일이 이미 존재(원본 제외) + var nameConflict = conflictPaths.Contains(entry.OriginalPath); + var destExists = File.Exists(destPath) && + !string.Equals(destPath, entry.OriginalPath, StringComparison.OrdinalIgnoreCase); + entry.HasConflict = nameConflict || destExists; + } + + RefreshUI(); + } + + private void RefreshUI() + { + var count = _entries.Count; + var conflicts = _entries.Count(e => e.HasConflict); + + FileCountBadge.Text = count > 0 ? $"— {count}개 파일" : ""; + EmptyState.Visibility = count == 0 ? Visibility.Visible : Visibility.Collapsed; + PreviewGrid.Visibility = count == 0 ? Visibility.Collapsed : Visibility.Visible; + + ConflictLabel.Text = conflicts > 0 ? $"⚠ 충돌 {conflicts}개" : ""; + } + + // ─── 타이틀바 ──────────────────────────────────────────────────────── + private void TitleBar_MouseDown(object sender, MouseButtonEventArgs e) + { + if (e.LeftButton == MouseButtonState.Pressed) DragMove(); + } + + private void BtnClose_Click(object sender, MouseButtonEventArgs e) => Close(); + + // ─── 패턴 입력 ─────────────────────────────────────────────────────── + private void PatternBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e) + => UpdatePreviews(); + + private void StartNumberBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e) + { + if (int.TryParse(StartNumberBox.Text, out var n) && n >= 0) + _startNumber = n; + UpdatePreviews(); + } + + // ─── 모드 선택 ─────────────────────────────────────────────────────── + private void BtnModeVar_Click(object sender, MouseButtonEventArgs e) + { + _regexMode = false; + SetModeUI(); + UpdatePreviews(); + } + + private void BtnModeRegex_Click(object sender, MouseButtonEventArgs e) + { + _regexMode = true; + SetModeUI(); + if (string.IsNullOrWhiteSpace(PatternBox.Text) || !PatternBox.Text.StartsWith('/')) + PatternBox.Text = "/(old)/(new)/"; + UpdatePreviews(); + } + + private void SetModeUI() + { + var accent = TryFindResource("AccentColor") as System.Windows.Media.Brush + ?? System.Windows.Media.Brushes.DodgerBlue; + var sec = TryFindResource("SecondaryText") as System.Windows.Media.Brush + ?? System.Windows.Media.Brushes.Gray; + var dimBg = new System.Windows.Media.SolidColorBrush( + System.Windows.Media.Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); + + if (_regexMode) + { + BtnModeVar.Background = dimBg; + BtnModeVarText.Foreground = sec; + BtnModeRegex.Background = accent; + BtnModeRegexText.Foreground = System.Windows.Media.Brushes.White; + } + else + { + BtnModeVar.Background = accent; + BtnModeVarText.Foreground = System.Windows.Media.Brushes.White; + BtnModeRegex.Background = dimBg; + BtnModeRegexText.Foreground = sec; + } + } + + // ─── 확장자 유지 토글 ──────────────────────────────────────────────── + private void ExtToggle_Click(object sender, MouseButtonEventArgs e) + { + _keepExt = !_keepExt; + + var accent = TryFindResource("AccentColor") as System.Windows.Media.Brush + ?? System.Windows.Media.Brushes.DodgerBlue; + var gray = new System.Windows.Media.SolidColorBrush( + System.Windows.Media.Color.FromArgb(0x40, 0xFF, 0xFF, 0xFF)); + + ExtToggle.Background = _keepExt ? accent : gray; + ExtThumb.HorizontalAlignment = _keepExt ? HorizontalAlignment.Right : HorizontalAlignment.Left; + ExtThumb.Margin = _keepExt ? new Thickness(0, 0, 1, 0) : new Thickness(1, 0, 0, 0); + + UpdatePreviews(); + } + + // ─── 힌트 팝업 ─────────────────────────────────────────────────────── + private void BtnHint_Click(object sender, MouseButtonEventArgs e) + => HintPopup.IsOpen = !HintPopup.IsOpen; + + // ─── 파일/폴더 추가 ────────────────────────────────────────────────── + private void BtnAddFolder_Click(object sender, MouseButtonEventArgs e) + { + using var dlg = new System.Windows.Forms.FolderBrowserDialog + { + Description = "파일이 있는 폴더를 선택하세요", + ShowNewFolderButton = false + }; + if (dlg.ShowDialog() != System.Windows.Forms.DialogResult.OK) return; + + var files = Directory.GetFiles(dlg.SelectedPath); + Array.Sort(files); + AddFiles(files); + } + + private void BtnAddFiles_Click(object sender, MouseButtonEventArgs e) + { + using var dlg = new System.Windows.Forms.OpenFileDialog + { + Title = "이름변경할 파일 선택", + Multiselect = true, + Filter = "모든 파일 (*.*)|*.*" + }; + if (dlg.ShowDialog() != System.Windows.Forms.DialogResult.OK) return; + + var files = dlg.FileNames.OrderBy(f => f).ToArray(); + AddFiles(files); + } + + private void BtnRemoveSelected_Click(object sender, MouseButtonEventArgs e) + { + var selected = PreviewGrid.SelectedItems.OfType().ToList(); + foreach (var item in selected) _entries.Remove(item); + UpdatePreviews(); + } + + private void BtnClearAll_Click(object sender, MouseButtonEventArgs e) + { + _entries.Clear(); + RefreshUI(); + } + + // ─── 적용 ──────────────────────────────────────────────────────────── + private void BtnApply_Click(object sender, MouseButtonEventArgs e) + { + var toRename = _entries + .Where(en => en.NewName != en.OriginalName && !en.HasConflict) + .ToList(); + + if (toRename.Count == 0) + { + NotificationService.Notify("배치 이름변경", "변경할 파일이 없습니다. 패턴을 확인하거나 충돌을 해소하세요."); + return; + } + + int ok = 0, fail = 0; + foreach (var entry in toRename) + { + try + { + var destPath = Path.Combine( + Path.GetDirectoryName(entry.OriginalPath) ?? "", + entry.NewName); + + File.Move(entry.OriginalPath, destPath); + + // 성공 후 엔트리 갱신 (새 경로로 업데이트) + entry.OriginalPath = destPath; + entry.OriginalName = entry.NewName; + ok++; + } + catch (Exception ex) + { + LogService.Warn($"배치 이름변경 실패: {entry.OriginalPath} → {entry.NewName} — {ex.Message}"); + fail++; + } + } + + // 미리보기 갱신 (적용 후 남은 항목) + UpdatePreviews(); + + var msg = fail > 0 + ? $"{ok}개 이름변경 완료, {fail}개 실패" + : $"{ok}개 파일 이름변경 완료"; + NotificationService.Notify("AX Copilot", msg); + LogService.Info($"배치 이름변경: {msg}"); + } + + // ─── 드래그 앤 드롭 ────────────────────────────────────────────────── + private void Window_DragOver(object sender, DragEventArgs e) + { + if (e.Data.GetDataPresent(DataFormats.FileDrop)) + { + e.Effects = DragDropEffects.Copy; + DropHintOverlay.Visibility = Visibility.Visible; + } + else + { + e.Effects = DragDropEffects.None; + } + e.Handled = true; + } + + private void Window_Drop(object sender, DragEventArgs e) + { + DropHintOverlay.Visibility = Visibility.Collapsed; + + if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return; + + var dropped = (string[])e.Data.GetData(DataFormats.FileDrop); + var files = new List(); + + foreach (var p in dropped) + { + if (File.Exists(p)) + { + files.Add(p); + } + else if (Directory.Exists(p)) + { + files.AddRange(Directory.GetFiles(p).OrderBy(f => f)); + } + } + + AddFiles(files); + } +} + +// ─── RenameEntry 모델 ──────────────────────────────────────────────────────── +public class RenameEntry : INotifyPropertyChanged +{ + public string OriginalPath { get; set; } = ""; + + private string _originalName = ""; + public string OriginalName + { + get => _originalName; + set { _originalName = value; OnPropertyChanged(); OnPropertyChanged(nameof(StatusText)); } + } + + private string _newName = ""; + public string NewName + { + get => _newName; + set { _newName = value; OnPropertyChanged(); OnPropertyChanged(nameof(StatusText)); } + } + + private bool _hasConflict; + public bool HasConflict + { + get => _hasConflict; + set { _hasConflict = value; OnPropertyChanged(); OnPropertyChanged(nameof(StatusText)); } + } + + public string StatusText => + HasConflict + ? "⚠ 충돌" + : OriginalName == NewName + ? "─" + : "✓"; + + public event PropertyChangedEventHandler? PropertyChanged; + protected void OnPropertyChanged([CallerMemberName] string? name = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); +} diff --git a/src/AxCopilot/Views/MacroEditorWindow.xaml b/src/AxCopilot/Views/MacroEditorWindow.xaml new file mode 100644 index 0000000..a94727a --- /dev/null +++ b/src/AxCopilot/Views/MacroEditorWindow.xaml @@ -0,0 +1,179 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AxCopilot/Views/MacroEditorWindow.xaml.cs b/src/AxCopilot/Views/MacroEditorWindow.xaml.cs new file mode 100644 index 0000000..98c30ad --- /dev/null +++ b/src/AxCopilot/Views/MacroEditorWindow.xaml.cs @@ -0,0 +1,320 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using AxCopilot.Models; +using AxCopilot.Services; + +namespace AxCopilot.Views; + +public partial class MacroEditorWindow : Window +{ + private readonly SettingsService _settings; + private readonly MacroEntry? _editing; + + // 유형 목록 + private static readonly string[] StepTypes = { "app", "url", "folder", "notification", "cmd" }; + private static readonly string[] StepTypeLabels = { "앱", "URL", "폴더", "알림", "PowerShell" }; + + // 각 행의 컨트롤 참조 + private readonly List _rows = new(); + + // 공유 타입 팝업 + private readonly Popup _typePopup = new() + { + StaysOpen = false, + AllowsTransparency = true, + Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom + }; + private StepRowUi? _typeTargetRow; + + public MacroEditorWindow(MacroEntry? entry, SettingsService settings) + { + InitializeComponent(); + _settings = settings; + _editing = entry; + Loaded += OnLoaded; + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + BuildTypePopup(); + + if (_editing != null) + { + NameBox.Text = _editing.Name; + DescBox.Text = _editing.Description; + foreach (var step in _editing.Steps) + AddRow(step); + } + + if (_rows.Count == 0) + AddRow(null); // 기본 빈 행 + } + + // ─── 팝업 빌드 ────────────────────────────────────────────────────────── + private void BuildTypePopup() + { + var bg = TryFindResource("ItemBackground") as Brush ?? Brushes.DimGray; + var border = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + var fg = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + var hover = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Gray; + + var panel = new StackPanel { Background = bg, MinWidth = 100 }; + + for (int i = 0; i < StepTypes.Length; i++) + { + var idx = i; + var label = StepTypeLabels[i]; + + var item = new Border + { + Padding = new Thickness(12, 6, 12, 6), + Background = Brushes.Transparent, + Cursor = Cursors.Hand, + Tag = idx + }; + item.MouseEnter += (_, _) => item.Background = hover; + item.MouseLeave += (_, _) => item.Background = Brushes.Transparent; + item.MouseLeftButtonUp += (_, _) => + { + if (_typeTargetRow != null) + SetRowType(_typeTargetRow, idx); + _typePopup.IsOpen = false; + }; + item.Child = new TextBlock + { + Text = label, + FontSize = 12, + Foreground = fg + }; + panel.Children.Add(item); + } + + var outerBorder = new Border + { + Background = bg, + BorderBrush = border, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(6), + Child = panel, + Effect = new System.Windows.Media.Effects.DropShadowEffect + { + BlurRadius = 12, + ShadowDepth = 3, + Opacity = 0.3, + Color = Colors.Black, + Direction = 270 + } + }; + + _typePopup.Child = outerBorder; + } + + // ─── 행 추가 ──────────────────────────────────────────────────────────── + private void AddRow(MacroStep? step) + { + var bg = TryFindResource("ItemBackground") as Brush ?? Brushes.DimGray; + var border = TryFindResource("BorderColor") as Brush ?? Brushes.Gray; + var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; + var secFg = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var primFg = TryFindResource("PrimaryText") as Brush ?? Brushes.White; + + var row = new StepRowUi(); + + var grid = new Grid { Margin = new Thickness(0, 0, 0, 4) }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(90) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(80) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(70) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(28) }); + + // Col 0: 유형 버튼 + var typeLbl = new TextBlock + { + FontSize = 11, + VerticalAlignment = VerticalAlignment.Center, + Foreground = primFg, + Text = StepTypeLabels[0] + }; + var typeBtn = new Border + { + Background = bg, + BorderBrush = border, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(4), + Padding = new Thickness(6, 4, 6, 4), + Cursor = Cursors.Hand, + Child = typeLbl, + Margin = new Thickness(0, 0, 4, 0) + }; + typeBtn.MouseLeftButtonUp += (_, _) => + { + _typeTargetRow = row; + _typePopup.PlacementTarget = typeBtn; + _typePopup.IsOpen = true; + }; + row.TypeButton = typeBtn; + row.TypeLabel = typeLbl; + Grid.SetColumn(typeBtn, 0); + + // Col 1: 대상 + var targetBox = new TextBox + { + FontSize = 11, + Foreground = primFg, + Background = (Brush)TryFindResource("LauncherBackground")! ?? Brushes.Black, + BorderBrush = border, + BorderThickness = new Thickness(1), + Padding = new Thickness(6, 4, 6, 4), + Margin = new Thickness(0, 0, 4, 0), + Text = step?.Target ?? "" + }; + row.TargetBox = targetBox; + Grid.SetColumn(targetBox, 1); + + // Col 2: 표시 이름 + var labelBox = new TextBox + { + FontSize = 11, + Foreground = secFg, + Background = (Brush)TryFindResource("LauncherBackground")! ?? Brushes.Black, + BorderBrush = border, + BorderThickness = new Thickness(1), + Padding = new Thickness(6, 4, 6, 4), + Margin = new Thickness(0, 0, 4, 0), + Text = step?.Label ?? "" + }; + row.LabelBox = labelBox; + Grid.SetColumn(labelBox, 2); + + // Col 3: 딜레이 + var delayBox = new TextBox + { + FontSize = 11, + Foreground = primFg, + Background = (Brush)TryFindResource("LauncherBackground")! ?? Brushes.Black, + BorderBrush = border, + BorderThickness = new Thickness(1), + Padding = new Thickness(6, 4, 6, 4), + Margin = new Thickness(0, 0, 4, 0), + Text = (step?.DelayMs ?? 500).ToString() + }; + row.DelayBox = delayBox; + Grid.SetColumn(delayBox, 3); + + // Col 4: 삭제 + var delBtn = new Border + { + Width = 24, + Height = 24, + CornerRadius = new CornerRadius(4), + Background = Brushes.Transparent, + Cursor = Cursors.Hand, + VerticalAlignment = VerticalAlignment.Center, + Child = new TextBlock + { + Text = "\uE711", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 11, + Foreground = secFg, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + } + }; + delBtn.MouseEnter += (_, _) => delBtn.Background = new SolidColorBrush(Color.FromArgb(0x30, 0xC0, 0x50, 0x50)); + delBtn.MouseLeave += (_, _) => delBtn.Background = Brushes.Transparent; + delBtn.MouseLeftButtonUp += (_, _) => RemoveRow(row); + Grid.SetColumn(delBtn, 4); + + grid.Children.Add(typeBtn); + grid.Children.Add(targetBox); + grid.Children.Add(labelBox); + grid.Children.Add(delayBox); + grid.Children.Add(delBtn); + + row.Grid = grid; + + // 유형 초기화 + int typeIdx = step != null ? Array.IndexOf(StepTypes, step.Type.ToLowerInvariant()) : 0; + if (typeIdx < 0) typeIdx = 0; + SetRowType(row, typeIdx); + + _rows.Add(row); + StepsPanel.Children.Add(grid); + } + + private void SetRowType(StepRowUi row, int typeIdx) + { + row.TypeIndex = typeIdx; + row.TypeLabel.Text = StepTypeLabels[typeIdx]; + + var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; + var secFg = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + + row.TypeLabel.Foreground = accent; + } + + private void RemoveRow(StepRowUi row) + { + StepsPanel.Children.Remove(row.Grid); + _rows.Remove(row); + } + + // ─── 버튼 이벤트 ───────────────────────────────────────────────────────── + private void BtnAddStep_Click(object sender, MouseButtonEventArgs e) => AddRow(null); + + private void BtnSave_Click(object sender, MouseButtonEventArgs e) + { + var name = NameBox.Text.Trim(); + if (string.IsNullOrWhiteSpace(name)) + { + MessageBox.Show("매크로 이름을 입력하세요.", "저장 오류", + MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + var steps = _rows + .Where(r => !string.IsNullOrWhiteSpace(r.TargetBox.Text)) + .Select(r => new MacroStep + { + Type = StepTypes[r.TypeIndex], + Target = r.TargetBox.Text.Trim(), + Label = r.LabelBox.Text.Trim(), + DelayMs = int.TryParse(r.DelayBox.Text, out var d) ? Math.Max(0, d) : 500 + }) + .ToList(); + + var entry = _editing ?? new MacroEntry(); + entry.Name = name; + entry.Description = DescBox.Text.Trim(); + entry.Steps = steps; + + if (_editing == null) + _settings.Settings.Macros.Add(entry); + + _settings.Save(); + NotificationService.Notify("AX Copilot", $"매크로 '{entry.Name}' 저장됨"); + Close(); + } + + private void BtnCancel_Click(object sender, MouseButtonEventArgs e) => Close(); + private void BtnClose_Click(object sender, MouseButtonEventArgs e) => Close(); + private void TitleBar_MouseDown(object sender, MouseButtonEventArgs e) + { + if (e.LeftButton == MouseButtonState.Pressed) DragMove(); + } + + // ─── 내부 행 참조 클래스 ────────────────────────────────────────────────── + private class StepRowUi + { + public Grid Grid = null!; + public Border TypeButton = null!; + public TextBlock TypeLabel = null!; + public TextBox TargetBox = null!; + public TextBox LabelBox = null!; + public TextBox DelayBox = null!; + public int TypeIndex; + } +} diff --git a/src/AxCopilot/Views/ScheduleEditorWindow.xaml b/src/AxCopilot/Views/ScheduleEditorWindow.xaml new file mode 100644 index 0000000..74c20cd --- /dev/null +++ b/src/AxCopilot/Views/ScheduleEditorWindow.xaml @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AxCopilot/Views/ScheduleEditorWindow.xaml.cs b/src/AxCopilot/Views/ScheduleEditorWindow.xaml.cs new file mode 100644 index 0000000..13d3523 --- /dev/null +++ b/src/AxCopilot/Views/ScheduleEditorWindow.xaml.cs @@ -0,0 +1,338 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Animation; +using AxCopilot.Models; +using AxCopilot.Services; +using Microsoft.Win32; + +namespace AxCopilot.Views; + +public partial class ScheduleEditorWindow : Window +{ + private readonly SettingsService _settings; + private readonly ScheduleEntry? _editing; // null = 새 스케줄 + + private string _triggerType = "daily"; + private string _actionType = "app"; + private bool _enabled = true; + private bool _conditionMustRun = true; + + // 요일 버튼 → Border 참조 + private Border[] _dayBtns = null!; + + public ScheduleEditorWindow(ScheduleEntry? entry, SettingsService settings) + { + InitializeComponent(); + _settings = settings; + _editing = entry; + + _dayBtns = new[] { BtnSun, BtnMon, BtnTue, BtnWed, BtnThu, BtnFri, BtnSat }; + + Loaded += OnLoaded; + } + + // ─── 초기화 ───────────────────────────────────────────────────────────── + private void OnLoaded(object sender, RoutedEventArgs e) + { + // 다크 테마 색상 + var dimBg = TryFindResource("ItemBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x25, 0x26, 0x37)); + var accent = TryFindResource("AccentColor") as Brush ?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)); + var border = TryFindResource("BorderColor") as Brush ?? new SolidColorBrush(Color.FromRgb(0x2E, 0x2F, 0x4A)); + + // 요일 버튼 기본 색 + foreach (var b in _dayBtns) + { + b.Background = dimBg; + b.BorderBrush = border; + b.BorderThickness = new Thickness(1); + if (b.Child is TextBlock tb) + tb.Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + } + + if (_editing != null) + LoadFromEntry(_editing); + else + SetTriggerUi("daily"); + + SetActionUi(_actionType); + UpdateToggleUi(_enabled); + SetConditionModeUi(_conditionMustRun); + } + + private void LoadFromEntry(ScheduleEntry e) + { + NameBox.Text = e.Name; + TimeBox.Text = e.TriggerTime; + _enabled = e.Enabled; + _triggerType = e.TriggerType; + _actionType = e.ActionType; + + if (e.TriggerDate != null) + DateBox.Text = e.TriggerDate; + + SetTriggerUi(e.TriggerType); + + // 요일 복원 + foreach (var b in _dayBtns) + { + if (int.TryParse(b.Tag?.ToString(), out var day) && e.WeekDays.Contains(day)) + SetDaySelected(b, true); + } + + if (e.ActionType == "app") + { + AppPathBox.Text = e.ActionTarget; + AppArgsBox.Text = e.ActionArgs ?? ""; + } + else + { + NotifMsgBox.Text = e.ActionTarget; + } + + // 조건 복원 + ConditionProcessBox.Text = e.ConditionProcess ?? ""; + _conditionMustRun = e.ConditionProcessMustRun; + SetConditionModeUi(_conditionMustRun); + } + + // ─── 트리거 유형 ───────────────────────────────────────────────────────── + private void TriggerType_Click(object sender, MouseButtonEventArgs e) + { + if (sender is Border b && b.Tag is string tag) + SetTriggerUi(tag); + } + + private void SetTriggerUi(string type) + { + _triggerType = type; + + var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; + var dimBg = TryFindResource("ItemBackground") as Brush ?? Brushes.DimGray; + var secFg = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var white = Brushes.White; + + // 버튼 배경·텍스트 색 초기화 + void SetBtn(Border btn, TextBlock txt, bool active) + { + btn.Background = active ? accent : dimBg; + txt.Foreground = active ? white : secFg; + } + + SetBtn(BtnDaily, TxtDaily, type == "daily"); + SetBtn(BtnWeekdays, TxtWeekdays, type == "weekdays"); + SetBtn(BtnWeekly, TxtWeekly, type == "weekly"); + SetBtn(BtnOnce, TxtOnce, type == "once"); + + // 요일 패널 / 날짜 패널 표시 + WeekDaysPanel.Visibility = type == "weekly" ? Visibility.Visible : Visibility.Collapsed; + DatePanel.Visibility = type == "once" ? Visibility.Visible : Visibility.Collapsed; + + // once 기본값 + if (type == "once" && string.IsNullOrWhiteSpace(DateBox.Text)) + DateBox.Text = DateTime.Now.AddDays(1).ToString("yyyy-MM-dd"); + } + + // ─── 요일 선택 ────────────────────────────────────────────────────────── + private void WeekDay_Click(object sender, MouseButtonEventArgs e) + { + if (sender is not Border btn) return; + var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; + var dimBg = TryFindResource("ItemBackground") as Brush ?? Brushes.DimGray; + bool current = btn.Background == accent; + SetDaySelected(btn, !current); + } + + private void SetDaySelected(Border btn, bool selected) + { + var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; + var dimBg = TryFindResource("ItemBackground") as Brush ?? Brushes.DimGray; + var secFg = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + + btn.Background = selected ? accent : dimBg; + if (btn.Child is TextBlock tb) + tb.Foreground = selected ? Brushes.White : secFg; + } + + private List GetSelectedDays() + { + var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; + var list = new List(); + foreach (var b in _dayBtns) + { + if (b.Background == accent && int.TryParse(b.Tag?.ToString(), out var day)) + list.Add(day); + } + return list; + } + + // ─── 액션 유형 ────────────────────────────────────────────────────────── + private void ActionType_Click(object sender, MouseButtonEventArgs e) + { + if (sender is Border b && b.Tag is string tag) + SetActionUi(tag); + } + + private void SetActionUi(string type) + { + _actionType = type; + + var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; + var dimBg = TryFindResource("ItemBackground") as Brush ?? Brushes.DimGray; + var secFg = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var white = Brushes.White; + + bool isApp = type == "app"; + + BtnActionApp.Background = isApp ? accent : dimBg; + BtnActionNotif.Background = !isApp ? accent : dimBg; + + TxtActionApp.Foreground = isApp ? white : secFg; + TxtActionNotif.Foreground = !isApp ? white : secFg; + + // 아이콘 TextBlock은 StackPanel의 첫 번째 자식 + if (BtnActionApp.Child is StackPanel spApp && spApp.Children.Count > 0) + ((TextBlock)spApp.Children[0]).Foreground = isApp ? white : secFg; + if (BtnActionNotif.Child is StackPanel spNotif && spNotif.Children.Count > 0) + ((TextBlock)spNotif.Children[0]).Foreground = !isApp ? white : secFg; + + AppPathPanel.Visibility = isApp ? Visibility.Visible : Visibility.Collapsed; + NotifPanel.Visibility = !isApp ? Visibility.Visible : Visibility.Collapsed; + } + + // ─── 앱 찾아보기 ───────────────────────────────────────────────────────── + private void BtnBrowseApp_Click(object sender, MouseButtonEventArgs e) + { + var dlg = new OpenFileDialog + { + Title = "실행 파일 선택", + Filter = "실행 파일|*.exe;*.bat;*.cmd;*.lnk;*.ps1|모든 파일|*.*" + }; + if (dlg.ShowDialog(this) == true) + AppPathBox.Text = dlg.FileName; + } + + // ─── 조건 모드 (L6-4) ─────────────────────────────────────────────────── + private void ConditionMode_Click(object sender, MouseButtonEventArgs e) + { + if (sender is Border b && b.Tag is string tag) + SetConditionModeUi(tag == "run"); + } + + private void SetConditionModeUi(bool mustRun) + { + _conditionMustRun = mustRun; + + var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; + var dimBg = TryFindResource("ItemBackground") as Brush ?? Brushes.DimGray; + var secFg = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + + BtnCondRun.Background = mustRun ? accent : dimBg; + BtnCondNotRun.Background = !mustRun ? accent : dimBg; + TxtCondRun.Foreground = mustRun ? Brushes.White : secFg; + TxtCondNotRun.Foreground = !mustRun ? Brushes.White : secFg; + } + + // ─── 활성화 토글 ───────────────────────────────────────────────────────── + private void EnabledToggle_Click(object sender, MouseButtonEventArgs e) + { + _enabled = !_enabled; + UpdateToggleUi(_enabled); + } + + private void UpdateToggleUi(bool enabled) + { + var accent = TryFindResource("AccentColor") as Brush + ?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)); + var off = new SolidColorBrush(Color.FromRgb(0x3A, 0x3B, 0x5A)); + + EnabledToggle.Background = enabled ? accent : off; + + // 썸 위치 애니메이션 + var da = new DoubleAnimation( + enabled ? 1.0 : -1.0, // 실제 HorizontalAlignment·Margin으로 처리 + TimeSpan.FromMilliseconds(150)); + + EnabledThumb.HorizontalAlignment = enabled ? HorizontalAlignment.Right : HorizontalAlignment.Left; + EnabledThumb.Margin = enabled ? new Thickness(0, 0, 2, 0) : new Thickness(2, 0, 0, 0); + } + + // ─── 저장 ──────────────────────────────────────────────────────────────── + private void BtnSave_Click(object sender, MouseButtonEventArgs e) + { + var name = NameBox.Text.Trim(); + if (string.IsNullOrWhiteSpace(name)) + { + MessageBox.Show("스케줄 이름을 입력하세요.", "저장 오류", + MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + var timeStr = TimeBox.Text.Trim(); + if (!TimeSpan.TryParseExact(timeStr, new[] { @"hh\:mm", @"h\:mm" }, + System.Globalization.CultureInfo.InvariantCulture, out _)) + { + MessageBox.Show("실행 시각을 HH:mm 형식으로 입력하세요. (예: 09:00)", + "저장 오류", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + if (_triggerType == "once") + { + var dateStr = DateBox.Text.Trim(); + if (!DateTime.TryParse(dateStr, out _)) + { + MessageBox.Show("실행 날짜를 yyyy-MM-dd 형식으로 입력하세요.", + "저장 오류", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + } + + if (_actionType == "app" && string.IsNullOrWhiteSpace(AppPathBox.Text)) + { + MessageBox.Show("실행할 앱 경로를 입력하세요.", "저장 오류", + MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + // 기존 항목 편집 or 신규 생성 + var entry = _editing ?? new ScheduleEntry(); + + entry.Name = name; + entry.Enabled = _enabled; + entry.TriggerType = _triggerType; + entry.TriggerTime = timeStr; + entry.WeekDays = _triggerType == "weekly" ? GetSelectedDays() : new List(); + entry.TriggerDate = _triggerType == "once" ? DateBox.Text.Trim() : null; + entry.ActionType = _actionType; + entry.ActionTarget = _actionType == "app" + ? AppPathBox.Text.Trim() + : NotifMsgBox.Text.Trim(); + entry.ActionArgs = _actionType == "app" ? AppArgsBox.Text.Trim() : ""; + + // 조건 저장 (L6-4) + entry.ConditionProcess = ConditionProcessBox.Text.Trim(); + entry.ConditionProcessMustRun = _conditionMustRun; + + var schedules = _settings.Settings.Schedules; + + if (_editing == null) + schedules.Add(entry); + // 편집 모드: 이미 리스트 내 참조이므로 별도 추가 불필요 + + _settings.Save(); + NotificationService.Notify("AX Copilot", $"스케줄 '{entry.Name}' 저장됨"); + Close(); + } + + // ─── 윈도우 컨트롤 ────────────────────────────────────────────────────── + private void TitleBar_MouseDown(object sender, MouseButtonEventArgs e) + { + if (e.LeftButton == MouseButtonState.Pressed) + DragMove(); + } + + private void BtnClose_Click(object sender, MouseButtonEventArgs e) => Close(); + private void BtnCancel_Click(object sender, MouseButtonEventArgs e) => Close(); +} diff --git a/src/AxCopilot/Views/SessionEditorWindow.xaml b/src/AxCopilot/Views/SessionEditorWindow.xaml new file mode 100644 index 0000000..60de7e2 --- /dev/null +++ b/src/AxCopilot/Views/SessionEditorWindow.xaml @@ -0,0 +1,251 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AxCopilot/Views/SessionEditorWindow.xaml.cs b/src/AxCopilot/Views/SessionEditorWindow.xaml.cs new file mode 100644 index 0000000..29b3fdf --- /dev/null +++ b/src/AxCopilot/Views/SessionEditorWindow.xaml.cs @@ -0,0 +1,386 @@ +using System.IO; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using AxCopilot.Models; +using AxCopilot.Services; + +namespace AxCopilot.Views; + +/// +/// L5-4: 앱 세션 편집기. +/// 세션 이름, 앱 목록(경로 + 라벨 + 스냅 위치)을 편집하여 저장합니다. +/// +public partial class SessionEditorWindow : Window +{ + private readonly SettingsService _settings; + private readonly AppSession? _original; // 편집 모드 원본 (새 세션이면 null) + private readonly List _rows = new(); + + // 스냅 팝업 대상 행 + private AppRowUi? _snapTargetRow; + + // 사용 가능한 스냅 위치 목록 (키 → 표시명) + private static readonly (string Key, string Label)[] SnapOptions = + [ + ("full", "전체화면"), + ("left", "왼쪽 절반"), + ("right", "오른쪽 절반"), + ("tl", "좌상단 1/4"), + ("tr", "우상단 1/4"), + ("bl", "좌하단 1/4"), + ("br", "우하단 1/4"), + ("center", "중앙 80%"), + ("third-l", "좌측 1/3"), + ("third-c", "중앙 1/3"), + ("third-r", "우측 1/3"), + ("two3-l", "좌측 2/3"), + ("two3-r", "우측 2/3"), + ("none", "스냅 없음"), + ]; + + // ────────────────────────────────────────────────────────────────────── + /// + /// 세션 편집기를 엽니다. + /// + /// 편집할 기존 세션. null이면 새로 만들기 모드. + /// 설정 서비스. + public SessionEditorWindow(AppSession? session, SettingsService settings) + { + InitializeComponent(); + _settings = settings; + _original = session; + + BuildSnapPopup(); + LoadSession(session); + } + + /// 새 세션 모드일 때 기본 이름을 설정합니다. + public string InitialName + { + set { if (_original == null) NameBox.Text = value; } + } + + // ─── 초기화 ─────────────────────────────────────────────────────────── + private void LoadSession(AppSession? session) + { + if (session == null) + { + NameBox.Text = "새 세션"; + DescBox.Text = ""; + } + else + { + NameBox.Text = session.Name; + DescBox.Text = session.Description; + foreach (var app in session.Apps) + AddRow(app.Path, app.Label, app.SnapPosition, app.Arguments, app.DelayMs); + } + + RefreshEmptyState(); + } + + private void BuildSnapPopup() + { + var panel = new StackPanel { Margin = new Thickness(0) }; + + foreach (var (key, label) in SnapOptions) + { + var keyCapture = key; + var border = new Border + { + CornerRadius = new CornerRadius(4), + Padding = new Thickness(10, 5, 10, 5), + Cursor = Cursors.Hand + }; + + border.MouseEnter += (_, _) => + border.Background = new SolidColorBrush(Color.FromArgb(0x28, 0xFF, 0xFF, 0xFF)); + border.MouseLeave += (_, _) => + border.Background = Brushes.Transparent; + + var stack = new StackPanel { Orientation = Orientation.Horizontal }; + stack.Children.Add(new TextBlock + { + Text = keyCapture, + FontFamily = new FontFamily("Cascadia Code, Consolas"), + FontSize = 11, + Foreground = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue, + VerticalAlignment = VerticalAlignment.Center, + MinWidth = 68, + }); + stack.Children.Add(new TextBlock + { + Text = label, + FontSize = 11, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + VerticalAlignment = VerticalAlignment.Center, + }); + border.Child = stack; + + border.MouseLeftButtonUp += (_, _) => + { + if (_snapTargetRow != null) + { + _snapTargetRow.SnapPosition = keyCapture; + _snapTargetRow.UpdateSnapLabel(); + } + SnapPickerPopup.IsOpen = false; + }; + + panel.Children.Add(border); + } + + SnapOptionsList.Content = panel; + } + + // ─── 앱 행 추가 ─────────────────────────────────────────────────────── + private void AddRow(string path = "", string label = "", string snap = "full", + string args = "", int delayMs = 0) + { + var row = new AppRowUi(path, label, snap, args, delayMs); + _rows.Add(row); + + var rowGrid = BuildRowGrid(row); + AppListPanel.Children.Add(rowGrid); + RefreshEmptyState(); + } + + private Grid BuildRowGrid(AppRowUi row) + { + var grid = new Grid { Margin = new Thickness(14, 2, 4, 2) }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(100) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(108) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(30) }); + + // 경로 TextBox + var pathBox = new TextBox + { + Text = row.Path, + FontSize = 11, + Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White, + Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black, + BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, + BorderThickness = new Thickness(1), + Padding = new Thickness(6, 4, 6, 4), + VerticalContentAlignment = VerticalAlignment.Center, + ToolTip = "앱 실행 파일 경로", + Margin = new Thickness(0, 0, 4, 0), + }; + pathBox.TextChanged += (_, _) => row.Path = pathBox.Text; + Grid.SetColumn(pathBox, 0); + + // 라벨 TextBox + var labelBox = new TextBox + { + Text = row.Label, + FontSize = 11, + Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray, + Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black, + BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, + BorderThickness = new Thickness(1), + Padding = new Thickness(6, 4, 6, 4), + VerticalContentAlignment = VerticalAlignment.Center, + ToolTip = "표시 이름 (선택)", + Margin = new Thickness(0, 0, 4, 0), + }; + labelBox.TextChanged += (_, _) => row.Label = labelBox.Text; + Grid.SetColumn(labelBox, 1); + + // 스냅 선택 Border (클릭 시 팝업) + var snapBtn = new Border + { + CornerRadius = new CornerRadius(4), + BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray, + BorderThickness = new Thickness(1), + Padding = new Thickness(6, 4, 6, 4), + Cursor = Cursors.Hand, + Margin = new Thickness(0, 0, 4, 0), + ToolTip = "스냅 위치 선택", + }; + snapBtn.MouseEnter += (_, _) => + snapBtn.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF)); + snapBtn.MouseLeave += (_, _) => + snapBtn.Background = Brushes.Transparent; + + var snapLabel = new TextBlock + { + FontFamily = new FontFamily("Cascadia Code, Consolas"), + FontSize = 10, + Foreground = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue, + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + }; + snapBtn.Child = snapLabel; + + // AppRowUi가 라벨 TextBlock을 참조할 수 있도록 저장 + row.SnapLabelRef = snapLabel; + row.SnapButtonRef = snapBtn; + row.UpdateSnapLabel(); + + snapBtn.MouseLeftButtonUp += (sender, e) => + { + _snapTargetRow = row; + SnapPickerPopup.PlacementTarget = (FrameworkElement)sender; + SnapPickerPopup.IsOpen = true; + e.Handled = true; + }; + Grid.SetColumn(snapBtn, 2); + + // 삭제 버튼 + var delBtn = new Border + { + Width = 24, + Height = 24, + CornerRadius = new CornerRadius(4), + Cursor = Cursors.Hand, + ToolTip = "이 앱 제거", + VerticalAlignment = VerticalAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center, + }; + delBtn.MouseEnter += (_, _) => + delBtn.Background = new SolidColorBrush(Color.FromArgb(0x30, 0xEF, 0x53, 0x50)); + delBtn.MouseLeave += (_, _) => + delBtn.Background = Brushes.Transparent; + + var delIcon = new TextBlock + { + Text = "\uE74D", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 12, + Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x53, 0x50)), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }; + delBtn.Child = delIcon; + + delBtn.MouseLeftButtonUp += (_, _) => + { + _rows.Remove(row); + AppListPanel.Children.Remove(grid); + RefreshEmptyState(); + }; + Grid.SetColumn(delBtn, 3); + + grid.Children.Add(pathBox); + grid.Children.Add(labelBox); + grid.Children.Add(snapBtn); + grid.Children.Add(delBtn); + + return grid; + } + + private void RefreshEmptyState() + { + EmptyState.Visibility = _rows.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + } + + // ─── 이벤트 핸들러 ──────────────────────────────────────────────────── + private void TitleBar_MouseDown(object sender, MouseButtonEventArgs e) + { + if (e.LeftButton == MouseButtonState.Pressed) DragMove(); + } + + private void BtnClose_Click(object sender, MouseButtonEventArgs e) => Close(); + private void BtnCancel_Click(object sender, MouseButtonEventArgs e) => Close(); + + private void BtnAddApp_Click(object sender, MouseButtonEventArgs e) + { + using var dlg = new System.Windows.Forms.OpenFileDialog + { + Title = "앱 실행 파일 선택", + Filter = "실행 파일 (*.exe)|*.exe|모든 파일 (*.*)|*.*", + }; + if (dlg.ShowDialog() != System.Windows.Forms.DialogResult.OK) return; + + var label = Path.GetFileNameWithoutExtension(dlg.FileName); + AddRow(dlg.FileName, label, "full"); + } + + private void BtnSave_Click(object sender, MouseButtonEventArgs e) + { + var name = NameBox.Text.Trim(); + if (string.IsNullOrWhiteSpace(name)) + { + NotificationService.Notify("세션 편집기", "세션 이름을 입력하세요."); + NameBox.Focus(); + return; + } + + // 빈 경로 행 필터링 + var validApps = _rows + .Where(r => !string.IsNullOrWhiteSpace(r.Path)) + .Select(r => new SessionApp + { + Path = r.Path.Trim(), + Arguments = r.Arguments.Trim(), + Label = r.Label.Trim(), + SnapPosition = r.SnapPosition, + DelayMs = r.DelayMs, + }).ToList(); + + var session = new AppSession + { + Name = name, + Description = DescBox.Text.Trim(), + Apps = validApps, + CreatedAt = _original?.CreatedAt ?? DateTime.Now, + }; + + // 기존 세션 교체 또는 신규 추가 + if (_original != null) + { + var idx = _settings.Settings.AppSessions.IndexOf(_original); + if (idx >= 0) + _settings.Settings.AppSessions[idx] = session; + else + _settings.Settings.AppSessions.Add(session); + } + else + { + // 동일 이름 세션이 있으면 교체 + var existing = _settings.Settings.AppSessions + .FirstOrDefault(s => s.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + if (existing != null) + _settings.Settings.AppSessions.Remove(existing); + _settings.Settings.AppSessions.Add(session); + } + + _settings.Save(); + NotificationService.Notify("AX Copilot", + $"세션 '{name}' 저장됨 ({validApps.Count}개 앱)"); + LogService.Info($"세션 저장: {name} ({validApps.Count}개 앱)"); + Close(); + } +} + +// ─── 앱 행 UI 모델 ──────────────────────────────────────────────────────────── +internal class AppRowUi +{ + public string Path { get; set; } + public string Label { get; set; } + public string SnapPosition { get; set; } + public string Arguments { get; set; } + public int DelayMs { get; set; } + + // UI 참조 (라벨 갱신용) + internal System.Windows.Controls.TextBlock? SnapLabelRef { get; set; } + internal System.Windows.Controls.Border? SnapButtonRef { get; set; } + + public AppRowUi(string path, string label, string snap, string args, int delayMs) + { + Path = path; + Label = label; + SnapPosition = snap; + Arguments = args; + DelayMs = delayMs; + } + + public void UpdateSnapLabel() + { + if (SnapLabelRef != null) + SnapLabelRef.Text = SnapPosition; + } +}