From d70e6357d0659a2a8001a3fa9c9a1b2b2ae2809a Mon Sep 17 00:00:00 2001 From: lacvet Date: Sat, 4 Apr 2026 11:34:37 +0900 Subject: [PATCH] =?UTF-8?q?[Phase=20L4-5]=20=EA=B3=A0=EA=B8=89=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=ED=95=84=ED=84=B0=20=EB=AC=B8=EB=B2=95=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(ext/type/in/size/modified)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core/SearchFilterParser.cs (285줄) — 신규: - Parse(): 쿼리에서 필터 토큰 추출 후 순수 텍스트 쿼리 반환 - 지원 필터: ext:.pdf,.docx / type:file|folder|app / in:경로조각 size:>1mb|<500kb / modified:today|week|month|year|>날짜 - Matches(): 8가지 조건(ext,type,in,size,modified) 순차 검사 size/modified는 파일시스템 접근이 필요해 마지막에 체크 - Describe(): UI 힌트용 필터 요약 텍스트 생성 - ParsedFilters 클래스: 파싱된 필터 상태 컨테이너 Core/FuzzyEngine.cs (+23줄): - SearchWithFilter(query, predicate, max): 텍스트 없으면 전체, 있으면 ×15 후보→필터 Core/CommandResolver.cs (+91줄): - ResolveAsync(): 경로 쿼리 감지 다음에 필터 감지 단계 추가 - BuildFilteredResults(): 필터 힌트 항목(상단) + 필터 적용 결과 목록 파일 항목에 size/수정일 부가 정보 subtitle 표시 - FormatBytes(): 파일 크기 포맷 (B/KB/MB/GB) - 빌드: 경고 0, 오류 0 --- src/AxCopilot/Core/CommandResolver.cs | 91 +++++++- src/AxCopilot/Core/FuzzyEngine.cs | 23 ++ src/AxCopilot/Core/SearchFilterParser.cs | 285 +++++++++++++++++++++++ 3 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 src/AxCopilot/Core/SearchFilterParser.cs diff --git a/src/AxCopilot/Core/CommandResolver.cs b/src/AxCopilot/Core/CommandResolver.cs index 5b7f267..e118ad0 100644 --- a/src/AxCopilot/Core/CommandResolver.cs +++ b/src/AxCopilot/Core/CommandResolver.cs @@ -82,9 +82,16 @@ public class CommandResolver return await fb.GetItemsAsync(input, ct); } - // 3. Fuzzy 검색 폴백 + null-prefix 핸들러 병렬 실행 + // 3. 고급 필터 문법 감지 (ext:, size:, modified:, type:, in:) var maxResults = _settings.Settings.Launcher.MaxResults; + var (cleanQuery, filters) = SearchFilterParser.Parse(input); + if (filters.HasFilters) + { + return BuildFilteredResults(cleanQuery, filters, maxResults); + } + + // 4. Fuzzy 검색 폴백 + null-prefix 핸들러 병렬 실행 // Path 기반 중복 제거: 같은 경로의 항목이 여러 키워드로 매칭될 때 첫 번째만 표시 var seenPaths = new HashSet(StringComparer.OrdinalIgnoreCase); // SortByUsage에 lazy 시퀀스를 직접 전달 → 중간 ToList 1회 제거 @@ -204,6 +211,88 @@ public class CommandResolver public IReadOnlyDictionary RegisteredHandlers => _handlers; + // ─── 고급 필터 검색 결과 생성 ───────────────────────────────────────── + private IEnumerable BuildFilteredResults( + string textQuery, ParsedFilters filters, int maxResults) + { + var results = new List(); + + // 필터 힌트 항목 (상단 표시 — 어떤 필터가 적용 중인지 안내) + var hint = SearchFilterParser.Describe(filters); + results.Add(new SDK.LauncherItem( + $"필터 적용 중: {hint}", + string.IsNullOrEmpty(textQuery) + ? "전체 항목에서 검색 중" + : $"'{textQuery}' + 필터", + null, null, + Symbol: "\uE16E", // Filter 아이콘 (MDL2) + Group: "필터")); + + // 퍼지 + 필터 적용 (size/modified는 파일시스템 접근이 있으므로 Task.Run에서 실행) + var filtered = _fuzzy.SearchWithFilter( + textQuery, + e => SearchFilterParser.Matches(e, filters), + maxResults); + + var seenPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var r in filtered) + { + if (!seenPaths.Add(r.Entry.Path)) continue; + + // 크기/수정일 부가 정보 — 파일 항목에만 추가 + string sub = r.Entry.Path + " ⇧ Shift+Enter: 폴더 열기"; + if (r.Entry.Type == IndexEntryType.File && + (filters.SizeBytes != null || filters.ModifiedFrom != null)) + { + try + { + var fi = new System.IO.FileInfo( + Environment.ExpandEnvironmentVariables(r.Entry.Path)); + if (fi.Exists) + { + var sizeTxt = FormatBytes(fi.Length); + var dateTxt = fi.LastWriteTime.ToString("yyyy-MM-dd"); + sub = $"{sizeTxt} · {dateTxt} · {r.Entry.Path}"; + } + } + catch { /* 접근 실패 시 기본 subtitle */ } + } + + results.Add(new SDK.LauncherItem( + r.Entry.DisplayName, + sub, + null, + r.Entry, + Symbol: r.Entry.Type switch + { + IndexEntryType.App => Symbols.App, + IndexEntryType.Folder => Symbols.Folder, + _ => Symbols.File + }, + Group: "검색 결과")); + } + + if (results.Count == 1) // 힌트 항목만 있음 → 결과 없음 메시지 + { + results.Add(new SDK.LauncherItem( + "검색 결과 없음", + $"필터 조건을 확인하세요: {hint}", + null, null, + Symbol: Symbols.Error, + Group: "검색 결과")); + } + + return results; + } + + private static string FormatBytes(long b) => b switch + { + >= 1024L * 1024 * 1024 => $"{b / (1024.0 * 1024 * 1024):F1}GB", + >= 1024L * 1024 => $"{b / (1024.0 * 1024):F1}MB", + >= 1024L => $"{b / 1024.0:F1}KB", + _ => $"{b}B" + }; + // null-prefix 핸들러 실행 (ExecuteAsync 라우팅) public async Task ExecuteNullPrefixAsync(SDK.LauncherItem item, CancellationToken ct) { diff --git a/src/AxCopilot/Core/FuzzyEngine.cs b/src/AxCopilot/Core/FuzzyEngine.cs index 9a7c10b..f396497 100644 --- a/src/AxCopilot/Core/FuzzyEngine.cs +++ b/src/AxCopilot/Core/FuzzyEngine.cs @@ -15,6 +15,29 @@ public class FuzzyEngine _index = index; } + /// + /// 필터 조건을 포함한 검색. 텍스트 쿼리가 없으면 모든 항목에서 필터 적용. + /// + public IEnumerable SearchWithFilter( + string query, Func filter, int maxResults = 7) + { + if (string.IsNullOrWhiteSpace(query)) + { + // 텍스트 없이 필터만 있는 경우: 전체 항목에서 조건 검색 + return _index.Entries + .AsParallel() + .Where(filter) + .OrderByDescending(e => e.Score) + .Take(maxResults) + .Select(e => new FuzzyResult(e, e.Score + 100)); + } + + // 텍스트 + 필터: 퍼지 후보 확장(×15) → 필터 적용 + return Search(query, maxResults * 15) + .Where(r => filter(r.Entry)) + .Take(maxResults); + } + /// /// 쿼리에 대해 유사도 순으로 정렬된 결과를 반환합니다. /// 300개 이상 항목은 PLINQ 병렬 처리로 검색 속도를 개선합니다. diff --git a/src/AxCopilot/Core/SearchFilterParser.cs b/src/AxCopilot/Core/SearchFilterParser.cs new file mode 100644 index 0000000..b107202 --- /dev/null +++ b/src/AxCopilot/Core/SearchFilterParser.cs @@ -0,0 +1,285 @@ +using System.IO; +using System.Text.RegularExpressions; +using AxCopilot.Services; + +namespace AxCopilot.Core; + +/// +/// L4-5: 고급 검색 필터 파서. +/// "report ext:.pdf size:>1mb modified:week in:Documents" 형식의 쿼리에서 +/// 필터 토큰을 분리하고 IndexEntry 매칭 여부를 판별합니다. +/// +/// 지원 필터: +/// ext:.pdf — 확장자 (쉼표 구분 다중 가능: ext:.docx,.xlsx) +/// type:file|folder|app|alias +/// in:경로조각 — 경로에 문자열 포함 +/// size:>1mb / size:<500kb / size:=10mb +/// modified:today / week / month / year / >2024-01-01 +/// +public static class SearchFilterParser +{ + // 필터 토큰 감지 정규식: key:value (value는 공백 전까지) + private static readonly Regex _tokenRx = new( + @"\b(ext|type|in|size|modified):([\S]+)", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + // size 파싱: >, <, = + 숫자 + 단위(b|kb|mb|gb) + private static readonly Regex _sizeRx = new( + @"^([>YYYY-MM-DD 또는 <]?)(\d{4}-\d{2}-\d{2})$", + RegexOptions.Compiled); + + /// + /// 입력 쿼리에서 필터 토큰을 파싱합니다. + /// 반환: (필터가 제거된 순수 텍스트 쿼리, 파싱된 필터) + /// + public static (string Query, ParsedFilters Filters) Parse(string input) + { + var filters = new ParsedFilters(); + var cleaned = _tokenRx.Replace(input.Trim(), m => + { + ParseToken(m.Groups[1].Value.ToLowerInvariant(), + m.Groups[2].Value, filters); + return " "; // 필터 토큰을 공백으로 교체 + }); + + // 연속 공백 제거 + cleaned = Regex.Replace(cleaned, @"\s+", " ").Trim(); + return (cleaned, filters); + } + + // ─── 토큰별 파싱 ────────────────────────────────────────────────────── + + private static void ParseToken(string key, string value, ParsedFilters f) + { + switch (key) + { + case "ext": + f.Extensions = value.Split(',') + .Select(e => e.Trim()) + .Where(e => !string.IsNullOrEmpty(e)) + .Select(e => e.StartsWith('.') ? e.ToLowerInvariant() : "." + e.ToLowerInvariant()) + .ToArray(); + break; + + case "type": + f.Types = value.ToLowerInvariant() switch + { + "file" or "f" => [IndexEntryType.File], + "folder" or "dir" or "d" => [IndexEntryType.Folder], + "app" or "a" => [IndexEntryType.App], + "alias" => [IndexEntryType.Alias], + _ => null + }; + break; + + case "in": + f.PathFragment = value; + break; + + case "size": + ParseSize(value, f); + break; + + case "modified": + ParseModified(value, f); + break; + } + } + + private static void ParseSize(string value, ParsedFilters f) + { + var m = _sizeRx.Match(value.Trim()); + if (!m.Success) return; + + var op = m.Groups[1].Value is "" ? ">" : m.Groups[1].Value; // 기본: > + var num = double.Parse(m.Groups[2].Value, System.Globalization.CultureInfo.InvariantCulture); + var unit = m.Groups[3].Value.ToLowerInvariant(); + var bytes = (long)(num * unit switch + { + "kb" => 1024.0, + "mb" => 1024.0 * 1024, + "gb" => 1024.0 * 1024 * 1024, + _ => 1.0 // b + }); + + f.SizeOp = op[0]; + f.SizeBytes = bytes; + } + + private static void ParseModified(string value, ParsedFilters f) + { + var now = DateTime.Now; + switch (value.ToLowerInvariant()) + { + case "today": + f.ModifiedFrom = now.Date; + f.ModifiedTo = now.Date.AddDays(1).AddTicks(-1); + break; + case "yesterday": + f.ModifiedFrom = now.Date.AddDays(-1); + f.ModifiedTo = now.Date.AddTicks(-1); + break; + case "week": + f.ModifiedFrom = now.Date.AddDays(-7); + f.ModifiedTo = now; + break; + case "month": + f.ModifiedFrom = now.Date.AddDays(-30); + f.ModifiedTo = now; + break; + case "year": + f.ModifiedFrom = now.Date.AddDays(-365); + f.ModifiedTo = now; + break; + default: + // modified:>2024-01-15 또는 modified:<2024-06-01 + var dm = _dateRx.Match(value.Trim()); + if (dm.Success && DateTime.TryParse(dm.Groups[2].Value, out var dt)) + { + if (dm.Groups[1].Value == "<") + { + f.ModifiedFrom = DateTime.MinValue; + f.ModifiedTo = dt; + } + else // > 또는 없음 + { + f.ModifiedFrom = dt; + f.ModifiedTo = DateTime.MaxValue; + } + } + break; + } + } + + // ─── 필터 매칭 ──────────────────────────────────────────────────────── + + /// + /// IndexEntry가 ParsedFilters 조건을 모두 만족하는지 확인합니다. + /// size/modified 조건은 파일시스템 접근이 필요하므로 마지막에 검사합니다. + /// + public static bool Matches(IndexEntry entry, ParsedFilters filters) + { + // 확장자 필터 (빠른 문자열 비교) + if (filters.Extensions is { Length: > 0 }) + { + var ext = Path.GetExtension(entry.Path).ToLowerInvariant(); + if (!filters.Extensions.Contains(ext)) return false; + } + + // 타입 필터 + if (filters.Types != null) + { + if (!filters.Types.Contains(entry.Type)) return false; + } + + // 경로 포함 필터 + if (filters.PathFragment != null) + { + if (!entry.Path.Contains(filters.PathFragment, StringComparison.OrdinalIgnoreCase)) + return false; + } + + // 크기/수정일 필터 — 파일 항목에만 적용, 파일시스템 접근 + if (filters.SizeBytes.HasValue || filters.ModifiedFrom.HasValue) + { + // 앱/폴더/Alias는 size 필터 대상 아님 + if (entry.Type != IndexEntryType.File) + return !filters.SizeBytes.HasValue; // 파일이 아니면 size 필터 통과 불가 + + try + { + var fi = new FileInfo(Environment.ExpandEnvironmentVariables(entry.Path)); + if (!fi.Exists) return false; + + if (filters.SizeBytes.HasValue) + { + bool ok = filters.SizeOp switch + { + '>' => fi.Length > filters.SizeBytes.Value, + '<' => fi.Length < filters.SizeBytes.Value, + '=' => fi.Length == filters.SizeBytes.Value, + '!' => fi.Length != filters.SizeBytes.Value, + _ => true + }; + if (!ok) return false; + } + + if (filters.ModifiedFrom.HasValue) + { + var mtime = fi.LastWriteTime; + if (mtime < filters.ModifiedFrom.Value || mtime > filters.ModifiedTo!.Value) + return false; + } + } + catch + { + return false; // 접근 불가 파일 제외 + } + } + + return true; + } + + /// + /// 활성 필터 목록을 사람이 읽기 쉬운 텍스트로 반환합니다. (UI 힌트용) + /// + public static string Describe(ParsedFilters f) + { + var parts = new List(); + if (f.Extensions is { Length: > 0 }) + parts.Add($"ext:{string.Join(",", f.Extensions)}"); + if (f.Types != null) + parts.Add($"type:{string.Join(",", f.Types.Select(t => t.ToString().ToLower()))}"); + if (f.PathFragment != null) + parts.Add($"in:{f.PathFragment}"); + if (f.SizeBytes.HasValue) + parts.Add($"size:{f.SizeOp}{FormatBytes(f.SizeBytes.Value)}"); + if (f.ModifiedFrom.HasValue && f.ModifiedTo.HasValue) + { + var span = (DateTime.Now - f.ModifiedFrom.Value).TotalDays; + parts.Add(span switch + { + <= 1 => "modified:today", + <= 2 => "modified:yesterday", + <= 8 => "modified:week", + <= 31 => "modified:month", + <= 366 => "modified:year", + _ => $"modified:>{f.ModifiedFrom:yyyy-MM-dd}" + }); + } + return string.Join(" ", parts); + } + + private static string FormatBytes(long b) => b switch + { + >= 1024L * 1024 * 1024 => $"{b / (1024.0 * 1024 * 1024):F1}GB", + >= 1024L * 1024 => $"{b / (1024.0 * 1024):F1}MB", + >= 1024L => $"{b / 1024.0:F1}KB", + _ => $"{b}B" + }; +} + +/// ParsedFilters: SearchFilterParser.Parse()가 반환하는 파싱된 필터 집합. +public class ParsedFilters +{ + public string[]? Extensions { get; set; } + public IndexEntryType[]? Types { get; set; } + public string? PathFragment { get; set; } + public char SizeOp { get; set; } = '>'; + public long? SizeBytes { get; set; } + public DateTime? ModifiedFrom { get; set; } + public DateTime? ModifiedTo { get; set; } + + /// 하나라도 필터가 설정되어 있으면 true. + public bool HasFilters => + (Extensions is { Length: > 0 }) || + Types != null || + PathFragment != null || + SizeBytes != null || + ModifiedFrom != null; +}