201 lines
7.6 KiB
C#
201 lines
7.6 KiB
C#
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();
|
||
}
|
||
}
|