#nullable enable
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace BTCPayServer
{
public enum SearchStringFormat
{
///
/// Include all filters.
///
All,
///
/// Exclude filters that are configured via other UI elements (those defined in UIFilters). This is meant to be a
/// string that we show in an input textbox.
///
ExceptUIFilters,
///
/// Only include filters that are configured via other UI elements (those defined in UIFilters).
///
OnlyUIFilters
}
public class SearchString
{
private const char FilterSeparator = ',';
private const char ValueSeparator = ':';
///
/// The list of filters that shouldn't appear in the search input text
///
public HashSet UIFilterTypes = new HashSet(["status", "timezone", "exceptionstatus", "unusual", "includearchived", "appid", "startdate", "enddate", "daterange"], StringComparer.OrdinalIgnoreCase);
public static SearchString Combine(string?[] str)
=> new SearchString(string.Join(",", str.Where(s => !string.IsNullOrWhiteSpace(s))));
public SearchString(string? str)
{
str ??= string.Empty;
str = str.Trim();
var splitted = str.Split(new [] { FilterSeparator }, StringSplitOptions.RemoveEmptyEntries);
Filters
= splitted
.Select(t => t.Split(new [] { ValueSeparator }, 2, StringSplitOptions.RemoveEmptyEntries))
.Where(kv => kv.Length == 2)
.Select(kv => new KeyValuePair(NormalizeKey(kv[0]), kv[1]))
.ToMultiValueDictionary(o => o.Key, o => o.Value);
TextSearch = splitted.FirstOrDefault(a => a.IndexOf(ValueSeparator, StringComparison.OrdinalIgnoreCase) == -1)?.Trim() ?? "";
}
///
/// The part of the search string that is free form text (not a filter)
///
public string TextSearch { get; set; }
///
/// The search string we should show in an input textbox.
/// Some filters are excluded from the string as they are configured via other UI elements
///
[Obsolete("Use ToString(SearchStringFormat.OnlyUIFilters) instead")]
public string TextCombined => ToString(SearchStringFormat.OnlyUIFilters);
public MultiValueDictionary Filters { get; }
public override string ToString() => ToString(SearchStringFormat.All);
public string ToString(SearchStringFormat format)
{
var filters = Filters
.Where(kv => format switch
{
SearchStringFormat.All => true,
SearchStringFormat.ExceptUIFilters => !UIFilterTypes.Contains(kv.Key),
SearchStringFormat.OnlyUIFilters => UIFilterTypes.Contains(kv.Key),
_ => throw new ArgumentOutOfRangeException(nameof(format), format, null)
})
.Select(f => string.Join(FilterSeparator, f.Value.Select(v => $"{f.Key}{ValueSeparator}{v}"))).ToList();
if (format != SearchStringFormat.OnlyUIFilters)
filters.Add(TextSearch);
return string.Join(FilterSeparator, filters.Where(x => !string.IsNullOrEmpty(x)));
}
[Obsolete("Use ToString(SearchStringFormat.OnlyUIFilters) instead")]
public string WithoutSearchText() => ToString(SearchStringFormat.OnlyUIFilters);
[Obsolete("Use SetFilter(key, value, true) instead")]
public string Toggle(string key, string value)
{
var s = Clone();
s.SetFilter(key, value, true);
return s.ToString();
}
public SearchString Clone() => new(ToString());
public string[]? GetFilterArray(string key)
{
key = NormalizeKey(key);
return Filters.TryGetValue(key, out var filter) ? filter.ToArray() : null;
}
public bool? GetFilterBool(string key)
{
key = NormalizeKey(key);
if (!Filters.TryGetValue(key, out var filter))
return null;
return bool.TryParse(filter.First(), out var r) ? r : null;
}
public string? GetFilterString(string key)
{
key = NormalizeKey(key);
if (!Filters.TryGetValue(key, out var filter))
return null;
return filter.First();
}
public (DateTimeOffset? StartDate, DateTimeOffset? EndDate) GetDateRange()
=> GetDateRange(null);
public (DateTimeOffset? StartDate, DateTimeOffset? EndDate) GetDateRange(TimeZoneInfo? defaultTimeZoneInfo)
{
DateTimeOffset? start;
DateTimeOffset? end;
if (Filters.TryGetValue("daterange", out var dateRange) && IsValidDateRange(dateRange.FirstOrDefault()))
{
start = GetDateRangeDate("startdate", dateRange.First(), defaultTimeZoneInfo);
end = GetDateRangeDate("enddate", dateRange.First(), defaultTimeZoneInfo);
return (start, end);
}
else
{
start = GetFilterDate("startdate", defaultTimeZoneInfo);
end = GetFilterDate("enddate", defaultTimeZoneInfo);
return (start, end);
}
}
TimeZoneInfo? GetTimeZoneInfo(TimeZoneInfo? defaultTimeZoneInfo)
{
var tz = this.GetExplicitTimeZone();
if (tz is null || !TimeZones.TryGet(tz, out var tzInfo))
return defaultTimeZoneInfo;
return tzInfo;
}
public DateTimeOffset? GetFilterDate(string key)
=> GetFilterDate(key, null);
public DateTimeOffset? GetFilterDate(string key, TimeZoneInfo? defaultTimeZoneInfo)
{
key = NormalizeKey(key);
var tz = GetTimeZoneInfo(defaultTimeZoneInfo);
if (!Filters.TryGetValue(key, out var filter))
return null;
var val = filter.First();
var dateRangeDate = GetDateRangeDate(key, val, defaultTimeZoneInfo);
if (dateRangeDate is not null)
return dateRangeDate;
// Parsing the date
if (DateTime.TryParse(val, CultureInfo.InvariantCulture, DateTimeStyles.None, out var localDateTime))
{
return localDateTime.Kind switch
{
DateTimeKind.Local => new DateTimeOffset(localDateTime, TimeZoneInfo.Local.GetUtcOffset(localDateTime)).ToUniversalTime(),
DateTimeKind.Utc => new DateTimeOffset(localDateTime, TimeSpan.Zero),
DateTimeKind.Unspecified when tz is not null => new DateTimeOffset(localDateTime, tz.GetUtcOffset(localDateTime)).ToUniversalTime(),
DateTimeKind.Unspecified when tz is null => null,
_ => throw new ArgumentOutOfRangeException()
};
}
return null;
}
private DateTimeOffset? GetDateRangeDate(string key, string val, TimeZoneInfo? defaultTimeZoneInfo)
{
var utcNow = DateTimeOffset.UtcNow;
var rollingStart = val switch
{
"-24h" or "-1d" => utcNow.AddDays(-1),
"-3d" => utcNow.AddDays(-3),
"-7d" => utcNow.AddDays(-7),
_ => (DateTimeOffset?)null
};
if (rollingStart is not null)
return rollingStart;
var tz = GetTimeZoneInfo(defaultTimeZoneInfo);
if (tz is null)
return null;
var now = TimeZoneInfo.ConvertTime(utcNow, tz);
var today = now.Date;
var startOfThisWeek = today.AddDays(-(int)today.DayOfWeek);
var startOfThisMonth = new DateTime(today.Year, today.Month, 1);
var startOfThisQuarter = new DateTime(today.Year, ((today.Month - 1) / 3) * 3 + 1, 1);
var startOfThisYear = new DateTime(today.Year, 1, 1);
var localDate = (key, val) switch
{
("startdate", "today") => today,
("startdate", "yesterday") => today.AddDays(-1),
("enddate", "yesterday") => today.AddTicks(-1),
("startdate", "thisweek") => startOfThisWeek,
("startdate", "lastweek") => startOfThisWeek.AddDays(-7),
("enddate", "lastweek") => startOfThisWeek.AddTicks(-1),
("startdate", "thismonth") => startOfThisMonth,
("startdate", "lastmonth") => startOfThisMonth.AddMonths(-1),
("enddate", "lastmonth") => startOfThisMonth.AddTicks(-1),
("startdate", "last30d") => today.AddDays(-29),
("startdate", "thisquarter") => startOfThisQuarter,
("startdate", "lastquarter") => startOfThisQuarter.AddMonths(-3),
("enddate", "lastquarter") => startOfThisQuarter.AddTicks(-1),
("startdate", "thisyear") => startOfThisYear,
("startdate", "lastyear") => startOfThisYear.AddYears(-1),
("enddate", "lastyear") => startOfThisYear.AddTicks(-1),
("startdate", "yeartodate") => startOfThisYear,
_ => (DateTime?)null
};
if (localDate is null)
return null;
var local = DateTime.SpecifyKind(localDate.Value, DateTimeKind.Unspecified);
return new DateTimeOffset(TimeZoneInfo.ConvertTimeToUtc(local, tz), TimeSpan.Zero);
}
public bool ContainsFilter(string key) => Filters.ContainsKey(NormalizeKey(key));
public int CountArrayFilter(string type) =>
ContainsFilter(type) ? GetFilterArray(type)!.Length : 0;
public bool HasArrayFilter(string type, string? key = null) =>
ContainsFilter(type) && (key is null || GetFilterArray(type).Contains(key));
public bool HasBooleanFilter(string key) =>
ContainsFilter(key) && GetFilterBool(key) is true;
private string NormalizeKey(string key) => key.ToLowerInvariant().Trim();
public static bool IsValidDateRange(string? dateRange) =>
dateRange is "alltime" or "today" or "yesterday" or "thisweek" or "lastweek"
or "thismonth" or "lastmonth" or "last30d" or "thisquarter" or "lastquarter" or "thisyear" or "lastyear" or "yeartodate"
or "-1d" or "-24h" or "-3d" or "-7d";
public void SetFilter(string filter, string? value = null, bool toggle = false, bool multi = false)
{
filter = NormalizeKey(filter);
if (!toggle)
{
if (!multi)
Filters.Remove(filter);
if (value is not null)
{
Filters.Remove(filter, value);
Filters.Add(filter, value);
}
}
else
{
if (!Filters.ContainsKey(filter) || value is null)
SetFilter(filter, value);
else if (Filters[filter].Contains(value))
{
Filters.Remove(filter, value);
}
else
{
if (!multi)
Filters.Remove(filter);
Filters.Add(filter, value);
}
}
}
public void SetDateRange(string? dateRange = null, bool toggle = false)
{
Filters.Remove("startdate");
Filters.Remove("enddate");
SetFilter("daterange", dateRange, toggle);
}
public string? GetExplicitTimeZone() => GetFilterString("timezone");
public void RemoveDups(string filter)
{
filter = NormalizeKey(filter);
if (Filters.TryGetValue(filter, out var c) && c.Count > 1)
{
Filters.Remove(filter);
Filters.Add(filter, c.First());
}
}
}
}