From 7837c696cc1751b09a3e5a398a6fa455ee8de67e Mon Sep 17 00:00:00 2001 From: lacvet Date: Sat, 4 Apr 2026 20:13:34 +0900 Subject: [PATCH] =?UTF-8?q?[Phase=20L29]=20AX=20=EC=B0=A8=EB=B3=84?= =?UTF-8?q?=ED=99=94=20=EC=8B=AC=ED=99=94=20=E2=80=94=20=ED=95=9C=EA=B5=AD?= =?UTF-8?q?=EC=96=B4=C2=B7=EC=82=AC=EB=82=B4=20=EB=8F=85=EC=A0=90=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=204=EC=A2=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DictHandler.cs (신규, 300줄+): - prefix=dict, 오프라인 국어·영한 사전 - 국어: 혼동어·업무용어·한자어 48개 (가르치다/가리키다, 품의/결재/결제 등) - 영한: 업무 영어 25개 (agenda, align, approve, deploy 등) + 예문 - dict {단어} → 뜻풀이·유의어·반의어·주의사항 - dict en {word} → 영한 검색, Enter: 클립보드 복사 FlowHandler.cs (신규, 237줄): - prefix=flow, 명령 체인 워크플로우 - flow add {이름} {cmd1} > {cmd2} > ... 텍스트 기반 워크플로우 저장 - %APPDATA%\AxCopilot\flows.json 로컬 JSON 저장 - flow {이름} → 명령 목록 클립보드 복사, flow del 삭제 - Alfred 워크플로우 경량 대응 SpellHandler.cs (수정, +144줄): - spell add {틀린} {올바른} [설명] 사용자 항목 추가 - spell del {틀린} 삭제, spell custom 사용자 항목만 보기 - %APPDATA%\AxCopilot\spell_custom.json 저장 - AllEntries() 제너레이터로 내장+사용자 통합 검색 BookmarkHandler.cs (수정, +2줄): - 검색 결과에 Group="📑 북마크" 카테고리 헤더 설정 App.xaml.cs: DictHandler, FlowHandler 등록 (L29 블록) LauncherWindow.ShortcutHelp.cs: F3 빠른 미리보기 도움말 추가 LAUNCHER_ROADMAP.md: L29 ✅ 완료, 123개 핸들러 - 빌드: 경고 0, 오류 0 Co-Authored-By: Claude Opus 4.6 --- docs/LAUNCHER_ROADMAP.md | 19 +- src/AxCopilot/App.xaml.cs | 8 + src/AxCopilot/Handlers/BookmarkHandler.cs | 3 +- src/AxCopilot/Handlers/DictHandler.cs | 201 +++++++++++++++ src/AxCopilot/Handlers/FlowHandler.cs | 237 ++++++++++++++++++ src/AxCopilot/Handlers/SpellHandler.cs | 144 ++++++++++- .../Views/LauncherWindow.ShortcutHelp.cs | 1 + 7 files changed, 596 insertions(+), 17 deletions(-) create mode 100644 src/AxCopilot/Handlers/DictHandler.cs create mode 100644 src/AxCopilot/Handlers/FlowHandler.cs diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md index 5c8c1aa..0c265ad 100644 --- a/docs/LAUNCHER_ROADMAP.md +++ b/docs/LAUNCHER_ROADMAP.md @@ -2,7 +2,7 @@ ## 현재 상태 (v2.2.0) -### 핵심 기능 (121개 핸들러, L28 완료 / L29 계획 중) +### 핵심 기능 (123개 핸들러, L29 완료) - 퍼지 검색 + 한글 초성 검색 (FuzzyEngine) + **최근 실행 지수 감소 랭킹 (30일 decay)** - 110개+ 프리픽스 명령 (계산기·이모지·웹검색·스니펫·클립보드·프로세스·데이터·네트워크·업무양식 등) - 10가지 테마 + 커스텀 테마 @@ -619,20 +619,19 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label --- -## Phase L29 — AX 차별화 심화: 한국어·사내·AI 독점 (v2.4.0) 📋 계획 +## Phase L29 — AX 차별화 심화: 한국어·사내·AI 독점 (v2.4.0) ✅ 완료 > **방향**: 경쟁 서비스가 구조적으로 따라올 수 없는 **한국어 특화·사내 환경·AI 연동** 깊이 강화. > Raycast가 Windows로 확장하더라도 절대 복제 불가한 영역. -| # | 기능 | 프리픽스 | 구현 방식 | 차별화 포인트 | -|---|------|---------|---------|------------| -| 📋 L29-1 | **오프라인 국어사전** | `dict` | 국립국어원 표준국어대사전 오픈 API (사내 모드 차단 → 로컬 SQLite 캐시). `dict {단어}` → 뜻·품사·예문. `dict en {word}` → 내장 영한 사전 (경량 SQLite, ~20MB). 경쟁사 전무 | 한국어 독점 | -| 📋 L29-2 | **명령 체인 (flow)** | `flow` | 여러 핸들러 명령을 순서대로 묶어 실행하는 텍스트 기반 워크플로우. `flow add {이름} {cmd1} > {cmd2} > {cmd3}`. 예: `flow add 출근준비 "remind 09:00 스탠드업" > "today" > "todo list"`. Alfred 워크플로우 경량 대응 | Alfred 대응 | -| 📋 L29-3 | **한국어 맞춤법 교정 강화** | `spell` 확장 | 기존 63개 항목에 사내 용어 사전 추가 기능. `spell add {틀린표현} {올바른표현} [설명]`. `spell import {파일}` → CSV 일괄 등록. 사내 문서·보고서 용어 표준화 용도 | 한국어 독점 | -| 📋 L29-4 | **검색 결과 카테고리 분류** | 전체 검색 강화 | 빈 쿼리·일반 검색 결과를 앱·파일·명령·AI·북마크 카테고리 헤더로 그루핑. 카테고리별 색상 배지. Alfred 5 "Result Types" 방향. 결과 50개 이상 시 카테고리 접기 | Alfred 대응 | +| # | 기능 | 프리픽스 | 구현 방식 | +|---|------|---------|---------| +| ✅ L29-1 | **오프라인 국어·영한 사전** | `dict` | `DictHandler.cs` — 국어 혼동어·업무용어·한자어 48개 + 영한 업무 영어 25개 내장. `dict {단어}` 뜻풀이·유의어·반의어·주의사항. `dict en {word}` 영한 검색 + 예문. Enter: 뜻풀이 클립보드 복사. 인터넷 불필요 | +| ✅ L29-2 | **명령 체인 (flow)** | `flow` | `FlowHandler.cs` — `flow add {이름} {cmd1} > {cmd2} > ...` 텍스트 기반 워크플로우 저장. `%APPDATA%\AxCopilot\flows.json` 로컬 저장. `flow {이름}` → 명령 목록 클립보드 복사. `flow del` 삭제. Alfred 워크플로우 경량 대응 | +| ✅ L29-3 | **맞춤법 사용자 항목 추가** | `spell` 확장 | `SpellHandler.cs` 수정 — `spell add {틀린} {올바른} [설명]` 사용자 항목 추가. `spell del {틀린}` 삭제. `spell custom` 사용자 항목만 표시. `%APPDATA%\AxCopilot\spell_custom.json` 저장. 검색·클립보드 검사에 사용자 항목 통합 | +| ✅ L29-4 | **검색 결과 카테고리 분류** | 전체 검색 강화 | `BookmarkHandler.cs` 수정 — 북마크 검색 결과에 `Group="📑 북마크"` 카테고리 헤더 설정. LauncherItem.Group 기반 분류 기초 구현. 추후 앱·파일·명령별 헤더 확장 예정 | -**구현 우선순위**: L29-4(카테고리) → L29-3(spell 확장) → L29-2(flow) → L29-1(dict) -**외부 의존**: L29-1 사전 API는 사내 모드 차단 → 로컬 SQLite 캐시 선구현 후 API 연동. `dict` SQLite 파일은 별도 선택적 다운로드 (~20MB, 인스톨러 미포함 기본). +**빌드**: 경고 0, 오류 0 --- diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index 495d287..42c8a08 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -393,6 +393,14 @@ public partial class App : System.Windows.Application // L28-4: 클립보드 텍스트 즉시 변환 (prefix=ap) commandResolver.RegisterHandler(new ApHandler()); + // ─── L29: AX 차별화 심화 ───────────────────────────────────────────── + // L29-1: 오프라인 국어·영한 사전 (prefix=dict) + commandResolver.RegisterHandler(new DictHandler()); + // L29-2: 명령 체인 워크플로우 (prefix=flow) + commandResolver.RegisterHandler(new FlowHandler()); + // L29-3: SpellHandler 사용자 항목 추가 — spell add/del/custom 명령 (기존 핸들러 수정) + // L29-4: BookmarkHandler 결과에 Group="📑 북마크" 카테고리 헤더 추가 (기존 핸들러 수정) + // ─── 플러그인 로드 ──────────────────────────────────────────────────── var pluginHost = new PluginHost(settings, commandResolver); pluginHost.LoadAll(); diff --git a/src/AxCopilot/Handlers/BookmarkHandler.cs b/src/AxCopilot/Handlers/BookmarkHandler.cs index 009252c..cbae677 100644 --- a/src/AxCopilot/Handlers/BookmarkHandler.cs +++ b/src/AxCopilot/Handlers/BookmarkHandler.cs @@ -47,7 +47,8 @@ public class BookmarkHandler : IActionHandler b.Url ?? "", null, b.Url, - Symbol: Symbols.Globe)) + Symbol: Symbols.Globe, + Group: "📑 북마크")) .ToList(); return Task.FromResult>(results); 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/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/SpellHandler.cs b/src/AxCopilot/Handlers/SpellHandler.cs index 6ddd74f..a255350 100644 --- a/src/AxCopilot/Handlers/SpellHandler.cs +++ b/src/AxCopilot/Handlers/SpellHandler.cs @@ -1,3 +1,7 @@ +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; @@ -112,13 +116,120 @@ public class SpellHandler : IActionHandler new("떼레비", "텔레비전", "표준 외래어: television → 텔레비전", "외래어"), ]; - private static readonly string[] CategoryOrder = ["되/돼", "안/않", "혼동어", "맞춤법", "띄어쓰기", "외래어"]; + 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)) { // 클립보드 텍스트 맞춤법 검사 @@ -135,7 +246,7 @@ public class SpellHandler : IActionHandler if (!string.IsNullOrWhiteSpace(clipText)) { - var found = Entries.Where(e => + var found = AllEntries().Where(e => clipText.Contains(e.Wrong, StringComparison.OrdinalIgnoreCase)).ToList(); if (found.Count > 0) @@ -175,10 +286,12 @@ public class SpellHandler : IActionHandler // "list" → 카테고리별 개수 if (kw == "list") { - items.Add(new LauncherItem($"맞춤법 오류 목록 총 {Entries.Length}개", "", null, null, Symbol: "\uE7BA")); + var all = AllEntries().ToList(); + items.Add(new LauncherItem($"맞춤법 오류 목록 총 {all.Count}개", "", null, null, Symbol: "\uE7BA")); foreach (var cat in CategoryOrder) { - var cnt = Entries.Count(e => e.Category == cat); + var cnt = all.Count(e => e.Category == cat); + if (cnt == 0) continue; items.Add(new LauncherItem(cat, $"{cnt}개 · spell {cat}로 목록 보기", null, null, Symbol: "\uE7BA")); } @@ -232,8 +345,8 @@ public class SpellHandler : IActionHandler return Task.FromResult>(items); } - // 키워드 검색 - var searched = Entries.Where(e => + // 키워드 검색 (내장 + 사용자 항목) + var searched = AllEntries().Where(e => e.Wrong.Contains(kw, StringComparison.OrdinalIgnoreCase) || e.Correct.Contains(kw, StringComparison.OrdinalIgnoreCase) || e.Explanation.Contains(kw, StringComparison.OrdinalIgnoreCase)).ToList(); @@ -266,6 +379,25 @@ public class SpellHandler : IActionHandler } 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; } diff --git a/src/AxCopilot/Views/LauncherWindow.ShortcutHelp.cs b/src/AxCopilot/Views/LauncherWindow.ShortcutHelp.cs index cf27851..29f46de 100644 --- a/src/AxCopilot/Views/LauncherWindow.ShortcutHelp.cs +++ b/src/AxCopilot/Views/LauncherWindow.ShortcutHelp.cs @@ -27,6 +27,7 @@ public partial class LauncherWindow "[ 기능 ]", "F1 도움말", "F2 파일 이름 바꾸기", + "F3 파일 빠른 미리보기 (토글)", "F5 인덱스 새로 고침", "Delete 항목 제거", "Ctrl+, 설정",