using System; using System.Collections.Generic; using System.Linq; using System.Text; using AutopilotMonitor.Shared.Models; namespace AutopilotMonitor.Functions.Helpers; /// /// The dashboard search-box grammar, parsed once per request for the server-side sweep /// (q= on /api/search/sessions and the global twin). Mirrors the web's /// app/dashboard/lib/sessionSearchQuery.ts exactly — both run /// app/dashboard/lib/session-search-syntax.cases.json, because a term that matches on one side /// and not the other is a ghost result (found by the server, filtered out by the client, or /// never fetched at all). /// /// Grammar: whitespace-separated terms are AND-ed; a leading minus excludes; double quotes /// protect a phrase; key=value / key:value restricts a term to one field when /// key is in (case-insensitive), otherwise the token stays a /// literal term (14:30). A qualifier is recognised only when the separator precedes any /// quote in the token. Every term is a case-insensitive substring match; free terms may hit /// any field in . There is no escape character. /// /// public sealed class SessionSearchQuery { /// Qualifier → field reader, in the web's suggestion order. Exactly the set the client searches. public static readonly IReadOnlyList>> Fields = new List>> { new("device", s => s.DeviceName), new("serial", s => s.SerialNumber), new("model", s => s.Model), new("manufacturer", s => s.Manufacturer), new("status", s => s.Status.ToString()), new("session", s => s.SessionId), new("country", s => s.GeoCountry), new("region", s => s.GeoRegion), new("city", s => s.GeoCity), new("agent", s => s.AgentVersion), new("os", s => s.OsName), new("build", s => s.OsBuild), new("osversion", s => s.OsDisplayVersion), new("edition", s => s.OsEdition), new("language", s => s.OsLanguage), }; private static readonly Dictionary> ReaderByQualifier = Fields.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase); /// One search term; is the qualifier (lowercase) or null for a free term. public readonly record struct Term(string Text, string? Field); public IReadOnlyList Include { get; } public IReadOnlyList Exclude { get; } /// True when the query carries no filter at all (matches every session). public bool IsEmpty => Include.Count == 0 && Exclude.Count == 0; private SessionSearchQuery(List include, List exclude) { Include = include; Exclude = exclude; } public static SessionSearchQuery Parse(string? query) { var include = new List(); var exclude = new List(); foreach (var token in Tokenize(query ?? string.Empty)) { Term term; if (token.Qualifier != null && ReaderByQualifier.ContainsKey(token.Qualifier)) term = new Term(token.Text.ToString(), token.Qualifier.ToLowerInvariant()); else term = new Term(token.LiteralText, null); var target = token.Negated ? exclude : include; if (!target.Contains(term)) target.Add(term); } return new SessionSearchQuery(include, exclude); } public bool Matches(SessionSummary session) { if (IsEmpty) return true; foreach (var t in Include) if (!TermMatches(t, session)) return false; foreach (var t in Exclude) if (TermMatches(t, session)) return false; return true; } private static bool TermMatches(Term term, SessionSummary session) { if (term.Field != null) return ContainsIgnoreCase(ReaderByQualifier[term.Field](session), term.Text); foreach (var field in Fields) if (ContainsIgnoreCase(field.Value(session), term.Text)) return true; return false; } private static bool ContainsIgnoreCase(string? haystack, string needle) => !string.IsNullOrEmpty(haystack) && haystack!.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0; private sealed class Token { public StringBuilder Text { get; } = new(); public bool Negated { get; init; } public string? Qualifier { get; set; } public char Separator { get; set; } public bool SawQuote { get; set; } public string LiteralText => Qualifier == null ? Text.ToString() : $"{Qualifier}{Separator}{Text}"; } // Character-for-character the web tokenizer (lib/searchQueryTokens.ts). private static List Tokenize(string query) { var tokens = new List(); Token? current = null; var inQuotes = false; void Flush() { if (current != null && current.Text.Length > 0) tokens.Add(current); current = null; } foreach (var ch in query) { if (!inQuotes && char.IsWhiteSpace(ch)) { Flush(); continue; } if (ch == '"') { current ??= new Token(); current.SawQuote = true; inQuotes = !inQuotes; continue; } if (current == null) { if (ch == '-') { current = new Token { Negated = true }; continue; } current = new Token(); } if ((ch == '=' || ch == ':') && !inQuotes && !current.SawQuote && current.Qualifier == null && current.Text.Length > 0) { current.Qualifier = current.Text.ToString(); current.Separator = ch; current.Text.Clear(); continue; } current.Text.Append(ch); } Flush(); return tokens; } }