Initial commit to new repository
This commit is contained in:
104
src/AxCopilot/Handlers/AiSnippetHandler.cs
Normal file
104
src/AxCopilot/Handlers/AiSnippetHandler.cs
Normal 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;
|
||||
}
|
||||
186
src/AxCopilot/Handlers/AliasHandler.cs
Normal file
186
src/AxCopilot/Handlers/AliasHandler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
210
src/AxCopilot/Handlers/BatchTextHandler.cs
Normal file
210
src/AxCopilot/Handlers/BatchTextHandler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
180
src/AxCopilot/Handlers/BookmarkHandler.cs
Normal file
180
src/AxCopilot/Handlers/BookmarkHandler.cs
Normal 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);
|
||||
}
|
||||
566
src/AxCopilot/Handlers/CalculatorHandler.cs
Normal file
566
src/AxCopilot/Handlers/CalculatorHandler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
170
src/AxCopilot/Handlers/ChatHandler.cs
Normal file
170
src/AxCopilot/Handlers/ChatHandler.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
193
src/AxCopilot/Handlers/ClipboardHandler.cs
Normal file
193
src/AxCopilot/Handlers/ClipboardHandler.cs
Normal 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 = "빈 줄 제거 및 각 줄 공백 정리" },
|
||||
];
|
||||
}
|
||||
216
src/AxCopilot/Handlers/ClipboardHistoryHandler.cs
Normal file
216
src/AxCopilot/Handlers/ClipboardHistoryHandler.cs
Normal 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);
|
||||
}
|
||||
151
src/AxCopilot/Handlers/ClipboardPipeHandler.cs
Normal file
151
src/AxCopilot/Handlers/ClipboardPipeHandler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
289
src/AxCopilot/Handlers/ColorHandler.cs
Normal file
289
src/AxCopilot/Handlers/ColorHandler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
59
src/AxCopilot/Handlers/ColorPickHandler.cs
Normal file
59
src/AxCopilot/Handlers/ColorPickHandler.cs
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
128
src/AxCopilot/Handlers/DateCalcHandler.cs
Normal file
128
src/AxCopilot/Handlers/DateCalcHandler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
245
src/AxCopilot/Handlers/DiffHandler.cs
Normal file
245
src/AxCopilot/Handlers/DiffHandler.cs
Normal 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);
|
||||
}
|
||||
553
src/AxCopilot/Handlers/EmojiHandler.cs
Normal file
553
src/AxCopilot/Handlers/EmojiHandler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
208
src/AxCopilot/Handlers/EncodeHandler.cs
Normal file
208
src/AxCopilot/Handlers/EncodeHandler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
100
src/AxCopilot/Handlers/EnvHandler.cs
Normal file
100
src/AxCopilot/Handlers/EnvHandler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
240
src/AxCopilot/Handlers/EverythingHandler.cs
Normal file
240
src/AxCopilot/Handlers/EverythingHandler.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
204
src/AxCopilot/Handlers/FavoriteHandler.cs
Normal file
204
src/AxCopilot/Handlers/FavoriteHandler.cs
Normal 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; } = "";
|
||||
}
|
||||
}
|
||||
443
src/AxCopilot/Handlers/HelpHandler.cs
Normal file
443
src/AxCopilot/Handlers/HelpHandler.cs
Normal 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);
|
||||
}
|
||||
132
src/AxCopilot/Handlers/JournalHandler.cs
Normal file
132
src/AxCopilot/Handlers/JournalHandler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
132
src/AxCopilot/Handlers/JsonHandler.cs
Normal file
132
src/AxCopilot/Handlers/JsonHandler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
350
src/AxCopilot/Handlers/JsonSkillLoader.cs
Normal file
350
src/AxCopilot/Handlers/JsonSkillLoader.cs
Normal 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);
|
||||
}
|
||||
107
src/AxCopilot/Handlers/MediaHandler.cs
Normal file
107
src/AxCopilot/Handlers/MediaHandler.cs
Normal 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);
|
||||
}
|
||||
163
src/AxCopilot/Handlers/MonitorHandler.cs
Normal file
163
src/AxCopilot/Handlers/MonitorHandler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
215
src/AxCopilot/Handlers/NoteHandler.cs
Normal file
215
src/AxCopilot/Handlers/NoteHandler.cs
Normal 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);
|
||||
229
src/AxCopilot/Handlers/PortHandler.cs
Normal file
229
src/AxCopilot/Handlers/PortHandler.cs
Normal 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;
|
||||
}
|
||||
127
src/AxCopilot/Handlers/ProcessHandler.cs
Normal file
127
src/AxCopilot/Handlers/ProcessHandler.cs
Normal 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);
|
||||
}
|
||||
127
src/AxCopilot/Handlers/QuickLinkHandler.cs
Normal file
127
src/AxCopilot/Handlers/QuickLinkHandler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
141
src/AxCopilot/Handlers/RecentFilesHandler.cs
Normal file
141
src/AxCopilot/Handlers/RecentFilesHandler.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
189
src/AxCopilot/Handlers/RenameHandler.cs
Normal file
189
src/AxCopilot/Handlers/RenameHandler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
186
src/AxCopilot/Handlers/RoutineHandler.cs
Normal file
186
src/AxCopilot/Handlers/RoutineHandler.cs
Normal 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);
|
||||
}
|
||||
96
src/AxCopilot/Handlers/RunHandler.cs
Normal file
96
src/AxCopilot/Handlers/RunHandler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
237
src/AxCopilot/Handlers/ScaffoldHandler.cs
Normal file
237
src/AxCopilot/Handlers/ScaffoldHandler.cs
Normal 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);
|
||||
}
|
||||
637
src/AxCopilot/Handlers/ScreenCaptureHandler.cs
Normal file
637
src/AxCopilot/Handlers/ScreenCaptureHandler.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
271
src/AxCopilot/Handlers/ServiceHandler.cs
Normal file
271
src/AxCopilot/Handlers/ServiceHandler.cs
Normal 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()
|
||||
};
|
||||
}
|
||||
201
src/AxCopilot/Handlers/SnapHandler.cs
Normal file
201
src/AxCopilot/Handlers/SnapHandler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
138
src/AxCopilot/Handlers/SnippetHandler.cs
Normal file
138
src/AxCopilot/Handlers/SnippetHandler.cs
Normal 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; }
|
||||
}
|
||||
322
src/AxCopilot/Handlers/SystemCommandHandler.cs
Normal file
322
src/AxCopilot/Handlers/SystemCommandHandler.cs
Normal 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);
|
||||
}
|
||||
509
src/AxCopilot/Handlers/SystemInfoHandler.cs
Normal file
509
src/AxCopilot/Handlers/SystemInfoHandler.cs
Normal 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);
|
||||
}
|
||||
149
src/AxCopilot/Handlers/TextStatsHandler.cs
Normal file
149
src/AxCopilot/Handlers/TextStatsHandler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
228
src/AxCopilot/Handlers/UninstallHandler.cs
Normal file
228
src/AxCopilot/Handlers/UninstallHandler.cs
Normal 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; }
|
||||
}
|
||||
216
src/AxCopilot/Handlers/WebSearchHandler.cs
Normal file
216
src/AxCopilot/Handlers/WebSearchHandler.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
130
src/AxCopilot/Handlers/WebSearchSummaryHandler.cs
Normal file
130
src/AxCopilot/Handlers/WebSearchSummaryHandler.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
129
src/AxCopilot/Handlers/WindowSwitchHandler.cs
Normal file
129
src/AxCopilot/Handlers/WindowSwitchHandler.cs
Normal 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);
|
||||
}
|
||||
118
src/AxCopilot/Handlers/WorkspaceHandler.cs
Normal file
118
src/AxCopilot/Handlers/WorkspaceHandler.cs
Normal 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 }
|
||||
Reference in New Issue
Block a user