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 @@
-
+
+
+
+
-
+
+
+
+
@@ -165,9 +199,26 @@
-
+
+
+
+
@@ -181,17 +232,16 @@
-
-
-
-
-
-
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
@@ -251,13 +400,90 @@
Increment="1"
FormatString="N0" />
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+