[Phase L28] Windows 생태계 통합 + 콘텐츠 UX 강화 4종 구현
PkgHandler.cs (신규, prefix=pkg):
- winget search subprocess 결과 Regex 파싱 (PkgResult 레코드)
- pkg install {id} → cmd 터미널으로 실행 (사용자 확인 가능)
- pkg list / pkg upgrade 서브명령
- winget 미설치 감지 → 안내 메시지 (캐시 체크)
- PowerToys Command Palette winget 기능 대응
ApHandler.cs (신규, prefix=ap):
- 클립보드 텍스트 즉시 변환 15종 내장
- upper/lower/trim/sort/rsort/unique/number/reverse
- blank/single/count/json/slug/base64/decode64
- replace {A} {B} 텍스트 치환 명령
- PowerToys Advanced Paste 대응
SnippetHandler.cs (수정, L28-2):
- ExpandVariables 5개 플레이스홀더 추가:
· {clipboard} → 현재 클립보드 텍스트
· {user} → Windows 사용자명
· {computer} → PC명
· {weekday} → 한국어 요일 (월요일~일요일)
· {app} → 이전 포커스 앱 프로세스명
- GetWindowThreadProcessId P/Invoke 추가
- Raycast 스니펫 동적 플레이스홀더 대응
LauncherViewModel.cs (수정, L28-3):
- UpdatePreviewAsync 미리보기 확장:
· 이미지 10종 (.jpg/.png/.gif 등): BitmapDecoder 해상도 + 파일 크기
· PDF: PdfPig 페이지 수 + 첫 페이지 텍스트 200자 추출
· 오디오/동영상 12종: 파일 크기 + 수정일 메타
- IsImageFile(), IsMediaFile(), GetImageMeta(), GetPdfMeta(),
GetFileSizeMeta(), FormatFileSize() 헬퍼 메서드 추가
App.xaml.cs: L28 핸들러 2개 등록 (PkgHandler, ApHandler)
LAUNCHER_ROADMAP.md: Phase L28 ✅ 완료 + 벤치마킹 공백 3개 해소
빌드: 경고 0, 오류 0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
## 현재 상태 (v2.2.0)
|
||||
|
||||
### 핵심 기능 (119개 핸들러, L27 완료 / L28~L29 계획 중)
|
||||
### 핵심 기능 (121개 핸들러, L28 완료 / L29 계획 중)
|
||||
- 퍼지 검색 + 한글 초성 검색 (FuzzyEngine) + **최근 실행 지수 감소 랭킹 (30일 decay)**
|
||||
- 110개+ 프리픽스 명령 (계산기·이모지·웹검색·스니펫·클립보드·프로세스·데이터·네트워크·업무양식 등)
|
||||
- 10가지 테마 + 커스텀 테마
|
||||
@@ -43,19 +43,19 @@
|
||||
| **스니펫 확장** | ✅ | ✅ 동적 플레이스홀더 | ✅ | △ 플러그인 | ❌ | ❌ |
|
||||
| **창 관리** | ✅ 22 레이아웃 | ✅ 70+ (Win 베타 탑재) | ❌ | ❌ | △ FancyZones 연동 | ❌ |
|
||||
| **파일 탐색기 인라인** | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ **독점** |
|
||||
| **파일 미리보기** | △ 텍스트 6줄 | ❌ | ✅ Grid View | ❌ | ❌ | △ 사이드바 |
|
||||
| **파일 미리보기** | ✅ (L28 강화) | ❌ | ✅ Grid View | ❌ | ❌ | △ 사이드바 |
|
||||
| **브라우저 북마크 검색** | ❌ **공백** | ✅ 확장 연동 | ✅ | △ 플러그인 | ✅ 내장 | ❌ |
|
||||
| **브라우저 탭 AI 전달** | ❌ **공백** | ✅ {browser-tab} | ❌ | ❌ | ❌ | ❌ |
|
||||
| **시스템 볼륨 제어** | ✅ (L27) | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| **화면 밝기 제어** | ✅ (L27) | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
| **QR 코드 생성** | ✅ (L27) | ✅ | ✅ | △ 플러그인 | ❌ | ❌ |
|
||||
| **회의 링크 빠른 열기** | ✅ (L27) | ✅ Calendar 연동 | ✅ | ❌ | ❌ | ❌ |
|
||||
| **winget 앱 설치** | ❌ **공백** | ✅ Win 베타 | ❌ | △ 플러그인 | ❌ | ✅ 내장 |
|
||||
| **winget 앱 설치** | ✅ (L28) | ✅ Win 베타 | ❌ | △ 플러그인 | ❌ | ✅ 내장 |
|
||||
| **노코드 워크플로우** | ❌ **공백** | △ AI Ext 베타 | ✅ 완전 지원 | ❌ | ❌ | ❌ |
|
||||
| **스크립트 명령 실행** | ✅ ^ 프리픽스 | ✅ Script Commands | ✅ | ✅ | ✅ | ❌ |
|
||||
| **Everything 연동** | ✅ es 프리픽스 | ❌ | ❌ | ✅ | ❌ | ❌ |
|
||||
| **선택 텍스트 AI** | ✅ 팝업 | ✅ AI Commands | ❌ | ❌ | ❌ | ❌ |
|
||||
| **AI 붙여넣기 변환** | ❌ **공백** | ❌ | ❌ | ❌ | ✅ Advanced Paste | ❌ |
|
||||
| **AI 붙여넣기 변환** | ✅ (L28) | ❌ | ❌ | ❌ | ✅ Advanced Paste | ❌ |
|
||||
| **OCR** | ✅ | ❌ | ❌ | △ 플러그인 | ✅ Text Extractor | ❌ |
|
||||
| **독 바 (영구 표시)** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| **플러그인 생태계** | DLL+JSON | 1,300+ | 수백+ | 200+ | 내장 중심 | ❌ |
|
||||
@@ -602,20 +602,20 @@ public record HotkeyAssignment(string HotkeyStr, string TargetPath, string Label
|
||||
|
||||
---
|
||||
|
||||
## Phase L28 — Windows 생태계 통합 + 콘텐츠 UX 강화 (v2.3.0) 📋 계획
|
||||
## Phase L28 — Windows 생태계 통합 + 콘텐츠 UX 강화 (v2.3.0) ✅ 완료
|
||||
|
||||
> **방향**: PowerToys·Raycast Windows 버전이 선점 중인 **Windows 네이티브 통합** + 스니펫·미리보기 UX 수준 향상.
|
||||
> 외부 설치(winget)가 필요한 기능은 미설치 감지 → 안내 메시지 패턴 적용.
|
||||
|
||||
| # | 기능 | 프리픽스 | 구현 방식 | 경쟁 대비 |
|
||||
|---|------|---------|---------|---------|
|
||||
| 📋 L28-1 | **winget 앱 검색·설치** | `pkg` | `winget search {q}` subprocess → 결과 파싱. `pkg install {id}` → 권한 확인 후 실행. `pkg list` → 설치된 앱. winget 미설치 감지 후 안내. PowerToys CP 내장 기능 대응 | PowerToys CP |
|
||||
| 📋 L28-2 | **스니펫 동적 플레이스홀더** | `snip` 확장 | 기존 SnippetHandler에 치환 엔진 추가. `{date}` → 오늘 날짜, `{time}` → 현재 시각, `{clipboard}` → 현재 클립보드, `{user}` → Windows 사용자명, `{app}` → 포커스 앱명. Raycast 스니펫 플레이스홀더 대응 | Raycast |
|
||||
| 📋 L28-3 | **파일 미리보기 강화** | 파일 검색 결과 확장 | 기존 텍스트 6줄에서: 이미지(jpg·png·gif·svg) → WPF BitmapImage 썸네일 (120px). PDF → `Windows.Data.Pdf` 첫 페이지 렌더링. 음악(mp3) → 파일 메타(태그·길이·비트레이트). Alfred Grid View 방향 | Alfred 5 |
|
||||
| 📋 L28-4 | **AI 붙여넣기 변환** | `ap` | 클립보드 텍스트를 AX Agent에 즉시 전달해 변환. `ap 요약`, `ap 번역`, `ap 교정`, `ap 표로 변환` 등. AI 비활성 시 차단. PowerToys Advanced Paste 대응 (사내 LLM 버전) | PowerToys |
|
||||
| # | 기능 | 프리픽스 | 구현 방식 |
|
||||
|---|------|---------|---------|
|
||||
| ✅ L28-1 | **winget 앱 검색·설치** | `pkg` | `PkgHandler.cs` — `winget search` subprocess 결과 Regex 파싱. `pkg install {id}` → cmd 터미널로 실행. `pkg list` / `pkg upgrade`. winget 미설치 감지 후 안내. PowerToys CP 대응 |
|
||||
| ✅ L28-2 | **스니펫 동적 플레이스홀더** | `;` 확장 | `SnippetHandler.cs` 수정 — 기존 {date}/{time} 외 `{clipboard}` (현재 클립보드), `{user}` (사용자명), `{computer}` (PC명), `{weekday}` (한국어 요일), `{app}` (이전 포커스 앱 프로세스명) 5개 추가. Raycast 스니펫 대응 |
|
||||
| ✅ L28-3 | **파일 미리보기 강화** | 파일 검색 확장 | `LauncherViewModel.cs` 수정 — 이미지(10종): BitmapDecoder 해상도 + 파일 크기. PDF: PdfPig 페이지 수 + 첫 페이지 텍스트 200자. 오디오/동영상: 파일 크기 + 수정일. Alfred Grid View 경량 대응 |
|
||||
| ✅ L28-4 | **클립보드 텍스트 즉시 변환** | `ap` | `ApHandler.cs` — 15종 변환: upper/lower/trim/sort/rsort/unique/number/reverse/blank/single/count/json/slug/base64/decode64 + replace 치환. PowerToys Advanced Paste 대응 (텍스트 변환 중심) |
|
||||
|
||||
**구현 우선순위**: L28-2(snip 확장) → L28-3(미리보기) → L28-1(pkg) → L28-4(ap)
|
||||
**주의**: L28-1 winget은 winget 설치 여부 런타임 체크 필수. L28-4는 `AiEnabled` 게이트 필수.
|
||||
**NuGet 추가**: 없음 (PdfPig 기존 참조 활용)
|
||||
**빌드**: 경고 0, 오류 0
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -385,6 +385,14 @@ public partial class App : System.Windows.Application
|
||||
// L27-6: 클립보드 순차 붙여넣기 (prefix=paste)
|
||||
commandResolver.RegisterHandler(new PasteHandler(_clipboardHistory));
|
||||
|
||||
// ─── L28: Windows 생태계 통합 + 콘텐츠 UX 강화 ─────────────────────
|
||||
// L28-1: winget 앱 검색·설치 (prefix=pkg)
|
||||
commandResolver.RegisterHandler(new PkgHandler());
|
||||
// L28-2: SnippetHandler 동적 플레이스홀더 확장 — {clipboard}/{user}/{app}/{weekday}/{computer} 추가 (기존 핸들러 수정)
|
||||
// L28-3: 파일 미리보기 강화 — 이미지 해상도·PDF 페이지수·미디어 메타 (LauncherViewModel 수정)
|
||||
// L28-4: 클립보드 텍스트 즉시 변환 (prefix=ap)
|
||||
commandResolver.RegisterHandler(new ApHandler());
|
||||
|
||||
// ─── 플러그인 로드 ────────────────────────────────────────────────────
|
||||
var pluginHost = new PluginHost(settings, commandResolver);
|
||||
pluginHost.LoadAll();
|
||||
|
||||
260
src/AxCopilot/Handlers/ApHandler.cs
Normal file
260
src/AxCopilot/Handlers/ApHandler.cs
Normal file
@@ -0,0 +1,260 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L28-4: 클립보드 텍스트 즉시 변환 핸들러. "ap" 프리픽스로 사용합니다.
|
||||
/// (Advanced Paste — PowerToys Advanced Paste 대응)
|
||||
///
|
||||
/// 예: ap → 클립보드 내용 표시 + 변환 목록
|
||||
/// ap upper → 대문자 변환
|
||||
/// ap lower → 소문자 변환
|
||||
/// ap trim → 앞뒤 공백 제거
|
||||
/// ap sort → 줄 정렬
|
||||
/// ap unique → 중복 줄 제거
|
||||
/// ap number → 줄 번호 추가
|
||||
/// ap reverse → 줄 순서 뒤집기
|
||||
/// ap count → 글자/단어/줄 수
|
||||
/// ap json → JSON 정리 (포맷팅)
|
||||
/// ap remove blank → 빈 줄 제거
|
||||
/// ap replace A B → A를 B로 전체 치환
|
||||
/// Enter → 변환된 텍스트를 클립보드에 복사.
|
||||
/// </summary>
|
||||
public class ApHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "ap";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"텍스트 변환",
|
||||
"클립보드 텍스트 즉시 변환 (Advanced Paste)",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private static readonly (string Cmd, string Label, string Desc)[] Commands =
|
||||
[
|
||||
("upper", "대문자 변환", "전체 텍스트를 대문자로"),
|
||||
("lower", "소문자 변환", "전체 텍스트를 소문자로"),
|
||||
("trim", "공백 정리", "각 줄 앞뒤 공백 제거"),
|
||||
("sort", "줄 정렬", "알파벳/가나다 순 줄 정렬"),
|
||||
("rsort", "줄 역순 정렬", "역순 줄 정렬"),
|
||||
("unique", "중복 제거", "동일 줄 제거 (순서 유지)"),
|
||||
("number", "줄 번호 추가", "각 줄 앞에 1. 2. 3. 번호"),
|
||||
("reverse", "줄 순서 뒤집기", "마지막 줄 → 첫 줄"),
|
||||
("count", "텍스트 통계", "글자·단어·줄 수 표시"),
|
||||
("blank", "빈 줄 제거", "빈 줄 삭제"),
|
||||
("json", "JSON 정리", "JSON 들여쓰기 포맷팅"),
|
||||
("single", "한 줄로 합치기", "줄바꿈 → 공백으로 연결"),
|
||||
("slug", "URL 슬러그", "소문자 + 하이픈 (공백/특수문자 변환)"),
|
||||
("base64", "Base64 인코딩", "텍스트 → Base64"),
|
||||
("decode64","Base64 디코딩", "Base64 → 텍스트"),
|
||||
];
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim().ToLowerInvariant();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
// 클립보드 텍스트 읽기
|
||||
string clipText;
|
||||
try
|
||||
{
|
||||
clipText = Application.Current.Dispatcher.Invoke(() => Clipboard.GetText()) ?? "";
|
||||
}
|
||||
catch
|
||||
{
|
||||
clipText = "";
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(clipText))
|
||||
{
|
||||
items.Add(new LauncherItem("클립보드가 비어 있습니다",
|
||||
"변환할 텍스트를 먼저 복사하세요",
|
||||
null, null, Symbol: Symbols.Clipboard));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var preview = clipText.Length > 80 ? clipText[..77].Replace("\n", " ") + "…" : clipText.Replace("\n", " ");
|
||||
int lineCount = clipText.Split('\n').Length;
|
||||
|
||||
// ── replace 명령 ───────────────────────────────────────────────────────
|
||||
if (q.StartsWith("replace "))
|
||||
{
|
||||
var parts = q[8..].Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length >= 2)
|
||||
{
|
||||
var from = parts[0];
|
||||
var to = parts[1];
|
||||
var result = clipText.Replace(from, to, StringComparison.OrdinalIgnoreCase);
|
||||
int count = (clipText.Length - result.Length) / Math.Max(from.Length - to.Length, 1);
|
||||
items.Add(new LauncherItem(
|
||||
$"치환: '{from}' → '{to}'",
|
||||
$"Enter: 클립보드 갱신",
|
||||
null, ("result", result), Symbol: Symbols.Clipboard));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem("사용법: ap replace {찾을값} {바꿀값}",
|
||||
"예: ap replace hello world", null, null, Symbol: Symbols.Info));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ── 빈 쿼리 → 전체 명령 목록 ─────────────────────────────────────────
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"클립보드: {preview}",
|
||||
$"{clipText.Length}자 · {lineCount}줄 · 아래 변환 명령 선택",
|
||||
null, null, Symbol: Symbols.Clipboard));
|
||||
|
||||
foreach (var (cmd, label, desc) in Commands)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"ap {cmd} — {label}",
|
||||
desc,
|
||||
null, ("cmd", cmd), Symbol: "\uE8AC"));
|
||||
}
|
||||
items.Add(new LauncherItem(
|
||||
"ap replace {A} {B} — 텍스트 치환",
|
||||
"A를 B로 전체 치환",
|
||||
null, null, Symbol: "\uE8AC"));
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ── 명령 실행 미리보기 ───────────────────────────────────────────────
|
||||
var transformed = Transform(clipText, q);
|
||||
if (transformed != null)
|
||||
{
|
||||
var tPreview = transformed.Length > 120 ? transformed[..117].Replace("\n", " ") + "…" : transformed.Replace("\n", " ");
|
||||
var cmdInfo = Commands.FirstOrDefault(c => c.Cmd == q);
|
||||
items.Add(new LauncherItem(
|
||||
$"{(cmdInfo.Label ?? q)} 결과",
|
||||
$"{tPreview} · Enter: 클립보드 갱신",
|
||||
null, ("result", transformed), Symbol: Symbols.Clipboard));
|
||||
}
|
||||
else
|
||||
{
|
||||
// 부분 매칭으로 명령 제안
|
||||
var matched = Commands.Where(c =>
|
||||
c.Cmd.Contains(q) || c.Label.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (matched.Count > 0)
|
||||
{
|
||||
foreach (var (cmd, label, desc) in matched)
|
||||
items.Add(new LauncherItem($"ap {cmd} — {label}", desc,
|
||||
null, ("cmd", cmd), Symbol: "\uE8AC"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"'{q}' 알 수 없는 변환 명령",
|
||||
"ap upper/lower/trim/sort/unique/number/reverse/json ...",
|
||||
null, null, Symbol: Symbols.Warning));
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is ("result", string result))
|
||||
{
|
||||
try
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(result));
|
||||
NotificationService.Notify("ap", "변환 결과가 클립보드에 복사되었습니다.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
NotificationService.Notify("ap", $"복사 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
else if (item.Data is ("cmd", string cmd))
|
||||
{
|
||||
try
|
||||
{
|
||||
var clipText = Application.Current.Dispatcher.Invoke(() => Clipboard.GetText()) ?? "";
|
||||
var result2 = Transform(clipText, cmd);
|
||||
if (result2 != null)
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(result2));
|
||||
NotificationService.Notify("ap", "변환 결과가 클립보드에 복사되었습니다.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
NotificationService.Notify("ap", $"변환 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ─── 변환 로직 ───────────────────────────────────────────────────────────
|
||||
|
||||
private static string? Transform(string text, string cmd)
|
||||
{
|
||||
var lines = text.Split('\n').Select(l => l.TrimEnd('\r')).ToArray();
|
||||
|
||||
return cmd switch
|
||||
{
|
||||
"upper" => text.ToUpperInvariant(),
|
||||
"lower" => text.ToLowerInvariant(),
|
||||
"trim" => string.Join("\n", lines.Select(l => l.Trim())),
|
||||
"sort" => string.Join("\n", lines.OrderBy(l => l, StringComparer.OrdinalIgnoreCase)),
|
||||
"rsort" => string.Join("\n", lines.OrderByDescending(l => l, StringComparer.OrdinalIgnoreCase)),
|
||||
"unique" => string.Join("\n", lines.Distinct()),
|
||||
"number" => string.Join("\n", lines.Select((l, i) => $"{i + 1}. {l}")),
|
||||
"reverse" => string.Join("\n", lines.Reverse()),
|
||||
"blank" => string.Join("\n", lines.Where(l => !string.IsNullOrWhiteSpace(l))),
|
||||
"single" => string.Join(" ", lines.Where(l => !string.IsNullOrWhiteSpace(l)).Select(l => l.Trim())),
|
||||
"count" => $"글자: {text.Length} · 단어: {CountWords(text)} · 줄: {lines.Length} · 바이트: {Encoding.UTF8.GetByteCount(text)}",
|
||||
"json" => TryFormatJson(text),
|
||||
"slug" => ToSlug(text),
|
||||
"base64" => Convert.ToBase64String(Encoding.UTF8.GetBytes(text)),
|
||||
"decode64" => TryDecodeBase64(text),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static int CountWords(string text)
|
||||
=> Regex.Matches(text, @"[\w가-힣]+").Count;
|
||||
|
||||
private static string TryFormatJson(string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
var doc = System.Text.Json.JsonDocument.Parse(text);
|
||||
using var ms = new System.IO.MemoryStream();
|
||||
using var writer = new System.Text.Json.Utf8JsonWriter(ms, new System.Text.Json.JsonWriterOptions { Indented = true });
|
||||
doc.WriteTo(writer);
|
||||
writer.Flush();
|
||||
return Encoding.UTF8.GetString(ms.ToArray());
|
||||
}
|
||||
catch { return "유효하지 않은 JSON입니다."; }
|
||||
}
|
||||
|
||||
private static string ToSlug(string text)
|
||||
{
|
||||
var slug = text.ToLowerInvariant().Trim();
|
||||
slug = Regex.Replace(slug, @"[^a-z0-9가-힣\s-]", "");
|
||||
slug = Regex.Replace(slug, @"[\s]+", "-");
|
||||
slug = Regex.Replace(slug, @"-{2,}", "-");
|
||||
return slug.Trim('-');
|
||||
}
|
||||
|
||||
private static string TryDecodeBase64(string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(text.Trim());
|
||||
return Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
catch { return "유효하지 않은 Base64입니다."; }
|
||||
}
|
||||
}
|
||||
238
src/AxCopilot/Handlers/PkgHandler.cs
Normal file
238
src/AxCopilot/Handlers/PkgHandler.cs
Normal file
@@ -0,0 +1,238 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.RegularExpressions;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L28-1: winget 앱 검색·설치·목록 핸들러. "pkg" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: pkg → 사용법 안내
|
||||
/// pkg vscode → winget search vscode
|
||||
/// pkg install {id} → winget install {id}
|
||||
/// pkg list → 설치된 앱 목록
|
||||
/// pkg upgrade → 업그레이드 가능 목록
|
||||
/// winget 미설치 시 안내 메시지 표시.
|
||||
/// </summary>
|
||||
public partial class PkgHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "pkg";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"앱 패키지",
|
||||
"winget 앱 검색·설치·업그레이드",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private static bool? _wingetAvailable;
|
||||
|
||||
public async Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
// winget 설치 여부 체크 (캐시)
|
||||
_wingetAvailable ??= await CheckWingetAsync();
|
||||
if (_wingetAvailable == false)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
"winget이 설치되어 있지 않습니다",
|
||||
"Windows Package Manager는 Windows 10 1709+ 에서 사용 가능합니다",
|
||||
null, null, Symbol: Symbols.Warning));
|
||||
return items;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
items.Add(new LauncherItem("winget 앱 패키지 관리",
|
||||
"pkg {검색어} · pkg install {id} · pkg list · pkg upgrade",
|
||||
null, null, Symbol: "\uECAA"));
|
||||
return items;
|
||||
}
|
||||
|
||||
// ── list 명령 ─────────────────────────────────────────────────────────
|
||||
if (q.Equals("list", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
items.Add(new LauncherItem("설치된 앱 목록 조회 중...",
|
||||
"winget list 실행", null, ("list", ""), Symbol: "\uECAA"));
|
||||
// 실행 시 터미널에서 보여주기
|
||||
return items;
|
||||
}
|
||||
|
||||
// ── upgrade 명령 ──────────────────────────────────────────────────────
|
||||
if (q.Equals("upgrade", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
items.Add(new LauncherItem("업그레이드 가능 앱 확인",
|
||||
"Enter: winget upgrade 실행", null, ("upgrade", ""), Symbol: "\uE777"));
|
||||
return items;
|
||||
}
|
||||
|
||||
// ── install 명령 ──────────────────────────────────────────────────────
|
||||
if (q.StartsWith("install ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var id = q[8..].Trim();
|
||||
if (!string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"앱 설치: {id}",
|
||||
$"Enter: winget install --id {id}",
|
||||
null, ("install", id), Symbol: "\uE896"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem("사용법: pkg install {앱ID}",
|
||||
"예: pkg install Microsoft.VisualStudioCode",
|
||||
null, null, Symbol: Symbols.Info));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// ── 검색 ──────────────────────────────────────────────────────────────
|
||||
try
|
||||
{
|
||||
var results = await SearchAsync(q, ct);
|
||||
if (results.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem($"'{q}' 검색 결과 없음",
|
||||
"다른 검색어를 시도하세요", null, null, Symbol: Symbols.Search));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"검색 결과: {results.Count}개",
|
||||
"Enter: winget install --id {ID}", null, null, Symbol: Symbols.Search));
|
||||
|
||||
foreach (var r in results.Take(10))
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"{r.Name} [{r.Version}]",
|
||||
$"{r.Id} · {r.Source}",
|
||||
null, ("install", r.Id), Symbol: "\uECAA"));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
items.Add(new LauncherItem("검색 오류", ex.Message, null, null, Symbol: Symbols.Error));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is ("install", string id) && !string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
RunWingetInTerminal($"install --id \"{id}\" --accept-source-agreements --accept-package-agreements");
|
||||
NotificationService.Notify("pkg", $"설치 시작: {id}");
|
||||
}
|
||||
else if (item.Data is ("list", _))
|
||||
{
|
||||
RunWingetInTerminal("list");
|
||||
}
|
||||
else if (item.Data is ("upgrade", _))
|
||||
{
|
||||
RunWingetInTerminal("upgrade --include-unknown");
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ─── winget 검색 ──────────────────────────────────────────────────────────
|
||||
|
||||
private record PkgResult(string Name, string Id, string Version, string Source);
|
||||
|
||||
private static async Task<List<PkgResult>> SearchAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var output = await RunWingetAsync($"search \"{query}\" --accept-source-agreements", ct);
|
||||
return ParseWingetOutput(output);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^(.+?)\s{2,}(\S+)\s{2,}(\S+)\s{2,}(\S+)\s*$")]
|
||||
private static partial Regex WingetLineRegex();
|
||||
|
||||
private static List<PkgResult> ParseWingetOutput(string output)
|
||||
{
|
||||
var results = new List<PkgResult>();
|
||||
var lines = output.Split('\n');
|
||||
bool pastHeader = false;
|
||||
|
||||
foreach (var rawLine in lines)
|
||||
{
|
||||
var line = rawLine.TrimEnd();
|
||||
|
||||
// 헤더 구분선 (---) 이후부터 데이터
|
||||
if (line.StartsWith("---") || line.StartsWith("───"))
|
||||
{
|
||||
pastHeader = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!pastHeader || string.IsNullOrWhiteSpace(line)) continue;
|
||||
|
||||
var match = WingetLineRegex().Match(line);
|
||||
if (match.Success)
|
||||
{
|
||||
results.Add(new PkgResult(
|
||||
match.Groups[1].Value.Trim(),
|
||||
match.Groups[2].Value.Trim(),
|
||||
match.Groups[3].Value.Trim(),
|
||||
match.Groups[4].Value.Trim()));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── winget 실행 ──────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<bool> CheckWingetAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var output = await RunWingetAsync("--version", CancellationToken.None);
|
||||
return output.TrimStart().StartsWith('v');
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static async Task<string> RunWingetAsync(string args, CancellationToken ct)
|
||||
{
|
||||
using var proc = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "winget",
|
||||
Arguments = args,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = System.Text.Encoding.UTF8
|
||||
}
|
||||
};
|
||||
proc.Start();
|
||||
var output = await proc.StandardOutput.ReadToEndAsync(ct);
|
||||
await proc.WaitForExitAsync(ct);
|
||||
return output;
|
||||
}
|
||||
|
||||
private static void RunWingetInTerminal(string args)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 사용자에게 진행 상황이 보이도록 터미널 창으로 실행
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "cmd.exe",
|
||||
Arguments = $"/k winget {args}",
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"winget 실행 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,14 +107,52 @@ public class SnippetHandler : IActionHandler
|
||||
private static string ExpandVariables(string content)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
return content
|
||||
var result = content
|
||||
.Replace("{date}", now.ToString("yyyy-MM-dd"))
|
||||
.Replace("{time}", now.ToString("HH:mm:ss"))
|
||||
.Replace("{datetime}", now.ToString("yyyy-MM-dd HH:mm:ss"))
|
||||
.Replace("{year}", now.Year.ToString())
|
||||
.Replace("{month}", now.Month.ToString("D2"))
|
||||
.Replace("{day}", now.Day.ToString("D2"));
|
||||
.Replace("{day}", now.Day.ToString("D2"))
|
||||
.Replace("{weekday}", now.ToString("dddd", System.Globalization.CultureInfo.GetCultureInfo("ko-KR")))
|
||||
.Replace("{user}", Environment.UserName)
|
||||
.Replace("{computer}", Environment.MachineName);
|
||||
|
||||
// {clipboard} → 현재 클립보드 텍스트 (UI 스레드에서 실행)
|
||||
if (result.Contains("{clipboard}"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var clip = System.Windows.Application.Current.Dispatcher.Invoke(
|
||||
() => System.Windows.Clipboard.GetText()) ?? "";
|
||||
result = result.Replace("{clipboard}", clip);
|
||||
}
|
||||
catch { result = result.Replace("{clipboard}", ""); }
|
||||
}
|
||||
|
||||
// {app} → 이전 포커스 앱 이름
|
||||
if (result.Contains("{app}"))
|
||||
{
|
||||
var appName = "";
|
||||
try
|
||||
{
|
||||
var hwnd = Services.WindowTracker.PreviousWindow;
|
||||
if (hwnd != IntPtr.Zero)
|
||||
{
|
||||
GetWindowThreadProcessId(hwnd, out uint pid);
|
||||
using var proc = System.Diagnostics.Process.GetProcessById((int)pid);
|
||||
appName = proc.ProcessName;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
result = result.Replace("{app}", appName);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("user32.dll")]
|
||||
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
||||
|
||||
private static string TruncatePreview(string content)
|
||||
{
|
||||
|
||||
@@ -568,6 +568,8 @@ public partial class LauncherViewModel : INotifyPropertyChanged
|
||||
if (item.Data is IndexEntry indexEntry)
|
||||
{
|
||||
var ext = System.IO.Path.GetExtension(indexEntry.Path).ToLowerInvariant();
|
||||
|
||||
// 텍스트 파일: 첫 6줄 미리보기
|
||||
if (IsPreviewableTextFile(ext) && System.IO.File.Exists(indexEntry.Path))
|
||||
{
|
||||
var lines = await ReadFirstLinesAsync(indexEntry.Path, 6, ct);
|
||||
@@ -576,6 +578,29 @@ public partial class LauncherViewModel : INotifyPropertyChanged
|
||||
HasPreview = !string.IsNullOrEmpty(PreviewText.Trim());
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미지 파일: 해상도·크기 메타데이터
|
||||
if (IsImageFile(ext) && System.IO.File.Exists(indexEntry.Path))
|
||||
{
|
||||
var meta = await Task.Run(() => GetImageMeta(indexEntry.Path), ct);
|
||||
if (ct.IsCancellationRequested) return;
|
||||
if (meta != null) { PreviewText = meta; HasPreview = true; return; }
|
||||
}
|
||||
|
||||
// PDF 파일: 페이지 수·크기·첫 페이지 텍스트
|
||||
if (ext == ".pdf" && System.IO.File.Exists(indexEntry.Path))
|
||||
{
|
||||
var meta = await Task.Run(() => GetPdfMeta(indexEntry.Path), ct);
|
||||
if (ct.IsCancellationRequested) return;
|
||||
if (meta != null) { PreviewText = meta; HasPreview = true; return; }
|
||||
}
|
||||
|
||||
// 미디어 파일: 파일 크기
|
||||
if (IsMediaFile(ext) && System.IO.File.Exists(indexEntry.Path))
|
||||
{
|
||||
var meta = GetFileSizeMeta(indexEntry.Path, ext);
|
||||
if (meta != null) { PreviewText = meta; HasPreview = true; return; }
|
||||
}
|
||||
}
|
||||
|
||||
HasPreview = false;
|
||||
@@ -589,6 +614,89 @@ public partial class LauncherViewModel : INotifyPropertyChanged
|
||||
or ".yaml" or ".yml" or ".ini" or ".cfg" or ".conf"
|
||||
or ".cs" or ".py" or ".js" or ".ts" or ".html" or ".css";
|
||||
|
||||
private static bool IsImageFile(string ext) => ext is
|
||||
".jpg" or ".jpeg" or ".png" or ".gif" or ".bmp" or ".webp"
|
||||
or ".svg" or ".ico" or ".tiff" or ".tif";
|
||||
|
||||
private static bool IsMediaFile(string ext) => ext is
|
||||
".mp3" or ".wav" or ".flac" or ".aac" or ".ogg" or ".wma"
|
||||
or ".mp4" or ".avi" or ".mkv" or ".mov" or ".wmv" or ".webm";
|
||||
|
||||
private static string? GetImageMeta(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fi = new System.IO.FileInfo(path);
|
||||
var size = FormatFileSize(fi.Length);
|
||||
var ext = fi.Extension.TrimStart('.').ToUpperInvariant();
|
||||
|
||||
// BitmapDecoder로 해상도 읽기
|
||||
using var stream = new System.IO.FileStream(path,
|
||||
System.IO.FileMode.Open, System.IO.FileAccess.Read, System.IO.FileShare.Read);
|
||||
var decoder = System.Windows.Media.Imaging.BitmapDecoder.Create(
|
||||
stream, System.Windows.Media.Imaging.BitmapCreateOptions.DelayCreation,
|
||||
System.Windows.Media.Imaging.BitmapCacheOption.None);
|
||||
var frame = decoder.Frames[0];
|
||||
return $"🖼 {ext} 이미지 · {frame.PixelWidth}×{frame.PixelHeight} · {size}\n" +
|
||||
$"수정: {fi.LastWriteTime:yyyy-MM-dd HH:mm}";
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static string? GetPdfMeta(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fi = new System.IO.FileInfo(path);
|
||||
var size = FormatFileSize(fi.Length);
|
||||
|
||||
using var doc = UglyToad.PdfPig.PdfDocument.Open(path);
|
||||
int pages = doc.NumberOfPages;
|
||||
var firstPageText = "";
|
||||
if (pages > 0)
|
||||
{
|
||||
var page = doc.GetPage(1);
|
||||
firstPageText = page.Text;
|
||||
if (firstPageText.Length > 200)
|
||||
firstPageText = firstPageText[..200] + "…";
|
||||
firstPageText = firstPageText.Replace("\r\n", " ").Replace("\n", " ");
|
||||
}
|
||||
|
||||
var meta = $"📄 PDF · {pages}페이지 · {size}";
|
||||
if (!string.IsNullOrWhiteSpace(firstPageText))
|
||||
meta += $"\n{firstPageText}";
|
||||
|
||||
return meta;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static string? GetFileSizeMeta(string path, string ext)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fi = new System.IO.FileInfo(path);
|
||||
var size = FormatFileSize(fi.Length);
|
||||
var type = ext switch
|
||||
{
|
||||
".mp3" or ".wav" or ".flac" or ".aac" or ".ogg" or ".wma" => "🎵 오디오",
|
||||
".mp4" or ".avi" or ".mkv" or ".mov" or ".wmv" or ".webm" => "🎬 동영상",
|
||||
_ => "📁 파일"
|
||||
};
|
||||
return $"{type} · {ext.TrimStart('.').ToUpperInvariant()} · {size}\n" +
|
||||
$"수정: {fi.LastWriteTime:yyyy-MM-dd HH:mm}";
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static string FormatFileSize(long bytes) => bytes switch
|
||||
{
|
||||
< 1024 => $"{bytes} B",
|
||||
< 1048576 => $"{bytes / 1024.0:F1} KB",
|
||||
< 1073741824 => $"{bytes / 1048576.0:F1} MB",
|
||||
_ => $"{bytes / 1073741824.0:F2} GB"
|
||||
};
|
||||
|
||||
private static async Task<IEnumerable<string>> ReadFirstLinesAsync(
|
||||
string path, int maxLines, CancellationToken ct)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user