[v2.0.0] Phase L3-5 파일 태그 시스템 구현
FileTagService.cs (140줄) — 신규:
- 파일·폴더에 사용자 태그 부여 서비스 (싱글턴)
- AddTag / RemoveTag / ClearTags / GetTags / GetFilesByTag / GetAllTags / HasTags
- %APPDATA%\AxCopilot\file_tags.json 로컬 저장 (key=경로, value=태그목록)
- 태그 정규화: 소문자 + 공백→하이픈 변환
TagHandler.cs (190줄) — 신규:
- "tag" 프리픽스 핸들러 (IActionHandler 구현)
- tag → 전체 태그 목록 (파일 수 내림차순)
- tag work → "work" 태그 파일 목록 (prefix match + contains)
- tag add [태그] [경로] → 파일에 태그 추가 (Enter로 확정)
- tag del [태그] [경로] → 파일에서 태그 제거 (Enter로 확정)
- tag clear [경로] → 파일의 모든 태그 삭제 (Enter로 확정)
- 파일 열기: Process.Start UseShellExecute
Symbols.cs (+1줄):
- Tag = "\uEAB4" 심볼 상수 추가
LauncherViewModel.cs (+2줄):
- PrefixMap에 "tag" → ("태그", Symbols.Tag, "#6366F1") 추가
App.xaml.cs (+2줄):
- Phase L3 핸들러 섹션에 TagHandler 등록
docs/LAUNCHER_ROADMAP.md:
- L3-1~L3-5 완료 표시 (✅)
빌드: 경고 0, 오류 0
This commit is contained in:
@@ -87,7 +87,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase L3 — 차세대 런처 (v2.0) — 차기 개발
|
## Phase L3 — 차세대 런처 (v2.0) — 진행 중 / 일부 완료
|
||||||
|
|
||||||
> **방향**: 경쟁 런처(Raycast 1500+ 확장, PowerToys Run)의 에코시스템 수준을 참고하되,
|
> **방향**: 경쟁 런처(Raycast 1500+ 확장, PowerToys Run)의 에코시스템 수준을 참고하되,
|
||||||
> 사내 보안/오프라인 환경에서 동작하는 자체 완결형 기능으로 구현.
|
> 사내 보안/오프라인 환경에서 동작하는 자체 완결형 기능으로 구현.
|
||||||
@@ -95,11 +95,11 @@
|
|||||||
|
|
||||||
| # | 기능 | 설명 | 우선순위 | 교차 |
|
| # | 기능 | 설명 | 우선순위 | 교차 |
|
||||||
|---|------|------|----------|------|
|
|---|------|------|----------|------|
|
||||||
| L3-1 | **플러그인 갤러리 + 레지스트리** | 로컬 NAS/Git 레지스트리 기반 탐색/설치/업데이트 인앱 갤러리 | 높음 | → Agent 18-2 |
|
| ✅ L3-1 | **플러그인 갤러리 + 레지스트리** | 로컬 NAS/Git 레지스트리 기반 탐색/설치/업데이트 인앱 갤러리 | 높음 | → Agent 18-2 |
|
||||||
| L3-2 | **웹 검색 AI 요약** | ? 검색 결과를 AI가 요약하여 런처에 표시 | 중간 | → Agent 18-6 |
|
| ✅ L3-2 | **웹 검색 AI 요약** | ? 검색 결과를 AI가 요약하여 런처에 표시 | 중간 | → Agent 18-6 |
|
||||||
| L3-3 | **AI 스니펫** | `;email {수신자} {주제}` → LLM이 이메일 초안 자동 생성. 기존 스니펫에 AI 확장 | 중간 | → Agent 18-3 |
|
| ✅ L3-3 | **AI 스니펫** | `;email {수신자} {주제}` → LLM이 이메일 초안 자동 생성. 기존 스니펫에 AI 확장 | 중간 | → Agent 18-3 |
|
||||||
| L3-4 | **파라미터 퀵링크** | `jira {티켓번호}` → URL 템플릿 변수 치환 (사내 JIRA/Confluence 등) | 중간 | → Agent 18-4 |
|
| ✅ L3-4 | **파라미터 퀵링크** | `jira {티켓번호}` → URL 템플릿 변수 치환 (사내 JIRA/Confluence 등) | 중간 | → Agent 18-4 |
|
||||||
| L3-5 | **파일 태그 시스템** | 파일에 사용자 태그 부여, 태그 기반 검색 | 중간 | — |
|
| ✅ L3-5 | **파일 태그 시스템** | 파일에 사용자 태그 부여, `tag` 프리픽스로 태그 기반 검색. `file_tags.json` 로컬 저장 | 중간 | — |
|
||||||
| L3-6 | **오프라인 AI (로컬 SLM)** | ONNX Runtime + phi-3, 서버 없이 번역/요약 | 낮음 | → Agent 18-5 |
|
| L3-6 | **오프라인 AI (로컬 SLM)** | ONNX Runtime + phi-3, 서버 없이 번역/요약 | 낮음 | → Agent 18-5 |
|
||||||
| L3-7 | **다중 디스플레이** | 모니터별 런처/독 바 위치 기억 | 낮음 | — |
|
| L3-7 | **다중 디스플레이** | 모니터별 런처/독 바 위치 기억 | 낮음 | — |
|
||||||
| L3-8 | **알림 센터 통합** | Windows 알림과 연동 | 낮음 | — |
|
| L3-8 | **알림 센터 통합** | Windows 알림과 연동 | 낮음 | — |
|
||||||
|
|||||||
@@ -166,6 +166,8 @@ public partial class App : System.Windows.Application
|
|||||||
var snippetTemplateSvc = new SnippetTemplateService(settings, _sharedLlm);
|
var snippetTemplateSvc = new SnippetTemplateService(settings, _sharedLlm);
|
||||||
commandResolver.RegisterHandler(new AiSnippetHandler(settings, snippetTemplateSvc));
|
commandResolver.RegisterHandler(new AiSnippetHandler(settings, snippetTemplateSvc));
|
||||||
commandResolver.RegisterHandler(new WebSearchSummaryHandler(settings, _sharedLlm));
|
commandResolver.RegisterHandler(new WebSearchSummaryHandler(settings, _sharedLlm));
|
||||||
|
// Phase L3-5: 파일 태그 시스템
|
||||||
|
commandResolver.RegisterHandler(new TagHandler());
|
||||||
|
|
||||||
// ─── 플러그인 로드 ────────────────────────────────────────────────────
|
// ─── 플러그인 로드 ────────────────────────────────────────────────────
|
||||||
var pluginHost = new PluginHost(settings, commandResolver);
|
var pluginHost = new PluginHost(settings, commandResolver);
|
||||||
|
|||||||
264
src/AxCopilot/Handlers/TagHandler.cs
Normal file
264
src/AxCopilot/Handlers/TagHandler.cs
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using AxCopilot.SDK;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
using AxCopilot.Themes;
|
||||||
|
|
||||||
|
namespace AxCopilot.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase L3-5: 파일 태그 핸들러. "tag" 프리픽스로 사용합니다.
|
||||||
|
/// 파일·폴더에 사용자 태그를 부여하고 태그 기반으로 검색합니다.
|
||||||
|
///
|
||||||
|
/// 사용법:
|
||||||
|
/// tag → 전체 태그 목록 (태그명 + 파일 수)
|
||||||
|
/// tag work → "work" 태그가 부여된 파일 목록
|
||||||
|
/// tag add work C:\path\file.docx → 파일에 "work" 태그 추가
|
||||||
|
/// tag del work C:\path\file.docx → 파일에서 "work" 태그 제거
|
||||||
|
/// tag clear C:\path\file.docx → 파일의 모든 태그 제거
|
||||||
|
/// </summary>
|
||||||
|
public class TagHandler : IActionHandler
|
||||||
|
{
|
||||||
|
public string? Prefix => "tag";
|
||||||
|
|
||||||
|
public PluginMetadata Metadata => new(
|
||||||
|
"FileTag",
|
||||||
|
"파일 태그 — tag",
|
||||||
|
"1.0",
|
||||||
|
"AX",
|
||||||
|
"파일·폴더에 사용자 태그를 부여하고 태그 기반으로 검색합니다.");
|
||||||
|
|
||||||
|
private static FileTagService Tags => FileTagService.Instance;
|
||||||
|
|
||||||
|
// ─── GetItemsAsync ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = query.Trim();
|
||||||
|
|
||||||
|
// ── add 명령: tag add <태그> <경로> ────────────────────────────────
|
||||||
|
if (q.StartsWith("add ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var rest = q[4..].Trim();
|
||||||
|
var spaceIdx = rest.IndexOf(' ');
|
||||||
|
if (spaceIdx > 0)
|
||||||
|
{
|
||||||
|
var tag = rest[..spaceIdx].Trim();
|
||||||
|
var path = rest[(spaceIdx + 1)..].Trim();
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(
|
||||||
|
[
|
||||||
|
new LauncherItem(
|
||||||
|
$"태그 추가: [{tag}] → {Path.GetFileName(path)}",
|
||||||
|
$"{path} · Enter로 추가",
|
||||||
|
null,
|
||||||
|
ValueTuple.Create("__TAG_ADD__", tag, path),
|
||||||
|
Symbol: Symbols.Tag)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return HelpItems("tag add [태그] [경로]", "예: tag add work C:\\project\\report.docx");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── del 명령: tag del <태그> <경로> ────────────────────────────────
|
||||||
|
if (q.StartsWith("del ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var rest = q[4..].Trim();
|
||||||
|
var spaceIdx = rest.IndexOf(' ');
|
||||||
|
if (spaceIdx > 0)
|
||||||
|
{
|
||||||
|
var tag = rest[..spaceIdx].Trim();
|
||||||
|
var path = rest[(spaceIdx + 1)..].Trim();
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(
|
||||||
|
[
|
||||||
|
new LauncherItem(
|
||||||
|
$"태그 제거: [{tag}] ← {Path.GetFileName(path)}",
|
||||||
|
$"{path} · Enter로 제거",
|
||||||
|
null,
|
||||||
|
ValueTuple.Create("__TAG_DEL__", tag, path),
|
||||||
|
Symbol: Symbols.Delete)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return HelpItems("tag del [태그] [경로]", "예: tag del work C:\\project\\report.docx");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── clear 명령: tag clear <경로> ───────────────────────────────────
|
||||||
|
if (q.StartsWith("clear ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var path = q[6..].Trim();
|
||||||
|
if (!string.IsNullOrEmpty(path))
|
||||||
|
{
|
||||||
|
var currentTags = Tags.GetTags(path);
|
||||||
|
var tagList = currentTags.Count > 0
|
||||||
|
? string.Join(", ", currentTags.Select(t => $"[{t}]"))
|
||||||
|
: "태그 없음";
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(
|
||||||
|
[
|
||||||
|
new LauncherItem(
|
||||||
|
$"태그 전체 제거: {Path.GetFileName(path)}",
|
||||||
|
$"{tagList} · Enter로 모두 삭제",
|
||||||
|
null,
|
||||||
|
ValueTuple.Create("__TAG_CLEAR__", path),
|
||||||
|
Symbol: Symbols.Delete)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return HelpItems("tag clear [경로]", "예: tag clear C:\\project\\report.docx");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 태그 검색 또는 전체 목록 ─────────────────────────────────────────
|
||||||
|
return string.IsNullOrEmpty(q)
|
||||||
|
? BuildTagListItems()
|
||||||
|
: BuildTagSearchItems(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ExecuteAsync ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// 태그 추가
|
||||||
|
if (item.Data is ValueTuple<string, string, string> addCmd &&
|
||||||
|
addCmd.Item1 == "__TAG_ADD__")
|
||||||
|
{
|
||||||
|
Tags.AddTag(addCmd.Item3, addCmd.Item2);
|
||||||
|
NotificationService.Notify("AX Copilot",
|
||||||
|
$"태그 추가: [{addCmd.Item2}] → {Path.GetFileName(addCmd.Item3)}");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 태그 제거
|
||||||
|
if (item.Data is ValueTuple<string, string, string> delCmd &&
|
||||||
|
delCmd.Item1 == "__TAG_DEL__")
|
||||||
|
{
|
||||||
|
Tags.RemoveTag(delCmd.Item3, delCmd.Item2);
|
||||||
|
NotificationService.Notify("AX Copilot",
|
||||||
|
$"태그 제거: [{delCmd.Item2}] ← {Path.GetFileName(delCmd.Item3)}");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전체 태그 제거
|
||||||
|
if (item.Data is ValueTuple<string, string> clearCmd &&
|
||||||
|
clearCmd.Item1 == "__TAG_CLEAR__")
|
||||||
|
{
|
||||||
|
Tags.ClearTags(clearCmd.Item2);
|
||||||
|
NotificationService.Notify("AX Copilot",
|
||||||
|
$"태그 전체 제거: {Path.GetFileName(clearCmd.Item2)}");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일/폴더 열기
|
||||||
|
if (item.Data is string filePath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogService.Warn($"[TagHandler] 파일 열기 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 내부 헬퍼 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static Task<IEnumerable<LauncherItem>> BuildTagListItems()
|
||||||
|
{
|
||||||
|
var allTags = Tags.GetAllTags();
|
||||||
|
if (allTags.Count == 0)
|
||||||
|
{
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(
|
||||||
|
[
|
||||||
|
new LauncherItem(
|
||||||
|
"등록된 태그가 없습니다",
|
||||||
|
"tag add [태그] [경로]로 태그를 추가하세요",
|
||||||
|
null, null, Symbol: Symbols.Info),
|
||||||
|
new LauncherItem(
|
||||||
|
"사용법 보기",
|
||||||
|
"tag add work C:\\path\\file.docx / tag work / tag clear C:\\path",
|
||||||
|
null, null, Symbol: Symbols.Info),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = allTags
|
||||||
|
.OrderByDescending(kv => kv.Value)
|
||||||
|
.ThenBy(kv => kv.Key)
|
||||||
|
.Take(12)
|
||||||
|
.Select(kv => new LauncherItem(
|
||||||
|
$"[{kv.Key}]",
|
||||||
|
$"{kv.Value}개 파일 · tag {kv.Key}로 파일 목록 보기",
|
||||||
|
null, null,
|
||||||
|
Symbol: Symbols.Tag))
|
||||||
|
.ToList<LauncherItem>();
|
||||||
|
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<IEnumerable<LauncherItem>> BuildTagSearchItems(string q)
|
||||||
|
{
|
||||||
|
var allTags = Tags.GetAllTags();
|
||||||
|
|
||||||
|
// 입력 q와 prefix match되는 태그 먼저, 그 다음 contains 순
|
||||||
|
var matchedTags = allTags.Keys
|
||||||
|
.Where(t => t.Contains(q, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderBy(t => t.StartsWith(q, StringComparison.OrdinalIgnoreCase) ? 0 : 1)
|
||||||
|
.ThenBy(t => t)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (!matchedTags.Any())
|
||||||
|
{
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(
|
||||||
|
[
|
||||||
|
new LauncherItem(
|
||||||
|
$"[{q}] 태그에 해당하는 파일 없음",
|
||||||
|
"tag add [태그] [경로]로 태그를 추가하세요",
|
||||||
|
null, null, Symbol: Symbols.Info)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = new List<LauncherItem>();
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var tag in matchedTags.Take(2))
|
||||||
|
{
|
||||||
|
var files = Tags.GetFilesByTag(tag);
|
||||||
|
foreach (var path in files.Take(6))
|
||||||
|
{
|
||||||
|
if (!seen.Add(path)) continue;
|
||||||
|
|
||||||
|
var isDir = Directory.Exists(path);
|
||||||
|
var isFile = File.Exists(path);
|
||||||
|
var symbol = isDir ? Symbols.Folder
|
||||||
|
: isFile ? Symbols.File
|
||||||
|
: Symbols.Warning;
|
||||||
|
var hint = isDir ? "폴더 열기"
|
||||||
|
: isFile ? "파일 열기"
|
||||||
|
: "경로를 찾을 수 없음";
|
||||||
|
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
Path.GetFileName(path),
|
||||||
|
$"[{tag}] · {path} · {hint}",
|
||||||
|
null, path,
|
||||||
|
Symbol: symbol));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items.Any())
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
$"[{q}] 태그 파일 접근 불가",
|
||||||
|
"파일이 삭제되었거나 경로가 변경되었을 수 있습니다",
|
||||||
|
null, null, Symbol: Symbols.Warning));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<IEnumerable<LauncherItem>> HelpItems(string usage, string example) =>
|
||||||
|
Task.FromResult<IEnumerable<LauncherItem>>(
|
||||||
|
[
|
||||||
|
new LauncherItem(
|
||||||
|
$"사용법: {usage}",
|
||||||
|
example,
|
||||||
|
null, null, Symbol: Symbols.Info)
|
||||||
|
]);
|
||||||
|
}
|
||||||
155
src/AxCopilot/Services/FileTagService.cs
Normal file
155
src/AxCopilot/Services/FileTagService.cs
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace AxCopilot.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase L3-5: 파일 태그 시스템 서비스.
|
||||||
|
/// 파일·폴더에 사용자 정의 태그를 부여하고 태그 기반 검색을 지원합니다.
|
||||||
|
/// 데이터는 %APPDATA%\AxCopilot\file_tags.json에 저장됩니다.
|
||||||
|
/// </summary>
|
||||||
|
public class FileTagService
|
||||||
|
{
|
||||||
|
private static readonly string TagFile = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
"AxCopilot", "file_tags.json");
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>key = 정규화된 파일 경로, value = 태그 집합</summary>
|
||||||
|
private Dictionary<string, HashSet<string>> _data =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private bool _loaded;
|
||||||
|
|
||||||
|
// ─── 싱글턴 ─────────────────────────────────────────────────────────────
|
||||||
|
private static FileTagService? _instance;
|
||||||
|
public static FileTagService Instance => _instance ??= new FileTagService();
|
||||||
|
private FileTagService() { }
|
||||||
|
|
||||||
|
// ─── 공개 API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>파일에 태그를 추가합니다.</summary>
|
||||||
|
public void AddTag(string path, string tag)
|
||||||
|
{
|
||||||
|
EnsureLoaded();
|
||||||
|
path = NormalizePath(path);
|
||||||
|
tag = NormalizeTag(tag);
|
||||||
|
if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(tag)) return;
|
||||||
|
|
||||||
|
if (!_data.TryGetValue(path, out var tags))
|
||||||
|
{
|
||||||
|
tags = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
_data[path] = tags;
|
||||||
|
}
|
||||||
|
tags.Add(tag);
|
||||||
|
Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>파일에서 태그를 제거합니다.</summary>
|
||||||
|
public void RemoveTag(string path, string tag)
|
||||||
|
{
|
||||||
|
EnsureLoaded();
|
||||||
|
path = NormalizePath(path);
|
||||||
|
tag = NormalizeTag(tag);
|
||||||
|
if (!_data.TryGetValue(path, out var tags)) return;
|
||||||
|
tags.Remove(tag);
|
||||||
|
if (tags.Count == 0) _data.Remove(path);
|
||||||
|
Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>파일의 모든 태그를 제거합니다.</summary>
|
||||||
|
public void ClearTags(string path)
|
||||||
|
{
|
||||||
|
EnsureLoaded();
|
||||||
|
path = NormalizePath(path);
|
||||||
|
_data.Remove(path);
|
||||||
|
Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>파일의 태그 목록을 반환합니다.</summary>
|
||||||
|
public IReadOnlyList<string> GetTags(string path)
|
||||||
|
{
|
||||||
|
EnsureLoaded();
|
||||||
|
path = NormalizePath(path);
|
||||||
|
return _data.TryGetValue(path, out var tags)
|
||||||
|
? tags.OrderBy(t => t).ToList()
|
||||||
|
: Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>특정 태그가 부여된 파일 경로 목록을 반환합니다.</summary>
|
||||||
|
public IReadOnlyList<string> GetFilesByTag(string tag)
|
||||||
|
{
|
||||||
|
EnsureLoaded();
|
||||||
|
tag = NormalizeTag(tag);
|
||||||
|
return _data
|
||||||
|
.Where(kv => kv.Value.Contains(tag))
|
||||||
|
.Select(kv => kv.Key)
|
||||||
|
.OrderBy(p => p)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>등록된 모든 태그와 각 파일 수를 반환합니다.</summary>
|
||||||
|
public IReadOnlyDictionary<string, int> GetAllTags()
|
||||||
|
{
|
||||||
|
EnsureLoaded();
|
||||||
|
return _data
|
||||||
|
.SelectMany(kv => kv.Value)
|
||||||
|
.GroupBy(t => t, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>경로에 태그가 하나 이상 있는지 확인합니다.</summary>
|
||||||
|
public bool HasTags(string path)
|
||||||
|
{
|
||||||
|
EnsureLoaded();
|
||||||
|
path = NormalizePath(path);
|
||||||
|
return _data.TryGetValue(path, out var tags) && tags.Count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 내부 헬퍼 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void EnsureLoaded()
|
||||||
|
{
|
||||||
|
if (_loaded) return;
|
||||||
|
_loaded = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(TagFile)) return;
|
||||||
|
var raw = JsonSerializer.Deserialize<Dictionary<string, List<string>>>(
|
||||||
|
File.ReadAllText(TagFile), JsonOpts);
|
||||||
|
if (raw != null)
|
||||||
|
{
|
||||||
|
_data = raw.ToDictionary(
|
||||||
|
kv => kv.Key,
|
||||||
|
kv => new HashSet<string>(kv.Value, StringComparer.OrdinalIgnoreCase),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) { LogService.Warn($"[FileTagService] 로드 실패: {ex.Message}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Save()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(TagFile)!);
|
||||||
|
var raw = _data.ToDictionary(
|
||||||
|
kv => kv.Key,
|
||||||
|
kv => kv.Value.OrderBy(t => t).ToList());
|
||||||
|
File.WriteAllText(TagFile, JsonSerializer.Serialize(raw, JsonOpts));
|
||||||
|
}
|
||||||
|
catch (Exception ex) { LogService.Warn($"[FileTagService] 저장 실패: {ex.Message}"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizePath(string path)
|
||||||
|
=> path.Trim().TrimEnd('\\', '/');
|
||||||
|
|
||||||
|
private static string NormalizeTag(string tag)
|
||||||
|
=> tag.Trim().ToLowerInvariant().Replace(" ", "-");
|
||||||
|
}
|
||||||
@@ -130,4 +130,7 @@ internal static class Symbols
|
|||||||
public const string RenameIcon = "\uE8AC"; // 이름 변경 (Rename와 동일)
|
public const string RenameIcon = "\uE8AC"; // 이름 변경 (Rename와 동일)
|
||||||
public const string MonitorIcon = "\uE7F4"; // 시스템 모니터 (Computer와 동일)
|
public const string MonitorIcon = "\uE7F4"; // 시스템 모니터 (Computer와 동일)
|
||||||
public const string ScaffoldIcon = "\uE8F1"; // 스캐폴딩 (프로젝트 구조)
|
public const string ScaffoldIcon = "\uE8F1"; // 스캐폴딩 (프로젝트 구조)
|
||||||
|
|
||||||
|
// ─── Phase L3-5 태그 시스템 ────────────────────────────────────────────────
|
||||||
|
public const string Tag = "\uEAB4"; // 파일 태그 (레이블)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,6 +147,8 @@ public partial class LauncherViewModel : INotifyPropertyChanged
|
|||||||
{ "snap", ("스냅", Symbols.SnapLayout, "#B45309") },
|
{ "snap", ("스냅", Symbols.SnapLayout, "#B45309") },
|
||||||
{ "cap", ("캡처", Symbols.CaptureIcon, "#BE185D") },
|
{ "cap", ("캡처", Symbols.CaptureIcon, "#BE185D") },
|
||||||
{ "help", ("도움말", Symbols.Info, "#6B7280") },
|
{ "help", ("도움말", Symbols.Info, "#6B7280") },
|
||||||
|
// ─── Phase L3-5 파일 태그 ──────────────────────────────────────────────
|
||||||
|
{ "tag", ("태그", Symbols.Tag, "#6366F1") },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── 설정 기능 토글 (런처 실동작 연결) ──────────────────────────────────
|
// ─── 설정 기능 토글 (런처 실동작 연결) ──────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user