using System.Globalization; using Nuuru.Server.Models.Booru; using Nuuru.Server.Services.Search.Nodes; using Nuuru.Server.Services.Search.Tokens; namespace Nuuru.Server.Services.Search; /// /// Parses a list of search tokens into an AST. /// public class SearchParser { private readonly List _tokens; private int _pos; private readonly List _errors = new(); private readonly List _warnings = new(); public SearchParser(List tokens) { _tokens = tokens; _pos = 0; } /// /// Parses the tokens into a search query AST. /// public SearchParseResult Parse() { var filterNodes = new List(); OrderByNode? orderBy = null; while (!AtEnd) { var token = Current; Advance(); switch (token) { case TagToken t: filterNodes.Add(new TagNode(t.Name)); break; case NegatedTagToken t: filterNodes.Add(new TagNode(t.Name, negated: true)); break; case WildcardTagToken t: filterNodes.Add(new WildcardTagNode(t.Prefix, t.Negated)); break; case OrGroupStartToken: var orNode = ParseOrGroup(); if (orNode != null) filterNodes.Add(orNode); break; case OrGroupEndToken: _warnings.Add("Unexpected '}' outside of OR group"); break; case OrSeparatorToken: _warnings.Add("Unexpected '~' outside of OR group"); break; case MetaTagToken m: var metaNode = ParseMetaTag(m); if (metaNode is OrderByNode ob) orderBy = ob; else if (metaNode != null) filterNodes.Add(metaNode); break; } } // Build root node SearchNode? rootNode = filterNodes.Count switch { 0 => null, 1 => filterNodes[0], _ => new AndNode(filterNodes) }; return new SearchParseResult(rootNode, orderBy, _errors, _warnings); } private OrNode? ParseOrGroup() { var alternatives = new List(); var currentTerms = new List(); while (!AtEnd) { var token = Current; if (token is OrGroupEndToken) { Advance(); break; } if (token is OrSeparatorToken) { Advance(); // Flush current terms as one alternative if (currentTerms.Count > 0) { alternatives.Add(currentTerms.Count == 1 ? currentTerms[0] : new AndNode(currentTerms)); currentTerms = new List(); } continue; } Advance(); switch (token) { case TagToken t: currentTerms.Add(new TagNode(t.Name)); break; case NegatedTagToken t: currentTerms.Add(new TagNode(t.Name, negated: true)); break; case WildcardTagToken t: currentTerms.Add(new WildcardTagNode(t.Prefix, t.Negated)); break; case MetaTagToken m: var node = ParseMetaTag(m); if (node != null && node is not OrderByNode) currentTerms.Add(node); break; } } // Flush remaining terms if (currentTerms.Count > 0) { alternatives.Add(currentTerms.Count == 1 ? currentTerms[0] : new AndNode(currentTerms)); } if (alternatives.Count == 0) { _warnings.Add("Empty OR group"); return null; } return new OrNode(alternatives); } private SearchNode? ParseMetaTag(MetaTagToken token) { return token.Key switch { "rating" or "r" => ParseRating(token), "category" or "cat" or "c" => ParseCategory(token), "uploader" or "user" => ParseUploader(token), "id" => ParseNumericFilter("id", token), "score" or "s" => ParseNumericFilter("score", token), "width" or "w" => ParseNumericFilter("width", token), "height" or "h" => ParseNumericFilter("height", token), "filesize" or "size" => ParseFileSizeFilter(token), "tagcount" or "tags" or "tag" => ParseNumericFilter("tagcount", token), "date" or "uploaded" => ParseDateFilter(token), "filetype" or "type" => ParseFileType(token), "status" or "is" => ParseStatus(token), "order" or "sort" => ParseOrderBy(token.Value), _ => HandleUnknownMetaTag(token) }; } private SearchNode? ParseStatus(MetaTagToken token) { return new StatusFilterNode(token.Value) { Negated = token.Negated }; } private SearchNode? ParseRating(MetaTagToken token) { var value = token.Value.ToLowerInvariant(); PostRating? rating = value switch { "s" or "safe" => PostRating.Safe, "q" or "questionable" => PostRating.Questionable, "e" or "explicit" => PostRating.Explicit, _ => null }; if (rating == null) { _warnings.Add($"Unknown rating '{token.Value}'. Valid values: safe, questionable, explicit"); return null; } return new RatingFilterNode(rating.Value) { Negated = token.Negated }; } private SearchNode? ParseCategory(MetaTagToken token) { var value = token.Value.ToLowerInvariant(); PostCategory? category = value switch { "g" or "gallery" => PostCategory.Gallery, "a" or "artwork" or "artworks" => PostCategory.Artworks, _ => null }; if (category == null) { _warnings.Add($"Unknown category '{token.Value}'. Valid values: gallery, artworks"); return null; } return new CategoryFilterNode(category.Value) { Negated = token.Negated }; } private SearchNode ParseUploader(MetaTagToken token) { return new UploaderFilterNode(token.Value) { Negated = token.Negated }; } private SearchNode? ParseNumericFilter(string field, MetaTagToken token) { long? min = null; long? max = null; switch (token.Operator) { case MetaOperator.Equals: if (long.TryParse(token.Value, out var exact)) { min = exact; max = exact; } else { _warnings.Add($"Invalid numeric value '{token.Value}' for {field}"); return null; } break; case MetaOperator.GreaterThan: if (long.TryParse(token.Value, out var gt)) min = gt + 1; else { _warnings.Add($"Invalid numeric value '{token.Value}' for {field}"); return null; } break; case MetaOperator.GreaterThanOrEqual: if (long.TryParse(token.Value, out var gte)) min = gte; else { _warnings.Add($"Invalid numeric value '{token.Value}' for {field}"); return null; } break; case MetaOperator.LessThan: if (long.TryParse(token.Value, out var lt)) max = lt - 1; else { _warnings.Add($"Invalid numeric value '{token.Value}' for {field}"); return null; } break; case MetaOperator.LessThanOrEqual: if (long.TryParse(token.Value, out var lte)) max = lte; else { _warnings.Add($"Invalid numeric value '{token.Value}' for {field}"); return null; } break; case MetaOperator.Range: var parts = token.Value.Split(".."); if (parts.Length == 2) { if (!string.IsNullOrEmpty(parts[0]) && long.TryParse(parts[0], out var rangeMin)) min = rangeMin; if (!string.IsNullOrEmpty(parts[1]) && long.TryParse(parts[1], out var rangeMax)) max = rangeMax; } if (min == null && max == null) { _warnings.Add($"Invalid range '{token.Value}' for {field}"); return null; } break; } return new NumericRangeFilterNode(field, min, max) { Negated = token.Negated }; } private SearchNode? ParseFileSizeFilter(MetaTagToken token) { // Parse file size with optional suffixes: kb, mb, gb long? bytes = ParseFileSize(token.Value, token.Operator); if (bytes == null) { _warnings.Add($"Invalid file size '{token.Value}'"); return null; } long? min = null; long? max = null; switch (token.Operator) { case MetaOperator.Equals: min = bytes; max = bytes; break; case MetaOperator.GreaterThan: min = bytes + 1; break; case MetaOperator.GreaterThanOrEqual: min = bytes; break; case MetaOperator.LessThan: max = bytes - 1; break; case MetaOperator.LessThanOrEqual: max = bytes; break; case MetaOperator.Range: var parts = token.Value.Split(".."); if (parts.Length == 2) { min = ParseFileSizeValue(parts[0]); max = ParseFileSizeValue(parts[1]); } break; } return new NumericRangeFilterNode("filesize", min, max) { Negated = token.Negated }; } private long? ParseFileSize(string value, MetaOperator op) { if (op == MetaOperator.Range) return 0; // Handle in the switch case above return ParseFileSizeValue(value); } private long? ParseFileSizeValue(string value) { if (string.IsNullOrWhiteSpace(value)) return null; value = value.ToLowerInvariant().Trim(); long multiplier = 1; string numPart = value; if (value.EndsWith("gb")) { multiplier = 1024L * 1024L * 1024L; numPart = value[..^2]; } else if (value.EndsWith("mb")) { multiplier = 1024L * 1024L; numPart = value[..^2]; } else if (value.EndsWith("kb")) { multiplier = 1024L; numPart = value[..^2]; } else if (value.EndsWith("b")) { numPart = value[..^1]; } if (double.TryParse(numPart, NumberStyles.Float, CultureInfo.InvariantCulture, out var num)) { return (long)(num * multiplier); } return null; } private SearchNode? ParseDateFilter(MetaTagToken token) { DateTime? min = null; DateTime? max = null; switch (token.Operator) { case MetaOperator.Equals: if (TryParseDate(token.Value, out var exact)) { min = exact.Date; max = exact.Date.AddDays(1).AddTicks(-1); } else { _warnings.Add($"Invalid date '{token.Value}'"); return null; } break; case MetaOperator.GreaterThan: case MetaOperator.GreaterThanOrEqual: if (TryParseDate(token.Value, out var gtDate)) min = token.Operator == MetaOperator.GreaterThan ? gtDate.Date.AddDays(1) : gtDate.Date; else { var relative = ParseRelativeDate(token.Value); if (relative.HasValue) min = relative.Value; else { _warnings.Add($"Invalid date '{token.Value}'"); return null; } } break; case MetaOperator.LessThan: case MetaOperator.LessThanOrEqual: if (TryParseDate(token.Value, out var ltDate)) max = token.Operator == MetaOperator.LessThan ? ltDate.Date.AddTicks(-1) : ltDate.Date.AddDays(1).AddTicks(-1); else { var relative = ParseRelativeDate(token.Value); if (relative.HasValue) max = relative.Value; else { _warnings.Add($"Invalid date '{token.Value}'"); return null; } } break; case MetaOperator.Range: var parts = token.Value.Split(".."); if (parts.Length == 2) { if (!string.IsNullOrEmpty(parts[0]) && TryParseDate(parts[0], out var rangeMin)) min = rangeMin.Date; if (!string.IsNullOrEmpty(parts[1]) && TryParseDate(parts[1], out var rangeMax)) max = rangeMax.Date.AddDays(1).AddTicks(-1); } if (min == null && max == null) { _warnings.Add($"Invalid date range '{token.Value}'"); return null; } break; } return new DateRangeFilterNode(min, max) { Negated = token.Negated }; } private bool TryParseDate(string value, out DateTime result) { // Support formats: yyyy-MM-dd, yyyy/MM/dd, yyyyMMdd string[] formats = { "yyyy-MM-dd", "yyyy/MM/dd", "yyyyMMdd" }; return DateTime.TryParseExact(value, formats, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out result); } private DateTime? ParseRelativeDate(string value) { var now = DateTime.UtcNow; value = value.ToLowerInvariant(); return value switch { "day" or "today" => now.AddDays(-1), "week" => now.AddDays(-7), "month" => now.AddMonths(-1), "year" => now.AddYears(-1), _ => ParseDuration(value) }; } private DateTime? ParseDuration(string value) { var match = System.Text.RegularExpressions.Regex.Match(value, @"^(\d+)([hdwmy]?)$"); if (!match.Success) return null; if (!int.TryParse(match.Groups[1].Value, out var amount)) return null; var unit = match.Groups[2].Value; var now = DateTime.UtcNow; return unit switch { "h" => now.AddHours(-amount), "d" => now.AddDays(-amount), "w" => now.AddDays(-amount * 7), "m" => now.AddMonths(-amount), "y" => now.AddYears(-amount), _ => now.AddDays(-amount) // Default to days if no unit }; } private SearchNode? ParseFileType(MetaTagToken token) { var value = token.Value.ToLowerInvariant(); // Map common extensions to MIME types var mimeType = value switch { "png" => "image/png", "jpg" or "jpeg" => "image/jpeg", "gif" => "image/gif", "webp" => "image/webp", "webm" => "video/webm", "mp4" => "video/mp4", "avif" => "image/avif", _ when value.Contains('/') => value, // Already a MIME type _ => $"image/{value}" // Assume image if no slash }; return new FileTypeFilterNode(mimeType) { Negated = token.Negated }; } private OrderByNode? ParseOrderBy(string value) { value = value.ToLowerInvariant(); bool descending = true; // Check for explicit direction suffix if (value.EndsWith("_asc")) { descending = false; value = value[..^4]; } else if (value.EndsWith("_desc")) { descending = true; value = value[..^5]; } // Normalize field names var field = value switch { "score" or "votes" => "score", "date" or "uploaded" or "newest" => "date", "id" => "id", "random" or "rand" => "random", "filesize" or "size" => "filesize", "tagcount" or "tags" => "tagcount", "controversial" or "drama" => "controversial", _ => null }; if (field == null) { _warnings.Add($"Unknown sort field '{value}'. Valid values: score, date, id, random, filesize, tagcount, controversial"); return null; } // Random sorting ignores direction if (field == "random") descending = true; return new OrderByNode(field, descending); } private SearchNode? HandleUnknownMetaTag(MetaTagToken token) { // Treat unknown meta-tags as category-prefixed tags (e.g., "character:naruto") // The query builder will search for tags with matching category slug and name return new CategoryTagNode(token.Key, token.Value, token.Negated); } #region Helper Properties private bool AtEnd => _pos >= _tokens.Count; private SearchToken Current => AtEnd ? null! : _tokens[_pos]; private void Advance() => _pos++; #endregion } /// /// Result of parsing a search query. /// public record SearchParseResult( SearchNode? RootNode, OrderByNode? OrderBy, List Errors, List Warnings );