using System; using System.Collections.Generic; using System.ComponentModel; using System.Globalization; using System.Linq; using System.Reflection; namespace OpenNest.PostSettings { public enum PostSettingKind { Text, Integer, Decimal, Boolean, Choice, StringMap, Table, } /// A column of a field: one property of its row type. public sealed class PostSettingsColumn { internal PostSettingsColumn(PropertyInfo property, PostSettingKind kind) { Property = property; Kind = kind; Label = property.GetCustomAttribute()?.DisplayName ?? property.Name; } public PropertyInfo Property { get; } public string Name => Property.Name; /// Text, Integer or Decimal. public PostSettingKind Kind { get; } public string Label { get; } } public sealed class PostSettingsField { internal PostSettingsField( PropertyInfo property, PostSettingKind kind, bool isOptional, IReadOnlyList columns, PostSettingAttribute setting ) { Property = property; Kind = kind; IsOptional = isOptional; Columns = columns ?? Array.Empty(); SuggestionsTable = setting?.SuggestionsTable; SuggestionsColumn = setting?.SuggestionsColumn; Label = property.GetCustomAttribute()?.DisplayName ?? property.Name; Description = property.GetCustomAttribute()?.Description ?? ""; var (min, max) = DefaultRange(kind); Minimum = setting != null && !double.IsNaN(setting.Minimum) ? setting.Minimum : min; Maximum = setting != null && !double.IsNaN(setting.Maximum) ? setting.Maximum : max; DecimalPlaces = kind == PostSettingKind.Integer ? 0 : setting != null && setting.DecimalPlaces >= 0 ? setting.DecimalPlaces : 3; KeyHeader = setting?.KeyHeader ?? "Name"; ValueHeader = setting?.ValueHeader ?? "Value"; } public PropertyInfo Property { get; } public string Name => Property.Name; public PostSettingKind Kind { get; } /// A nullable number: the editor can leave it unset (null). public bool IsOptional { get; } /// The row type's columns for a field. public IReadOnlyList Columns { get; } /// Table field whose a text field offers; null for none. public string SuggestionsTable { get; } public string SuggestionsColumn { get; } public string Label { get; } public string Description { get; } public double Minimum { get; } public double Maximum { get; } public int DecimalPlaces { get; } public string KeyHeader { get; } public string ValueHeader { get; } public string[] ChoiceNames => Kind == PostSettingKind.Choice ? Enum.GetNames(Property.PropertyType) : Array.Empty(); public object GetValue(object config) => Property.GetValue(config); public void SetValue(object config, object value) => Property.SetValue(config, value); private static (double, double) DefaultRange(PostSettingKind kind) => kind == PostSettingKind.Integer ? (int.MinValue, int.MaxValue) : (-1e9, 1e9); } public sealed class PostSettingsSection { internal PostSettingsSection( string name, string description, IReadOnlyList fields ) { Name = name; Description = description ?? ""; Fields = fields; } public string Name { get; } public string Description { get; } public IReadOnlyList Fields { get; } } /// /// Builds the sectioned settings layout for a post-processor config from its /// metadata, without any UI dependency. /// public static class PostSettingsLayout { public const string OtherSection = "Other"; /// /// Returns the ordered sections, or null when the config should use the /// generic PropertyGrid: it has no , /// or one of its editable properties has a type the editor cannot show. /// Editable properties without the attribute go to the /// so none become uneditable. /// public static IReadOnlyList TryBuild(Type configType) { if (configType == null) return null; var properties = configType .GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(p => p.CanRead && p.GetSetMethod() != null && p.GetIndexParameters().Length == 0 && p.GetCustomAttribute()?.Browsable != false ) .OrderBy(p => p.MetadataToken) .ToList(); if (!properties.Any(p => p.GetCustomAttribute() != null)) return null; var entries = new List<(string Section, int Order, int Index, PostSettingsField Field)>(); for (var i = 0; i < properties.Count; i++) { var property = properties[i]; var kind = KindOf(property.PropertyType, out var isOptional, out var columns); if (kind == null) return null; var setting = property.GetCustomAttribute(); var section = string.IsNullOrWhiteSpace(setting?.Section) ? OtherSection : setting.Section; entries.Add( ( section, setting?.Order ?? int.MaxValue, i, new PostSettingsField(property, kind.Value, isOptional, columns, setting) ) ); } var declared = configType .GetCustomAttributes() .GroupBy(a => a.Name, StringComparer.Ordinal) .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); return entries .GroupBy(e => e.Section, StringComparer.Ordinal) .Select(g => new { Name = g.Key, Declared = declared.TryGetValue(g.Key, out var d) ? d : null, FirstIndex = g.Min(e => e.Index), Fields = g.OrderBy(e => e.Order).ThenBy(e => e.Index).Select(e => e.Field).ToList(), }) .OrderBy(s => s.Name == OtherSection && s.Declared == null ? 2 : s.Declared != null ? 0 : 1) .ThenBy(s => s.Declared?.Order ?? 0) .ThenBy(s => s.FirstIndex) .Select(s => new PostSettingsSection(s.Name, s.Declared?.Description, s.Fields)) .ToList(); } /// /// Builds a name/value map from edited table rows using the supplied key /// comparer. Keys and values are trimmed; fully blank rows are skipped. /// Throws naming the 1-based row for a /// missing or duplicate name. /// public static Dictionary BuildMap( IEnumerable> rows, IEqualityComparer comparer ) { var map = new Dictionary(comparer ?? StringComparer.Ordinal); var row = 0; foreach (var entry in rows ?? Enumerable.Empty>()) { row++; var key = entry.Key?.Trim() ?? ""; var value = entry.Value?.Trim() ?? ""; if (key.Length == 0 && value.Length == 0) continue; if (key.Length == 0) throw new FormatException($"Row {row}: a name is required."); if (map.ContainsKey(key)) throw new FormatException($"Row {row}: \"{key}\" is listed more than once."); map.Add(key, value); } return map; } /// /// Builds a field's list from edited /// rows of cell text, one entry per column, in row order. Cells are /// trimmed and numbers parsed with ; fully blank /// rows are skipped. Throws naming the /// 1-based row and column for a missing or malformed number. /// public static object BuildList( PostSettingsField field, IEnumerable> rows, IFormatProvider culture ) { if (field == null || field.Kind != PostSettingKind.Table) throw new ArgumentException("A table field is required.", nameof(field)); var list = (System.Collections.IList)Activator.CreateInstance(field.Property.PropertyType); var rowType = ElementType(field.Property.PropertyType); var row = 0; foreach (var cells in rows ?? Enumerable.Empty>()) { row++; var text = field .Columns.Select((_, c) => (cells != null && c < cells.Count ? cells[c] : null)?.Trim() ?? "") .ToList(); if (text.All(t => t.Length == 0)) continue; var item = Activator.CreateInstance(rowType); for (var c = 0; c < field.Columns.Count; c++) { var column = field.Columns[c]; column.Property.SetValue(item, ParseCell(column, text[c], row, culture)); } list.Add(item); } return list; } private static object ParseCell(PostSettingsColumn column, string text, int row, IFormatProvider culture) { if (column.Kind == PostSettingKind.Text) return text; if (text.Length == 0) throw new FormatException($"Row {row}: {column.Label} is required."); if (column.Kind == PostSettingKind.Integer) { if (int.TryParse(text, NumberStyles.Integer, culture, out var whole)) return whole; } else if (double.TryParse(text, NumberStyles.Float, culture, out var number) && double.IsFinite(number)) { return number; } throw new FormatException($"Row {row}: {column.Label} \"{text}\" is not a valid number."); } private static PostSettingKind? KindOf( Type type, out bool isOptional, out IReadOnlyList columns ) { isOptional = false; columns = null; var underlying = Nullable.GetUnderlyingType(type); if (underlying == typeof(int) || underlying == typeof(double)) { isOptional = true; return KindOf(underlying); } var rowType = ElementType(type); if (rowType != null) { columns = ColumnsOf(rowType); return columns == null ? null : PostSettingKind.Table; } return KindOf(type); } private static Type ElementType(Type type) => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>) ? type.GetGenericArguments()[0] : null; /// /// A row type's editable columns, or null when it is not a class with a /// public parameterless constructor whose every public settable property /// is text or a number. /// private static IReadOnlyList ColumnsOf(Type rowType) { if (!rowType.IsClass || rowType == typeof(string) || rowType.GetConstructor(Type.EmptyTypes) == null) return null; var columns = new List(); var properties = rowType .GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(p => p.CanRead && p.GetSetMethod() != null && p.GetIndexParameters().Length == 0) .OrderBy(p => p.MetadataToken); foreach (var property in properties) { var kind = KindOf(property.PropertyType); if (kind is not (PostSettingKind.Text or PostSettingKind.Integer or PostSettingKind.Decimal)) return null; columns.Add(new PostSettingsColumn(property, kind.Value)); } return columns.Count == 0 ? null : columns; } private static PostSettingKind? KindOf(Type type) { if (type == typeof(string)) return PostSettingKind.Text; if (type == typeof(int)) return PostSettingKind.Integer; if (type == typeof(double)) return PostSettingKind.Decimal; if (type == typeof(bool)) return PostSettingKind.Boolean; if (type.IsEnum) return PostSettingKind.Choice; if ( type == typeof(Dictionary) || type == typeof(IDictionary) ) return PostSettingKind.StringMap; return null; } } }