[Phase L4-5] 고급 검색 필터 문법 구현 (ext/type/in/size/modified)

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
This commit is contained in:
2026-04-04 11:34:37 +09:00
parent d4a1532d81
commit d70e6357d0
3 changed files with 398 additions and 1 deletions

View File

@@ -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<string>(StringComparer.OrdinalIgnoreCase);
// SortByUsage에 lazy 시퀀스를 직접 전달 → 중간 ToList 1회 제거
@@ -204,6 +211,88 @@ public class CommandResolver
public IReadOnlyDictionary<string, IActionHandler> RegisteredHandlers => _handlers;
// ─── 고급 필터 검색 결과 생성 ─────────────────────────────────────────
private IEnumerable<SDK.LauncherItem> BuildFilteredResults(
string textQuery, ParsedFilters filters, int maxResults)
{
var results = new List<SDK.LauncherItem>();
// 필터 힌트 항목 (상단 표시 — 어떤 필터가 적용 중인지 안내)
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<string>(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)
{

View File

@@ -15,6 +15,29 @@ public class FuzzyEngine
_index = index;
}
/// <summary>
/// 필터 조건을 포함한 검색. 텍스트 쿼리가 없으면 모든 항목에서 필터 적용.
/// </summary>
public IEnumerable<FuzzyResult> SearchWithFilter(
string query, Func<IndexEntry, bool> 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);
}
/// <summary>
/// 쿼리에 대해 유사도 순으로 정렬된 결과를 반환합니다.
/// 300개 이상 항목은 PLINQ 병렬 처리로 검색 속도를 개선합니다.

View File

@@ -0,0 +1,285 @@
using System.IO;
using System.Text.RegularExpressions;
using AxCopilot.Services;
namespace AxCopilot.Core;
/// <summary>
/// L4-5: 고급 검색 필터 파서.
/// "report ext:.pdf size:&gt;1mb modified:week in:Documents" 형식의 쿼리에서
/// 필터 토큰을 분리하고 IndexEntry 매칭 여부를 판별합니다.
///
/// 지원 필터:
/// ext:.pdf — 확장자 (쉼표 구분 다중 가능: ext:.docx,.xlsx)
/// type:file|folder|app|alias
/// in:경로조각 — 경로에 문자열 포함
/// size:&gt;1mb / size:&lt;500kb / size:=10mb
/// modified:today / week / month / year / &gt;2024-01-01
/// </summary>
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(
@"^([><!=]?)(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)$",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
// modified 날짜 파싱: >YYYY-MM-DD 또는 <YYYY-MM-DD
private static readonly Regex _dateRx = new(
@"^([><]?)(\d{4}-\d{2}-\d{2})$",
RegexOptions.Compiled);
/// <summary>
/// 입력 쿼리에서 필터 토큰을 파싱합니다.
/// 반환: (필터가 제거된 순수 텍스트 쿼리, 파싱된 필터)
/// </summary>
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;
}
}
// ─── 필터 매칭 ────────────────────────────────────────────────────────
/// <summary>
/// IndexEntry가 ParsedFilters 조건을 모두 만족하는지 확인합니다.
/// size/modified 조건은 파일시스템 접근이 필요하므로 마지막에 검사합니다.
/// </summary>
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;
}
/// <summary>
/// 활성 필터 목록을 사람이 읽기 쉬운 텍스트로 반환합니다. (UI 힌트용)
/// </summary>
public static string Describe(ParsedFilters f)
{
var parts = new List<string>();
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"
};
}
/// <summary>ParsedFilters: SearchFilterParser.Parse()가 반환하는 파싱된 필터 집합.</summary>
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; }
/// <summary>하나라도 필터가 설정되어 있으면 true.</summary>
public bool HasFilters =>
(Extensions is { Length: > 0 }) ||
Types != null ||
PathFragment != null ||
SizeBytes != null ||
ModifiedFrom != null;
}