[Phase L29] AX 차별화 심화 — 한국어·사내 독점 기능 4종 구현

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 20:13:34 +09:00
parent b5c60b5398
commit 7837c696cc
7 changed files with 596 additions and 17 deletions

View File

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

View File

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

View File

@@ -47,7 +47,8 @@ public class BookmarkHandler : IActionHandler
b.Url ?? "",
null,
b.Url,
Symbol: Symbols.Globe))
Symbol: Symbols.Globe,
Group: "📑 북마크"))
.ToList();
return Task.FromResult<IEnumerable<LauncherItem>>(results);

View File

@@ -0,0 +1,201 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
namespace AxCopilot.Handlers;
/// <summary>
/// L29-1: 오프라인 국어사전 핸들러. "dict" 프리픽스로 사용합니다.
///
/// 예: dict → 사용법 안내
/// dict 부럽다 → "부럽다" 검색 (표제어·뜻풀이)
/// dict en hello → 영한 사전 검색
/// dict 유의어 돕다 → 유의어 검색
/// Enter → 뜻풀이 클립보드 복사.
/// 내장 데이터 기반 (인터넷 불필요). 혼동어·유의어·반의어 중심.
/// </summary>
public class DictHandler : IActionHandler
{
public string? Prefix => "dict";
public PluginMetadata Metadata => new(
"국어사전",
"오프라인 국어·영한 사전 — 뜻풀이·유의어·혼동어",
"1.0",
"AX");
// ─── 내장 국어 사전 데이터 ────────────────────────────────────────────────
private sealed record KorEntry(string Word, string Pos, string Def, string? Synonym = null, string? Antonym = null, string? Note = null);
private static readonly KorEntry[] KorDict =
[
// ── 혼동어 ────────────────────────────────────────────────────────────
new("가르치다", "동사", "지식·기술을 알려 주다", Note: "'가리키다'와 혼동 주의. 가리키다=손가락으로 방향을 보여 주다"),
new("가리키다", "동사", "손가락 등으로 방향이나 대상을 보여 주다", Note: "'가르치다'와 혼동 주의"),
new("다르다", "형용사", "비교 대상이 같지 아니하다", Synonym: "상이하다", Antonym: "같다", Note: "'틀리다'와 혼동 주의. 틀리다=옳지 않다"),
new("틀리다", "동사", "사실이나 이치에 맞지 아니하다, 잘못되다", Synonym: "그르다", Antonym: "맞다", Note: "'다르다'와 혼동 주의"),
new("바라다", "동사", "생각대로 되기를 기대하다", Note: "'바래다(햇볕에 색이 변하다)'와 혼동 주의"),
new("바래다", "동사", "1. 햇볕·비에 색이 변하다 2. 어떤 곳까지 배웅하다"),
new("늘이다", "동사", "길이를 길게 하다 (고무줄을 늘이다)", Note: "'늘리다'와 혼동 주의. 늘리다=수량·범위를 크게 하다"),
new("늘리다", "동사", "수량·범위·세력을 크게 하다 (매출을 늘리다)", Note: "'늘이다'와 혼동 주의. 늘이다=길이를 길게 하다"),
new("부치다", "동사", "1. 편지를 보내다 2. 부침개를 만들다 3. 세금을 매기다"),
new("붙이다", "동사", "1. 떨어지지 않게 접합하다 2. 이름을 정하다 3. 조건을 달다"),
new("맞추다", "동사", "1. 서로 대어 비교하다 2. 정답을 알아 내다", Note: "'맞히다(맞다의 사동사)'와 혼동 주의"),
new("맞히다", "동사", "'맞다'의 사동사. 1. 적중시키다 2. 정답을 고르게 하다"),
new("받치다", "동사", "1. 밑에서 떠받치다 2. 감정이 치밀다", Note: "'바치다(드리다)'와 혼동 주의"),
new("바치다", "동사", "웃어른에게 물건이나 정성을 드리다"),
new("반듯이", "부사", "모양이 기울거나 비뚤어지지 않게", Note: "'반드시'와 혼동 주의"),
new("반드시", "부사", "틀림없이, 꼭", Synonym: "기필코, 필히"),
new("어이없다", "형용사", "뜻밖에 기가 막혀 말이 나오지 않다", Note: "'어의(御醫)없다'가 아님"),
new("설레다", "동사", "마음이 가라앉지 아니하고 들뜨다", Note: "'설레이다'는 비표준"),
new("깨끗이", "부사", "깨끗하게", Note: "'-이'로 적음 (깨끗히 ×)"),
new("일찍이", "부사", "일찍", Note: "'-이'로 적음 (일찍히 ×)"),
// ── 업무 용어 ─────────────────────────────────────────────────────────
new("품의", "명사", "윗사람에게 어떤 일의 처리를 의논하여 품함"),
new("결재", "명사", "윗사람이 아랫사람이 제출한 안건을 승인함", Note: "'결제(대금 지불)'와 혼동 주의"),
new("결제", "명사", "대금을 주고받아 거래를 끝맺음", Note: "'결재(승인)'와 혼동 주의"),
new("수립", "명사", "계획이나 정책을 세움", Synonym: "설정, 설립"),
new("이관", "명사", "관할이나 담당을 다른 곳으로 옮김"),
new("선결", "명사", "다른 것보다 앞서 먼저 해결함"),
new("귀결", "명사", "어떤 결론에 다다름"),
new("전결", "명사", "위임을 받아 대신 결재함"),
new("대조", "명사", "둘 이상을 맞대어 비교함", Synonym: "비교"),
new("취합", "명사", "여러 곳의 자료를 모아서 합침", Synonym: "수합"),
new("회람", "명사", "문서를 여러 사람이 돌려 봄"),
new("시달", "명사", "상급 기관이 하급 기관에 지시를 내림"),
new("협조", "명사", "힘을 합하여 도움", Synonym: "협력"),
new("검토", "명사", "자세히 살펴서 따져 봄", Synonym: "심사, 분석"),
new("조율", "명사", "서로 다른 의견을 맞추어 조정함", Synonym: "조정"),
new("후속", "명사", "앞에 한 일의 뒤를 이음"),
new("단축", "명사", "시간·거리를 줄임", Antonym: "연장"),
new("지양", "명사", "바람직하지 않은 것을 피하거나 그만둠", Note: "'지향(나아감)'과 혼동 주의"),
new("지향", "명사", "목표를 향하여 나아감", Note: "'지양(피함)'과 혼동 주의"),
// ── 자주 헷갈리는 한자어 ──────────────────────────────────────────────
new("이상", "명사", "1. 보통 정도를 넘어선 상태 (以上) 2. 생각하는 것 중 가장 완전한 상태 (理想)"),
new("이하", "명사", "기준이 되는 정도에 미치지 못하는 상태", Antonym: "이상(以上)"),
new("과반", "명사", "절반을 넘는 수"),
new("역할", "명사", "자기가 마땅히 하여야 할 임무나 직책", Note: "'역활'은 비표준"),
new("요지", "명사", "중요한 내용의 핵심", Synonym: "골자, 핵심"),
new("명시", "명사", "분명하게 드러내어 보임"),
new("고지", "명사", "1. 알려 줌 2. 높은 곳의 땅"),
new("기한", "명사", "미리 정해 놓은 시한", Synonym: "시한, 마감"),
];
// ─── 내장 영한 사전 데이터 (업무 필수 영어) ────────────────────────────────
private sealed record EngEntry(string Word, string Pos, string Kor, string? Example = null);
private static readonly EngEntry[] EngDict =
[
new("agenda", "명사", "의제, 안건", "Let's move to the next agenda item."),
new("align", "동사", "맞추다, 조율하다", "We need to align our goals."),
new("approve", "동사", "승인하다", "The manager approved the budget."),
new("assign", "동사", "배정하다, 할당하다", "I'll assign this task to you."),
new("brief", "동사", "간략히 보고하다", "Can you brief me on the project?"),
new("deadline", "명사", "마감일, 기한", "The deadline is next Friday."),
new("delegate", "동사", "위임하다", "You should delegate this to the team."),
new("deploy", "동사", "배포하다, 배치하다", "We'll deploy the update tonight."),
new("escalate", "동사", "상부에 보고하다, 확대하다", "Let's escalate this issue."),
new("feasible", "형용사","실현 가능한", "Is this plan feasible?"),
new("follow-up", "명사", "후속 조치", "I'll send a follow-up email."),
new("implement", "동사", "구현하다, 시행하다", "We need to implement this feature."),
new("initiative", "명사", "주도적 행동, 계획", "This is a company-wide initiative."),
new("milestone", "명사", "중요 시점, 이정표", "We've reached a major milestone."),
new("onboard", "동사", "입사시키다, 적응시키다", "We'll onboard new hires next week."),
new("optimize", "동사", "최적화하다", "Let's optimize the workflow."),
new("prioritize", "동사", "우선순위를 정하다", "We need to prioritize these tasks."),
new("provisional", "형용사","잠정적인, 임시의", "This is a provisional plan."),
new("regarding", "전치사","~에 관하여", "Regarding your request..."),
new("scope", "명사", "범위", "This is out of scope."),
new("stakeholder", "명사", "이해관계자", "All stakeholders should attend."),
new("sync", "동사", "동기화하다, 맞추다", "Let's sync on this topic."),
new("tentative", "형용사","잠정적인, 시험적인", "This is a tentative schedule."),
new("throughput", "명사", "처리량", "We need to improve throughput."),
new("viable", "형용사","실행 가능한", "Is this a viable option?"),
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
items.Add(new LauncherItem("국어·영한 사전",
$"국어 {KorDict.Length}개 · 영한 {EngDict.Length}개 · dict {{단어}} / dict en {{word}}",
null, null, Symbol: "\uE82D"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ── 영한 사전 ─────────────────────────────────────────────────────────
if (q.StartsWith("en ", StringComparison.OrdinalIgnoreCase))
{
var word = q[3..].Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(word))
{
items.Add(new LauncherItem("dict en {영단어}", "예: dict en deadline", null, null, Symbol: "\uE774"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var found = EngDict.Where(e => e.Word.Contains(word, StringComparison.OrdinalIgnoreCase)).ToList();
if (found.Count == 0)
{
items.Add(new LauncherItem($"'{word}' 검색 결과 없음",
"내장 업무 영어 사전에 없는 단어입니다", null, null, Symbol: "\uE783"));
}
else
{
foreach (var e in found)
{
var sub = $"[{e.Pos}] {e.Kor}";
if (e.Example != null) sub += $" · {e.Example}";
items.Add(new LauncherItem(e.Word, sub, null, ("copy", $"{e.Word}: {e.Kor}"), Symbol: "\uE774"));
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ── 국어 사전 검색 ────────────────────────────────────────────────────
var kw = q;
var results = KorDict.Where(e =>
e.Word.Contains(kw, StringComparison.OrdinalIgnoreCase) ||
e.Def.Contains(kw, StringComparison.OrdinalIgnoreCase) ||
(e.Synonym?.Contains(kw, StringComparison.OrdinalIgnoreCase) ?? false) ||
(e.Note?.Contains(kw, StringComparison.OrdinalIgnoreCase) ?? false)).ToList();
if (results.Count == 0)
{
items.Add(new LauncherItem($"'{kw}' 검색 결과 없음",
"내장 국어 사전에 없는 단어입니다", null, null, Symbol: "\uE783"));
}
else
{
foreach (var e in results.Take(12))
{
var title = $"{e.Word} [{e.Pos}]";
var sub = e.Def;
if (e.Synonym != null) sub += $" · 유의어: {e.Synonym}";
if (e.Antonym != null) sub += $" · 반의어: {e.Antonym}";
if (e.Note != null) sub += $" · 💡 {e.Note}";
items.Add(new LauncherItem(title, sub, null, ("copy", $"{e.Word}: {e.Def}"), Symbol: "\uE82D"));
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
NotificationService.Notify("dict", "뜻풀이가 클립보드에 복사되었습니다.");
}
catch { }
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,237 @@
using System.IO;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
namespace AxCopilot.Handlers;
/// <summary>
/// L29-2: 명령 체인(워크플로우) 핸들러. "flow" 프리픽스로 사용합니다.
///
/// 예: flow → 등록된 플로우 목록
/// flow add 출근준비 "remind 09:00 회의" > "today" > "todo list" → 플로우 추가
/// flow 출근준비 → 플로우 실행
/// flow del 출근준비 → 플로우 삭제
/// flow edit 출근준비 → 플로우 명령 목록 표시 (클립보드 복사)
/// Enter → 저장된 명령들을 순서대로 런처에 실행 (클립보드에 명령 목록 복사).
/// Alfred 워크플로우 경량 대응.
/// 저장: %APPDATA%\AxCopilot\flows.json
/// </summary>
public class FlowHandler : IActionHandler
{
public string? Prefix => "flow";
public PluginMetadata Metadata => new(
"명령 체인",
"여러 명령을 묶어 순서대로 실행 (워크플로우)",
"1.0",
"AX");
private sealed record FlowEntry(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("commands")] List<string> Commands,
[property: JsonPropertyName("created")] DateTime Created);
private static readonly string DataPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "flows.json");
private static readonly JsonSerializerOptions JsonOpt = new()
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
var flows = Load();
// ── add 명령 ──────────────────────────────────────────────────────────
if (q.StartsWith("add ", StringComparison.OrdinalIgnoreCase))
{
var rest = q[4..].Trim();
var spaceIdx = rest.IndexOf(' ');
if (spaceIdx < 1)
{
items.Add(new LauncherItem("사용법: flow add {이름} {명령1} > {명령2} > ...",
"예: flow add 출근 \"today\" > \"todo list\" > \"remind 09:00 회의\"",
null, null, Symbol: "\uE710"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var name = rest[..spaceIdx];
var cmdStr = rest[(spaceIdx + 1)..].Trim();
var commands = ParseCommands(cmdStr);
if (commands.Count == 0)
{
items.Add(new LauncherItem("명령을 > 로 구분해 입력하세요",
"예: \"today\" > \"todo list\"",
null, null, Symbol: Themes.Symbols.Warning));
}
else
{
items.Add(new LauncherItem(
$"플로우 저장: {name} ({commands.Count}개 명령)",
string.Join(" → ", commands),
null, ("add", name, commands), Symbol: "\uE710"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ── del 명령 ──────────────────────────────────────────────────────────
if (q.StartsWith("del ", StringComparison.OrdinalIgnoreCase))
{
var name = q[4..].Trim();
var found = flows.FirstOrDefault(f => f.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
if (found != null)
{
items.Add(new LauncherItem($"플로우 삭제: {found.Name}",
$"{found.Commands.Count}개 명령 · {string.Join(" ", found.Commands)}",
null, ("del", found.Name), Symbol: "\uE74D"));
}
else
{
items.Add(new LauncherItem($"'{name}' 플로우를 찾을 수 없습니다",
"flow del {이름}", null, null, Symbol: "\uE783"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ── 빈 쿼리 → 전체 목록 ──────────────────────────────────────────────
if (string.IsNullOrWhiteSpace(q))
{
if (flows.Count == 0)
{
items.Add(new LauncherItem("등록된 명령 체인이 없습니다",
"flow add {이름} {명령1} > {명령2} > ... 로 추가하세요",
null, null, Symbol: "\uE8A0"));
items.Add(new LauncherItem("예시: flow add 출근 \"today\" > \"todo list\"",
"오늘 업무 뷰 → 할일 목록 순서대로 실행",
null, null, Symbol: Themes.Symbols.Info));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
items.Add(new LauncherItem($"명령 체인 {flows.Count}개",
"Enter: 명령 목록 클립보드 복사 · flow add/del 로 관리",
null, null, Symbol: "\uE8A0"));
foreach (var f in flows)
{
items.Add(new LauncherItem(
$"▶ {f.Name} ({f.Commands.Count}단계)",
string.Join(" → ", f.Commands),
null, ("run", f), Symbol: "\uE768"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ── 이름 검색 → 실행 ──────────────────────────────────────────────────
var match = flows.FirstOrDefault(f => f.Name.Equals(q, StringComparison.OrdinalIgnoreCase));
if (match != null)
{
items.Add(new LauncherItem(
$"▶ {match.Name} 실행",
string.Join(" → ", match.Commands),
null, ("run", match), Symbol: "\uE768"));
for (int i = 0; i < match.Commands.Count; i++)
items.Add(new LauncherItem($" {i + 1}. {match.Commands[i]}", "",
null, null, Symbol: Themes.Symbols.Terminal));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 부분 매칭
var searched = flows.Where(f =>
f.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
f.Commands.Any(c => c.Contains(q, StringComparison.OrdinalIgnoreCase))).ToList();
if (searched.Count > 0)
{
foreach (var f in searched)
items.Add(new LauncherItem($"▶ {f.Name} ({f.Commands.Count}단계)",
string.Join(" → ", f.Commands),
null, ("run", f), Symbol: "\uE768"));
}
else
{
items.Add(new LauncherItem($"'{q}' 플로우를 찾을 수 없습니다",
"flow add {이름} {명령} > {명령} 으로 추가하세요",
null, null, Symbol: "\uE783"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("add", string name, List<string> commands))
{
var flows = Load();
flows.RemoveAll(f => f.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
flows.Add(new FlowEntry(name, commands, DateTime.Now));
Save(flows);
NotificationService.Notify("flow", $"'{name}' 플로우가 저장되었습니다. ({commands.Count}단계)");
}
else if (item.Data is ("del", string delName))
{
var flows = Load();
flows.RemoveAll(f => f.Name.Equals(delName, StringComparison.OrdinalIgnoreCase));
Save(flows);
NotificationService.Notify("flow", $"'{delName}' 플로우가 삭제되었습니다.");
}
else if (item.Data is ("run", FlowEntry flow))
{
// 명령 목록을 클립보드에 복사 (사용자가 순서대로 런처에 입력)
var text = string.Join("\n", flow.Commands.Select((c, i) => $"{i + 1}. {c}"));
try
{
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
NotificationService.Notify("flow", $"'{flow.Name}' 명령 {flow.Commands.Count}개가 클립보드에 복사되었습니다.");
}
catch { }
}
return Task.CompletedTask;
}
// ─── 명령 파싱 ────────────────────────────────────────────────────────────
private static List<string> ParseCommands(string input)
{
// "cmd1" > "cmd2" > "cmd3" 또는 cmd1 > cmd2 > cmd3
return input.Split('>')
.Select(s => s.Trim().Trim('"').Trim())
.Where(s => !string.IsNullOrWhiteSpace(s))
.ToList();
}
// ─── JSON I/O ─────────────────────────────────────────────────────────────
private static List<FlowEntry> Load()
{
try
{
if (!File.Exists(DataPath)) return [];
var json = File.ReadAllText(DataPath);
return JsonSerializer.Deserialize<List<FlowEntry>>(json) ?? [];
}
catch { return []; }
}
private static void Save(List<FlowEntry> list)
{
try
{
var dir = Path.GetDirectoryName(DataPath)!;
if (!Directory.Exists(dir)) Directory.CreateDirectory(dir);
File.WriteAllText(DataPath, JsonSerializer.Serialize(list, JsonOpt));
}
catch { }
}
}

View File

@@ -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<CustomSpell> LoadCustom()
{
try
{
if (!File.Exists(CustomPath)) return [];
return JsonSerializer.Deserialize<List<CustomSpell>>(File.ReadAllText(CustomPath)) ?? [];
}
catch { return []; }
}
private static void SaveCustom(List<CustomSpell> list)
{
try
{
var dir = Path.GetDirectoryName(CustomPath)!;
if (!Directory.Exists(dir)) Directory.CreateDirectory(dir);
File.WriteAllText(CustomPath, JsonSerializer.Serialize(list, JsonOpt));
}
catch { }
}
private IEnumerable<SpellEntry> AllEntries()
{
foreach (var e in Entries) yield return e;
foreach (var c in LoadCustom())
yield return new SpellEntry(c.Wrong, c.Correct, c.Desc, "사용자");
}
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
// ── add 명령 (L29-3) ─────────────────────────────────────────────────
if (q.StartsWith("add ", StringComparison.OrdinalIgnoreCase))
{
var parts = q[4..].Trim().Split(' ', 3, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2)
{
items.Add(new LauncherItem("사용법: spell add {틀린표현} {올바른표현} [설명]",
"예: spell add 어의없다 어이없다 어이(기가 막혀)가 올바른 표현",
null, null, Symbol: "\uE710"));
}
else
{
var wrong = parts[0];
var correct = parts[1];
var desc = parts.Length >= 3 ? parts[2] : "사용자 추가 항목";
items.Add(new LauncherItem(
$"맞춤법 추가: ❌ {wrong} → ✅ {correct}",
desc,
null, ("add", $"{wrong}\t{correct}\t{desc}"), Symbol: "\uE710"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ── del 명령 (L29-3) ─────────────────────────────────────────────────
if (q.StartsWith("del ", StringComparison.OrdinalIgnoreCase))
{
var wrong = q[4..].Trim();
var custom = LoadCustom();
var found = custom.FirstOrDefault(c => c.Wrong.Equals(wrong, StringComparison.OrdinalIgnoreCase));
if (found != null)
{
items.Add(new LauncherItem($"사용자 항목 삭제: {found.Wrong} → {found.Correct}",
found.Desc, null, ("del", found.Wrong), Symbol: "\uE74D"));
}
else
{
items.Add(new LauncherItem($"'{wrong}' 사용자 항목을 찾을 수 없습니다",
"기본 항목은 삭제할 수 없습니다", null, null, Symbol: "\uE783"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ── custom 명령 (사용자 항목만 보기) ──────────────────────────────────
if (q.Equals("custom", StringComparison.OrdinalIgnoreCase) ||
q.Equals("사용자", StringComparison.OrdinalIgnoreCase))
{
var custom = LoadCustom();
if (custom.Count == 0)
{
items.Add(new LauncherItem("사용자 항목이 없습니다",
"spell add {틀린표현} {올바른표현} [설명] 으로 추가하세요",
null, null, Symbol: "\uE7BA"));
}
else
{
items.Add(new LauncherItem($"사용자 맞춤법 항목 {custom.Count}개", "",
null, null, Symbol: "\uE7BA"));
foreach (var c in custom)
items.Add(MakeSpellItem(new SpellEntry(c.Wrong, c.Correct, c.Desc, "사용자")));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
if (string.IsNullOrWhiteSpace(q))
{
// 클립보드 텍스트 맞춤법 검사
@@ -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<IEnumerable<LauncherItem>>(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;
}

View File

@@ -27,6 +27,7 @@ public partial class LauncherWindow
"[ 기능 ]",
"F1 도움말",
"F2 파일 이름 바꾸기",
"F3 파일 빠른 미리보기 (토글)",
"F5 인덱스 새로 고침",
"Delete 항목 제거",
"Ctrl+, 설정",