diff --git a/MfGames.Nitride.sln b/MfGames.Nitride.sln index 1e0b9d5..013765b 100644 --- a/MfGames.Nitride.sln +++ b/MfGames.Nitride.sln @@ -51,6 +51,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MfGames.Nitride.Json", "src EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MfGames.Nitride.Json.Tests", "tests\MfGames.Nitride.Json.Tests\MfGames.Nitride.Json.Tests.csproj", "{7CCC3A82-D5FE-4D54-9751-5E7985DE1F26}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MfGames.Nitride.Temporal.Schedules", "src\MfGames.Nitride.Temporal.Schedules\MfGames.Nitride.Temporal.Schedules.csproj", "{6AC8F985-B11B-44F4-A000-DFEAFEF59754}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MfGames.Nitride.Temporal.Schedules.Tests", "tests\MfGames.Nitride.Temporal.Schedules.Tests\MfGames.Nitride.Temporal.Schedules.Tests.csproj", "{CA009524-E64A-4380-874E-C9D19D868572}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -316,6 +320,30 @@ Global {7CCC3A82-D5FE-4D54-9751-5E7985DE1F26}.Release|x64.Build.0 = Release|Any CPU {7CCC3A82-D5FE-4D54-9751-5E7985DE1F26}.Release|x86.ActiveCfg = Release|Any CPU {7CCC3A82-D5FE-4D54-9751-5E7985DE1F26}.Release|x86.Build.0 = Release|Any CPU + {6AC8F985-B11B-44F4-A000-DFEAFEF59754}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6AC8F985-B11B-44F4-A000-DFEAFEF59754}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6AC8F985-B11B-44F4-A000-DFEAFEF59754}.Debug|x64.ActiveCfg = Debug|Any CPU + {6AC8F985-B11B-44F4-A000-DFEAFEF59754}.Debug|x64.Build.0 = Debug|Any CPU + {6AC8F985-B11B-44F4-A000-DFEAFEF59754}.Debug|x86.ActiveCfg = Debug|Any CPU + {6AC8F985-B11B-44F4-A000-DFEAFEF59754}.Debug|x86.Build.0 = Debug|Any CPU + {6AC8F985-B11B-44F4-A000-DFEAFEF59754}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6AC8F985-B11B-44F4-A000-DFEAFEF59754}.Release|Any CPU.Build.0 = Release|Any CPU + {6AC8F985-B11B-44F4-A000-DFEAFEF59754}.Release|x64.ActiveCfg = Release|Any CPU + {6AC8F985-B11B-44F4-A000-DFEAFEF59754}.Release|x64.Build.0 = Release|Any CPU + {6AC8F985-B11B-44F4-A000-DFEAFEF59754}.Release|x86.ActiveCfg = Release|Any CPU + {6AC8F985-B11B-44F4-A000-DFEAFEF59754}.Release|x86.Build.0 = Release|Any CPU + {CA009524-E64A-4380-874E-C9D19D868572}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CA009524-E64A-4380-874E-C9D19D868572}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CA009524-E64A-4380-874E-C9D19D868572}.Debug|x64.ActiveCfg = Debug|Any CPU + {CA009524-E64A-4380-874E-C9D19D868572}.Debug|x64.Build.0 = Debug|Any CPU + {CA009524-E64A-4380-874E-C9D19D868572}.Debug|x86.ActiveCfg = Debug|Any CPU + {CA009524-E64A-4380-874E-C9D19D868572}.Debug|x86.Build.0 = Debug|Any CPU + {CA009524-E64A-4380-874E-C9D19D868572}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CA009524-E64A-4380-874E-C9D19D868572}.Release|Any CPU.Build.0 = Release|Any CPU + {CA009524-E64A-4380-874E-C9D19D868572}.Release|x64.ActiveCfg = Release|Any CPU + {CA009524-E64A-4380-874E-C9D19D868572}.Release|x64.Build.0 = Release|Any CPU + {CA009524-E64A-4380-874E-C9D19D868572}.Release|x86.ActiveCfg = Release|Any CPU + {CA009524-E64A-4380-874E-C9D19D868572}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {D480943C-764D-4A8A-B546-642ED10586BB} = {570184FD-ECE4-4EC8-86E1-C1265E17D647} @@ -339,5 +367,7 @@ Global {2AAE2B69-A93D-4045-B7E6-A32ED08D0D65} = {251D9C68-34EB-439D-B167-688BCC47DA17} {9A0D2BEE-859A-4E74-8CA7-5E0FB7C2B113} = {570184FD-ECE4-4EC8-86E1-C1265E17D647} {7CCC3A82-D5FE-4D54-9751-5E7985DE1F26} = {251D9C68-34EB-439D-B167-688BCC47DA17} + {6AC8F985-B11B-44F4-A000-DFEAFEF59754} = {570184FD-ECE4-4EC8-86E1-C1265E17D647} + {CA009524-E64A-4380-874E-C9D19D868572} = {251D9C68-34EB-439D-B167-688BCC47DA17} EndGlobalSection EndGlobal diff --git a/src/MfGames.Nitride.Temporal.Schedules/ApplySchedules.cs b/src/MfGames.Nitride.Temporal.Schedules/ApplySchedules.cs index 55378d3..82eae2e 100644 --- a/src/MfGames.Nitride.Temporal.Schedules/ApplySchedules.cs +++ b/src/MfGames.Nitride.Temporal.Schedules/ApplySchedules.cs @@ -1,6 +1,85 @@ +using System.Collections.Generic; +using System.Linq; + +using FluentValidation; + +using MfGames.Gallium; +using MfGames.Nitride.Generators; + namespace MfGames.Nitride.Temporal.Schedules; -public interface ApplySchedules +public enum SchedulePeriod { - + /// + /// Indicates that all the matching schedule periods are instant (zero time). + /// + Instant, + + /// + /// Indicates that the entities will be scheduled a day apart. + /// + Day, + + /// + /// Indicates that the entities will be scheduled seven days apart. + /// + Week, +} + +/// +/// Applies schedules against the list of entities. +/// +[WithProperties] +public partial class ApplySchedules : OperationBase +{ + private readonly IValidator validator; + + public ApplySchedules( + IValidator validator, + Timekeeper timekeeper) + { + this.Timekeeper = timekeeper; + this.validator = validator; + this.Schedules = new List(); + } + + /// + /// Gets or sets the ordered list of schedules to apply to the entities. + /// + public IList Schedules { get; set; } + + /// + /// Gets or sets the timekeeper associated with this operation. + /// + public Timekeeper Timekeeper { get; set; } + + /// + public override IEnumerable Run(IEnumerable input) + { + this.validator.ValidateAndThrow(this); + + return input.Select(this.Apply); + } + + public ApplySchedules WithSchedules(IEnumerable items) + where TItem : ISchedule + { + this.Schedules = items.OfType().ToList(); + return this; + } + + private Entity Apply(Entity entity) + { + foreach (ISchedule schedule in this.Schedules) + { + if (!schedule.CanApply(entity)) + { + continue; + } + + entity = schedule.Apply(entity, this.Timekeeper); + } + + return entity; + } } diff --git a/src/MfGames.Nitride.Temporal.Schedules/ISchedule.cs b/src/MfGames.Nitride.Temporal.Schedules/ISchedule.cs index 3d556ec..a1fa15b 100644 --- a/src/MfGames.Nitride.Temporal.Schedules/ISchedule.cs +++ b/src/MfGames.Nitride.Temporal.Schedules/ISchedule.cs @@ -8,17 +8,23 @@ namespace MfGames.Nitride.Temporal.Schedules; /// public interface ISchedule { + /// + /// Applies the schedule changes to the entity and returns the results. + /// + /// The entity to update. + /// The timekeeper for apply. + /// + /// The modified entity, if the changes can be applied, otherwise the same + /// entity. + /// + Entity Apply( + Entity entity, + Timekeeper timekeeper); + /// /// Determines if a schedule applies to a given entity. /// /// The entity to test. /// True if the schedule applies to the given entity, otherwise false. bool CanApply(Entity entity); - - /// - /// Applies the schedule changes to the entity and returns the results. - /// - /// The entity to update. - /// The modified entity, if the changes can be applied, otherwise the same entity. - Entity Apply(Entity entity); } diff --git a/src/MfGames.Nitride.Temporal.Schedules/MfGames.Nitride.Temporal.Schedules.csproj b/src/MfGames.Nitride.Temporal.Schedules/MfGames.Nitride.Temporal.Schedules.csproj index ee081a1..9871895 100644 --- a/src/MfGames.Nitride.Temporal.Schedules/MfGames.Nitride.Temporal.Schedules.csproj +++ b/src/MfGames.Nitride.Temporal.Schedules/MfGames.Nitride.Temporal.Schedules.csproj @@ -11,12 +11,13 @@ - + + diff --git a/src/MfGames.Nitride.Temporal.Schedules/NumericalPathSchedule.cs b/src/MfGames.Nitride.Temporal.Schedules/NumericalPathSchedule.cs index c964160..cd95618 100644 --- a/src/MfGames.Nitride.Temporal.Schedules/NumericalPathSchedule.cs +++ b/src/MfGames.Nitride.Temporal.Schedules/NumericalPathSchedule.cs @@ -4,6 +4,10 @@ using System.Text.RegularExpressions; using MfGames.Gallium; using MfGames.Nitride.Generators; +using NodaTime; + +using Zio; + namespace MfGames.Nitride.Temporal.Schedules; /// @@ -11,7 +15,7 @@ namespace MfGames.Nitride.Temporal.Schedules; /// a starting point and calculates the information from there. /// [WithProperties] -public partial class NumericalPathSchedule +public partial class NumericalPathSchedule : ISchedule { public NumericalPathSchedule() { @@ -21,13 +25,100 @@ public partial class NumericalPathSchedule this.CaptureOffset = -1; } - public Regex? PathRegex { get; set; } - - public Func GetPath { get; set; } - public DateTime? ScheduleStart { get; set; } - public SchedulePeriod SchedulePeriod { get; set; } - + /// + /// Gets or sets the group number of the capture group with 1 being being the + /// first group in PathRegex. + /// public int CaptureGroup { get; set; } + /// + /// Gets or sets the offset to make the resulting capture group a zero-based + /// number. + /// public int CaptureOffset { get; set; } + + /// + /// Gets or sets the method for retrieving the path for the entity. + /// + public Func GetPath { get; set; } + + /// + /// Gets or sets the regular expression to identify a matching path. + /// + public Regex? PathRegex { get; set; } + + /// + /// Gets or sets the period between each matching entity. More precisely, + /// the schedule will be TimeSpan * (CaptureGroup + CaptureOffset). + /// + public SchedulePeriod SchedulePeriod { get; set; } + + /// + /// Gets or sets when the first item is scheduled. + /// + public DateTime? ScheduleStart { get; set; } + + /// + public virtual Entity Apply( + Entity entity, + Timekeeper timekeeper) + { + // Get the path and match it. + string? path = this.GetPath(entity); + Match? match = this.PathRegex?.Match(path) + ?? throw new NullReferenceException( + "PathRegex was not configured for the " + + this.GetType().Name + + "."); + + if (!match.Success) + { + return entity; + } + + // Figure out the index/number of this entry. + int number = int.Parse(match.Groups[this.CaptureGroup].Value) + + this.CaptureOffset; + + // Figure out the time from the start. + TimeSpan span = this.SchedulePeriod switch + { + SchedulePeriod.Instant => TimeSpan.Zero, + SchedulePeriod.Day => TimeSpan.FromDays(1), + SchedulePeriod.Week => TimeSpan.FromDays(7), + _ => throw new InvalidOperationException( + "Cannot parse schedule period from " + + this.SchedulePeriod + + "."), + }; + DateTime start = this.ScheduleStart + ?? throw new NullReferenceException( + "Cannot use a schedule without a start date."); + DateTime when = start + span * number; + Instant instant = timekeeper.CreateInstant(when); + + // If the time hasn't past, then we don't apply it. + Instant now = timekeeper.Clock.GetCurrentInstant(); + + return instant > now + ? entity + : this.Apply(entity, number, instant); + } + + /// + public virtual bool CanApply(Entity entity) + { + string? path = this.GetPath(entity); + bool match = this.PathRegex?.IsMatch(path) ?? false; + + return match; + } + + protected virtual Entity Apply( + Entity entity, + int number, + Instant instant) + { + return entity.Set(instant); + } } diff --git a/src/MfGames.Nitride.Temporal.Schedules/README.md b/src/MfGames.Nitride.Temporal.Schedules/README.md index ee08cbc..a08c9eb 100644 --- a/src/MfGames.Nitride.Temporal.Schedules/README.md +++ b/src/MfGames.Nitride.Temporal.Schedules/README.md @@ -1,41 +1,180 @@ -# Date Processing +# Schedules -One of the common features of static websites are blogs which leads to having -some form of date-centric processing of pages to build archive pages, calendars, -and being able to write posts in the future. +Scheduling posts and entries is fairly common process with blob posts but the +methods for creating those schedules can vary drastically because it requires +site- or project-specific elements such as which components or models to change +and how. This package provides one approach to scheduling that "applies" +changes (as described in JSON or YAML) to a given object based on the current +date (as provided by the `MfGames.Nitride.Temporal` package). -With the component system, the date of a given file is simply attached to -`Nodatime.Instant` component of the `Entity` object for the bulk of the -processing. - -## Supporting Time Zones - -The concept of time zones while date processing is one that is frequently -overlooked. A date is a date, right? However, most blogs and news sites have a -concept of when a new day starts but it isn't always the same time as the server -that is building the site. While a blog might be in America/Chicago time, a CI -server could be set to UTC (such as Azure build servers) and the -"day" may roll over fix or six hours before or after the blog's time. - -This is why Nitride uses `Instant` for when pages are implemented. These are -points in time that are independent of time zones, but we also provide tools for -converting a date model or one from the path into a proper instant based on the -blog's time zone. - -## Why NodaTime? - -We decided to use [NodaTime](https://nodatime.org/) instead of the built-in date -time functions for a number of reasons, mainly because it has a more intuitive -way of handling time zones +A schedule is not needed when the date is in the path or inside a component. Using +`MfGames.Nitride.Temporal.SetFromComponent` or `MfGames.Nitride.Temporal.SetFromPath` +would be more than sufficient. This is for dynamically changing entities based on +the date based on `Timekeeper`. ## Configuring -There are two callbacks on `NitrideBuilder` that can be used to define the date -and time processing for the blog. +To use the modules, either the `NitrideTemporalSchedulesModule` can be added or +the builder extension method can be used. ```csharp NitrideBuilder builder; -builder - .ConfigureDates((NitrideClock clock) => clock.SetTimeZone()) +builder.UseTemporalSchedules(); +``` + +## ISchedule + +A schedule is a class that implements `ISchedule` which has the following methods: + +- CanApply(Entity) ⟶ bool + - This returns true if the schedule can apply to the given entity. +- Apply(Entity) ⟶ Entity + - This makes the changes for the schedule on the entity and returns the results. + - If the entity doesn't apply, then it should return the entity without changes. + +## ApplySchedules + +The primary operation is `ApplySchedules` which take a sequence of schedule objects +and applies each one in turn against every entity given to the operation. + +```csharp +IEnumerable entities; +IList schedules; +ApplySchedules op; + +return op + .WithSchedules(schedules) + .Run(entities); +``` + +The following properties are available (along with their corresponding `With*` methods): + +- `IList Schedules` + - Contains the list of schedules to apply against entities. + - Defaults to an empty list. +- `Timekeeper Timekeeper` + - Used to determine when the site is being generated. + - Defaults to the one provided by `MfGames.Nitride.Temporal`. + +### Numerical Path-Based Schedules + +A common pattern for using schedules is to dole out a numerical series of posts over +a period of time. For example, a weekly posting of chapters or comic strips. In these +cases, there is usually a number in the path such as `chapter-01` or `comic-0244`. The +schedule starts at a certain point and then continues every day or week as needed. + +The `NumericalPathSchedule` encapsulates this pattern. It uses the `UPath` component +of the entity and compares it against a regular expression that captures the numerical +part of the path. If it matches, then the schedule sets the date equal to the starting +point plus the "period" for every one past the first. + +In this example: + +```csharp +ApplySchedule op; +var entities = new List +{ + new Entity().Set((UPath) "chapter-01.md"), + new Entity().Set((UPath) "chapter-02.md"), + new Entity().Set((UPath) "chapter-03.md"), +}; +var schedules = new List +{ + new NumericalPathSchedule + { + PathRegex = "chapter-(\d+), + ScheduleStart = DateTime.Parse("2023-01-01"), + SchedulePeriod = SchedulePeriod.Week, + }, +} + +return entities.Run(op.WithSchedules(schedules)); +``` + +This will have chapter-01 have an `Instant` component set to 2023-01-01, the second +chapter will be set to 2023-01-08, and the third at 2023-01-15. + +`NumericalPathSchedule` has the following properties: + +- `Func GetPath` + - An override to allow retrieving a different function. + - Defaults to `entity.Get().ToString()` +- `Regex PathRegex` + - The regular expression that retrieves the number. + - Defaults to `^.*(\d+)` which grabs the last number found. +- `DateTime ScheduleStart` + - The date that the schedule starts. + - No default. +- `SchedulePeriod SchedulePeriod` + - Values: + - Instant (meaning everything at once, default) + - Week + - Day +- `int CaptureGroup` + - The numerical index of the capture group. + - Defaults to `1` because the first match in Regex is 1. +- `int CaptureOffset` + - The offset to make the capture group a zero-based number. + - Defaults to `-1` which makes `chapter-01` the first entry. + +There is also a virtual method for applying the schedule. + +- `protected Entity ApplySchedule(Entity entity, int number, Instant instant)` + - The callback method for applying the schedule to the entity. + - This defaults to `return entity.Set(instant)`. + +#### Overriding Logic + +The default operation of a `NumericalPathSchedule` is to only set the `Instant` +component of the entity. The class can be extended to have more site-specific +entries including adding more properties to the schedule and applying them. + +```csharp +/// A model for the YAML front matter on a page. +public class PageModel +{ + /// Gets or sets the access key for the page. + public string? Access { get; set; } + + /// Gets or sets the optional schedule for this page. + List? Schedules { get; set; } +} + +/// A schedule specific to this project. +public class PageSchedule : NumericalPathSchedule +{ + public PageSchedule() + { + // Set the default to weekly. + this.SchedulePeriod = SchedulePeriod.Week; + } + + /// Gets or sets the access key for the page. + public Access { get; set; } + + protected override Entity Apply(Entity entity, int number, Instant instant) + { + var model = entity.Get(); + model.Access = this.Access; + return entity.Set(instant, model); + } +} +``` + +Commonly, this schedule will be put into a JSON or YAML file. + +```yaml +schedules: + # Patron and Ko-Fi subscribers get it all at once + - pathRegex: chapters/chapter-\d+ + scheduleDate: 2025-01-01 + schedulePeriod: instant # Because we overrode the default to be weekly. + access: subscribers + # The first fifteen chapters (01-15) were released at the rate of one + # per week starting in 2024. This will replace all the schedules above + # it. + - path: chapters/chapter-(0\d|1[1-5]) + scheduleDate: 2030-01-01 + access: public ``` diff --git a/src/MfGames.Nitride.Temporal.Schedules/Setup/NitrideTemporalSchedulesBuilderExtensions.cs b/src/MfGames.Nitride.Temporal.Schedules/Setup/NitrideTemporalSchedulesBuilderExtensions.cs index 7234447..53fa846 100644 --- a/src/MfGames.Nitride.Temporal.Schedules/Setup/NitrideTemporalSchedulesBuilderExtensions.cs +++ b/src/MfGames.Nitride.Temporal.Schedules/Setup/NitrideTemporalSchedulesBuilderExtensions.cs @@ -1,79 +1,17 @@ -using System; - using Autofac; -using MfGames.Nitride.Commands; -using MfGames.Nitride.Temporal.Cli; - -using Serilog; - namespace MfGames.Nitride.Temporal.Schedules.Setup; public static class NitrideTemporalSchedulesBuilderExtensions { /// /// Extends the builder to allow for configuring the temporal - /// settings for generation. + /// schedules during processing. /// - public static NitrideBuilder UseTemporal( - this NitrideBuilder builder, - Action? configure = null) + public static NitrideBuilder UseTemporalSchedules( + this NitrideBuilder builder) { - // Get the configuration so we can set the various options. - var config = new NitrideTemporalConfiguration(); - - configure?.Invoke(config); - - // Add in the module registration. - builder.ConfigureContainer( - x => - { - // Register the module. - x.RegisterModule(); - - // Add in the CLI options. - if (config.AddDateOptionToCommandLine) - { - x.RegisterType() - .As(); - } - - if (config.AddExpireOptionToCommandLine - && config.Expiration != null) - { - x.Register( - context => - { - ILogger logger = context.Resolve(); - Timekeeper - clock = context.Resolve(); - - return new ExpiresPipelineCommandOption( - logger, - clock, - config.Expiration); - }) - .As(); - } - }); - - if (config.DateTimeZone != null) - { - builder.ConfigureSite( - ( - _, - scope) => - { - ILogger logger = scope.Resolve(); - Timekeeper timekeeper = scope.Resolve(); - - timekeeper.DateTimeZone = config.DateTimeZone; - logger.Verbose( - "Setting time zone to {Zone:l}", - timekeeper.DateTimeZone); - }); - } - - return builder; + return builder.ConfigureContainer( + x => x.RegisterModule()); } } diff --git a/src/MfGames.Nitride.Temporal.Schedules/Setup/NitrideTemporalSchedulesModule.cs b/src/MfGames.Nitride.Temporal.Schedules/Setup/NitrideTemporalSchedulesModule.cs index ab79259..8e0d108 100644 --- a/src/MfGames.Nitride.Temporal.Schedules/Setup/NitrideTemporalSchedulesModule.cs +++ b/src/MfGames.Nitride.Temporal.Schedules/Setup/NitrideTemporalSchedulesModule.cs @@ -1,5 +1,7 @@ using Autofac; +using MfGames.Nitride.Temporal.Setup; + namespace MfGames.Nitride.Temporal.Schedules.Setup; public class NitrideTemporalSchedulesModule : Module @@ -7,14 +9,8 @@ public class NitrideTemporalSchedulesModule : Module /// protected override void Load(ContainerBuilder builder) { + builder.RegisterModule(); builder.RegisterOperators(this); builder.RegisterValidators(this); - - builder.RegisterType() - .AsSelf() - .SingleInstance(); - - builder.RegisterGeneric(typeof(SetInstantFromComponent<>)) - .As(typeof(SetInstantFromComponent<>)); } } diff --git a/src/MfGames.Nitride.Temporal.Schedules/Validators/ApplySchedulesValidator.cs b/src/MfGames.Nitride.Temporal.Schedules/Validators/ApplySchedulesValidator.cs index b2a317c..e525913 100644 --- a/src/MfGames.Nitride.Temporal.Schedules/Validators/ApplySchedulesValidator.cs +++ b/src/MfGames.Nitride.Temporal.Schedules/Validators/ApplySchedulesValidator.cs @@ -2,17 +2,14 @@ using FluentValidation; namespace MfGames.Nitride.Temporal.Schedules.Validators; -public class CreateDateIndexesValidator : AbstractValidator +public class ApplySchedulesValidator : AbstractValidator { - public CreateDateIndexesValidator() + public ApplySchedulesValidator() { - this.RuleFor(a => a.Timekeeper) - .NotNull(); + this.RuleFor(x => x.Schedules) + .NotEmpty(); - this.RuleFor(a => a.CreateIndex) - .NotNull(); - - this.RuleFor(a => a.Formats) + this.RuleFor(x => x.Timekeeper) .NotNull(); } } diff --git a/src/MfGames.Nitride.Temporal/CanExpire.cs b/src/MfGames.Nitride.Temporal/CanExpire.cs index 3aa9efb..2b425b4 100644 --- a/src/MfGames.Nitride.Temporal/CanExpire.cs +++ b/src/MfGames.Nitride.Temporal/CanExpire.cs @@ -1,9 +1,11 @@ +using MfGames.Nitride.Generators; + namespace MfGames.Nitride.Temporal; /// /// A marker component for identifying a post that can expire. /// -public class CanExpire +[SingletonComponent] +public partial class CanExpire { - public static CanExpire Instance { get; } = new(); } diff --git a/src/MfGames.Nitride.Temporal/MfGames.Nitride.Temporal.csproj b/src/MfGames.Nitride.Temporal/MfGames.Nitride.Temporal.csproj index c51c119..1f29e72 100644 --- a/src/MfGames.Nitride.Temporal/MfGames.Nitride.Temporal.csproj +++ b/src/MfGames.Nitride.Temporal/MfGames.Nitride.Temporal.csproj @@ -11,7 +11,7 @@ - + diff --git a/src/MfGames.Nitride.Temporal/Setup/NitrideTemporalModule.cs b/src/MfGames.Nitride.Temporal/Setup/NitrideTemporalModule.cs index ddd98dd..e27c746 100644 --- a/src/MfGames.Nitride.Temporal/Setup/NitrideTemporalModule.cs +++ b/src/MfGames.Nitride.Temporal/Setup/NitrideTemporalModule.cs @@ -1,6 +1,6 @@ using Autofac; -namespace MfGames.Nitride.Temporal; +namespace MfGames.Nitride.Temporal.Setup; public class NitrideTemporalModule : Module { diff --git a/tests/MfGames.Nitride.Temporal.Schedules.Tests/MfGames.Nitride.Temporal.Schedules.Tests.csproj b/tests/MfGames.Nitride.Temporal.Schedules.Tests/MfGames.Nitride.Temporal.Schedules.Tests.csproj index da6250d..b0e4ff2 100644 --- a/tests/MfGames.Nitride.Temporal.Schedules.Tests/MfGames.Nitride.Temporal.Schedules.Tests.csproj +++ b/tests/MfGames.Nitride.Temporal.Schedules.Tests/MfGames.Nitride.Temporal.Schedules.Tests.csproj @@ -12,7 +12,7 @@ - + @@ -25,6 +25,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + diff --git a/tests/MfGames.Nitride.Temporal.Schedules.Tests/NumericalPathScheduleTests.cs b/tests/MfGames.Nitride.Temporal.Schedules.Tests/NumericalPathScheduleTests.cs index c161cce..72ca49c 100644 --- a/tests/MfGames.Nitride.Temporal.Schedules.Tests/NumericalPathScheduleTests.cs +++ b/tests/MfGames.Nitride.Temporal.Schedules.Tests/NumericalPathScheduleTests.cs @@ -3,322 +3,228 @@ using System.Collections.Generic; using System.Linq; using MfGames.Gallium; -using MfGames.Nitride.Temporal.Tests; using MfGames.Nitride.Tests; +using NodaTime; +using NodaTime.Testing; + using Xunit; using Xunit.Abstractions; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +using Zio; + namespace MfGames.Nitride.Temporal.Schedules.Tests; -public class TestPageModel +public class NumericalPathScheduleTests : TemporalSchedulesTestBase { - public string Access { get; set; } -} - -public class TestSchedule : NumericalPathSchedule -{ -} - -public class ApplySchedulesTests : TemporalSchedulesTestBase -{ - public ApplySchedulesTests(ITestOutputHelper output) + public NumericalPathScheduleTests(ITestOutputHelper output) : base(output) { } [Fact] - public void MonthOnlyIndexes() + public void DeserializedSetupWorks() { using TemporalSchedulesTestContext context = this.CreateContext(); - Timekeeper timekeeper = context.Resolve(); - CreateDateIndexes op = context.Resolve() - .WithFormats("yyyy-MM") - .WithCreateIndex(this.CreateIndex); - - List input = new() + // Create a numerical series of entities. + var input = new List { - new Entity().Add("page1") - .Add(timekeeper.CreateInstant(2021, 1, 2)), - new Entity().Add("page2") - .Add(timekeeper.CreateInstant(2021, 2, 2)), - new Entity().Add("page3") - .Add(timekeeper.CreateInstant(2022, 1, 2)), + new Entity().SetAll((UPath)"/chapter-01.md", new TestModel()), + new Entity().SetAll((UPath)"/chapter-02.md", new TestModel()), + new Entity().SetAll((UPath)"/chapter-03.md", new TestModel()), }; - List?, List?>> actual = - this.GetActual(op, input); + TestModel model = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build() + .Deserialize( + string.Join( + "\n", + "---", + "access: custom", + "schedules:", + " - scheduleStart: 2023-01-01", + " access: public", + "")); + List? schedules = model.Schedules!; - var expected = new List?, List?>> - { - new( - "index-2021-01", - new List { "page1" }, - new List()), - new( - "index-2021-02", - new List { "page2" }, - new List()), - new( - "index-2022-01", - new List { "page3" }, - new List()), - new("page1", null, null), - new("page2", null, null), - new("page3", null, null), - }; + // Create the operation and run it, but treat it as being set after the + // second but before the third item. + Timekeeper time = context.Resolve(); + ApplySchedules op = context.Resolve() + .WithSchedules(schedules); + var now = Instant.FromUtc(2023, 1, 9, 0, 0); - TestHelper.CompareObjects(expected, actual); - } + time.Clock = new FakeClock(now); - [Fact] - public void YearMonthDayIndexes() - { - using TemporalSchedulesTestContext context = this.CreateContext(); - Timekeeper timekeeper = context.Resolve(); - - CreateDateIndexes op = context.Resolve() - .WithFormats("yyyy/MM/dd", "yyyy/MM", "yyyy") - .WithCreateIndex(this.CreateIndex); - - List input = new() - { - new Entity().Add("page1") - .Add(timekeeper.CreateInstant(2021, 1, 2)), - new Entity().Add("page2") - .Add(timekeeper.CreateInstant(2021, 2, 2)), - new Entity().Add("page3") - .Add(timekeeper.CreateInstant(2022, 1, 2)), - }; - - List?, List?>> actual = - this.GetActual(op, input); - - var expected = new List?, List?>> - { - new( - "index-2021", - new List(), - new List { "index-2021/01", "index-2021/02" }), - new( - "index-2021/01", - new List(), - new List { "index-2021/01/02" }), - new( - "index-2021/01/02", - new List { "page1" }, - new List()), - new( - "index-2021/02", - new List(), - new List { "index-2021/02/02" }), - new( - "index-2021/02/02", - new List { "page2" }, - new List()), - new( - "index-2022", - new List(), - new List { "index-2022/01" }), - new( - "index-2022/01", - new List(), - new List { "index-2022/01/02" }), - new( - "index-2022/01/02", - new List { "page3" }, - new List()), - new("page1", null, null), - new("page2", null, null), - new("page3", null, null), - }; - - TestHelper.CompareObjects(expected, actual); - } - - [Fact] - public void YearMonthDayIndexesThreshold1() - { - using TemporalSchedulesTestContext context = this.CreateContext(); - Timekeeper timekeeper = context.Resolve(); - - CreateDateIndexes op = context.Resolve() - .WithFormats("yyyy/MM/dd", "yyyy/MM", "yyyy") - .WithCreateIndex(this.CreateIndex) - .WithLessThanEqualCollapse(1); - - List input = new() - { - new Entity().Add("page1") - .Add(timekeeper.CreateInstant(2021, 1, 2)), - new Entity().Add("page2") - .Add(timekeeper.CreateInstant(2021, 2, 2)), - new Entity().Add("page3") - .Add(timekeeper.CreateInstant(2022, 1, 2)), - }; - - List?, List?>> actual = - this.GetActual(op, input); - - var expected = new List?, List?>> - { - new( - "index-2021", - new List(), - new List { "index-2021/01", "index-2021/02" }), - new( - "index-2021/01", - new List { "page1" }, - new List { "index-2021/01/02" }), - new( - "index-2021/01/02", - new List { "page1" }, - new List()), - new( - "index-2021/02", - new List { "page2" }, - new List { "index-2021/02/02" }), - new( - "index-2021/02/02", - new List { "page2" }, - new List()), - new( - "index-2022", - new List { "page3" }, - new List { "index-2022/01" }), - new( - "index-2022/01", - new List { "page3" }, - new List { "index-2022/01/02" }), - new( - "index-2022/01/02", - new List { "page3" }, - new List()), - new("page1", null, null), - new("page2", null, null), - new("page3", null, null), - }; - - TestHelper.CompareObjects(expected, actual); - } - - [Fact] - public void YearMonthIndexes() - { - using TemporalSchedulesTestContext context = this.CreateContext(); - Timekeeper timekeeper = context.Resolve(); - - CreateDateIndexes op = context.Resolve() - .WithFormats("yyyy-MM", "yyyy") - .WithCreateIndex(this.CreateIndex); - - List input = new() - { - new Entity().Add("page1") - .Add(timekeeper.CreateInstant(2021, 1, 2)), - new Entity().Add("page2") - .Add(timekeeper.CreateInstant(2021, 2, 2)), - new Entity().Add("page3") - .Add(timekeeper.CreateInstant(2022, 1, 2)), - }; - - List?, List?>> actual = - this.GetActual(op, input); - - var expected = new List?, List?>> - { - new( - "index-2021", - new List(), - new List { "index-2021-01", "index-2021-02" }), - new( - "index-2021-01", - new List { "page1" }, - new List()), - new( - "index-2021-02", - new List { "page2" }, - new List()), - new( - "index-2022", - new List(), - new List { "index-2022-01" }), - new( - "index-2022-01", - new List { "page3" }, - new List()), - new("page1", null, null), - new("page2", null, null), - new("page3", null, null), - }; - - TestHelper.CompareObjects(expected, actual); - } - - [Fact] - public void YearOnlyIndexes() - { - using TemporalSchedulesTestContext context = this.CreateContext(); - Timekeeper timekeeper = context.Resolve(); - - CreateDateIndexes op = context.Resolve() - .WithFormats("yyyy") - .WithCreateIndex(this.CreateIndex); - - List input = new() - { - new Entity().Add("page1") - .Add(timekeeper.CreateInstant(2021, 1, 2)), - new Entity().Add("page2") - .Add(timekeeper.CreateInstant(2021, 2, 2)), - new Entity().Add("page3") - .Add(timekeeper.CreateInstant(2022, 1, 2)), - }; - - List?, List?>> actual = - this.GetActual(op, input); - - var expected = new List?, List?>> - { - new( - "index-2021", - new List { "page1", "page2" }, - new List()), - new("index-2022", new List { "page3" }, new List()), - new("page1", null, null), - new("page2", null, null), - new("page3", null, null), - }; - - TestHelper.CompareObjects(expected, actual); - } - - private Entity CreateIndex(DateIndex a) - { - return new Entity().Add(a) - .Add($"index-{a.Key}"); - } - - private List?, List?>> GetActual( - CreateDateIndexes op, - List input) - { - var actual = op.Run(input) + var actual = op + .Run(input) .Select( - x => new Tuple?, List?>( - x.Get(), - x.GetOptional() - ?.Entries.Select(a => a.Get()) - .OrderBy(b => b) - .ToList(), - x.GetOptional() - ?.Indexes.Select(a => a.Get()) - .OrderBy(b => b) - .ToList())) - .OrderBy(x => x.Item1) + a => string.Format( + "{0} -- {1} -- {2}", + a.Get().ToString(), + a.Has() + ? time + .ToDateTime(a.Get()) + .ToString("yyyy-MM-dd") + : "none", + a.Get().Access)) .ToList(); - return actual; + var expected = new List + { + "/chapter-01.md -- 2023-01-01 -- public", + "/chapter-02.md -- 2023-01-08 -- public", + "/chapter-03.md -- none -- private", + }; + + TestHelper.CompareObjects(expected, actual); + } + + [Fact] + public void ManualSetupWorks() + { + using TemporalSchedulesTestContext context = this.CreateContext(); + + // Create a numerical series of entities. + var input = new List + { + new Entity().SetAll((UPath)"/chapter-01.md", new TestModel()), + new Entity().SetAll((UPath)"/chapter-02.md", new TestModel()), + new Entity().SetAll((UPath)"/chapter-03.md", new TestModel()), + }; + + var schedules = new List + { + new() + { + ScheduleStart = DateTime.Parse("2023-01-01"), + Access = "public", + }, + }; + + // Create the operation and run it, but treat it as being set after the + // second but before the third item. + Timekeeper time = context.Resolve(); + ApplySchedules op = context.Resolve() + .WithSchedules(schedules); + var now = Instant.FromUtc(2023, 1, 9, 0, 0); + + time.Clock = new FakeClock(now); + + var actual = op + .Run(input) + .Select( + a => string.Format( + "{0} -- {1} -- {2}", + a.Get().ToString(), + a.Has() + ? time + .ToDateTime(a.Get()) + .ToString("yyyy-MM-dd") + : "none", + a.Get().Access)) + .ToList(); + + var expected = new List + { + "/chapter-01.md -- 2023-01-01 -- public", + "/chapter-02.md -- 2023-01-08 -- public", + "/chapter-03.md -- none -- private", + }; + + TestHelper.CompareObjects(expected, actual); + } + + [Fact] + public void SequencedScheduleWorks() + { + using TemporalSchedulesTestContext context = this.CreateContext(); + + // Create a numerical series of entities. + var input = new List + { + new Entity().SetAll((UPath)"/chapter-01.md", new TestModel()), + new Entity().SetAll((UPath)"/chapter-02.md", new TestModel()), + new Entity().SetAll((UPath)"/chapter-03.md", new TestModel()), + }; + + var schedules = new List + { + new() + { + ScheduleStart = DateTime.Parse("2023-01-01"), + Access = "subscriber", + }, + new() + { + ScheduleStart = DateTime.Parse("2023-01-07"), + Access = "public", + }, + }; + + // Create the operation and run it, but treat it as being set after the + // second but before the third item. + Timekeeper time = context.Resolve(); + ApplySchedules op = context.Resolve() + .WithSchedules(schedules); + var now = Instant.FromUtc(2023, 1, 9, 0, 0); + + time.Clock = new FakeClock(now); + + var actual = op + .Run(input) + .Select( + a => string.Format( + "{0} -- {1} -- {2}", + a.Get().ToString(), + a.Has() + ? time + .ToDateTime(a.Get()) + .ToString("yyyy-MM-dd") + : "none", + a.Get().Access)) + .ToList(); + + var expected = new List + { + "/chapter-01.md -- 2023-01-07 -- public", + "/chapter-02.md -- 2023-01-08 -- subscriber", + "/chapter-03.md -- none -- private", + }; + + TestHelper.CompareObjects(expected, actual); + } + + public class TestModel + { + public string? Access { get; set; } = "private"; + + public List? Schedules { get; set; } + } + + public class TestSchedule : NumericalPathSchedule + { + public TestSchedule() + { + this.SchedulePeriod = SchedulePeriod.Week; + } + + public string? Access { get; set; } + + /// + protected override Entity Apply( + Entity entity, + int number, + Instant instant) + { + TestModel model = entity.Get(); + model.Access = this.Access; + return entity.SetAll(instant, model); + } } } diff --git a/tests/MfGames.Nitride.Temporal.Schedules.Tests/TemporalSchedulesTestBase.cs b/tests/MfGames.Nitride.Temporal.Schedules.Tests/TemporalSchedulesTestBase.cs index a332cb7..8f989d4 100644 --- a/tests/MfGames.Nitride.Temporal.Schedules.Tests/TemporalSchedulesTestBase.cs +++ b/tests/MfGames.Nitride.Temporal.Schedules.Tests/TemporalSchedulesTestBase.cs @@ -4,7 +4,8 @@ using Xunit.Abstractions; namespace MfGames.Nitride.Temporal.Schedules.Tests; -public abstract class TemporalSchedulesTestBase : TestBase +public abstract class TemporalSchedulesTestBase + : TestBase { protected TemporalSchedulesTestBase(ITestOutputHelper output) : base(output) diff --git a/tests/MfGames.Nitride.Temporal.Schedules.Tests/TemporalSchedulesTestContext.cs b/tests/MfGames.Nitride.Temporal.Schedules.Tests/TemporalSchedulesTestContext.cs index 3bc1770..999f6e3 100644 --- a/tests/MfGames.Nitride.Temporal.Schedules.Tests/TemporalSchedulesTestContext.cs +++ b/tests/MfGames.Nitride.Temporal.Schedules.Tests/TemporalSchedulesTestContext.cs @@ -1,16 +1,18 @@ using Autofac; +using MfGames.Nitride.Temporal.Schedules.Setup; using MfGames.Nitride.Temporal.Setup; using MfGames.Nitride.Tests; namespace MfGames.Nitride.Temporal.Schedules.Tests; -public class TemporalTestContext : NitrideTestContext +public class TemporalSchedulesTestContext : NitrideTestContext { /// protected override void ConfigureContainer(ContainerBuilder builder) { base.ConfigureContainer(builder); builder.RegisterModule(); + builder.RegisterModule(); } } diff --git a/tests/MfGames.Nitride.Temporal.Tests/MfGames.Nitride.Temporal.Tests.csproj b/tests/MfGames.Nitride.Temporal.Tests/MfGames.Nitride.Temporal.Tests.csproj index 1e9f90c..b4120aa 100644 --- a/tests/MfGames.Nitride.Temporal.Tests/MfGames.Nitride.Temporal.Tests.csproj +++ b/tests/MfGames.Nitride.Temporal.Tests/MfGames.Nitride.Temporal.Tests.csproj @@ -12,7 +12,7 @@ - + diff --git a/tests/MfGames.Nitride.Temporal.Tests/TemporalTestContext.cs b/tests/MfGames.Nitride.Temporal.Tests/TemporalTestContext.cs index 186502f..5218afa 100644 --- a/tests/MfGames.Nitride.Temporal.Tests/TemporalTestContext.cs +++ b/tests/MfGames.Nitride.Temporal.Tests/TemporalTestContext.cs @@ -1,5 +1,6 @@ using Autofac; +using MfGames.Nitride.Temporal.Setup; using MfGames.Nitride.Tests; namespace MfGames.Nitride.Temporal.Tests;