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
8 changes: 8 additions & 0 deletions src/AStar.Dev.FunctionalParadigm/Exceptional{T}.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ namespace AStar.Dev.FunctionalParadigm;
/// <typeparam name="T">The type of the success value.</typeparam>
public abstract record Exceptional<T>
{
/// <summary>
/// Restricts derivation of <see cref="Exceptional{T}" /> to <see cref="Success{T}" /> and
/// <see cref="Failure{T}" />, both declared in this assembly.
/// </summary>
private protected Exceptional()
{
}

/// <summary>
/// Implicitly lifts a success value into an <see cref="Exceptional{T}" />.
/// </summary>
Expand Down
8 changes: 8 additions & 0 deletions src/AStar.Dev.FunctionalParadigm/Result{TResult,TError}.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ namespace AStar.Dev.FunctionalParadigm;
/// <typeparam name="TError">The type of the failure error.</typeparam>
public abstract record Result<TResult, TError>
{
/// <summary>
/// Restricts derivation of <see cref="Result{TResult,TError}" /> to <see cref="Ok{TResult,TError}" /> and
/// <see cref="Fail{TResult,TError}" />, both declared in this assembly.
/// </summary>
private protected Result()
{
}

/// <summary>
/// Implicitly lifts a success value into a <see cref="Result{TResult,TError}" />.
/// </summary>
Expand Down
31 changes: 31 additions & 0 deletions src/AStar.Dev.FunctionalParadigm/RetryExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
namespace AStar.Dev.FunctionalParadigm;

/// <summary>
/// Retry helpers for <see cref="Result{TResult,TError}" />-returning operations.
/// </summary>
public static class RetryExtensions
{
/// <summary>
/// Runs <paramref name="operation" /> once. If it fails, runs <paramref name="onRetry" /> and then runs
/// <paramref name="operation" /> a second and final time, returning whichever attempt's result is current
/// at that point (the second attempt's result when both fail).
/// </summary>
/// <param name="operation">The operation to run, at most twice.</param>
/// <param name="onRetry">The side effect (typically a delay) to run between the first and second attempts.</param>
public static async Task<Result<TResult, TError>> RetryOnceAsync<TResult, TError>(Func<Task<Result<TResult, TError>>> operation, Func<Task> onRetry)
{
ArgumentNullException.ThrowIfNull(operation);
ArgumentNullException.ThrowIfNull(onRetry);

var firstAttempt = await operation().ConfigureAwait(false);

return await firstAttempt.MatchAsync(
value => Task.FromResult(Result.Success<TResult, TError>(value)),
async _ =>
{
await onRetry().ConfigureAwait(false);

return await operation().ConfigureAwait(false);
}).ConfigureAwait(false);
}
}
14 changes: 7 additions & 7 deletions src/AStar.Dev.Wallpaper.Scrapper/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,14 @@ public override void OnFrameworkInitializationCompleted()
.AddTransient<ImagePage>()
.AddSingleton<IDirectoryHelper, DirectoryHelper>()
.AddSingleton<IDelayStrategy, RandomDelayStrategy>()
.AddTransient<IImageSaver, ImageSaver>()
.AddHttpClient<IImageRetriever, ImageRetriever>(client => client.Timeout = TimeSpan.FromMinutes(2))
.Services
.AddTransient(_ => TimeProvider.System)
.AddTransient<Func<ScrapeConfigurationView>>(sp => () => sp.GetRequiredService<ScrapeConfigurationView>())
.AddTransient<Func<ClassificationsView>>(sp => () => sp.GetRequiredService<ClassificationsView>())
.AddTransient<Func<TagsView>>(sp => () => sp.GetRequiredService<TagsView>())
.AddTransient<MainWindow>();
.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();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using AStar.Dev.FunctionalParadigm;
using AStar.Dev.Wallpaper.Scrapper.Models;
using AStar.Dev.Wallpaper.Scrapper.Services;
using AStar.Dev.Wallpaper.Scrapper.Support;
using Avalonia.Controls;
Expand Down Expand Up @@ -55,7 +56,7 @@ private async void OnImportClicked(object? sender, RoutedEventArgs e)
}
)
.Tap(_ => logger.Information("Importing classifications..."))
.Bind(_ => importExportService.ImportFileClassificationsFromFile())
.Bind(_ => importExportService.ImportFileClassificationsFromFile().ToStringError())
.MapAsync(classifications => fileClassificationService.ImportClassificationsAsync(classifications, cts!.Token))
.TapAsync(_ => logger.Information("Import completed..."))
.EnsureAsync(() => ResetUI());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using AStar.Dev.FunctionalParadigm;
using AStar.Dev.Guard.Clauses;

namespace AStar.Dev.Wallpaper.Scrapper.DTOs;

/// <summary>Validates an imported <see cref="FileClassification" /> row, accumulating every problem found instead of stopping at the first.</summary>
public static class FileClassificationDtoValidator
{
private const int MinimumLevel = 1;
private const int MaximumLevel = 3;

/// <summary>Validates <paramref name="dto" />, tagging any errors with its <paramref name="rowIndex" /> for reporting.</summary>
public static Validation<FileClassification> Validate(FileClassification dto, int rowIndex)
{
GuardAgainst.Null(dto);

Validation<Func<string, Func<int, FileClassification>>> constructor = Validation.Valid<Func<string, Func<int, FileClassification>>>(_ => _ => dto);

return constructor
.Apply(ValidateName(dto, rowIndex))
.Apply(ValidateLevel(dto, rowIndex));
}

/// <summary>Validates every row in <paramref name="dtos" />, accumulating every invalid row's errors, in row order.</summary>
public static Validation<IReadOnlyList<FileClassification>> ValidateAll(IReadOnlyList<FileClassification> dtos)
{
GuardAgainst.Null(dtos);

return dtos.Select(Validate).Combine();
}

private static Validation<string> ValidateName(FileClassification dto, int rowIndex)
=> string.IsNullOrWhiteSpace(dto.Name)
? Validation.Invalid<string>(ValidationErrorFactory.Create($"Categories[{rowIndex}].Name", "Name is required."))
: Validation.Valid(dto.Name);

private static Validation<int> ValidateLevel(FileClassification dto, int rowIndex)
=> dto.Level is < MinimumLevel or > MaximumLevel
? Validation.Invalid<int>(ValidationErrorFactory.Create($"Categories[{rowIndex}].Level", $"Level must be between {MinimumLevel} and {MaximumLevel}."))
: Validation.Valid(dto.Level);
}
6 changes: 4 additions & 2 deletions src/AStar.Dev.Wallpaper.Scrapper/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using AStar.Dev.FunctionalParadigm;
using AStar.Dev.Wallpaper.Scrapper.Classifications;
using AStar.Dev.Wallpaper.Scrapper.Dialogs;
using AStar.Dev.Wallpaper.Scrapper.Models;
using AStar.Dev.Wallpaper.Scrapper.ScrapeConfigurationEditor;
using AStar.Dev.Wallpaper.Scrapper.Services;
using AStar.Dev.Wallpaper.Scrapper.Support;
Expand Down Expand Up @@ -146,8 +147,9 @@ private Task<Result<Unit, string>> RunScrapeWorkflowAsync(CancellationToken ct)
logger.Information("Configuring Playwright...");
logger.Information("Starting scrape...");

return searchWorkflow.RunAsync(logger, ct)
.TapAsync(_ => logger.Information("Scrape completed..."));
return searchWorkflow.RunAsync(ct)
.TapAsync(_ => logger.Information("Scrape completed..."))
.ToStringError();
}

private void ResetUI()
Expand Down
19 changes: 19 additions & 0 deletions src/AStar.Dev.Wallpaper.Scrapper/Models/ImagePageOutcome.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using AStar.Dev.Wallpaper.Scrapper.Repositories;

namespace AStar.Dev.Wallpaper.Scrapper.Models;

/// <summary>The outcome of scraping an image page, replacing the historical <c>ImagePageResult</c>.</summary>
public abstract record ImagePageOutcome;

/// <summary>The image page's tags were accepted; the image should be downloaded and saved.</summary>
/// <param name="ImageUrl">The URL of the image to download.</param>
/// <param name="DirectorySegments">The directory segments the image should be saved under.</param>
/// <param name="FilePrefix">The prefix to apply to the saved file name.</param>
/// <param name="Tags">The tag text scraped from the image page.</param>
/// <param name="RawTags">The raw tag data scraped from the image page.</param>
public sealed record ScrapedImage(string ImageUrl, IReadOnlyList<string> DirectorySegments, string FilePrefix, IReadOnlyList<string> Tags, IReadOnlyList<TagData> RawTags) : ImagePageOutcome;

/// <summary>The image page's tags matched an ignore rule; the image should not be downloaded.</summary>
/// <param name="Tags">The tag text scraped from the image page before the skip was detected.</param>
/// <param name="RawTags">The raw tag data scraped from the image page before the skip was detected.</param>
public sealed record SkippedImage(IReadOnlyList<string> Tags, IReadOnlyList<TagData> RawTags) : ImagePageOutcome;
29 changes: 29 additions & 0 deletions src/AStar.Dev.Wallpaper.Scrapper/Models/ImagePageOutcomeFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using AStar.Dev.Guard.Clauses;
using AStar.Dev.Wallpaper.Scrapper.Repositories;

namespace AStar.Dev.Wallpaper.Scrapper.Models;

/// <summary>Factory methods for creating instances of the <see cref="ImagePageOutcome" /> discriminated union.</summary>
public static class ImagePageOutcomeFactory
{
/// <summary>Creates a <see cref="ScrapedImage" /> outcome.</summary>
public static ScrapedImage CreateScrapedImage(string imageUrl, IReadOnlyList<string> directorySegments, string filePrefix, IReadOnlyList<string> tags, IReadOnlyList<TagData> rawTags)
{
GuardAgainst.Null(imageUrl);
GuardAgainst.Null(directorySegments);
GuardAgainst.Null(filePrefix);
GuardAgainst.Null(tags);
GuardAgainst.Null(rawTags);

return new(imageUrl, directorySegments, filePrefix, tags, rawTags);
}

/// <summary>Creates a <see cref="SkippedImage" /> outcome.</summary>
public static SkippedImage CreateSkippedImage(IReadOnlyList<string> tags, IReadOnlyList<TagData> rawTags)
{
GuardAgainst.Null(tags);
GuardAgainst.Null(rawTags);

return new(tags, rawTags);
}
}
3 changes: 0 additions & 3 deletions src/AStar.Dev.Wallpaper.Scrapper/Models/ImagePageResult.cs

This file was deleted.

17 changes: 17 additions & 0 deletions src/AStar.Dev.Wallpaper.Scrapper/Models/ScrapeError.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using AStar.Dev.FunctionalParadigm;

namespace AStar.Dev.Wallpaper.Scrapper.Models;

/// <summary>Base type for every error that a scrape pipeline operation can fail with.</summary>
Expand Down Expand Up @@ -36,3 +38,18 @@ public sealed record ClassificationFailed(string FileName, string Message) : Scr
/// <summary>An unanticipated exception was raised while running the scrape pipeline.</summary>
/// <param name="Exception">The exception that was raised.</param>
public sealed record UnexpectedError(Exception Exception) : ScrapeError(Exception.Message);

/// <summary>The dimensions of a downloaded image could not be read.</summary>
/// <param name="Path">The path of the image whose dimensions failed to read.</param>
/// <param name="Message">A human-readable description of the failure.</param>
public sealed record ImageDimensionReadFailed(string Path, string Message) : ScrapeError(Message);

/// <summary>An export file could not be imported.</summary>
/// <param name="FilePath">The path of the file that failed to import.</param>
/// <param name="Message">A human-readable description of the failure.</param>
public sealed record ImportFailed(string FilePath, string Message) : ScrapeError(Message);

/// <summary>An imported payload failed validation.</summary>
/// <param name="Errors">The accumulated validation errors.</param>
/// <param name="Message">A human-readable description of the failure.</param>
public sealed record ValidationFailed(IReadOnlyList<ValidationError> Errors, string Message) : ScrapeError(Message);
28 changes: 28 additions & 0 deletions src/AStar.Dev.Wallpaper.Scrapper/Models/ScrapeErrorFactory.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using AStar.Dev.FunctionalParadigm;
using AStar.Dev.Guard.Clauses;

namespace AStar.Dev.Wallpaper.Scrapper.Models;
Expand Down Expand Up @@ -64,4 +65,31 @@ public static UnexpectedError CreateUnexpectedError(Exception exception)

return new(exception);
}

/// <summary>Creates an <see cref="ImageDimensionReadFailed" /> error.</summary>
public static ImageDimensionReadFailed CreateImageDimensionReadFailed(string path, string message)
{
GuardAgainst.Null(path);
GuardAgainst.Null(message);

return new(path, message);
}

/// <summary>Creates an <see cref="ImportFailed" /> error.</summary>
public static ImportFailed CreateImportFailed(string filePath, string message)
{
GuardAgainst.Null(filePath);
GuardAgainst.Null(message);

return new(filePath, message);
}

/// <summary>Creates a <see cref="ValidationFailed" /> error.</summary>
public static ValidationFailed CreateValidationFailed(IReadOnlyList<ValidationError> errors, string message)
{
GuardAgainst.Null(errors);
GuardAgainst.Null(message);

return new(errors, message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,18 @@ public static class ScrapeErrorLoggingExtensions
{
/// <summary>Logs an error when <paramref name="result" /> is a failure, then returns <paramref name="result" /> unchanged.</summary>
public static Result<TResult, ScrapeError> LogFailure<TResult>(this Result<TResult, ScrapeError> result, ILogger logger)
=> result.Tap(_ => { }, error => logger.Error("Scrape pipeline failure: {Message}", error.Message));
{
ArgumentNullException.ThrowIfNull(result);
ArgumentNullException.ThrowIfNull(logger);

return result.Tap(_ => { }, error => logger.Error("Scrape pipeline failure: {Message}", error.Message));
}

/// <summary>Logs an error when the awaited result is a failure, then returns the result unchanged.</summary>
public static Task<Result<TResult, ScrapeError>> LogFailure<TResult>(this Task<Result<TResult, ScrapeError>> resultTask, ILogger logger)
{
ArgumentNullException.ThrowIfNull(resultTask);

return resultTask.TapAsync(_ => { }, error => logger.Error("Scrape pipeline failure: {Message}", error.Message));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using AStar.Dev.FunctionalParadigm;

namespace AStar.Dev.Wallpaper.Scrapper.Models;

/// <summary>
/// Bridges <see cref="Result{TResult,ScrapeError}" /> pipelines into call sites that have not yet migrated off
/// string-typed errors, by mapping the <see cref="ScrapeError" /> down to its <see cref="ScrapeError.Message" />.
/// </summary>
public static class ScrapeErrorResultExtensions
{
/// <summary>Maps a <see cref="ScrapeError" /> failure down to its message, preserving a success value unchanged.</summary>
public static Result<TResult, string> ToStringError<TResult>(this Result<TResult, ScrapeError> result)
{
ArgumentNullException.ThrowIfNull(result);

return result.Match(value => Result.Success<TResult, string>(value), error => Result.Failure<TResult, string>(error.Message));
}

/// <summary>Maps a <see cref="ScrapeError" /> failure down to its message, preserving a success value unchanged.</summary>
public static async Task<Result<TResult, string>> ToStringError<TResult>(this Task<Result<TResult, ScrapeError>> resultTask)
{
ArgumentNullException.ThrowIfNull(resultTask);

return (await resultTask.ConfigureAwait(false)).ToStringError();
}
}
9 changes: 5 additions & 4 deletions src/AStar.Dev.Wallpaper.Scrapper/Pages/ITopWallpapersPage.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
using Microsoft.Playwright;
using AStar.Dev.FunctionalParadigm;
using AStar.Dev.Wallpaper.Scrapper.Models;

namespace AStar.Dev.Wallpaper.Scrapper.Pages;

public interface ITopWallpapersPage
{
Task<IResponse?> LoadTopWallpapersPageAsync(int pageNumber);
Task<Result<Unit, ScrapeError>> LoadTopWallpapersPageAsync(int pageNumber);

Task<int> PageInfoAsync();
Task<Result<int, ScrapeError>> PageInfoAsync();

Task<IReadOnlyCollection<string>> GetImagePageLinksAsync();
Task<Result<IReadOnlyCollection<string>, ScrapeError>> GetImagePageLinksAsync();
}
Loading
Loading