Initial commit to new repository

This commit is contained in:
2026-04-03 18:23:52 +09:00
commit deffb33cf9
5248 changed files with 267762 additions and 0 deletions

View File

@@ -0,0 +1,104 @@
using System.Windows;
using AxCopilot.Models;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// Phase L3-3: AI 스니펫 핸들러. "ai" 예약어로 사용합니다.
/// 예: ai email 프로젝트 일정 변경 안내 → 업무 이메일 초안 생성
/// ai summary 이 문서의 핵심 내용 → 내용 요약
/// ai (목록) → 등록된 AI 템플릿 목록 표시
///
/// AiEnabled=false이면 항목 자체를 표시하지 않습니다.
/// </summary>
public class AiSnippetHandler : IActionHandler
{
private readonly SettingsService _settings;
private readonly SnippetTemplateService _templateService;
public string? Prefix => "ai";
public PluginMetadata Metadata => new(
"AiSnippet",
"AI 스니펫 — ai [템플릿] [내용]",
"1.0",
"AX");
public AiSnippetHandler(SettingsService settings, SnippetTemplateService templateService)
{
_settings = settings;
_templateService = templateService;
}
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
// AI 비활성화 시 항목 표시 안 함
if (!(_settings.Settings.AiEnabled))
return Task.FromResult<IEnumerable<LauncherItem>>(Array.Empty<LauncherItem>());
var parts = query.Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
var keyword = parts.Length > 0 ? parts[0] : "";
var argument = parts.Length > 1 ? parts[1] : "";
var templates = _templateService.Search(keyword);
var items = new List<LauncherItem>();
if (templates.Count == 0)
{
items.Add(new LauncherItem(
"등록된 AI 템플릿 없음",
"설정 → AI 스니펫 탭에서 추가하세요",
null, null, Symbol: Symbols.Lightbulb));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
foreach (var tmpl in templates)
{
var hasArg = !string.IsNullOrWhiteSpace(argument);
items.Add(new LauncherItem(
hasArg
? $"[AI] {tmpl.Name}: {TruncateArg(argument)}"
: $"[AI] {tmpl.Name}",
hasArg
? $"Enter: AI가 생성 후 클립보드에 복사 · 템플릿: {tmpl.Prompt}"
: $"ai {tmpl.Keyword} [내용] · {tmpl.Prompt}",
null,
hasArg ? (object)(tmpl, argument) : null,
Symbol: Symbols.Lightbulb));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (!_settings.Settings.AiEnabled) return;
if (item.Data is not (AiSnippetTemplate tmpl, string arg)) return;
try
{
var result = await _templateService.GenerateAsync(tmpl, arg, ct);
if (string.IsNullOrWhiteSpace(result)) return;
// 결과를 클립보드에 복사
Application.Current.Dispatcher.Invoke(() =>
{
Clipboard.SetText(result);
Services.NotificationService.Notify(
$"AI {tmpl.Name} 완료",
"결과가 클립보드에 복사되었습니다.");
});
}
catch (Exception ex)
{
LogService.Warn($"AI 스니펫 생성 실패: {ex.Message}");
}
}
private static string TruncateArg(string arg)
=> arg.Length > 30 ? arg[..27] + "…" : arg;
}

View File

@@ -0,0 +1,186 @@
using System.Diagnostics;
using System.IO;
using AxCopilot.Models;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// @ prefix 핸들러: URL 열기 Alias
/// </summary>
public class UrlAliasHandler : IActionHandler
{
private readonly SettingsService _settings;
public string? Prefix => "@";
public PluginMetadata Metadata => new("url-alias", "URL 별칭", "1.0", "AX");
public UrlAliasHandler(SettingsService settings) => _settings = settings;
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var aliases = _settings.Settings.Aliases
.Where(a => a.Type == "url" &&
(string.IsNullOrEmpty(query) ||
a.Key.Contains(query, StringComparison.OrdinalIgnoreCase)))
.Select(a =>
{
// favicon 캐시 경로 조회 (없으면 백그라운드 다운로드 시작)
var faviconPath = GetFaviconPath(a.Target);
return new LauncherItem(
a.Key,
a.Description ?? a.Target,
faviconPath,
a,
a.Target,
Symbols.Globe);
});
return Task.FromResult<IEnumerable<LauncherItem>>(aliases);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is AliasEntry alias)
{
var url = Environment.ExpandEnvironmentVariables(alias.Target);
if (!IsAllowedUrl(url))
{
LogService.Warn($"허용되지 않는 URL 스킴: {url}");
return Task.CompletedTask;
}
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
}
return Task.CompletedTask;
}
// http, https, ftp, ms-settings, mailto, file 스킴만 허용 (javascript: 등 차단)
private static readonly HashSet<string> AllowedSchemes =
new(StringComparer.OrdinalIgnoreCase) { "http", "https", "ftp", "ftps", "ms-settings", "mailto", "file" };
private static bool IsAllowedUrl(string url)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false;
return AllowedSchemes.Contains(uri.Scheme);
}
/// <summary>favicon 캐시 파일 경로를 반환합니다. 캐시에 없으면 백그라운드 다운로드를 시작합니다.</summary>
private static string? GetFaviconPath(string url)
{
try
{
if (!url.StartsWith("http", StringComparison.OrdinalIgnoreCase))
url = "https://" + url;
var domain = new Uri(url).Host.ToLowerInvariant();
var cachePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "favicons", $"{domain}.png");
if (File.Exists(cachePath))
return cachePath;
// 백그라운드 다운로드 시작 (다음 표시 시 캐시됨)
FaviconService.GetFavicon(url);
return null;
}
catch (Exception) { return null; }
}
}
/// <summary>
/// cd prefix 핸들러: 폴더 열기 Alias
/// </summary>
public class FolderAliasHandler : IActionHandler
{
private readonly SettingsService _settings;
public string? Prefix => "cd";
public PluginMetadata Metadata => new("folder-alias", "폴더 별칭", "1.0", "AX");
public FolderAliasHandler(SettingsService settings) => _settings = settings;
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var aliases = _settings.Settings.Aliases
.Where(a => a.Type == "folder" &&
(string.IsNullOrEmpty(query) ||
a.Key.Contains(query, StringComparison.OrdinalIgnoreCase)))
.Select(a => new LauncherItem(
a.Key,
Environment.ExpandEnvironmentVariables(a.Target),
null,
a,
Symbol: Symbols.Folder));
return Task.FromResult<IEnumerable<LauncherItem>>(aliases);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is AliasEntry alias)
{
var path = Environment.ExpandEnvironmentVariables(alias.Target);
Process.Start(new ProcessStartInfo("explorer.exe", path) { UseShellExecute = true });
}
return Task.CompletedTask;
}
}
/// <summary>
/// > prefix 핸들러: 터미널 명령 실행
/// </summary>
public class BatchHandler : IActionHandler
{
private readonly SettingsService _settings;
public string? Prefix => ">";
public PluginMetadata Metadata => new("batch", "명령 실행", "1.0", "AX");
public BatchHandler(SettingsService settings) => _settings = settings;
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
// 등록된 배치 Alias 표시 + 직접 입력 실행 옵션
var items = new List<LauncherItem>();
var batchAliases = _settings.Settings.Aliases
.Where(a => a.Type == "batch" &&
(string.IsNullOrEmpty(query) ||
a.Key.Contains(query, StringComparison.OrdinalIgnoreCase)))
.Select(a => new LauncherItem(a.Key, a.Target, null, a, Symbol: Symbols.Terminal));
items.AddRange(batchAliases);
// 직접 명령어 입력 허용 (큰따옴표 이스케이프로 인젝션 방지)
if (!string.IsNullOrEmpty(query))
{
var safeQuery = query.Replace("\"", "\\\"");
items.Insert(0, new LauncherItem(
$"실행: {query}",
"PowerShell에서 직접 실행",
null,
new AliasEntry { Type = "batch", Target = $"powershell -NoProfile -Command \"{safeQuery}\"", ShowWindow = true },
Symbol: Symbols.Terminal
));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is AliasEntry alias)
{
var target = Environment.ExpandEnvironmentVariables(alias.Target);
var parts = target.Split(' ', 2);
var psi = new ProcessStartInfo(parts[0])
{
Arguments = parts.Length > 1 ? parts[1] : "",
UseShellExecute = alias.ShowWindow,
CreateNoWindow = !alias.ShowWindow,
WindowStyle = alias.ShowWindow ? ProcessWindowStyle.Normal : ProcessWindowStyle.Hidden
};
Process.Start(psi);
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,210 @@
using System.Text;
using System.Text.RegularExpressions;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 텍스트 일괄 처리 핸들러. "batch" 프리픽스로 사용합니다.
/// 클립보드 텍스트의 각 줄에 동시에 변환을 적용합니다.
/// 예: batch prefix [텍스트] → 각 줄 앞에 [텍스트] 추가
/// batch suffix [텍스트] → 각 줄 뒤에 [텍스트] 추가
/// batch number → 줄번호 추가
/// batch sort → 줄 정렬
/// batch unique → 중복 줄 제거
/// batch wrap " → 각 줄을 "로 감싸기
/// batch replace A B → A를 B로 치환
/// batch csv → 줄 → CSV 한 줄로 합치기
/// batch split , → CSV 한 줄 → 여러 줄로 분리
/// Enter → 결과를 클립보드에 복사.
/// </summary>
public class BatchTextHandler : IActionHandler
{
public string? Prefix => "batch";
public PluginMetadata Metadata => new(
"BatchText",
"텍스트 일괄 처리 — batch",
"1.0",
"AX");
private static readonly (string Cmd, string Desc)[] Commands =
[
("prefix [텍스트]", " "),
("suffix [텍스트]", "각 줄 뒤에 텍스트 추가"),
("wrap [문자]", "각 줄을 지정 문자로 감싸기 (예: wrap \")"),
("number", "줄번호 추가 (1. 2. 3. ...)"),
("sort", "줄 오름차순 정렬"),
("sortd", "줄 내림차순 정렬"),
("reverse", "줄 순서 뒤집기"),
("unique", "중복 줄 제거"),
("trim", "각 줄 앞뒤 공백 제거"),
("replace [A] [B]", "A를 B로 전체 치환"),
("csv", "줄들을 쉼표로 합쳐 한 줄로"),
("split [구분자]", "한 줄을 구분자로 분리하여 여러 줄로"),
("indent [N]", "각 줄 앞에 공백 N개 추가"),
("unindent", "각 줄의 선행 공백/탭 제거"),
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
if (string.IsNullOrWhiteSpace(q))
{
var items = Commands.Select(c => new LauncherItem(
$"batch {c.Cmd}",
c.Desc,
null, null,
Symbol: Symbols.Text)).ToList<LauncherItem>();
items.Insert(0, new LauncherItem(
"텍스트 일괄 처리",
"클립보드 텍스트의 각 줄에 변환 적용 · 명령 입력",
null, null, Symbol: Symbols.Info));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 클립보드 읽기
string? text = null;
try
{
if (Application.Current?.Dispatcher.Invoke(() => Clipboard.ContainsText()) == true)
text = Application.Current.Dispatcher.Invoke(() => Clipboard.GetText());
}
catch (Exception) { }
if (string.IsNullOrEmpty(text))
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem("클립보드에 텍스트가 없습니다", "텍스트를 복사한 후 시도하세요",
null, null, Symbol: Symbols.Warning)
]);
}
var lines = text.Split('\n').Select(l => l.TrimEnd('\r')).ToArray();
var parts = q.Split(' ', 2, StringSplitOptions.TrimEntries);
var cmd = parts[0].ToLowerInvariant();
var arg = parts.Length > 1 ? parts[1] : "";
string? result = null;
string? desc = null;
try
{
switch (cmd)
{
case "prefix":
result = string.Join("\n", lines.Select(l => arg + l));
desc = $"각 줄 앞에 '{arg}' 추가";
break;
case "suffix":
result = string.Join("\n", lines.Select(l => l + arg));
desc = $"각 줄 뒤에 '{arg}' 추가";
break;
case "wrap":
var w = string.IsNullOrEmpty(arg) ? "\"" : arg;
result = string.Join("\n", lines.Select(l => w + l + w));
desc = $"각 줄을 '{w}'로 감싸기";
break;
case "number":
result = string.Join("\n", lines.Select((l, i) => $"{i + 1}. {l}"));
desc = "줄번호 추가";
break;
case "sort":
result = string.Join("\n", lines.Order());
desc = "오름차순 정렬";
break;
case "sortd":
result = string.Join("\n", lines.OrderDescending());
desc = "내림차순 정렬";
break;
case "reverse":
result = string.Join("\n", lines.Reverse());
desc = "줄 순서 뒤집기";
break;
case "unique":
var unique = lines.Distinct().ToArray();
result = string.Join("\n", unique);
desc = $"중복 제거: {lines.Length}줄 → {unique.Length}줄";
break;
case "trim":
result = string.Join("\n", lines.Select(l => l.Trim()));
desc = "각 줄 공백 제거";
break;
case "replace":
var rParts = arg.Split(' ', 2, StringSplitOptions.TrimEntries);
if (rParts.Length == 2)
{
result = text.Replace(rParts[0], rParts[1]);
desc = $"'{rParts[0]}' → '{rParts[1]}' 치환";
}
break;
case "csv":
result = string.Join(",", lines.Select(l => l.Trim()));
desc = $"{lines.Length}줄 → CSV 한 줄";
break;
case "split":
var sep = string.IsNullOrEmpty(arg) ? "," : arg;
result = string.Join("\n", text.Split(sep));
desc = $"'{sep}' 기준 분리";
break;
case "indent":
var n = int.TryParse(arg, out var indent) ? indent : 4;
var pad = new string(' ', n);
result = string.Join("\n", lines.Select(l => pad + l));
desc = $"{n}칸 들여쓰기";
break;
case "unindent":
result = string.Join("\n", lines.Select(l => l.TrimStart(' ', '\t')));
desc = "선행 공백 제거";
break;
}
}
catch (Exception ex)
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem($"처리 오류: {ex.Message}", "입력 데이터를 확인하세요",
null, null, Symbol: Symbols.Error)
]);
}
if (result == null)
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem($"알 수 없는 명령: {cmd}", "batch 만 입력하면 전체 명령 목록",
null, null, Symbol: Symbols.Warning)
]);
}
var preview = result.Length > 120 ? result[..117] + "…" : result;
preview = preview.Replace("\n", "↵ ");
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
$"[{desc}] Enter로 ",
preview,
null, result,
Symbol: Symbols.Text)
]);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is string text)
{
try { Application.Current?.Dispatcher.Invoke(() => Clipboard.SetText(text)); }
catch (Exception) { }
NotificationService.Notify("일괄 처리 완료", "변환 결과가 클립보드에 복사되었습니다");
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,180 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Nodes;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// Chrome / Edge 브라우저 북마크를 검색합니다.
/// 프리픽스 없음 — 일반 퍼지 검색에 통합됩니다.
/// 지원 브라우저: Google Chrome, Microsoft Edge
/// </summary>
public class BookmarkHandler : IActionHandler
{
public string? Prefix => null; // 퍼지 검색에 통합
public PluginMetadata Metadata => new(
"Bookmarks",
"Chrome / Edge 북마크 검색",
"1.0",
"AX");
// 캐시 (앱 세션 중 북마크가 자주 바뀌지 않으므로 한 번 로드 후 재사용)
private List<BookmarkEntry>? _cache;
private DateTime _cacheTime = DateTime.MinValue;
private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(5);
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(query))
return Task.FromResult<IEnumerable<LauncherItem>>(Array.Empty<LauncherItem>());
RefreshCacheIfNeeded();
if (_cache == null || _cache.Count == 0)
return Task.FromResult<IEnumerable<LauncherItem>>(Array.Empty<LauncherItem>());
var q = query.Trim().ToLowerInvariant();
var results = _cache
.Where(b => b.Name.ToLowerInvariant().Contains(q)
|| (b.Url?.ToLowerInvariant().Contains(q) ?? false))
.Take(8)
.Select(b => new LauncherItem(
b.Name,
b.Url ?? "",
null,
b.Url,
Symbol: Symbols.Globe))
.ToList();
return Task.FromResult<IEnumerable<LauncherItem>>(results);
}
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;
}
// ─── 캐시 ────────────────────────────────────────────────────────────────
private void RefreshCacheIfNeeded()
{
if (_cache != null && DateTime.Now - _cacheTime < CacheTtl) return;
var bookmarks = new List<BookmarkEntry>();
foreach (var file in GetBookmarkFiles())
{
try
{
var json = File.ReadAllText(file);
ParseChromeBookmarks(json, bookmarks);
}
catch (Exception ex)
{
LogService.Warn($"북마크 파일 읽기 실패: {file} — {ex.Message}");
}
}
_cache = bookmarks;
_cacheTime = DateTime.Now;
LogService.Info($"북마크 로드 완료: {bookmarks.Count}개");
}
// ─── 북마크 파일 경로 ─────────────────────────────────────────────────────
private static IEnumerable<string> GetBookmarkFiles()
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
// Chrome (안정, 베타, 개발, 카나리아)
var chromePaths = new[]
{
Path.Combine(localAppData, "Google", "Chrome", "User Data"),
Path.Combine(localAppData, "Google", "Chrome Beta", "User Data"),
Path.Combine(localAppData, "Google", "Chrome Dev", "User Data"),
Path.Combine(localAppData, "Google", "Chrome SxS", "User Data"),
};
// Edge
var edgePaths = new[]
{
Path.Combine(localAppData, "Microsoft", "Edge", "User Data"),
Path.Combine(localAppData, "Microsoft", "Edge Beta", "User Data"),
Path.Combine(localAppData, "Microsoft", "Edge Dev", "User Data"),
Path.Combine(localAppData, "Microsoft", "Edge Canary", "User Data"),
};
foreach (var profileRoot in chromePaths.Concat(edgePaths))
{
if (!Directory.Exists(profileRoot)) continue;
// Default 프로파일
var defaultBookmark = Path.Combine(profileRoot, "Default", "Bookmarks");
if (File.Exists(defaultBookmark)) yield return defaultBookmark;
// Profile 1, 2, ... 프로파일
foreach (var dir in Directory.GetDirectories(profileRoot, "Profile *"))
{
var f = Path.Combine(dir, "Bookmarks");
if (File.Exists(f)) yield return f;
}
}
}
// ─── Chrome/Edge JSON 파싱 ───────────────────────────────────────────────
private static void ParseChromeBookmarks(string json, List<BookmarkEntry> result)
{
var doc = JsonNode.Parse(json);
var roots = doc?["roots"];
if (roots == null) return;
foreach (var rootKey in new[] { "bookmark_bar", "other", "synced" })
{
var node = roots[rootKey];
if (node != null) WalkNode(node, result);
}
}
private static void WalkNode(JsonNode node, List<BookmarkEntry> result)
{
var type = node["type"]?.GetValue<string>();
if (type == "url")
{
var name = node["name"]?.GetValue<string>() ?? "";
var url = node["url"]?.GetValue<string>() ?? "";
if (!string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(url))
result.Add(new BookmarkEntry(name, url));
return;
}
if (type == "folder")
{
var children = node["children"]?.AsArray();
if (children == null) return;
foreach (var child in children)
{
if (child != null) WalkNode(child, result);
}
}
}
private record BookmarkEntry(string Name, string? Url);
}

View File

@@ -0,0 +1,566 @@
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
using System.Net.Http;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Windows;
namespace AxCopilot.Handlers;
/// <summary>
/// 수식 계산기 핸들러. "=" 프리픽스로 사용합니다.
/// 예: = 1+2*3 → 7
/// = sqrt(16) → 4
/// = 100km in miles → 단위 변환
/// = 100 USD to KRW → 통화 변환 (실시간 환율)
/// </summary>
public class CalculatorHandler : IActionHandler
{
public string? Prefix => "=";
public PluginMetadata Metadata => new(
"Calculator",
"수식 계산기 — = 뒤에 수식 입력",
"1.0",
"AX");
public async Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(query))
{
return
[
new LauncherItem(
"수식을 입력하세요",
"예: 1+2*3 · sqrt(16) · 100km in miles · 100 USD to KRW",
null, null, Symbol: Symbols.Calculator)
];
}
var trimmed = query.Trim();
// ─── 통화 변환 우선 감지 ──────────────────────────────────────────────
if (CurrencyConverter.IsCurrencyQuery(trimmed))
{
return await CurrencyConverter.ConvertAsync(trimmed, ct);
}
// ─── 단위 변환 우선 감지 ──────────────────────────────────────────────
if (UnitConverter.TryConvert(trimmed, out var convertResult))
{
return
[
new LauncherItem(
convertResult!,
$"{trimmed} · Enter로 클립보드에 복사",
null, convertResult, Symbol: Symbols.Calculator),
];
}
// ─── 수식 계산 ────────────────────────────────────────────────────────
try
{
var value = MathEvaluator.Evaluate(trimmed);
var result = FormatResult(value);
return
[
new LauncherItem(
result,
$"{trimmed} = {result} · Enter로 클립보드에 복사",
null, result, Symbol: Symbols.Calculator),
];
}
catch (Exception ex)
{
return
[
new LauncherItem(
"계산할 수 없습니다",
ex.Message,
null, null, Symbol: Symbols.Error)
];
}
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is string result)
{
try { Clipboard.SetText(result); }
catch (Exception) { /* 클립보드 접근 실패 무시 */ }
}
return Task.CompletedTask;
}
// ─── 결과 포맷 ─────────────────────────────────────────────────────────
private static string FormatResult(double value)
{
if (double.IsNaN(value)) return "NaN";
if (double.IsPositiveInfinity(value)) return "∞";
if (double.IsNegativeInfinity(value)) return "-∞";
// 정수이고 너무 크지 않으면 천 단위 구분 없이 정수로 표시
if (value == Math.Floor(value) && Math.Abs(value) < 1e15)
return ((long)value).ToString();
// 소수점 10자리까지, 불필요한 0은 제거
return value.ToString("G10", System.Globalization.CultureInfo.InvariantCulture);
}
}
// ─── 단위 변환 ─────────────────────────────────────────────────────────────────
/// <summary>
/// "100km in miles", "32f in c", "5lb to kg" 형식의 단위 변환.
/// </summary>
internal static class UnitConverter
{
// 패턴: <숫자> <단위> in|to <단위>
private static readonly Regex Pattern = new(
@"^(-?\d+(?:\.\d+)?)\s*([a-z°/²³µ]+)\s+(?:in|to)\s+([a-z°/²³µ]+)$",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
public static bool TryConvert(string input, out string? result)
{
result = null;
var m = Pattern.Match(input.Trim());
if (!m.Success) return false;
if (!double.TryParse(m.Groups[1].Value,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out var value))
return false;
var from = m.Groups[2].Value.ToLowerInvariant();
var to = m.Groups[3].Value.ToLowerInvariant();
// 온도는 비선형 → 별도 처리
if (TryConvertTemperature(value, from, to, out var tResult))
{
result = $"{FormatNum(tResult)} {TemperatureLabel(to)}";
return true;
}
// 나머지 범주(선형 변환)
foreach (var table in _tables)
{
if (table.TryGetValue(from, out var fromFactor) &&
table.TryGetValue(to, out var toFactor))
{
var converted = value * fromFactor / toFactor;
result = $"{FormatNum(converted)} {to}";
return true;
}
}
return false;
}
// ─── 온도 ────────────────────────────────────────────────────────────────
private static bool TryConvertTemperature(double v, string from, string to, out double r)
{
r = 0;
// 섭씨 표준화
double celsius;
switch (from)
{
case "c": case "°c": case "celsius": celsius = v; break;
case "f": case "°f": case "fahrenheit": celsius = (v - 32) * 5 / 9; break;
case "k": case "kelvin": celsius = v - 273.15; break;
default: return false;
}
switch (to)
{
case "c": case "°c": case "celsius": r = celsius; break;
case "f": case "°f": case "fahrenheit": r = celsius * 9 / 5 + 32; break;
case "k": case "kelvin": r = celsius + 273.15; break;
default: return false;
}
return true;
}
private static string TemperatureLabel(string unit) => unit switch
{
"c" or "°c" or "celsius" => "°C",
"f" or "°f" or "fahrenheit" => "°F",
"k" or "kelvin" => "K",
_ => unit
};
// ─── 선형 변환 테이블 (기준 단위 = 1) ────────────────────────────────────
// 길이 (기준: m)
private static readonly Dictionary<string, double> _length = new()
{
["km"] = 1000, ["m"] = 1, ["cm"] = 0.01, ["mm"] = 0.001,
["mi"] = 1609.344, ["mile"] = 1609.344, ["miles"] = 1609.344,
["ft"] = 0.3048, ["feet"] = 0.3048, ["foot"] = 0.3048,
["in"] = 0.0254, ["inch"] = 0.0254, ["inches"] = 0.0254,
["yd"] = 0.9144, ["yard"] = 0.9144, ["yards"] = 0.9144,
["nm"] = 1e-9,
};
// 무게 (기준: kg)
private static readonly Dictionary<string, double> _weight = new()
{
["t"] = 1000, ["ton"] = 1000, ["tonnes"] = 1000,
["kg"] = 1, ["g"] = 0.001, ["mg"] = 1e-6,
["lb"] = 0.453592, ["lbs"] = 0.453592, ["pound"] = 0.453592, ["pounds"] = 0.453592,
["oz"] = 0.0283495, ["ounce"] = 0.0283495, ["ounces"] = 0.0283495,
};
// 속도 (기준: m/s)
private static readonly Dictionary<string, double> _speed = new()
{
["m/s"] = 1, ["mps"] = 1,
["km/h"] = 1.0 / 3.6, ["kmh"] = 1.0 / 3.6, ["kph"] = 1.0 / 3.6,
["mph"] = 0.44704,
["kn"] = 0.514444, ["knot"] = 0.514444, ["knots"] = 0.514444,
};
// 데이터 (기준: byte)
private static readonly Dictionary<string, double> _data = new()
{
["b"] = 1, ["byte"] = 1, ["bytes"] = 1,
["kb"] = 1024, ["kib"] = 1024,
["mb"] = 1024 * 1024, ["mib"] = 1024 * 1024,
["gb"] = 1024.0 * 1024 * 1024, ["gib"] = 1024.0 * 1024 * 1024,
["tb"] = 1024.0 * 1024 * 1024 * 1024, ["tib"] = 1024.0 * 1024 * 1024 * 1024,
["pb"] = 1024.0 * 1024 * 1024 * 1024 * 1024,
};
// 넓이 (기준: m²)
private static readonly Dictionary<string, double> _area = new()
{
["m²"] = 1, ["m2"] = 1,
["km²"] = 1e6, ["km2"] = 1e6,
["cm²"] = 1e-4, ["cm2"] = 1e-4,
["ha"] = 10000,
["acre"] = 4046.86, ["acres"] = 4046.86,
["ft²"] = 0.092903, ["ft2"] = 0.092903,
};
private static readonly List<Dictionary<string, double>> _tables = new()
{ _length, _weight, _speed, _data, _area };
private static string FormatNum(double v)
{
if (v == Math.Floor(v) && Math.Abs(v) < 1e12)
return ((long)v).ToString("N0", System.Globalization.CultureInfo.CurrentCulture);
return v.ToString("G6", System.Globalization.CultureInfo.InvariantCulture);
}
}
// ─── 수식 파서 ─────────────────────────────────────────────────────────────────
/// <summary>
/// 재귀 하강 파서 기반 수학 수식 평가기.
/// 지원: +, -, *, /, %, ^ (거듭제곱), 괄호, 단항 음수,
/// sqrt, abs, ceil, floor, round, sin, cos, tan (도 단위),
/// log (밑 10), ln (자연로그), pi, e
/// </summary>
internal static class MathEvaluator
{
public static double Evaluate(string expr)
{
var evaluator = new Evaluator(
expr.Replace(" ", "")
.Replace("×", "*")
.Replace("÷", "/")
.Replace("", ",")
.ToLowerInvariant());
return evaluator.Parse();
}
private class Evaluator
{
private readonly string _s;
private int _i;
public Evaluator(string s) { _s = s; _i = 0; }
public double Parse()
{
var result = ParseExpr();
if (_i < _s.Length)
throw new InvalidOperationException($"예기치 않은 문자: '{_s[_i]}'");
return result;
}
// 덧셈 / 뺄셈
private double ParseExpr()
{
var left = ParseTerm();
while (_i < _s.Length && (_s[_i] == '+' || _s[_i] == '-'))
{
var op = _s[_i++];
var right = ParseTerm();
left = op == '+' ? left + right : left - right;
}
return left;
}
// 곱셈 / 나눗셈 / 나머지
private double ParseTerm()
{
var left = ParsePower();
while (_i < _s.Length && (_s[_i] == '*' || _s[_i] == '/' || _s[_i] == '%'))
{
var op = _s[_i++];
var right = ParsePower();
left = op == '*' ? left * right
: op == '/' ? left / right
: left % right;
}
return left;
}
// 거듭제곱 (오른쪽 결합)
private double ParsePower()
{
var b = ParseUnary();
if (_i < _s.Length && _s[_i] == '^')
{
_i++;
var exp = ParseUnary();
return Math.Pow(b, exp);
}
return b;
}
// 단항 부호
private double ParseUnary()
{
if (_i < _s.Length && _s[_i] == '-') { _i++; return -ParsePrimary(); }
if (_i < _s.Length && _s[_i] == '+') { _i++; return ParsePrimary(); }
return ParsePrimary();
}
// 리터럴 / 괄호 / 함수 호출
private double ParsePrimary()
{
if (_i >= _s.Length)
throw new InvalidOperationException("수식이 불완전합니다.");
// 16진수 리터럴 0x...
if (_i + 1 < _s.Length && _s[_i] == '0' && _s[_i + 1] == 'x')
{
_i += 2;
var hexStart = _i;
while (_i < _s.Length && "0123456789abcdef".Contains(_s[_i])) _i++;
return Convert.ToInt64(_s[hexStart.._i], 16);
}
// 숫자
if (char.IsDigit(_s[_i]) || _s[_i] == '.')
{
var start = _i;
while (_i < _s.Length && (char.IsDigit(_s[_i]) || _s[_i] == '.')) _i++;
// 과학적 표기: 1.5e3
if (_i < _s.Length && _s[_i] == 'e')
{
_i++;
if (_i < _s.Length && (_s[_i] == '+' || _s[_i] == '-')) _i++;
while (_i < _s.Length && char.IsDigit(_s[_i])) _i++;
}
return double.Parse(_s[start.._i],
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture);
}
// 괄호
if (_s[_i] == '(')
{
_i++;
var val = ParseExpr();
if (_i < _s.Length && _s[_i] == ')') _i++;
return val;
}
// 식별자 (상수 또는 함수)
if (char.IsLetter(_s[_i]))
{
var start = _i;
while (_i < _s.Length && (char.IsLetterOrDigit(_s[_i]) || _s[_i] == '_')) _i++;
var name = _s[start.._i];
// 상수
if (name == "pi") return Math.PI;
if (name == "e") return Math.E;
if (name == "inf") return double.PositiveInfinity;
// 함수 호출
if (_i < _s.Length && _s[_i] == '(')
{
_i++; // (
var arg = ParseExpr();
// 두 번째 인자 (pow, log2 등)
double? arg2 = null;
if (_i < _s.Length && _s[_i] == ',')
{
_i++;
arg2 = ParseExpr();
}
if (_i < _s.Length && _s[_i] == ')') _i++;
return name switch
{
"sqrt" => Math.Sqrt(arg),
"abs" => Math.Abs(arg),
"ceil" => Math.Ceiling(arg),
"floor" => Math.Floor(arg),
"round" => arg2.HasValue ? Math.Round(arg, (int)arg2.Value) : Math.Round(arg),
"sin" => Math.Sin(arg * Math.PI / 180), // 도 단위
"cos" => Math.Cos(arg * Math.PI / 180),
"tan" => Math.Tan(arg * Math.PI / 180),
"asin" => Math.Asin(arg) * 180 / Math.PI,
"acos" => Math.Acos(arg) * 180 / Math.PI,
"atan" => Math.Atan(arg) * 180 / Math.PI,
"log" => arg2.HasValue ? Math.Log(arg, arg2.Value) : Math.Log10(arg),
"log2" => Math.Log2(arg),
"ln" => Math.Log(arg),
"exp" => Math.Exp(arg),
"pow" => arg2.HasValue ? Math.Pow(arg, arg2.Value) : throw new InvalidOperationException("pow(x,y) 형식으로 사용하세요."),
"min" => arg2.HasValue ? Math.Min(arg, arg2.Value) : arg,
"max" => arg2.HasValue ? Math.Max(arg, arg2.Value) : arg,
_ => throw new InvalidOperationException($"알 수 없는 함수: {name}()")
};
}
throw new InvalidOperationException($"알 수 없는 식별자: {name}");
}
throw new InvalidOperationException($"예기치 않은 문자: '{_s[_i]}'");
}
}
}
// ─── 통화 변환 ──────────────────────────────────────────────────────────────────
/// <summary>
/// "100 USD to KRW", "50 EUR in JPY" 형식의 통화 변환.
/// open.er-api.com 무료 API 사용 (1시간 캐시).
/// </summary>
internal static class CurrencyConverter
{
// 패턴: <숫자> <통화코드 3자리> in|to <통화코드 3자리>
private static readonly Regex _pattern = new(
@"^(\d+(?:\.\d+)?)\s+([A-Za-z]{3})\s+(?:to|in)\s+([A-Za-z]{3})$",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
// 캐시: base currency → (fetched at, rates dict)
private static readonly Dictionary<string, (DateTime At, Dictionary<string, double> Rates)> _cache = new();
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(5) };
private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(1);
// 주요 통화 이름
private static readonly Dictionary<string, string> _names = new(StringComparer.OrdinalIgnoreCase)
{
["KRW"] = "원", ["USD"] = "달러", ["EUR"] = "유로",
["JPY"] = "엔", ["GBP"] = "파운드", ["CNY"] = "위안",
["HKD"] = "홍콩달러", ["SGD"] = "싱가포르달러", ["CAD"] = "캐나다달러",
["AUD"] = "호주달러", ["CHF"] = "스위스프랑", ["TWD"] = "대만달러",
["MXN"] = "멕시코페소", ["BRL"] = "브라질헤알", ["INR"] = "인도루피",
["RUB"] = "루블", ["THB"] = "바트", ["VND"] = "동",
["IDR"] = "루피아", ["MYR"] = "링깃", ["PHP"] = "페소",
["NZD"] = "뉴질랜드달러", ["SEK"] = "크로나", ["NOK"] = "크로나(노르)",
["DKK"] = "크로나(덴)", ["AED"] = "디르함", ["SAR"] = "리얄",
};
public static bool IsCurrencyQuery(string input)
=> _pattern.IsMatch(input.Trim());
public static async Task<IEnumerable<LauncherItem>> ConvertAsync(string input, CancellationToken ct)
{
var m = _pattern.Match(input.Trim());
if (!m.Success)
return [new LauncherItem("통화 형식 오류", "예: 100 USD to KRW", null, null, Symbol: Symbols.Error)];
if (!double.TryParse(m.Groups[1].Value,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out double amount))
return [new LauncherItem("숫자 형식 오류", "", null, null, Symbol: Symbols.Error)];
var from = m.Groups[2].Value.ToUpperInvariant();
var to = m.Groups[3].Value.ToUpperInvariant();
try
{
var rates = await GetRatesAsync(from, ct);
if (rates == null)
return [new LauncherItem("환율 조회 실패", "네트워크 연결을 확인하세요", null, null, Symbol: Symbols.Warning)];
if (!rates.TryGetValue(to, out double rate))
return [new LauncherItem($"지원하지 않는 통화: {to}", "3자리 ISO 4217 코드를 입력하세요", null, null, Symbol: Symbols.Error)];
double result = amount * rate;
var fromName = _names.TryGetValue(from, out var fn) ? fn : from;
var toName = _names.TryGetValue(to, out var tn) ? tn : to;
// 금액 포맷
string resultStr = to == "KRW" || to == "JPY" || to == "IDR" || to == "VND"
? result.ToString("N0", System.Globalization.CultureInfo.CurrentCulture)
: result.ToString("N2", System.Globalization.CultureInfo.CurrentCulture);
string rateStr = rate < 0.01
? rate.ToString("G4", System.Globalization.CultureInfo.InvariantCulture)
: rate.ToString("N4", System.Globalization.CultureInfo.CurrentCulture);
var display = $"{resultStr} {to}";
return
[
new LauncherItem(
$"{amount:N2} {from} = {display}",
$"1 {from}({fromName}) = {rateStr} {to}({toName}) · Enter로 복사",
null, display, Symbol: Symbols.Calculator),
new LauncherItem(
$"숫자만: {resultStr}",
"Enter로 숫자만 복사",
null, resultStr, Symbol: Symbols.Calculator),
];
}
catch (OperationCanceledException)
{
return [];
}
catch (Exception ex)
{
LogService.Warn($"환율 조회 오류: {ex.Message}");
return [new LauncherItem("환율 조회 실패", ex.Message, null, null, Symbol: Symbols.Warning)];
}
}
private static async Task<Dictionary<string, double>?> GetRatesAsync(string baseCurrency, CancellationToken ct)
{
if (_cache.TryGetValue(baseCurrency, out var cached) &&
(DateTime.Now - cached.At) < CacheTtl)
return cached.Rates;
var url = $"https://open.er-api.com/v6/latest/{baseCurrency}";
var json = await _http.GetStringAsync(url, ct);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (!root.TryGetProperty("result", out var resultEl) ||
resultEl.GetString() != "success")
return null;
if (!root.TryGetProperty("rates", out var ratesEl))
return null;
var rates = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
foreach (var prop in ratesEl.EnumerateObject())
rates[prop.Name] = prop.Value.GetDouble();
_cache[baseCurrency] = (DateTime.Now, rates);
return rates;
}
}

View File

@@ -0,0 +1,170 @@
using AxCopilot.Models;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Views;
namespace AxCopilot.Handlers;
/// <summary>
/// "!" 프리픽스 핸들러. AX Agent (AI 어시스턴트) 기능.
/// ★ DEPLOY_STUB = true → 배포용 "개발 중" 표시
/// ★ DEPLOY_STUB = false → 실제 ChatWindow 동작
/// </summary>
public class ChatHandler : IActionHandler
{
private static App? CurrentApp => System.Windows.Application.Current as App;
// ┌──────────────────────────────────────────────────────────────┐
// │ 배포 시 true, 개발 활성화 시 false 로 전환 │
// └──────────────────────────────────────────────────────────────┘
private const bool DEPLOY_STUB = false;
private readonly SettingsService _settings;
private readonly object _windowLock = new();
private ChatWindow? _chatWindow;
public string? Prefix => "!";
public PluginMetadata Metadata => new("ax.agent", "AX Agent", "1.0", "AX Agent — AI 어시스턴트");
public ChatHandler(SettingsService settings)
{
_settings = settings;
}
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
// ── 배포용 스텁 ─────────────────────────────────────────────
#pragma warning disable CS0162 // DEPLOY_STUB 플래그에 의한 의도된 비활성 코드
if (DEPLOY_STUB)
{
var stub = new List<LauncherItem>
{
new LauncherItem(
"AX Agent (개발 중)",
"이 기능은 다음 버전에서 제공될 예정입니다. 기대해 주세요!",
null, null, Symbol: "\uE8BD")
};
return Task.FromResult<IEnumerable<LauncherItem>>(stub);
}
#pragma warning restore CS0162
// ── AI 비활성화 체크 ─────────────────────────────────────────
var appSettings = CurrentApp?.SettingsService?.Settings;
if (appSettings?.AiEnabled == false)
return Task.FromResult<IEnumerable<LauncherItem>>(Array.Empty<LauncherItem>());
// ── 실제 구현 ───────────────────────────────────────────────
var items = new List<LauncherItem>();
var q = query.Trim();
if (string.IsNullOrEmpty(q))
{
items.Add(new LauncherItem(
"AX Agent 대화하기",
"AI 비서와 대화를 시작합니다",
null, "open_chat", Symbol: "\uE8BD"));
try
{
var storage = new ChatStorageService();
var metas = storage.LoadAllMeta();
foreach (var conv in metas.Take(5))
{
var ago = FormatTimeAgo(conv.UpdatedAt);
var symbol = ChatCategory.GetSymbol(conv.Category);
items.Add(new LauncherItem(
conv.Title,
$"{ago} · 메시지 {conv.Messages.Count}개",
null, $"resume:{conv.Id}", Symbol: symbol));
}
if (metas.Any())
items.Add(new LauncherItem(
"새 대화 시작",
"이전 대화와 별개의 새 대화를 시작합니다",
null, "new_chat", Symbol: "\uE710"));
}
catch (Exception) { }
}
else
{
items.Add(new LauncherItem(
$"AI에게 물어보기: {(q.Length > 40 ? q[..40] + "" : q)}",
"Enter를 누르면 AX Agent이 열리고 질문이 전송됩니다",
null, $"ask:{q}", Symbol: "\uE8BD"));
items.Add(new LauncherItem(
"AX Agent 대화하기",
"질문 없이 Agent 창만 엽니다",
null, "open_chat", Symbol: "\uE8BD"));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
#pragma warning disable CS0162 // DEPLOY_STUB 플래그에 의한 의도된 비활성 코드
if (DEPLOY_STUB) return Task.CompletedTask;
#pragma warning restore CS0162
// AI 비활성화 시 실행 차단
var appSettings2 = CurrentApp?.SettingsService?.Settings;
if (appSettings2?.AiEnabled == false) return Task.CompletedTask;
var data = item.Data as string ?? "open_chat";
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
EnsureChatWindow();
if (data.StartsWith("ask:"))
{
var question = data[4..];
_chatWindow!.Show();
_chatWindow.Activate();
_chatWindow.SendInitialMessage(question);
}
else if (data.StartsWith("resume:"))
{
var convId = data[7..];
_chatWindow!.Show();
_chatWindow.Activate();
_chatWindow.ResumeConversation(convId);
}
else if (data == "new_chat")
{
_chatWindow!.Show();
_chatWindow.Activate();
_chatWindow.StartNewAndFocus();
}
else
{
_chatWindow!.Show();
_chatWindow.Activate();
}
});
return Task.CompletedTask;
}
private void EnsureChatWindow()
{
lock (_windowLock)
{
if (_chatWindow == null || !_chatWindow.IsLoaded)
{
_chatWindow = new ChatWindow(_settings);
_chatWindow.Closed += (_, _) => { lock (_windowLock) _chatWindow = null; };
}
}
}
private static string FormatTimeAgo(DateTime dt)
{
var diff = DateTime.Now - dt;
if (diff.TotalMinutes < 1) return "방금 전";
if (diff.TotalHours < 1) return $"{(int)diff.TotalMinutes}분 전";
if (diff.TotalDays < 1) return $"{(int)diff.TotalHours}시간 전";
if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}일 전";
return dt.ToString("yyyy-MM-dd");
}
}

View File

@@ -0,0 +1,193 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Windows;
using AxCopilot.Models;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// $ prefix 핸들러: 클립보드 텍스트 변환
/// </summary>
public class ClipboardHandler : IActionHandler
{
private readonly SettingsService _settings;
public string? Prefix => "$";
public PluginMetadata Metadata => new("clipboard", "클립보드 변환", "1.0", "AX");
public ClipboardHandler(SettingsService settings) => _settings = settings;
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var items = new List<LauncherItem>();
// 빌트인 변환 목록
var builtins = GetBuiltinTransformers()
.Where(t => string.IsNullOrEmpty(query) ||
t.Key.Contains(query, StringComparison.OrdinalIgnoreCase));
foreach (var t in builtins)
items.Add(new LauncherItem(t.Key, t.Description ?? "", null, t, Symbol: Symbols.Clipboard));
// 사용자 정의 변환
var custom = _settings.Settings.ClipboardTransformers
.Where(t => string.IsNullOrEmpty(query) ||
t.Key.Contains(query, StringComparison.OrdinalIgnoreCase))
.Select(t => new LauncherItem(t.Key, t.Description ?? t.Type, null, t, Symbol: Symbols.Clipboard));
items.AddRange(custom);
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is not ClipboardTransformer transformer) return;
// 클립보드에서 텍스트 읽기 (STA 스레드 필요)
string? input = null;
Application.Current.Dispatcher.Invoke(() =>
{
input = Clipboard.ContainsText() ? Clipboard.GetText() : null;
});
if (input == null) return;
string? result = await TransformAsync(transformer, input, ct);
if (result == null) return;
// 결과를 클립보드에 쓰고 이전 활성 창으로 포커스 복원 후 붙여넣기
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(result));
// 런처 호출 전 활성 창으로 포커스 복원
var prevHwnd = WindowTracker.PreviousWindow;
if (prevHwnd != IntPtr.Zero)
SetForegroundWindow(prevHwnd);
await Task.Delay(120, ct);
System.Windows.Forms.SendKeys.SendWait("^v");
LogService.Info($"클립보드 변환: '{transformer.Key}' 적용");
}
private static async Task<string?> TransformAsync(ClipboardTransformer t, string input, CancellationToken ct)
{
try
{
return t.Type switch
{
// ReDoS 방지: 사용자 정의 패턴에 타임아웃 적용
"regex" when t.Pattern != null && t.Replace != null =>
Regex.Replace(input, t.Pattern, t.Replace, RegexOptions.None,
TimeSpan.FromMilliseconds(t.Timeout > 0 ? t.Timeout : 5000)),
"script" when t.Command != null =>
await RunScriptAsync(t.Command, input, t.Timeout, ct),
_ => ExecuteBuiltin(t.Key, input)
};
}
catch (Exception ex)
{
LogService.Error($"변환 실패 ({t.Key}): {ex.Message}");
return null;
}
}
private static async Task<string> RunScriptAsync(string command, string input, int timeoutMs, CancellationToken ct)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(timeoutMs);
var parts = command.Split(' ', 2);
var psi = new ProcessStartInfo(parts[0])
{
Arguments = parts.Length > 1 ? parts[1] : "",
RedirectStandardInput = true,
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardInputEncoding = Encoding.UTF8,
StandardOutputEncoding = Encoding.UTF8
};
using var proc = Process.Start(psi)!;
await proc.StandardInput.WriteAsync(input);
proc.StandardInput.Close();
return await proc.StandardOutput.ReadToEndAsync(cts.Token);
}
// ─── 빌트인 변환 ─────────────────────────────────────────────────────────
internal static string? ExecuteBuiltin(string key, string input)
{
return key switch
{
"$json" => FormatJson(input),
"$upper" => input.ToUpperInvariant(),
"$lower" => input.ToLowerInvariant(),
"$ts" => TryParseTimestamp(input),
"$epoch" => TryParseDate(input),
"$urle" => Uri.EscapeDataString(input),
"$urld" => Uri.UnescapeDataString(input),
"$b64e" => Convert.ToBase64String(Encoding.UTF8.GetBytes(input)),
"$b64d" => Encoding.UTF8.GetString(Convert.FromBase64String(input)),
"$md" => StripMarkdown(input),
"$trim" => input.Trim(),
"$lines" => string.Join(Environment.NewLine, input.Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0)),
_ => null
};
}
private static string FormatJson(string input)
{
try
{
var doc = JsonDocument.Parse(input);
return JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true });
}
catch (Exception) { return input; }
}
private static string? TryParseTimestamp(string input)
{
if (long.TryParse(input.Trim(), out var ts))
{
var dt = DateTimeOffset.FromUnixTimeSeconds(ts).LocalDateTime;
return dt.ToString("yyyy-MM-dd HH:mm:ss");
}
return null;
}
private static string? TryParseDate(string input)
{
if (DateTime.TryParse(input.Trim(), out var dt))
return new DateTimeOffset(dt).ToUnixTimeSeconds().ToString();
return null;
}
private static string StripMarkdown(string input) =>
Regex.Replace(input, @"(\*\*|__)(.*?)\1|(\*|_)(.*?)\3|`(.+?)`|#{1,6}\s*", "$2$4$5");
[DllImport("user32.dll")]
private static extern bool SetForegroundWindow(IntPtr hWnd);
private static IEnumerable<ClipboardTransformer> GetBuiltinTransformers() =>
[
new() { Key = "$json", Type = "builtin", Description = "JSON 포맷팅 (들여쓰기 적용)" },
new() { Key = "$upper", Type = "builtin", Description = "대문자 변환" },
new() { Key = "$lower", Type = "builtin", Description = "소문자 변환" },
new() { Key = "$ts", Type = "builtin", Description = "유닉스 타임스탬프 → 날짜 문자열" },
new() { Key = "$epoch", Type = "builtin", Description = "날짜 문자열 → 유닉스 타임스탬프" },
new() { Key = "$urle", Type = "builtin", Description = "URL 인코딩" },
new() { Key = "$urld", Type = "builtin", Description = "URL 디코딩" },
new() { Key = "$b64e", Type = "builtin", Description = "Base64 인코딩" },
new() { Key = "$b64d", Type = "builtin", Description = "Base64 디코딩" },
new() { Key = "$md", Type = "builtin", Description = "마크다운 문법 제거" },
new() { Key = "$trim", Type = "builtin", Description = "앞뒤 공백 제거" },
new() { Key = "$lines", Type = "builtin", Description = "빈 줄 제거 및 각 줄 공백 정리" },
];
}

View File

@@ -0,0 +1,216 @@
using System.Runtime.InteropServices;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 클립보드 히스토리 핸들러. "#" 프리픽스로 사용합니다.
/// 예: # (빈 쿼리) → 최근 클립보드 목록
/// # hello → "hello"가 포함된 항목 필터
/// </summary>
public class ClipboardHistoryHandler : IActionHandler
{
private readonly ClipboardHistoryService _historyService;
public string? Prefix => "#";
public PluginMetadata Metadata => new(
"ClipboardHistory",
"클립보드 히스토리 — # 뒤에 검색어 (또는 빈 입력으로 전체 보기)",
"1.0",
"AX");
public ClipboardHistoryHandler(ClipboardHistoryService historyService)
{
_historyService = historyService;
}
// 카테고리 필터 프리픽스: #url, #코드, #경로
private static readonly Dictionary<string, string> CategoryFilters = new(StringComparer.OrdinalIgnoreCase)
{
{ "url", "URL" }, { "코드", "코드" }, { "code", "코드" },
{ "경로", "경로" }, { "path", "경로" }, { "핀", "핀" }, { "pin", "핀" },
};
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var history = _historyService.History;
if (history.Count == 0)
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
"클립보드 히스토리가 없습니다",
"텍스트를 복사하면 이 곳에 기록됩니다",
null, null, Symbol: Symbols.History)
]);
}
var q = query.Trim().ToLowerInvariant();
// 카테고리 필터 감지 (예: #url, #핀)
string? catFilter = null;
foreach (var (prefix, cat) in CategoryFilters)
{
if (q == prefix || q.StartsWith(prefix + " "))
{
catFilter = cat;
q = q.Length > prefix.Length ? q[(prefix.Length + 1)..].Trim() : "";
break;
}
}
var filtered = history.AsEnumerable();
// 카테고리 필터 적용
if (catFilter == "핀")
filtered = filtered.Where(e => e.IsPinned);
else if (catFilter != null)
filtered = filtered.Where(e => e.Category == catFilter);
// 텍스트 검색
if (!string.IsNullOrEmpty(q))
filtered = filtered.Where(e => e.Preview.ToLowerInvariant().Contains(q));
// 핀 항목을 상단에 배치
var sorted = filtered
.OrderByDescending(e => e.IsPinned)
.ThenByDescending(e => e.CopiedAt);
var items = sorted
.Select(e =>
{
var pinMark = e.IsPinned ? "\uD83D\uDCCC " : "";
var catMark = e.Category != "일반" ? $"[{e.Category}] " : "";
return new LauncherItem(
$"{pinMark}{e.Preview}",
$"{catMark}{e.RelativeTime} · {e.CopiedAt:MM/dd HH:mm}",
null,
e,
Symbol: e.IsPinned ? Symbols.Favorite : (e.IsText ? Symbols.History : Symbols.Picture));
})
.ToList();
if (items.Count == 0)
{
items.Add(new LauncherItem(
$"'{query}'에 해당하는 항목 없음",
"#pin #url #코드 #경로 로 필터링 가능",
null, null, Symbol: Symbols.History));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is not ClipboardEntry entry) return;
try
{
_historyService.SuppressNextCapture();
_historyService.PromoteEntry(entry); // 사용 시각 갱신 + 목록 맨 위로
if (!entry.IsText && entry.Image != null)
{
// 원본 이미지가 있으면 원본 해상도로 클립보드 복사
var originalImg = ClipboardHistoryService.LoadOriginalImage(entry.OriginalImagePath);
Clipboard.SetImage(originalImg ?? entry.Image);
return; // 이미지는 붙여넣기 시뮬레이션 없이 클립보드만 설정
}
if (string.IsNullOrEmpty(entry.Text)) return;
Clipboard.SetText(entry.Text);
var prevWindow = WindowTracker.PreviousWindow;
if (prevWindow == IntPtr.Zero) return;
// ── 이전 창 포커스 복원 후 Ctrl+V ────────────────────────────────
// 런처 창이 완전히 숨겨지고 이전 창이 포커스를 회복할 시간 확보
await Task.Delay(300, ct);
// AttachThreadInput으로 포그라운드 전환 권한 획득 후 SetForegroundWindow 호출
// (Alt 트릭 대비: Alt 키 주입 없이 안정적으로 전환, 대상 앱 메뉴 트리거 방지)
var targetThread = GetWindowThreadProcessId(prevWindow, out _);
var currentThread = GetCurrentThreadId();
AttachThreadInput(currentThread, targetThread, true);
SetForegroundWindow(prevWindow);
AttachThreadInput(currentThread, targetThread, false);
// 포커스 전환이 완전히 반영될 때까지 대기
await Task.Delay(100, ct);
// SendInput으로 Ctrl+V 주입
// Win32 INPUT 구조체: type(4) + 패딩(4) + union(32) = 40 bytes on x64
// KEYBDINPUT.dwExtraInfo = ULONG_PTR → union 8바이트 정렬 필요 → offset 8에서 시작
SendCtrlV();
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
LogService.Warn($"클립보드 히스토리 붙여넣기 실패: {ex.Message}");
}
}
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];
// Ctrl 누름
inputs[0].Type = INPUT_KEYBOARD;
inputs[0].ki.wVk = VK_CONTROL;
// V 누름
inputs[1].Type = INPUT_KEYBOARD;
inputs[1].ki.wVk = VK_V;
// V 뗌
inputs[2].Type = INPUT_KEYBOARD;
inputs[2].ki.wVk = VK_V;
inputs[2].ki.dwFlags = KEYEVENTF_KEYUP;
// Ctrl 뗌
inputs[3].Type = INPUT_KEYBOARD;
inputs[3].ki.wVk = VK_CONTROL;
inputs[3].ki.dwFlags = KEYEVENTF_KEYUP;
SendInput((uint)inputs.Length, inputs, Marshal.SizeOf<INPUT>());
}
// ─── P/Invoke ──────────────────────────────────────────────────────────
// Win32 INPUT 구조체 — x64에서 40바이트
// type(4) + 패딩(4) + union은 offset 8에서 시작 (MOUSEINPUT 32바이트가 최대)
[StructLayout(LayoutKind.Explicit, Size = 40)]
private struct INPUT
{
[FieldOffset(0)] public uint Type;
[FieldOffset(8)] public KEYBDINPUT ki;
}
// KEYBDINPUT: 2+2+4+4 = 12, dwExtraInfo(IntPtr)는 8바이트 정렬 → 패딩 포함 24바이트
[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);
}

View File

@@ -0,0 +1,151 @@
using System.Text;
using System.Text.RegularExpressions;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 클립보드 파이프라인 핸들러. "pipe" 프리픽스로 사용합니다.
/// 여러 변환을 체이닝하여 클립보드 텍스트를 한 번에 처리합니다.
/// 예: pipe upper > trim > b64e → 대문자 → 공백제거 → Base64 인코딩
/// pipe sort > unique > number → 정렬 → 중복제거 → 줄번호
/// pipe → 사용 가능한 필터 목록
/// Enter → 변환 결과를 클립보드에 복사.
/// </summary>
public class ClipboardPipeHandler : IActionHandler
{
public string? Prefix => "pipe";
public PluginMetadata Metadata => new(
"ClipboardPipe",
"클립보드 파이프라인 — pipe",
"1.0",
"AX");
private static readonly Dictionary<string, (string Desc, Func<string, string> Fn)> Filters = new(StringComparer.OrdinalIgnoreCase)
{
["upper"] = ("대문자 변환", s => s.ToUpperInvariant()),
["lower"] = ("소문자 변환", s => s.ToLowerInvariant()),
["trim"] = ("앞뒤 공백 제거", s => s.Trim()),
["trimall"] = ("모든 공백 제거", s => Regex.Replace(s, @"\s+", "", RegexOptions.None, TimeSpan.FromSeconds(1))),
["sort"] = ("줄 정렬 (오름차순)", s => string.Join("\n", s.Split('\n').Order())),
["sortd"] = ("줄 정렬 (내림차순)", s => string.Join("\n", s.Split('\n').OrderDescending())),
["unique"] = ("중복 줄 제거", s => string.Join("\n", s.Split('\n').Distinct())),
["reverse"] = ("줄 순서 뒤집기", s => string.Join("\n", s.Split('\n').Reverse())),
["number"] = ("줄번호 추가", s => string.Join("\n", s.Split('\n').Select((l, i) => $"{i + 1}. {l}"))),
["quote"] = ("각 줄 따옴표 감싸기", s => string.Join("\n", s.Split('\n').Select(l => $"\"{l}\""))),
["b64e"] = ("Base64 인코딩", s => Convert.ToBase64String(Encoding.UTF8.GetBytes(s))),
["b64d"] = ("Base64 디코딩", s => Encoding.UTF8.GetString(Convert.FromBase64String(s.Trim()))),
["urle"] = ("URL 인코딩", s => Uri.EscapeDataString(s)),
["urld"] = ("URL 디코딩", s => Uri.UnescapeDataString(s)),
["md"] = ("마크다운 제거", s => Regex.Replace(s, @"[#*_`~\[\]()]", "", RegexOptions.None, TimeSpan.FromSeconds(1))),
["lines"] = ("빈 줄 제거", s => string.Join("\n", s.Split('\n').Where(l => !string.IsNullOrWhiteSpace(l)))),
["count"] = ("글자/단어/줄 수", s => $"글자: {s.Length} 단어: {s.Split((char[])null!, StringSplitOptions.RemoveEmptyEntries).Length} 줄: {s.Split('\n').Length}"),
["csv"] = ("CSV → 탭 변환", s => s.Replace(',', '\t')),
["tab"] = ("탭 → CSV 변환", s => s.Replace('\t', ',')),
};
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
if (string.IsNullOrWhiteSpace(q))
{
var items = Filters.Select(kv => new LauncherItem(
kv.Key,
kv.Value.Desc,
null, null,
Symbol: Symbols.Clipboard)).ToList<LauncherItem>();
items.Insert(0, new LauncherItem(
"클립보드 파이프라인",
"필터를 > 로 연결: pipe upper > trim > b64e",
null, null, Symbol: Symbols.Info));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 파이프라인 파싱
var steps = q.Split('>')
.Select(s => s.Trim())
.Where(s => !string.IsNullOrEmpty(s))
.ToArray();
// 유효성 검사
var invalid = steps.Where(s => !Filters.ContainsKey(s)).ToArray();
if (invalid.Length > 0)
{
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem(
$"알 수 없는 필터: {string.Join(", ", invalid)}",
$"사용 가능: {string.Join(", ", Filters.Keys.Take(10))} ...",
null, null, Symbol: Symbols.Warning)
}.AsEnumerable());
}
// 클립보드 텍스트 읽기
string? text = null;
try
{
if (Application.Current?.Dispatcher.Invoke(() => Clipboard.ContainsText()) == true)
text = Application.Current.Dispatcher.Invoke(() => Clipboard.GetText());
}
catch (Exception) { }
if (string.IsNullOrEmpty(text))
{
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem("클립보드에 텍스트가 없습니다", "텍스트를 복사한 후 시도하세요",
null, null, Symbol: Symbols.Warning)
}.AsEnumerable());
}
// 파이프라인 실행
var result = text;
var desc = new List<string>();
try
{
foreach (var step in steps)
{
result = Filters[step].Fn(result);
desc.Add(Filters[step].Desc);
}
}
catch (Exception ex)
{
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem($"파이프라인 실행 오류: {ex.Message}", "입력 데이터를 확인하세요",
null, null, Symbol: Symbols.Error)
}.AsEnumerable());
}
var preview = result.Length > 100 ? result[..97] + "…" : result;
preview = preview.Replace("\r\n", "↵ ").Replace("\n", "↵ ");
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem(
$"[{string.Join(" ", steps)}] 결과 적용",
$"{preview} · Enter로 클립보드 복사",
null, result,
Symbol: Symbols.Clipboard)
}.AsEnumerable());
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is string text)
{
try { Application.Current?.Dispatcher.Invoke(() => Clipboard.SetText(text)); }
catch (Exception) { }
NotificationService.Notify("파이프라인 완료", "변환 결과가 클립보드에 복사되었습니다");
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,289 @@
using AxCopilot.SDK;
using AxCopilot.Themes;
using System.Text.RegularExpressions;
using System.Windows;
namespace AxCopilot.Handlers;
/// <summary>
/// 색상 변환기 핸들러. "color" 프리픽스로 사용합니다.
/// 예: color #FF5500 → HEX / RGB / HSL 모두 표시
/// color 255,85,0 → HEX 변환
/// color red → 색상 이름 → HEX
/// color hsl(24,100%,50%) → HSL → HEX / RGB
/// </summary>
public class ColorHandler : IActionHandler
{
public string? Prefix => "color";
public PluginMetadata Metadata => new(
"Color",
"색상 변환기 — color #FF5500 · color 255,85,0 · color red",
"1.0",
"AX");
// ─── 색상 이름 사전 ─────────────────────────────────────────────────────
private static readonly Dictionary<string, string> _namedColors = new(StringComparer.OrdinalIgnoreCase)
{
["red"] = "#FF0000", ["빨강"] = "#FF0000", ["빨간색"] = "#FF0000",
["green"] = "#008000", ["초록"] = "#008000", ["초록색"] = "#008000",
["blue"] = "#0000FF", ["파랑"] = "#0000FF", ["파란색"] = "#0000FF",
["white"] = "#FFFFFF", ["흰색"] = "#FFFFFF", ["하양"] = "#FFFFFF",
["black"] = "#000000", ["검정"] = "#000000", ["검은색"] = "#000000",
["yellow"] = "#FFFF00", ["노랑"] = "#FFFF00", ["노란색"] = "#FFFF00",
["orange"] = "#FFA500", ["주황"] = "#FFA500", ["주황색"] = "#FFA500",
["purple"] = "#800080", ["보라"] = "#800080", ["보라색"] = "#800080",
["pink"] = "#FFC0CB", ["분홍"] = "#FFC0CB", ["분홍색"] = "#FFC0CB",
["hotpink"] = "#FF69B4", ["핫핑크"] = "#FF69B4",
["cyan"] = "#00FFFF", ["시안"] = "#00FFFF",
["magenta"] = "#FF00FF", ["마젠타"] = "#FF00FF",
["brown"] = "#A52A2A", ["갈색"] = "#A52A2A",
["gray"] = "#808080", ["grey"] = "#808080", ["회색"] = "#808080", ["회"] = "#808080",
["silver"] = "#C0C0C0", ["은색"] = "#C0C0C0",
["gold"] = "#FFD700", ["금색"] = "#FFD700",
["navy"] = "#000080", ["남색"] = "#000080",
["teal"] = "#008080", ["틸"] = "#008080",
["lime"] = "#00FF00", ["라임"] = "#00FF00",
["maroon"] = "#800000", ["밤색"] = "#800000",
["olive"] = "#808000", ["올리브"] = "#808000",
["coral"] = "#FF7F50", ["코랄"] = "#FF7F50",
["salmon"] = "#FA8072", ["연어색"] = "#FA8072",
["skyblue"] = "#87CEEB", ["하늘색"] = "#87CEEB",
["lightblue"] = "#ADD8E6", ["연파랑"] = "#ADD8E6",
["darkblue"] = "#00008B", ["진파랑"] = "#00008B",
["darkgreen"] = "#006400", ["진초록"] = "#006400",
["lightgreen"] = "#90EE90", ["연초록"] = "#90EE90",
["indigo"] = "#4B0082", ["인디고"] = "#4B0082",
["violet"] = "#EE82EE", ["바이올렛"] = "#EE82EE",
["beige"] = "#F5F5DC", ["베이지"] = "#F5F5DC",
["ivory"] = "#FFFFF0", ["아이보리"] = "#FFFFF0",
["khaki"] = "#F0E68C", ["카키"] = "#F0E68C",
["lavender"] = "#E6E6FA", ["라벤더"] = "#E6E6FA",
["turquoise"] = "#40E0D0", ["터키옥"] = "#40E0D0",
["chocolate"] = "#D2691E", ["초콜릿"] = "#D2691E",
["crimson"] = "#DC143C", ["크림슨"] = "#DC143C",
["transparent"] = "#00000000",
};
// ─── 정규식 ─────────────────────────────────────────────────────────────
private static readonly Regex _hexRe = new(@"^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$");
private static readonly Regex _rgbRe = new(@"^(?:rgb\s*\()?\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)?$", RegexOptions.IgnoreCase);
private static readonly Regex _rgbaRe = new(@"^rgba\s*\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*([\d.]+)\s*\)$", RegexOptions.IgnoreCase);
private static readonly Regex _hslRe = new(@"^hsl\s*\(\s*([\d.]+)\s*,\s*([\d.]+)%?\s*,\s*([\d.]+)%?\s*\)$", RegexOptions.IgnoreCase);
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(query))
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
"색상 코드를 입력하세요",
"예: #FF5500 · 255,85,0 · red · hsl(20,100%,50%)",
null, null, Symbol: Symbols.ColorPicker)
]);
}
var q = query.Trim();
var items = new List<LauncherItem>();
// HEX 형식
if (_hexRe.IsMatch(q))
{
var hex = q.TrimStart('#');
if (hex.Length == 3)
hex = $"{hex[0]}{hex[0]}{hex[1]}{hex[1]}{hex[2]}{hex[2]}";
int r, g, b;
byte a = 255;
if (hex.Length == 8)
{
a = Convert.ToByte(hex[..2], 16);
r = Convert.ToInt32(hex[2..4], 16);
g = Convert.ToInt32(hex[4..6], 16);
b = Convert.ToInt32(hex[6..8], 16);
}
else
{
r = Convert.ToInt32(hex[..2], 16);
g = Convert.ToInt32(hex[2..4], 16);
b = Convert.ToInt32(hex[4..6], 16);
}
AddColorItems(items, r, g, b, a, $"#{hex.ToUpperInvariant()}");
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// RGB 형식
var rgbM = _rgbRe.Match(q);
if (rgbM.Success)
{
int r = int.Parse(rgbM.Groups[1].Value);
int g = int.Parse(rgbM.Groups[2].Value);
int b = int.Parse(rgbM.Groups[3].Value);
if (r <= 255 && g <= 255 && b <= 255)
{
AddColorItems(items, r, g, b, 255, $"rgb({r},{g},{b})");
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
}
// RGBA 형식
var rgbaM = _rgbaRe.Match(q);
if (rgbaM.Success)
{
int r = int.Parse(rgbaM.Groups[1].Value);
int g = int.Parse(rgbaM.Groups[2].Value);
int b = int.Parse(rgbaM.Groups[3].Value);
double aD = double.Parse(rgbaM.Groups[4].Value, System.Globalization.CultureInfo.InvariantCulture);
byte a = (byte)Math.Clamp((int)(aD * 255), 0, 255);
if (r <= 255 && g <= 255 && b <= 255)
{
AddColorItems(items, r, g, b, a, $"rgba({r},{g},{b},{aD:G3})");
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
}
// HSL 형식
var hslM = _hslRe.Match(q);
if (hslM.Success)
{
double h = double.Parse(hslM.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture);
double s = double.Parse(hslM.Groups[2].Value, System.Globalization.CultureInfo.InvariantCulture) / 100;
double l = double.Parse(hslM.Groups[3].Value, System.Globalization.CultureInfo.InvariantCulture) / 100;
var (r, g, b) = HslToRgb(h, s, l);
AddColorItems(items, r, g, b, 255, $"hsl({h:G},{s*100:G}%,{l*100:G}%)");
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 색상 이름
if (_namedColors.TryGetValue(q, out var namedHex))
{
var hex = namedHex.TrimStart('#');
int r = Convert.ToInt32(hex[..2], 16);
int g = Convert.ToInt32(hex[2..4], 16);
int b = Convert.ToInt32(hex[4..6], 16);
AddColorItems(items, r, g, b, 255, namedHex.ToUpperInvariant());
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 검색 모드 (일부 색상 이름 검색)
var searchQ = q.ToLowerInvariant();
var matched = _namedColors
.Where(kv => kv.Key.Contains(searchQ, StringComparison.OrdinalIgnoreCase))
.Take(8);
foreach (var kv in matched)
{
items.Add(new LauncherItem(
$"{kv.Key} → {kv.Value.ToUpperInvariant()}",
"Enter로 HEX 복사",
null, kv.Value.ToUpperInvariant(), Symbol: Symbols.ColorPicker));
}
if (!items.Any())
{
items.Add(new LauncherItem(
"인식할 수 없는 색상",
"#RRGGBB · RGB(r,g,b) · hsl(h,s%,l%) · red 등 색상 이름",
null, null, Symbol: Symbols.Error));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is string value)
try { Clipboard.SetText(value); } catch (Exception) { }
return Task.CompletedTask;
}
// ─── 색상 항목 생성 ──────────────────────────────────────────────────────
private static void AddColorItems(List<LauncherItem> items, int r, int g, int b, byte a, string source)
{
var hex = a == 255
? $"#{r:X2}{g:X2}{b:X2}"
: $"#{a:X2}{r:X2}{g:X2}{b:X2}";
var rgb = a == 255
? $"rgb({r}, {g}, {b})"
: $"rgba({r}, {g}, {b}, {a / 255.0:G3})";
var (h, s, l) = RgbToHsl(r, g, b);
var hsl = $"hsl({h:G3}, {s * 100:G3}%, {l * 100:G3}%)";
var (hh, sv, v) = RgbToHsv(r, g, b);
var hsv = $"hsv({hh:G3}, {sv * 100:G3}%, {v * 100:G3}%)";
items.Add(new LauncherItem($"HEX → {hex.ToUpperInvariant()}", $"{source} · Enter로 복사", null, hex.ToUpperInvariant(), Symbol: Symbols.ColorPicker));
items.Add(new LauncherItem($"RGB → {rgb}", $"{source} · Enter로 복사", null, rgb, Symbol: Symbols.ColorPicker));
items.Add(new LauncherItem($"HSL → {hsl}", $"{source} · Enter로 복사", null, hsl, Symbol: Symbols.ColorPicker));
items.Add(new LauncherItem($"HSV → {hsv}", $"{source} · Enter로 복사", null, hsv, Symbol: Symbols.ColorPicker));
}
// ─── 색상 공간 변환 ───────────────────────────────────────────────────────
private static (double h, double s, double l) RgbToHsl(int r, int g, int b)
{
double rf = r / 255.0, gf = g / 255.0, bf = b / 255.0;
double max = Math.Max(rf, Math.Max(gf, bf));
double min = Math.Min(rf, Math.Min(gf, bf));
double l = (max + min) / 2;
double h = 0, s = 0;
if (max != min)
{
double d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
h = max == rf ? (gf - bf) / d + (gf < bf ? 6 : 0)
: max == gf ? (bf - rf) / d + 2
: (rf - gf) / d + 4;
h /= 6;
}
return (Math.Round(h * 360, 1), Math.Round(s, 4), Math.Round(l, 4));
}
private static (double h, double s, double v) RgbToHsv(int r, int g, int b)
{
double rf = r / 255.0, gf = g / 255.0, bf = b / 255.0;
double max = Math.Max(rf, Math.Max(gf, bf));
double min = Math.Min(rf, Math.Min(gf, bf));
double v = max, s = 0, h = 0;
double d = max - min;
if (max != 0) s = d / max;
if (d != 0)
{
h = max == rf ? (gf - bf) / d + (gf < bf ? 6 : 0)
: max == gf ? (bf - rf) / d + 2
: (rf - gf) / d + 4;
h /= 6;
}
return (Math.Round(h * 360, 1), Math.Round(s, 4), Math.Round(v, 4));
}
private static (int r, int g, int b) HslToRgb(double h, double s, double l)
{
double r, g, b;
if (s == 0) { r = g = b = l; }
else
{
double q = l < 0.5 ? l * (1 + s) : l + s - l * s;
double p = 2 * l - q;
h /= 360;
r = Hue2Rgb(p, q, h + 1.0 / 3);
g = Hue2Rgb(p, q, h);
b = Hue2Rgb(p, q, h - 1.0 / 3);
}
return ((int)Math.Round(r * 255), (int)Math.Round(g * 255), (int)Math.Round(b * 255));
}
private static double Hue2Rgb(double p, double q, double t)
{
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1.0 / 6) return p + (q - p) * 6 * t;
if (t < 1.0 / 2) return q;
if (t < 2.0 / 3) return p + (q - p) * (2.0 / 3 - t) * 6;
return p;
}
}

View File

@@ -0,0 +1,59 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 스포이드 색상 추출 핸들러. "pick" 프리픽스로 사용합니다.
/// 실행하면 전체 화면 스포이드 모드에 진입하고,
/// 클릭한 지점의 색상을 HEX 코드로 클립보드에 복사합니다.
/// 반투명 결과 창이 5초간 표시됩니다.
/// 예: pick → 스포이드 모드 진입
/// </summary>
public class ColorPickHandler : IActionHandler
{
public string? Prefix => "pick";
public PluginMetadata Metadata => new(
"ColorPick",
"스포이드 색상 추출 — pick",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
"스포이드로 화면 색상 추출",
"Enter → 스포이드 모드 진입 · 화면 아무 곳을 클릭하면 색상 코드 추출 · Esc 취소",
null, "__PICK__",
Symbol: Symbols.ColorPicker)
]);
}
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is not string s || s != "__PICK__") return;
// 런처가 닫히는 동안 대기
await Task.Delay(200, ct);
Application.Current?.Dispatcher.Invoke(() =>
{
var dropper = new Views.EyeDropperWindow();
dropper.ShowDialog();
if (dropper.PickedColor.HasValue)
{
var result = new Views.ColorPickResultWindow(
dropper.PickedColor.Value,
dropper.PickX,
dropper.PickY);
result.Show();
}
});
}
}

View File

@@ -0,0 +1,128 @@
using System.Globalization;
using System.Text.RegularExpressions;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 날짜/시간 변환기 핸들러. "date" 프리픽스로 사용합니다.
/// 예: date → 현재 날짜/시간 + 유닉스 타임스탬프
/// date +30d → 오늘 + 30일
/// date -100d → 오늘 - 100일
/// date 2026-12-25 → 해당 날짜까지 D-day + 요일
/// date 1711584000 → 유닉스 타임스탬프 → 날짜
/// date to unix → 현재 시각의 유닉스 타임스탬프
/// Enter → 결과를 클립보드에 복사.
/// </summary>
public class DateCalcHandler : IActionHandler
{
public string? Prefix => "date";
public PluginMetadata Metadata => new(
"DateCalc",
"날짜 계산 · D-day · 타임스탬프 변환",
"1.0",
"AX");
private static readonly string[] DateFormats =
["yyyy-MM-dd", "yyyy/MM/dd", "yyyyMMdd", "MM/dd/yyyy", "dd-MM-yyyy"];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var now = DateTime.Now;
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
// 현재 날짜/시간 정보
var dayName = now.ToString("dddd", new CultureInfo("ko-KR"));
items.Add(Item($"{now:yyyy-MM-dd} ({dayName})", $"{now:HH:mm:ss} · {now:yyyy-MM-dd}"));
items.Add(Item($"유닉스 타임스탬프: {new DateTimeOffset(now).ToUnixTimeSeconds()}", "현재 시각의 Unix epoch"));
items.Add(Item($"올해 {now.DayOfYear}일째 / 남은 일: {(new DateTime(now.Year, 12, 31) - now).Days}일",
$"ISO 주차: {ISOWeek.GetWeekOfYear(now)}주"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// +30d / -100d 패턴
var offsetMatch = Regex.Match(q, @"^([+-])(\d+)([dDwWmMyY])$");
if (offsetMatch.Success)
{
var sign = offsetMatch.Groups[1].Value == "+" ? 1 : -1;
var val = int.Parse(offsetMatch.Groups[2].Value) * sign;
var unit = offsetMatch.Groups[3].Value.ToLowerInvariant();
var target = unit switch
{
"d" => now.AddDays(val),
"w" => now.AddDays(val * 7),
"m" => now.AddMonths(val),
"y" => now.AddYears(val),
_ => now
};
var dayName = target.ToString("dddd", new CultureInfo("ko-KR"));
var diff = (target.Date - now.Date).Days;
var diffStr = diff >= 0 ? $"오늘로부터 {diff}일 후" : $"오늘로부터 {Math.Abs(diff)}일 전";
items.Add(Item($"{target:yyyy-MM-dd} ({dayName})", diffStr));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 유닉스 타임스탬프 (10자리 또는 13자리 숫자)
if (Regex.IsMatch(q, @"^\d{10,13}$") && long.TryParse(q, out long epoch))
{
var ts = epoch > 9_999_999_999 ? epoch / 1000 : epoch; // 밀리초→초
var dt = DateTimeOffset.FromUnixTimeSeconds(ts).LocalDateTime;
var dayName = dt.ToString("dddd", new CultureInfo("ko-KR"));
items.Add(Item($"{dt:yyyy-MM-dd HH:mm:ss} ({dayName})", $"Unix {epoch} → 로컬 시간"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// "to unix" / "unix" 키워드
if (q.Equals("unix", StringComparison.OrdinalIgnoreCase) ||
q.Equals("to unix", StringComparison.OrdinalIgnoreCase))
{
var unix = new DateTimeOffset(now).ToUnixTimeSeconds();
items.Add(Item($"{unix}", $"현재 시각 ({now:yyyy-MM-dd HH:mm:ss}) → Unix 타임스탬프"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 날짜 파싱 → D-day 계산
if (DateTime.TryParseExact(q, DateFormats, CultureInfo.InvariantCulture,
DateTimeStyles.None, out var parsed))
{
var dayName = parsed.ToString("dddd", new CultureInfo("ko-KR"));
var diff = (parsed.Date - now.Date).Days;
var dday = diff switch
{
0 => "오늘",
> 0 => $"D-{diff} (앞으로 {diff}일)",
_ => $"D+{Math.Abs(diff)} ({Math.Abs(diff)}일 지남)"
};
items.Add(Item($"{parsed:yyyy-MM-dd} ({dayName})", dday));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
items.Add(new LauncherItem(
"날짜 형식을 인식할 수 없습니다",
"예: +30d, -100d, 2026-12-25, 1711584000, unix",
null, null, Symbol: Symbols.Warning));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
private static LauncherItem Item(string title, string subtitle) =>
new(title, $"{subtitle} · Enter로 복사", null, title, Symbol: Symbols.Clock);
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is string text && !string.IsNullOrWhiteSpace(text))
{
try { Application.Current?.Dispatcher.Invoke(() => Clipboard.SetText(text)); }
catch (Exception) { }
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,245 @@
using System.IO;
using System.Text;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 텍스트/파일 비교 핸들러. "diff" 프리픽스로 사용합니다.
/// 클립보드 히스토리의 최근 2개 텍스트를 줄 단위로 비교하거나,
/// 파일 2개를 지정하여 비교합니다.
/// 예: diff → 클립보드 히스토리 최근 2개 비교
/// diff C:\a.txt C:\b.txt → 파일 비교
/// Enter → 비교 결과를 클립보드에 복사.
/// </summary>
public class DiffHandler : IActionHandler
{
private readonly ClipboardHistoryService? _clipHistory;
public DiffHandler(ClipboardHistoryService? clipHistory = null)
{
_clipHistory = clipHistory;
}
public string? Prefix => "diff";
public PluginMetadata Metadata => new(
"Diff",
"텍스트/파일 비교 — diff",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
// 파일 비교 모드
if (!string.IsNullOrWhiteSpace(q))
{
// 파일 2개 지정
var paths = q.Split(' ', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (paths.Length == 2 && File.Exists(paths[0]) && File.Exists(paths[1]))
{
try
{
var textA = File.ReadAllText(paths[0]);
var textB = File.ReadAllText(paths[1]);
var result = BuildDiff(textA, textB,
Path.GetFileName(paths[0]), Path.GetFileName(paths[1]));
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
$"파일 비교: {Path.GetFileName(paths[0])} {Path.GetFileName(paths[1])}",
$"{result.Added}줄 추가, {result.Removed}줄 삭제, {result.Same}줄 동일 · Enter로 결과 복사",
null, result.Text,
Symbol: Symbols.File)
]);
}
catch (Exception ex)
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem($"파일 읽기 실패: {ex.Message}", "",
null, null, Symbol: Symbols.Error)
]);
}
}
// 파일 1개만 있으면 안내
if (paths.Length >= 1 && (File.Exists(paths[0]) || Directory.Exists(Path.GetDirectoryName(paths[0]) ?? "")))
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
"비교할 파일 2개의 경로를 입력하세요",
"예: diff C:\\a.txt C:\\b.txt",
null, null, Symbol: Symbols.Info)
]);
}
}
// 클립보드 히스토리 비교 모드
var history = _clipHistory?.History;
if (history == null || history.Count < 2)
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
"비교할 텍스트가 부족합니다",
"클립보드에 2개 이상의 텍스트를 복사하거나, diff [파일A] [B] ",
null, null, Symbol: Symbols.Info)
]);
}
var textEntries = history.Where(e => e.IsText).Take(2).ToList();
if (textEntries.Count < 2)
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem("텍스트 히스토리가 2개 미만입니다", "텍스트를 2번 이상 복사하세요",
null, null, Symbol: Symbols.Info)
]);
}
var older = textEntries[1]; // 이전
var newer = textEntries[0]; // 최근
var diff = BuildDiff(older.Text, newer.Text,
$"이전 ({older.RelativeTime})", $"최근 ({newer.RelativeTime})");
var items = new List<LauncherItem>
{
new(
$"클립보드 비교: +{diff.Added} -{diff.Removed} ={diff.Same}",
$"이전 복사 ↔ 최근 복사 · Enter로 결과 복사",
null, diff.Text,
Symbol: Symbols.History),
};
// 미리보기 (변경된 줄 최대 5개)
var changedLines = diff.Text.Split('\n')
.Where(l => l.StartsWith("+ ") || l.StartsWith("- "))
.Take(5);
foreach (var line in changedLines)
{
var symbol = line.StartsWith("+ ") ? "\uE710" : "\uE711"; // + or X
items.Add(new LauncherItem(
line,
"",
null, null,
Symbol: symbol));
}
// 파일 선택 비교 항목
items.Add(new LauncherItem(
"파일 선택하여 비교",
"파일 선택 대화 상자에서 2개의 파일을 골라 비교합니다",
null, "__FILE_DIALOG__",
Symbol: Symbols.File));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
// 파일 선택 다이얼로그
if (item.Data is string s && s == "__FILE_DIALOG__")
{
await Task.Delay(200, ct); // 런처 닫힘 대기
Application.Current?.Dispatcher.Invoke(() =>
{
var dlg = new Microsoft.Win32.OpenFileDialog
{
Title = "비교할 첫 번째 파일 선택",
Filter = "텍스트 파일|*.txt;*.cs;*.json;*.xml;*.md;*.csv;*.log|모든 파일|*.*"
};
if (dlg.ShowDialog() != true) return;
var fileA = dlg.FileName;
dlg.Title = "비교할 두 번째 파일 선택";
if (dlg.ShowDialog() != true) return;
var fileB = dlg.FileName;
try
{
var textA = File.ReadAllText(fileA);
var textB = File.ReadAllText(fileB);
var result = BuildDiff(textA, textB,
Path.GetFileName(fileA), Path.GetFileName(fileB));
Clipboard.SetText(result.Text);
NotificationService.Notify("파일 비교 완료",
$"+{result.Added} -{result.Removed} ={result.Same} · 결과 클립보드 복사됨");
}
catch (Exception ex)
{
NotificationService.Notify("비교 실패", ex.Message);
}
});
return;
}
if (item.Data is string text && !string.IsNullOrWhiteSpace(text))
{
try { Application.Current?.Dispatcher.Invoke(() => Clipboard.SetText(text)); }
catch (Exception) { }
NotificationService.Notify("비교 결과", "클립보드에 복사되었습니다");
}
}
// 간단한 줄 단위 diff
private static DiffResult BuildDiff(string textA, string textB, string labelA, string labelB)
{
var linesA = textA.Split('\n').Select(l => l.TrimEnd('\r')).ToArray();
var linesB = textB.Split('\n').Select(l => l.TrimEnd('\r')).ToArray();
var setA = new HashSet<string>(linesA);
var setB = new HashSet<string>(linesB);
var sb = new StringBuilder();
sb.AppendLine($"--- {labelA}");
sb.AppendLine($"+++ {labelB}");
sb.AppendLine();
int added = 0, removed = 0, same = 0;
int maxLen = Math.Max(linesA.Length, linesB.Length);
for (int i = 0; i < maxLen; i++)
{
var a = i < linesA.Length ? linesA[i] : null;
var b = i < linesB.Length ? linesB[i] : null;
if (a == b)
{
sb.AppendLine($" {a}");
same++;
}
else
{
if (a != null && !setB.Contains(a))
{
sb.AppendLine($"- {a}");
removed++;
}
if (b != null && !setA.Contains(b))
{
sb.AppendLine($"+ {b}");
added++;
}
if (a != null && setB.Contains(a) && a != b)
{
sb.AppendLine($" {a}");
same++;
}
}
}
return new DiffResult(sb.ToString(), added, removed, same);
}
private record DiffResult(string Text, int Added, int Removed, int Same);
}

View File

@@ -0,0 +1,553 @@
using AxCopilot.SDK;
using AxCopilot.Themes;
using System.Windows;
namespace AxCopilot.Handlers;
/// <summary>
/// 이모지 피커 핸들러. "emoji" 프리픽스로 사용합니다.
/// 예: emoji 하트 → ❤️ 검색
/// emoji wave → 👋 검색
/// emoji → 자주 쓰는 이모지 목록
/// </summary>
public class EmojiHandler : IActionHandler
{
public string? Prefix => "emoji";
public PluginMetadata Metadata => new(
"Emoji",
"이모지 피커 — emoji 뒤에 이름 입력",
"1.0",
"AX");
// ─── 이모지 데이터베이스 (이모지, 이름(한/영), 태그) ──────────────────────
private static readonly (string Emoji, string Name, string Tags)[] _emojis =
{
// 표정 / 감정
("😀", "크게 웃는 얼굴", "smile happy grin 웃음 행복"),
("😃", "웃는 얼굴", "smile happy joy 웃음"),
("😄", "눈 웃음", "smile laugh 웃음 기쁨"),
("😁", "히죽 웃음", "grin beam 씩 웃다"),
("😆", "크게 웃음", "laughing 폭소"),
("😅", "식은땀 웃음", "sweat smile 안도"),
("🤣", "바닥 구르며 웃음", "rofl lol 빵 웃음"),
("😂", "눈물 나게 웃음", "joy tears laugh 폭소"),
("🙂", "살짝 웃음", "slightly smiling 미소"),
("🙃", "거꾸로 웃음", "upside down 뒤집힌"),
("😉", "윙크", "wink 윙크"),
("😊", "볼 빨개진 웃음", "blush 부끄러움 미소"),
("😇", "천사", "angel halo 천사 선량"),
("🥰", "사랑스러운 얼굴", "love hearts 사랑 하트"),
("😍", "하트 눈", "heart eyes 사랑 반함"),
("🤩", "별 눈", "star struck 감동 황홀"),
("😘", "뽀뽀", "kiss blow 키스 뽀뽀"),
("😗", "오므린 입", "kiss whistle 키스"),
("😚", "눈 감고 뽀뽀", "kiss 키스"),
("😙", "볼 뽀뽀", "kiss 키스"),
("😋", "맛있다", "yum delicious 맛 음식"),
("😛", "혀 내밀기", "tongue out 혀 놀림"),
("😜", "윙크하며 혀", "wink tongue 장난"),
("🤪", "미친 표정", "zany crazy 정신없음"),
("😝", "눈 감고 혀", "tongue 혀"),
("🤑", "돈 눈", "money face 돈 부자"),
("🤗", "포옹", "hugging hug 안아줘 포옹"),
("🤭", "입 가리고", "hand over mouth 헉 깜짝"),
("🤫", "쉿", "shushing quiet 조용 쉿"),
("🤔", "생각 중", "thinking 고민 생각"),
("🤐", "입 막음", "zipper mouth 비밀"),
("🤨", "의심", "raised eyebrow 의심 의아"),
("😐", "무표정", "neutral 무감각 무표정"),
("😑", "표정 없음", "expressionless 냉담"),
("😶", "입 없는 얼굴", "no mouth 침묵"),
("😏", "비웃음", "smirk 비웃 냉소"),
("😒", "불만", "unamused 불만 짜증"),
("🙄", "눈 굴리기", "eye roll 어이없음"),
("😬", "이 드러냄", "grimace 으 민망"),
("🤥", "거짓말", "lying pinocchio 거짓말"),
("😌", "안도/평온", "relieved 안도 평온"),
("😔", "슬픔", "pensive sad 슬픔 우울"),
("😪", "졸림", "sleepy 졸음"),
("🤤", "침 흘림", "drooling 군침 식욕"),
("😴", "잠", "sleeping sleep 수면 잠"),
("😷", "마스크", "mask sick 마스크 아픔"),
("🤒", "열 나는", "sick fever 열 아픔"),
("🤕", "머리 붕대", "injured hurt 부상"),
("🤢", "구역질", "nauseated sick 구역 메스꺼움"),
("🤮", "토하는", "vomit 구토"),
("🤧", "재채기", "sneezing sick 재채기 감기"),
("🥵", "더운", "hot overheated 더움 열"),
("🥶", "추운", "cold freezing 추움 냉기"),
("🥴", "어지러운", "woozy 어지럼 취함"),
("😵", "어질어질", "dizzy 어지럼 충격"),
("🤯", "머리 폭발", "exploding head 충격 대박"),
("🤠", "카우보이", "cowboy hat 카우보이"),
("🥸", "변장", "disguise 변장 선글라스"),
("😎", "쿨한", "cool sunglasses 선글라스 쿨"),
("🤓", "공부벌레", "nerd glasses 공부 안경"),
("🧐", "모노클", "monocle curious 고상 탐정"),
("😕", "당황", "confused 당황 모호"),
("😟", "걱정", "worried concern 걱정"),
("🙁", "살짝 찡그림", "frown 슬픔"),
("☹️", "찡그린 얼굴", "frown sad 슬픔"),
("😮", "입 벌림", "open mouth surprised 놀람"),
("😯", "놀람", "hushed surprised 깜짝"),
("😲", "충격", "astonished 충격 놀람"),
("😳", "얼굴 빨개짐", "flushed embarrassed 부끄럼 당황"),
("🥺", "애원", "pleading eyes 부탁 눈빛"),
("😦", "찡그리며 벌린 입", "frowning 불안"),
("😧", "고통", "anguished 고통"),
("😨", "무서움", "fearful scared 무서움 공포"),
("😰", "식은땀", "anxious sweat 불안 걱정"),
("😥", "눈물 조금", "sad disappointed 실망 눈물"),
("😢", "울음", "cry sad 슬픔 눈물"),
("😭", "엉엉 울음", "loudly crying sob 통곡"),
("😱", "공포에 질림", "screaming fear 비명 공포"),
("😖", "혼란", "confounded 혼란"),
("😣", "힘듦", "persevering 고생"),
("😞", "실망", "disappointed 실망"),
("😓", "땀", "downcast sweat 땀 힘듦"),
("😩", "피곤", "weary tired 지침 피곤"),
("😫", "극도로 지침", "tired exhausted 탈진"),
("🥱", "하품", "yawning bored 하품 지루함"),
("😤", "콧김", "triumph snort 분노 콧김"),
("😡", "화남", "angry mad 화남 분노"),
("😠", "성남", "angry 화 성남"),
("🤬", "욕", "cursing swearing 욕 분노"),
("😈", "나쁜 미소", "smiling devil 악마 장난"),
("👿", "화난 악마", "angry devil 악마"),
("💀", "해골", "skull death 해골 죽음"),
("☠️", "해골 십자", "skull crossbones 독"),
("💩", "응가", "poop 똥 응가"),
("🤡", "피에로", "clown 광대"),
("👹", "도깨비", "ogre 도깨비 귀신"),
("👺", "텐구", "goblin 텐구"),
("👻", "유령", "ghost 유령 귀신"),
("👾", "우주인", "alien monster 외계인 게임"),
("🤖", "로봇", "robot 로봇"),
// 손 / 몸
("👋", "손 흔들기", "wave waving hi bye 안녕"),
("🤚", "손 뒤", "raised back hand 손"),
("🖐️", "손바닥", "hand palm 다섯 손가락"),
("✋", "손 들기", "raised hand 손 들기 멈춤"),
("🖖", "스팍 손인사", "vulcan salute 스타트렉"),
("👌", "오케이", "ok perfect 오케이 좋아"),
("🤌", "손가락 모아", "pinched fingers 이탈리아"),
("✌️", "브이", "victory peace v 브이 평화"),
("🤞", "행운 손가락", "crossed fingers lucky 행운 기도"),
("🤟", "아이 러브 유", "love you 사랑해"),
("🤘", "록 손", "rock on metal 록"),
("🤙", "전화해", "call me shaka 전화 샤카"),
("👈", "왼쪽 가리킴", "backhand left 왼쪽"),
("👉", "오른쪽 가리킴", "backhand right 오른쪽"),
("👆", "위 가리킴", "backhand up 위"),
("🖕", "욕", "middle finger 욕"),
("👇", "아래 가리킴", "backhand down 아래"),
("☝️", "검지 들기", "index pointing up 하나 포인트"),
("👍", "좋아요", "thumbs up like good 좋아 최고"),
("👎", "싫어요", "thumbs down dislike 싫어 별로"),
("✊", "주먹", "fist punch 주먹"),
("👊", "주먹 치기", "punch fist 주먹"),
("🤛", "왼 주먹", "left fist 주먹"),
("🤜", "오른 주먹", "right fist 주먹"),
("👏", "박수", "clapping applause 박수 응원"),
("🙌", "만세", "raising hands celebrate 만세"),
("👐", "양손 펼침", "open hands 환영"),
("🤲", "두 손 모음", "palms up together 기도 바람"),
("🙏", "두 손 합장", "pray please thanks 감사 부탁 기도"),
("✍️", "글쓰기", "writing pen 글쓰기"),
("💅", "네일", "nail polish manicure 네일 손톱"),
("🤳", "셀카", "selfie 셀카"),
("💪", "근육", "muscle strong 근육 힘"),
("🦾", "기계 팔", "mechanical arm 로봇 팔"),
("🦿", "기계 다리", "mechanical leg 로봇 다리"),
("🦵", "다리", "leg kick 다리"),
("🦶", "발", "foot kick 발"),
("👂", "귀", "ear hear 귀"),
("🦻", "보청기 귀", "ear hearing aid 보청기"),
("👃", "코", "nose smell 코"),
("🫀", "심장", "heart anatomical 심장"),
("🫁", "폐", "lungs 폐"),
("🧠", "뇌", "brain mind 뇌 지능"),
("🦷", "치아", "tooth dental 치아"),
("🦴", "뼈", "bone 뼈"),
("👀", "눈", "eyes look see 눈 보기"),
("👁️", "한쪽 눈", "eye 눈"),
("👅", "혀", "tongue 혀"),
("👄", "입술", "lips mouth 입술"),
("💋", "입맞춤", "kiss lips 키스 입술"),
("🩸", "피", "blood drop 피 혈액"),
// 하트 / 감정 기호
("❤️", "빨간 하트", "red heart love 사랑 빨강"),
("🧡", "주황 하트", "orange heart 사랑"),
("💛", "노란 하트", "yellow heart 사랑"),
("💚", "초록 하트", "green heart 사랑"),
("💙", "파란 하트", "blue heart 사랑"),
("💜", "보라 하트", "purple heart 사랑"),
("🖤", "검은 하트", "black heart 사랑 다크"),
("🤍", "흰 하트", "white heart 사랑"),
("🤎", "갈색 하트", "brown heart 사랑"),
("💔", "깨진 하트", "broken heart 이별 상처"),
("❣️", "느낌표 하트", "heart exclamation 사랑"),
("💕", "두 하트", "two hearts 사랑"),
("💞", "회전 하트", "revolving hearts 사랑"),
("💓", "뛰는 하트", "beating heart 설렘"),
("💗", "성장 하트", "growing heart 사랑"),
("💖", "반짝 하트", "sparkling heart 사랑"),
("💘", "화살 하트", "heart arrow 큐피드"),
("💝", "리본 하트", "heart ribbon 선물 사랑"),
("💟", "하트 장식", "heart decoration 사랑"),
("☮️", "평화", "peace 평화"),
("✝️", "십자가", "cross 기독교"),
("☯️", "음양", "yin yang 음양 균형"),
("🔮", "수정구", "crystal ball magic 마법 점"),
("✨", "반짝임", "sparkles glitter 빛 반짝"),
("⭐", "별", "star 별"),
("🌟", "빛나는 별", "glowing star 별빛"),
("💫", "현기증", "dizzy star 빙글"),
("⚡", "번개", "lightning bolt 번개 전기"),
("🔥", "불", "fire hot 불 열정"),
("💥", "폭발", "explosion boom 폭발"),
("❄️", "눈송이", "snowflake cold 눈 추위"),
("🌈", "무지개", "rainbow 무지개"),
("☀️", "태양", "sun sunny 태양 맑음"),
("🌙", "달", "moon crescent 달"),
("🌊", "파도", "wave ocean 파도 바다"),
("💨", "바람", "wind dash 바람"),
("💦", "물방울", "sweat droplets water 물"),
("🌸", "벚꽃", "cherry blossom 벚꽃 봄"),
("🌹", "장미", "rose 장미 꽃"),
("🌺", "히비스커스", "hibiscus 꽃"),
("🌻", "해바라기", "sunflower 해바라기"),
("🌼", "꽃", "blossom flower 꽃"),
("🌷", "튤립", "tulip 튤립"),
("💐", "꽃다발", "bouquet flowers 꽃다발"),
("🍀", "네잎클로버", "four leaf clover lucky 행운"),
("🌿", "허브", "herb green 풀 허브"),
("🍃", "잎사귀", "leaf 잎"),
// 음식
("🍕", "피자", "pizza 피자"),
("🍔", "햄버거", "hamburger burger 버거"),
("🌮", "타코", "taco 타코"),
("🍜", "라면", "ramen noodles 라면 국수"),
("🍱", "도시락", "bento box 도시락"),
("🍣", "초밥", "sushi 초밥"),
("🍚", "밥", "rice 밥"),
("🍛", "카레", "curry rice 카레"),
("🍝", "파스타", "pasta spaghetti 파스타"),
("🍦", "소프트 아이스크림", "ice cream soft serve 아이스크림"),
("🎂", "생일 케이크", "cake birthday 생일 케이크"),
("🍰", "케이크 조각", "cake slice 케이크"),
("🧁", "컵케이크", "cupcake 컵케이크"),
("🍩", "도넛", "donut 도넛"),
("🍪", "쿠키", "cookie 쿠키"),
("🍫", "초콜릿", "chocolate bar 초콜릿"),
("🍬", "사탕", "candy 사탕"),
("🍭", "막대 사탕", "lollipop 막대사탕"),
("🍺", "맥주", "beer mug 맥주"),
("🍻", "건배", "clinking beer 건배"),
("🥂", "샴페인 건배", "champagne 샴페인 건배"),
("🍷", "와인", "wine 와인"),
("☕", "커피", "coffee hot 커피"),
("🧃", "주스", "juice 주스"),
("🥤", "음료", "drink cup 음료 컵"),
("🧋", "버블티", "bubble tea boba 버블티"),
("🍵", "녹차", "tea matcha 차 녹차"),
// 동물
("🐶", "강아지", "dog puppy 강아지 개"),
("🐱", "고양이", "cat kitten 고양이"),
("🐭", "쥐", "mouse 쥐"),
("🐹", "햄스터", "hamster 햄스터"),
("🐰", "토끼", "rabbit bunny 토끼"),
("🦊", "여우", "fox 여우"),
("🐻", "곰", "bear 곰"),
("🐼", "판다", "panda 판다"),
("🐨", "코알라", "koala 코알라"),
("🐯", "호랑이", "tiger 호랑이"),
("🦁", "사자", "lion 사자"),
("🐮", "소", "cow 소"),
("🐷", "돼지", "pig 돼지"),
("🐸", "개구리", "frog 개구리"),
("🐵", "원숭이", "monkey 원숭이"),
("🙈", "눈 가린 원숭이", "see no evil monkey 안 봐"),
("🙉", "귀 가린 원숭이", "hear no evil monkey 안 들어"),
("🙊", "입 가린 원숭이", "speak no evil monkey 안 말해"),
("🐔", "닭", "chicken 닭"),
("🐧", "펭귄", "penguin 펭귄"),
("🐦", "새", "bird 새"),
("🦆", "오리", "duck 오리"),
("🦅", "독수리", "eagle 독수리"),
("🦉", "부엉이", "owl 부엉이"),
("🐍", "뱀", "snake 뱀"),
("🐢", "거북이", "turtle 거북이"),
("🦋", "나비", "butterfly 나비"),
("🐌", "달팽이", "snail 달팽이"),
("🐛", "애벌레", "bug caterpillar 애벌레"),
("🐝", "꿀벌", "bee honeybee 벌"),
("🦑", "오징어", "squid 오징어"),
("🐙", "문어", "octopus 문어"),
("🐠", "열대어", "tropical fish 열대어"),
("🐡", "복어", "blowfish puffer 복어"),
("🦈", "상어", "shark 상어"),
("🐬", "돌고래", "dolphin 돌고래"),
("🐳", "고래", "whale 고래"),
("🐲", "용", "dragon 용"),
("🦄", "유니콘", "unicorn 유니콘"),
// 물건 / 도구
("📱", "스마트폰", "phone mobile smartphone 폰"),
("💻", "노트북", "laptop computer 노트북"),
("🖥️", "데스크톱", "desktop computer 컴퓨터"),
("⌨️", "키보드", "keyboard 키보드"),
("🖱️", "마우스", "mouse 마우스"),
("🖨️", "프린터", "printer 프린터"),
("📷", "카메라", "camera 카메라"),
("📸", "플래시 카메라", "camera flash 사진"),
("📹", "비디오 카메라", "video camera 동영상"),
("🎥", "영화 카메라", "movie camera film 영화"),
("📺", "TV", "television tv 텔레비전"),
("📻", "라디오", "radio 라디오"),
("🎙️", "마이크", "microphone studio 마이크"),
("🎤", "마이크 핸드헬드", "microphone karaoke 마이크"),
("🎧", "헤드폰", "headphones 헤드폰"),
("📡", "안테나", "satellite antenna 안테나"),
("🔋", "배터리", "battery 배터리"),
("🔌", "전원 플러그", "plug electric 플러그"),
("💡", "전구", "bulb idea light 전구 아이디어"),
("🔦", "손전등", "flashlight torch 손전등"),
("🕯️", "양초", "candle 양초"),
("📚", "책", "books stack 책"),
("📖", "열린 책", "open book read 독서"),
("📝", "메모", "memo note pencil 메모 노트"),
("✏️", "연필", "pencil 연필"),
("🖊️", "펜", "pen 펜"),
("📌", "압정", "pushpin pin 압정"),
("📎", "클립", "paperclip 클립"),
("✂️", "가위", "scissors cut 가위"),
("🗂️", "파일 폴더", "card index dividers folder 파일"),
("📁", "폴더", "folder 폴더"),
("📂", "열린 폴더", "open folder 폴더"),
("🗃️", "파일 박스", "card file box 서류함"),
("🗑️", "휴지통", "wastebasket trash 휴지통"),
("🔒", "잠금", "locked lock 잠금"),
("🔓", "열림", "unlocked 열림"),
("🔑", "열쇠", "key 열쇠"),
("🗝️", "구식 열쇠", "old key 열쇠"),
("🔨", "망치", "hammer 망치"),
("🔧", "렌치", "wrench tool 렌치"),
("🔩", "나사", "nut bolt 나사"),
("⚙️", "톱니바퀴", "gear settings 설정 톱니"),
("🛠️", "도구", "tools hammer wrench 도구"),
("💊", "알약", "pill medicine 약 알약"),
("💉", "주사기", "syringe injection 주사"),
("🩺", "청진기", "stethoscope doctor 청진기"),
("🏆", "트로피", "trophy award 트로피 우승"),
("🥇", "금메달", "first gold medal 금메달"),
("🥈", "은메달", "second silver 은메달"),
("🥉", "동메달", "third bronze 동메달"),
("🎖️", "훈장", "medal military 훈장"),
("🎗️", "리본", "ribbon awareness 리본"),
("🎫", "티켓", "ticket admission 티켓"),
("🎟️", "입장권", "admission tickets 티켓"),
("🎪", "서커스", "circus tent 서커스"),
("🎨", "팔레트", "art palette paint 그림 예술"),
("🎭", "연극", "performing arts theater 연극"),
("🎬", "클래퍼보드", "clapper film 영화 촬영"),
("🎮", "게임 컨트롤러", "video game controller 게임"),
("🎲", "주사위", "dice game 주사위"),
("🎯", "다트", "bullseye target dart 다트 목표"),
("🎳", "볼링", "bowling 볼링"),
("⚽", "축구", "soccer football 축구"),
("🏀", "농구", "basketball 농구"),
("🏈", "미식축구", "american football 미식축구"),
("⚾", "야구", "baseball 야구"),
("🎾", "테니스", "tennis 테니스"),
("🏐", "배구", "volleyball 배구"),
("🏉", "럭비", "rugby 럭비"),
("🎱", "당구", "billiards pool 당구"),
("🏓", "탁구", "ping pong table tennis 탁구"),
("🏸", "배드민턴", "badminton 배드민턴"),
("🥊", "권투 장갑", "boxing glove 권투"),
("🎣", "낚시", "fishing 낚시"),
("🏋️", "역도", "weightlifting gym 헬스 역도"),
("🧘", "명상", "yoga meditation 명상 요가"),
// 이동수단
("🚗", "자동차", "car automobile 자동차"),
("🚕", "택시", "taxi cab 택시"),
("🚙", "SUV", "suv car 차"),
("🚌", "버스", "bus 버스"),
("🚎", "무궤도 전차", "trolleybus 버스"),
("🏎️", "레이싱카", "racing car 레이싱"),
("🚓", "경찰차", "police car 경찰"),
("🚑", "구급차", "ambulance 구급차"),
("🚒", "소방차", "fire truck 소방차"),
("🚐", "미니밴", "minibus van 밴"),
("🚚", "트럭", "truck delivery 트럭"),
("✈️", "비행기", "airplane flight plane 비행기"),
("🚀", "로켓", "rocket space launch 로켓"),
("🛸", "UFO", "flying saucer ufo 유에프오"),
("🚁", "헬리콥터", "helicopter 헬리콥터"),
("🚂", "기차", "train locomotive 기차"),
("🚆", "고속열차", "train 기차"),
("🚇", "지하철", "metro subway 지하철"),
("⛵", "돛단배", "sailboat 요트"),
("🚢", "배", "ship cruise 배"),
("🚲", "자전거", "bicycle bike 자전거"),
("🛵", "스쿠터", "scooter moped 스쿠터"),
("🏍️", "오토바이", "motorcycle 오토바이"),
// 장소
("🏠", "집", "house home 집"),
("🏡", "마당 있는 집", "house garden 집"),
("🏢", "빌딩", "office building 빌딩"),
("🏣", "우체국", "post office 우체국"),
("🏥", "병원", "hospital 병원"),
("🏦", "은행", "bank 은행"),
("🏨", "호텔", "hotel 호텔"),
("🏫", "학교", "school 학교"),
("🏪", "편의점", "convenience store shop 편의점"),
("🏬", "백화점", "department store 백화점"),
("🏰", "성", "castle 성"),
("⛪", "교회", "church 교회"),
("🕌", "모스크", "mosque 모스크"),
("🗼", "에펠탑", "eiffel tower paris 파리"),
("🗽", "자유의 여신상", "statue of liberty new york 뉴욕"),
("🏔️", "산", "mountain snow 산"),
("🌋", "화산", "volcano 화산"),
("🗻", "후지산", "mount fuji japan 후지산"),
("🏕️", "캠핑", "camping tent 캠핑"),
("🏖️", "해변", "beach summer 해변 해수욕"),
("🌏", "지구", "earth globe asia 지구"),
// 기호 / 숫자
("💯", "100점", "hundred percent perfect 완벽 100"),
("🔢", "숫자", "numbers 숫자"),
("🆗", "OK", "ok button 오케이"),
("🆙", "업", "up button 업"),
("🆒", "쿨", "cool button 쿨"),
("🆕", "새것", "new button 새"),
("🆓", "무료", "free button 무료"),
("🆘", "SOS", "sos emergency 긴급 구조"),
("⚠️", "경고", "warning caution 경고 주의"),
("🚫", "금지", "prohibited no 금지"),
("✅", "체크", "check mark done 완료 확인"),
("❌", "엑스", "x cross error 실패 오류"),
("❓", "물음표", "question mark 물음표"),
("❗", "느낌표", "exclamation mark 느낌표"),
("", "더하기", "plus add 더하기"),
("", "빼기", "minus subtract 빼기"),
("➗", "나누기", "divide 나누기"),
("✖️", "곱하기", "multiply times 곱하기"),
("♾️", "무한대", "infinity 무한"),
("🔁", "반복", "repeat loop 반복"),
("🔀", "셔플", "shuffle random 랜덤"),
("▶️", "재생", "play 재생"),
("⏸️", "일시정지", "pause 일시정지"),
("⏹️", "정지", "stop 정지"),
("⏩", "빨리 감기", "fast forward 빨리감기"),
("⏪", "되감기", "rewind 되감기"),
("🔔", "알림", "bell notification 알림 벨"),
("🔕", "알림 끔", "bell off 알림끔"),
("🔊", "볼륨 크게", "loud speaker volume up 볼륨"),
("🔇", "음소거", "muted speaker 음소거"),
("📣", "메가폰", "megaphone loud 확성기"),
("📢", "스피커", "loudspeaker 스피커"),
("💬", "말풍선", "speech bubble chat 대화"),
("💭", "생각 말풍선", "thought bubble thinking 생각"),
("📧", "이메일", "email mail 이메일 메일"),
("📨", "수신 봉투", "incoming envelope 수신"),
("📩", "발신 봉투", "envelope outbox 발신"),
("📬", "우편함", "mailbox 우편함"),
("📦", "택배 박스", "package box parcel 택배 상자"),
("🎁", "선물", "gift present 선물"),
("🎀", "리본 묶음", "ribbon bow 리본"),
("🎊", "색종이", "confetti 파티 축하"),
("🎉", "파티 폭죽", "party popper celebrate 파티 축하"),
("🎈", "풍선", "balloon party 풍선"),
("🕐", "1시", "one o'clock 1시 시간"),
("🕒", "3시", "three o'clock 3시 시간"),
("🕔", "4시", "four o'clock 4시 시간"),
("⏰", "알람 시계", "alarm clock 알람 시계"),
("⏱️", "스톱워치", "stopwatch timer 스톱워치 타이머"),
("📅", "달력", "calendar date 달력 날짜"),
("📆", "찢는 달력", "tear-off calendar 달력"),
("💰", "돈 가방", "money bag 돈 부자"),
("💳", "신용카드", "credit card payment 카드 결제"),
("💵", "달러", "dollar banknote 달러"),
("💴", "엔화", "yen banknote 엔"),
("💶", "유로", "euro banknote 유로"),
("💷", "파운드", "pound banknote 파운드"),
("📊", "막대 그래프", "bar chart graph 그래프"),
("📈", "상승 그래프", "chart increasing trend 상승 트렌드"),
("📉", "하락 그래프", "chart decreasing trend 하락"),
("🔍", "돋보기", "magnifying glass search 검색 돋보기"),
("🔎", "오른쪽 돋보기", "magnifying glass right search 검색"),
("🏳️", "흰 깃발", "white flag 항복"),
("🏴", "검은 깃발", "black flag 해적"),
("🚩", "빨간 삼각기", "triangular flag 경고 깃발"),
("🏁", "체크무늬 깃발", "chequered flag finish race 결승"),
("🌐", "지구본", "globe internet web 인터넷 웹"),
("⚓", "닻", "anchor 닻"),
("🎵", "음표", "music note 음악 음표"),
("🎶", "음표들", "musical notes 음악"),
("🎼", "악보", "musical score 악보"),
("🎹", "피아노", "piano keyboard 피아노"),
("🎸", "기타", "guitar 기타"),
("🥁", "드럼", "drum 드럼"),
("🪗", "아코디언", "accordion 아코디언"),
("🎷", "색소폰", "saxophone 색소폰"),
("🎺", "트럼펫", "trumpet 트럼펫"),
("🎻", "바이올린", "violin 바이올린"),
};
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
IEnumerable<(string Emoji, string Name, string Tags)> matches;
if (string.IsNullOrWhiteSpace(query))
{
// 기본 화면: 자주 쓰는 이모지 30개
matches = _emojis.Take(30);
}
else
{
var q = query.Trim().ToLowerInvariant();
matches = _emojis
.Where(e => e.Name.Contains(q, StringComparison.OrdinalIgnoreCase)
|| e.Tags.Contains(q, StringComparison.OrdinalIgnoreCase)
|| e.Emoji.Contains(q))
.Take(20);
}
var items = matches.Select(e => new LauncherItem(
$"{e.Emoji} {e.Name}",
"Enter로 클립보드에 복사",
null,
e.Emoji,
Symbol: Symbols.Emoji)).ToList();
if (!items.Any() && !string.IsNullOrWhiteSpace(query))
{
items.Add(new LauncherItem(
"검색 결과 없음",
$"'{query}'에 해당하는 이모지가 없습니다",
null, null, Symbol: Symbols.Info));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is string emoji)
{
try { Clipboard.SetText(emoji); }
catch (Exception) { }
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,208 @@
using System.Security.Cryptography;
using System.Text;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 인코딩/해싱 변환 핸들러. "encode " 프리픽스로 사용합니다.
/// 예: encode base64 hello → aGVsbG8=
/// encode decode base64 aGVsbG8= → hello
/// encode url https://example.com → URL 인코딩
/// encode hex hello → 68656c6c6f
/// encode md5 hello → 5d41402abc4b2a76b9719d911017c592
/// encode sha256 hello → 2cf24dba...
/// encode sha1 hello → aaf4c61d...
/// encode (빈 쿼리) → 지원 목록
/// </summary>
public class EncodeHandler : IActionHandler
{
public string? Prefix => "encode "; // 뒤에 공백 포함
public PluginMetadata Metadata => new(
"Encoder",
"인코딩/해싱 — encode base64/url/hex/md5/sha256 텍스트",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
if (string.IsNullOrWhiteSpace(q))
return Task.FromResult(HelpItems());
// "decode <타입> <값>" 파턴
if (q.StartsWith("decode ", StringComparison.OrdinalIgnoreCase))
{
var rest = q[7..].Trim();
return Task.FromResult(HandleDecode(rest));
}
// "<타입> <값>" 패턴
var spaceIdx = q.IndexOf(' ');
if (spaceIdx < 0)
return Task.FromResult(HelpItems());
var type = q[..spaceIdx].Trim().ToLowerInvariant();
var input = q[(spaceIdx + 1)..];
return Task.FromResult(HandleEncode(type, input));
}
private static IEnumerable<LauncherItem> HandleEncode(string type, string input)
{
try
{
return type switch
{
"base64" or "b64" => SingleResult("Base64 인코딩",
Convert.ToBase64String(Encoding.UTF8.GetBytes(input))),
"url" => SingleResult("URL 인코딩",
Uri.EscapeDataString(input)),
"hex" => SingleResult("HEX 인코딩",
Convert.ToHexString(Encoding.UTF8.GetBytes(input)).ToLowerInvariant()),
"md5" => SingleResult("MD5 해시",
MD5Hash(input)),
"sha1" => SingleResult("SHA-1 해시",
SHA1Hash(input)),
"sha256" => SingleResult("SHA-256 해시",
SHA256Hash(input)),
"sha512" => SingleResult("SHA-512 해시",
SHA512Hash(input)),
"html" => SingleResult("HTML 엔티티 인코딩",
System.Net.WebUtility.HtmlEncode(input)),
_ =>
[
new LauncherItem(
$"알 수 없는 타입: {type}",
"base64, url, hex, md5, sha1, sha256, sha512, html 중 선택",
null, null, Symbol: Symbols.Warning)
]
};
}
catch (Exception ex)
{
return
[
new LauncherItem("변환 오류", ex.Message, null, null, Symbol: Symbols.Error)
];
}
}
private static IEnumerable<LauncherItem> HandleDecode(string rest)
{
var spaceIdx = rest.IndexOf(' ');
if (spaceIdx < 0)
return [new LauncherItem("사용법: encode decode <타입> <값>", "타입: base64, url, hex, html", null, null, Symbol: Symbols.Info)];
var type = rest[..spaceIdx].Trim().ToLowerInvariant();
var input = rest[(spaceIdx + 1)..];
try
{
return type switch
{
"base64" or "b64" => SingleResult("Base64 디코딩",
Encoding.UTF8.GetString(Convert.FromBase64String(input))),
"url" => SingleResult("URL 디코딩",
Uri.UnescapeDataString(input)),
"hex" => SingleResult("HEX 디코딩",
Encoding.UTF8.GetString(Convert.FromHexString(input))),
"html" => SingleResult("HTML 엔티티 디코딩",
System.Net.WebUtility.HtmlDecode(input)),
_ =>
[
new LauncherItem(
$"디코딩 불가 타입: {type}",
"디코딩 지원: base64, url, hex, html",
null, null, Symbol: Symbols.Warning)
]
};
}
catch (Exception ex)
{
return
[
new LauncherItem("디코딩 오류", ex.Message, null, null, Symbol: Symbols.Error)
];
}
}
private static IEnumerable<LauncherItem> SingleResult(string title, string result)
{
var preview = result.Length > 80 ? result[..77] + "…" : result;
return
[
new LauncherItem(
title,
$"{preview} · Enter로 복사",
null,
result,
Symbol: Symbols.EncodeIcon)
];
}
private static IEnumerable<LauncherItem> HelpItems()
{
return
[
new LauncherItem("encode base64 <텍스트>", "Base64 인코딩/디코딩", null, null, Symbol: Symbols.EncodeIcon),
new LauncherItem("encode url <텍스트>", "URL 퍼센트 인코딩/디코딩", null, null, Symbol: Symbols.EncodeIcon),
new LauncherItem("encode hex <텍스트>", "16진수 인코딩/디코딩", null, null, Symbol: Symbols.EncodeIcon),
new LauncherItem("encode md5 <텍스트>", "MD5 해시 (단방향)", null, null, Symbol: Symbols.EncodeIcon),
new LauncherItem("encode sha256 <텍스트>", "SHA-256 해시 (단방향)", null, null, Symbol: Symbols.EncodeIcon),
new LauncherItem("encode html <텍스트>", "HTML 엔티티 인코딩/디코딩", null, null, Symbol: Symbols.EncodeIcon),
new LauncherItem("encode decode base64 <값>", "Base64 디코딩 예시", null, null, Symbol: Symbols.EncodeIcon),
];
}
// ─── 해시 헬퍼 ─────────────────────────────────────────────────────────────
private static string MD5Hash(string input)
{
var bytes = MD5.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
private static string SHA1Hash(string input)
{
var bytes = SHA1.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
private static string SHA256Hash(string input)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
private static string SHA512Hash(string input)
{
var bytes = SHA512.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is string text)
{
try { Clipboard.SetText(text); } catch (Exception) { }
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,100 @@
using AxCopilot.SDK;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 환경변수 조회 핸들러. "env" 프리픽스로 사용합니다.
/// 예: env → 자주 쓰는 환경변수 목록
/// env PATH → PATH 값 표시 (Enter로 복사)
/// env java → 이름에 "java" 포함된 환경변수 검색
/// </summary>
public class EnvHandler : IActionHandler
{
public string? Prefix => "env";
public PluginMetadata Metadata => new(
"EnvVars",
"환경변수 조회 — env 뒤에 변수명 입력",
"1.0",
"AX");
// 자주 쓰는 환경변수 우선 표시 순서
private static readonly string[] _priorityKeys =
[
"PATH", "JAVA_HOME", "PYTHON_HOME", "NODE_HOME", "GOPATH", "GOROOT",
"USERPROFILE", "APPDATA", "LOCALAPPDATA", "TEMP", "TMP",
"COMPUTERNAME", "USERNAME", "USERDOMAIN", "OS", "PROCESSOR_ARCHITECTURE",
"SYSTEMROOT", "WINDIR", "PROGRAMFILES", "PROGRAMFILES(X86)",
"COMMONPROGRAMFILES", "NUMBER_OF_PROCESSORS"
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var allVars = Environment.GetEnvironmentVariables()
.Cast<System.Collections.DictionaryEntry>()
.Select(e => (Key: e.Key?.ToString() ?? "", Value: e.Value?.ToString() ?? ""))
.Where(x => !string.IsNullOrEmpty(x.Key))
.ToList();
IEnumerable<(string Key, string Value)> filtered;
if (string.IsNullOrWhiteSpace(q))
{
// 우선순위 키를 앞에, 나머지 알파벳 순
var prioritySet = new HashSet<string>(_priorityKeys, StringComparer.OrdinalIgnoreCase);
var prioritized = _priorityKeys
.Where(k => allVars.Any(v => string.Equals(v.Key, k, StringComparison.OrdinalIgnoreCase)))
.Select(k => allVars.First(v => string.Equals(v.Key, k, StringComparison.OrdinalIgnoreCase)));
var rest = allVars
.Where(v => !prioritySet.Contains(v.Key))
.OrderBy(v => v.Key);
filtered = prioritized.Concat(rest).Take(20);
}
else
{
// 키 또는 값에 쿼리 포함 검색
filtered = allVars
.Where(v => v.Key.Contains(q, StringComparison.OrdinalIgnoreCase)
|| v.Value.Contains(q, StringComparison.OrdinalIgnoreCase))
.OrderBy(v => v.Key.StartsWith(q, StringComparison.OrdinalIgnoreCase) ? 0 : 1)
.ThenBy(v => v.Key)
.Take(20);
}
var items = filtered.Select(v =>
{
// PATH처럼 긴 값은 첫 경로만 미리보기
var preview = v.Value.Length > 80 ? v.Value[..77] + "…" : v.Value;
// PATH 변수는 세미콜론으로 분할하여 첫 항목만 표시
if (v.Key.Equals("PATH", StringComparison.OrdinalIgnoreCase) && v.Value.Contains(';'))
preview = v.Value.Split(';')[0] + $" (외 {v.Value.Split(';').Length - 1}개)";
return new LauncherItem(
v.Key,
$"{preview} · Enter로 값 복사",
null,
v.Value,
Symbol: Symbols.EnvVar);
}).ToList<LauncherItem>();
if (!items.Any())
items.Add(new LauncherItem(
$"'{q}' — 환경변수 없음",
"해당 이름의 환경변수를 찾을 수 없습니다",
null, null, Symbol: Symbols.Warning));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is string value)
{
try { System.Windows.Clipboard.SetText(value); } catch (Exception) { }
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,240 @@
using System.IO;
using System.Runtime.InteropServices;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// Everything SDK를 이용한 초고속 파일 검색 핸들러.
/// "es" 프리픽스로 사용합니다.
///
/// 예: es report → "report" 파일명 검색
/// es *.xlsx → 엑셀 파일 검색
/// es 회의록 → 한글 파일명 검색
///
/// Everything이 설치되어 있지 않으면 자동으로 비활성화됩니다.
/// </summary>
public class EverythingHandler : IActionHandler
{
public string? Prefix => "es";
public PluginMetadata Metadata => new(
"EverythingSearch",
"Everything 초고속 파일 검색 — es [키워드]",
"1.0",
"AX");
// ─── Everything SDK P/Invoke ─────────────────────────────────────────────
private const int EVERYTHING_OK = 0;
private const int EVERYTHING_REQUEST_FILE_NAME = 0x00000001;
private const int EVERYTHING_REQUEST_PATH = 0x00000002;
private const int EVERYTHING_REQUEST_SIZE = 0x00000010;
[DllImport("Everything64.dll", CharSet = CharSet.Unicode)]
private static extern uint Everything_SetSearchW(string lpSearchString);
[DllImport("Everything64.dll")]
private static extern void Everything_SetMax(uint dwMax);
[DllImport("Everything64.dll")]
private static extern void Everything_SetRequestFlags(uint dwRequestFlags);
[DllImport("Everything64.dll")]
private static extern bool Everything_QueryW(bool bWait);
[DllImport("Everything64.dll")]
private static extern uint Everything_GetNumResults();
[DllImport("Everything64.dll")]
private static extern uint Everything_GetLastError();
[DllImport("Everything64.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr Everything_GetResultFullPathNameW(uint nIndex, IntPtr lpString, uint nMaxCount);
[DllImport("Everything64.dll", CharSet = CharSet.Unicode)]
private static extern void Everything_GetResultSize(uint nIndex, out long lpFileSize);
[DllImport("Everything64.dll")]
private static extern uint Everything_GetMajorVersion();
// ─── 상태 ────────────────────────────────────────────────────────────────
private bool? _isAvailable;
private bool IsAvailable
{
get
{
if (_isAvailable.HasValue) return _isAvailable.Value;
try
{
Everything_GetMajorVersion();
_isAvailable = true;
}
catch (Exception)
{
_isAvailable = false;
}
return _isAvailable.Value;
}
}
// ─── IActionHandler ──────────────────────────────────────────────────────
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
if (!IsAvailable)
{
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem(
"Everything이 설치되어 있지 않습니다",
"voidtools.com에서 Everything을 설치하면 초고속 파일 검색을 사용할 수 있습니다",
null, null, Symbol: Symbols.Warning)
});
}
var q = query.Trim();
if (string.IsNullOrWhiteSpace(q))
{
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem(
"Everything 파일 검색",
"검색어를 입력하세요 — 파일명, 확장자(*.xlsx), 경로 일부 등",
null, null, Symbol: Symbols.Search)
});
}
try
{
Everything_SetSearchW(q);
Everything_SetMax(30);
Everything_SetRequestFlags(EVERYTHING_REQUEST_FILE_NAME | EVERYTHING_REQUEST_PATH | EVERYTHING_REQUEST_SIZE);
if (!Everything_QueryW(true))
{
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem(
"Everything 검색 실패",
$"오류 코드: {Everything_GetLastError()} — Everything 서비스가 실행 중인지 확인하세요",
null, null, Symbol: Symbols.Warning)
});
}
var count = Everything_GetNumResults();
if (count == 0)
{
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem(
$"검색 결과 없음: {q}",
"다른 키워드나 와일드카드(*.xlsx)를 시도해 보세요",
null, null, Symbol: Symbols.Info)
});
}
var items = new List<LauncherItem>();
var buffer = Marshal.AllocHGlobal(520 * 2); // MAX_PATH * sizeof(wchar_t)
try
{
for (uint i = 0; i < count && i < 30; i++)
{
Everything_GetResultFullPathNameW(i, buffer, 520);
var fullPath = Marshal.PtrToStringUni(buffer) ?? "";
Everything_GetResultSize(i, out var fileSize);
var fileName = Path.GetFileName(fullPath);
var dirPath = Path.GetDirectoryName(fullPath) ?? "";
var sizeStr = fileSize > 0 ? FormatSize(fileSize) : "";
var subtitle = string.IsNullOrEmpty(sizeStr) ? dirPath : $"{sizeStr} · {dirPath}";
var symbol = Directory.Exists(fullPath) ? Symbols.Folder : GetFileSymbol(fullPath);
items.Add(new LauncherItem(fileName, subtitle, null, fullPath, Symbol: symbol));
}
}
finally
{
Marshal.FreeHGlobal(buffer);
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
catch (Exception ex)
{
LogService.Warn($"Everything 검색 오류: {ex.Message}");
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem("Everything 검색 오류", ex.Message, null, null, Symbol: Symbols.Warning)
});
}
}
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is not string path || string.IsNullOrEmpty(path)) return;
try
{
if (Directory.Exists(path))
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = path,
UseShellExecute = true,
});
}
else if (File.Exists(path))
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = path,
UseShellExecute = true,
});
}
}
catch (Exception ex)
{
LogService.Warn($"Everything 결과 열기 실패: {ex.Message}");
}
await Task.CompletedTask;
}
// ─── 헬퍼 ────────────────────────────────────────────────────────────────
private static string FormatSize(long bytes)
{
if (bytes < 1024) return $"{bytes} B";
if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB";
if (bytes < 1024 * 1024 * 1024) return $"{bytes / (1024.0 * 1024):F1} MB";
return $"{bytes / (1024.0 * 1024 * 1024):F2} GB";
}
private static string GetFileSymbol(string path)
{
var ext = Path.GetExtension(path).ToLowerInvariant();
return ext switch
{
".xlsx" or ".xls" or ".csv" => Symbols.Excel,
".docx" or ".doc" => Symbols.Word,
".pptx" or ".ppt" => Symbols.Slides,
".pdf" => Symbols.Pdf,
".png" or ".jpg" or ".jpeg" or ".gif" or ".bmp" or ".svg" => Symbols.Image,
".mp4" or ".avi" or ".mkv" or ".mov" => Symbols.Video,
".mp3" or ".wav" or ".flac" => Symbols.Music,
".zip" or ".rar" or ".7z" or ".tar" or ".gz" => Symbols.Archive,
".exe" or ".msi" => Symbols.App,
".cs" or ".py" or ".js" or ".ts" or ".java" or ".cpp" or ".c" or ".go" => Symbols.Code,
".json" or ".xml" or ".yaml" or ".yml" => Symbols.Config,
".txt" or ".md" or ".log" => Symbols.TextFile,
".html" or ".htm" or ".css" => Symbols.Web,
_ => Symbols.File,
};
}
}

View File

@@ -0,0 +1,204 @@
using System.Diagnostics;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 즐겨찾기(파일/폴더/경로) 핸들러. "fav" 프리픽스로 사용합니다.
/// 자주 사용하는 로컬 파일·폴더 경로를 등록하고 빠르게 실행합니다.
/// 예: fav → 전체 즐겨찾기 목록
/// fav 보고서 → "보고서" 포함 항목 필터
/// fav add 보고서 C:\work\report.xlsx → 즐겨찾기 추가
/// fav del 보고서 → 즐겨찾기 삭제
/// Enter → 파일/폴더 열기.
/// </summary>
public class FavoriteHandler : IActionHandler
{
public string? Prefix => "fav";
public PluginMetadata Metadata => new(
"Favorite",
"즐겨찾기 관리 — fav",
"1.0",
"AX");
private static readonly string FavFile = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "favorites.json");
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = true,
PropertyNameCaseInsensitive = true
};
private List<FavEntry> _cache = new();
private bool _loaded;
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
EnsureLoaded();
var q = query.Trim();
// add 명령
if (q.StartsWith("add ", StringComparison.OrdinalIgnoreCase))
{
var rest = q[4..].Trim();
var spaceIdx = rest.IndexOf(' ');
if (spaceIdx > 0)
{
var name = rest[..spaceIdx].Trim();
var path = rest[(spaceIdx + 1)..].Trim();
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
$"즐겨찾기 추가: {name}",
$"경로: {path} · Enter로 추가",
null, ValueTuple.Create("__ADD__", name, path),
Symbol: Symbols.Save)
]);
}
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
"사용법: fav add [이름] []",
"예: fav add 보고서 C:\\work\\report.xlsx",
null, null, Symbol: Symbols.Info)
]);
}
// del 명령
if (q.StartsWith("del ", StringComparison.OrdinalIgnoreCase))
{
var name = q[4..].Trim();
var found = _cache.FirstOrDefault(b =>
b.Name.Contains(name, StringComparison.OrdinalIgnoreCase));
if (found != null)
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
$"즐겨찾기 삭제: {found.Name}",
$"경로: {found.Path} · Enter로 삭제",
null, ValueTuple.Create("__DEL__", found.Name),
Symbol: Symbols.Delete)
]);
}
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem($"'{name}'에 해당하는 즐겨찾기 없음", "fav으로 전체 목록 확인",
null, null, Symbol: Symbols.Warning)
]);
}
// 목록/검색
var filtered = string.IsNullOrWhiteSpace(q)
? _cache
: _cache.Where(b =>
b.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
b.Path.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
if (!filtered.Any())
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
_cache.Count == 0 ? "즐겨찾기가 없습니다" : $"'{q}'에 해당하는 즐겨찾기 없음",
"fav add [이름] [] ",
null, null, Symbol: Symbols.Info)
]);
}
var items = filtered.Select(b =>
{
var isDir = Directory.Exists(b.Path);
var isFile = File.Exists(b.Path);
var symbol = isDir ? Symbols.Folder : isFile ? Symbols.File : Symbols.Warning;
var hint = isDir ? "폴더 열기" : isFile ? "파일 열기" : "경로를 찾을 수 없음";
return new LauncherItem(b.Name, $"{b.Path} · {hint}", null, b.Path, Symbol: symbol);
}).ToList<LauncherItem>();
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ValueTuple<string, string, string> add && add.Item1 == "__ADD__")
{
AddFav(add.Item2, add.Item3);
NotificationService.Notify("AX Copilot", $"즐겨찾기 추가: {add.Item2}");
return Task.CompletedTask;
}
if (item.Data is ValueTuple<string, string> del && del.Item1 == "__DEL__")
{
RemoveFav(del.Item2);
NotificationService.Notify("AX Copilot", $"즐겨찾기 삭제: {del.Item2}");
return Task.CompletedTask;
}
if (item.Data is string path)
{
if (Directory.Exists(path) || File.Exists(path))
{
try { Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); }
catch (Exception ex) { LogService.Warn($"즐겨찾기 열기 실패: {ex.Message}"); }
}
else
{
try { Application.Current?.Dispatcher.Invoke(() => Clipboard.SetText(path)); }
catch (Exception) { }
}
}
return Task.CompletedTask;
}
private void EnsureLoaded()
{
if (_loaded) return;
_loaded = true;
try
{
if (!File.Exists(FavFile)) return;
_cache = JsonSerializer.Deserialize<List<FavEntry>>(File.ReadAllText(FavFile), JsonOpts) ?? new();
}
catch (Exception ex) { LogService.Warn($"즐겨찾기 로드 실패: {ex.Message}"); }
}
private void AddFav(string name, string path)
{
EnsureLoaded();
_cache.RemoveAll(b => b.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
_cache.Insert(0, new FavEntry { Name = name, Path = path });
Save();
}
private void RemoveFav(string name)
{
EnsureLoaded();
_cache.RemoveAll(b => b.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
Save();
}
private void Save()
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(FavFile)!);
File.WriteAllText(FavFile, JsonSerializer.Serialize(_cache, JsonOpts));
}
catch (Exception ex) { LogService.Warn($"즐겨찾기 저장 실패: {ex.Message}"); }
}
private class FavEntry
{
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("path")] public string Path { get; set; } = "";
}
}

View File

@@ -0,0 +1,443 @@
using System.Windows;
using System.Windows.Media;
using AxCopilot.SDK;
using AxCopilot.Themes;
using AxCopilot.Views;
namespace AxCopilot.Handlers;
/// <summary>
/// 간편 사용 설명서 핸들러. "help" 프리픽스로 사용합니다.
/// 예: help → 전체 명령어 카드 목록
/// help 계산 → "계산" 관련 명령어 필터
/// help clip → 클립보드 관련 명령어 필터
/// Enter 시 해당 예시를 클립보드에 복사합니다.
/// </summary>
public class HelpHandler : IActionHandler
{
private readonly AxCopilot.Services.SettingsService? _settings;
public HelpHandler(AxCopilot.Services.SettingsService? settings = null)
{
_settings = settings;
}
public string? Prefix => "help";
public PluginMetadata Metadata => new(
"Help",
"AX Commander 사용 설명서",
"1.0",
"AX");
/// <summary>설정에서 변경된 프리픽스와 핫키를 실시간 반영한 항목 목록을 반환합니다.</summary>
private HelpEntry[] GetEntries()
{
var capPrefix = _settings?.Settings.ScreenCapture.Prefix ?? "cap";
if (string.IsNullOrWhiteSpace(capPrefix)) capPrefix = "cap";
var hotkey = _settings?.Settings.Hotkey ?? "Alt+Space";
var entries = (HelpEntry[])_baseEntries.Clone();
for (int i = 0; i < entries.Length; i++)
{
// cap 프리픽스 동적 반영
if (entries[i].Title == "스크린샷 캡처 (예약어 변경 가능)")
{
entries[i] = entries[i] with
{
Command = capPrefix,
Example = $"{capPrefix} screen / {capPrefix} window / {capPrefix} region / {capPrefix} scroll / (설정 → 캡처 탭)"
};
}
// 글로벌 핫키 동적 반영
if (entries[i].Title == "AX Commander 열기 / 닫기")
{
entries[i] = entries[i] with
{
Command = hotkey,
Example = "설정 → 핫키에서 변경 가능"
};
}
}
return entries;
}
// ─── 명령어 카드 데이터 ────────────────────────────────────────────────────
// (카테고리, 예약어/명령, 제목, 설명, 예시, 심볼, 색)
private static readonly HelpEntry[] _baseEntries =
[
// ── 검색 ────────────────────────────────────────────────────────────────
new("검색", "", "앱 · 파일 · 북마크 퍼지 검색",
"앱 이름, 파일명, 한국어 초성, Chrome·Edge 북마크를 통합 검색",
"chrome / 보고서 / ㅅㄷ (설정) / 북마크 제목",
Symbols.Search, "#0078D4"),
// ── 계산 & 변환 ──────────────────────────────────────────────────────────
new("계산", "=", "계산기",
"수식 계산 · 단위 변환 · 실시간 환율",
"= sqrt(144) / = 100 USD to KRW / = 15km to miles",
Symbols.Calculator, "#4B5EFC"),
// ── 웹 검색 ──────────────────────────────────────────────────────────────
new("검색", "?", "웹 검색 (10개 엔진)",
"?n 네이버 · ?g 구글 · ?y 유튜브 · ?gh 깃허브 · ?d DuckDuckGo · ?w 위키피디아 · ?nw 나무위키 · ?nm 네이버지도 · ?ni 네이버이미지 · ?gi 구글이미지",
"? 오늘 날씨 / ?n 뉴스 / ?g python / ?nw 검색어 / ?y 음악 / ?gh axios",
Symbols.Globe, "#006EAF"),
// ── 클립보드 ─────────────────────────────────────────────────────────────
new("클립보드", "#", "클립보드 히스토리",
"복사 이력 검색 & 재사용 · Shift+↑↓로 여러 항목 병합",
"# / # 회의록 / (Shift+↑↓ 선택 후 Shift+Enter 병합)",
Symbols.History, "#B7791F"),
new("클립보드", "$", "클립보드 텍스트 변환 (12종)",
"현재 클립보드 내용을 즉시 변환",
"$json / $b64e / $upper / $url / $md5 / $trim",
Symbols.Clipboard, "#8764B8"),
// ── 텍스트 스니펫 ────────────────────────────────────────────────────────
new("텍스트", ";", "텍스트 스니펫",
"저장된 템플릿 불러오기 · 변수 치환 지원",
";addr / ;sig / ;greet",
Symbols.Snippet, "#0F6CBD"),
// ── 단축키 ───────────────────────────────────────────────────────────────
new("단축키", "@", "URL 단축키",
"저장해둔 URL을 키워드로 바로 열기",
"@gh / @notion / @jira",
Symbols.Globe, "#0078D4"),
new("단축키", "cd", "폴더 단축키",
"저장해둔 폴더를 키워드로 바로 열기",
"cd dl / cd work / cd desktop",
Symbols.Folder, "#107C10"),
new("단축키", ">", "터미널 명령 실행",
"PowerShell 명령을 AX Commander에서 직접 실행",
"> git status / > ipconfig / > cls",
Symbols.Terminal, "#323130"),
// ── 워크스페이스 ──────────────────────────────────────────────────────────
new("창관리", "~", "워크스페이스 저장·복원",
"현재 창 배치를 스냅샷으로 저장하고 언제든 복원",
"~save 업무 / ~restore 업무 / ~list",
Symbols.Workspace, "#C50F1F"),
new("창관리", "snap", "창 분할 레이아웃",
"창을 화면의 특정 영역에 즉시 스냅",
"snap left / snap right / snap tl / snap full",
Symbols.SnapLayout, "#B45309"),
new("창관리", "cap", "스크린샷 캡처 (예약어 변경 가능)",
"영역 선택 · 활성 창 · 스크롤 · 전체 화면. Shift+Enter로 지연 캡처(3/5/10초 타이머). 결과는 클립보드에 복사. 글로벌 단축키(PrintScreen 등)로 바로 캡처 가능. 설정 → 캡처 탭에서 예약어·단축키·스크롤 속도 변경",
"cap region / cap window / cap scroll / cap screen / Shift+Enter → 지연 캡처",
Symbols.CaptureIcon, "#BE185D"),
// ── 시스템 명령 ───────────────────────────────────────────────────────────
new("시스템", "/", "시스템 명령",
"잠금·절전·재시작·종료·타이머·알람",
"/lock / /sleep / /shutdown / /timer 5m / /alarm 14:30",
Symbols.Power, "#4A4A4A"),
new("시스템", "info · *", "시스템 정보",
"IP · 배터리 · 볼륨 · 가동시간 · CPU · 디스크. `*` 입력으로도 동일하게 사용 가능",
"info / info ip / info battery / * / * ip",
Symbols.Computer, "#5B4E7E"),
new("알림", "", "잠금 해제 사용시간 알림",
"PC 잠금 해제 시 오늘 누적 사용 시간과 격려 문구·명언 팝업 표시. 설정 → 알림 탭에서 활성화",
"설정 → 알림 탭: 활성화 토글 / 표시 위치(4방향) / 표시 간격(30분~4시간) / 자동 닫힘(5초~3분)",
Symbols.ReminderBell, "#EA8F00"),
new("시스템", "kill", "프로세스 종료",
"프로세스 이름으로 검색 후 강제 종료",
"kill chrome / kill node / kill teams",
Symbols.Error, "#CC2222"),
new("시스템", "media", "미디어 제어",
"재생·일시정지·이전·다음·볼륨 조절",
"media play / media next / media vol+ / media mute",
Symbols.MediaPlay, "#1A6B3C"),
// ── 개발자 도구 ───────────────────────────────────────────────────────────
new("개발", "json", "JSON 포맷 · 검증",
"클립보드의 JSON을 정렬·압축·유효성 검사",
"json → format / minify / validate",
Symbols.JsonValid, "#D97706"),
new("개발", "encode", "인코딩 · 해싱",
"Base64 · URL · HTML · UTF-8 · MD5 · SHA256",
"encode base64 / encode url / encode sha256",
Symbols.EncodeIcon, "#6366F1"),
new("개발", "color", "색상 변환",
"HEX ↔ RGB ↔ HSL ↔ HSV 변환 및 색상명 지원",
"color #FF5733 / color 255,87,51 / color red",
Symbols.ColorPicker, "#EC4899"),
new("개발", "port", "포트 · 프로세스 조회",
"포트 번호로 점유 프로세스 확인",
"port 3000 / port 8080 / port 443",
Symbols.PortIcon, "#006699"),
new("개발", "env", "환경변수 조회",
"시스템 환경변수 검색 및 클립보드 복사",
"env / env PATH / env JAVA",
Symbols.EnvVar, "#0D9488"),
// ── 앱 관리 ───────────────────────────────────────────────────────────────
new("앱", "emoji", "이모지 피커",
"300개+ 이모지 검색 · Enter로 클립보드 복사",
"emoji / emoji 하트 / emoji wave / emoji 불꽃",
Symbols.Emoji, "#F59E0B"),
new("앱", "recent", "최근 파일",
"Windows 최근 파일 목록 검색 & 바로 열기",
"recent / recent 보고서 / recent xlsx",
Symbols.RecentFiles, "#059669"),
new("앱", "note", "빠른 메모",
"간단한 메모를 저장하고 불러오기",
"note 내일 회의 10시 / note",
Symbols.Note, "#7C3AED"),
new("앱", "uninstall","앱 제거",
"설치된 앱 검색 후 제거",
"uninstall / uninstall kakao / uninstall zoom",
Symbols.Uninstall, "#DC2626"),
// ── 유틸리티 ────────────────────────────────────────────────────────────
new("유틸", "pick", "스포이드 색상 추출",
"화면 아무 곳을 클릭하여 HEX 색상 코드 추출 · 돋보기로 실시간 미리보기 · 결과 반투명 창 5초 표시",
"pick → 스포이드 모드 진입 → 클릭으로 색상 추출",
Symbols.ColorPicker, "#EC4899"),
new("유틸", "date", "날짜 계산 · D-day · 타임스탬프",
"날짜 가감(+30d), D-day 계산, Unix ↔ 날짜 변환, 요일·ISO 주차 조회",
"date / date +30d / date 2026-12-25 / date 1711584000 / date unix",
Symbols.DateIcon, "#0EA5E9"),
new("시스템", "svc", "서비스 관리",
"Windows 서비스 검색·시작·중지·재시작 + AX 클립보드 서비스 강제 재시작",
"svc / svc spooler / svc restart clipboard",
Symbols.ServiceIcon, "#6366F1"),
new("유틸", "pipe", "클립보드 파이프라인",
"변환을 > 로 체이닝: 대문자→공백제거→Base64 등 19종 필터 한 번에 적용",
"pipe upper > trim > b64e / pipe sort > unique > number",
Symbols.PipeIcon, "#8B5CF6"),
new("유틸", "journal", "업무 일지 자동 생성",
"오늘 사용한 앱·명령어·활성 시간을 마크다운 요약으로 자동 생성. 스탠드업/일일 보고에 바로 사용",
"journal / journal 2026-03-25",
Symbols.JournalIcon, "#0EA5E9"),
new("유틸", "routine", "루틴 자동화",
"등록된 루틴(앱·폴더·URL 조합)을 한 번에 순서대로 실행. 출근/퇴근/회의 전 세팅",
"routine / routine morning / routine endofday",
Symbols.RoutineIcon, "#F59E0B"),
new("유틸", "batch", "텍스트 일괄 처리",
"클립보드 각 줄에 동시 적용: 접두사·접미사·줄번호·정렬·중복제거·따옴표·치환·CSV 변환",
"batch number / batch prefix >> / batch sort / batch replace A B",
Symbols.BatchIcon, "#10B981"),
new("유틸", "diff", "텍스트/파일 비교",
"클립보드 최근 2개 텍스트 또는 파일 2개를 줄 단위 비교. 추가·삭제·동일 줄 하이라이트",
"diff / diff C:\\a.txt C:\\b.txt",
Symbols.DiffIcon, "#EF4444"),
new("유틸", "win", "윈도우 포커스 스위처",
"열린 창을 타이틀·프로세스명으로 검색하여 Alt+Tab 없이 즉시 전환",
"win / win chrome / win 보고서",
Symbols.WindowIcon, "#6366F1"),
new("시스템", "^", "Windows 실행 명령",
"Win+R 실행 창과 동일하게 명령어 실행. notepad, calc, cmd, control, mstsc 등 모든 Windows 실행 명령 지원",
"^ notepad / ^ cmd / ^ calc / ^ control / ^ mspaint",
Symbols.LaunchIcon, "#E08850"),
new("유틸", "stats", "텍스트 통계 분석",
"클립보드 텍스트 글자·단어·줄 수, 키워드 빈도, 읽기 시간 추정",
"stats / stats 키워드 (클립보드 텍스트 분석)",
Symbols.TextStats, "#6366F1"),
new("유틸", "fav", "즐겨찾기 (파일·폴더)",
"자주 쓰는 경로를 등록하고 빠르게 열기",
"fav / fav add 보고서 C:\\work\\report.xlsx / fav del 보고서",
Symbols.Favorite, "#F59E0B"),
new("유틸", "rename", "파일 일괄 이름변경",
"폴더 내 파일을 패턴·변수로 일괄 이름변경. {n}순번 {date}날짜 {orig}원본명",
"rename C:\\work\\*.xlsx 보고서_{n}",
Symbols.RenameIcon, "#8B5CF6"),
new("유틸", "monitor", "시스템 리소스 모니터",
"CPU·메모리·디스크·프로세스 실시간 현황 조회",
"monitor / monitor cpu / monitor mem / monitor disk",
Symbols.MonitorIcon, "#10B981"),
new("유틸", "scaffold", "프로젝트 스캐폴딩",
"내장/사용자 템플릿으로 프로젝트 폴더 구조 일괄 생성",
"scaffold / scaffold webapi / scaffold C:\\new-project webapi",
Symbols.ScaffoldIcon,"#0EA5E9"),
// ── 단축키 ───────────────────────────────────────────────────────────────
new("키보드", "Alt+Space", "AX Commander 열기 / 닫기",
"어디서든 AX Commander를 토글",
"설정 → 핫키에서 변경 가능",
Symbols.Info, "#6B7280"),
new("키보드", "Enter", "실행",
"선택된 항목 실행",
"",
Symbols.Info, "#6B7280"),
new("키보드", "Shift+Enter", "Large Type / 병합 실행 / 지연 캡처",
"텍스트를 전체화면으로 표시 · 클립보드 항목 병합 · 캡처 모드에서는 지연 캡처(3초/5초/10초) 타이머 선택",
"(#모드) Shift+↑↓ 선택 후 Shift+Enter 병합 / (cap모드) Shift+Enter → 타이머 선택",
Symbols.Info, "#6B7280"),
new("키보드", "Tab", "자동완성",
"선택 항목으로 입력 자동완성",
"",
Symbols.Info, "#6B7280"),
new("키보드", "→ (커서 끝)", "파일 액션 메뉴",
"앱·파일 선택 후 → 키: 경로복사 · 탐색기 · 관리자 · 터미널",
"",
Symbols.Info, "#6B7280"),
new("키보드", "Ctrl+,", "설정 열기",
"AX Copilot 설정 창",
"",
Symbols.Info, "#6B7280"),
];
// ─── GetItemsAsync ─────────────────────────────────────────────────────────
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var allEntries = GetEntries();
// 쿼리 없이 "help"만 입력한 경우: 전체 기능 창 여는 항목 하나만 표시
if (string.IsNullOrEmpty(q))
{
var cmdCount = allEntries.Count(e => e.Category != "키보드");
var keyCount = allEntries.Count(e => e.Category == "키보드");
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
"AX Commander — 전체 기능 목록 보기",
$"총 {cmdCount}개 명령어 · {keyCount}개 단축키 · Enter → 기능 설명 창 열기",
null, "__HELP_OVERVIEW__",
Symbol: "\uE946")
]);
}
// "help 검색어" 형태: 일치하는 항목 리스트 표시
var filtered = allEntries.Where(e =>
e.Category.Contains(q, StringComparison.OrdinalIgnoreCase) ||
e.Command.Contains(q, StringComparison.OrdinalIgnoreCase) ||
e.Title.Contains(q, StringComparison.OrdinalIgnoreCase) ||
e.Description.Contains(q, StringComparison.OrdinalIgnoreCase));
var items = filtered
.Select(e => new LauncherItem(
FormatTitle(e),
FormatSubtitle(e),
null,
e.Example,
Symbol: e.Symbol))
.ToList<LauncherItem>();
if (items.Count == 0)
{
items.Add(new LauncherItem(
$"'{q}'에 해당하는 명령어가 없습니다",
"help만 입력하면 전체 기능 창을 열 수 있어요",
null, null,
Symbol: Symbols.Info));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ─── ExecuteAsync ──────────────────────────────────────────────────────────
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is not string s) return Task.CompletedTask;
if (s == "__HELP_OVERVIEW__")
{
// 전체 기능 창 열기
Application.Current.Dispatcher.Invoke(() =>
{
var currentEntries = GetEntries();
var models = currentEntries.Select(e => new HelpItemModel
{
Category = e.Category,
Command = string.IsNullOrEmpty(e.Command) ? "(퍼지 검색)" : e.Command,
Title = e.Title,
Description = e.Description,
Example = e.Example,
Symbol = e.Symbol,
ColorBrush = ParseColor(e.ColorHex)
});
var globalHotkey = _settings?.Settings.Hotkey ?? "Alt+Space";
new HelpDetailWindow(models, currentEntries.Count(e => e.Category != "키보드"), globalHotkey).Show();
});
return Task.CompletedTask;
}
// 일반 항목: 예시를 클립보드에 복사
if (!string.IsNullOrWhiteSpace(s))
{
try { Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(s)); }
catch (Exception) { /* 무시 */ }
}
return Task.CompletedTask;
}
private static SolidColorBrush ParseColor(string hex)
{
try
{
var color = (System.Windows.Media.Color)
System.Windows.Media.ColorConverter.ConvertFromString(hex);
return new SolidColorBrush(color);
}
catch (Exception) { return new SolidColorBrush(System.Windows.Media.Colors.Gray); }
}
// ─── 헬퍼 ─────────────────────────────────────────────────────────────────
private static string FormatTitle(HelpEntry e)
{
var prefix = string.IsNullOrEmpty(e.Command)
? $"[{e.Category}]"
: $"[{e.Category}] {e.Command}";
return $"{prefix} — {e.Title}";
}
private static string FormatSubtitle(HelpEntry e)
{
var parts = new List<string> { e.Description };
if (!string.IsNullOrEmpty(e.Example))
parts.Add($"예) {e.Example}");
return string.Join(" · ", parts);
}
// ─── 데이터 모델 ───────────────────────────────────────────────────────────
private record HelpEntry(
string Category,
string Command,
string Title,
string Description,
string Example,
string Symbol,
string ColorHex);
}

View File

@@ -0,0 +1,132 @@
using System.IO;
using System.Text;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 업무 일지 자동 생성 핸들러. "journal" 프리픽스로 사용합니다.
/// UsageStatisticsService 데이터를 기반으로 오늘/지정일의 업무 요약을 자동 생성합니다.
/// 예: journal → 오늘의 업무 일지 생성
/// journal 2026-03-25 → 해당 날짜 일지
/// Enter → 마크다운 형식으로 클립보드에 복사.
/// </summary>
public class JournalHandler : IActionHandler
{
public string? Prefix => "journal";
public PluginMetadata Metadata => new(
"Journal",
"업무 일지 자동 생성 — journal",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
DateTime targetDate;
if (string.IsNullOrWhiteSpace(q))
targetDate = DateTime.Today;
else if (DateTime.TryParse(q, out var parsed))
targetDate = parsed.Date;
else
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem("날짜 형식 오류", "예: journal 2026-03-25 또는 journal (오늘)",
null, null, Symbol: Symbols.Warning)
]);
}
// 날짜 범위 계산 (오늘~targetDate 간 차이)
var daysAgo = (DateTime.Today - targetDate).Days;
var allStats = UsageStatisticsService.GetStats(Math.Max(daysAgo + 1, 1));
var stats = allStats.FirstOrDefault(s => s.Date == targetDate.ToString("yyyy-MM-dd"));
var items = new List<LauncherItem>();
if (stats == null)
{
items.Add(new LauncherItem(
$"{targetDate:yyyy-MM-dd} — 기록 없음",
"해당 날짜의 사용 기록이 없습니다",
null, null, Symbol: Symbols.Info));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 요약 생성
var activeHours = stats.ActiveSeconds / 3600.0;
var sb = new StringBuilder();
sb.AppendLine($"## 업무 일지 — {targetDate:yyyy-MM-dd} ({targetDate:dddd})");
sb.AppendLine();
sb.AppendLine($"- **PC 활성 시간**: {activeHours:F1}시간");
sb.AppendLine($"- **런처 호출**: {stats.LauncherOpens}회");
if (stats.CommandUsage.Count > 0)
{
sb.AppendLine();
sb.AppendLine("### 사용한 명령어");
foreach (var kv in stats.CommandUsage.OrderByDescending(x => x.Value).Take(10))
sb.AppendLine($"- `{kv.Key}` — {kv.Value}회");
}
sb.AppendLine();
sb.AppendLine("---");
sb.AppendLine($"*AX Copilot 자동 생성 · {DateTime.Now:HH:mm}*");
var journal = sb.ToString();
// 요약 카드
var topCmds = stats.CommandUsage.OrderByDescending(x => x.Value).Take(3)
.Select(x => x.Key);
var cmdPreview = stats.CommandUsage.Count > 0
? $"주요 명령: {string.Join(", ", topCmds)}"
: "명령어 사용 기록 없음";
items.Add(new LauncherItem(
$"{targetDate:yyyy-MM-dd} 업무 일지 — 클립보드로 복사",
$"활성 {activeHours:F1}h · 런처 {stats.LauncherOpens}회 · {cmdPreview}",
null, journal,
Symbol: Symbols.Note));
items.Add(new LauncherItem(
$"PC 활성 시간: {activeHours:F1}시간",
"잠금 해제 시간 기준 누적",
null, $"PC 활성 시간: {activeHours:F1}시간",
Symbol: Symbols.Clock));
items.Add(new LauncherItem(
$"런처 호출: {stats.LauncherOpens}회",
"Alt+Space 또는 트레이 클릭",
null, $"런처 호출: {stats.LauncherOpens}회",
Symbol: Symbols.Search));
if (stats.CommandUsage.Count > 0)
{
foreach (var kv in stats.CommandUsage.OrderByDescending(x => x.Value).Take(5))
{
items.Add(new LauncherItem(
$"{kv.Key} — {kv.Value}회",
"Enter로 복사",
null, $"{kv.Key}: {kv.Value}회",
Symbol: Symbols.Terminal));
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is string text && !string.IsNullOrWhiteSpace(text))
{
try { Application.Current?.Dispatcher.Invoke(() => Clipboard.SetText(text)); }
catch (Exception) { }
NotificationService.Notify("업무 일지", "클립보드에 복사되었습니다");
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,132 @@
using System.Text.Json;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// JSON 검증/포맷 핸들러. "json" 프리픽스로 사용합니다.
/// 예: json → 클립보드의 JSON을 파싱하여 미리보기 표시
/// json {"a":1} → 인라인 JSON 파싱 + 미리보기
/// json minify → 클립보드 JSON 미니파이 → 클립보드 복사
/// json format → 클립보드 JSON 예쁘게 포맷 → 클립보드 복사
/// </summary>
public class JsonHandler : IActionHandler
{
public string? Prefix => "json";
public PluginMetadata Metadata => new(
"JsonFormatter",
"JSON 검증/포맷 — json 뒤에 내용 또는 명령 입력",
"1.0",
"AX");
private static readonly JsonSerializerOptions _prettyOpts = new() { WriteIndented = true };
private static readonly JsonSerializerOptions _compactOpts = new() { WriteIndented = false };
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
// ─── 빈 쿼리 or format/minify: 클립보드에서 JSON 읽기 ─────────────────
if (string.IsNullOrWhiteSpace(q) || q.Equals("format", StringComparison.OrdinalIgnoreCase)
|| q.Equals("minify", StringComparison.OrdinalIgnoreCase)
|| q.Equals("min", StringComparison.OrdinalIgnoreCase))
{
string clipText = "";
try { clipText = Clipboard.GetText(); } catch (Exception) { }
if (string.IsNullOrWhiteSpace(clipText))
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
"클립보드가 비어 있습니다",
"클립보드에 JSON 텍스트를 복사한 뒤 실행하세요",
null, null, Symbol: Symbols.Clipboard)
]);
return Task.FromResult(BuildItems(clipText,
isMinify: q.StartsWith("min", StringComparison.OrdinalIgnoreCase)));
}
// ─── 인라인 JSON ──────────────────────────────────────────────────────
return Task.FromResult(BuildItems(q, isMinify: false));
}
private static IEnumerable<LauncherItem> BuildItems(string input, bool isMinify)
{
try
{
using var doc = JsonDocument.Parse(input, new JsonDocumentOptions
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip
});
// 포맷된 버전
var pretty = JsonSerializer.Serialize(doc.RootElement, _prettyOpts);
var compact = JsonSerializer.Serialize(doc.RootElement, _compactOpts);
// 루트 타입 정보
var rootType = doc.RootElement.ValueKind switch
{
JsonValueKind.Object => $"Object ({doc.RootElement.EnumerateObject().Count()}개 키)",
JsonValueKind.Array => $"Array ({doc.RootElement.GetArrayLength()}개 항목)",
_ => doc.RootElement.ValueKind.ToString()
};
var targetText = isMinify ? compact : pretty;
var actionLabel = isMinify ? "미니파이" : "포맷";
// 미리보기 (처음 100자)
var preview = compact.Length > 100 ? compact[..97] + "…" : compact;
return
[
new LauncherItem(
$"✅ 유효한 JSON — {rootType}",
$"{preview} · Enter로 {actionLabel} 결과 클립보드 복사",
null,
targetText,
Symbol: Symbols.JsonValid),
new LauncherItem(
"포맷 (Pretty Print) 복사",
$"{pretty.Length}자 · 들여쓰기 2스페이스",
null,
pretty,
Symbol: Symbols.JsonFormat),
new LauncherItem(
"미니파이 (Minify) 복사",
$"{compact.Length}자 · 공백 제거",
null,
compact,
Symbol: Symbols.JsonMinify),
];
}
catch (JsonException ex)
{
var msg = ex.Message.Length > 100 ? ex.Message[..97] + "…" : ex.Message;
return
[
new LauncherItem(
"❌ JSON 오류",
msg,
null,
null,
Symbol: Symbols.Error)
];
}
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is string text)
{
try { Clipboard.SetText(text); } catch (Exception) { }
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,350 @@
using System.IO;
using System.Net.Http;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// .skill.json 파일을 읽어 IActionHandler로 변환하는 로더.
/// JSON 스킬은 외부 API를 호출하는 동적 핸들러를 코드 없이 정의합니다.
/// </summary>
public static class JsonSkillLoader
{
public static IActionHandler? Load(string filePath)
{
var json = File.ReadAllText(filePath);
var def = JsonSerializer.Deserialize<JsonSkillDefinition>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (def == null) return null;
return new JsonSkillHandler(def);
}
}
public class JsonSkillDefinition
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public string Version { get; set; } = "1.0";
public string Prefix { get; set; } = "";
public JsonSkillCredential? Credential { get; set; }
public JsonSkillRequest? Request { get; set; }
public JsonSkillResponse? Response { get; set; }
public JsonSkillCache? Cache { get; set; }
}
public class JsonSkillCredential
{
public string Type { get; set; } = "bearer_token"; // bearer_token | basic_auth
public string CredentialKey { get; set; } = "";
}
public class JsonSkillRequest
{
public string Method { get; set; } = "GET";
public string Url { get; set; } = "";
public Dictionary<string, string>? Headers { get; set; }
public object? Body { get; set; }
}
public class JsonSkillResponse
{
public string ResultsPath { get; set; } = "results";
public string TitleField { get; set; } = "title";
public string? SubtitleField { get; set; }
public string? ActionUrl { get; set; }
}
public class JsonSkillCache
{
public int Ttl { get; set; } = 0; // 초 단위
}
/// <summary>
/// JSON 스킬 정의를 기반으로 실제 HTTP 호출을 수행하는 핸들러
/// </summary>
public class JsonSkillHandler : IActionHandler
{
private readonly JsonSkillDefinition _def;
private readonly HttpClient _http = new();
private List<LauncherItem>? _cache;
private DateTime _cacheExpiry = DateTime.MinValue;
public string? Prefix => _def.Prefix;
public PluginMetadata Metadata => new(_def.Id, _def.Name, _def.Version, "JSON Skill");
public JsonSkillHandler(JsonSkillDefinition def)
{
_def = def;
_http.Timeout = TimeSpan.FromSeconds(3);
// 인증 헤더 설정
if (def.Credential?.Type == "bearer_token")
{
var token = CredentialManager.GetToken(def.Credential.CredentialKey);
if (!string.IsNullOrEmpty(token))
_http.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
}
}
public async Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
// 캐시 확인
if (_cache != null && DateTime.Now < _cacheExpiry)
return _cache;
if (_def.Request == null || _def.Response == null)
return Enumerable.Empty<LauncherItem>();
try
{
var url = _def.Request.Url.Replace("{{INPUT}}", Uri.EscapeDataString(query));
// URL 유효성 검증: http/https 스킴만 허용
if (!Uri.TryCreate(url, UriKind.Absolute, out var parsedUrl) ||
(parsedUrl.Scheme != Uri.UriSchemeHttp && parsedUrl.Scheme != Uri.UriSchemeHttps))
{
LogService.Error($"[{_def.Name}] 유효하지 않은 URL: {url}");
return [new LauncherItem("설정 오류", "스킬 URL이 유효하지 않습니다 (http/https만 허용)", null, null)];
}
var response = _def.Request.Method.ToUpper() switch
{
"POST" => await _http.PostAsync(url, BuildBody(query), ct),
_ => await _http.GetAsync(url, ct)
};
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(ct);
var items = ParseResults(json);
// 캐시 저장
if (_def.Cache?.Ttl > 0)
{
_cache = items;
_cacheExpiry = DateTime.Now.AddSeconds(_def.Cache.Ttl);
}
return items;
}
catch (TaskCanceledException)
{
// 타임아웃 → 캐시 반환
if (_cache != null)
{
LogService.Warn($"[{_def.Name}] API 타임아웃, 캐시 반환");
return _cache;
}
return [new LauncherItem("네트워크 오류", "연결을 확인하세요", null, null)];
}
catch (Exception ex)
{
LogService.Error($"[{_def.Name}] API 호출 실패: {ex.Message}");
return [new LauncherItem($"오류: {ex.Message}", _def.Name, null, null)];
}
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.ActionUrl != null &&
Uri.TryCreate(item.ActionUrl, UriKind.Absolute, out var uri) &&
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
{
System.Diagnostics.Process.Start(
new System.Diagnostics.ProcessStartInfo(item.ActionUrl) { UseShellExecute = true });
}
return Task.CompletedTask;
}
private HttpContent BuildBody(string query)
{
var bodyJson = JsonSerializer.Serialize(_def.Request?.Body)
.Replace("\"{{INPUT}}\"", $"\"{query}\"");
return new StringContent(bodyJson, Encoding.UTF8, "application/json");
}
private List<LauncherItem> ParseResults(string json)
{
var items = new List<LauncherItem>();
try
{
var root = JsonNode.Parse(json);
if (root == null) return items;
// resultsPath로 배열 탐색 (dot notation)
var node = NavigatePath(root, _def.Response!.ResultsPath);
if (node is not JsonArray arr) return items;
foreach (var element in arr.Take(10))
{
if (element == null) continue;
var title = NavigatePath(element, _def.Response.TitleField)?.ToString() ?? "(제목 없음)";
var subtitle = _def.Response.SubtitleField != null
? NavigatePath(element, _def.Response.SubtitleField)?.ToString() ?? ""
: "";
var actionUrl = _def.Response.ActionUrl != null
? NavigatePath(element, _def.Response.ActionUrl)?.ToString()
: null;
items.Add(new LauncherItem(title, subtitle, null, element, actionUrl, Symbols.Cloud));
}
}
catch (Exception ex)
{
LogService.Error($"[{_def.Name}] 응답 파싱 실패: {ex.Message}");
}
return items;
}
private static JsonNode? NavigatePath(JsonNode root, string path)
{
var parts = path.Split('.');
JsonNode? current = root;
foreach (var part in parts)
{
if (current == null) return null;
// 배열 인덱스 처리: field[0]
var bracketIdx = part.IndexOf('[');
if (bracketIdx >= 0)
{
var closingIdx = part.IndexOf(']');
if (closingIdx < 0) return null; // 잘못된 경로 형식 (예: field[0 )
var fieldName = part[..bracketIdx];
var index = int.Parse(part[(bracketIdx + 1)..closingIdx]);
current = current[fieldName]?[index];
}
else
{
current = current[part];
}
}
return current;
}
}
/// <summary>
/// Windows Credential Manager (advapi32.dll)를 사용해 자격증명을 안전하게 저장/조회합니다.
/// DPAPI 기반 암호화로 현재 사용자 계정에 귀속되어 저장됩니다.
/// </summary>
public static class CredentialManager
{
private const uint CRED_TYPE_GENERIC = 1;
private const uint CRED_PERSIST_LOCAL_MACHINE = 2;
[StructLayout(LayoutKind.Sequential)]
private struct FILETIME { public uint dwLowDateTime; public uint dwHighDateTime; }
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct CREDENTIAL
{
public uint Flags;
public uint Type;
public IntPtr TargetName;
public IntPtr Comment;
public FILETIME LastWritten;
public uint CredentialBlobSize;
public IntPtr CredentialBlob;
public uint Persist;
public uint AttributeCount;
public IntPtr Attributes;
public IntPtr TargetAlias;
public IntPtr UserName;
}
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool CredRead(string target, uint type, uint flags, out IntPtr credential);
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool CredWrite([In] ref CREDENTIAL userCredential, uint flags);
[DllImport("advapi32.dll", SetLastError = true)]
private static extern void CredFree(IntPtr cred);
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool CredDelete(string target, uint type, uint flags);
/// <summary>
/// Windows Credential Manager에서 토큰을 읽습니다.
/// 저장된 자격증명이 없으면 환경변수에서 폴백합니다.
/// </summary>
public static string? GetToken(string key)
{
if (string.IsNullOrEmpty(key)) return null;
try
{
if (CredRead(key, CRED_TYPE_GENERIC, 0, out IntPtr ptr))
{
try
{
var cred = Marshal.PtrToStructure<CREDENTIAL>(ptr);
if (cred.CredentialBlobSize > 0 && cred.CredentialBlob != IntPtr.Zero)
return Marshal.PtrToStringUni(cred.CredentialBlob,
(int)cred.CredentialBlobSize / 2);
}
finally
{
CredFree(ptr);
}
}
}
catch (Exception ex)
{
LogService.Warn($"Windows Credential Manager 읽기 실패 ({key}): {ex.Message}");
}
// 환경변수 폴백 (개발 환경용)
return Environment.GetEnvironmentVariable(key.ToUpperInvariant());
}
/// <summary>
/// Windows Credential Manager에 토큰을 DPAPI로 암호화하여 저장합니다.
/// </summary>
public static void SetToken(string key, string token)
{
if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(token)) return;
var blob = Encoding.Unicode.GetBytes(token);
var blobPtr = Marshal.AllocHGlobal(blob.Length);
var targetPtr = Marshal.StringToCoTaskMemUni(key);
var userPtr = Marshal.StringToCoTaskMemUni(Environment.UserName);
try
{
Marshal.Copy(blob, 0, blobPtr, blob.Length);
var cred = new CREDENTIAL
{
Type = CRED_TYPE_GENERIC,
TargetName = targetPtr,
UserName = userPtr,
CredentialBlobSize = (uint)blob.Length,
CredentialBlob = blobPtr,
Persist = CRED_PERSIST_LOCAL_MACHINE,
};
if (!CredWrite(ref cred, 0))
LogService.Error($"토큰 저장 실패: {key}, 오류 코드: {Marshal.GetLastWin32Error()}");
else
LogService.Info($"토큰 저장 완료: {key}");
}
finally
{
Marshal.FreeHGlobal(blobPtr);
Marshal.FreeCoTaskMem(targetPtr);
Marshal.FreeCoTaskMem(userPtr);
}
}
/// <summary>
/// Windows Credential Manager에서 자격증명을 삭제합니다.
/// </summary>
public static bool DeleteToken(string key) =>
!string.IsNullOrEmpty(key) && CredDelete(key, CRED_TYPE_GENERIC, 0);
}

View File

@@ -0,0 +1,107 @@
using System.Runtime.InteropServices;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 미디어 컨트롤 핸들러. "media" 프리픽스로 사용합니다.
/// 예: media play → 재생/일시정지
/// media next → 다음 트랙
/// media prev → 이전 트랙
/// media vol+ → 볼륨 올리기
/// media vol- → 볼륨 낮추기
/// media mute → 음소거 토글
/// </summary>
public class MediaHandler : IActionHandler
{
public string? Prefix => "media";
public PluginMetadata Metadata => new(
"MediaControl",
"미디어 컨트롤 — media 뒤에 명령어 입력",
"1.0",
"AX");
// Windows 미디어/볼륨 가상 키 코드
private const byte VK_MEDIA_PLAY_PAUSE = 0xB3;
private const byte VK_MEDIA_NEXT_TRACK = 0xB0;
private const byte VK_MEDIA_PREV_TRACK = 0xB1;
private const byte VK_VOLUME_UP = 0xAF;
private const byte VK_VOLUME_DOWN = 0xAE;
private const byte VK_VOLUME_MUTE = 0xAD;
// KEYEVENTF flags
private const uint KEYEVENTF_EXTENDEDKEY = 0x0001;
private const uint KEYEVENTF_KEYUP = 0x0002;
[DllImport("user32.dll", SetLastError = false)]
private static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, nuint dwExtraInfo);
// 명령어 → (제목, 설명, VK코드, 심볼)
private static readonly List<(string[] Keys, string Title, string Subtitle, byte Vk, string Symbol)> _commands =
[
(["play", "pause", "pp"], "재생 / 일시정지", "현재 미디어 재생 또는 일시정지", VK_MEDIA_PLAY_PAUSE, Symbols.MediaPlay),
(["next", ">>"], "다음 트랙", "다음 곡으로 이동", VK_MEDIA_NEXT_TRACK, Symbols.MediaNext),
(["prev", "previous", "<<"], "이전 트랙", "이전 곡으로 이동", VK_MEDIA_PREV_TRACK, Symbols.MediaPrev),
(["vol+", "volup", "up"], "볼륨 올리기", "시스템 볼륨 증가", VK_VOLUME_UP, Symbols.VolumeUp),
(["vol-", "voldown", "down"], "볼륨 낮추기", "시스템 볼륨 감소", VK_VOLUME_DOWN, Symbols.VolumeDown),
(["mute", "음소거"], "음소거 토글", "볼륨 음소거 / 해제", VK_VOLUME_MUTE, Symbols.VolumeMute),
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim().ToLowerInvariant();
IEnumerable<(string[] Keys, string Title, string Subtitle, byte Vk, string Symbol)> matches;
if (string.IsNullOrEmpty(q))
{
// 쿼리 없으면 전체 목록 표시
matches = _commands;
}
else
{
// 입력어와 일치하는 명령 필터
matches = _commands.Where(c =>
c.Keys.Any(k => k.StartsWith(q, StringComparison.OrdinalIgnoreCase)) ||
c.Title.Contains(q, StringComparison.OrdinalIgnoreCase));
}
var items = matches
.Select(c => new LauncherItem(c.Title, c.Subtitle, null, new MediaKeyData(c.Vk), Symbol: c.Symbol))
.ToList();
if (items.Count == 0)
{
items.Add(new LauncherItem(
"알 수 없는 명령어",
"play · next · prev · vol+ · vol- · mute 중 하나를 입력하세요",
null, null, Symbol: Symbols.Warning));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is not MediaKeyData data) return Task.CompletedTask;
try
{
// 키 누름 → 키 뗌
keybd_event(data.Vk, 0, KEYEVENTF_EXTENDEDKEY, 0);
keybd_event(data.Vk, 0, KEYEVENTF_EXTENDEDKEY | KEYEVENTF_KEYUP, 0);
LogService.Info($"미디어 키 전송: VK=0x{data.Vk:X2}");
}
catch (Exception ex)
{
LogService.Warn($"미디어 키 전송 실패: {ex.Message}");
}
return Task.CompletedTask;
}
private record MediaKeyData(byte Vk);
}

View File

@@ -0,0 +1,163 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 시스템 리소스 모니터 핸들러. "monitor" 프리픽스로 사용합니다.
/// CPU, 메모리, 디스크, 프로세스 수 등 실시간 시스템 리소스 정보를 표시합니다.
/// 예: monitor → 전체 리소스 현황
/// monitor cpu → CPU 관련 정보만
/// monitor mem → 메모리 관련 정보만
/// monitor disk → 디스크 사용량
/// Enter → 결과를 클립보드에 복사.
/// </summary>
public class MonitorHandler : IActionHandler
{
public string? Prefix => "monitor";
public PluginMetadata Metadata => new(
"Monitor",
"시스템 리소스 모니터 — monitor",
"1.0",
"AX");
[DllImport("kernel32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GlobalMemoryStatusEx(ref MEMORYSTATUSEX lpBuffer);
[StructLayout(LayoutKind.Sequential)]
private struct MEMORYSTATUSEX
{
public uint dwLength;
public uint dwMemoryLoad;
public ulong ullTotalPhys;
public ulong ullAvailPhys;
public ulong ullTotalPageFile;
public ulong ullAvailPageFile;
public ulong ullTotalVirtual;
public ulong ullAvailVirtual;
public ulong ullAvailExtendedVirtual;
}
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim().ToLowerInvariant();
var items = new List<LauncherItem>();
var showAll = string.IsNullOrWhiteSpace(q);
// CPU
if (showAll || q.Contains("cpu") || q.Contains("프로세서"))
{
var cpuCount = Environment.ProcessorCount;
var processes = Process.GetProcesses().Length;
var threads = 0;
try { threads = Process.GetProcesses().Sum(p => { try { return p.Threads.Count; } catch (Exception) { return 0; } }); }
catch (Exception) { }
items.Add(new LauncherItem(
$"CPU: {cpuCount}코어 · 프로세스 {processes}개 · 스레드 {threads:N0}개",
"Enter로 클립보드 복사",
null, $"CPU: {cpuCount}코어, 프로세스 {processes}개, 스레드 {threads:N0}개",
Symbol: Symbols.Processor));
}
// Memory
if (showAll || q.Contains("mem") || q.Contains("ram") || q.Contains("메모리"))
{
var mem = new MEMORYSTATUSEX { dwLength = (uint)Marshal.SizeOf<MEMORYSTATUSEX>() };
GlobalMemoryStatusEx(ref mem);
var totalGB = mem.ullTotalPhys / (1024.0 * 1024 * 1024);
var usedGB = (mem.ullTotalPhys - mem.ullAvailPhys) / (1024.0 * 1024 * 1024);
var pct = mem.dwMemoryLoad;
items.Add(new LauncherItem(
$"메모리: {usedGB:F1}GB / {totalGB:F1}GB ({pct}% 사용)",
$"사용 가능: {mem.ullAvailPhys / (1024.0 * 1024 * 1024):F1}GB · Enter로 복사",
null, $"메모리: {usedGB:F1}GB / {totalGB:F1}GB ({pct}% 사용)",
Symbol: Symbols.Memory));
}
// Disk
if (showAll || q.Contains("disk") || q.Contains("디스크") || q.Contains("저장"))
{
foreach (var drive in System.IO.DriveInfo.GetDrives())
{
if (!drive.IsReady || drive.DriveType != System.IO.DriveType.Fixed) continue;
var totalGB = drive.TotalSize / (1024.0 * 1024 * 1024);
var freeGB = drive.AvailableFreeSpace / (1024.0 * 1024 * 1024);
var usedGB = totalGB - freeGB;
var pct = (int)(usedGB / totalGB * 100);
items.Add(new LauncherItem(
$"디스크 {drive.Name.TrimEnd('\\')} {usedGB:F0}GB / {totalGB:F0}GB ({pct}%)",
$"여유: {freeGB:F1}GB · {drive.DriveFormat} · Enter로 복사",
null, $"디스크 {drive.Name}: {usedGB:F0}GB / {totalGB:F0}GB ({pct}%), 여유 {freeGB:F1}GB",
Symbol: Symbols.Storage));
}
}
// Uptime
if (showAll || q.Contains("uptime") || q.Contains("가동"))
{
var uptime = TimeSpan.FromMilliseconds(Environment.TickCount64);
var uptimeStr = uptime.Days > 0
? $"{uptime.Days}일 {uptime.Hours}시간 {uptime.Minutes}분"
: $"{uptime.Hours}시간 {uptime.Minutes}분";
items.Add(new LauncherItem(
$"가동 시간: {uptimeStr}",
"마지막 재시작 이후 경과 · Enter로 복사",
null, $"가동 시간: {uptimeStr}",
Symbol: Symbols.Clock));
}
// Top processes by memory
if (showAll || q.Contains("top") || q.Contains("프로세스"))
{
try
{
var topProcs = Process.GetProcesses()
.Where(p => { try { return p.WorkingSet64 > 0; } catch (Exception) { return false; } })
.OrderByDescending(p => { try { return p.WorkingSet64; } catch (Exception) { return 0L; } })
.Take(5)
.Select(p =>
{
try { return $"{p.ProcessName} ({p.WorkingSet64 / (1024 * 1024)}MB)"; }
catch (Exception) { return p.ProcessName; }
});
items.Add(new LauncherItem(
"메모리 상위 프로세스",
string.Join(", ", topProcs),
null, $"메모리 상위: {string.Join(", ", topProcs)}",
Symbol: Symbols.Computer));
}
catch (Exception) { }
}
if (items.Count == 0)
{
items.Add(new LauncherItem(
$"'{q}'에 해당하는 리소스 항목 없음",
"cpu / mem / disk / uptime / top",
null, null, Symbol: Symbols.Warning));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is string text && !string.IsNullOrWhiteSpace(text))
{
try { Application.Current?.Dispatcher.Invoke(() => Clipboard.SetText(text)); }
catch (Exception) { }
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,215 @@
using System.IO;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 빠른 메모 핸들러. "note" 프리픽스로 사용합니다.
/// 예: note → 최근 메모 10개 목록 (Enter로 클립보드 복사)
/// note 내일 회의 9시 → 메모 저장 (타임스탬프 자동 추가)
/// note clear → 전체 메모 삭제
/// 저장 위치: %APPDATA%\AxCopilot\notes.txt
/// </summary>
public class NoteHandler : IActionHandler
{
public string? Prefix => "note";
public PluginMetadata Metadata => new(
"Note",
"빠른 메모 — note 뒤에 내용 입력",
"1.0",
"AX");
private static readonly string NotesFile = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "notes.txt");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
// ─── 비어 있으면 최근 메모 목록 ──────────────────────────────────────
if (string.IsNullOrWhiteSpace(q))
{
var notes = ReadNotes();
if (!notes.Any())
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
"메모가 없습니다",
"note 뒤에 내용을 입력하면 저장됩니다",
null, null, Symbol: Symbols.Note)
]);
}
var items = notes.Take(10).Select(n => new LauncherItem(
n.Content.Length > 60 ? n.Content[..57] + "…" : n.Content,
$"{n.SavedAt:yyyy-MM-dd HH:mm} · Enter 복사 · Delete 삭제",
null,
n.Content,
Symbol: Symbols.Note)).ToList();
// 전체 삭제 항목
items.Add(new LauncherItem(
"전체 메모 삭제",
$"총 {notes.Count}개 메모 모두 삭제 · Enter로 실행",
null,
"__CLEAR__",
Symbol: Symbols.Delete));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ─── "clear" 명령 ─────────────────────────────────────────────────
if (q.Equals("clear", StringComparison.OrdinalIgnoreCase))
{
var count = ReadNotes().Count;
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
$"전체 메모 삭제 ({count}개)",
"모든 메모를 삭제합니다 · Enter로 실행",
null,
"__CLEAR__",
Symbol: Symbols.Delete)
]);
}
// ─── 새 메모 저장 미리보기 ────────────────────────────────────────────
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
$"메모 저장: {(q.Length > 60 ? q[..57] + "…" : q)}",
$"{DateTime.Now:yyyy-MM-dd HH:mm} · Enter로 저장",
null,
new ValueTuple<string, string>("__SAVE__", q),
Symbol: Symbols.Note)
]);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
switch (item.Data)
{
// ValueTuple<string,string> 명시적 타입 매칭 (object? 패턴 안전하게)
case ValueTuple<string, string> t when t.Item1 == "__SAVE__":
SaveNote(t.Item2);
NotificationService.Notify("AX Copilot",
$"메모 저장됨: {(t.Item2.Length > 30 ? t.Item2[..27] + "" : t.Item2)}");
break;
case string text when text == "__CLEAR__":
ClearNotes();
break;
case string text:
try { Clipboard.SetText(text); } catch (Exception) { }
break;
}
return Task.CompletedTask;
}
// ─── 파일 I/O ──────────────────────────────────────────────────────────────
private static void SaveNote(string content)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(NotesFile)!);
var line = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {content}{Environment.NewLine}";
File.AppendAllText(NotesFile, line, System.Text.Encoding.UTF8);
}
catch (Exception ex)
{
LogService.Warn($"메모 저장 실패: {ex.Message}");
}
}
private static List<NoteEntry> ReadNotes()
{
var result = new List<NoteEntry>();
if (!File.Exists(NotesFile)) return result;
try
{
var lines = File.ReadAllLines(NotesFile, System.Text.Encoding.UTF8);
foreach (var line in lines.Reverse())
{
if (string.IsNullOrWhiteSpace(line)) continue;
// 형식: [yyyy-MM-dd HH:mm:ss] 내용
if (line.StartsWith('[') && line.Length > 21 && line[20] == ']')
{
if (DateTime.TryParse(line[1..20], out var dt))
{
result.Add(new NoteEntry(dt, line[22..].Trim()));
continue;
}
}
result.Add(new NoteEntry(DateTime.MinValue, line.Trim()));
}
}
catch (Exception ex)
{
LogService.Warn($"메모 읽기 실패: {ex.Message}");
}
return result;
}
private static void ClearNotes()
{
try
{
if (File.Exists(NotesFile))
File.Delete(NotesFile);
}
catch (Exception ex)
{
LogService.Warn($"메모 삭제 실패: {ex.Message}");
}
}
/// <summary>
/// 특정 메모 1건 삭제. content가 일치하는 가장 최근 항목을 제거합니다.
/// </summary>
public static bool DeleteNote(string content)
{
try
{
if (!File.Exists(NotesFile)) return false;
var lines = File.ReadAllLines(NotesFile, System.Text.Encoding.UTF8).ToList();
// 뒤에서부터 찾아 가장 최근 일치 항목 제거
for (int i = lines.Count - 1; i >= 0; i--)
{
var line = lines[i];
if (string.IsNullOrWhiteSpace(line)) continue;
string extracted;
if (line.StartsWith('[') && line.Length > 21 && line[20] == ']')
extracted = line[22..].Trim();
else
extracted = line.Trim();
if (extracted == content)
{
lines.RemoveAt(i);
File.WriteAllLines(NotesFile, lines, System.Text.Encoding.UTF8);
return true;
}
}
}
catch (Exception ex)
{
LogService.Warn($"메모 개별 삭제 실패: {ex.Message}");
}
return false;
}
}
internal record NoteEntry(DateTime SavedAt, string Content);

View File

@@ -0,0 +1,229 @@
using System.Diagnostics;
using System.Net.NetworkInformation;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 포트/프로세스 점검 핸들러. "port " 프리픽스로 사용합니다.
/// 예: port → 활성 TCP 연결 목록
/// port 8080 → 8080 포트를 점유 중인 프로세스 상세
/// port chrome → chrome이 사용하는 포트 목록
/// </summary>
public class PortHandler : IActionHandler
{
public string? Prefix => "port";
public PluginMetadata Metadata => new(
"PortChecker",
"포트 & 프로세스 점검 — port 뒤에 포트번호 또는 프로세스명",
"1.0",
"AX");
// 프로세스 이름 캐시 (PID → 이름), 5초 유효
private static readonly Dictionary<int, string> _procCache = new();
// netstat 결과 캐시 (포트 → PID), 5초 유효 — netstat 단일 실행으로 N+1 해결
private static readonly Dictionary<int, int> _pidMap = new();
private static DateTime _cacheExpiry = DateTime.MinValue;
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
RefreshProcessCache();
TcpConnectionInformation[] tcpConns;
try
{
var props = IPGlobalProperties.GetIPGlobalProperties();
tcpConns = props.GetActiveTcpConnections();
}
catch (Exception ex)
{
LogService.Warn($"포트 조회 실패: {ex.Message}");
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem("네트워크 정보를 가져올 수 없습니다", ex.Message, null, null, Symbol: Symbols.Warning)
]);
}
var q = query.Trim();
// ─── 빈 쿼리: 활성 연결 상위 목록 ────────────────────────────────────
if (string.IsNullOrWhiteSpace(q))
{
var items = tcpConns
.Where(c => c.State == TcpState.Established || c.State == TcpState.Listen)
.OrderBy(c => c.LocalEndPoint.Port)
.Take(20)
.Select(c =>
{
var procName = GetProcessNameForPort(c.LocalEndPoint.Port);
var state = c.State == TcpState.Listen ? "LISTEN" : "ESTABLISHED";
return new LauncherItem(
$":{c.LocalEndPoint.Port} → {c.RemoteEndPoint}",
$"{state} · {procName} · Enter로 포트번호 복사",
null,
c.LocalEndPoint.Port.ToString(),
Symbol: Symbols.Network);
})
.ToList<LauncherItem>();
if (!items.Any())
items.Add(new LauncherItem("활성 연결 없음", "TCP 연결이 감지되지 않았습니다", null, null, Symbol: Symbols.Network));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ─── 숫자: 포트 번호 검색 ─────────────────────────────────────────────
if (int.TryParse(q, out var portNum))
{
var matches = tcpConns
.Where(c => c.LocalEndPoint.Port == portNum || c.RemoteEndPoint.Port == portNum)
.ToList();
if (!matches.Any())
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
$"포트 {portNum} — 사용 중 아님",
"해당 포트를 사용하는 TCP 연결이 없습니다",
null,
portNum.ToString(),
Symbol: Symbols.Info)
]);
}
var result = matches.Select(c =>
{
var procName = GetProcessNameForPort(c.LocalEndPoint.Port);
var pid = GetPidForPort(c.LocalEndPoint.Port);
return new LauncherItem(
$":{c.LocalEndPoint.Port} ←→ {c.RemoteEndPoint}",
$"{c.State} · {procName} (PID {pid}) · Enter로 PID 복사",
null,
pid > 0 ? pid.ToString() : portNum.ToString(),
Symbol: Symbols.Network);
}).ToList<LauncherItem>();
return Task.FromResult<IEnumerable<LauncherItem>>(result);
}
// ─── 문자열: 프로세스명 검색 ──────────────────────────────────────────
var procLower = q.ToLowerInvariant();
var procPorts = tcpConns
.Where(c =>
{
var name = GetProcessNameForPort(c.LocalEndPoint.Port).ToLowerInvariant();
return name.Contains(procLower);
})
.Take(15)
.Select(c =>
{
var procName = GetProcessNameForPort(c.LocalEndPoint.Port);
return new LauncherItem(
$"{procName} : {c.LocalEndPoint.Port} → {c.RemoteEndPoint}",
$"{c.State} · Enter로 포트번호 복사",
null,
c.LocalEndPoint.Port.ToString(),
Symbol: Symbols.Network);
})
.ToList<LauncherItem>();
if (!procPorts.Any())
procPorts.Add(new LauncherItem(
$"'{q}' — 연결 없음",
"해당 프로세스의 TCP 연결이 없습니다",
null, null, Symbol: Symbols.Warning));
return Task.FromResult<IEnumerable<LauncherItem>>(procPorts);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is string text)
{
try { System.Windows.Clipboard.SetText(text); } catch (Exception) { }
}
return Task.CompletedTask;
}
// ─── 프로세스/PID 캐시 헬퍼 ──────────────────────────────────────────────────
/// <summary>
/// 프로세스 목록 + netstat PID 맵을 한 번에 갱신 (5초 캐시).
/// GetItemsAsync 진입 시 한 번만 호출하여 N+1 netstat 실행 방지.
/// </summary>
private static void RefreshProcessCache()
{
if (DateTime.Now < _cacheExpiry) return;
_procCache.Clear();
_pidMap.Clear();
// ① 프로세스 목록 (PID → 이름)
try
{
foreach (var p in Process.GetProcesses())
{
try { _procCache[p.Id] = p.ProcessName; }
catch (Exception) { }
}
}
catch (Exception ex)
{
LogService.Warn($"프로세스 목록 갱신 실패: {ex.Message}");
}
// ② netstat -ano 단 1회 실행 → 포트→PID 전체 맵 구축
try
{
var psi = new ProcessStartInfo("netstat", "-ano")
{
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var proc = Process.Start(psi);
if (proc != null)
{
var output = proc.StandardOutput.ReadToEnd();
proc.WaitForExit(2000);
foreach (var line in output.Split('\n'))
{
var parts = line.Trim().Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries);
// 형식: Proto Local Address Foreign Address State PID
if (parts.Length < 5) continue;
if (!int.TryParse(parts[^1], out var pid)) continue;
// Local Address에서 포트 추출 (예: 0.0.0.0:8080 또는 [::]:8080)
var localAddr = parts[1];
var colonIdx = localAddr.LastIndexOf(':');
if (colonIdx >= 0 && int.TryParse(localAddr[(colonIdx + 1)..], out var port))
_pidMap.TryAdd(port, pid);
}
}
}
catch (Exception ex)
{
LogService.Warn($"netstat 실행 실패: {ex.Message}");
}
_cacheExpiry = DateTime.Now.AddSeconds(5);
}
private static string GetProcessNameForPort(int port)
{
var pid = GetPidForPort(port);
return pid > 0 && _procCache.TryGetValue(pid, out var name) ? name : "알 수 없음";
}
/// <summary>
/// 캐시된 pidMap에서 즉시 반환 — netstat를 추가로 실행하지 않음.
/// </summary>
private static int GetPidForPort(int port)
=> _pidMap.TryGetValue(port, out var pid) ? pid : -1;
}

View File

@@ -0,0 +1,127 @@
using System.Diagnostics;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 실행 중인 프로세스를 검색하고 종료합니다. "kill " 프리픽스로 사용합니다.
/// 예: kill chrome → Chrome 프로세스 목록 표시 후 선택하면 종료
/// kill → 현재 실행 중인 모든 사용자 프로세스 표시
/// </summary>
public class ProcessHandler : IActionHandler
{
public string? Prefix => "kill "; // 뒤에 공백 포함 — 오탐 방지
public PluginMetadata Metadata => new(
"ProcessKiller",
"프로세스 종료 — kill 뒤에 프로세스명 입력",
"1.0",
"AX");
// 시스템 핵심 프로세스 보호 목록 (종료 방지)
private static readonly HashSet<string> ProtectedProcesses = new(StringComparer.OrdinalIgnoreCase)
{
"system", "smss", "csrss", "wininit", "winlogon", "services", "lsass",
"svchost", "explorer", "dwm", "fontdrvhost", "spoolsv", "registry",
};
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(query))
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
"종료할 프로세스명을 입력하세요",
"예: kill chrome · kill notepad · kill explorer",
null, null, Symbol: Symbols.Power)
]);
}
var q = query.Trim().ToLowerInvariant();
try
{
var processes = Process.GetProcesses()
.Where(p =>
!ProtectedProcesses.Contains(p.ProcessName) &&
p.ProcessName.ToLowerInvariant().Contains(q))
.OrderBy(p => p.ProcessName)
.Take(12)
.ToList();
if (processes.Count == 0)
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
$"'{query}' 프로세스를 찾을 수 없습니다",
"실행 중인 프로세스가 없거나 이름이 다릅니다",
null, null, Symbol: Symbols.Warning)
]);
}
// 같은 이름의 프로세스 묶기
var grouped = processes
.GroupBy(p => p.ProcessName, StringComparer.OrdinalIgnoreCase)
.Select(g =>
{
var pids = g.Select(p => p.Id).ToList();
var memMb = g.Sum(p =>
{
try { return p.WorkingSet64 / 1024 / 1024; }
catch (Exception) { return 0L; }
});
var title = g.Count() > 1
? $"{g.Key} ({g.Count()}개 인스턴스)"
: g.Key;
var subtitle = $"PID: {string.Join(", ", pids)} · 메모리: {memMb} MB · Enter로 종료";
return new LauncherItem(
title,
subtitle,
null,
new ProcessKillData(g.Key, pids),
Symbol: Symbols.Power);
})
.ToList();
return Task.FromResult<IEnumerable<LauncherItem>>(grouped);
}
catch (Exception ex)
{
LogService.Warn($"프로세스 목록 조회 실패: {ex.Message}");
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem("프로세스 목록 조회 실패", ex.Message, null, null, Symbol: Symbols.Error)
]);
}
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is not ProcessKillData data) return Task.CompletedTask;
int killed = 0, failed = 0;
foreach (var pid in data.Pids)
{
try
{
var proc = Process.GetProcessById(pid);
proc.Kill(entireProcessTree: false);
killed++;
}
catch (Exception)
{
failed++;
}
}
LogService.Info($"프로세스 종료: {data.Name} — {killed}개 성공, {failed}개 실패");
return Task.CompletedTask;
}
private record ProcessKillData(string Name, List<int> Pids);
}

View File

@@ -0,0 +1,127 @@
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// Phase L3-4: 파라미터 퀵링크 핸들러. "ql" 예약어로 사용합니다.
/// 예: ql maps 강남역 → "maps" 키워드 URL에 "강남역" 치환 후 열기
/// ql jira PROJ-1234 → "jira" 키워드 URL에 티켓 번호 치환
/// ql (목록) → 등록된 퀵링크 목록 표시
///
/// 퀵링크는 설정 → 일반 → 퀵링크 탭에서 등록합니다.
/// </summary>
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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var links = _settings.Settings.QuickLinks;
// 등록된 퀵링크 없음
if (links.Count == 0)
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
"등록된 퀵링크 없음",
"설정 → 일반 → 퀵링크에서 추가하세요. 예: keyword=maps, url=https://map.naver.com/p/search/{0}",
null, null, Symbol: Symbols.Globe)
]);
}
var items = new List<LauncherItem>();
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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;
}
}

View File

@@ -0,0 +1,141 @@
using System.IO;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 최근 파일 핸들러. "recent" 프리픽스로 사용합니다.
/// Windows Recent 폴더(%APPDATA%\Microsoft\Windows\Recent)의 .lnk 파일을
/// 최근 수정 순으로 나열합니다.
/// 예: recent → 최근 20개 파일 목록
/// recent 보고서 → 이름에 "보고서" 포함 파일 필터
/// </summary>
public class RecentFilesHandler : IActionHandler
{
public string? Prefix => "recent";
public PluginMetadata Metadata => new(
"RecentFiles",
"최근 파일 — recent 뒤에 검색어 입력",
"1.0",
"AX");
private static readonly string RecentFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
@"Microsoft\Windows\Recent");
// 간단한 캐시: 10초간 유효
private static (DateTime At, List<(string Name, string LinkPath, DateTime Modified)> Files)? _cache;
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(10);
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
if (string.IsNullOrWhiteSpace(q))
{
// 힌트만 표시
}
var files = GetRecentFiles();
IEnumerable<(string Name, string LinkPath, DateTime Modified)> filtered = files;
if (!string.IsNullOrWhiteSpace(q))
{
filtered = files.Where(f =>
f.Name.Contains(q, StringComparison.OrdinalIgnoreCase));
}
var items = filtered.Take(20).Select(f => new LauncherItem(
f.Name,
$"{f.Modified:yyyy-MM-dd HH:mm} · Enter로 열기",
null,
f.LinkPath,
Symbol: GetSymbol(f.Name))).ToList();
if (!items.Any())
{
items.Add(new LauncherItem(
string.IsNullOrWhiteSpace(q) ? "최근 파일 없음" : "검색 결과 없음",
string.IsNullOrWhiteSpace(q)
? "Windows Recent 폴더가 비어 있습니다"
: $"'{q}' 파일을 최근 목록에서 찾을 수 없습니다",
null, null, Symbol: Symbols.Info));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is string linkPath && File.Exists(linkPath))
{
try
{
System.Diagnostics.Process.Start(
new System.Diagnostics.ProcessStartInfo(linkPath)
{ UseShellExecute = true });
}
catch (Exception ex)
{
LogService.Warn($"최근 파일 열기 실패: {ex.Message}");
}
}
return Task.CompletedTask;
}
// ─── 내부 ─────────────────────────────────────────────────────────────────
private static List<(string Name, string LinkPath, DateTime Modified)> GetRecentFiles()
{
// 캐시 유효 확인
if (_cache.HasValue && (DateTime.Now - _cache.Value.At) < CacheTtl)
return _cache.Value.Files;
var result = new List<(string, string, DateTime)>();
try
{
if (!Directory.Exists(RecentFolder))
return result;
var lnkFiles = Directory
.GetFiles(RecentFolder, "*.lnk")
.Select(p => (Path: p, Info: new FileInfo(p)))
.OrderByDescending(f => f.Info.LastWriteTime)
.Take(100)
.ToList();
foreach (var (path, info) in lnkFiles)
{
var name = Path.GetFileNameWithoutExtension(info.Name);
result.Add((name, path, info.LastWriteTime));
}
}
catch (Exception ex)
{
LogService.Warn($"최근 파일 목록 읽기 실패: {ex.Message}");
}
_cache = (DateTime.Now, result);
return result;
}
private static string GetSymbol(string name)
{
var ext = Path.GetExtension(name).ToLowerInvariant();
return ext switch
{
".exe" or ".msi" => Symbols.App,
".xlsx" or ".xls" or ".csv" => Symbols.File,
".docx" or ".doc" => Symbols.File,
".pptx" or ".ppt" => Symbols.File,
".pdf" => Symbols.File,
".txt" or ".md" or ".log" => Symbols.Text,
".jpg" or ".jpeg" or ".png" or ".gif" or ".webp" or ".bmp" => Symbols.Picture,
_ => Symbols.File
};
}
}

View File

@@ -0,0 +1,189 @@
using System.Diagnostics;
using System.IO;
using System.Text.RegularExpressions;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 파일 일괄 이름변경 핸들러. "rename" 프리픽스로 사용합니다.
/// 지정된 폴더 내 파일을 패턴으로 일괄 이름변경합니다.
/// 예: rename → 사용법 안내
/// rename C:\work\*.xlsx → 해당 폴더의 xlsx 파일 목록
/// rename C:\work\*.xlsx 보고서_{n} → 보고서_1.xlsx, 보고서_2.xlsx ...
/// {n}=순번, {date}=오늘 날짜, {orig}=원본명
/// Enter → 실행 전 미리보기, Shift+Enter → 실행.
/// </summary>
public class RenameHandler : IActionHandler
{
public string? Prefix => "rename";
public PluginMetadata Metadata => new(
"Rename",
"파일 일괄 이름변경 — rename",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
if (string.IsNullOrWhiteSpace(q))
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
"파일 일괄 이름변경",
"rename [폴더\\패턴] [ 릿]",
null, null, Symbol: Symbols.Rename),
new LauncherItem(
"사용 예시",
"rename C:\\work\\*.xlsx 보고서_{n} → 보고서_1.xlsx, 보고서_2.xlsx ...",
null, null, Symbol: Symbols.Info),
new LauncherItem(
"변수: {n} 순번, {date} 날짜, {orig} 원본명",
"rename D:\\photos\\*.jpg {date}_{n} → 2026-03-27_1.jpg ...",
null, null, Symbol: Symbols.Info),
]);
}
// 파싱: [경로\패턴] [템플릿]
var parts = q.Split(' ', 2, StringSplitOptions.TrimEntries);
var pattern = parts[0];
var template = parts.Length > 1 ? parts[1] : null;
// 경로 분리
var dir = Path.GetDirectoryName(pattern);
var glob = Path.GetFileName(pattern);
if (string.IsNullOrWhiteSpace(dir) || !Directory.Exists(dir))
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
"폴더를 찾을 수 없습니다",
$"경로: {dir ?? "(비어 있음)"}",
null, null, Symbol: Symbols.Warning)
]);
}
string[] files;
try { files = Directory.GetFiles(dir, glob); }
catch (Exception) { files = Array.Empty<string>(); }
if (files.Length == 0)
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
"일치하는 파일이 없습니다",
$"패턴: {glob} · 폴더: {dir}",
null, null, Symbol: Symbols.Warning)
]);
}
Array.Sort(files);
// 템플릿이 없으면 파일 목록만 표시
if (string.IsNullOrWhiteSpace(template))
{
var items = files.Take(10).Select((f, i) => new LauncherItem(
Path.GetFileName(f),
dir,
null, null,
Symbol: Symbols.File)).ToList<LauncherItem>();
items.Insert(0, new LauncherItem(
$"총 {files.Length}개 파일 발견",
"뒤에 새 이름 템플릿을 추가하세요 (예: 보고서_{n})",
null, null, Symbol: Symbols.Info));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 미리보기 생성
var today = DateTime.Today.ToString("yyyy-MM-dd");
var previews = new List<(string From, string To)>();
for (int i = 0; i < files.Length; i++)
{
var origName = Path.GetFileNameWithoutExtension(files[i]);
var ext = Path.GetExtension(files[i]);
var newName = template
.Replace("{n}", (i + 1).ToString())
.Replace("{date}", today)
.Replace("{orig}", origName);
// 확장자 자동 유지 (템플릿에 확장자가 없으면)
if (!Path.HasExtension(newName))
newName += ext;
previews.Add((Path.GetFileName(files[i]), newName));
}
var result = new List<LauncherItem>();
// 실행 항목
result.Add(new LauncherItem(
$"총 {files.Length}개 파일 이름변경 실행",
$"Enter로 실행 · {previews[0].From} → {previews[0].To} ...",
null, ValueTuple.Create(dir, files, previews.Select(p => p.To).ToArray()),
Symbol: Symbols.Rename));
// 미리보기 (최대 8개)
foreach (var (from, to) in previews.Take(8))
{
result.Add(new LauncherItem(
$"{from} → {to}",
"미리보기",
null, null,
Symbol: Symbols.File));
}
if (files.Length > 8)
{
result.Add(new LauncherItem(
$"... 외 {files.Length - 8}개",
"",
null, null,
Symbol: Symbols.Info));
}
return Task.FromResult<IEnumerable<LauncherItem>>(result);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is not ValueTuple<string, string[], string[]> data)
return Task.CompletedTask;
var (dir, files, newNames) = data;
int renamed = 0;
int failed = 0;
for (int i = 0; i < files.Length && i < newNames.Length; i++)
{
try
{
var dest = Path.Combine(dir, newNames[i]);
if (File.Exists(dest))
{
failed++;
continue;
}
File.Move(files[i], dest);
renamed++;
}
catch (Exception)
{
failed++;
}
}
var msg = failed > 0
? $"{renamed}개 이름변경 완료, {failed}개 실패 (이미 존재하거나 접근 불가)"
: $"{renamed}개 파일 이름변경 완료";
NotificationService.Notify("AX Copilot", msg);
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,186 @@
using System.Diagnostics;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 루틴 자동화 핸들러. "routine" 프리픽스로 사용합니다.
/// 등록된 루틴을 실행하면 앱·폴더·URL을 순서대로 일괄 실행합니다.
/// 예: routine → 등록된 루틴 목록
/// routine morning → "morning" 루틴 실행
/// routine add morning → 루틴 추가 안내
/// 루틴 정의: %APPDATA%\AxCopilot\routines.json
/// </summary>
public class RoutineHandler : IActionHandler
{
public string? Prefix => "routine";
public PluginMetadata Metadata => new(
"Routine",
"루틴 자동화 — routine",
"1.0",
"AX");
private static readonly string RoutineFile = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "routines.json");
private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = true, PropertyNameCaseInsensitive = true };
// 내장 기본 루틴
private static readonly RoutineDefinition[] BuiltInRoutines =
[
new("morning", "출근 루틴", [
new("app", "explorer.exe", "파일 탐색기"),
new("info", "info", "시스템 정보 표시"),
]),
new("endofday", "퇴근 루틴", [
new("cmd", "journal", "오늘 업무 일지 생성"),
]),
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var all = LoadRoutines();
if (string.IsNullOrWhiteSpace(q))
{
var items = new List<LauncherItem>
{
new("루틴 자동화",
$"총 {all.Count}개 루틴 · 이름 입력 시 실행 · routines.json에서 편집",
null, null, Symbol: Symbols.Info)
};
foreach (var r in all)
{
var steps = string.Join(" → ", r.Steps.Select(s => s.Label));
items.Add(new LauncherItem(
$"[{r.Name}] {r.Description}",
$"{r.Steps.Length}단계: {steps} · Enter로 실행",
null, r,
Symbol: Symbols.Lightbulb));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 루틴 검색/실행
var match = all.FirstOrDefault(r =>
r.Name.Equals(q, StringComparison.OrdinalIgnoreCase));
if (match != null)
{
var steps = string.Join(" → ", match.Steps.Select(s => s.Label));
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
$"[{match.Name}] ",
$"{match.Description} · {steps}",
null, match,
Symbol: Symbols.Lightbulb)
]);
}
// 부분 매칭
var filtered = all.Where(r =>
r.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
r.Description.Contains(q, StringComparison.OrdinalIgnoreCase));
var result = filtered.Select(r => new LauncherItem(
$"[{r.Name}] {r.Description}",
$"{r.Steps.Length}단계 · Enter로 실행",
null, r,
Symbol: Symbols.Lightbulb)).ToList<LauncherItem>();
if (!result.Any())
{
result.Add(new LauncherItem(
$"'{q}' 루틴 없음",
$"routines.json에서 직접 추가하거나 routine으로 목록 확인",
null, null, Symbol: Symbols.Warning));
}
return Task.FromResult<IEnumerable<LauncherItem>>(result);
}
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is not RoutineDefinition routine) return;
int executed = 0;
foreach (var step in routine.Steps)
{
try
{
switch (step.Type.ToLowerInvariant())
{
case "app":
case "url":
case "folder":
Process.Start(new ProcessStartInfo(step.Target) { UseShellExecute = true });
break;
case "cmd":
// PowerShell 명령 실행
Process.Start(new ProcessStartInfo("powershell.exe", $"-Command \"{step.Target}\"")
{ UseShellExecute = false, CreateNoWindow = true });
break;
case "info":
// 알림으로 대체
NotificationService.Notify("루틴", step.Label);
break;
}
executed++;
await Task.Delay(300, ct); // 앱 간 간격
}
catch (Exception ex)
{
LogService.Warn($"루틴 단계 실행 실패: {step.Label} — {ex.Message}");
}
}
NotificationService.Notify("루틴 완료", $"[{routine.Name}] {executed}/{routine.Steps.Length}단계 실행 완료");
}
private List<RoutineDefinition> LoadRoutines()
{
var list = new List<RoutineDefinition>(BuiltInRoutines);
try
{
if (File.Exists(RoutineFile))
{
var json = File.ReadAllText(RoutineFile);
var user = JsonSerializer.Deserialize<List<RoutineDefinition>>(json, JsonOpts);
if (user != null)
{
// 사용자 루틴이 내장 루틴을 오버라이드
foreach (var r in user)
{
list.RemoveAll(x => x.Name.Equals(r.Name, StringComparison.OrdinalIgnoreCase));
list.Add(r);
}
}
}
}
catch (Exception ex) { LogService.Warn($"루틴 로드 실패: {ex.Message}"); }
return list;
}
internal record RoutineDefinition(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("description")] string Description,
[property: JsonPropertyName("steps")] RoutineStep[] Steps);
internal record RoutineStep(
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("target")] string Target,
[property: JsonPropertyName("label")] string Label);
}

View File

@@ -0,0 +1,96 @@
using System.Diagnostics;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Themes;
using AxCopilot.Views;
namespace AxCopilot.Handlers;
/// <summary>
/// Windows 실행(Run) 핸들러. "^" 프리픽스로 사용합니다.
/// Windows 실행 창(Win+R)에서 입력하는 것과 동일하게 작동합니다.
/// 예: ^ notepad → 메모장 실행
/// ^ cmd → 명령 프롬프트
/// ^ calc → 계산기
/// ^ mspaint → 그림판
/// ^ control → 제어판
/// </summary>
public class RunHandler : IActionHandler
{
public string? Prefix => "^";
public PluginMetadata Metadata => new(
"Run",
"Windows 실행 명령",
"1.0",
"AX Copilot");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
if (string.IsNullOrEmpty(q))
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
"Windows 실행 명령",
"Win+R 실행 창과 동일 · 명령어 입력 후 Enter",
null, null,
Symbol: Symbols.LaunchIcon),
new LauncherItem(
"^ notepad",
"메모장 실행",
null, null, Symbol: Symbols.Info),
new LauncherItem(
"^ cmd",
"명령 프롬프트",
null, null, Symbol: Symbols.Info),
new LauncherItem(
"^ control",
"제어판",
null, null, Symbol: Symbols.Info),
new LauncherItem(
"^ calc",
"계산기",
null, null, Symbol: Symbols.Info),
]);
}
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
$"실행: {q}",
"Enter → Windows 실행 명령으로 실행",
null, $"__RUN__{q}",
Symbol: Symbols.LaunchIcon)
]);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
var data = item.Data as string;
if (data == null || !data.StartsWith("__RUN__")) return Task.CompletedTask;
var cmd = data["__RUN__".Length..].Trim();
if (string.IsNullOrEmpty(cmd)) return Task.CompletedTask;
try
{
Process.Start(new ProcessStartInfo(cmd)
{
UseShellExecute = true
})?.Dispose();
}
catch (Exception ex)
{
CustomMessageBox.Show(
$"실행 실패: {ex.Message}",
"AX Copilot",
MessageBoxButton.OK,
MessageBoxImage.Error);
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,237 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 프로젝트 스캐폴딩 핸들러. "scaffold" 프리픽스로 사용합니다.
/// 로컬 템플릿으로 프로젝트 폴더 구조를 일괄 생성합니다.
/// 예: scaffold → 등록된 템플릿 목록
/// scaffold webapi → "webapi" 템플릿 적용
/// scaffold add [이름] → 현재 폴더 구조를 템플릿으로 저장 (별도 도구에서)
/// 템플릿 저장 위치: %APPDATA%\AxCopilot\templates\[이름].json
/// Enter → 대상 경로를 물어본 후 생성.
/// </summary>
public class ScaffoldHandler : IActionHandler
{
public string? Prefix => "scaffold";
public PluginMetadata Metadata => new(
"Scaffold",
"프로젝트 스캐폴딩 — scaffold",
"1.0",
"AX");
private static readonly string TemplateDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "templates");
// 내장 기본 템플릿
private static readonly ScaffoldTemplate[] BuiltInTemplates =
[
new("webapi", "Web API 프로젝트", new[]
{
"src/Controllers/",
"src/Models/",
"src/Services/",
"src/Middleware/",
"tests/",
"docs/",
"README.md",
".gitignore",
}),
new("console", "콘솔 애플리케이션", new[]
{
"src/",
"src/Core/",
"src/Services/",
"tests/",
"README.md",
}),
new("wpf", "WPF 데스크톱 앱", new[]
{
"src/Views/",
"src/ViewModels/",
"src/Models/",
"src/Services/",
"src/Themes/",
"src/Assets/",
"tests/",
"docs/",
}),
new("data", "데이터 파이프라인", new[]
{
"src/Extractors/",
"src/Transformers/",
"src/Loaders/",
"config/",
"scripts/",
"tests/",
"data/input/",
"data/output/",
"README.md",
}),
new("docs", "문서 프로젝트", new[]
{
"docs/",
"images/",
"templates/",
"README.md",
"CHANGELOG.md",
}),
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
// 사용자 정의 템플릿 로드
var userTemplates = LoadUserTemplates();
var allTemplates = BuiltInTemplates.Concat(userTemplates).ToList();
if (string.IsNullOrWhiteSpace(q))
{
var items = allTemplates.Select(t => new LauncherItem(
$"[{t.Name}] {t.Description}",
$"{t.Paths.Length}개 폴더/파일 · Enter → 대상 경로 입력 후 생성",
null, t,
Symbol: Symbols.Folder)).ToList<LauncherItem>();
items.Insert(0, new LauncherItem(
"프로젝트 스캐폴딩",
$"총 {allTemplates.Count}개 템플릿 · 이름을 입력해 필터링",
null, null, Symbol: Symbols.Info));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// "경로 템플릿" 형태 감지 (경로에 \ 또는 / 포함)
if (q.Contains('\\') || q.Contains('/'))
{
var spaceIdx = q.LastIndexOf(' ');
if (spaceIdx > 0)
{
var targetPath = q[..spaceIdx].Trim();
var templateName = q[(spaceIdx + 1)..].Trim();
var tmpl = allTemplates.FirstOrDefault(t =>
t.Name.Equals(templateName, StringComparison.OrdinalIgnoreCase));
if (tmpl != null)
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
$"[{tmpl.Name}] {targetPath}",
$"{tmpl.Paths.Length}개 폴더/파일 생성 · Enter로 실행",
null, ValueTuple.Create(targetPath, tmpl),
Symbol: Symbols.Save)
]);
}
}
}
// 필터링
var filtered = allTemplates.Where(t =>
t.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
t.Description.Contains(q, StringComparison.OrdinalIgnoreCase));
var result = filtered.Select(t =>
{
var preview = string.Join(", ", t.Paths.Take(4));
if (t.Paths.Length > 4) preview += $" ... (+{t.Paths.Length - 4})";
return new LauncherItem(
$"[{t.Name}] {t.Description}",
$"{preview} · 사용법: scaffold [대상경로] {t.Name}",
null, t,
Symbol: Symbols.Folder);
}).ToList<LauncherItem>();
if (!result.Any())
{
result.Add(new LauncherItem(
$"'{q}'에 해당하는 템플릿 없음",
"scaffold 으로 전체 목록 확인",
null, null, Symbol: Symbols.Warning));
}
return Task.FromResult<IEnumerable<LauncherItem>>(result);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ValueTuple<string, ScaffoldTemplate> data)
{
var (targetPath, template) = data;
return CreateStructure(targetPath, template);
}
if (item.Data is ScaffoldTemplate tmpl)
{
// 클립보드에 사용법 복사
var usage = $"scaffold [대상경로] {tmpl.Name}";
try { System.Windows.Application.Current?.Dispatcher.Invoke(
() => System.Windows.Clipboard.SetText(usage)); }
catch (Exception) { }
}
return Task.CompletedTask;
}
private static Task CreateStructure(string basePath, ScaffoldTemplate template)
{
try
{
int created = 0;
foreach (var path in template.Paths)
{
var fullPath = Path.Combine(basePath, path.Replace('/', Path.DirectorySeparatorChar));
if (path.EndsWith('/') || path.EndsWith('\\') || !Path.HasExtension(path))
{
Directory.CreateDirectory(fullPath);
}
else
{
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
if (!File.Exists(fullPath))
File.WriteAllText(fullPath, "");
}
created++;
}
NotificationService.Notify("스캐폴딩 완료",
$"[{template.Name}] {created}개 항목 생성 → {basePath}");
}
catch (Exception ex)
{
LogService.Error($"스캐폴딩 실패: {ex.Message}");
NotificationService.Notify("AX Copilot", $"스캐폴딩 실패: {ex.Message}");
}
return Task.CompletedTask;
}
private static IEnumerable<ScaffoldTemplate> LoadUserTemplates()
{
if (!Directory.Exists(TemplateDir)) yield break;
foreach (var file in Directory.GetFiles(TemplateDir, "*.json"))
{
ScaffoldTemplate? tmpl = null;
try
{
var json = File.ReadAllText(file);
tmpl = JsonSerializer.Deserialize<ScaffoldTemplate>(json);
}
catch (Exception) { }
if (tmpl != null) yield return tmpl;
}
}
internal record ScaffoldTemplate(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("description")] string Description,
[property: JsonPropertyName("paths")] string[] Paths);
}

View File

@@ -0,0 +1,637 @@
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Runtime.InteropServices;
using System.Windows.Media.Imaging;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 화면 캡처 핸들러. "cap" 프리픽스로 사용합니다.
///
/// 예: cap → 전체 화면 캡처
/// cap screen → 전체 화면 캡처
/// cap window → 런처 호출 전 활성 창 캡처
/// cap scroll → 활성 창 스크롤 캡처 (페이지 전체)
/// cap region → 마우스로 영역 선택 후 캡처
/// 파일 저장 여부 / 경로는 설정 → 캡처 탭에서 변경 가능.
/// 기본값: 저장 안 함, 클립보드에만 복사.
/// </summary>
public class ScreenCaptureHandler : IActionHandler
{
private readonly AxCopilot.Services.SettingsService _settings;
public ScreenCaptureHandler(AxCopilot.Services.SettingsService settings)
{
_settings = settings;
}
public string? Prefix => string.IsNullOrWhiteSpace(_settings.Settings.ScreenCapture.Prefix)
? "cap"
: _settings.Settings.ScreenCapture.Prefix.Trim();
public PluginMetadata Metadata => new(
"ScreenCapture",
"화면 캡처 — cap screen/window/scroll/region",
"1.0",
"AX");
// ─── P/Invoke ──────────────────────────────────────────────────────────────
[DllImport("user32.dll")] private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
[DllImport("user32.dll")] private static extern bool IsWindow(IntPtr hWnd);
[DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll")] private static extern bool PrintWindow(IntPtr hwnd, IntPtr hdcBlt, uint nFlags);
[DllImport("user32.dll")] private static extern bool IsWindowVisible(IntPtr hWnd);
// 스크롤/키 메시지 전송
[DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll")] private static extern IntPtr FindWindowEx(IntPtr parent, IntPtr child, string? className, string? windowText);
[StructLayout(LayoutKind.Sequential)]
private struct RECT { public int left, top, right, bottom; }
private const int SW_RESTORE = 9;
private const byte VK_NEXT = 0x22; // Page Down
private const uint WM_VSCROLL = 0x0115;
private const uint WM_KEYDOWN = 0x0100;
private const uint WM_KEYUP = 0x0101;
private const int SB_PAGEDOWN = 3;
// 보안 정책: 캡처 결과는 클립보드에만 복사. 파일 저장 기능 없음.
private static readonly (string Key, string Label, string Desc)[] _options =
[
("region", "영역 선택 캡처", "마우스로 드래그하여 원하는 영역만 캡처 · Shift+Enter: 타이머 캡처"),
("window", "활성 창 캡처", "런처 호출 전 활성 창만 캡처 · Shift+Enter: 타이머 캡처"),
("scroll", "스크롤 캡처", "활성 창을 끝까지 스크롤하며 페이지 전체 캡처 · Shift+Enter: 타이머 캡처"),
("screen", "전체 화면 캡처", "모든 모니터를 포함한 전체 화면 · Shift+Enter: 타이머 캡처"),
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim().ToLowerInvariant();
var saveHint = "클립보드에 복사";
IEnumerable<(string Key, string Label, string Desc)> options = string.IsNullOrWhiteSpace(q)
? _options
: _options.Where(o => o.Key.StartsWith(q) || o.Label.Contains(q));
var items = options.Select(o => new LauncherItem(
o.Label,
$"{o.Desc} · {saveHint}",
null,
o.Key,
Symbol: Symbols.CaptureIcon)).ToList<LauncherItem>();
if (!items.Any())
items.Add(new LauncherItem(
$"알 수 없는 캡처 모드: {q}",
"screen / window / scroll / region",
null, null, Symbol: Symbols.Warning));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
/// <summary>
/// 지연 캡처 타이머 선택 항목을 반환합니다.
/// Data 형식: "delay:모드:초" (예: "delay:region:3")
/// </summary>
public IEnumerable<LauncherItem> GetDelayItems(string mode)
{
var label = _options.FirstOrDefault(o => o.Key == mode).Label ?? mode;
return new[]
{
new LauncherItem($"3초 후 {label}", "Shift+Enter로 지연 캡처", null, $"delay:{mode}:3", Symbol: Symbols.Timer),
new LauncherItem($"5초 후 {label}", "Shift+Enter로 지연 캡처", null, $"delay:{mode}:5", Symbol: Symbols.Timer),
new LauncherItem($"10초 후 {label}", "Shift+Enter로 지연 캡처", null, $"delay:{mode}:10", Symbol: Symbols.Timer),
};
}
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is not string data) return;
// 지연 캡처: "delay:모드:초"
if (data.StartsWith("delay:"))
{
var parts = data.Split(':');
if (parts.Length == 3 && int.TryParse(parts[2], out var delaySec))
{
await ExecuteDelayedCaptureAsync(parts[1], delaySec, ct);
return;
}
}
// 런처가 닫히는 동안 잠시 대기
await Task.Delay(150, ct);
await CaptureDirectAsync(data, ct);
}
/// <summary>
/// 지정된 초만큼 대기 후 캡처를 실행합니다. 완료 시 알림이 표시됩니다.
/// </summary>
private async Task ExecuteDelayedCaptureAsync(string mode, int delaySec, CancellationToken ct)
{
// 런처가 닫히는 동안 잠시 대기
await Task.Delay(200, ct);
// 알림 없이 조용히 대기 후 캡처
for (int i = delaySec; i > 0; i--)
{
ct.ThrowIfCancellationRequested();
await Task.Delay(1000, ct);
}
await CaptureDirectAsync(mode, ct);
}
/// <summary>
/// 런처 없이 직접 캡처를 실행합니다. 글로벌 단축키용.
/// </summary>
public async Task CaptureDirectAsync(string mode, CancellationToken ct = default)
{
try
{
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
// 캡처 결과는 클립보드에만 복사 (보안 정책)
switch (mode)
{
case "screen":
await CaptureScreenAsync(timestamp);
break;
case "window":
await CaptureWindowAsync(timestamp);
break;
case "scroll":
await CaptureScrollAsync(timestamp, ct);
break;
case "region":
await CaptureRegionAsync(timestamp, ct);
break;
}
}
catch (Exception ex)
{
LogService.Error($"캡처 실패: {ex.Message}");
NotificationService.Notify("AX Copilot", $"캡처 실패: {ex.Message}");
}
}
/// <summary>
/// 스크롤 캡처에 사용되는 프레임 간 대기 시간(ms). 설정에서 읽기.
/// </summary>
internal int ScrollDelayMs => Math.Max(50, _settings.Settings.ScreenCapture.ScrollDelayMs);
// ─── 전체 화면 캡처 ────────────────────────────────────────────────────────
private async Task CaptureScreenAsync(string timestamp)
{
var bounds = GetAllScreenBounds();
using var bmp = new Bitmap(bounds.Width, bounds.Height, PixelFormat.Format32bppArgb);
using var g = Graphics.FromImage(bmp);
g.CopyFromScreen(bounds.X, bounds.Y, 0, 0, bounds.Size, CopyPixelOperation.SourceCopy);
CopyToClipboard(bmp);
await Task.Delay(10);
NotificationService.Notify("화면 캡처 완료", "클립보드에 복사되었습니다");
}
// ─── 창 캡처 ───────────────────────────────────────────────────────────────
private async Task CaptureWindowAsync(string timestamp)
{
var hwnd = WindowTracker.PreviousWindow;
if (hwnd == IntPtr.Zero || !IsWindow(hwnd))
{
NotificationService.Notify("AX Copilot", "캡처할 창이 없습니다. 런처 호출 전 창을 확인하세요.");
return;
}
// 런처가 완전히 사라질 때까지 대기 (런처 자체가 캡처되는 버그 방지)
var launcherHwnd = GetLauncherHwnd();
if (launcherHwnd != IntPtr.Zero)
{
for (int i = 0; i < 10 && IsWindowVisible(launcherHwnd); i++)
await Task.Delay(50);
}
ShowWindow(hwnd, SW_RESTORE);
SetForegroundWindow(hwnd);
await Task.Delay(150);
if (!GetWindowRect(hwnd, out var rect)) return;
int w = rect.right - rect.left;
int h = rect.bottom - rect.top;
if (w <= 0 || h <= 0) return;
using var bmp = CaptureWindow(hwnd, w, h, rect);
CopyToClipboard(bmp);
NotificationService.Notify("창 캡처 완료", "클립보드에 복사되었습니다");
}
// ─── 스크롤 캡처 ───────────────────────────────────────────────────────────
private async Task CaptureScrollAsync(string timestamp, CancellationToken ct)
{
var hwnd = WindowTracker.PreviousWindow;
if (hwnd == IntPtr.Zero || !IsWindow(hwnd))
{
NotificationService.Notify("AX Copilot", "캡처할 창이 없습니다.");
return;
}
// 런처가 완전히 사라질 때까지 대기
var launcherHwnd = GetLauncherHwnd();
if (launcherHwnd != IntPtr.Zero)
{
for (int i = 0; i < 10 && IsWindowVisible(launcherHwnd); i++)
await Task.Delay(50, ct);
}
ShowWindow(hwnd, SW_RESTORE);
SetForegroundWindow(hwnd);
await Task.Delay(200, ct);
if (!GetWindowRect(hwnd, out var rect)) return;
int w = rect.right - rect.left;
int h = rect.bottom - rect.top;
if (w <= 0 || h <= 0) return;
// 스크롤 가능한 자식 창 찾기 (웹브라우저, 텍스트뷰어 등)
var scrollTarget = FindScrollableChild(hwnd);
const int maxPages = 15;
var frames = new List<Bitmap>();
// 첫 프레임
frames.Add(CaptureWindow(hwnd, w, h, rect));
for (int i = 0; i < maxPages - 1; i++)
{
ct.ThrowIfCancellationRequested();
// Page Down 전송
if (scrollTarget != IntPtr.Zero)
SendMessage(scrollTarget, WM_VSCROLL, new IntPtr(SB_PAGEDOWN), IntPtr.Zero);
else
SendPageDown(hwnd);
await Task.Delay(ScrollDelayMs, ct);
// 현재 프레임 캡처
if (!GetWindowRect(hwnd, out var newRect)) break;
var frame = CaptureWindow(hwnd, w, h, newRect);
// 이전 프레임과 동일하면 끝 (스크롤 종료 감지)
if (frames.Count > 0 && AreSimilar(frames[^1], frame))
{
frame.Dispose();
break;
}
frames.Add(frame);
}
// 프레임들을 수직으로 이어 붙이기 (오버랩 제거)
using var stitched = StitchFrames(frames, h);
// 각 프레임 해제
foreach (var f in frames) f.Dispose();
CopyToClipboard(stitched);
NotificationService.Notify("스크롤 캡처 완료", $"{stitched.Height}px · 클립보드에 복사되었습니다");
}
// ─── 헬퍼: 창 캡처 (PrintWindow 우선, 실패 시 BitBlt 폴백) ──────────────
private static Bitmap CaptureWindow(IntPtr hwnd, int w, int h, RECT rect)
{
var bmp = new Bitmap(w, h, PixelFormat.Format32bppArgb);
using var g = Graphics.FromImage(bmp);
// PrintWindow로 창 내용 캡처 (최소화된 창도 동작)
var hdc = g.GetHdc();
bool ok = PrintWindow(hwnd, hdc, 2); // PW_RENDERFULLCONTENT = 2
g.ReleaseHdc(hdc);
if (!ok)
{
// 폴백: 화면에서 BitBlt
g.CopyFromScreen(rect.left, rect.top, 0, 0,
new System.Drawing.Size(w, h), CopyPixelOperation.SourceCopy);
}
return bmp;
}
// ─── 헬퍼: 전체 화면 범위 ─────────────────────────────────────────────────
private static System.Drawing.Rectangle GetAllScreenBounds()
{
var screens = System.Windows.Forms.Screen.AllScreens;
int minX = screens.Min(s => s.Bounds.X);
int minY = screens.Min(s => s.Bounds.Y);
int maxX = screens.Max(s => s.Bounds.Right);
int maxY = screens.Max(s => s.Bounds.Bottom);
return new System.Drawing.Rectangle(minX, minY, maxX - minX, maxY - minY);
}
// ─── 헬퍼: 스크롤 가능 자식 창 찾기 ──────────────────────────────────────
private static IntPtr FindScrollableChild(IntPtr hwnd)
{
// 공통 스크롤 가능 클래스 탐색
foreach (var cls in new[] { "Internet Explorer_Server", "Chrome_RenderWidgetHostHWND",
"MozillaWindowClass", "RichEdit20W", "RICHEDIT50W",
"TextBox", "EDIT" })
{
var child = FindWindowEx(hwnd, IntPtr.Zero, cls, null);
if (child != IntPtr.Zero) return child;
}
return IntPtr.Zero;
}
// ─── 헬퍼: Page Down 키 전송 ─────────────────────────────────────────────
private static void SendPageDown(IntPtr hwnd)
{
SendMessage(hwnd, WM_KEYDOWN, new IntPtr(VK_NEXT), IntPtr.Zero);
SendMessage(hwnd, WM_KEYUP, new IntPtr(VK_NEXT), IntPtr.Zero);
}
// ─── 헬퍼: 두 비트맵이 유사한지 비교 (스크롤 종료 감지) ─────────────────
// LockBits를 사용하여 GetPixel 대비 ~50× 빠르게 처리.
private static bool AreSimilar(Bitmap a, Bitmap b)
{
if (a.Width != b.Width || a.Height != b.Height) return false;
int startY = (int)(a.Height * 0.8);
int w = a.Width;
int h = a.Height;
var rectA = new System.Drawing.Rectangle(0, startY, w, h - startY);
var rectB = new System.Drawing.Rectangle(0, startY, w, h - startY);
var dataA = a.LockBits(rectA, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
var dataB = b.LockBits(rectB, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
try
{
int sameCount = 0;
int totalSamples = 0;
int stride = dataA.Stride;
int sampleW = w / 16 + 1;
int sampleH = (h - startY) / 8 + 1;
unsafe
{
byte* ptrA = (byte*)dataA.Scan0.ToPointer();
byte* ptrB = (byte*)dataB.Scan0.ToPointer();
for (int sy = 0; sy < sampleH; sy++)
{
int row = sy * 8;
if (row >= h - startY) break;
for (int sx = 0; sx < sampleW; sx++)
{
int col = sx * 16;
if (col >= w) break;
int idx = row * stride + col * 4;
if (Math.Abs(ptrA[idx] - ptrB[idx]) < 5 &&
Math.Abs(ptrA[idx + 1] - ptrB[idx + 1]) < 5 &&
Math.Abs(ptrA[idx + 2] - ptrB[idx + 2]) < 5)
sameCount++;
totalSamples++;
}
}
}
return totalSamples > 0 && (double)sameCount / totalSamples > 0.97;
}
finally
{
a.UnlockBits(dataA);
b.UnlockBits(dataB);
}
}
// ─── 헬퍼: 프레임 이어 붙이기 (증분만 추가) ──────────────────────────────
private static Bitmap StitchFrames(List<Bitmap> frames, int windowHeight)
{
if (frames.Count == 0)
return new Bitmap(1, 1);
if (frames.Count == 1)
return new Bitmap(frames[0]);
int w = frames[0].Width;
// 각 프레임에서 새로운 부분의 시작 Y (오버랩 제외)
var newPartStarts = new List<int>(); // 인덱스 1부터: frames[i]에서 오버랩 이후 시작 행
var newPartHeights = new List<int>(); // 새로운 부분 높이
int totalHeight = windowHeight; // 첫 프레임은 전체 사용
for (int i = 1; i < frames.Count; i++)
{
int overlap = FindOverlap(frames[i - 1], frames[i]);
int newStart = overlap > 0 ? overlap : windowHeight / 5; // 오버랩 감지 실패 시 상단 20% 제거
int newH = windowHeight - newStart;
if (newH <= 0) { newH = windowHeight / 4; newStart = windowHeight - newH; }
newPartStarts.Add(newStart);
newPartHeights.Add(newH);
totalHeight += newH;
}
var result = new Bitmap(w, totalHeight, PixelFormat.Format32bppArgb);
using var g = Graphics.FromImage(result);
// 첫 프레임: 전체 그리기
g.DrawImage(frames[0], 0, 0, w, windowHeight);
// 이후 프레임: 새로운 부분(증분)만 잘라서 붙이기
int yPos = windowHeight;
for (int i = 1; i < frames.Count; i++)
{
int srcY = newPartStarts[i - 1];
int srcH = newPartHeights[i - 1];
var srcRect = new System.Drawing.Rectangle(0, srcY, w, srcH);
var dstRect = new System.Drawing.Rectangle(0, yPos, w, srcH);
g.DrawImage(frames[i], dstRect, srcRect, System.Drawing.GraphicsUnit.Pixel);
yPos += srcH;
}
return result;
}
// ─── 헬퍼: 두 프레임 사이 오버랩 픽셀 수 계산 (다중 행 비교) ────────────
private static int FindOverlap(Bitmap prev, Bitmap next)
{
int w = Math.Min(prev.Width, next.Width);
int h = prev.Height;
if (h < 16 || w < 16) return 0;
int searchRange = (int)(h * 0.7); // 최대 70% 오버랩 탐색
const int checkRows = 8; // 오버랩 후보당 검증할 행 수
var dataPrev = prev.LockBits(
new System.Drawing.Rectangle(0, 0, prev.Width, prev.Height),
ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
var dataNext = next.LockBits(
new System.Drawing.Rectangle(0, 0, next.Width, next.Height),
ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
try
{
int stridePrev = dataPrev.Stride;
int strideNext = dataNext.Stride;
int bestOverlap = 0;
unsafe
{
byte* ptrPrev = (byte*)dataPrev.Scan0.ToPointer();
byte* ptrNext = (byte*)dataNext.Scan0.ToPointer();
// 큰 오버랩부터 탐색 (스크롤은 보통 1페이지 미만)
for (int overlap = searchRange; overlap > 8; overlap -= 2)
{
int prevStartY = h - overlap;
if (prevStartY < 0) continue;
int totalMatch = 0;
int totalCheck = 0;
// 오버랩 영역 내 여러 행을 검증
for (int r = 0; r < checkRows; r++)
{
int rowInOverlap = r * (overlap / checkRows);
int prevRow = prevStartY + rowInOverlap;
int nextRow = rowInOverlap;
if (prevRow >= h || nextRow >= next.Height) continue;
// 행 내 샘플 픽셀 비교
for (int x = 4; x < w - 4; x += 12)
{
int idxP = prevRow * stridePrev + x * 4;
int idxN = nextRow * strideNext + x * 4;
if (idxP + 2 >= dataPrev.Height * stridePrev) continue;
if (idxN + 2 >= dataNext.Height * strideNext) continue;
if (Math.Abs(ptrPrev[idxP] - ptrNext[idxN]) < 10 &&
Math.Abs(ptrPrev[idxP + 1] - ptrNext[idxN + 1]) < 10 &&
Math.Abs(ptrPrev[idxP + 2] - ptrNext[idxN + 2]) < 10)
totalMatch++;
totalCheck++;
}
}
if (totalCheck > 0 && (double)totalMatch / totalCheck > 0.80)
{
bestOverlap = overlap;
break;
}
}
}
return bestOverlap;
}
finally
{
prev.UnlockBits(dataPrev);
next.UnlockBits(dataNext);
}
}
// ─── 영역 선택 캡처 ──────────────────────────────────────────────────────
private async Task CaptureRegionAsync(string timestamp, CancellationToken ct)
{
// 전체 화면을 먼저 캡처 (배경으로 사용)
var bounds = GetAllScreenBounds();
using var 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);
// WPF 오버레이 창으로 영역 선택
System.Drawing.Rectangle? selected = null;
System.Windows.Application.Current?.Dispatcher.Invoke(() =>
{
var overlay = new AxCopilot.Views.RegionSelectWindow(fullBmp, bounds);
overlay.ShowDialog();
selected = overlay.SelectedRect;
});
if (selected == null || selected.Value.Width < 4 || selected.Value.Height < 4)
{
NotificationService.Notify("AX Copilot", "영역 선택이 취소되었습니다.");
return;
}
var r = selected.Value;
using var crop = new Bitmap(r.Width, r.Height, PixelFormat.Format32bppArgb);
using (var g = Graphics.FromImage(crop))
g.DrawImage(fullBmp, new System.Drawing.Rectangle(0, 0, r.Width, r.Height), r, System.Drawing.GraphicsUnit.Pixel);
CopyToClipboard(crop);
NotificationService.Notify("영역 캡처 완료", $"{r.Width}×{r.Height} · 클립보드에 복사되었습니다");
await Task.CompletedTask;
}
// ─── 헬퍼: 클립보드에 이미지 복사 ───────────────────────────────────────
private static void CopyToClipboard(Bitmap bmp)
{
try
{
// WPF Clipboard에 BitmapSource로 복사
System.Windows.Application.Current?.Dispatcher.Invoke(() =>
{
using var ms = new MemoryStream();
bmp.Save(ms, ImageFormat.Bmp);
ms.Position = 0;
var bitmapImage = new BitmapImage();
bitmapImage.BeginInit();
bitmapImage.StreamSource = ms;
bitmapImage.CacheOption = BitmapCacheOption.OnLoad;
bitmapImage.EndInit();
bitmapImage.Freeze();
System.Windows.Clipboard.SetImage(bitmapImage);
});
}
catch (Exception ex)
{
LogService.Warn($"클립보드 이미지 복사 실패: {ex.Message}");
}
}
// ─── 헬퍼: 런처 창 핸들 조회 (캡처 시 런처 숨김 대기용) ───────────────
private static IntPtr GetLauncherHwnd()
{
try
{
IntPtr hwnd = IntPtr.Zero;
System.Windows.Application.Current?.Dispatcher.Invoke(() =>
{
var launcher = System.Windows.Application.Current.Windows
.OfType<System.Windows.Window>()
.FirstOrDefault(w => w.GetType().Name == "LauncherWindow");
if (launcher != null)
hwnd = new System.Windows.Interop.WindowInteropHelper(launcher).Handle;
});
return hwnd;
}
catch (Exception) { return IntPtr.Zero; }
}
}

View File

@@ -0,0 +1,271 @@
using System.Diagnostics;
using System.ServiceProcess;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// Windows 서비스 관리 핸들러. "svc" 프리픽스로 사용합니다.
/// 서비스 검색, 상태 조회, 시작/중지/재시작.
/// 특수 명령: svc restart clipboard → 클립보드 히스토리 서비스 강제 재시작
/// 예: svc → 실행 중 서비스 목록 (상위 20개)
/// svc spooler → "spooler" 검색
/// svc start [이름] → 서비스 시작
/// svc stop [이름] → 서비스 중지
/// svc restart clipboard → AX 클립보드 히스토리 서비스 재시작
/// </summary>
public class ServiceHandler : IActionHandler
{
private readonly ClipboardHistoryService? _clipboardService;
public ServiceHandler(ClipboardHistoryService? clipboardService = null)
{
_clipboardService = clipboardService;
}
public string? Prefix => "svc";
public PluginMetadata Metadata => new(
"Service",
"Windows 서비스 관리 — svc",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
// 특수: 클립보드 서비스 재시작
if (q.Equals("restart clipboard", StringComparison.OrdinalIgnoreCase) ||
q.Equals("클립보드 재시작", StringComparison.OrdinalIgnoreCase))
{
items.Add(new LauncherItem(
"AX 클립보드 히스토리 서비스 재시작",
"클립보드 감지가 작동하지 않을 때 사용 · Enter로 실행",
null, "__RESTART_CLIPBOARD__",
Symbol: Symbols.Restart));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// start/stop/restart 명령
if (q.StartsWith("start ", StringComparison.OrdinalIgnoreCase) ||
q.StartsWith("stop ", StringComparison.OrdinalIgnoreCase) ||
q.StartsWith("restart ", StringComparison.OrdinalIgnoreCase))
{
var parts = q.Split(' ', 2, StringSplitOptions.TrimEntries);
var action = parts[0].ToLowerInvariant();
var svcName = parts.Length > 1 ? parts[1] : "";
if (!string.IsNullOrWhiteSpace(svcName))
{
try
{
var svc = ServiceController.GetServices()
.FirstOrDefault(s =>
s.ServiceName.Contains(svcName, StringComparison.OrdinalIgnoreCase) ||
s.DisplayName.Contains(svcName, StringComparison.OrdinalIgnoreCase));
if (svc != null)
{
var actionLabel = action switch
{
"start" => "시작",
"stop" => "중지",
"restart" => "재시작",
_ => action
};
items.Add(new LauncherItem(
$"[{actionLabel}] {svc.DisplayName}",
$"서비스명: {svc.ServiceName} · 현재 상태: {StatusText(svc.Status)} · Enter로 실행",
null, ($"__{action.ToUpperInvariant()}__", svc.ServiceName),
Symbol: action == "stop" ? Symbols.Error : Symbols.Restart));
}
else
{
items.Add(new LauncherItem(
$"'{svcName}' 서비스를 찾을 수 없습니다",
"서비스 이름 또는 표시 이름으로 검색",
null, null, Symbol: Symbols.Warning));
}
}
catch (Exception ex)
{
items.Add(new LauncherItem("서비스 조회 실패", ex.Message, null, null, Symbol: Symbols.Error));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
}
// 서비스 검색/목록
try
{
var services = ServiceController.GetServices();
var filtered = string.IsNullOrWhiteSpace(q)
? services.Where(s => s.Status == ServiceControllerStatus.Running)
.OrderBy(s => s.DisplayName).Take(20)
: services.Where(s =>
s.ServiceName.Contains(q, StringComparison.OrdinalIgnoreCase) ||
s.DisplayName.Contains(q, StringComparison.OrdinalIgnoreCase))
.OrderBy(s => s.DisplayName).Take(20);
// 클립보드 재시작 항목 항상 상단에 표시
items.Add(new LauncherItem(
"AX 클립보드 히스토리 서비스 재시작",
"svc restart clipboard · 클립보드 감지 문제 시 사용",
null, "__RESTART_CLIPBOARD__",
Symbol: Symbols.Restart));
foreach (var svc in filtered)
{
var status = StatusText(svc.Status);
var symbol = svc.Status == ServiceControllerStatus.Running
? "\uE73E" // 체크마크
: "\uE711"; // X
items.Add(new LauncherItem(
$"[{status}] {svc.DisplayName}",
$"{svc.ServiceName} · svc start/stop/restart {svc.ServiceName}",
null, svc.ServiceName,
Symbol: symbol));
}
}
catch (Exception ex)
{
items.Add(new LauncherItem("서비스 조회 실패", ex.Message, null, null, Symbol: Symbols.Error));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
// 클립보드 서비스 재시작
if (item.Data is string s && s == "__RESTART_CLIPBOARD__")
{
RestartClipboardService();
return Task.CompletedTask;
}
// Windows 서비스 제어
if (item.Data is ValueTuple<string, string> cmd)
{
var (action, svcName) = cmd;
return ExecuteServiceAction(action, svcName);
}
// 서비스 이름 클립보드 복사
if (item.Data is string name)
{
try { Application.Current?.Dispatcher.Invoke(() => Clipboard.SetText(name)); }
catch (Exception) { }
}
return Task.CompletedTask;
}
private void RestartClipboardService()
{
try
{
if (_clipboardService == null)
{
NotificationService.Notify("AX Copilot", "클립보드 서비스 참조를 찾을 수 없습니다.");
return;
}
// Dispose 후 재초기화
_clipboardService.Dispose();
// 약간의 딜레이 후 재초기화
Application.Current?.Dispatcher.BeginInvoke(() =>
{
try
{
_clipboardService.Reinitialize();
NotificationService.Notify("AX Copilot", "클립보드 히스토리 서비스가 재시작되었습니다.");
LogService.Info("클립보드 히스토리 서비스 강제 재시작 완료");
}
catch (Exception ex)
{
NotificationService.Notify("AX Copilot", $"클립보드 재시작 실패: {ex.Message}");
LogService.Error($"클립보드 재시작 실패: {ex.Message}");
}
}, System.Windows.Threading.DispatcherPriority.Background);
}
catch (Exception ex)
{
NotificationService.Notify("AX Copilot", $"클립보드 재시작 실패: {ex.Message}");
}
}
private static async Task ExecuteServiceAction(string action, string svcName)
{
try
{
// sc.exe를 사용하여 관리자 권한으로 실행
var verb = action switch
{
"__START__" => "start",
"__STOP__" => "stop",
"__RESTART__" => "stop", // restart는 stop + start
_ => null
};
if (verb == null) return;
var psi = new ProcessStartInfo("sc.exe", $"{verb} \"{svcName}\"")
{
Verb = "runas",
UseShellExecute = true,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden
};
var proc = Process.Start(psi);
proc?.WaitForExit(5000);
if (action == "__RESTART__")
{
await Task.Delay(1000);
var startPsi = new ProcessStartInfo("sc.exe", $"start \"{svcName}\"")
{
Verb = "runas",
UseShellExecute = true,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden
};
Process.Start(startPsi)?.WaitForExit(5000);
}
var label = action switch
{
"__START__" => "시작",
"__STOP__" => "중지",
"__RESTART__" => "재시작",
_ => action
};
NotificationService.Notify("서비스 관리", $"{svcName} {label} 요청 완료");
}
catch (Exception ex)
{
NotificationService.Notify("AX Copilot", $"서비스 제어 실패: {ex.Message}");
}
}
private static string StatusText(ServiceControllerStatus status) => status switch
{
ServiceControllerStatus.Running => "실행 중",
ServiceControllerStatus.Stopped => "중지됨",
ServiceControllerStatus.StartPending => "시작 중",
ServiceControllerStatus.StopPending => "중지 중",
ServiceControllerStatus.Paused => "일시 중지",
ServiceControllerStatus.ContinuePending => "재개 중",
ServiceControllerStatus.PausePending => "일시 중지 중",
_ => status.ToString()
};
}

View File

@@ -0,0 +1,201 @@
using System.Runtime.InteropServices;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 창 스냅/레이아웃 핸들러. "snap" 프리픽스로 사용합니다.
/// 런처 호출 직전의 활성 창을 대상으로 배치합니다.
///
/// 예: snap left → 화면 왼쪽 절반
/// snap right → 화면 오른쪽 절반
/// snap top → 화면 위쪽 절반
/// snap bottom → 화면 아래쪽 절반
/// snap full → 전체 화면 (최대화)
/// snap tl → 좌상단 1/4
/// snap tr → 우상단 1/4
/// snap bl → 좌하단 1/4
/// snap br → 우하단 1/4
/// snap center → 화면 중앙 (80% 크기)
/// snap restore → 이전 크기/위치로 복원
/// </summary>
public class SnapHandler : IActionHandler
{
public string? Prefix => "snap";
public PluginMetadata Metadata => new(
"WindowSnap",
"창 배치 — 2/3/4분할, 1/3·2/3, 전체화면, 중앙, 복원",
"1.1",
"AX");
// ─── P/Invoke ──────────────────────────────────────────────────────────────
[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);
[DllImport("user32.dll")] private static extern bool IsWindow(IntPtr hWnd);
[DllImport("user32.dll")] private static extern bool IsIconic(IntPtr hWnd);
[StructLayout(LayoutKind.Sequential)]
private struct RECT { public int left, top, right, bottom; }
[StructLayout(LayoutKind.Sequential)]
private struct MONITORINFO
{
public int cbSize;
public RECT rcMonitor;
public RECT rcWork; // 작업표시줄 제외 영역
public uint dwFlags;
}
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;
private static readonly (string Key, string Label, string Desc)[] _snapOptions =
[
// ── 2분할 ──
("left", "왼쪽 절반", "화면 왼쪽 50% 영역에 배치"),
("right", "오른쪽 절반", "화면 오른쪽 50% 영역에 배치"),
("top", "위쪽 절반", "화면 위쪽 50% 영역에 배치"),
("bottom", "아래쪽 절반", "화면 아래쪽 50% 영역에 배치"),
// ── 4분할 ──
("tl", "좌상단 1/4", "화면 좌상단 25% 영역에 배치"),
("tr", "우상단 1/4", "화면 우상단 25% 영역에 배치"),
("bl", "좌하단 1/4", "화면 좌하단 25% 영역에 배치"),
("br", "우하단 1/4", "화면 우하단 25% 영역에 배치"),
// ── 3분할 (좌 50% + 우 상하) ──
("l-rt", "좌반 + 우상", "왼쪽 50% + 오른쪽 상단 25% (2창용)"),
("l-rb", "좌반 + 우하", "왼쪽 50% + 오른쪽 하단 25% (2창용)"),
("r-lt", "우반 + 좌상", "오른쪽 50% + 왼쪽 상단 25% (2창용)"),
("r-lb", "우반 + 좌하", "오른쪽 50% + 왼쪽 하단 25% (2창용)"),
// ── 3등분 (가로) ──
("third-l", "좌측 1/3", "화면 왼쪽 33% 영역에 배치"),
("third-c", "중앙 1/3", "화면 가운데 33% 영역에 배치"),
("third-r", "우측 1/3", "화면 오른쪽 33% 영역에 배치"),
// ── 2/3 + 1/3 ──
("two3-l", "좌측 2/3", "화면 왼쪽 66% 영역에 배치"),
("two3-r", "우측 2/3", "화면 오른쪽 66% 영역에 배치"),
// ── 기타 ──
("full", "전체 화면", "최대화"),
("center", "화면 중앙", "화면 중앙 80% 크기로 배치"),
("restore", "원래 크기 복원", "창을 이전 크기로 복원"),
];
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim().ToLowerInvariant();
var hwnd = WindowTracker.PreviousWindow;
var windowValid = hwnd != IntPtr.Zero && IsWindow(hwnd);
var hint = windowValid ? "Enter로 현재 활성 창에 적용" : "대상 창 없음 — 런처를 열기 전 창에 적용됩니다";
IEnumerable<(string Key, string Label, string Desc)> options = string.IsNullOrWhiteSpace(q)
? _snapOptions
: _snapOptions.Where(o => o.Key.StartsWith(q) || o.Label.Contains(q));
var items = options.Select(o => new LauncherItem(
o.Label,
$"{o.Desc} · {hint}",
null,
o.Key,
Symbol: Symbols.SnapLayout)).ToList<LauncherItem>();
if (!items.Any())
items.Add(new LauncherItem(
$"알 수 없는 스냅 방향: {q}",
"left / right / tl / tr / bl / br / third-l/c/r / two3-l/r / full / center / restore",
null, null, Symbol: Symbols.Warning));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is not string snapKey) return;
var hwnd = WindowTracker.PreviousWindow;
if (hwnd == IntPtr.Zero || !IsWindow(hwnd)) return;
// 최대화/아이콘화 상태면 먼저 복원
if (IsIconic(hwnd))
ShowWindow(hwnd, SW_RESTORE);
// 잠시 대기 (런처 닫힘 애니메이션 후 적용)
await Task.Delay(80, ct);
ApplySnap(hwnd, snapKey);
}
private static void ApplySnap(IntPtr hwnd, string key)
{
// 창이 속한 모니터의 작업 영역 가져오기
var hMonitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
var mi = new MONITORINFO { cbSize = Marshal.SizeOf<MONITORINFO>() };
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;
if (key == "full")
{
ShowWindow(hwnd, SW_MAXIMIZE);
return;
}
if (key == "restore")
{
ShowWindow(hwnd, SW_RESTORE);
return;
}
var (x, y, cw, ch) = key switch
{
// 2분할
"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),
// 4분할
"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),
// 3분할 (좌반 + 우상/우하)
"l-rt" => (mx + mw / 2, my, mw / 2, mh / 2),
"l-rb" => (mx + mw / 2, my + mh / 2, mw / 2, mh / 2),
"r-lt" => (mx, my, mw / 2, mh / 2),
"r-lb" => (mx, my + mh / 2, mw / 2, mh / 2),
// 3등분
"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),
// 2/3 + 1/3
"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)
};
// SW_RESTORE 후 SetWindowPos — 최대화 플래그 해제 필요
ShowWindow(hwnd, SW_RESTORE);
SetWindowPos(hwnd, IntPtr.Zero, x, y, cw, ch, SWP_SHOWWINDOW | SWP_NOZORDER);
}
}

View File

@@ -0,0 +1,138 @@
using System.Windows;
using AxCopilot.Models;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 텍스트 스니펫 핸들러. ";" 프리픽스로 사용합니다.
/// 예: ;addr → 미리 저장된 주소 텍스트를 클립보드에 복사 후 붙여넣기
/// ;sig → 서명 텍스트 붙여넣기
/// </summary>
public class SnippetHandler : IActionHandler
{
private readonly SettingsService _settings;
public string? Prefix => ";";
public PluginMetadata Metadata => new(
"Snippets",
"텍스트 스니펫 — ; 뒤에 키워드 입력",
"1.0",
"AX");
public SnippetHandler(SettingsService settings)
{
_settings = settings;
}
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var snippets = _settings.Settings.Snippets;
if (!snippets.Any())
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
"등록된 스니펫이 없습니다",
"설정 → 스니펫 탭에서 추가하세요",
null, null, Symbol: Symbols.Snippet)
]);
}
var q = query.Trim().ToLowerInvariant();
var results = snippets
.Where(s => string.IsNullOrEmpty(q) ||
s.Key.ToLowerInvariant().Contains(q) ||
s.Name.ToLowerInvariant().Contains(q))
.Select(s => new LauncherItem(
s.Name.Length > 0 ? s.Name : s.Key,
TruncatePreview(s.Content),
null,
s,
Symbol: Symbols.Snippet))
.ToList();
if (results.Count == 0)
{
results.Add(new LauncherItem(
$"'{query}'에 해당하는 스니펫 없음",
"설정에서 새 스니펫을 추가하세요",
null, null, Symbol: Symbols.Snippet));
}
return Task.FromResult<IEnumerable<LauncherItem>>(results);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is not SnippetEntry snippet) return Task.CompletedTask;
try
{
// 변수 치환: {date}, {time}, {datetime}
var expanded = ExpandVariables(snippet.Content);
Clipboard.SetText(expanded);
// 100ms 후 Ctrl+V 시뮬레이션
Task.Delay(100, ct).ContinueWith(_ =>
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
var inputs = new[]
{
new INPUT { type = 1, u = new InputUnion { ki = new KEYBDINPUT { wVk = 0x11 } } }, // Ctrl down
new INPUT { type = 1, u = new InputUnion { ki = new KEYBDINPUT { wVk = 0x56 } } }, // V down
new INPUT { type = 1, u = new InputUnion { ki = new KEYBDINPUT { wVk = 0x56, dwFlags = 0x0002 } } }, // V up
new INPUT { type = 1, u = new InputUnion { ki = new KEYBDINPUT { wVk = 0x11, dwFlags = 0x0002 } } }, // Ctrl up
};
SendInput((uint)inputs.Length, inputs, System.Runtime.InteropServices.Marshal.SizeOf<INPUT>());
});
}, ct, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Default);
}
catch (Exception ex)
{
LogService.Warn($"스니펫 실행 실패: {ex.Message}");
}
return Task.CompletedTask;
}
// ─── 변수 치환 ─────────────────────────────────────────────────────────
private static string ExpandVariables(string content)
{
var now = DateTime.Now;
return 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"));
}
private static string TruncatePreview(string content)
{
var oneLine = content.Replace("\r\n", " ").Replace("\n", " ").Replace("\r", " ");
return oneLine.Length > 60 ? oneLine[..57] + "…" : oneLine;
}
// ─── P/Invoke (SendInput) ──────────────────────────────────────────────
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
private struct INPUT { public uint type; public InputUnion u; }
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Explicit)]
private struct InputUnion { [System.Runtime.InteropServices.FieldOffset(0)] public KEYBDINPUT ki; }
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
private struct KEYBDINPUT { public ushort wVk; public ushort wScan; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; }
}

View File

@@ -0,0 +1,322 @@
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using System.Windows;
using AxCopilot.Models;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
using AxCopilot.Views;
namespace AxCopilot.Handlers;
/// <summary>
/// 시스템 명령 핸들러. "/" 프리픽스로 사용합니다.
/// 예: /lock → 화면 잠금
/// /sleep → 절전 모드
/// /shutdown → 종료
/// /timer 5m → 5분 타이머
/// /timer 1h30m → 1시간 30분 타이머
/// /timer 30s 회의 → 라벨 포함 타이머
/// /alarm 14:30 → 지정 시각 알람
/// </summary>
public class SystemCommandHandler : IActionHandler
{
private static App? CurrentApp => System.Windows.Application.Current as App;
private readonly SettingsService _settings;
public string? Prefix => "/";
public PluginMetadata Metadata => new(
"SystemCommands",
"시스템 명령 — / 뒤에 명령 입력",
"1.0",
"AX");
public SystemCommandHandler(SettingsService settings)
{
_settings = settings;
}
// 타이머 파싱: 5m, 30s, 1h, 1h30m, 2h15m30s
private static readonly Regex _timerRe = new(
@"^(?:(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?)$",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
// 알람 파싱: 14:30, 9:00, 23:59
private static readonly Regex _alarmRe = new(
@"^(\d{1,2}):(\d{2})$",
RegexOptions.Compiled);
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var cfg = _settings.Settings.SystemCommands;
var q = query.Trim().ToLowerInvariant();
// ─── 타이머 ─────────────────────────────────────────────────────────
if (q.StartsWith("timer"))
{
var rest = q[5..].Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrEmpty(rest))
{
items.Add(new LauncherItem("타이머 — 시간을 입력하세요",
"예: /timer 5m · /timer 1h30m · /timer 30s 회의",
null, null, Symbol: Symbols.Timer));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 라벨 분리: "5m 회의" → durationPart="5m", label="회의"
var parts = rest.Split(' ', 2);
var durationStr = parts[0];
var label = parts.Length > 1 ? parts[1].Trim() : "";
if (TryParseTimer(durationStr, out var seconds) && seconds > 0)
{
var display = FormatDuration(seconds);
var title = string.IsNullOrEmpty(label) ? $"타이머 {display}" : $"타이머 {display} — {label}";
items.Add(new LauncherItem(
title,
$"{display} 후 알림 · Enter로 시작",
null,
(Func<Task>)(() => StartTimerAsync(seconds, label.Length > 0 ? label : display)),
Symbol: Symbols.Timer));
}
else
{
items.Add(new LauncherItem("형식 오류",
"예: /timer 5m · /timer 1h30m · /timer 90s",
null, null, Symbol: Symbols.Error));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ─── 알람 ─────────────────────────────────────────────────────────
if (q.StartsWith("alarm"))
{
var rest = q[5..].Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrEmpty(rest))
{
items.Add(new LauncherItem("알람 — 시각을 입력하세요",
"예: /alarm 14:30 · /alarm 9:00",
null, null, Symbol: Symbols.Timer));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var m = _alarmRe.Match(rest.Split(' ')[0]);
if (m.Success)
{
int hour = int.Parse(m.Groups[1].Value);
int min = int.Parse(m.Groups[2].Value);
var label = rest.Contains(' ') ? rest[(rest.IndexOf(' ') + 1)..] : "";
if (hour is >= 0 and <= 23 && min is >= 0 and <= 59)
{
var target = DateTime.Today.AddHours(hour).AddMinutes(min);
if (target <= DateTime.Now) target = target.AddDays(1); // 내일로
var diff = (int)(target - DateTime.Now).TotalSeconds;
var timeStr = target.ToString("HH:mm");
items.Add(new LauncherItem(
$"알람 {timeStr} {(target.Date > DateTime.Today ? "()" : "")}",
$"{FormatDuration(diff)} 후 알림 · Enter로 시작 {(label.Length > 0 ? " " + label : "")}".Trim(),
null,
(Func<Task>)(() => StartTimerAsync(diff, label.Length > 0 ? label : $"{timeStr} 알람")),
Symbol: Symbols.Timer));
}
else
{
items.Add(new LauncherItem("시각 범위 오류",
"00:00 ~ 23:59 범위로 입력하세요",
null, null, Symbol: Symbols.Error));
}
}
else
{
items.Add(new LauncherItem("형식 오류",
"예: /alarm 14:30 · /alarm 09:00",
null, null, Symbol: Symbols.Error));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// ─── 일반 시스템 명령 ────────────────────────────────────────────────
var allCommands = new List<(string Key, string Name, string Hint, string Symbol, bool Enabled, Func<Task> Action)>
{
("lock", "화면 잠금", "현재 세션을 잠급니다", Symbols.Lock, cfg.ShowLock, LockAsync),
("sleep", "절전 모드", "시스템을 절전 상태로 전환합니다", Symbols.Sleep, cfg.ShowSleep, SleepAsync),
("restart", "재시작", "컴퓨터를 재시작합니다", Symbols.Restart, cfg.ShowRestart, RestartAsync),
("shutdown", "시스템 종료", "컴퓨터를 종료합니다", Symbols.Power, cfg.ShowShutdown, ShutdownAsync),
("hibernate", "최대 절전", "최대 절전 모드로 전환합니다", Symbols.Sleep, cfg.ShowHibernate, HibernateAsync),
("logout", "로그아웃", "현재 사용자 세션에서 로그아웃합니다", Symbols.Logout, cfg.ShowLogout, LogoutAsync),
("recycle", "휴지통 비우기", "휴지통의 모든 파일을 영구 삭제합니다", Symbols.RecycleBin, cfg.ShowRecycleBin, EmptyRecycleBinAsync),
("dock", "독 바 표시/숨기기", "화면 하단 독 바를 표시하거나 숨깁니다", Symbols.SnapLayout, true, ToggleDockAsync),
// timer/alarm은 q.StartsWith("timer"/"alarm") early return으로 처리됨
};
var filtered = allCommands
.Where(c => c.Enabled)
.Where(c => string.IsNullOrEmpty(q) ||
c.Key.StartsWith(q) ||
c.Name.Contains(q) ||
(cfg.CommandAliases.TryGetValue(c.Key, out var ca) &&
ca.Any(a => a.StartsWith(q, StringComparison.OrdinalIgnoreCase))))
.Select(c => new LauncherItem(
c.Name,
c.Hint,
null,
c.Action,
Symbol: c.Symbol));
return Task.FromResult<IEnumerable<LauncherItem>>(filtered.ToList());
}
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is Func<Task> action)
await action();
}
// ─── 타이머 구현 ─────────────────────────────────────────────────────────
private static async Task StartTimerAsync(int totalSeconds, string label)
{
await Task.Delay(TimeSpan.FromSeconds(totalSeconds));
NotificationService.Notify("⏰ 타이머 완료", label);
}
private static bool TryParseTimer(string s, out int totalSeconds)
{
totalSeconds = 0;
var m = _timerRe.Match(s.ToLowerInvariant());
if (!m.Success) return false;
int h = m.Groups[1].Success ? int.Parse(m.Groups[1].Value) : 0;
int mn = m.Groups[2].Success ? int.Parse(m.Groups[2].Value) : 0;
int sc = m.Groups[3].Success ? int.Parse(m.Groups[3].Value) : 0;
totalSeconds = h * 3600 + mn * 60 + sc;
return true;
}
private static string FormatDuration(int totalSeconds)
{
int h = totalSeconds / 3600;
int m = (totalSeconds % 3600) / 60;
int s = totalSeconds % 60;
if (h > 0 && m > 0) return $"{h}시간 {m}분";
if (h > 0) return $"{h}시간";
if (m > 0 && s > 0) return $"{m}분 {s}초";
if (m > 0) return $"{m}분";
return $"{s}초";
}
// ─── 시스템 명령 구현 ───────────────────────────────────────────────────
private static Task LockAsync()
{
LockWorkStation();
return Task.CompletedTask;
}
private static Task SleepAsync()
{
System.Windows.Forms.Application.SetSuspendState(
System.Windows.Forms.PowerState.Suspend, false, false);
return Task.CompletedTask;
}
private static Task HibernateAsync()
{
System.Windows.Forms.Application.SetSuspendState(
System.Windows.Forms.PowerState.Hibernate, false, false);
return Task.CompletedTask;
}
private static Task RestartAsync()
{
var result = CustomMessageBox.Show(
"컴퓨터를 재시작하시겠습니까?\n저장되지 않은 작업이 있으면 먼저 저장하세요.",
"재시작 확인",
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (result == MessageBoxResult.Yes)
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(
"shutdown", "/r /t 5 /c \"AX Copilot에 의해 재시작됩니다.\"")
{ UseShellExecute = true, CreateNoWindow = true });
return Task.CompletedTask;
}
private static Task ShutdownAsync()
{
var result = CustomMessageBox.Show(
"컴퓨터를 종료하시겠습니까?\n저장되지 않은 작업이 있으면 먼저 저장하세요.",
"종료 확인",
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (result == MessageBoxResult.Yes)
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(
"shutdown", "/s /t 5 /c \"AX Copilot에 의해 종료됩니다.\"")
{ UseShellExecute = true, CreateNoWindow = true });
return Task.CompletedTask;
}
private static Task LogoutAsync()
{
var result = CustomMessageBox.Show(
"로그아웃하시겠습니까?",
"로그아웃 확인",
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (result == MessageBoxResult.Yes)
ExitWindowsEx(0, 0); // EWX_LOGOFF
return Task.CompletedTask;
}
private static Task EmptyRecycleBinAsync()
{
var result = CustomMessageBox.Show(
"휴지통을 비우시겠습니까?\n삭제된 파일은 복구할 수 없습니다.",
"휴지통 비우기",
MessageBoxButton.YesNo,
MessageBoxImage.Warning);
if (result == MessageBoxResult.Yes)
SHEmptyRecycleBin(IntPtr.Zero, null,
0x0001 | 0x0002 | 0x0004); // SHERB_NOCONFIRMATION | SHERB_NOPROGRESSUI | SHERB_NOSOUND
return Task.CompletedTask;
}
private static Task ToggleDockAsync()
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
CurrentApp?.ToggleDockBar();
});
return Task.CompletedTask;
}
// ─── P/Invoke ──────────────────────────────────────────────────────────
[DllImport("user32.dll", SetLastError = true)]
private static extern bool LockWorkStation();
[DllImport("user32.dll", SetLastError = true)]
private static extern bool ExitWindowsEx(uint uFlags, uint dwReason);
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern uint SHEmptyRecycleBin(IntPtr hwnd, string? pszRootPath, uint dwFlags);
}

View File

@@ -0,0 +1,509 @@
using System.Diagnostics;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
using AxCopilot.Views;
namespace AxCopilot.Handlers;
/// <summary>
/// 시스템 정보를 표시합니다. "info" 프리픽스로 사용합니다.
/// 예: info → 전체 시스템 정보 목록
/// info ip → IP 주소
/// info battery → 배터리 상태
/// info uptime → 시스템 가동 시간
/// info volume → 볼륨 수준
/// </summary>
public class SystemInfoHandler : IActionHandler
{
public string? Prefix => "info";
public PluginMetadata Metadata => new(
"SystemInfo",
"시스템 정보 — IP, 배터리, 볼륨, 가동시간 등",
"1.0",
"AX");
// ─── P/Invoke ────────────────────────────────────────────────────────────
[DllImport("winmm.dll")]
private static extern int waveOutGetVolume(IntPtr hwo, out uint dwVolume);
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GlobalMemoryStatusEx(ref MEMORYSTATUSEX lpBuffer);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
private struct MEMORYSTATUSEX
{
public uint dwLength;
public uint dwMemoryLoad; // % 사용 중
public ulong ullTotalPhys;
public ulong ullAvailPhys;
public ulong ullTotalPageFile;
public ulong ullAvailPageFile;
public ulong ullTotalVirtual;
public ulong ullAvailVirtual;
public ulong ullAvailExtendedVirtual;
}
// CPU 카운터: 앱 시작 시 백그라운드에서 사전 초기화 (UI 스레드 차단 방지)
private static volatile PerformanceCounter? _cpuCounter;
private static float _cpuCached;
private static DateTime _cpuUpdated = DateTime.MinValue;
private static readonly object _cpuLock = new();
static SystemInfoHandler()
{
// 백그라운드에서 PerformanceCounter 초기화 (1~3초 소요, UI 스레드 차단 방지)
_ = Task.Run(() =>
{
try
{
var counter = new PerformanceCounter("Processor", "% Processor Time", "_Total");
counter.NextValue(); // 첫 호출은 항상 0 → 초기화만
_cpuCounter = counter;
}
catch (Exception) { /* PerformanceCounter 미지원 환경 */ }
});
}
private static float GetCpuUsage()
{
try
{
var counter = _cpuCounter;
if (counter == null) return -1; // 아직 초기화 중
lock (_cpuLock)
{
if ((DateTime.Now - _cpuUpdated).TotalMilliseconds > 800)
{
_cpuCached = counter.NextValue();
_cpuUpdated = DateTime.Now;
}
return _cpuCached;
}
}
catch (Exception) { return -1; }
}
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim().ToLowerInvariant();
var items = new List<LauncherItem>();
try
{
// 어떤 카테고리를 보여줄지 결정
bool showAll = string.IsNullOrEmpty(q);
bool showIp = showAll || q.Contains("ip") || q.Contains("네트워크") || q.Contains("network");
bool showBat = showAll || q.Contains("bat") || q.Contains("배터리") || q.Contains("battery");
bool showVol = showAll || q.Contains("vol") || q.Contains("볼륨") || q.Contains("volume");
bool showUp = showAll || q.Contains("up") || q.Contains("가동") || q.Contains("uptime");
bool showSys = showAll || q.Contains("sys") || q.Contains("시스템") || q.Contains("system")
|| q.Contains("host") || q.Contains("호스트") || q.Contains("os");
bool showUser = showAll || q.Contains("user") || q.Contains("사용자");
bool showCpu = showAll || q.Contains("cpu") || q.Contains("프로세서") || q.Contains("processor");
bool showRam = showAll || q.Contains("ram") || q.Contains("메모리") || q.Contains("memory");
bool showDisk = showAll || q.Contains("disk") || q.Contains("디스크") || q.Contains("storage") || q.Contains("저장");
bool showGfx = showAll || q.Contains("screen") || q.Contains("화면") || q.Contains("resolution") || q.Contains("display");
// ─── 호스트 / OS / 사용자 ────────────────────────────────────────
if (showSys)
{
items.Add(new LauncherItem(
$"컴퓨터: {Environment.MachineName}",
$"OS: {GetOsVersion()} · Enter로 시스템 정보 열기",
null,
new InfoAction("shell", "msinfo32"),
Symbol: Symbols.Computer));
}
if (showUser)
{
var user = $"{Environment.UserDomainName}\\{Environment.UserName}";
items.Add(new LauncherItem(
$"사용자: {Environment.UserName}",
$"{user} · Enter로 사용자 계정 설정 열기",
null,
new InfoAction("shell", "netplwiz"),
Symbol: Symbols.Person));
}
// ─── IP 주소 ────────────────────────────────────────────────────
if (showIp)
{
var localIp = GetLocalIpAddress();
if (localIp != null)
{
items.Add(new LauncherItem(
$"로컬 IP: {localIp}",
"LAN / Wi-Fi 주소 · Enter로 네트워크 설정 열기",
null,
new InfoAction("ms_settings", "ms-settings:network"),
Symbol: Symbols.Network));
}
var gateway = GetDefaultGateway();
if (gateway != null)
{
items.Add(new LauncherItem(
$"게이트웨이: {gateway}",
"기본 게이트웨이 · Enter로 네트워크 설정 열기",
null,
new InfoAction("ms_settings", "ms-settings:network"),
Symbol: Symbols.Network));
}
}
// ─── 배터리 ────────────────────────────────────────────────────
if (showBat)
{
var batItem = GetBatteryItem();
if (batItem != null) items.Add(batItem);
}
// ─── 볼륨 ──────────────────────────────────────────────────────
if (showVol)
{
var volItem = GetVolumeItem();
if (volItem != null) items.Add(volItem);
}
// ─── 가동 시간 ─────────────────────────────────────────────────
if (showUp)
{
var uptime = TimeSpan.FromMilliseconds(Environment.TickCount64);
var uptimeStr = FormatUptime(uptime);
var bootTime = DateTime.Now - uptime;
items.Add(MakeItem(
$"가동 시간: {uptimeStr}",
$"마지막 재시작: {bootTime:MM/dd HH:mm}",
uptimeStr,
Symbols.Clock));
}
// ─── CPU 사용률 ────────────────────────────────────────────────
if (showCpu)
{
var cpu = GetCpuUsage();
var cpuTitle = cpu < 0 ? "CPU: 측정 중…" : $"CPU: {(int)cpu}%";
var cpuProc = GetProcessorName();
items.Add(new LauncherItem(
cpuTitle,
(string.IsNullOrEmpty(cpuProc) ? "전체 CPU 사용률" : cpuProc) + " · Enter로 리소스 모니터 열기",
null,
new InfoAction("resource_mon"),
Symbol: Symbols.Processor));
}
// ─── 메모리 (RAM) ──────────────────────────────────────────────
if (showRam)
{
var mem = new MEMORYSTATUSEX { dwLength = (uint)Marshal.SizeOf<MEMORYSTATUSEX>() };
if (GlobalMemoryStatusEx(ref mem))
{
var totalGb = mem.ullTotalPhys / 1024.0 / 1024 / 1024;
var usedGb = (mem.ullTotalPhys - mem.ullAvailPhys) / 1024.0 / 1024 / 1024;
var pct = mem.dwMemoryLoad;
items.Add(new LauncherItem(
$"RAM: {usedGb:F1} / {totalGb:F1} GB ({pct}%)",
$"사용 중: {usedGb:F1} GB · 여유: {(mem.ullAvailPhys / 1024.0 / 1024 / 1024):F1} GB · Enter로 리소스 모니터 열기",
null,
new InfoAction("resource_mon"),
Symbol: Symbols.Memory));
}
}
// ─── 디스크 ────────────────────────────────────────────────────
if (showDisk)
{
foreach (var drive in System.IO.DriveInfo.GetDrives()
.Where(d => d.IsReady && d.DriveType == System.IO.DriveType.Fixed))
{
var totalGb = drive.TotalSize / 1024.0 / 1024 / 1024;
var freeGb = drive.AvailableFreeSpace / 1024.0 / 1024 / 1024;
var usedGb = totalGb - freeGb;
var pct = (int)(usedGb / totalGb * 100);
var label = string.IsNullOrWhiteSpace(drive.VolumeLabel) ? drive.Name.TrimEnd('\\') : drive.VolumeLabel;
items.Add(new LauncherItem(
$"드라이브 {drive.Name.TrimEnd('\\')} ({label}) — {usedGb:F0} / {totalGb:F0} GB ({pct}%)",
$"여유 공간: {freeGb:F1} GB · Enter로 탐색기 열기",
null,
new InfoAction("open_drive", drive.RootDirectory.FullName),
Symbol: Symbols.Storage));
}
}
// ─── 화면 해상도 ───────────────────────────────────────────────
if (showGfx)
{
var screen = System.Windows.SystemParameters.PrimaryScreenWidth;
var screenH = System.Windows.SystemParameters.PrimaryScreenHeight;
items.Add(new LauncherItem(
$"화면: {(int)screen} × {(int)screenH}",
"기본 모니터 해상도 · Enter로 디스플레이 설정 열기",
null,
new InfoAction("ms_settings", "ms-settings:display"),
Symbol: Symbols.Computer));
}
}
catch (Exception ex)
{
LogService.Warn($"시스템 정보 조회 오류: {ex.Message}");
items.Add(new LauncherItem("시스템 정보 조회 실패", ex.Message, null, null, Symbol: Symbols.Error));
}
if (items.Count == 0)
{
items.Add(new LauncherItem(
"시스템 정보 없음",
"ip · battery · volume · uptime · system 키워드로 검색하세요",
null, null, Symbol: Symbols.Info));
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is not InfoAction action) return Task.CompletedTask;
switch (action.Type)
{
case "copy":
// 클립보드에 복사
if (!string.IsNullOrWhiteSpace(action.Payload))
{
try
{
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(action.Payload));
LogService.Info($"클립보드 복사: {action.Payload}");
}
catch (Exception ex) { LogService.Warn($"클립보드 복사 실패: {ex.Message}"); }
}
break;
case "open_drive":
// 탐색기로 드라이브 열기
if (!string.IsNullOrWhiteSpace(action.Payload))
{
try
{
Process.Start(new ProcessStartInfo("explorer.exe", action.Payload)
{ UseShellExecute = true });
}
catch (Exception ex) { LogService.Warn($"드라이브 열기 실패: {ex.Message}"); }
}
break;
case "resource_mon":
// CPU/RAM 실시간 모니터 창 열기
Application.Current.Dispatcher.Invoke(() =>
{
var existing = Application.Current.Windows
.OfType<ResourceMonitorWindow>().FirstOrDefault();
if (existing != null) { existing.Activate(); return; }
new ResourceMonitorWindow().Show();
});
break;
case "shell":
// 셸 명령 실행 (msinfo32, netplwiz 등)
if (!string.IsNullOrWhiteSpace(action.Payload))
{
try
{
Process.Start(new ProcessStartInfo(action.Payload) { UseShellExecute = true });
}
catch (Exception ex) { LogService.Warn($"셸 명령 실행 실패: {ex.Message}"); }
}
break;
case "ms_settings":
// Windows 설정 URI (ms-settings:network, ms-settings:display 등)
if (!string.IsNullOrWhiteSpace(action.Payload))
{
try
{
Process.Start(new ProcessStartInfo(action.Payload) { UseShellExecute = true });
}
catch (Exception ex) { LogService.Warn($"설정 열기 실패: {ex.Message}"); }
}
break;
}
return Task.CompletedTask;
}
// ─── 액션 데이터 태그 ────────────────────────────────────────────────────
/// <summary>info 항목에 의미 있는 동작을 부여하기 위한 태그</summary>
internal sealed record InfoAction(string Type, string? Payload = null);
// Type: "copy" → 클립보드 복사 (Payload = 복사할 텍스트)
// "open_drive" → 탐색기로 드라이브 열기 (Payload = 드라이브 루트)
// "resource_mon" → CPU/RAM 실시간 모니터 창 열기
// "shell" → 셸 명령 실행 (Payload = 명령)
// "ms_settings" → ms-settings: URI 열기 (Payload = URI)
// ─── 헬퍼 ────────────────────────────────────────────────────────────────
private static LauncherItem MakeItem(string title, string subtitle, string? copyValue, string symbol) =>
new(title, subtitle, null,
copyValue != null ? new InfoAction("copy", copyValue) : null,
Symbol: symbol);
private static string? GetLocalIpAddress()
{
try
{
foreach (var iface in NetworkInterface.GetAllNetworkInterfaces())
{
if (iface.OperationalStatus != OperationalStatus.Up) continue;
if (iface.NetworkInterfaceType is NetworkInterfaceType.Loopback
or NetworkInterfaceType.Tunnel) continue;
foreach (var addr in iface.GetIPProperties().UnicastAddresses)
{
if (addr.Address.AddressFamily == AddressFamily.InterNetwork)
return addr.Address.ToString();
}
}
}
catch (Exception) { /* 무시 */ }
return null;
}
private static string? GetDefaultGateway()
{
try
{
foreach (var iface in NetworkInterface.GetAllNetworkInterfaces())
{
if (iface.OperationalStatus != OperationalStatus.Up) continue;
var gw = iface.GetIPProperties().GatewayAddresses
.FirstOrDefault(g => g.Address.AddressFamily == AddressFamily.InterNetwork);
if (gw != null) return gw.Address.ToString();
}
}
catch (Exception) { /* 무시 */ }
return null;
}
private static LauncherItem? GetBatteryItem()
{
try
{
var status = System.Windows.Forms.SystemInformation.PowerStatus;
var chargeLevel = status.BatteryLifePercent;
if (chargeLevel < 0) // 배터리 없는 데스크톱
return new LauncherItem("배터리: 해당 없음", "데스크톱 PC 또는 항상 연결됨 · Enter로 전원 설정 열기", null,
new InfoAction("ms_settings", "ms-settings:powersleep"), Symbol: Symbols.Battery);
var pct = (int)(chargeLevel * 100);
var charging = status.PowerLineStatus == System.Windows.Forms.PowerLineStatus.Online;
var timeLeft = status.BatteryLifeRemaining >= 0
? $" · 잔여: {FormatUptime(TimeSpan.FromSeconds(status.BatteryLifeRemaining))}"
: "";
var symbol = charging ? Symbols.BatteryCharging
: pct > 50 ? Symbols.Battery
: Symbols.BatteryLow;
return new LauncherItem(
$"배터리: {pct}%{(charging ? " " : "")}",
$"전원: {(charging ? "AC " : " ")}{timeLeft} · Enter로 전원 설정 열기",
null,
new InfoAction("ms_settings", "ms-settings:powersleep"),
Symbol: symbol);
}
catch (Exception) { return null; }
}
private static LauncherItem? GetVolumeItem()
{
try
{
if (waveOutGetVolume(IntPtr.Zero, out uint vol) == 0)
{
// 왼쪽 채널 0~0xFFFF → 0~100 변환
var left = (int)((vol & 0xFFFF) / 655.35);
var right = (int)(((vol >> 16) & 0xFFFF) / 655.35);
var avg = (left + right) / 2;
return new LauncherItem(
$"볼륨: {avg}%",
$"L: {left}% · R: {right}% · Enter로 사운드 설정 열기",
null,
new InfoAction("ms_settings", "ms-settings:sound"),
Symbol: avg == 0 ? Symbols.VolumeMute : Symbols.VolumeUp);
}
}
catch (Exception) { /* 무시 */ }
return null;
}
private static string GetOsVersion()
{
try
{
// registry에서 실제 Windows 버전 읽기
using var key = Microsoft.Win32.Registry.LocalMachine
.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion");
if (key != null)
{
var name = key.GetValue("ProductName") as string ?? "Windows";
var build = key.GetValue("CurrentBuildNumber") as string ?? "";
var ubr = key.GetValue("UBR");
return ubr != null ? $"{name} (빌드 {build}.{ubr})" : $"{name} (빌드 {build})";
}
}
catch (Exception) { /* 무시 */ }
return Environment.OSVersion.ToString();
}
private static string GetProcessorName()
{
try
{
using var key = Microsoft.Win32.Registry.LocalMachine
.OpenSubKey(@"HARDWARE\DESCRIPTION\System\CentralProcessor\0");
return key?.GetValue("ProcessorNameString") as string ?? "";
}
catch (Exception) { return ""; }
}
private static string FormatUptime(TimeSpan t)
{
if (t.TotalDays >= 1)
return $"{(int)t.TotalDays}일 {t.Hours}시간 {t.Minutes}분";
if (t.TotalHours >= 1)
return $"{t.Hours}시간 {t.Minutes}분";
return $"{t.Minutes}분 {t.Seconds}초";
}
}
/// <summary>
/// * 단축키로 시스템 정보를 빠르게 조회합니다. SystemInfoHandler에 완전히 위임합니다.
/// </summary>
public class StarInfoHandler : IActionHandler
{
private readonly SystemInfoHandler _inner = new();
public string? Prefix => "*";
public PluginMetadata Metadata => new(
"StarInfo",
"시스템 정보 빠른 조회 — * 단축키 (info와 동일)",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
=> _inner.GetItemsAsync(query, ct);
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
=> _inner.ExecuteAsync(item, ct);
}

View File

@@ -0,0 +1,149 @@
using System.Text.RegularExpressions;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 텍스트 통계 분석 핸들러. "stats" 프리픽스로 사용합니다.
/// 클립보드 텍스트의 글자 수, 단어 수, 줄 수, 키워드 빈도 등을 분석합니다.
/// 예: stats → 현재 클립보드 텍스트 분석
/// stats 키워드 → 클립보드에서 해당 키워드 빈도 검색
/// Enter 시 분석 결과를 클립보드에 복사합니다.
/// </summary>
public class TextStatsHandler : IActionHandler
{
public string? Prefix => "stats";
public PluginMetadata Metadata => new(
"TextStats",
"텍스트 통계 분석",
"1.0",
"AX");
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
string? text = null;
try
{
if (Application.Current?.Dispatcher.Invoke(() => Clipboard.ContainsText()) == true)
text = Application.Current.Dispatcher.Invoke(() => Clipboard.GetText());
}
catch (Exception) { }
if (string.IsNullOrEmpty(text))
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
"클립보드에 텍스트가 없습니다",
"텍스트를 복사한 후 다시 시도하세요",
null, null, Symbol: Symbols.Info)
]);
}
var q = query.Trim();
var items = new List<LauncherItem>();
// 기본 통계
var charCount = text.Length;
var charNoSpace = text.Replace(" ", "").Replace("\t", "").Replace("\r", "").Replace("\n", "").Length;
var words = Regex.Split(text, @"\s+", RegexOptions.None, TimeSpan.FromSeconds(1))
.Where(w => !string.IsNullOrWhiteSpace(w)).ToArray();
var wordCount = words.Length;
var lines = text.Split('\n');
var lineCount = lines.Length;
var nonEmptyLines = lines.Count(l => !string.IsNullOrWhiteSpace(l));
var byteCount = System.Text.Encoding.UTF8.GetByteCount(text);
// 요약
var summary = $"글자 {charCount:N0} · 공백 제외 {charNoSpace:N0} · 단어 {wordCount:N0} · 줄 {lineCount:N0}";
items.Add(new LauncherItem(
$"글자 수: {charCount:N0} ({charNoSpace:N0} 공백 제외)",
$"UTF-8: {byteCount:N0} bytes · Enter로 결과 복사",
null, $"글자 수: {charCount:N0} (공백 제외: {charNoSpace:N0})",
Symbol: "\uE8D2"));
items.Add(new LauncherItem(
$"단어 수: {wordCount:N0}",
$"공백·줄바꿈 기준 분리 · Enter로 결과 복사",
null, $"단어 수: {wordCount:N0}",
Symbol: "\uE8D2"));
items.Add(new LauncherItem(
$"줄 수: {lineCount:N0} (비어있지 않은 줄: {nonEmptyLines:N0})",
"Enter로 결과 복사",
null, $"줄 수: {lineCount:N0} (비어있지 않은 줄: {nonEmptyLines:N0})",
Symbol: "\uE8D2"));
// 읽기 시간 추정 (한국어 500자/분, 영어 200단어/분)
var koreanChars = text.Count(c => c >= '\uAC00' && c <= '\uD7AF');
var readMinutes = koreanChars > charCount / 2
? (double)charNoSpace / 500
: (double)wordCount / 200;
var readTime = readMinutes < 1 ? "1분 미만" : $"약 {Math.Ceiling(readMinutes)}분";
items.Add(new LauncherItem(
$"예상 읽기 시간: {readTime}",
koreanChars > charCount / 2 ? "한국어 기준 (500자/분)" : "영어 기준 (200단어/분)",
null, $"예상 읽기 시간: {readTime}",
Symbol: Symbols.Clock));
// 키워드 빈도 (상위 5개)
if (string.IsNullOrWhiteSpace(q))
{
var freq = words
.Where(w => w.Length >= 2)
.GroupBy(w => w.ToLowerInvariant())
.OrderByDescending(g => g.Count())
.Take(5)
.Select(g => $"{g.Key}({g.Count()})");
var freqStr = string.Join(", ", freq);
if (!string.IsNullOrEmpty(freqStr))
{
items.Add(new LauncherItem(
"상위 키워드",
freqStr,
null, $"상위 키워드: {freqStr}",
Symbol: Symbols.Search));
}
}
else
{
// 특정 키워드 빈도 검색
try
{
var count = Regex.Matches(text, Regex.Escape(q), RegexOptions.IgnoreCase,
TimeSpan.FromSeconds(1)).Count;
items.Insert(0, new LauncherItem(
$"'{q}' 출현 횟수: {count}회",
$"대소문자 무시 검색 · Enter로 결과 복사",
null, $"'{q}' 출현 횟수: {count}회",
Symbol: Symbols.Search));
}
catch (Exception) { }
}
// 전체 요약 항목
items.Add(new LauncherItem(
"전체 요약 복사",
summary,
null, $"[텍스트 통계]\n{summary}\nUTF-8: {byteCount:N0} bytes\n읽기 시간: {readTime}",
Symbol: Symbols.Clipboard));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is string text && !string.IsNullOrWhiteSpace(text))
{
try { Application.Current?.Dispatcher.Invoke(() => Clipboard.SetText(text)); }
catch (Exception) { }
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,228 @@
using Microsoft.Win32;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 앱 제거 핸들러. "uninstall" 프리픽스로 사용합니다.
/// 예: uninstall → 설치된 앱 전체 목록
/// uninstall chrome → "chrome" 포함 앱 필터
/// Enter 시 해당 앱의 제거 프로그램 실행.
/// </summary>
public class UninstallHandler : IActionHandler
{
public string? Prefix => "uninstall";
public PluginMetadata Metadata => new(
"Uninstall",
"앱 제거 — uninstall 뒤에 앱 이름 입력",
"1.0",
"AX");
// 레지스트리 언인스톨 키 경로
private static readonly string[] _regPaths =
{
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
@"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
};
// 캐시: 30초
private static (DateTime At, List<InstalledApp> Apps)? _cache;
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30);
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
if (string.IsNullOrWhiteSpace(q))
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
"제거할 앱 이름을 입력하세요",
"예: uninstall chrome · uninstall office",
null, null, Symbol: Symbols.Uninstall)
]);
}
var apps = GetInstalledApps();
var filtered = apps
.Where(a => a.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
(a.Publisher?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false))
.OrderBy(a => a.Name)
.Take(12)
.ToList();
if (!filtered.Any())
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
"검색 결과 없음",
$"'{q}'에 해당하는 설치된 앱이 없습니다",
null, null, Symbol: Symbols.Info)
]);
}
var items = filtered.Select(a => new LauncherItem(
a.Name,
BuildSubtitle(a),
null,
a,
Symbol: Symbols.Uninstall)).ToList();
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is not InstalledApp app) return Task.CompletedTask;
if (string.IsNullOrWhiteSpace(app.UninstallString))
{
LogService.Warn($"제거 문자열 없음: {app.Name}");
return Task.CompletedTask;
}
try
{
// UninstallString이 "msiexec /x {GUID}" 또는 경로+args 형태
var uninstall = app.UninstallString.Trim();
if (uninstall.StartsWith("msiexec", StringComparison.OrdinalIgnoreCase))
{
// msiexec 직접 실행
var args = uninstall.Substring("msiexec".Length).Trim();
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(
"msiexec.exe", args) { UseShellExecute = true });
}
else
{
// 따옴표로 둘러싸인 경로 + 나머지 인수 분리
string exe, arguments;
if (uninstall.StartsWith('"'))
{
var close = uninstall.IndexOf('"', 1);
exe = close > 0 ? uninstall[1..close] : uninstall;
arguments = close > 0 ? uninstall[(close + 1)..].Trim() : "";
}
else
{
var spaceIdx = uninstall.IndexOf(' ');
if (spaceIdx > 0)
{
exe = uninstall[..spaceIdx];
arguments = uninstall[(spaceIdx + 1)..];
}
else
{
exe = uninstall;
arguments = "";
}
}
var psi = new System.Diagnostics.ProcessStartInfo(exe, arguments)
{ UseShellExecute = true };
System.Diagnostics.Process.Start(psi);
}
}
catch (Exception ex)
{
LogService.Warn($"제거 실행 실패 [{app.Name}]: {ex.Message}");
}
return Task.CompletedTask;
}
// ─── 내부 ─────────────────────────────────────────────────────────────────
private static string BuildSubtitle(InstalledApp a)
{
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(a.Publisher)) parts.Add(a.Publisher!);
if (!string.IsNullOrWhiteSpace(a.Version)) parts.Add($"v{a.Version}");
if (!string.IsNullOrWhiteSpace(a.InstallDate)) parts.Add(a.InstallDate!);
parts.Add("Enter로 제거");
return string.Join(" · ", parts);
}
private static List<InstalledApp> GetInstalledApps()
{
if (_cache.HasValue && (DateTime.Now - _cache.Value.At) < CacheTtl)
return _cache.Value.Apps;
var result = new List<InstalledApp>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var root in new[] { Registry.LocalMachine, Registry.CurrentUser })
{
foreach (var regPath in _regPaths)
{
try
{
using var key = root.OpenSubKey(regPath);
if (key == null) continue;
foreach (var subName in key.GetSubKeyNames())
{
try
{
using var sub = key.OpenSubKey(subName);
if (sub == null) continue;
var name = sub.GetValue("DisplayName") as string;
var uninstall = sub.GetValue("UninstallString") as string;
// 이름·제거 문자열 없으면 스킵, 시스템 업데이트/패치 스킵
if (string.IsNullOrWhiteSpace(name)) continue;
if (string.IsNullOrWhiteSpace(uninstall)) continue;
if (name.StartsWith("KB", StringComparison.OrdinalIgnoreCase) &&
name.Length < 12) continue;
if (!seen.Add(name)) continue;
var systemComponent = sub.GetValue("SystemComponent");
if (systemComponent is int sc && sc == 1) continue;
result.Add(new InstalledApp
{
Name = name,
UninstallString = uninstall,
Publisher = sub.GetValue("Publisher") as string,
Version = sub.GetValue("DisplayVersion") as string,
InstallDate = FormatDate(sub.GetValue("InstallDate") as string),
});
}
catch (Exception) { /* 개별 키 읽기 실패 무시 */ }
}
}
catch (Exception) { /* 레지스트리 접근 실패 무시 */ }
}
}
result.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
_cache = (DateTime.Now, result);
return result;
}
private static string? FormatDate(string? raw)
{
// InstallDate 형식: "20230615" → "2023-06-15"
if (raw?.Length == 8 &&
int.TryParse(raw, out _))
{
return $"{raw[..4]}-{raw[4..6]}-{raw[6..8]}";
}
return null;
}
}
internal sealed class InstalledApp
{
public string Name { get; init; } = "";
public string? UninstallString { get; init; }
public string? Publisher { get; init; }
public string? Version { get; init; }
public string? InstallDate { get; init; }
}

View File

@@ -0,0 +1,216 @@
using System.IO;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 웹 검색 핸들러. "?" 예약어로 사용합니다.
/// 예: ? 오늘 날씨 → 기본 검색엔진으로 검색
/// ? github.com → URL 직접 열기
/// ?g 파이썬 튜토리얼 → Google 검색
/// ?n naver 뉴스 → Naver 검색
/// ?y youtube 음악 → YouTube 검색
/// ?g github.com → GitHub 직접
/// </summary>
public class WebSearchHandler : IActionHandler
{
private readonly SettingsService _settings;
/// <summary>내장 검색 엔진 아이콘 폴더 (Assets\SearchEngines)</summary>
private static readonly string _iconDir = Path.Combine(
Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location) ?? ".",
"Assets", "SearchEngines");
public WebSearchHandler(SettingsService settings) => _settings = settings;
public string? Prefix => "?";
public PluginMetadata Metadata => new(
"WebSearch",
"웹 검색 — ? 뒤에 검색어 또는 URL 입력",
"1.0",
"AX");
// 검색 엔진 단축 접두어 — Icon: 내장 PNG 파일명 (확장자 제외)
private static readonly Dictionary<string, (string Name, string UrlTemplate, string Icon)> _engines = new()
{
["g"] = ("Google", "https://www.google.com/search?q={0}", "google"),
["n"] = ("Naver", "https://search.naver.com/search.naver?query={0}", "naver"),
["y"] = ("YouTube", "https://www.youtube.com/results?search_query={0}", "youtube"),
["gh"] = ("GitHub", "https://github.com/search?q={0}", "github"),
["d"] = ("DuckDuckGo", "https://duckduckgo.com/?q={0}", "duckduckgo"),
["w"] = ("Wikipedia", "https://ko.wikipedia.org/w/index.php?search={0}", "wikipedia"),
["nm"] = ("Naver Map", "https://map.naver.com/p/search/{0}", "navermap"),
["nw"] = ("나무위키", "https://namu.wiki/Special:Search?query={0}", "namuwiki"),
["ni"] = ("Naver 이미지", "https://search.naver.com/search.naver?where=image&query={0}", "naver"),
["gi"] = ("Google 이미지", "https://www.google.com/search?tbm=isch&q={0}", "google"),
};
/// <summary>엔진 키에 대응하는 내장 아이콘 파일 경로를 반환합니다. 없으면 null.</summary>
private static string? GetIconPath(string engineKey)
{
if (!_engines.TryGetValue(engineKey, out var eng)) return null;
var path = Path.Combine(_iconDir, $"{eng.Icon}.png");
return File.Exists(path) ? path : null;
}
private string DefaultEngineKey =>
_settings.Settings.Launcher.WebSearchEngine ?? "g";
private (string Name, string UrlTemplate, string Icon) DefaultEngine =>
_engines.TryGetValue(DefaultEngineKey, out var eng) ? eng : _engines["g"];
private const string SecurityWarn = "⚠ 검색어가 외부로 전송됩니다 — 기밀 데이터 입력 금지";
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
// 사내 모드에서는 웹 검색 차단
if (_settings.Settings.InternalModeEnabled)
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
"웹 검색 차단됨",
"사내 모드에서는 외부 인터넷 검색이 차단됩니다. 설정에서 사외 모드를 활성화하세요.",
null, null, Symbol: Symbols.Lock)
]);
}
if (string.IsNullOrWhiteSpace(query))
{
var defIcon = GetIconPath(DefaultEngineKey);
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
"검색어를 입력하세요",
$"예: ? 오늘 날씨 · ?g python · ?y 음악 · ?n 뉴스 | {SecurityWarn}",
defIcon, null, Symbol: Symbols.Globe)
]);
}
var items = new List<LauncherItem>();
var q = query.Trim();
// URL인 경우 직접 열기 — FaviconService로 웹에서 아이콘 가져오기
if (IsUrl(q))
{
var url = q.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? q : "https://" + q;
var faviconPath = GetFaviconPathForUrl(url);
items.Add(new LauncherItem(
$"열기: {q}",
$"{url} | {SecurityWarn}",
faviconPath, url, Symbol: Symbols.Globe));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 엔진 단축어 확인 (예: "g 파이썬" → Google 검색)
var parts = q.Split(' ', 2);
var engineKey = parts[0].ToLowerInvariant();
if (parts.Length == 2 && _engines.TryGetValue(engineKey, out var engine))
{
var searchQuery = parts[1].Trim();
if (!string.IsNullOrWhiteSpace(searchQuery))
{
var url = string.Format(engine.UrlTemplate, Uri.EscapeDataString(searchQuery));
items.Add(new LauncherItem(
$"{engine.Name}에서 '{searchQuery}' 검색",
$"{SecurityWarn}",
GetIconPath(engineKey), url, Symbol: Symbols.Globe));
}
}
// 기본 엔진 검색 항목
var defKey = DefaultEngineKey;
var def = DefaultEngine;
var defaultUrl = string.Format(def.UrlTemplate, Uri.EscapeDataString(q));
items.Add(new LauncherItem(
$"{def.Name}에서 '{q}' 검색",
SecurityWarn,
GetIconPath(defKey), defaultUrl, Symbol: Symbols.Globe));
// 빠른 접속 사이트 표시 (쿼리가 짧을 때만)
if (q.Length <= 2)
{
foreach (var (key, eng) in _engines)
{
items.Add(new LauncherItem(
$"?{key} — {eng.Name}",
$"예: ?{key} {q} | {SecurityWarn}",
GetIconPath(key), null, Symbol: Symbols.Globe));
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
// 사내 모드에서는 실행도 차단
if (_settings.Settings.InternalModeEnabled) return Task.CompletedTask;
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;
}
// ─── URL 판별 ────────────────────────────────────────────────────────────
private static bool IsUrl(string input)
{
if (input.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
input.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
return true;
// xxx.yyy 형태 (도메인처럼 보이는 경우)
if (!input.Contains(' ') && input.Contains('.') && input.Length > 3)
{
var domainPart = input.Split('/')[0];
var tldPart = domainPart.Split('.');
return tldPart.Length >= 2
&& tldPart[^1].Length >= 2
&& tldPart[^1].Length <= 6
&& tldPart[^1].All(char.IsLetter);
}
return false;
}
// ─── URL 파비콘 가져오기 (FaviconService 연동) ─────────────────────────
/// <summary>
/// URL에 대한 파비콘 경로를 반환합니다.
/// FaviconService 디스크 캐시에 있으면 즉시 반환, 없으면 백그라운드 다운로드를 시작합니다.
/// </summary>
private static string? GetFaviconPathForUrl(string url)
{
try
{
if (!url.StartsWith("http", StringComparison.OrdinalIgnoreCase))
url = "https://" + url;
var domain = new Uri(url).Host.ToLowerInvariant();
var cachePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "favicons", $"{domain}.png");
if (File.Exists(cachePath))
return cachePath;
// 디스크 캐시에 없으면 FaviconService를 통해 백그라운드 다운로드 시작
FaviconService.GetFavicon(url);
return null;
}
catch (Exception) { return null; }
}
}

View File

@@ -0,0 +1,130 @@
using System.Windows;
using AxCopilot.Models;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// Phase L3-2: 웹 검색 AI 요약 핸들러. "?!" 예약어로 사용합니다.
/// 예: ?! 최신 AI 동향 → 검색 쿼리에 대해 AI가 요약한 결과를 생성
/// ?! https://... → 해당 URL의 내용을 AI가 요약
///
/// 조건: AiEnabled=true + InternalModeEnabled=false (사외 모드)
/// </summary>
public class WebSearchSummaryHandler : IActionHandler
{
private readonly SettingsService _settings;
private readonly LlmService _llm;
public string? Prefix => "?!";
public PluginMetadata Metadata => new(
"WebSearchSummary",
"웹 검색 AI 요약 — ?! 뒤에 검색어 또는 URL 입력",
"1.0",
"AX");
public WebSearchSummaryHandler(SettingsService settings, LlmService llm)
{
_settings = settings;
_llm = llm;
}
private bool IsAvailable =>
_settings.Settings.AiEnabled && !_settings.Settings.InternalModeEnabled;
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
// AI 비활성화 또는 사내 모드에서는 항목 표시 안 함
if (!IsAvailable)
return Task.FromResult<IEnumerable<LauncherItem>>(Array.Empty<LauncherItem>());
if (string.IsNullOrWhiteSpace(query))
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
"웹 검색 AI 요약",
"?! [검색어 또는 URL] AI가 ",
null, null, Symbol: Symbols.Lightbulb)
]);
}
var q = query.Trim();
var isUrl = q.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|| q.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
|| (!q.Contains(' ') && q.Contains('.') && q.Length > 4);
if (isUrl)
{
var url = q.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? q : "https://" + q;
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
$"AI 요약: {q}",
"Enter → 페이지 내용을 AI가 요약하여 클립보드에 복사",
null, ("url", url), Symbol: Symbols.Lightbulb)
]);
}
else
{
// 검색 쿼리 → 기본 검색엔진 URL 사용
var searchUrl = $"https://www.google.com/search?q={Uri.EscapeDataString(q)}";
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
$"AI 요약: '{q}'",
"Enter → 검색어에 대한 AI 답변 생성 후 클립보드에 복사",
null, ("query", q), Symbol: Symbols.Lightbulb)
]);
}
}
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (!IsAvailable) return;
string prompt;
switch (item.Data)
{
case ("url", string url):
var content = await ContentExtractor.ExtractAsync(url, maxChars: 3000, ct);
prompt = $"다음 웹 페이지 내용을 핵심 위주로 한국어로 요약해주세요 ({url}):\n\n{content}";
break;
case ("query", string q):
prompt = $"'{q}'에 대해 최신 정보를 바탕으로 핵심을 간결하게 설명해주세요.";
break;
default:
return;
}
try
{
var messages = new List<ChatMessage>
{
new() { Role = "system", Content = "당신은 정보를 간결하게 요약하는 전문 어시스턴트입니다. 핵심만 추려 명확하게 전달하세요." },
new() { Role = "user", Content = prompt }
};
var result = await _llm.SendAsync(messages, ct);
if (!string.IsNullOrWhiteSpace(result))
{
Application.Current.Dispatcher.Invoke(() =>
{
Clipboard.SetText(result);
NotificationService.Notify("AI 요약 완료", "결과가 클립보드에 복사되었습니다.");
});
}
}
catch (Exception ex)
{
LogService.Warn($"웹 검색 AI 요약 실패: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,129 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using AxCopilot.SDK;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 윈도우 포커스 스위처 핸들러. "win" 프리픽스로 사용합니다.
/// 열린 창 목록을 타이틀/프로세스명으로 검색하여 즉시 전환합니다.
/// 예: win → 열린 창 목록
/// win chrome → Chrome 창 필터
/// win 보고서 → 타이틀에 "보고서" 포함하는 창
/// Enter → 해당 창으로 즉시 전환.
/// </summary>
public class WindowSwitchHandler : IActionHandler
{
public string? Prefix => "win";
public PluginMetadata Metadata => new(
"WindowSwitch",
"윈도우 포커스 스위처 — win",
"1.0",
"AX");
[DllImport("user32.dll")] private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
[DllImport("user32.dll")] private static extern bool IsWindowVisible(IntPtr hWnd);
[DllImport("user32.dll")] private static extern int GetWindowTextLength(IntPtr hWnd);
[DllImport("user32.dll")] private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
[DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
[DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll")] private static extern bool IsIconic(IntPtr hWnd);
[DllImport("user32.dll")] private static extern IntPtr GetShellWindow();
private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
private const int SW_RESTORE = 9;
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var windows = GetOpenWindows();
if (!string.IsNullOrWhiteSpace(q))
{
windows = windows.Where(w =>
w.Title.Contains(q, StringComparison.OrdinalIgnoreCase) ||
w.ProcessName.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
}
if (!windows.Any())
{
return Task.FromResult<IEnumerable<LauncherItem>>(
[
new LauncherItem(
string.IsNullOrWhiteSpace(q) ? "열린 창이 없습니다" : $"'{q}'에 해당하는 창 없음",
"win 뒤에 프로세스명 또는 창 제목 입력",
null, null, Symbol: Symbols.Info)
]);
}
var items = windows.Take(15).Select(w => new LauncherItem(
w.Title.Length > 60 ? w.Title[..57] + "…" : w.Title,
$"{w.ProcessName} · {(w.IsMinimized ? "[] · " : "")}Enter → 전환",
null, w.Handle,
Symbol: Symbols.Computer)).ToList<LauncherItem>();
items.Insert(0, new LauncherItem(
$"열린 창 {windows.Count}개",
"Enter → 해당 창으로 즉시 전환",
null, null, Symbol: Symbols.Info));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is IntPtr hwnd && hwnd != IntPtr.Zero)
{
if (IsIconic(hwnd))
ShowWindow(hwnd, SW_RESTORE);
SetForegroundWindow(hwnd);
}
return Task.CompletedTask;
}
private static List<WindowInfo> GetOpenWindows()
{
var list = new List<WindowInfo>();
var shellWnd = GetShellWindow();
var currentProcessName = Process.GetCurrentProcess().ProcessName;
EnumWindows((hWnd, _) =>
{
if (hWnd == shellWnd) return true;
if (!IsWindowVisible(hWnd)) return true;
var titleLen = GetWindowTextLength(hWnd);
if (titleLen == 0) return true;
var sb = new StringBuilder(titleLen + 1);
GetWindowText(hWnd, sb, sb.Capacity);
var title = sb.ToString();
string processName = "";
try
{
GetWindowThreadProcessId(hWnd, out uint pid);
processName = Process.GetProcessById((int)pid).ProcessName;
}
catch (Exception) { }
// 자기 자신 + 시스템 프로세스 제외
if (processName == currentProcessName)
return true;
if (processName is "ApplicationFrameHost" or "ShellExperienceHost" or "SearchHost"
or "StartMenuExperienceHost" or "TextInputHost")
return true;
list.Add(new WindowInfo(hWnd, title, processName, IsIconic(hWnd)));
return true;
}, IntPtr.Zero);
return list;
}
private record WindowInfo(IntPtr Handle, string Title, string ProcessName, bool IsMinimized);
}

View File

@@ -0,0 +1,118 @@
using AxCopilot.Core;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// ~ prefix 핸들러: 워크스페이스 프로필 저장/복원/관리
/// 예: ~dev, ~save dev, ~delete dev, ~rename dev work
/// </summary>
public class WorkspaceHandler : IActionHandler
{
private readonly ContextManager _context;
private readonly SettingsService _settings;
public string? Prefix => "~";
public PluginMetadata Metadata => new("workspace", "워크스페이스", "1.0", "AX");
public WorkspaceHandler(ContextManager context, SettingsService settings)
{
_context = context;
_settings = settings;
}
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var parts = query.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
// 서브 커맨드 분기
if (parts.Length == 0 || string.IsNullOrEmpty(query))
{
// 프로필 목록 표시
var items = _settings.Settings.Profiles
.Select(p => new LauncherItem(
$"~{p.Name}",
$"{p.Windows.Count}개 창 | {p.CreatedAt:MM/dd HH:mm}",
null, p, Symbol: Symbols.Workspace))
.ToList();
if (items.Count == 0)
items.Add(new LauncherItem("저장된 프로필 없음", "~save <이름> 으로 현재 배치를 저장하세요", null, null, Symbol: Symbols.Info));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var cmd = parts[0].ToLowerInvariant();
if (cmd == "save")
{
var name = parts.Length > 1 ? parts[1] : "default";
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem($"현재 창 배치를 '{name}'으로 저장", "Enter로 확인", null,
new WorkspaceAction(WorkspaceActionType.Save, name), Symbol: Symbols.Save)
});
}
if (cmd == "delete" && parts.Length > 1)
{
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem($"프로필 '{parts[1]}' 삭제", "Enter로 확인 (되돌릴 수 없습니다)", null,
new WorkspaceAction(WorkspaceActionType.Delete, parts[1]), Symbol: Symbols.Delete)
});
}
if (cmd == "rename" && parts.Length > 2)
{
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem($"프로필 '{parts[1]}' → '{parts[2]}'로 이름 변경", "Enter로 확인", null,
new WorkspaceAction(WorkspaceActionType.Rename, parts[1], parts[2]), Symbol: Symbols.Rename)
});
}
// 이름으로 프로필 검색
var matched = _settings.Settings.Profiles
.Where(p => p.Name.Contains(query, StringComparison.OrdinalIgnoreCase))
.Select(p => new LauncherItem(
$"~{p.Name} 복원",
$"{p.Windows.Count}개 창 | {p.CreatedAt:MM/dd HH:mm}",
null,
new WorkspaceAction(WorkspaceActionType.Restore, p.Name),
Symbol: Symbols.Restore));
return Task.FromResult<IEnumerable<LauncherItem>>(matched);
}
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is not WorkspaceAction action) return;
switch (action.Type)
{
case WorkspaceActionType.Restore:
var result = await _context.RestoreProfileAsync(action.Name, ct);
LogService.Info($"복원 결과: {result.Message}");
break;
case WorkspaceActionType.Save:
_context.CaptureProfile(action.Name);
break;
case WorkspaceActionType.Delete:
_context.DeleteProfile(action.Name);
break;
case WorkspaceActionType.Rename when action.NewName != null:
_context.RenameProfile(action.Name, action.NewName);
break;
}
}
}
public record WorkspaceAction(WorkspaceActionType Type, string Name, string? NewName = null);
public enum WorkspaceActionType { Restore, Save, Delete, Rename }