using System.Globalization; using System.Text; namespace FluidExplorer.Services.Search; /// /// Everything 风格的查询:空格 = AND,| = OR,"引号" = 精确短语,!term = 排除, /// 支持 ext: size: dm: dc: folder: file: path: 以及 * ? 通配符。 /// public sealed class SearchQuery { public string RawText { get; init; } = string.Empty; public List IncludeTerms { get; } = []; public List ExcludeTerms { get; } = []; public List IncludeRegexLike { get; } = []; // 含通配符的项 public List Extensions { get; } = []; public List 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 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); } /// 按空格切词,但保留引号内的空格。 private static IEnumerable 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(); } }