Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ on:

jobs:
build:
permissions:
contents: read
pull-requests: write
runs-on: ubuntu-latest

steps:
Expand Down
6 changes: 5 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,9 @@
"tagname",
"topwallpapers",
"Xunit"
]
],
"chat.tools.terminal.autoApprove": {
"dotnet test": true,
"git push": true
}
}
12 changes: 12 additions & 0 deletions src/AStar.Dev.FunctionalParadigm/ValidationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@ public static Validation<IReadOnlyList<T>> Combine<T>(this IEnumerable<Validatio
: new Valid<IReadOnlyList<T>>(values);
}

/// <summary>
/// Reduces a <see cref="Validation{T}" /> to a single value by invoking <paramref name="onValid" />
/// with the validated value, or <paramref name="onInvalid" /> with the accumulated errors.
/// </summary>
public static TOut Match<T, TOut>(this Validation<T> validation, Func<T, TOut> onValid, Func<IReadOnlyList<ValidationError>, TOut> onInvalid)
=> validation switch
{
Valid<T> valid => onValid(valid.Value),
Invalid<T> invalid => onInvalid(invalid.Errors),
_ => throw new InvalidOperationException(_unexpectedValidationTypeMessage)
};

/// <summary>
/// Lifts a <see cref="Validation{T}" /> into a <see cref="Result{TResult,TError}" />, mapping the
/// accumulated errors to a domain error via <paramref name="mapErrors" />.
Expand Down
30 changes: 20 additions & 10 deletions src/AStar.Dev.Wallpaper.Scrapper/App.axaml.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Globalization;
using System.IO.Abstractions;
using AStar.Dev.FunctionalParadigm;
using AStar.Dev.Infrastructure.AppDb;
using AStar.Dev.Infrastructure.AppDb.Entities;
using AStar.Dev.Wallpaper.Scrapper.Classifications;
Expand Down Expand Up @@ -33,7 +34,7 @@ public partial class App : Application

public override void Initialize() => AvaloniaXamlLoader.Load(this);

public override void OnFrameworkInitializationCompleted()
public override async void OnFrameworkInitializationCompleted()
{
var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings
{
Expand Down Expand Up @@ -86,9 +87,9 @@ public override void OnFrameworkInitializationCompleted()
.AddTransient<ConfigurationSaver>()
.AddTransient<DatabaseInitializationService>()
.AddTransient<ScrapeConfigurationViewModel>()
.AddTransient<ScrapeConfigurationView>()
.AddTransient<ClassificationsView>()
.AddTransient<TagsView>()
.AddViewFactory<ScrapeConfigurationView>()
.AddViewFactory<ClassificationsView>()
.AddViewFactory<TagsView>()
.AddSingleton<IPlaywrightService, PlaywrightService>()
.AddTransient<SearchWorkflow>()
.AddTransient<SearchResultsPage>()
Expand All @@ -104,31 +105,40 @@ public override void OnFrameworkInitializationCompleted()
.AddTransient<ImagePage>()
.AddSingleton<IDirectoryHelper, DirectoryHelper>()
.AddSingleton<IDelayStrategy, RandomDelayStrategy>()
.AddTransient<Func<TagsView>>(sp => () => sp.GetRequiredService<TagsView>())
.AddTransient<Func<ClassificationsView>>(sp => () => sp.GetRequiredService<ClassificationsView>())
.AddTransient<MainWindow>()
.AddTransient<Func<ScrapeConfigurationView>>(sp => () => sp.GetRequiredService<ScrapeConfigurationView>())
.AddTransient(_ => TimeProvider.System)
.AddTransient<IImageSaver, ImageSaver>()
.AddTransient<IImageDimensionReader, ImageDimensionReader>()
.AddHttpClient<IImageRetriever, ImageRetriever>(client => client.Timeout = TimeSpan.FromMinutes(2));

_host = builder.Build();

Task.Run(async () =>
await _host.Services.GetRequiredService<DatabaseInitializationService>().InitialiseAsync()
).GetAwaiter().GetResult();
await _host.Services.GetRequiredService<DatabaseInitializationService>().InitialiseAsync();

if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = _host.Services.GetRequiredService<MainWindow>();
desktop.Exit += OnExit;
desktop.MainWindow.Show();
}

_host.Start();
SurfaceConfigurationErrors();
base.OnFrameworkInitializationCompleted();
}

private void SurfaceConfigurationErrors()
=> ScrapeConfigurationValidator.Validate(_host.Services.GetRequiredService<ScrapeConfiguration>())
.Match(_ => Unit.Value, errors =>
{
var broadcaster = _host.Services.GetRequiredService<LogBroadcaster>();

foreach (var error in errors)
broadcaster.Broadcast($"Configuration error - {error.Property}: {error.Message}");

return Unit.Value;
});

private void OnExit(object? sender, ControlledApplicationLifetimeExitEventArgs e)
=> _host.StopAsync().GetAwaiter().GetResult();
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using AStar.Dev.Wallpaper.Scrapper.Models;
using AStar.Dev.Wallpaper.Scrapper.Services;
using AStar.Dev.Wallpaper.Scrapper.Support;
using AStar.Dev.Wallpaper.Scrapper.Workflows;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Threading;
Expand Down Expand Up @@ -29,37 +30,40 @@ public ClassificationsView(FileClassificationService fileClassificationService,
}

private async void OnExportClicked(object? sender, RoutedEventArgs e)
=> _ = await ResetCancellationTokenSource()
.Match(
onSuccess: DisableControlsAndClearStatus,
onFailure: ex =>
{
logger.Error(ex, "Failed to reset cancellation token source");
UpdateStatus($"Error: {ex.Message}");
return ex.Message;
}
)
.Tap(_ => logger.Information("Exporting classifications..."))
.MapAsync(_ => fileClassificationService.ExportClassificationsAsync(cts!.Token))
.Tap(importExportService.ExportFileClassificationsToFile)
.TapAsync(_ => logger.Information("Export completed..."))
.EnsureAsync(() => ResetUI());
{
try
{
_ = await ScrapeSiteWorkflowDecision.DecideAsync(
ResetCancellationTokenSource().Tap(onSuccess: DisableControlsAndClearStatus, onFailure: LogSetupFailure),
ct => Result.Success<CancellationToken, string>(ct)
.Tap(_ => logger.Information("Exporting classifications..."))
.MapAsync(_ => fileClassificationService.ExportClassificationsAsync(ct))
.Tap(importExportService.ExportFileClassificationsToFile)
.TapAsync(_ => logger.Information("Export completed...")));
}
finally
{
ResetUI();
}
}

private async void OnImportClicked(object? sender, RoutedEventArgs e)
=> _ = await ResetCancellationTokenSource()
.Match(
onSuccess: DisableControlsAndClearStatus,
onFailure: ex =>
{
logger.Error(ex, "Failed to reset cancellation token source");
return ex.Message;
}
)
.Tap(_ => logger.Information("Importing classifications..."))
.Bind(_ => importExportService.ImportFileClassificationsFromFile().ToStringError())
.MapAsync(classifications => fileClassificationService.ImportClassificationsAsync(classifications, cts!.Token))
.TapAsync(_ => logger.Information("Import completed..."))
.EnsureAsync(() => ResetUI());
{
try
{
_ = await ScrapeSiteWorkflowDecision.DecideAsync(
ResetCancellationTokenSource().Tap(onSuccess: DisableControlsAndClearStatus, onFailure: LogSetupFailure),
ct => Result.Success<CancellationToken, string>(ct)
.Tap(_ => logger.Information("Importing classifications..."))
.Bind(_ => importExportService.ImportFileClassificationsFromFile().ToStringError())
.MapAsync(classifications => fileClassificationService.ImportClassificationsAsync(classifications, ct))
.TapAsync(_ => logger.Information("Import completed...")));
}
finally
{
ResetUI();
}
}

private Result<CancellationToken, Exception> ResetCancellationTokenSource()
{
Expand All @@ -68,13 +72,17 @@ private Result<CancellationToken, Exception> ResetCancellationTokenSource()
return cts.Token;
}

private Result<CancellationToken, string> DisableControlsAndClearStatus(CancellationToken ct = default)
private void LogSetupFailure(Exception exception)
{
logger.Error(exception, "Failed to reset cancellation token source");
UpdateStatus($"Error: {exception.Message}");
}

private void DisableControlsAndClearStatus(CancellationToken ct)
{
ExportButton.IsEnabled = false;
ImportButton.IsEnabled = false;
StatusLabel.Text = string.Empty;

return ct;
}

private void ResetUI()
Expand Down
15 changes: 7 additions & 8 deletions src/AStar.Dev.Wallpaper.Scrapper/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,14 +106,7 @@ private async void OnScrapeSiteFunctionalClicked(object? sender, RoutedEventArgs
try
{
_ = await ScrapeSiteWorkflowDecision.DecideAsync(
ResetCancellationTokenSource().Tap(
onSuccess: DisableControlsAndClearStatus,
onFailure: ex =>
{
logger.Error(ex, "Failed to reset cancellation token source");
UpdateStatus($"Error: {ex.Message}");
}
),
ResetCancellationTokenSource().Tap(onSuccess: DisableControlsAndClearStatus, onFailure: LogSetupFailure),
RunScrapeWorkflowAsync
);
}
Expand All @@ -135,6 +128,12 @@ private Result<CancellationToken, Exception> ResetCancellationTokenSource()
return cts.Token;
}

private void LogSetupFailure(Exception exception)
{
logger.Error(exception, "Failed to reset cancellation token source");
UpdateStatus($"Error: {exception.Message}");
}

private void DisableControlsAndClearStatus(CancellationToken ct)
{
ScrapeSiteNewButton.IsEnabled = false;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using AStar.Dev.FunctionalParadigm;

namespace AStar.Dev.Wallpaper.Scrapper.Models;

/// <summary>
/// Validates a <see cref="ScrapeConfiguration" /> at startup, accumulating every problem so the UI can
/// surface all configuration errors at once instead of the scrape failing part-way through.
/// </summary>
public static class ScrapeConfigurationValidator
{
/// <summary>
/// Validates the supplied <paramref name="configuration" />, accumulating all errors.
/// </summary>
/// <param name="configuration">The configuration loaded at startup.</param>
/// <returns>A <see cref="Valid{T}" /> carrying the configuration, or an <see cref="Invalid{T}" /> carrying every error found.</returns>
public static Validation<ScrapeConfiguration> Validate(ScrapeConfiguration configuration)
{
var errors = new List<ValidationError>();
errors.AddRange(ValidateSearchConfiguration(configuration.SearchConfiguration));
errors.AddRange(ValidateUserConfiguration(configuration.UserConfiguration));
errors.AddRange(ValidateScrapeDirectories(configuration.ScrapeDirectories));

return errors.Count > 0 ? Validation.Invalid<ScrapeConfiguration>(errors) : Validation.Valid(configuration);
}

private static IEnumerable<ValidationError> ValidateSearchConfiguration(SearchConfiguration? searchConfiguration)
{
if (searchConfiguration is null)
{
yield return ValidationErrorFactory.Create(nameof(ScrapeConfiguration.SearchConfiguration), "The search configuration section is missing.");
yield break;
}

if (string.IsNullOrWhiteSpace(searchConfiguration.BaseUrl))
yield return ValidationErrorFactory.Create($"{nameof(ScrapeConfiguration.SearchConfiguration)}.{nameof(SearchConfiguration.BaseUrl)}", "The base URL is required.");

if (string.IsNullOrWhiteSpace(searchConfiguration.LoginUrl))
yield return ValidationErrorFactory.Create($"{nameof(ScrapeConfiguration.SearchConfiguration)}.{nameof(SearchConfiguration.LoginUrl)}", "The login URL is required.");

if (searchConfiguration.ImagePauseInSeconds < 0)
yield return ValidationErrorFactory.Create($"{nameof(ScrapeConfiguration.SearchConfiguration)}.{nameof(SearchConfiguration.ImagePauseInSeconds)}", "The image pause cannot be negative.");

if (searchConfiguration.StartingPageNumber < 1)
yield return ValidationErrorFactory.Create($"{nameof(ScrapeConfiguration.SearchConfiguration)}.{nameof(SearchConfiguration.StartingPageNumber)}", "The starting page number must be at least 1.");
}

private static IEnumerable<ValidationError> ValidateUserConfiguration(UserConfiguration? userConfiguration)
{
if (userConfiguration is null)
{
yield return ValidationErrorFactory.Create(nameof(ScrapeConfiguration.UserConfiguration), "The user configuration section is missing.");
yield break;
}

if (string.IsNullOrWhiteSpace(userConfiguration.LoginEmailAddress))
yield return ValidationErrorFactory.Create($"{nameof(ScrapeConfiguration.UserConfiguration)}.{nameof(UserConfiguration.LoginEmailAddress)}", "The login email address is required.");

if (string.IsNullOrWhiteSpace(userConfiguration.Password))
yield return ValidationErrorFactory.Create($"{nameof(ScrapeConfiguration.UserConfiguration)}.{nameof(UserConfiguration.Password)}", "The password is required.");
}

private static IEnumerable<ValidationError> ValidateScrapeDirectories(ScrapeDirectories? scrapeDirectories)
{
if (scrapeDirectories is null)
{
yield return ValidationErrorFactory.Create(nameof(ScrapeConfiguration.ScrapeDirectories), "The scrape directories section is missing.");
yield break;
}

if (string.IsNullOrWhiteSpace(scrapeDirectories.RootDirectory))
yield return ValidationErrorFactory.Create($"{nameof(ScrapeConfiguration.ScrapeDirectories)}.{nameof(ScrapeDirectories.RootDirectory)}", "The root directory is required.");

if (string.IsNullOrWhiteSpace(scrapeDirectories.BaseSaveDirectory))
yield return ValidationErrorFactory.Create($"{nameof(ScrapeConfiguration.ScrapeDirectories)}.{nameof(ScrapeDirectories.BaseSaveDirectory)}", "The base save directory is required.");
}
}
Loading
Loading