[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:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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 병렬 처리로 검색 속도를 개선합니다.
|
||||
|
||||
285
src/AxCopilot/Core/SearchFilterParser.cs
Normal file
285
src/AxCopilot/Core/SearchFilterParser.cs
Normal 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:>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
|
||||
/// </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;
|
||||
}
|
||||
Reference in New Issue
Block a user