AX Commander 비교본 런처 기능 대량 이식

변경 목적: Agent Compare 아래 비교본의 개발 문서와 런처 소스를 기준으로 현재 AX Commander에 빠져 있던 신규 런처 기능을 동일한 흐름으로 옮겨, 비교본 수준의 기능 폭을 현재 제품에 반영했습니다.

핵심 수정사항: 비교본의 신규 런처 핸들러 다수를 src/AxCopilot/Handlers로 이식하고 App.xaml.cs 등록 흐름에 연결했습니다. 빠른 링크, 파일 태그, 알림 센터, 포모도로, 파일 브라우저, 핫키 관리, OCR, 세션/스케줄/매크로, Git/정규식/네트워크/압축/해시/UUID/JWT/QR 등 AX Commander 기능을 추가했습니다.

핵심 수정사항: 신규 기능이 실제 동작하도록 AppSettings 확장, SchedulerService/FileTagService/NotificationCenterService/IconCacheService/UrlTemplateEngine/PomodoroService 추가, 배치 이름변경/세션/스케줄/매크로 편집 창 추가, NotificationService와 Symbols 보강, QR/OCR용 csproj 의존성과 Windows 타겟 프레임워크를 반영했습니다.

문서 반영: README.md와 docs/DEVELOPMENT.md에 비교본 기반 런처 기능 이식 이력과 검증 결과를 업데이트했습니다.

검증 결과: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 실행 기준 경고 0개, 오류 0개를 확인했습니다.
This commit is contained in:
2026-04-05 00:59:45 +09:00
parent 0929778ca7
commit 0336904258
115 changed files with 30749 additions and 1 deletions

View File

@@ -0,0 +1,324 @@
using System.Text;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L20-2: 랜덤 생성기 핸들러. "rand" 프리픽스로 사용합니다.
///
/// 예: rand → 사용법 목록
/// rand → 1~100 기본 난수
/// rand 50 → 1~50 난수
/// rand 10 99 → 10~99 난수
/// rand str → 랜덤 문자열 (12자, 영숫자)
/// rand str 20 → 20자 랜덤 문자열
/// rand str 16 alpha → 16자 알파벳만
/// rand str 8 num → 8자 숫자만
/// rand str 12 hex → 12자 hex 문자열
/// rand str 16 special → 특수문자 포함
/// rand color → 랜덤 HEX 색상
/// rand pick 항목1 항목2 → 목록에서 무작위 선택
/// rand dice → 주사위 1d6
/// rand dice 2d6 → 2개 주사위
/// rand dice 1d20 → 20면체 주사위
/// rand coin → 동전 던지기
/// rand shuffle a b c d → 목록 셔플
/// rand uuid → UUID v4
/// rand token → 보안 토큰 (32자 hex)
/// rand pin → 6자리 PIN
/// rand pin 4 → 4자리 PIN
/// Enter → 결과 복사.
/// </summary>
public class RandHandler : IActionHandler
{
public string? Prefix => "rand";
public PluginMetadata Metadata => new(
"Rand",
"랜덤 생성기 — 숫자·문자열·색상·주사위·UUID·토큰·PIN·셔플",
"1.0",
"AX");
private static readonly Random _rng = Random.Shared;
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
if (string.IsNullOrWhiteSpace(q))
{
// 기본: 1~100 난수 + 사용법
var n = _rng.Next(1, 101);
items.Add(new LauncherItem($"{n}",
"1~100 랜덤 숫자 · Enter 복사", null, ("copy", n.ToString()), Symbol: "\uE8D0"));
items.Add(new LauncherItem("사용법",
"rand <max> / rand <min> <max> / rand str / rand color / rand dice / rand pick …",
null, null, Symbol: "\uE8D0"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
switch (sub)
{
// rand str [length] [charset]
case "str" or "string" or "문자열":
{
int len = 12;
if (parts.Length >= 2 && int.TryParse(parts[1], out var l)) len = Math.Clamp(l, 1, 256);
var charset = parts.Length >= 3 ? parts[2].ToLowerInvariant() : "alnum";
if (parts.Length == 2 && !int.TryParse(parts[1], out _)) charset = parts[1].ToLowerInvariant();
var cs = GetCharset(charset);
if (string.IsNullOrEmpty(cs)) { items.Add(ErrorItem("charset: alpha/num/alnum/hex/special")); break; }
var result = RandStr(len, cs);
items.Add(new LauncherItem(result, $"{len}자 랜덤 문자열 ({charset}) · Enter 복사",
null, ("copy", result), Symbol: "\uE8D0"));
// 다른 자릿수 미리 생성
foreach (var altLen in new[] { 8, 16, 32 }.Where(x => x != len))
items.Add(new LauncherItem(RandStr(altLen, cs), $"{altLen}자 ({charset})",
null, ("copy", RandStr(altLen, cs)), Symbol: "\uE8D0"));
break;
}
// rand color
case "color" or "색상" or "colour":
{
for (int i = 0; i < 5; i++)
{
var r = _rng.Next(256); var g = _rng.Next(256); var b = _rng.Next(256);
var hex = $"#{r:X2}{g:X2}{b:X2}";
var rgb = $"rgb({r}, {g}, {b})";
var hsl = RgbToHsl(r, g, b);
items.Add(new LauncherItem(hex, $"{rgb} {hsl} · Enter 복사",
null, ("copy", hex), Symbol: "\uE8D0"));
}
break;
}
// rand dice [NdS]
case "dice" or "주사위":
{
var spec = parts.Length >= 2 ? parts[1].ToLowerInvariant() : "1d6";
if (!TryParseDice(spec, out var dCount, out var dSides))
{ items.Add(ErrorItem("형식: 1d6 / 2d10 / 1d20")); break; }
var rolls = Enumerable.Range(0, dCount).Select(_ => _rng.Next(1, dSides + 1)).ToList();
var total = rolls.Sum();
var detail = string.Join(" + ", rolls);
items.Add(new LauncherItem($"{dCount}d{dSides} → {total}",
$"각 주사위: {detail} · Enter 복사", null, ("copy", total.ToString()), Symbol: "\uE8D0"));
if (dCount > 1)
{
items.Add(CopyItem("합계", total.ToString()));
items.Add(CopyItem("상세", detail));
items.Add(CopyItem("최솟값", rolls.Min().ToString()));
items.Add(CopyItem("최댓값", rolls.Max().ToString()));
}
// 다시 굴리기 미리보기
items.Add(new LauncherItem("── 다시 굴리기 (미리보기) ──", "", null, null, Symbol: "\uE8D0"));
for (int i = 0; i < 3; i++)
{
var r2 = Enumerable.Range(0, dCount).Select(_ => _rng.Next(1, dSides + 1)).ToList();
items.Add(new LauncherItem($"{r2.Sum()}", string.Join(" + ", r2),
null, ("copy", r2.Sum().ToString()), Symbol: "\uE8D0"));
}
break;
}
// rand coin
case "coin" or "동전":
{
var result = _rng.Next(2) == 0 ? "앞면 (Head)" : "뒷면 (Tail)";
items.Add(new LauncherItem(result, "동전 던지기 · Enter 복사",
null, ("copy", result), Symbol: "\uE8D0"));
// 연속 5회
items.Add(new LauncherItem("── 연속 5회 ──", "", null, null, Symbol: "\uE8D0"));
for (int i = 0; i < 5; i++)
items.Add(new LauncherItem(_rng.Next(2) == 0 ? "앞면 ⬤" : "뒷면 ○",
$"#{i + 1}", null, null, Symbol: "\uE8D0"));
break;
}
// rand pick item1 item2 ...
case "pick" or "선택":
{
if (parts.Length < 2) { items.Add(ErrorItem("예: rand pick 치킨 피자 짜장면")); break; }
var pool = parts[1..];
var picked = pool[_rng.Next(pool.Length)];
items.Add(new LauncherItem(picked,
$"{pool.Length}개 중 선택 · Enter 복사",
null, ("copy", picked), Symbol: "\uE8D0"));
items.Add(new LauncherItem("── 다시 선택 ──", "", null, null, Symbol: "\uE8D0"));
for (int i = 0; i < Math.Min(3, pool.Length); i++)
items.Add(new LauncherItem(pool[_rng.Next(pool.Length)],
$"후보 {i + 1}", null, null, Symbol: "\uE8D0"));
break;
}
// rand shuffle item1 item2 ...
case "shuffle" or "섞기":
{
if (parts.Length < 2) { items.Add(ErrorItem("예: rand shuffle a b c d e")); break; }
var list = parts[1..].ToList();
for (int i = list.Count - 1; i > 0; i--)
{
var j = _rng.Next(i + 1);
(list[i], list[j]) = (list[j], list[i]);
}
var joined = string.Join(" ", list);
items.Add(new LauncherItem(joined,
$"{list.Count}개 셔플 · Enter 복사",
null, ("copy", joined), Symbol: "\uE8D0"));
items.Add(CopyItem("쉼표 구분", string.Join(", ", list)));
for (int r = 0; r < list.Count; r++)
items.Add(new LauncherItem($"#{r + 1} {list[r]}", "", null, null, Symbol: "\uE8D0"));
break;
}
// rand uuid
case "uuid":
{
for (int i = 0; i < 5; i++)
{
var guid = Guid.NewGuid().ToString();
items.Add(new LauncherItem(guid, $"UUID v4 · Enter 복사",
null, ("copy", guid), Symbol: "\uE8D0"));
}
break;
}
// rand token
case "token" or "secret" or "key":
{
int tLen = 32;
if (parts.Length >= 2 && int.TryParse(parts[1], out var tl)) tLen = Math.Clamp(tl, 8, 128);
var tokenBytes = new byte[tLen];
System.Security.Cryptography.RandomNumberGenerator.Fill(tokenBytes);
var tokenHex = BitConverter.ToString(tokenBytes).Replace("-", "").ToLowerInvariant();
var tokenB64 = Convert.ToBase64String(tokenBytes);
items.Add(new LauncherItem(tokenHex, $"{tLen}바이트 보안 토큰 (hex) · Enter 복사",
null, ("copy", tokenHex), Symbol: "\uE8D0"));
items.Add(CopyItem("Hex", tokenHex));
items.Add(CopyItem("Base64", tokenB64));
break;
}
// rand pin [length]
case "pin":
{
int pLen = 6;
if (parts.Length >= 2 && int.TryParse(parts[1], out var pl)) pLen = Math.Clamp(pl, 4, 12);
var pin = string.Concat(Enumerable.Range(0, pLen).Select(_ => _rng.Next(10)));
items.Add(new LauncherItem(pin, $"{pLen}자리 PIN · Enter 복사",
null, ("copy", pin), Symbol: "\uE8D0"));
items.Add(new LauncherItem("── 추가 PIN ──", "", null, null, Symbol: "\uE8D0"));
for (int i = 0; i < 4; i++)
items.Add(new LauncherItem(
string.Concat(Enumerable.Range(0, pLen).Select(_ => _rng.Next(10))),
$"{pLen}자리", null, null, Symbol: "\uE8D0"));
break;
}
default:
{
// rand <max> 또는 rand <min> <max>
if (int.TryParse(sub, out var max))
{
int min = 1;
if (parts.Length >= 2 && int.TryParse(parts[1], out var mx)) { min = max; max = mx; }
if (min > max) (min, max) = (max, min);
var n = _rng.Next(min, max + 1);
items.Add(new LauncherItem($"{n}",
$"{min}~{max} 랜덤 숫자 · Enter 복사",
null, ("copy", n.ToString()), Symbol: "\uE8D0"));
items.Add(new LauncherItem("── 추가 결과 ──", "", null, null, Symbol: "\uE8D0"));
for (int i = 0; i < 4; i++)
items.Add(new LauncherItem(_rng.Next(min, max + 1).ToString(),
$"{min}~{max}", null, null, Symbol: "\uE8D0"));
}
else
{
items.Add(new LauncherItem($"알 수 없는 서브커맨드: '{sub}'",
"rand / rand <n> / rand str / rand color / rand dice / rand pick / rand uuid / rand token / rand pin",
null, null, Symbol: "\uE783"));
}
break;
}
}
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Rand", "클립보드에 복사했습니다.");
}
catch { }
}
return Task.CompletedTask;
}
// ── 헬퍼 ────────────────────────────────────────────────────────────────
private static string GetCharset(string name) => name switch
{
"alpha" or "알파" => "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
"num" or "숫자" => "0123456789",
"alnum" or "영숫자" => "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
"hex" => "0123456789abcdef",
"lower" => "abcdefghijklmnopqrstuvwxyz",
"upper" => "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
"special" => "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*",
_ => "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
};
private static string RandStr(int len, string charset)
{
var sb = new StringBuilder(len);
for (int i = 0; i < len; i++) sb.Append(charset[_rng.Next(charset.Length)]);
return sb.ToString();
}
private static bool TryParseDice(string s, out int count, out int sides)
{
count = sides = 0;
var d = s.IndexOf('d');
if (d < 0) return false;
var left = d == 0 ? "1" : s[..d];
var right = s[(d + 1)..];
return int.TryParse(left, out count) && count >= 1 && count <= 100 &&
int.TryParse(right, out sides) && sides >= 2 && sides <= 10000;
}
private static string RgbToHsl(int r, int g, int b)
{
var rf = r / 255.0; var gf = g / 255.0; var bf = b / 255.0;
var max = Math.Max(rf, Math.Max(gf, bf));
var min = Math.Min(rf, Math.Min(gf, bf));
var l = (max + min) / 2;
if (max == min) return $"hsl(0, 0%, {l:P0})";
var d = max - min;
var s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
var h = max == rf ? (gf - bf) / d + (gf < bf ? 6 : 0)
: max == gf ? (bf - rf) / d + 2
: (rf - gf) / d + 4;
h /= 6;
return $"hsl({h * 360:F0}, {s:P0}, {l:P0})";
}
private static LauncherItem CopyItem(string label, string value) =>
new(label, value, null, ("copy", value), Symbol: "\uE8D0");
private static LauncherItem ErrorItem(string msg) =>
new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783");
}