From 712f11dc4bbd56d41c77a95b7dda53800f6bd416 Mon Sep 17 00:00:00 2001 From: whimsical-c4lic0 Date: Fri, 13 Feb 2026 13:38:04 -0600 Subject: [PATCH] UI improvements to the configuration page --- .../Services/DownloadOrchestrationService.cs | 7 + .../Services/IDownloadOrchestrationService.cs | 5 + .../ViewModels/ConfigCategoryViewModel.cs | 55 +++- OF DL.Gui/ViewModels/ConfigFieldViewModel.cs | 213 ++++++++++++- .../ViewModels/ConfigSelectOptionViewModel.cs | 14 + .../FileNameFormatSegmentViewModel.cs | 14 + OF DL.Gui/ViewModels/MainWindowViewModel.cs | 195 +++++++++++- .../ViewModels/MultiSelectOptionViewModel.cs | 7 +- OF DL.Gui/Views/MainWindow.axaml | 287 ++++++++++++++++-- 9 files changed, 756 insertions(+), 41 deletions(-) create mode 100644 OF DL.Gui/ViewModels/ConfigSelectOptionViewModel.cs create mode 100644 OF DL.Gui/ViewModels/FileNameFormatSegmentViewModel.cs diff --git a/OF DL.Core/Services/DownloadOrchestrationService.cs b/OF DL.Core/Services/DownloadOrchestrationService.cs index 2a27c79..b708f2e 100644 --- a/OF DL.Core/Services/DownloadOrchestrationService.cs +++ b/OF DL.Core/Services/DownloadOrchestrationService.cs @@ -96,6 +96,13 @@ public class DownloadOrchestrationService( return result; } + /// + /// Retrieves the user's lists only. + /// + /// A dictionary of list names to list IDs. + public async Task> GetUserListsAsync() => + await apiService.GetLists("/lists") ?? new Dictionary(); + /// /// Resolves the users that belong to a specific list. /// diff --git a/OF DL.Core/Services/IDownloadOrchestrationService.cs b/OF DL.Core/Services/IDownloadOrchestrationService.cs index f471bfd..8c4dc7c 100644 --- a/OF DL.Core/Services/IDownloadOrchestrationService.cs +++ b/OF DL.Core/Services/IDownloadOrchestrationService.cs @@ -9,6 +9,11 @@ public interface IDownloadOrchestrationService /// Task GetAvailableUsersAsync(); + /// + /// Fetch only user lists. + /// + Task> GetUserListsAsync(); + /// /// Get users for a specific list by name. /// diff --git a/OF DL.Gui/ViewModels/ConfigCategoryViewModel.cs b/OF DL.Gui/ViewModels/ConfigCategoryViewModel.cs index 896ae3e..15f1db0 100644 --- a/OF DL.Gui/ViewModels/ConfigCategoryViewModel.cs +++ b/OF DL.Gui/ViewModels/ConfigCategoryViewModel.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using OF_DL.Models.Config; namespace OF_DL.Gui.ViewModels; @@ -7,7 +8,22 @@ public sealed class ConfigCategoryViewModel : ViewModelBase public ConfigCategoryViewModel(string categoryName, IEnumerable fields) { CategoryName = categoryName; - foreach (ConfigFieldViewModel field in fields) + List fieldList = fields.ToList(); + + DownloadOnlySpecificDatesField = fieldList.FirstOrDefault(field => + string.Equals(field.PropertyName, nameof(Config.DownloadOnlySpecificDates), StringComparison.Ordinal)); + DownloadDateSelectionField = fieldList.FirstOrDefault(field => + string.Equals(field.PropertyName, nameof(Config.DownloadDateSelection), StringComparison.Ordinal)); + CustomDateField = fieldList.FirstOrDefault(field => + string.Equals(field.PropertyName, nameof(Config.CustomDate), StringComparison.Ordinal)); + + IEnumerable visibleFields = IsDownloadBehavior + ? fieldList.Where(field => field.PropertyName is not nameof(Config.DownloadOnlySpecificDates) + and not nameof(Config.DownloadDateSelection) + and not nameof(Config.CustomDate)) + : fieldList; + + foreach (ConfigFieldViewModel field in visibleFields) { Fields.Add(field); } @@ -18,5 +34,42 @@ public sealed class ConfigCategoryViewModel : ViewModelBase public bool IsDownloadBehavior => string.Equals(CategoryName, "Download Behavior", StringComparison.Ordinal); + public ConfigFieldViewModel? DownloadOnlySpecificDatesField { get; } + + public ConfigFieldViewModel? DownloadDateSelectionField { get; } + + public ConfigFieldViewModel? CustomDateField { get; } + + public bool HasSpecificDateFilterFields => + DownloadOnlySpecificDatesField != null && + DownloadDateSelectionField != null && + CustomDateField != null; + + public string SpecificDateFilterHelpText + { + get + { + List parts = []; + if (!string.IsNullOrWhiteSpace(DownloadOnlySpecificDatesField?.HelpText)) + { + parts.Add(DownloadOnlySpecificDatesField.HelpText); + } + + if (!string.IsNullOrWhiteSpace(DownloadDateSelectionField?.HelpText)) + { + parts.Add(DownloadDateSelectionField.HelpText); + } + + if (!string.IsNullOrWhiteSpace(CustomDateField?.HelpText)) + { + parts.Add(CustomDateField.HelpText); + } + + return string.Join(" ", parts.Distinct(StringComparer.Ordinal)); + } + } + + public bool HasSpecificDateFilterHelpText => !string.IsNullOrWhiteSpace(SpecificDateFilterHelpText); + public ObservableCollection Fields { get; } = []; } diff --git a/OF DL.Gui/ViewModels/ConfigFieldViewModel.cs b/OF DL.Gui/ViewModels/ConfigFieldViewModel.cs index 33d2895..2014edb 100644 --- a/OF DL.Gui/ViewModels/ConfigFieldViewModel.cs +++ b/OF DL.Gui/ViewModels/ConfigFieldViewModel.cs @@ -1,6 +1,8 @@ using System.Collections.ObjectModel; using System.Reflection; +using System.Text.RegularExpressions; using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; using Newtonsoft.Json; using OF_DL.Models.Config; @@ -8,12 +10,43 @@ namespace OF_DL.Gui.ViewModels; public partial class ConfigFieldViewModel : ViewModelBase { - public ConfigFieldViewModel(PropertyInfo propertyInfo, object? initialValue) + private const string NoListSelectedValue = ""; + private const string NoListSelectedDisplayName = "(No list selected)"; + + private static readonly Regex s_fileNameVariableRegex = new(@"\{([^{}]+)\}", RegexOptions.Compiled); + + private static readonly Dictionary s_fileNameVariablesByConfigOption = + new(StringComparer.Ordinal) + { + [nameof(Config.PaidPostFileNameFormat)] = + [ + "id", "postedAt", "mediaId", "mediaCreatedAt", "filename", "username", "text" + ], + [nameof(Config.PostFileNameFormat)] = + [ + "id", "postedAt", "mediaId", "mediaCreatedAt", "filename", "username", "text", "rawText" + ], + [nameof(Config.PaidMessageFileNameFormat)] = + [ + "id", "createdAt", "mediaId", "mediaCreatedAt", "filename", "username", "text" + ], + [nameof(Config.MessageFileNameFormat)] = + [ + "id", "createdAt", "mediaId", "mediaCreatedAt", "filename", "username", "text" + ] + }; + + public ConfigFieldViewModel( + PropertyInfo propertyInfo, + object? initialValue, + IEnumerable? ignoredUsersListNames = null, + string? helpText = null) { PropertyInfo = propertyInfo; PropertyName = propertyInfo.Name; DisplayName = ToDisplayName(propertyInfo.Name); PropertyType = propertyInfo.PropertyType; + HelpText = helpText?.Trim() ?? string.Empty; IsBoolean = PropertyType == typeof(bool); IsEnum = PropertyType.IsEnum; @@ -30,7 +63,33 @@ public partial class ConfigFieldViewModel : ViewModelBase } } + if (IsFileNameFormatField) + { + foreach (string variableName in GetAllowedFileNameVariables()) + { + AvailableFileNameVariables.Add(variableName); + } + + SelectedFileNameVariable = AvailableFileNameVariables.FirstOrDefault(); + string availableVariables = string.Join(", ", AvailableFileNameVariables.Select(variable => $"{{{variable}}}")); + string fileNameHelpText = + $"Available variables: {availableVariables}. Include {{mediaId}} or {{filename}} to avoid collisions."; + HelpText = string.IsNullOrWhiteSpace(HelpText) + ? fileNameHelpText + : $"{HelpText} {fileNameHelpText}"; + } + LoadInitialValue(initialValue); + + if (IsIgnoredUsersListField) + { + SetIgnoredUsersListOptions(ignoredUsersListNames ?? []); + } + + if (IsFileNameFormatField) + { + UpdateFileNameFormatPreview(); + } } public PropertyInfo PropertyInfo { get; } @@ -53,10 +112,25 @@ public partial class ConfigFieldViewModel : ViewModelBase public bool IsMultiline { get; } + public bool IsFileNameFormatField => s_fileNameVariablesByConfigOption.ContainsKey(PropertyName); + + public bool IsIgnoredUsersListField => + string.Equals(PropertyName, nameof(Config.IgnoredUsersListName), StringComparison.Ordinal); + + public bool IsRegularTextInput => IsTextInput && !IsIgnoredUsersListField; + + public bool HasHelpText => !string.IsNullOrWhiteSpace(HelpText); + public double TextBoxMinHeight => IsMultiline ? 150 : 36; public ObservableCollection EnumOptions { get; } = []; + public ObservableCollection AvailableFileNameVariables { get; } = []; + + public ObservableCollection IgnoredUsersListOptions { get; } = []; + + public ObservableCollection FileNameFormatSegments { get; } = []; + [ObservableProperty] private bool _boolValue; [ObservableProperty] private string? _enumValue; @@ -67,12 +141,28 @@ public partial class ConfigFieldViewModel : ViewModelBase [ObservableProperty] private string _textValue = string.Empty; + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(InsertSelectedFileNameVariableCommand))] + private string? _selectedFileNameVariable; + + [ObservableProperty] private ConfigSelectOptionViewModel? _selectedIgnoredUsersListOption; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasHelpText))] + private string _helpText = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasUnknownFileNameVariables))] + private string _unknownFileNameVariablesMessage = string.Empty; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasError))] private string _errorMessage = string.Empty; public bool HasError => !string.IsNullOrWhiteSpace(ErrorMessage); + public bool HasUnknownFileNameVariables => !string.IsNullOrWhiteSpace(UnknownFileNameVariablesMessage); + public bool TryGetTypedValue(out object? value, out string? error) { value = null; @@ -192,6 +282,73 @@ public partial class ConfigFieldViewModel : ViewModelBase ErrorMessage = message; } + public void SetIgnoredUsersListOptions(IEnumerable listNames) + { + if (!IsIgnoredUsersListField) + { + return; + } + + string selectedValue = SelectedIgnoredUsersListOption?.Value ?? TextValue.Trim(); + ConfigSelectOptionViewModel noSelectionOption = + new(NoListSelectedValue, NoListSelectedDisplayName); + + IgnoredUsersListOptions.Clear(); + IgnoredUsersListOptions.Add(noSelectionOption); + + IEnumerable distinctNames = listNames + .Where(listName => !string.IsNullOrWhiteSpace(listName)) + .Distinct(StringComparer.Ordinal) + .OrderBy(listName => listName, StringComparer.OrdinalIgnoreCase); + + foreach (string listName in distinctNames) + { + IgnoredUsersListOptions.Add(new ConfigSelectOptionViewModel(listName, listName)); + } + + ConfigSelectOptionViewModel selectedOption = + IgnoredUsersListOptions.FirstOrDefault(option => + string.Equals(option.Value, selectedValue, StringComparison.Ordinal)) ?? noSelectionOption; + + SelectedIgnoredUsersListOption = selectedOption; + TextValue = selectedOption.Value; + } + + [RelayCommand(CanExecute = nameof(CanInsertSelectedFileNameVariable))] + private void InsertSelectedFileNameVariable() + { + if (string.IsNullOrWhiteSpace(SelectedFileNameVariable)) + { + return; + } + + string placeholder = $"{{{SelectedFileNameVariable}}}"; + TextValue += placeholder; + } + + partial void OnTextValueChanged(string value) + { + if (!IsFileNameFormatField) + { + return; + } + + UpdateFileNameFormatPreview(); + } + + private bool CanInsertSelectedFileNameVariable() => + IsFileNameFormatField && !string.IsNullOrWhiteSpace(SelectedFileNameVariable); + + partial void OnSelectedIgnoredUsersListOptionChanged(ConfigSelectOptionViewModel? value) + { + if (!IsIgnoredUsersListField) + { + return; + } + + TextValue = value?.Value ?? NoListSelectedValue; + } + private void LoadInitialValue(object? initialValue) { if (IsBoolean) @@ -236,6 +393,60 @@ public partial class ConfigFieldViewModel : ViewModelBase TextValue = initialValue?.ToString() ?? string.Empty; } + private IEnumerable GetAllowedFileNameVariables() => + s_fileNameVariablesByConfigOption.TryGetValue(PropertyName, out string[]? variables) + ? variables + : []; + + private void UpdateFileNameFormatPreview() + { + FileNameFormatSegments.Clear(); + UnknownFileNameVariablesMessage = string.Empty; + + if (string.IsNullOrEmpty(TextValue)) + { + return; + } + + HashSet allowedVariables = new(GetAllowedFileNameVariables(), StringComparer.OrdinalIgnoreCase); + HashSet unknownVariables = new(StringComparer.OrdinalIgnoreCase); + + MatchCollection matches = s_fileNameVariableRegex.Matches(TextValue); + int currentIndex = 0; + foreach (Match match in matches) + { + if (match.Index > currentIndex) + { + string plainText = TextValue[currentIndex..match.Index]; + FileNameFormatSegments.Add(new FileNameFormatSegmentViewModel(plainText, "#1F2A44")); + } + + string variableName = match.Groups[1].Value; + bool isAllowedVariable = allowedVariables.Contains(variableName); + FileNameFormatSegments.Add(new FileNameFormatSegmentViewModel(match.Value, + isAllowedVariable ? "#2E6EEA" : "#D84E4E")); + + if (!isAllowedVariable) + { + unknownVariables.Add(variableName); + } + + currentIndex = match.Index + match.Length; + } + + if (currentIndex < TextValue.Length) + { + string trailingText = TextValue[currentIndex..]; + FileNameFormatSegments.Add(new FileNameFormatSegmentViewModel(trailingText, "#1F2A44")); + } + + if (unknownVariables.Count > 0) + { + string tokens = string.Join(", ", unknownVariables.Select(variable => $"{{{variable}}}")); + UnknownFileNameVariablesMessage = $"Unknown variable(s): {tokens}"; + } + } + private static string ToDisplayName(string propertyName) { if (string.IsNullOrWhiteSpace(propertyName)) diff --git a/OF DL.Gui/ViewModels/ConfigSelectOptionViewModel.cs b/OF DL.Gui/ViewModels/ConfigSelectOptionViewModel.cs new file mode 100644 index 0000000..f54bed8 --- /dev/null +++ b/OF DL.Gui/ViewModels/ConfigSelectOptionViewModel.cs @@ -0,0 +1,14 @@ +namespace OF_DL.Gui.ViewModels; + +public sealed class ConfigSelectOptionViewModel +{ + public ConfigSelectOptionViewModel(string value, string displayName) + { + Value = value; + DisplayName = displayName; + } + + public string Value { get; } + + public string DisplayName { get; } +} diff --git a/OF DL.Gui/ViewModels/FileNameFormatSegmentViewModel.cs b/OF DL.Gui/ViewModels/FileNameFormatSegmentViewModel.cs new file mode 100644 index 0000000..cdb4a2a --- /dev/null +++ b/OF DL.Gui/ViewModels/FileNameFormatSegmentViewModel.cs @@ -0,0 +1,14 @@ +namespace OF_DL.Gui.ViewModels; + +public sealed class FileNameFormatSegmentViewModel +{ + public FileNameFormatSegmentViewModel(string text, string foreground) + { + Text = text; + Foreground = foreground; + } + + public string Text { get; } + + public string Foreground { get; } +} diff --git a/OF DL.Gui/ViewModels/MainWindowViewModel.cs b/OF DL.Gui/ViewModels/MainWindowViewModel.cs index 2efff39..b3b0bbc 100644 --- a/OF DL.Gui/ViewModels/MainWindowViewModel.cs +++ b/OF DL.Gui/ViewModels/MainWindowViewModel.cs @@ -44,6 +44,85 @@ public partial class MainWindowViewModel( ("Paid Messages", nameof(Config.DownloadPaidMessages)) ]; + private static readonly Dictionary s_configHelpTextByProperty = new(StringComparer.Ordinal) + { + [nameof(Config.FFmpegPath)] = + "Path to the FFmpeg executable. If blank, OF-DL will try the app directory and PATH.", + [nameof(Config.DownloadPath)] = + "Base download folder. If blank, OF-DL uses __user_data__/sites/OnlyFans/{username}.", + [nameof(Config.DownloadVideos)] = "Download video media when enabled.", + [nameof(Config.DownloadImages)] = "Download image media when enabled.", + [nameof(Config.DownloadAudios)] = "Download audio media when enabled.", + [nameof(Config.DownloadAvatarHeaderPhoto)] = + "Download creator avatar and header images when enabled.", + [nameof(Config.DownloadPosts)] = "Download free posts when enabled.", + [nameof(Config.DownloadPaidPosts)] = "Download paid posts when enabled.", + [nameof(Config.DownloadArchived)] = "Download archived posts when enabled.", + [nameof(Config.DownloadStreams)] = "Download posts from the Streams tab when enabled.", + [nameof(Config.DownloadStories)] = "Download stories when enabled.", + [nameof(Config.DownloadHighlights)] = "Download highlights when enabled.", + [nameof(Config.DownloadMessages)] = + "Download free media from messages (including paid-message previews) when enabled.", + [nameof(Config.DownloadPaidMessages)] = + "Download paid media from messages (excluding preview media) when enabled.", + [nameof(Config.IgnoreOwnMessages)] = + "Ignore your own sent messages and do not download media sent by your account.", + [nameof(Config.DownloadPostsIncrementally)] = + "Only download new posts after the latest downloaded post in metadata DB.", + [nameof(Config.BypassContentForCreatorsWhoNoLongerExist)] = + "Allow downloading accessible purchased content for deleted creators.", + [nameof(Config.DownloadDuplicatedMedia)] = + "When enabled, duplicate media can be downloaded instead of being skipped.", + [nameof(Config.SkipAds)] = + "Skip posts/messages containing #ad or free-trial links when enabled.", + [nameof(Config.DownloadOnlySpecificDates)] = + "Limit downloads by date using DownloadDateSelection and CustomDate.", + [nameof(Config.DownloadDateSelection)] = + "Choose whether date filtering uses content before or after CustomDate.", + [nameof(Config.CustomDate)] = + "Date used for date-filtered downloads (yyyy-MM-dd).", + [nameof(Config.ShowScrapeSize)] = + "Show total byte size instead of item counts during download progress.", + [nameof(Config.DisableTextSanitization)] = + "Store post/message text as-is without XML stripping.", + [nameof(Config.DownloadVideoResolution)] = + "Choose preferred video resolution (source, 240, or 720 when available).", + [nameof(Config.PaidPostFileNameFormat)] = + "Custom filename format for paid posts. See custom filename formats docs.", + [nameof(Config.PostFileNameFormat)] = + "Custom filename format for free posts/archived/streams. See custom filename formats docs.", + [nameof(Config.PaidMessageFileNameFormat)] = + "Custom filename format for paid messages. See custom filename formats docs.", + [nameof(Config.MessageFileNameFormat)] = + "Custom filename format for free messages. See custom filename formats docs.", + [nameof(Config.RenameExistingFilesWhenCustomFormatIsSelected)] = + "Rename previously downloaded files when custom filename format is enabled.", + [nameof(Config.CreatorConfigs)] = + "Per-creator filename format overrides. Values here override global filename formats.", + [nameof(Config.FolderPerPaidPost)] = + "Create a separate folder per paid post when enabled.", + [nameof(Config.FolderPerPost)] = + "Create a separate folder per free post when enabled.", + [nameof(Config.FolderPerPaidMessage)] = + "Create a separate folder per paid message when enabled.", + [nameof(Config.FolderPerMessage)] = + "Create a separate folder per free message when enabled.", + [nameof(Config.IncludeExpiredSubscriptions)] = + "Include expired subscriptions in user selection.", + [nameof(Config.IncludeRestrictedSubscriptions)] = + "Include restricted creators in scraping and download flow.", + [nameof(Config.IgnoredUsersListName)] = + "Users in this list are ignored during scraping. Empty means no users are ignored.", + [nameof(Config.Timeout)] = + "HTTP timeout override in seconds (-1 uses default behavior).", + [nameof(Config.LimitDownloadRate)] = + "Enable download speed limiting.", + [nameof(Config.DownloadLimitInMbPerSec)] = + "Download rate limit in MB/s when rate limiting is enabled.", + [nameof(Config.LoggingLevel)] = + "Log verbosity written to logs/OFDL.txt." + }; + private Dictionary _allUsers = []; private Dictionary _allLists = []; private StartupResult _startupResult = new(); @@ -55,6 +134,10 @@ public partial class MainWindowViewModel( public ObservableCollection ConfigCategories { get; } = []; + public ObservableCollection ConfigCategoriesLeft { get; } = []; + + public ObservableCollection ConfigCategoriesRight { get; } = []; + public ObservableCollection MediaTypeOptions { get; } = []; public ObservableCollection MediaSourceOptions { get; } = []; @@ -135,6 +218,10 @@ public partial class MainWindowViewModel( public bool HasMediaSourcesError => !string.IsNullOrWhiteSpace(MediaSourcesError); + public string FfmpegPathHelpText => GetConfigHelpText(nameof(Config.FFmpegPath)); + + public string DownloadPathHelpText => GetConfigHelpText(nameof(Config.DownloadPath)); + public string SelectedUsersSummary => $"{AvailableUsers.Count(user => user.IsSelected)} / {AvailableUsers.Count} selected"; @@ -206,6 +293,7 @@ public partial class MainWindowViewModel( DownloadPurchasedTabCommand.NotifyCanExecuteChanged(); SelectUsersFromListCommand.NotifyCanExecuteChanged(); RefreshUsersCommand.NotifyCanExecuteChanged(); + RefreshIgnoredUsersListsCommand.NotifyCanExecuteChanged(); } [RelayCommand] @@ -248,6 +336,39 @@ public partial class MainWindowViewModel( await LoadUsersAndListsAsync(); } + [RelayCommand(CanExecute = nameof(CanRefreshIgnoredUsersLists))] + private async Task RefreshIgnoredUsersListsAsync(ConfigFieldViewModel? field) + { + if (field == null || !field.IsIgnoredUsersListField) + { + return; + } + + ConfigScreenMessage = "Refreshing user lists..."; + StatusMessage = ConfigScreenMessage; + + try + { + Dictionary latestLists = await downloadOrchestrationService.GetUserListsAsync(); + _allLists = latestLists + .OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase) + .ToDictionary(pair => pair.Key, pair => pair.Value); + + UpdateUserListsCollection(); + UpdateIgnoredUsersListFieldOptions(); + + ConfigScreenMessage = $"User lists refreshed ({_allLists.Count})."; + StatusMessage = ConfigScreenMessage; + AppendLog(ConfigScreenMessage); + } + catch (Exception ex) + { + ConfigScreenMessage = $"Could not refresh user lists: {ex.Message}"; + StatusMessage = ConfigScreenMessage; + AppendLog(ConfigScreenMessage); + } + } + [RelayCommand] private async Task SaveConfigAsync() { @@ -510,6 +631,9 @@ public partial class MainWindowViewModel( private bool CanRefreshUsers() => CurrentScreen == AppScreen.UserSelection && !IsDownloading; + private bool CanRefreshIgnoredUsersLists() => + CurrentScreen == AppScreen.Config && IsAuthenticated && !IsDownloading; + private bool CanLogout() => IsAuthenticated && !IsDownloading; partial void OnCurrentScreenChanged(AppScreen value) @@ -525,6 +649,7 @@ public partial class MainWindowViewModel( SelectUsersFromListCommand.NotifyCanExecuteChanged(); StopWorkCommand.NotifyCanExecuteChanged(); RefreshUsersCommand.NotifyCanExecuteChanged(); + RefreshIgnoredUsersListsCommand.NotifyCanExecuteChanged(); LogoutCommand.NotifyCanExecuteChanged(); } @@ -535,12 +660,14 @@ public partial class MainWindowViewModel( SelectUsersFromListCommand.NotifyCanExecuteChanged(); StopWorkCommand.NotifyCanExecuteChanged(); RefreshUsersCommand.NotifyCanExecuteChanged(); + RefreshIgnoredUsersListsCommand.NotifyCanExecuteChanged(); LogoutCommand.NotifyCanExecuteChanged(); } partial void OnIsAuthenticatedChanged(bool value) { LogoutCommand.NotifyCanExecuteChanged(); + RefreshIgnoredUsersListsCommand.NotifyCanExecuteChanged(); } partial void OnSelectedListNameChanged(string? value) @@ -692,8 +819,12 @@ public partial class MainWindowViewModel( SetLoading("Fetching users and user lists..."); UserListResult listResult = await downloadOrchestrationService.GetAvailableUsersAsync(); - _allUsers = listResult.Users.OrderBy(pair => pair.Key).ToDictionary(pair => pair.Key, pair => pair.Value); - _allLists = listResult.Lists.OrderBy(pair => pair.Key).ToDictionary(pair => pair.Key, pair => pair.Value); + _allUsers = listResult.Users + .OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase) + .ToDictionary(pair => pair.Key, pair => pair.Value); + _allLists = listResult.Lists + .OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase) + .ToDictionary(pair => pair.Key, pair => pair.Value); foreach (SelectableUserViewModel user in AvailableUsers) { @@ -709,11 +840,8 @@ public partial class MainWindowViewModel( } OnPropertyChanged(nameof(SelectedUsersSummary)); - UserLists.Clear(); - foreach (string listName in _allLists.Keys) - { - UserLists.Add(listName); - } + UpdateUserListsCollection(); + UpdateIgnoredUsersListFieldOptions(); SelectedListName = null; @@ -809,6 +937,8 @@ public partial class MainWindowViewModel( { ConfigFields.Clear(); ConfigCategories.Clear(); + ConfigCategoriesLeft.Clear(); + ConfigCategoriesRight.Clear(); BuildSpecialConfigInputs(config); IEnumerable properties = typeof(Config) @@ -820,7 +950,14 @@ public partial class MainWindowViewModel( foreach (System.Reflection.PropertyInfo property in properties) { object? value = property.GetValue(config); - ConfigFields.Add(new ConfigFieldViewModel(property, value)); + IEnumerable ignoredUsersListNames = property.Name == nameof(Config.IgnoredUsersListName) + ? _allLists.Keys + : []; + ConfigFields.Add(new ConfigFieldViewModel( + property, + value, + ignoredUsersListNames, + GetConfigHelpText(property.Name))); } IEnumerable> grouped = ConfigFields @@ -828,9 +965,41 @@ public partial class MainWindowViewModel( .OrderBy(group => GetCategoryOrder(group.Key)) .ThenBy(group => group.Key); + int categoryIndex = 0; foreach (IGrouping group in grouped) { - ConfigCategories.Add(new ConfigCategoryViewModel(group.Key, group.OrderBy(field => field.DisplayName))); + ConfigCategoryViewModel category = new(group.Key, group.OrderBy(field => field.DisplayName)); + ConfigCategories.Add(category); + if (categoryIndex % 2 == 0) + { + ConfigCategoriesLeft.Add(category); + } + else + { + ConfigCategoriesRight.Add(category); + } + + categoryIndex++; + } + } + + private void UpdateIgnoredUsersListFieldOptions() + { + IEnumerable listNames = _allLists.Keys + .OrderBy(listName => listName, StringComparer.OrdinalIgnoreCase); + + foreach (ConfigFieldViewModel field in ConfigFields.Where(field => field.IsIgnoredUsersListField)) + { + field.SetIgnoredUsersListOptions(listNames); + } + } + + private void UpdateUserListsCollection() + { + UserLists.Clear(); + foreach (string listName in _allLists.Keys.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)) + { + UserLists.Add(listName); } } @@ -855,7 +1024,8 @@ public partial class MainWindowViewModel( foreach ((string displayName, string propertyName) in definitions) { options.Add(new MultiSelectOptionViewModel(displayName, propertyName, - GetBooleanConfigValue(config, propertyName))); + GetBooleanConfigValue(config, propertyName), + GetConfigHelpText(propertyName))); } } @@ -954,6 +1124,11 @@ public partial class MainWindowViewModel( private static string EscapePathForConfig(string path) => path.Replace(@"\", @"\\"); + private static string GetConfigHelpText(string propertyName) => + s_configHelpTextByProperty.TryGetValue(propertyName, out string? helpText) + ? helpText + : string.Empty; + private void SubscribeSpecialSelectionEvents() { foreach (MultiSelectOptionViewModel option in MediaTypeOptions) diff --git a/OF DL.Gui/ViewModels/MultiSelectOptionViewModel.cs b/OF DL.Gui/ViewModels/MultiSelectOptionViewModel.cs index d79e643..6326e96 100644 --- a/OF DL.Gui/ViewModels/MultiSelectOptionViewModel.cs +++ b/OF DL.Gui/ViewModels/MultiSelectOptionViewModel.cs @@ -4,16 +4,21 @@ namespace OF_DL.Gui.ViewModels; public partial class MultiSelectOptionViewModel : ViewModelBase { - public MultiSelectOptionViewModel(string displayName, string propertyName, bool isSelected) + public MultiSelectOptionViewModel(string displayName, string propertyName, bool isSelected, string? helpText = null) { DisplayName = displayName; PropertyName = propertyName; IsSelected = isSelected; + HelpText = helpText?.Trim() ?? string.Empty; } public string DisplayName { get; } public string PropertyName { get; } + public string HelpText { get; } + + public bool HasHelpText => !string.IsNullOrWhiteSpace(HelpText); + [ObservableProperty] private bool _isSelected; } diff --git a/OF DL.Gui/Views/MainWindow.axaml b/OF DL.Gui/Views/MainWindow.axaml index 77a45ff..49ff73a 100644 --- a/OF DL.Gui/Views/MainWindow.axaml +++ b/OF DL.Gui/Views/MainWindow.axaml @@ -105,9 +105,26 @@ - + + +