Files
fluid-explorer/Services/Search/SearchQuery.cs
T

201 lines
7.6 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Globalization;
using System.Text;
namespace FluidExplorer.Services.Search;
/// <summary>
/// Everything 风格的查询:空格 = AND,| = OR,"引号" = 精确短语,!term = 排除,
/// 支持 ext: size: dm: dc: folder: file: path: 以及 * ? 通配符。
/// </summary>
public sealed class SearchQuery
{
public string RawText { get; init; } = string.Empty;
public List<string> IncludeTerms { get; } = [];
public List<string> ExcludeTerms { get; } = [];
public List<string> IncludeRegexLike { get; } = []; // 含通配符的项
public List<string> Extensions { get; } = [];
public List<string> ExcludeExtensions { get; } = [];
public bool DirectoriesOnly { get; set; }
public bool FilesOnly { get; set; }
public long? MinSize { get; set; }
public long? MaxSize { get; set; }
public DateTime? ModifiedAfter { get; set; }
public DateTime? ModifiedBefore { get; set; }
public DateTime? CreatedAfter { get; set; }
public string? PathFilter { get; set; }
public bool MatchWholePath { get; set; }
public bool IsEmpty => IncludeTerms.Count == 0 && IncludeRegexLike.Count == 0 && Extensions.Count == 0
&& !DirectoriesOnly && !FilesOnly && MinSize is null && MaxSize is null
&& ModifiedAfter is null && ModifiedBefore is null && CreatedAfter is null;
}
public static class SearchQueryParser
{
public static SearchQuery Parse(string? text)
{
var q = new SearchQuery { RawText = text ?? string.Empty };
if (string.IsNullOrWhiteSpace(text)) return q;
if (text.StartsWith('*') && text.EndsWith('*') && text.Length > 2)
{
q.IncludeTerms.Add(text.Trim('*'));
return q;
}
foreach (var token in Tokenize(text))
{
if (token.Length == 0) continue;
var span = token.AsSpan();
if (span[0] == '!')
{
var t = token[1..].Trim();
if (t.Length == 0) continue;
if (TryExt(t, out var ex)) q.ExcludeExtensions.Add(ex);
else q.ExcludeTerms.Add(t.Trim('"'));
continue;
}
if (TryPrefix(span, "ext:", out var extValue))
{
foreach (var e in extValue.Split([';', ','], StringSplitOptions.RemoveEmptyEntries))
q.Extensions.Add(e.Trim().TrimStart('.').ToLowerInvariant());
continue;
}
if (TryPrefix(span, "size:", out var sizeValue))
{
ParseSize(sizeValue, q);
continue;
}
if (TryPrefix(span, "dm:", out var dm))
{
if (TryParseDate(dm, out var d)) q.ModifiedAfter = d;
continue;
}
if (TryPrefix(span, "dc:", out var dc))
{
if (TryParseDate(dc, out var d)) q.CreatedAfter = d;
continue;
}
if (TryPrefix(span, "folder:", out _) || token.Equals("folder:", StringComparison.OrdinalIgnoreCase))
{
q.DirectoriesOnly = true;
continue;
}
if (token.Equals("file:", StringComparison.OrdinalIgnoreCase))
{
q.FilesOnly = true;
continue;
}
if (TryPrefix(span, "path:", out var pathValue))
{
q.PathFilter = pathValue.Trim('"');
q.MatchWholePath = true;
continue;
}
if (token.Equals("dm:today", StringComparison.OrdinalIgnoreCase)) { q.ModifiedAfter = DateTime.Today; continue; }
var cleaned = token.Trim('"');
if (cleaned.Length == 0) continue;
if (cleaned.IndexOfAny(['*', '?']) >= 0) q.IncludeRegexLike.Add(cleaned);
else q.IncludeTerms.Add(cleaned);
}
return q;
}
private static bool TryExt(string token, out string ext)
{
ext = string.Empty;
if (!TryPrefix(token.AsSpan(), "ext:", out var v)) return false;
ext = v.Trim().TrimStart('.').ToLowerInvariant();
return ext.Length > 0;
}
private static bool TryPrefix(ReadOnlySpan<char> token, string prefix, out string value)
{
value = string.Empty;
if (token.Length <= prefix.Length) return false;
if (!token.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false;
value = token[prefix.Length..].ToString();
return true;
}
private static void ParseSize(string value, SearchQuery q)
{
value = value.Trim();
if (value.Length == 0) return;
var op = '=';
if (value[0] is '>' or '<' or '=') { op = value[0]; value = value[1..]; }
else if (value.StartsWith(">=", StringComparison.Ordinal)) { op = '>'; value = value[2..]; }
else if (value.StartsWith("<=", StringComparison.Ordinal)) { op = '<'; value = value[2..]; }
double mul = 1;
var lower = value.ToLowerInvariant();
foreach (var (suffix, factor) in SizeSuffixes)
{
if (lower.EndsWith(suffix, StringComparison.Ordinal))
{
mul = factor;
value = value[..^suffix.Length];
break;
}
}
if (!double.TryParse(value.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var num)) return;
var bytes = (long)(num * mul);
switch (op)
{
case '>': q.MinSize = bytes; break;
case '<': q.MaxSize = bytes; break;
default: q.MinSize = bytes; q.MaxSize = bytes; break;
}
}
private static readonly (string, double)[] SizeSuffixes =
[
("kb", 1024d), ("mb", 1024d * 1024), ("gb", 1024d * 1024 * 1024), ("tb", 1024d * 1024 * 1024 * 1024),
("k", 1024d), ("m", 1024d * 1024), ("g", 1024d * 1024 * 1024), ("b", 1d)
];
private static bool TryParseDate(string value, out DateTime date)
{
date = default;
var v = value.Trim().ToLowerInvariant();
var now = DateTime.Now;
switch (v)
{
case "today": date = now.Date; return true;
case "yesterday": date = now.Date.AddDays(-1); return true;
case "thisweek": date = now.Date.AddDays(-(int)now.DayOfWeek); return true;
case "thismonth": date = new DateTime(now.Year, now.Month, 1); return true;
case "thisyear": date = new DateTime(now.Year, 1, 1); return true;
}
if (v.EndsWith('d') && int.TryParse(v[..^1], out var days)) { date = now.AddDays(-days); return true; }
if (v.EndsWith('h') && int.TryParse(v[..^1], out var hours)) { date = now.AddHours(-hours); return true; }
if (v.EndsWith('w') && int.TryParse(v[..^1], out var weeks)) { date = now.AddDays(-7 * weeks); return true; }
return DateTime.TryParse(v, CultureInfo.CurrentCulture, DateTimeStyles.None, out date);
}
/// <summary>按空格切词,但保留引号内的空格。</summary>
private static IEnumerable<string> Tokenize(string text)
{
var sb = new StringBuilder();
bool inQuotes = false;
foreach (var ch in text)
{
if (ch == '"')
{
inQuotes = !inQuotes;
sb.Append(ch);
continue;
}
if (!inQuotes && char.IsWhiteSpace(ch))
{
if (sb.Length > 0) { yield return sb.ToString(); sb.Clear(); }
continue;
}
sb.Append(ch);
}
if (sb.Length > 0) yield return sb.ToString();
}
}