UI improvements to the configuration page

This commit is contained in:
whimsical-c4lic0 2026-02-13 13:38:04 -06:00
parent ec8bf47de5
commit 712f11dc4b
9 changed files with 756 additions and 41 deletions

View File

@ -96,6 +96,13 @@ public class DownloadOrchestrationService(
return result;
}
/// <summary>
/// Retrieves the user's lists only.
/// </summary>
/// <returns>A dictionary of list names to list IDs.</returns>
public async Task<Dictionary<string, long>> GetUserListsAsync() =>
await apiService.GetLists("/lists") ?? new Dictionary<string, long>();
/// <summary>
/// Resolves the users that belong to a specific list.
/// </summary>

View File

@ -9,6 +9,11 @@ public interface IDownloadOrchestrationService
/// </summary>
Task<UserListResult> GetAvailableUsersAsync();
/// <summary>
/// Fetch only user lists.
/// </summary>
Task<Dictionary<string, long>> GetUserListsAsync();
/// <summary>
/// Get users for a specific list by name.
/// </summary>

View File

@ -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<ConfigFieldViewModel> fields)
{
CategoryName = categoryName;
foreach (ConfigFieldViewModel field in fields)
List<ConfigFieldViewModel> 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<ConfigFieldViewModel> 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<string> 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<ConfigFieldViewModel> Fields { get; } = [];
}

View File

@ -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<string, string[]> 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<string>? 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<string> EnumOptions { get; } = [];
public ObservableCollection<string> AvailableFileNameVariables { get; } = [];
public ObservableCollection<ConfigSelectOptionViewModel> IgnoredUsersListOptions { get; } = [];
public ObservableCollection<FileNameFormatSegmentViewModel> 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<string> listNames)
{
if (!IsIgnoredUsersListField)
{
return;
}
string selectedValue = SelectedIgnoredUsersListOption?.Value ?? TextValue.Trim();
ConfigSelectOptionViewModel noSelectionOption =
new(NoListSelectedValue, NoListSelectedDisplayName);
IgnoredUsersListOptions.Clear();
IgnoredUsersListOptions.Add(noSelectionOption);
IEnumerable<string> 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<string> GetAllowedFileNameVariables() =>
s_fileNameVariablesByConfigOption.TryGetValue(PropertyName, out string[]? variables)
? variables
: [];
private void UpdateFileNameFormatPreview()
{
FileNameFormatSegments.Clear();
UnknownFileNameVariablesMessage = string.Empty;
if (string.IsNullOrEmpty(TextValue))
{
return;
}
HashSet<string> allowedVariables = new(GetAllowedFileNameVariables(), StringComparer.OrdinalIgnoreCase);
HashSet<string> 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))

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -44,6 +44,85 @@ public partial class MainWindowViewModel(
("Paid Messages", nameof(Config.DownloadPaidMessages))
];
private static readonly Dictionary<string, string> 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<string, long> _allUsers = [];
private Dictionary<string, long> _allLists = [];
private StartupResult _startupResult = new();
@ -55,6 +134,10 @@ public partial class MainWindowViewModel(
public ObservableCollection<ConfigCategoryViewModel> ConfigCategories { get; } = [];
public ObservableCollection<ConfigCategoryViewModel> ConfigCategoriesLeft { get; } = [];
public ObservableCollection<ConfigCategoryViewModel> ConfigCategoriesRight { get; } = [];
public ObservableCollection<MultiSelectOptionViewModel> MediaTypeOptions { get; } = [];
public ObservableCollection<MultiSelectOptionViewModel> 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<string, long> 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<System.Reflection.PropertyInfo> 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<string> ignoredUsersListNames = property.Name == nameof(Config.IgnoredUsersListName)
? _allLists.Keys
: [];
ConfigFields.Add(new ConfigFieldViewModel(
property,
value,
ignoredUsersListNames,
GetConfigHelpText(property.Name)));
}
IEnumerable<IGrouping<string, ConfigFieldViewModel>> grouped = ConfigFields
@ -828,9 +965,41 @@ public partial class MainWindowViewModel(
.OrderBy(group => GetCategoryOrder(group.Key))
.ThenBy(group => group.Key);
int categoryIndex = 0;
foreach (IGrouping<string, ConfigFieldViewModel> 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<string> 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)

View File

@ -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;
}

View File

@ -105,9 +105,26 @@
<Border Grid.Column="0" Classes="surface" Padding="12" Margin="0,0,10,0">
<StackPanel Spacing="8">
<TextBlock FontSize="16" FontWeight="Bold" Foreground="#1F2A44" Text="External" />
<TextBlock FontWeight="SemiBold"
Foreground="#1F2A44"
Text="FFmpeg Path" />
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock FontWeight="SemiBold"
Foreground="#1F2A44"
Text="FFmpeg Path" />
<Button Width="20"
Height="20"
MinWidth="20"
MinHeight="20"
Padding="0"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
FontSize="12"
FontWeight="Bold"
Background="#EAF0FB"
BorderBrush="#C5D4EC"
BorderThickness="1"
CornerRadius="10"
Content="?"
ToolTip.Tip="{Binding FfmpegPathHelpText}" />
</StackPanel>
<Grid ColumnDefinitions="*,Auto">
<TextBox Grid.Column="0"
VerticalAlignment="Center"
@ -143,9 +160,26 @@
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:MultiSelectOptionViewModel">
<CheckBox Margin="0,0,14,6"
Content="{Binding DisplayName}"
IsChecked="{Binding IsSelected}" />
<StackPanel Margin="0,0,14,6" Orientation="Horizontal" Spacing="6">
<CheckBox Content="{Binding DisplayName}"
IsChecked="{Binding IsSelected}" />
<Button IsVisible="{Binding HasHelpText}"
Width="18"
Height="18"
MinWidth="18"
MinHeight="18"
Padding="0"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
FontSize="11"
FontWeight="Bold"
Background="#EAF0FB"
BorderBrush="#C5D4EC"
BorderThickness="1"
CornerRadius="9"
Content="?"
ToolTip.Tip="{Binding HelpText}" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
@ -165,9 +199,26 @@
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:MultiSelectOptionViewModel">
<CheckBox Margin="0,0,14,6"
Content="{Binding DisplayName}"
IsChecked="{Binding IsSelected}" />
<StackPanel Margin="0,0,14,6" Orientation="Horizontal" Spacing="6">
<CheckBox Content="{Binding DisplayName}"
IsChecked="{Binding IsSelected}" />
<Button IsVisible="{Binding HasHelpText}"
Width="18"
Height="18"
MinWidth="18"
MinHeight="18"
Padding="0"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
FontSize="11"
FontWeight="Bold"
Background="#EAF0FB"
BorderBrush="#C5D4EC"
BorderThickness="1"
CornerRadius="9"
Content="?"
ToolTip.Tip="{Binding HelpText}" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
@ -181,17 +232,16 @@
</Grid>
<Border Classes="surface" Padding="12">
<ItemsControl ItemsSource="{Binding ConfigCategories}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<Grid ColumnDefinitions="*,*">
<ItemsControl x:Name="LeftConfigCategories"
Grid.Column="0"
Margin="0,0,6,0"
ItemsSource="{Binding ConfigCategoriesLeft}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConfigCategoryViewModel">
<Border Classes="surface"
Width="600"
Margin="0,0,12,12"
Margin="0,0,0,12"
HorizontalAlignment="Stretch"
Padding="10">
<StackPanel Spacing="8">
<TextBlock FontSize="16"
@ -202,9 +252,26 @@
Spacing="4"
Margin="0,0,0,10"
x:CompileBindings="False">
<TextBlock FontWeight="SemiBold"
Foreground="#1F2A44"
Text="Download Path" />
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock FontWeight="SemiBold"
Foreground="#1F2A44"
Text="Download Path" />
<Button Width="20"
Height="20"
MinWidth="20"
MinHeight="20"
Padding="0"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
FontSize="12"
FontWeight="Bold"
Background="#EAF0FB"
BorderBrush="#C5D4EC"
BorderThickness="1"
CornerRadius="10"
Content="?"
ToolTip.Tip="{Binding DataContext.DownloadPathHelpText, RelativeSource={RelativeSource AncestorType=Window}}" />
</StackPanel>
<Grid ColumnDefinitions="*,Auto">
<TextBox Grid.Column="0"
VerticalAlignment="Center"
@ -222,16 +289,98 @@
Text="{Binding DataContext.DownloadPathError, RelativeSource={RelativeSource AncestorType=Window}}"
TextWrapping="Wrap" />
</StackPanel>
<StackPanel IsVisible="{Binding HasSpecificDateFilterFields}"
Margin="0,0,0,6"
Spacing="6"
HorizontalAlignment="Stretch"
x:CompileBindings="False">
<StackPanel Orientation="Horizontal"
Spacing="6"
VerticalAlignment="Center">
<TextBlock FontWeight="SemiBold"
Foreground="#1F2A44"
Text="Download Posts from Specific Dates"
VerticalAlignment="Center"
TextWrapping="Wrap" />
<Button IsVisible="{Binding HasSpecificDateFilterHelpText}"
Width="20"
Height="20"
MinWidth="20"
MinHeight="20"
Padding="0"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
FontSize="12"
FontWeight="Bold"
Background="#EAF0FB"
BorderBrush="#C5D4EC"
BorderThickness="1"
CornerRadius="10"
Content="?"
ToolTip.Tip="{Binding SpecificDateFilterHelpText}" />
</StackPanel>
<Grid ColumnDefinitions="Auto,Auto,*"
HorizontalAlignment="Stretch">
<CheckBox Grid.Column="0"
Content="Enable"
VerticalAlignment="Center"
Margin="0,0,8,0"
IsChecked="{Binding DownloadOnlySpecificDatesField.BoolValue}" />
<ComboBox Grid.Column="1"
Width="140"
VerticalAlignment="Center"
Margin="0,0,8,0"
IsEnabled="{Binding DownloadOnlySpecificDatesField.BoolValue}"
ItemsSource="{Binding DownloadDateSelectionField.EnumOptions}"
SelectedItem="{Binding DownloadDateSelectionField.EnumValue}" />
<DatePicker Grid.Column="2"
MinWidth="320"
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
IsEnabled="{Binding DownloadOnlySpecificDatesField.BoolValue}"
SelectedDate="{Binding CustomDateField.DateValue}" />
</Grid>
</StackPanel>
<ItemsControl ItemsSource="{Binding Fields}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="10" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConfigFieldViewModel">
<Grid Margin="0,0,0,10" ColumnDefinitions="190,*">
<TextBlock Grid.Column="0"
Margin="0,6,10,0"
FontWeight="SemiBold"
Foreground="#1F2A44"
Text="{Binding DisplayName}"
TextWrapping="Wrap" />
<Grid Margin="0" ColumnDefinitions="190,*">
<Grid Grid.Column="0"
ColumnDefinitions="*,Auto"
Margin="0,6,10,0"
ClipToBounds="True">
<TextBlock Grid.Column="0"
FontWeight="SemiBold"
Foreground="#1F2A44"
Text="{Binding DisplayName}"
TextWrapping="Wrap"
VerticalAlignment="Top" />
<Button Grid.Column="1"
IsVisible="{Binding HasHelpText}"
Width="20"
Height="20"
MinWidth="20"
MinHeight="20"
Margin="6,0,0,0"
Padding="0"
VerticalAlignment="Top"
HorizontalAlignment="Right"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
FontSize="12"
FontWeight="Bold"
Background="#EAF0FB"
BorderBrush="#C5D4EC"
BorderThickness="1"
CornerRadius="10"
Content="?"
ToolTip.Tip="{Binding HelpText}" />
</Grid>
<StackPanel Grid.Column="1" Spacing="4">
<CheckBox IsVisible="{Binding IsBoolean}"
IsChecked="{Binding BoolValue}" />
@ -251,13 +400,90 @@
Increment="1"
FormatString="N0" />
<TextBox IsVisible="{Binding IsTextInput}"
<TextBox IsVisible="{Binding IsRegularTextInput}"
HorizontalAlignment="Stretch"
AcceptsReturn="{Binding IsMultiline}"
TextWrapping="Wrap"
MinHeight="{Binding TextBoxMinHeight}"
Text="{Binding TextValue}" />
<Grid IsVisible="{Binding IsIgnoredUsersListField}"
ColumnDefinitions="*,Auto"
x:CompileBindings="False">
<ComboBox Grid.Column="0"
HorizontalAlignment="Stretch"
ItemsSource="{Binding IgnoredUsersListOptions}"
SelectedItem="{Binding SelectedIgnoredUsersListOption}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:ConfigSelectOptionViewModel">
<TextBlock Text="{Binding DisplayName}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Grid.Column="1"
Width="34"
Height="34"
MinWidth="34"
MinHeight="34"
Margin="8,0,0,0"
Padding="0"
FontSize="16"
FontWeight="SemiBold"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
Background="#EAF0FB"
BorderBrush="#C5D4EC"
BorderThickness="1"
CornerRadius="8"
Content="⟳"
ToolTip.Tip="Refresh list names from OnlyFans"
Command="{Binding DataContext.RefreshIgnoredUsersListsCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}" />
</Grid>
<StackPanel IsVisible="{Binding IsFileNameFormatField}"
Spacing="6">
<Grid ColumnDefinitions="*,Auto">
<ComboBox Grid.Column="0"
HorizontalAlignment="Stretch"
ItemsSource="{Binding AvailableFileNameVariables}"
SelectedItem="{Binding SelectedFileNameVariable}" />
<Button Grid.Column="1"
Margin="8,0,0,0"
Classes="secondary"
Content="Insert Variable"
Command="{Binding InsertSelectedFileNameVariableCommand}" />
</Grid>
<Border Padding="8"
Background="#F5F8FE"
BorderBrush="#D8E3F4"
BorderThickness="1"
CornerRadius="8">
<ItemsControl ItemsSource="{Binding FileNameFormatSegments}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:FileNameFormatSegmentViewModel">
<TextBlock Text="{Binding Text}"
Foreground="{Binding Foreground}"
FontFamily="Consolas"
TextWrapping="Wrap" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
<TextBlock IsVisible="{Binding HasUnknownFileNameVariables}"
Foreground="#FF5A5A"
Text="{Binding UnknownFileNameVariablesMessage}"
TextWrapping="Wrap" />
</StackPanel>
<TextBlock IsVisible="{Binding HasError}"
Foreground="#FF5A5A"
Text="{Binding ErrorMessage}"
@ -272,6 +498,11 @@
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ItemsControl Grid.Column="1"
Margin="6,0,0,0"
ItemsSource="{Binding ConfigCategoriesRight}"
ItemTemplate="{Binding ItemTemplate, ElementName=LeftConfigCategories}" />
</Grid>
</Border>
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">