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:
323
src/AxCopilot/Handlers/LeaveHandler.cs
Normal file
323
src/AxCopilot/Handlers/LeaveHandler.cs
Normal file
@@ -0,0 +1,323 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L23-2: 연차·휴가 관리. "leave" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: leave → 잔여 연차 현황
|
||||
/// leave set 15 → 연간 연차 일수 설정
|
||||
/// leave use 2026-04-10 → 1일 연차 기록
|
||||
/// leave use 2026-04-10 0.5 → 반차 기록
|
||||
/// leave use 2026-04-10 0.5 반차메모 → 메모 포함
|
||||
/// leave del 2026-04-10 → 기록 삭제
|
||||
/// leave remaining → 잔여 연차
|
||||
/// leave list → 올해 사용 이력
|
||||
/// leave clear → 올해 기록 초기화
|
||||
/// Enter → 저장 실행 또는 복사
|
||||
/// 저장: %APPDATA%\AxCopilot\leave.json
|
||||
/// </summary>
|
||||
public class LeaveHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "leave";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"연차 관리",
|
||||
"연차·휴가 기록 — 설정 · 사용 · 잔여 조회 · 이력",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
private static readonly string DataPath = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "leave.json");
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
private sealed class LeaveData
|
||||
{
|
||||
[JsonPropertyName("annualDays")] public double AnnualDays { get; set; } = 15;
|
||||
[JsonPropertyName("records")] public List<LeaveRecord> Records { get; set; } = [];
|
||||
}
|
||||
|
||||
private sealed class LeaveRecord
|
||||
{
|
||||
[JsonPropertyName("date")] public string Date { get; set; } = "";
|
||||
[JsonPropertyName("days")] public double Days { get; set; } = 1;
|
||||
[JsonPropertyName("note")] public string Note { get; set; } = "";
|
||||
}
|
||||
|
||||
// ── GetItemsAsync ─────────────────────────────────────────────────────────
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
var data = LoadData();
|
||||
var year = DateTime.Today.Year;
|
||||
var used = data.Records.Where(r => r.Date.StartsWith(year.ToString())).Sum(r => r.Days);
|
||||
var left = data.AnnualDays - used;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"연차 현황 — 연간 {data.AnnualDays}일 사용 {used}일 잔여 {left}일",
|
||||
"leave set N · leave use 날짜 [일수] [메모] · leave list · leave remaining",
|
||||
null, null, Symbol: "\uE716"));
|
||||
items.Add(new LauncherItem("leave set <일수>", "연간 연차 총일수 설정", null, null, Symbol: "\uE716"));
|
||||
items.Add(new LauncherItem("leave use <날짜>", "연차 사용 기록 (기본 1일)", null, null, Symbol: "\uE716"));
|
||||
items.Add(new LauncherItem("leave list", "올해 사용 이력 전체 조회", null, null, Symbol: "\uE716"));
|
||||
items.Add(new LauncherItem("leave remaining", "잔여 연차 확인", null, null, Symbol: "\uE716"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
// set N
|
||||
if (sub == "set")
|
||||
{
|
||||
if (parts.Length >= 2 && double.TryParse(parts[1], out var n) && n > 0)
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"연간 연차를 {n}일로 설정",
|
||||
$"현재: {data.AnnualDays}일 → {n}일 · Enter: 저장",
|
||||
null, ("set", n.ToString(System.Globalization.CultureInfo.InvariantCulture)),
|
||||
Symbol: "\uE716"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem("일수를 입력하세요", "예: leave set 15", null, null, Symbol: "\uE783"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// use 날짜 [일수] [메모]
|
||||
if (sub == "use")
|
||||
{
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
items.Add(new LauncherItem("날짜를 입력하세요", "예: leave use 2026-04-10", null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
var dateStr = parts[1];
|
||||
if (!DateOnly.TryParseExact(dateStr, new[] { "yyyy-MM-dd", "yyyy/MM/dd" },
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.None, out _))
|
||||
{
|
||||
items.Add(new LauncherItem("날짜 형식 오류", "yyyy-MM-dd 형식으로 입력하세요", null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
var useDays = 1.0;
|
||||
var note = "";
|
||||
if (parts.Length >= 3 && double.TryParse(parts[2],
|
||||
System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var pd))
|
||||
{
|
||||
useDays = pd;
|
||||
if (parts.Length >= 4)
|
||||
note = string.Join(" ", parts[3..]);
|
||||
}
|
||||
else if (parts.Length >= 3)
|
||||
{
|
||||
note = string.Join(" ", parts[2..]);
|
||||
}
|
||||
var newLeft = left - useDays;
|
||||
var label = note.Length > 0
|
||||
? $"{dateStr} 연차 {useDays}일 기록 [{note}]"
|
||||
: $"{dateStr} 연차 {useDays}일 기록";
|
||||
items.Add(new LauncherItem(
|
||||
label,
|
||||
$"잔여: {newLeft}일 · Enter: 저장",
|
||||
null, ("use", $"{dateStr}|{useDays.ToString(System.Globalization.CultureInfo.InvariantCulture)}|{note}"),
|
||||
Symbol: "\uE716"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// del 날짜
|
||||
if (sub == "del")
|
||||
{
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
items.Add(new LauncherItem("날짜를 입력하세요", "예: leave del 2026-04-10", null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
var delDate = parts[1];
|
||||
var target = data.Records.FirstOrDefault(r => r.Date == delDate);
|
||||
if (target == null)
|
||||
{
|
||||
items.Add(new LauncherItem($"{delDate} 기록 없음", "해당 날짜의 연차 기록이 없습니다", null, null, Symbol: "\uE783"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"{delDate} 기록 삭제 ({target.Days}일{(target.Note.Length > 0 ? " / " + target.Note : "")})",
|
||||
"Enter: 삭제",
|
||||
null, ("del", delDate), Symbol: "\uE716"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// remaining
|
||||
if (sub == "remaining")
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
$"잔여 연차: {left}일 (연간 {data.AnnualDays}일 - 사용 {used}일)",
|
||||
"Enter: 클립보드 복사",
|
||||
null, ("copy", $"잔여 연차: {left}일"), Symbol: "\uE716"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// list
|
||||
if (sub == "list")
|
||||
{
|
||||
var yearRecords = data.Records
|
||||
.Where(r => r.Date.StartsWith(year.ToString()))
|
||||
.OrderBy(r => r.Date)
|
||||
.ToList();
|
||||
items.Add(new LauncherItem(
|
||||
$"{year}년 연차 사용 이력 — {yearRecords.Count}건 총 {used}일",
|
||||
$"잔여: {left}일", null, null, Symbol: "\uE716"));
|
||||
if (yearRecords.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("사용 이력 없음", "", null, null, Symbol: "\uE716"));
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var r in yearRecords)
|
||||
{
|
||||
var noteStr = r.Note.Length > 0 ? $" [{r.Note}]" : "";
|
||||
items.Add(new LauncherItem(
|
||||
$"{r.Date} {r.Days}일{noteStr}",
|
||||
"Enter: 삭제",
|
||||
null, ("del", r.Date), Symbol: "\uE716"));
|
||||
}
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// clear
|
||||
if (sub == "clear")
|
||||
{
|
||||
var cnt = data.Records.Count(r => r.Date.StartsWith(year.ToString()));
|
||||
items.Add(new LauncherItem(
|
||||
$"{year}년 연차 기록 초기화 ({cnt}건)",
|
||||
"Enter: 확인",
|
||||
null, ("clear", year.ToString()), Symbol: "\uE716"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 기본 — 안내
|
||||
items.Add(new LauncherItem($"'{q}' — 알 수 없는 명령",
|
||||
"leave set · use · del · remaining · list · clear",
|
||||
null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ── ExecuteAsync ──────────────────────────────────────────────────────────
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
var data = LoadData();
|
||||
|
||||
switch (item.Data)
|
||||
{
|
||||
case ("set", string nStr) when double.TryParse(nStr,
|
||||
System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var n):
|
||||
data.AnnualDays = n;
|
||||
SaveData(data);
|
||||
NotificationService.Notify("연차 관리", $"연간 연차를 {n}일로 설정했습니다.");
|
||||
break;
|
||||
|
||||
case ("use", string payload):
|
||||
{
|
||||
var parts = payload.Split('|');
|
||||
if (parts.Length >= 2 &&
|
||||
double.TryParse(parts[1],
|
||||
System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var days))
|
||||
{
|
||||
var dateStr = parts[0];
|
||||
var note = parts.Length >= 3 ? parts[2] : "";
|
||||
// 중복 날짜 처리 — 누적
|
||||
var existing = data.Records.FirstOrDefault(r => r.Date == dateStr);
|
||||
if (existing != null)
|
||||
{
|
||||
existing.Days += days;
|
||||
if (note.Length > 0) existing.Note = note;
|
||||
}
|
||||
else
|
||||
{
|
||||
data.Records.Add(new LeaveRecord { Date = dateStr, Days = days, Note = note });
|
||||
}
|
||||
data.Records.Sort((a, b) => string.Compare(a.Date, b.Date, StringComparison.Ordinal));
|
||||
SaveData(data);
|
||||
NotificationService.Notify("연차 관리", $"{dateStr} 연차 {days}일 기록됐습니다.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case ("del", string delDate):
|
||||
{
|
||||
var removed = data.Records.RemoveAll(r => r.Date == delDate);
|
||||
if (removed > 0)
|
||||
{
|
||||
SaveData(data);
|
||||
NotificationService.Notify("연차 관리", $"{delDate} 기록이 삭제됐습니다.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case ("clear", string yearStr) when int.TryParse(yearStr, out var y):
|
||||
{
|
||||
var cnt = data.Records.RemoveAll(r => r.Date.StartsWith(y.ToString()));
|
||||
SaveData(data);
|
||||
NotificationService.Notify("연차 관리", $"{y}년 연차 기록 {cnt}건 초기화됐습니다.");
|
||||
break;
|
||||
}
|
||||
|
||||
case ("copy", string text):
|
||||
try
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
|
||||
NotificationService.Notify("연차 관리", "클립보드에 복사했습니다.");
|
||||
}
|
||||
catch { }
|
||||
break;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 저장/불러오기 ─────────────────────────────────────────────────────────
|
||||
|
||||
private static LeaveData LoadData()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!System.IO.File.Exists(DataPath)) return new LeaveData();
|
||||
var json = System.IO.File.ReadAllText(DataPath, System.Text.Encoding.UTF8);
|
||||
return JsonSerializer.Deserialize<LeaveData>(json, JsonOpts) ?? new LeaveData();
|
||||
}
|
||||
catch { return new LeaveData(); }
|
||||
}
|
||||
|
||||
private static void SaveData(LeaveData data)
|
||||
{
|
||||
try
|
||||
{
|
||||
System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(DataPath)!);
|
||||
System.IO.File.WriteAllText(DataPath,
|
||||
JsonSerializer.Serialize(data, JsonOpts),
|
||||
System.Text.Encoding.UTF8);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user