Compare commits

...

7 commits

Author SHA1 Message Date
D. Moonfire 5a993e85cb fix(html): added validatiors to Autofac module
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2023-01-21 21:49:41 -06:00
D. Moonfire aac4b4373d feat(html): added identify operations
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2023-01-21 21:14:36 -06:00
D. Moonfire 22ddae11f8 fix(temporal): added CurrentInstant and CurrentDateTime to TimeService
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2023-01-21 17:48:39 -06:00
D. Moonfire b32ca7582c fix(schedules): protect against null schedules 2023-01-21 12:13:09 -06:00
D. Moonfire 07eb12414a refactor(temporal)!: renamed Timekeeper to TimeService
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2023-01-21 01:52:52 -06:00
D. Moonfire 82e1bc3c28 feat(schedules)!: reworked schedule names and added a new style
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2023-01-20 23:28:41 -06:00
D. Moonfire 070cf2bfb8 fix(scheduler): added better error messages and regular expression for numerical paths
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2023-01-18 23:51:37 -06:00
40 changed files with 1020 additions and 289 deletions

View file

@ -28,13 +28,13 @@ namespace MfGames.Nitride.Calendar;
[WithProperties]
public partial class CreateCalender : OperationBase
{
private readonly Timekeeper clock;
private readonly TimeService clock;
private readonly IValidator<CreateCalender> validator;
public CreateCalender(
IValidator<CreateCalender> validator,
Timekeeper clock)
TimeService clock)
{
this.validator = validator;
this.clock = clock;

View file

@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.Threading;
using FluentValidation;
using MfGames.Gallium;
using MfGames.Nitride.Contents;
using MfGames.Nitride.Generators;
using Zio;
namespace MfGames.Nitride.Html;
/// <summary>
/// An operation that identifies Markdown files by their common extensions
/// and converts them to text input while also adding the IsMarkdown
/// component to identify them.
/// </summary>
[WithProperties]
public partial class IdentifyHtml : IOperation
{
private readonly IValidator<IdentifyHtml> validator;
public IdentifyHtml(IValidator<IdentifyHtml> validator)
{
this.validator = validator;
}
public Func<Entity, UPath, bool> IsHtmlTest { get; set; } = null!;
/// <inheritdoc />
public IEnumerable<Entity> Run(
IEnumerable<Entity> input,
CancellationToken cancellationToken = default)
{
this.validator.ValidateAndThrow(this);
return input.SelectEntity<UPath, ITextContent>(this.MarkTextEntities)
.SelectEntity<UPath, IBinaryContent>(this.MarkBinaryEntities);
}
private Entity MarkBinaryEntities(
Entity entity,
UPath path,
IBinaryContent binary)
{
// If we aren't a Markdown file, then there is nothing we can do about that.
if (!this.IsHtmlTest(entity, path))
{
return entity;
}
// Convert the file as a binary.
if (binary is ITextContentConvertable textConvertable)
{
entity = entity.SetTextContent(textConvertable.ToTextContent())
.SetIsHtml();
}
else
{
throw new InvalidOperationException(
"Cannot convert a binary content to a text without ITextContentConvertable.");
}
return entity;
}
private Entity MarkTextEntities(
Entity entity,
UPath path,
ITextContent _)
{
return this.IsHtmlTest(entity, path)
? entity.SetIsHtml()
: entity;
}
}

View file

@ -0,0 +1,29 @@
using FluentValidation;
using MfGames.Gallium;
using Zio;
namespace MfGames.Nitride.Html;
public class IdentifyHtmlFromPath : IdentifyHtml
{
public IdentifyHtmlFromPath(IValidator<IdentifyHtml> validator)
: base(validator)
{
this.IsHtmlTest = DefaultIsHtml;
}
private static bool DefaultIsHtml(
Entity entity,
UPath path)
{
return (path.GetExtensionWithDot() ?? string.Empty).ToLowerInvariant()
switch
{
".htm" => true,
".html" => true,
_ => false,
};
}
}

View file

@ -9,7 +9,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MfGames.Nitride\MfGames.Nitride.csproj"/>
<ProjectReference Include="..\MfGames.Nitride\MfGames.Nitride.csproj" />
</ItemGroup>
<!-- Include the source generator -->
@ -25,7 +25,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="MfGames.Gallium" Version="0.4.0"/>
<PackageReference Include="MfGames.Gallium" Version="0.4.0" />
</ItemGroup>
</Project>

View file

@ -8,5 +8,6 @@ public class NitrideHtmlModule : Module
protected override void Load(ContainerBuilder builder)
{
builder.RegisterOperators(this);
builder.RegisterValidators(this);
}
}

View file

@ -0,0 +1,12 @@
using FluentValidation;
namespace MfGames.Nitride.Html.Validators;
public class IdentifyHtmlValidator : AbstractValidator<IdentifyHtml>
{
public IdentifyHtmlValidator()
{
this.RuleFor(x => x.IsHtmlTest)
.NotNull();
}
}

View file

@ -71,16 +71,8 @@ public partial class IdentifyMarkdown : IOperation
UPath path,
ITextContent _)
{
// If we aren't a Markdown file, then there is nothing
// we can do about that.
if (!this.IsMarkdownTest(entity, path))
{
return entity;
}
// We are already text, so just mark it as Markdown.
entity = entity.Set(IsMarkdown.Instance);
return entity;
return this.IsMarkdownTest(entity, path)
? entity.SetIsMarkdown()
: entity;
}
}

View file

@ -10,17 +10,17 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MfGames.Nitride.Gemtext\MfGames.Nitride.Gemtext.csproj"/>
<ProjectReference Include="..\MfGames.Nitride.Html\MfGames.Nitride.Html.csproj"/>
<ProjectReference Include="..\MfGames.Nitride.Slugs\MfGames.Nitride.Slugs.csproj"/>
<ProjectReference Include="..\MfGames.Nitride\MfGames.Nitride.csproj"/>
<ProjectReference Include="..\MfGames.Nitride.Gemtext\MfGames.Nitride.Gemtext.csproj" />
<ProjectReference Include="..\MfGames.Nitride.Html\MfGames.Nitride.Html.csproj" />
<ProjectReference Include="..\MfGames.Nitride.Slugs\MfGames.Nitride.Slugs.csproj" />
<ProjectReference Include="..\MfGames.Nitride\MfGames.Nitride.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MfGames.Gallium" Version="0.4.0"/>
<PackageReference Include="Markdig" Version="0.30.3"/>
<PackageReference Include="MfGames.Markdown.Gemtext" Version="1.2.2"/>
<PackageReference Include="Zio" Version="0.15.0"/>
<PackageReference Include="MfGames.Gallium" Version="0.4.0" />
<PackageReference Include="Markdig" Version="0.30.3" />
<PackageReference Include="MfGames.Markdown.Gemtext" Version="1.2.2" />
<PackageReference Include="Zio" Version="0.15.0" />
</ItemGroup>
<!-- Include the source generator -->

View file

@ -1,6 +1,6 @@
using FluentValidation;
namespace MfGames.Nitride.Markdown;
namespace MfGames.Nitride.Markdown.Validators;
public class ConvertMarkdownToBaseValidator
: AbstractValidator<ConvertMarkdownToBase>

View file

@ -1,6 +1,6 @@
using FluentValidation;
namespace MfGames.Nitride.Markdown;
namespace MfGames.Nitride.Markdown.Validators;
public class IdentifyMarkdownValidator : AbstractValidator<IdentifyMarkdown>
{

View file

@ -20,9 +20,9 @@ public partial class ApplySchedules : OperationBase
public ApplySchedules(
IValidator<ApplySchedules> validator,
Timekeeper timekeeper)
TimeService timeService)
{
this.Timekeeper = timekeeper;
this.TimeService = timeService;
this.validator = validator;
}
@ -35,9 +35,9 @@ public partial class ApplySchedules : OperationBase
public Func<Entity, IList<ISchedule>?>? GetSchedules { get; set; }
/// <summary>
/// Gets or sets the timekeeper associated with this operation.
/// Gets or sets the time service associated with this operation.
/// </summary>
public Timekeeper Timekeeper { get; set; }
public TimeService TimeService { get; set; }
/// <inheritdoc />
public override IEnumerable<Entity> Run(
@ -49,6 +49,15 @@ public partial class ApplySchedules : OperationBase
return input.Select(this.Apply);
}
/// <summary>
/// Adds a single schedule into the apply schedules and returns the ApplySchedule
/// class.
/// </summary>
public ApplySchedules WithGetSchedules(Func<Entity, ISchedule> schedule)
{
return this.WithGetSchedules(entity => new[] { schedule(entity) });
}
public ApplySchedules WithGetSchedules<TType>(
Func<Entity, IEnumerable<TType>?> value)
where TType : ISchedule
@ -74,12 +83,12 @@ public partial class ApplySchedules : OperationBase
// Otherwise, apply the schedules to this entity.
foreach (ISchedule schedule in schedules)
{
if (!schedule.CanApply(entity))
if (schedule == null! || !schedule.CanApply(entity))
{
continue;
}
entity = schedule.Apply(entity, this.Timekeeper);
entity = schedule.Apply(entity, this.TimeService);
}
return entity;

View file

@ -12,14 +12,14 @@ public interface ISchedule
/// Applies the schedule changes to the entity and returns the results.
/// </summary>
/// <param name="entity">The entity to update.</param>
/// <param name="timekeeper">The timekeeper for apply.</param>
/// <param name="timeService">The service to use for time.</param>
/// <returns>
/// The modified entity, if the changes can be applied, otherwise the same
/// entity.
/// </returns>
Entity Apply(
Entity entity,
Timekeeper timekeeper);
TimeService timeService);
/// <summary>
/// Determines if a schedule applies to a given entity.

View file

@ -0,0 +1,57 @@
using System.Collections.Generic;
using System.Linq;
using MfGames.Gallium;
namespace MfGames.Nitride.Temporal.Schedules;
/// <summary>
/// An indexed-based schedule that uses the regular expression to get a
/// numerical value and then uses that to determine the schedule.
/// </summary>
public partial class IndexedPathRegexSchedule<TSchedule> : PathRegexScheduleBase
where TSchedule : IndexedSchedule
{
public IndexedPathRegexSchedule()
{
this.Indexes = new Dictionary<int, TSchedule>();
}
/// <summary>
/// Gets or sets the dictionary that indexes the various numerical indexes
/// to provide the scheduling.
/// </summary>
public Dictionary<int, TSchedule> Indexes { get; set; }
public IndexedPathRegexSchedule<TSchedule> WithIndexes(
Dictionary<int, TSchedule> value)
{
this.Indexes = value;
return this;
}
/// <inheritdoc />
protected override Entity Apply(
Entity entity,
int number,
TimeService timeService)
{
// Figure out the entry in the index.
var applicableKeys = this.Indexes.Keys
.Where(a => a <= number)
.ToList();
if (applicableKeys.Count == 0)
{
return entity;
}
int startIndex = applicableKeys.Max(a => a);
TSchedule schedule = this.Indexes[startIndex];
// Pass everything into the schedule to perform the applying.
int startOffset = number - startIndex;
return schedule.Apply(entity, startOffset, timeService);
}
}

View file

@ -0,0 +1,79 @@
using System;
using MfGames.Gallium;
using NodaTime;
namespace MfGames.Nitride.Temporal.Schedules;
/// <summary>
/// Describes a simplified schedule object that contains a start and period
/// for the schedule and is designed to work with the numbers provided by
/// the <see cref="IndexedPathRegexSchedule" />.
/// </summary>
public partial class IndexedSchedule
{
/// <summary>
/// Gets or sets the period between each matching entity. The period is
/// amount of time between each item based on the number. So the first will
/// be on the ScheduleDate, the second SchedulePeriod later, the third
/// SchedulePeriod after the second, etc. More precisely, the date for
/// any item TimeSpan * ((int)CaptureGroup + (int)CaptureOffset).
/// </summary>
public string? SchedulePeriod { get; set; }
/// <summary>
/// Gets or sets the schedule period as a TimeSpan object. This is converted
/// from SchedulePeriod using https://github.com/pengowray/TimeSpanParser.
/// If the value is null or blank or "immediate", then this will be instant
/// (TimeSpan.Zero).
/// </summary>
public virtual TimeSpan? SchedulePeriodTimeSpan
{
get => TimeSpanHelper.Parse(this.SchedulePeriod);
set => this.SchedulePeriod = value?.ToString();
}
/// <summary>
/// Gets or sets when the first item is scheduled.
/// </summary>
public DateTime? ScheduleStart { get; set; }
public virtual Entity Apply(
Entity entity,
int number,
TimeService timeService)
{
// If we have a "never", then we skip it.
TimeSpan? period = this.SchedulePeriodTimeSpan;
DateTime? start = this.ScheduleStart;
if (period == null || start == null)
{
return entity;
}
// Figure out the time from the start.
DateTime when = start.Value + period.Value * number;
Instant instant = timeService.CreateInstant(when);
// If the time hasn't past, then we don't apply it.
Instant now = timeService.Clock.GetCurrentInstant();
return instant > now
? entity
: this.Apply(entity, number, instant);
}
/// <summary>
/// Applies the schedule to the entity based on the number and instant
/// given.
/// </summary>
protected virtual Entity Apply(
Entity entity,
int number,
Instant instant)
{
return entity.Set(instant);
}
}

View file

@ -1,141 +0,0 @@
using System;
using System.Text.RegularExpressions;
using MfGames.Gallium;
using MfGames.Nitride.Generators;
using NodaTime;
using TimeSpanParserUtil;
using Zio;
namespace MfGames.Nitride.Temporal.Schedules;
/// <summary>
/// A schedule that uses the `UPath` of the entity to determine an offset from
/// a starting point and calculates the information from there.
/// </summary>
[WithProperties]
public partial class NumericalPathSchedule : ISchedule
{
public NumericalPathSchedule()
{
this.PathRegex = @"^.*(\d+)";
this.GetPath = entity => entity.Get<UPath>().ToString();
this.CaptureGroup = 1;
this.CaptureOffset = -1;
}
/// <summary>
/// Gets or sets the group number of the capture group with 1 being being the
/// first group in PathRegex.
/// </summary>
public int CaptureGroup { get; set; }
/// <summary>
/// Gets or sets the offset to make the resulting capture group a zero-based
/// number.
/// </summary>
public int CaptureOffset { get; set; }
/// <summary>
/// Gets or sets the method for retrieving the path for the entity.
/// </summary>
public Func<Entity, string> GetPath { get; set; }
/// <summary>
/// Gets or sets the regular expression to identify a matching path.
/// </summary>
public string? PathRegex { get; set; }
/// <summary>
/// Gets or sets the period between each matching entity. The period is
/// amount of time between each item based on the number. So the first will
/// be on the ScheduleDate, the second SchedulePeriod later, the third
/// SchedulePeriod after the second, etc. More precisely, the date for
/// any item TimeSpan * ((int)CaptureGroup + (int)CaptureOffset).
/// </summary>
public string? SchedulePeriod { get; set; }
/// <summary>
/// Gets or sets the schedule period as a TimeSpan object. This is converted
/// from SchedulePeriod using https://github.com/pengowray/TimeSpanParser.
/// If the value is null, then this will be instant (TimeSpan.Zero).
/// </summary>
public virtual TimeSpan SchedulePeriodTimeSpan
{
get => this.SchedulePeriod == null
? TimeSpan.Zero
: TimeSpanParser.Parse(this.SchedulePeriod);
set => this.SchedulePeriod = value.ToString();
}
/// <summary>
/// Gets or sets when the first item is scheduled.
/// </summary>
public DateTime? ScheduleStart { get; set; }
/// <inheritdoc />
public virtual Entity Apply(
Entity entity,
Timekeeper timekeeper)
{
// Get the path and match it.
string? path = this.GetPath(entity);
Regex? regex = this.GetRegex();
Match? match = regex?.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.
DateTime start = this.ScheduleStart
?? throw new NullReferenceException(
"Cannot use a schedule without a start date.");
DateTime when = start + this.SchedulePeriodTimeSpan * 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);
}
/// <inheritdoc />
public virtual bool CanApply(Entity entity)
{
string path = this.GetPath(entity);
Regex? regex = this.GetRegex();
bool match = regex?.IsMatch(path) ?? false;
return match;
}
public Regex? GetRegex()
{
return this.PathRegex == null
? null
: new Regex(this.PathRegex);
}
protected virtual Entity Apply(
Entity entity,
int number,
Instant instant)
{
return entity.Set(instant);
}
}

View file

@ -0,0 +1,118 @@
using System;
using System.Text.RegularExpressions;
using MfGames.Gallium;
using MfGames.Nitride.Generators;
using Zio;
namespace MfGames.Nitride.Temporal.Schedules;
/// <summary>
/// A common base for a schedule that is built off the path associated with
/// the entity.
/// </summary>
[WithProperties]
public abstract partial class PathRegexScheduleBase : ISchedule
{
protected PathRegexScheduleBase()
{
this.PathRegex = @"^.*?(\d+)[^\d]*$";
this.GetPath = entity => entity.Get<UPath>().ToString();
this.CaptureGroup = 1;
}
/// <summary>
/// Gets or sets the group number of the capture group with 1 being being the
/// first group in PathRegex.
/// </summary>
public int CaptureGroup { get; set; }
/// <summary>
/// Gets or sets the offset to make the resulting capture group a zero-based
/// number.
/// </summary>
public int CaptureOffset { get; set; }
/// <summary>
/// Gets or sets the method for retrieving the path for the entity.
/// </summary>
public Func<Entity, string> GetPath { get; set; }
/// <summary>
/// Gets or sets the regular expression to identify a matching path.
/// </summary>
public string? PathRegex { get; set; }
/// <inheritdoc />
public virtual Entity Apply(
Entity entity,
TimeService timeService)
{
// Get the path and match it.
string path = this.GetPath(entity);
Regex? regex = this.GetRegex();
Match match = regex?.Match(path)
?? throw new NullReferenceException(
"PathRegex was not configured for the "
+ this.GetType().Name
+ ".");
if (!match.Success)
{
return entity;
}
if (match.Groups.Count < 2)
{
throw new InvalidOperationException(
"There must be at least one capture group in '"
+ this.PathRegex
+ "'.");
}
// Figure out the index/number of this entry.
string numberValue = match.Groups[this.CaptureGroup].Value;
if (!int.TryParse(numberValue, out int number))
{
throw new FormatException(
path + ": Cannot parse '" + numberValue + "' as integer.");
}
number += this.CaptureOffset;
// Pass it onto the extending class.
return this.Apply(entity, number, timeService);
}
/// <inheritdoc />
public virtual bool CanApply(Entity entity)
{
string path = this.GetPath(entity);
Regex? regex = this.GetRegex();
Match match = regex?.Match(path)
?? throw new NullReferenceException(
"PathRegex was not configured for the "
+ this.GetType().Name
+ ".");
return match.Success;
}
/// <summary>
/// Applies the schedule according to the normalized number (after
/// CaptureOffset).
/// </summary>
protected abstract Entity Apply(
Entity entity,
int number,
TimeService timeService);
private Regex? GetRegex()
{
return this.PathRegex == null
? null
: new Regex(this.PathRegex);
}
}

View file

@ -0,0 +1,86 @@
using System;
using MfGames.Gallium;
using MfGames.Nitride.Generators;
using NodaTime;
namespace MfGames.Nitride.Temporal.Schedules;
/// <summary>
/// A schedule that uses the `UPath` of the entity to determine an offset from
/// a starting point and calculates the information from there.
/// </summary>
[WithProperties]
public partial class PeriodicPathRegexSchedule : PathRegexScheduleBase
{
public PeriodicPathRegexSchedule()
{
this.CaptureOffset = -1;
}
/// <summary>
/// Gets or sets the period between each matching entity. The period is
/// amount of time between each item based on the number. So the first will
/// be on the ScheduleDate, the second SchedulePeriod later, the third
/// SchedulePeriod after the second, etc. More precisely, the date for
/// any item TimeSpan * ((int)CaptureGroup + (int)CaptureOffset).
/// </summary>
public string? SchedulePeriod { get; set; }
/// <summary>
/// Gets or sets the schedule period as a TimeSpan object. This is converted
/// from SchedulePeriod using https://github.com/pengowray/TimeSpanParser.
/// If the value is null or blank or "immediate", then this will be instant
/// (TimeSpan.Zero).
/// </summary>
public virtual TimeSpan? SchedulePeriodTimeSpan
{
get => TimeSpanHelper.Parse(this.SchedulePeriod);
set => this.SchedulePeriod = value?.ToString();
}
/// <summary>
/// Gets or sets when the first item is scheduled.
/// </summary>
public DateTime? ScheduleStart { get; set; }
/// <inheritdoc />
protected override Entity Apply(
Entity entity,
int number,
TimeService timeService)
{
// If we have a "never", then we skip it.
TimeSpan? period = this.SchedulePeriodTimeSpan;
DateTime? start = this.ScheduleStart;
if (period == null || start == null)
{
return entity;
}
// Figure out the time from the start.
DateTime when = start.Value + period.Value * number;
Instant instant = timeService.CreateInstant(when);
// If the time hasn't past, then we don't apply it.
Instant now = timeService.Clock.GetCurrentInstant();
return instant > now
? entity
: this.Apply(entity, number, instant);
}
/// <summary>
/// Applies the schedule to the entity based on the number and instant
/// given.
/// </summary>
protected virtual Entity Apply(
Entity entity,
int number,
Instant instant)
{
return entity.Set(instant);
}
}

View file

@ -10,7 +10,7 @@ date (as provided by the `MfGames.Nitride.Temporal` package).
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`.
the date based on `TimeService`.
## Configuring
@ -53,18 +53,32 @@ The following properties are available (along with their corresponding `With*` m
- `IList<ISchedule> Schedules`
- Contains the list of schedules to apply against entities.
- Defaults to an empty list.
- `Timekeeper Timekeeper`
- `TimeService TimeService`
- Used to determine when the site is being generated.
- Defaults to the one provided by `MfGames.Nitride.Temporal`.
### Numerical Path-Based Schedules
## Provided Schedules
A number of schedules are provided as part of this library.
### Schedule Periods
In all cases, the `SchedulePeriod` is a string that parses into a `TimeSpan` object
that determines the amount of time between two successive entities of the same
schedule. It is parsed using [TimeSpanParser](https://github.com/pengowray/TimeSpanParser)
and allow for `TimeSpan` formatting (such as "1.00:00:00"), descriptions such as
"4 days" or "1 month". In addition, it also handles `instant` (non-case sensitive)
for a zero length time or effectively all at once or `never` (also not case sensitive)
to never apply to it.
### Periodic Path Regex 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
The `PeriodicPathRegexSchedule` 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.
@ -81,7 +95,7 @@ var entities = new List<Entity>
};
var schedules = new List<ISchedule>
{
new NumericalPathSchedule
new PeriodicPathRegexSchedule
{
PathRegex = "chapter-(\d+),
ScheduleStart = DateTime.Parse("2023-01-01"),
@ -96,14 +110,14 @@ 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:
`PeriodicPathRegexSchedule` has the following properties:
- `Func<Entity, string> GetPath`
- An override to allow retrieving a different function.
- Defaults to `entity.Get<UPath>().ToString()`
- `Regex PathRegex`
- The regular expression that retrieves the number.
- Defaults to `^.*(\d+)` which grabs the last number found.
- Defaults to `^.*?(\d+)[^\d]*$` which grabs the last number found.
- `DateTime ScheduleStart`
- The date that the schedule starts.
- No default.
@ -127,7 +141,7 @@ There is also a virtual method for applying the schedule.
#### Overriding Logic
The default operation of a `NumericalPathSchedule` is to only set the `Instant`
The default operation of a `PeriodicPathRegexSchedule` 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.
@ -168,14 +182,54 @@ 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
- pathRegex: chapters/chapter-(\d+)
scheduleStart: 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
scheduleStart: 2030-01-01
access: public
```
### Indexed Path Regex Schedules
A variant on the periodic schedule is the `IndexedPathRegexSchedule`. This evolved
from the periodic one in order to simplify the regular expressions at the expense
of having a more complex structure. It uses the same regular expression as
`PeriodicPathRegexSchedule` including `CaptureGroup` (still defaults to 1) and
`CaptureOffset` (which defaults to 0) but instead of having the schedule date
and period, those are relegated to a `Dictionary<int, IndexedSchedule>` (or a
class that extends `IndexedSchedule`).
Using the below example:
```yaml
schedules:
pathRegex: chapters/chapter-(\d+)
indexes:
1:
scheduleStart: 2025-01-01
schedulePeriod: instant
access: subscribers
10:
scheduleStart: 2030-01-01
schedulePeriod: 1 week
access: subscribers
30:
schedulePeriod: never
```
For a given calculated number (such as `chapter-01` being `1`), the schedule picks the
highest index that is not replaced by a higher number. So, chapters 1-9 would use the 2025
date and be instantly available on that date while chapters 10 and higher would use the
2030 date and be doled out a week at a time.
While this example doesn't use the top level as a sequence, it could be used to handle
multiple overlapping schedules (like the first where two changes), but it is more
verbose while describing it.
For the inner schedule, the number is normalized to be 0-based automatically for each one
so the above example would have 1, 10, and 30 all pass in `0` into the `Apply` function.

View file

@ -21,15 +21,15 @@ public partial class SimplePathSchedule : ISchedule
/// <inheritdoc />
public virtual Entity Apply(
Entity entity,
Timekeeper timekeeper)
TimeService timeService)
{
DateTime start = this.ScheduleStart
?? throw new NullReferenceException(
"Cannot use a schedule without a start date.");
Instant instant = timekeeper.CreateInstant(start);
Instant instant = timeService.CreateInstant(start);
// If the time hasn't past, then we don't apply it.
Instant now = timekeeper.Clock.GetCurrentInstant();
Instant now = timeService.Clock.GetCurrentInstant();
return instant > now
? entity

View file

@ -0,0 +1,35 @@
using System;
using TimeSpanParserUtil;
namespace MfGames.Nitride.Temporal.Schedules;
public static class TimeSpanHelper
{
public static TimeSpan? Parse(string? input)
{
if (string.IsNullOrWhiteSpace(input))
{
return null;
}
if (input.Equals(
"immediate",
StringComparison.InvariantCultureIgnoreCase)
|| input.Equals(
"instant",
StringComparison.InvariantCultureIgnoreCase))
{
return TimeSpan.Zero;
}
if (input.Equals(
"never",
StringComparison.InvariantCultureIgnoreCase))
{
return null;
}
return TimeSpanParser.Parse(input);
}
}

View file

@ -9,7 +9,7 @@ public class ApplySchedulesValidator : AbstractValidator<ApplySchedules>
this.RuleFor(x => x.GetSchedules)
.NotNull();
this.RuleFor(x => x.Timekeeper)
this.RuleFor(x => x.TimeService)
.NotNull();
}
}

View file

@ -20,14 +20,14 @@ public class DatePipelineCommandOption : IPipelineCommandOption
{
private readonly ILogger logger;
private readonly Timekeeper timekeeper;
private readonly TimeService timeService;
public DatePipelineCommandOption(
ILogger logger,
Timekeeper timekeeper)
TimeService timeService)
{
this.logger = logger.ForContext<Instant>();
this.timekeeper = timekeeper;
this.timeService = timeService;
this.Option = new Option<DateTime>("--date")
{
@ -53,15 +53,15 @@ public class DatePipelineCommandOption : IPipelineCommandOption
// date for the entire run.
var local = LocalDateTime.FromDateTime(value.Value);
ZonedDateTime zoned =
local.InZoneStrictly(this.timekeeper.DateTimeZone);
local.InZoneStrictly(this.timeService.DateTimeZone);
var instant = zoned.ToInstant();
this.timekeeper.Clock = new FakeClock(instant);
this.timeService.Clock = new FakeClock(instant);
}
// Report the date we are processing.
Instant now = this.timekeeper.Clock.GetCurrentInstant();
ZonedDateTime dateTime = now.InZone(this.timekeeper.DateTimeZone);
Instant now = this.timeService.Clock.GetCurrentInstant();
ZonedDateTime dateTime = now.InZone(this.timeService.DateTimeZone);
string formatted = dateTime.ToString("G", CultureInfo.InvariantCulture);
this.logger.Information("Setting date/time to {When:l}", formatted);

View file

@ -18,13 +18,13 @@ namespace MfGames.Nitride.Temporal.Cli;
/// </summary>
public class ExpiresPipelineCommandOption : IPipelineCommandOption
{
private readonly Timekeeper clock;
private readonly TimeService clock;
private readonly ILogger logger;
public ExpiresPipelineCommandOption(
ILogger logger,
Timekeeper clock,
TimeService clock,
string? defaultValue = null)
{
this.logger = logger.ForContext<Instant>();

View file

@ -25,10 +25,10 @@ public partial class CreateDateIndexes : OperationBase, IResolvingOperation
public CreateDateIndexes(
IValidator<CreateDateIndexes> validator,
Timekeeper timekeeper)
TimeService timeService)
{
this.validator = validator;
this.Timekeeper = timekeeper;
this.TimeService = timeService;
}
/// <summary>
@ -51,7 +51,7 @@ public partial class CreateDateIndexes : OperationBase, IResolvingOperation
/// </summary>
public int LessThanEqualCollapse { get; set; }
public Timekeeper Timekeeper { get; }
public TimeService TimeService { get; }
/// <inheritdoc />
public override IEnumerable<Entity> Run(
@ -136,7 +136,7 @@ public partial class CreateDateIndexes : OperationBase, IResolvingOperation
Entity? first = pair.Value[0];
string? nextKey = this.Timekeeper
string? nextKey = this.TimeService
.ToDateTime(first.Get<Instant>())
.ToString(this.Formats[i + 1]);
@ -166,7 +166,7 @@ public partial class CreateDateIndexes : OperationBase, IResolvingOperation
List<Dictionary<string, List<Entity>>> grouped,
Entity entity)
{
var dateTime = this.Timekeeper.ToDateTime(instant);
var dateTime = this.TimeService.ToDateTime(instant);
for (int i = 0; i < this.Formats.Count; i++)
{

View file

@ -20,13 +20,13 @@ public partial class FilterOutExpiredInstant : OperationBase
public FilterOutExpiredInstant(
IValidator<FilterOutExpiredInstant> validator,
Timekeeper clock)
TimeService clock)
{
this.validator = validator;
this.Timekeeper = clock;
this.TimeService = clock;
}
public Timekeeper Timekeeper { get; set; }
public TimeService TimeService { get; set; }
/// <inheritdoc />
public override IEnumerable<Entity> Run(
@ -35,7 +35,7 @@ public partial class FilterOutExpiredInstant : OperationBase
{
this.validator.ValidateAndThrow(this);
if (!this.Timekeeper.Expiration.HasValue)
if (!this.TimeService.Expiration.HasValue)
{
return input;
}
@ -48,7 +48,7 @@ public partial class FilterOutExpiredInstant : OperationBase
Instant instant,
CanExpire _)
{
Instant expiration = this.Timekeeper.Expiration!.Value;
Instant expiration = this.TimeService.Expiration!.Value;
bool isExpired = instant.CompareTo(expiration) < 0;
return isExpired ? null : entity;

View file

@ -15,19 +15,19 @@ namespace MfGames.Nitride.Temporal;
[WithProperties]
public partial class FilterOutFutureInstant : OperationBase
{
public FilterOutFutureInstant(Timekeeper timekeeper)
public FilterOutFutureInstant(TimeService timeService)
{
this.Timekeeper = timekeeper;
this.TimeService = timeService;
}
public Timekeeper Timekeeper { get; set; }
public TimeService TimeService { get; set; }
/// <inheritdoc />
public override IEnumerable<Entity> Run(
IEnumerable<Entity> input,
CancellationToken cancellationToken = default)
{
Instant now = this.Timekeeper.Clock.GetCurrentInstant();
Instant now = this.TimeService.Clock.GetCurrentInstant();
return input
.SelectEntity<Instant>(

View file

@ -10,18 +10,18 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Autofac" Version="6.4.0"/>
<PackageReference Include="MfGames.Gallium" Version="0.4.0"/>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="6.0.0"/>
<PackageReference Include="NodaTime" Version="3.1.2"/>
<PackageReference Include="NodaTime.Testing" Version="3.1.2"/>
<PackageReference Include="Serilog" Version="2.11.0"/>
<PackageReference Include="TimeSpanParserUtil" Version="1.2.0"/>
<PackageReference Include="Zio" Version="0.15.0"/>
<PackageReference Include="Autofac" Version="6.4.0" />
<PackageReference Include="MfGames.Gallium" Version="0.4.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="6.0.0" />
<PackageReference Include="NodaTime" Version="3.1.2" />
<PackageReference Include="NodaTime.Testing" Version="3.1.2" />
<PackageReference Include="Serilog" Version="2.11.0" />
<PackageReference Include="TimeSpanParserUtil" Version="1.2.0" />
<PackageReference Include="Zio" Version="0.15.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MfGames.Nitride\MfGames.Nitride.csproj"/>
<ProjectReference Include="..\MfGames.Nitride\MfGames.Nitride.csproj" />
</ItemGroup>
<!-- Include the source generator -->

View file

@ -17,11 +17,11 @@ namespace MfGames.Nitride.Temporal;
/// </summary>
public class SetInstantFromComponent<TComponent> : OperationBase
{
private readonly Timekeeper clock;
private readonly TimeService clock;
private readonly IValidator<SetInstantFromComponent<TComponent>> validator;
public SetInstantFromComponent(Timekeeper clock)
public SetInstantFromComponent(TimeService clock)
{
// TODO: Figure out why Autofac won't let us register IValidator of generic classes.
this.validator = new SetInstantFromComponentValidator<TComponent>();
@ -73,6 +73,7 @@ public class SetInstantFromComponent<TComponent> : OperationBase
{
case null:
return entity;
case Instant direct:
instant = direct;

View file

@ -23,13 +23,13 @@ namespace MfGames.Nitride.Temporal;
[WithProperties]
public partial class SetInstantFromPath : OperationBase
{
private readonly Timekeeper clock;
private readonly TimeService clock;
private readonly IValidator<SetInstantFromPath> validator;
public SetInstantFromPath(
IValidator<SetInstantFromPath> validator,
Timekeeper clock)
TimeService clock)
{
this.validator = validator;
this.clock = clock;
@ -67,15 +67,9 @@ public partial class SetInstantFromPath : OperationBase
// Create an Instant from this.
Instant instant = this.clock.CreateInstant(
Convert.ToInt32(
match.Groups["year"]
.Value),
Convert.ToInt32(
match.Groups["month"]
.Value),
Convert.ToInt32(
match.Groups["day"]
.Value));
Convert.ToInt32(match.Groups["year"].Value),
Convert.ToInt32(match.Groups["month"].Value),
Convert.ToInt32(match.Groups["day"].Value));
return entity.Set(instant);
}

View file

@ -45,8 +45,8 @@ public static class NitrideTemporalBuilderExtensions
context =>
{
ILogger logger = context.Resolve<ILogger>();
Timekeeper
clock = context.Resolve<Timekeeper>();
TimeService
clock = context.Resolve<TimeService>();
return new ExpiresPipelineCommandOption(
logger,
@ -65,12 +65,12 @@ public static class NitrideTemporalBuilderExtensions
scope) =>
{
ILogger logger = scope.Resolve<ILogger>();
Timekeeper timekeeper = scope.Resolve<Timekeeper>();
TimeService timeService = scope.Resolve<TimeService>();
timekeeper.DateTimeZone = config.DateTimeZone;
timeService.DateTimeZone = config.DateTimeZone;
logger.Verbose(
"Setting time zone to {Zone:l}",
timekeeper.DateTimeZone);
timeService.DateTimeZone);
});
}

View file

@ -10,7 +10,7 @@ public class NitrideTemporalModule : Module
builder.RegisterOperators(this);
builder.RegisterValidators(this);
builder.RegisterType<Timekeeper>()
builder.RegisterType<TimeService>()
.AsSelf()
.SingleInstance();

View file

@ -10,9 +10,9 @@ namespace MfGames.Nitride.Temporal;
/// the desire time zone along with various methods for processing parsed
/// DateTime objects into NodaTime.Instant.
/// </summary>
public class Timekeeper
public class TimeService
{
public Timekeeper()
public TimeService()
{
// We use FakeClock because we don't want time to advance in the
// middle of running, just in case we are just a few seconds before
@ -28,6 +28,16 @@ public class Timekeeper
/// </summary>
public IClock Clock { get; set; }
/// <summary>
/// Gets the current date time.
/// </summary>
public DateTime CurrentDateTime => this.ToDateTime(this.CurrentInstant);
/// <summary>
/// Gets the current instant.
/// </summary>
public Instant CurrentInstant => this.Clock.GetCurrentInstant();
/// <summary>
/// Gets or sets the date time zone used for processing.
/// </summary>

View file

@ -6,7 +6,7 @@ public class CreateDateIndexesValidator : AbstractValidator<CreateDateIndexes>
{
public CreateDateIndexesValidator()
{
this.RuleFor(a => a.Timekeeper)
this.RuleFor(a => a.TimeService)
.NotNull();
this.RuleFor(a => a.CreateIndex)

View file

@ -7,7 +7,7 @@ public class FilterOutExpiredInstantValidator
{
public FilterOutExpiredInstantValidator()
{
this.RuleFor(x => x.Timekeeper)
this.RuleFor(x => x.TimeService)
.NotNull();
}
}

View file

@ -7,7 +7,7 @@ public class FilterOutFutureInstantValidator
{
public FilterOutFutureInstantValidator()
{
this.RuleFor(x => x.Timekeeper)
this.RuleFor(x => x.TimeService)
.NotNull();
}
}

View file

@ -0,0 +1,260 @@
using System;
using System.Collections.Generic;
using System.Linq;
using MfGames.Gallium;
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 IndexedPathRegexScheduleTest : TemporalSchedulesTestBase
{
public IndexedPathRegexScheduleTest(ITestOutputHelper output)
: base(output)
{
}
[Fact]
public void DeserializedSetupWorks()
{
using TemporalSchedulesTestContext context = this.CreateContext();
// Create a numerical series of entities.
var input = new List<Entity>
{
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()),
};
TestModel model = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build()
.Deserialize<TestModel>(
string.Join(
"\n",
"---",
"access: custom",
"schedules:",
" pathRegex: chapter-(\\d+)",
" indexes:",
" 1:",
" scheduleStart: 2020-01-01",
" schedulePeriod: instant",
" access: t-1",
" 2:",
" scheduleStart: 2023-01-02",
" schedulePeriod: 1 week",
" access: t-2",
""));
var schedules = model.Schedules!;
// Create the operation and run it, but treat it as being set after the
// second but before the third item.
TimeService time = context.Resolve<TimeService>();
ApplySchedules op = context.Resolve<ApplySchedules>()
.WithGetSchedules(_ => new ISchedule[] { schedules });
var now = Instant.FromUtc(2023, 1, 3, 0, 0);
time.Clock = new FakeClock(now);
var actual = op
.Run(input)
.Select(
a => string.Format(
"{0} -- {1} -- {2}",
a.Get<UPath>().ToString(),
a.Has<Instant>()
? time
.ToDateTime(a.Get<Instant>())
.ToString("yyyy-MM-dd")
: "none",
a.Get<TestModel>().Access))
.ToList();
var expected = new List<string>
{
"/chapter-01.md -- 2020-01-01 -- t-1",
"/chapter-02.md -- 2023-01-02 -- t-2",
"/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<Entity>
{
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 TestRegexSchedule
{
Indexes = new Dictionary<int, TestSchedule>
{
[1] = new TestSchedule
{
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.
TimeService time = context.Resolve<TimeService>();
ApplySchedules op = context.Resolve<ApplySchedules>()
.WithGetSchedules(_ => new ISchedule[] { 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<UPath>().ToString(),
a.Has<Instant>()
? time
.ToDateTime(a.Get<Instant>())
.ToString("yyyy-MM-dd")
: "none",
a.Get<TestModel>().Access))
.ToList();
var expected = new List<string>
{
"/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<Entity>
{
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()),
new Entity().SetAll((UPath)"/chapter-04.md", new TestModel()),
new Entity().SetAll((UPath)"/chapter-05.md", new TestModel()),
new Entity().SetAll((UPath)"/chapter-06.md", new TestModel()),
};
var schedules = new TestRegexSchedule()
{
Indexes = new Dictionary<int, TestSchedule>
{
[1] = new()
{
ScheduleStart = DateTime.Parse("2020-01-01"),
Access = "subscriber",
SchedulePeriodTimeSpan = TimeSpan.FromDays(7),
},
[3] = new()
{
ScheduleStart = DateTime.Parse("2023-01-07"),
Access = "public",
SchedulePeriodTimeSpan = TimeSpan.Zero,
},
[5] = new()
{
SchedulePeriod = "never",
},
},
};
// Create the operation and run it, but treat it as being set after the
// second but before the third item.
TimeService time = context.Resolve<TimeService>();
ApplySchedules op = context.Resolve<ApplySchedules>()
.WithGetSchedules(_ => new ISchedule[] { 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<UPath>().ToString(),
a.Has<Instant>()
? time
.ToDateTime(a.Get<Instant>())
.ToString("yyyy-MM-dd")
: "none",
a.Get<TestModel>().Access))
.ToList();
var expected = new List<string>
{
"/chapter-01.md -- 2020-01-01 -- subscriber",
"/chapter-02.md -- 2020-01-08 -- subscriber",
"/chapter-03.md -- 2023-01-07 -- public",
"/chapter-04.md -- 2023-01-07 -- public",
"/chapter-05.md -- none -- private",
"/chapter-06.md -- none -- private",
};
TestHelper.CompareObjects(expected, actual);
}
public class TestModel
{
public string? Access { get; set; } = "private";
public TestRegexSchedule? Schedules { get; set; }
}
public class TestRegexSchedule : IndexedPathRegexSchedule<TestSchedule>
{
}
public class TestSchedule : IndexedSchedule
{
public TestSchedule()
{
this.SchedulePeriod = "1 week";
}
public string? Access { get; set; }
/// <inheritdoc />
protected override Entity Apply(
Entity entity,
int number,
Instant instant)
{
TestModel model = entity.Get<TestModel>();
model.Access = this.Access;
return entity.SetAll(instant, model);
}
}
}

View file

@ -6,17 +6,17 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MfGames.Nitride.Temporal.Schedules\MfGames.Nitride.Temporal.Schedules.csproj"/>
<ProjectReference Include="..\MfGames.Nitride.Tests\MfGames.Nitride.Tests.csproj"/>
<ProjectReference Include="..\..\src\MfGames.Nitride.Temporal.Schedules\MfGames.Nitride.Temporal.Schedules.csproj" />
<ProjectReference Include="..\MfGames.Nitride.Tests\MfGames.Nitride.Tests.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CompareNETObjects" Version="4.78.0"/>
<PackageReference Include="MfGames.Gallium" Version="0.4.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.1"/>
<PackageReference Include="JunitXml.TestLogger" Version="3.0.114"/>
<PackageReference Include="Newtonsoft.Json" Version="13.0.2"/>
<PackageReference Include="xunit" Version="2.4.2"/>
<PackageReference Include="CompareNETObjects" Version="4.78.0" />
<PackageReference Include="MfGames.Gallium" Version="0.4.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.1" />
<PackageReference Include="JunitXml.TestLogger" Version="3.0.114" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.2" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@ -25,7 +25,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="YamlDotNet" Version="12.0.0"/>
<PackageReference Include="YamlDotNet" Version="12.0.0" />
</ItemGroup>
</Project>

View file

@ -18,9 +18,9 @@ using Zio;
namespace MfGames.Nitride.Temporal.Schedules.Tests;
public class NumericalPathScheduleTests : TemporalSchedulesTestBase
public class PeriodicPathRegexScheduleTest : TemporalSchedulesTestBase
{
public NumericalPathScheduleTests(ITestOutputHelper output)
public PeriodicPathRegexScheduleTest(ITestOutputHelper output)
: base(output)
{
}
@ -49,13 +49,14 @@ public class NumericalPathScheduleTests : TemporalSchedulesTestBase
"schedules:",
" - pathRegex: chapter-(\\d+)",
" scheduleStart: 2023-01-01",
" schedulePeriod: 1 week",
" access: public",
""));
List<TestSchedule>? schedules = model.Schedules!;
List<TestRegexSchedule>? schedules = model.Schedules!;
// Create the operation and run it, but treat it as being set after the
// second but before the third item.
Timekeeper time = context.Resolve<Timekeeper>();
TimeService time = context.Resolve<TimeService>();
ApplySchedules op = context.Resolve<ApplySchedules>()
.WithGetSchedules(_ => schedules);
var now = Instant.FromUtc(2023, 1, 9, 0, 0);
@ -99,7 +100,7 @@ public class NumericalPathScheduleTests : TemporalSchedulesTestBase
new Entity().SetAll((UPath)"/chapter-03.md", new TestModel()),
};
var schedules = new List<TestSchedule>
var schedules = new List<TestRegexSchedule>
{
new()
{
@ -110,7 +111,7 @@ public class NumericalPathScheduleTests : TemporalSchedulesTestBase
// Create the operation and run it, but treat it as being set after the
// second but before the third item.
Timekeeper time = context.Resolve<Timekeeper>();
TimeService time = context.Resolve<TimeService>();
ApplySchedules op = context.Resolve<ApplySchedules>()
.WithGetSchedules(_ => schedules);
var now = Instant.FromUtc(2023, 1, 9, 0, 0);
@ -141,6 +142,62 @@ public class NumericalPathScheduleTests : TemporalSchedulesTestBase
TestHelper.CompareObjects(expected, actual);
}
[Fact]
public void ScheduleOffsetWorks()
{
using TemporalSchedulesTestContext context = this.CreateContext();
// Create a numerical series of entities.
var input = new List<Entity>
{
new Entity().SetAll((UPath)"/chapter-11.md", new TestModel()),
new Entity().SetAll((UPath)"/chapter-12.md", new TestModel()),
new Entity().SetAll((UPath)"/chapter-13.md", new TestModel()),
};
var schedules = new List<TestRegexSchedule>
{
new()
{
ScheduleStart = DateTime.Parse("2023-01-01"),
CaptureOffset = -11,
Access = "public",
},
};
// Create the operation and run it, but treat it as being set after the
// second but before the third item.
TimeService time = context.Resolve<TimeService>();
ApplySchedules op = context.Resolve<ApplySchedules>()
.WithGetSchedules(_ => 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<UPath>().ToString(),
a.Has<Instant>()
? time
.ToDateTime(a.Get<Instant>())
.ToString("yyyy-MM-dd")
: "none",
a.Get<TestModel>().Access))
.ToList();
var expected = new List<string>
{
"/chapter-11.md -- 2023-01-01 -- public",
"/chapter-12.md -- 2023-01-08 -- public",
"/chapter-13.md -- none -- private",
};
TestHelper.CompareObjects(expected, actual);
}
[Fact]
public void SequencedScheduleWorks()
{
@ -154,7 +211,7 @@ public class NumericalPathScheduleTests : TemporalSchedulesTestBase
new Entity().SetAll((UPath)"/chapter-03.md", new TestModel()),
};
var schedules = new List<TestSchedule>
var schedules = new List<TestRegexSchedule>
{
new()
{
@ -170,7 +227,7 @@ public class NumericalPathScheduleTests : TemporalSchedulesTestBase
// Create the operation and run it, but treat it as being set after the
// second but before the third item.
Timekeeper time = context.Resolve<Timekeeper>();
TimeService time = context.Resolve<TimeService>();
ApplySchedules op = context.Resolve<ApplySchedules>()
.WithGetSchedules(_ => schedules);
var now = Instant.FromUtc(2023, 1, 9, 0, 0);
@ -205,12 +262,12 @@ public class NumericalPathScheduleTests : TemporalSchedulesTestBase
{
public string? Access { get; set; } = "private";
public List<TestSchedule>? Schedules { get; set; }
public List<TestRegexSchedule>? Schedules { get; set; }
}
public class TestSchedule : NumericalPathSchedule
public class TestRegexSchedule : PeriodicPathRegexSchedule
{
public TestSchedule()
public TestRegexSchedule()
{
this.SchedulePeriod = "1 week";
}

View file

@ -21,7 +21,7 @@ public class CreateDateIndexesTests : TemporalTestBase
public void MonthOnlyIndexes()
{
using TemporalTestContext context = this.CreateContext();
Timekeeper timekeeper = context.Resolve<Timekeeper>();
TimeService timeService = context.Resolve<TimeService>();
CreateDateIndexes op = context.Resolve<CreateDateIndexes>()
.WithFormats("yyyy-MM")
@ -30,11 +30,11 @@ public class CreateDateIndexesTests : TemporalTestBase
List<Entity> input = new()
{
new Entity().Add("page1")
.Add(timekeeper.CreateInstant(2021, 1, 2)),
.Add(timeService.CreateInstant(2021, 1, 2)),
new Entity().Add("page2")
.Add(timekeeper.CreateInstant(2021, 2, 2)),
.Add(timeService.CreateInstant(2021, 2, 2)),
new Entity().Add("page3")
.Add(timekeeper.CreateInstant(2022, 1, 2)),
.Add(timeService.CreateInstant(2022, 1, 2)),
};
List<Tuple<string, List<string>?, List<string>?>> actual =
@ -66,7 +66,7 @@ public class CreateDateIndexesTests : TemporalTestBase
public void YearMonthDayIndexes()
{
using TemporalTestContext context = this.CreateContext();
Timekeeper timekeeper = context.Resolve<Timekeeper>();
TimeService timeService = context.Resolve<TimeService>();
CreateDateIndexes op = context.Resolve<CreateDateIndexes>()
.WithFormats("yyyy/MM/dd", "yyyy/MM", "yyyy")
@ -75,11 +75,11 @@ public class CreateDateIndexesTests : TemporalTestBase
List<Entity> input = new()
{
new Entity().Add("page1")
.Add(timekeeper.CreateInstant(2021, 1, 2)),
.Add(timeService.CreateInstant(2021, 1, 2)),
new Entity().Add("page2")
.Add(timekeeper.CreateInstant(2021, 2, 2)),
.Add(timeService.CreateInstant(2021, 2, 2)),
new Entity().Add("page3")
.Add(timekeeper.CreateInstant(2022, 1, 2)),
.Add(timeService.CreateInstant(2022, 1, 2)),
};
List<Tuple<string, List<string>?, List<string>?>> actual =
@ -131,7 +131,7 @@ public class CreateDateIndexesTests : TemporalTestBase
public void YearMonthDayIndexesThreshold1()
{
using TemporalTestContext context = this.CreateContext();
Timekeeper timekeeper = context.Resolve<Timekeeper>();
TimeService timeService = context.Resolve<TimeService>();
CreateDateIndexes op = context.Resolve<CreateDateIndexes>()
.WithFormats("yyyy/MM/dd", "yyyy/MM", "yyyy")
@ -141,11 +141,11 @@ public class CreateDateIndexesTests : TemporalTestBase
List<Entity> input = new()
{
new Entity().Add("page1")
.Add(timekeeper.CreateInstant(2021, 1, 2)),
.Add(timeService.CreateInstant(2021, 1, 2)),
new Entity().Add("page2")
.Add(timekeeper.CreateInstant(2021, 2, 2)),
.Add(timeService.CreateInstant(2021, 2, 2)),
new Entity().Add("page3")
.Add(timekeeper.CreateInstant(2022, 1, 2)),
.Add(timeService.CreateInstant(2022, 1, 2)),
};
List<Tuple<string, List<string>?, List<string>?>> actual =
@ -197,7 +197,7 @@ public class CreateDateIndexesTests : TemporalTestBase
public void YearMonthIndexes()
{
using TemporalTestContext context = this.CreateContext();
Timekeeper timekeeper = context.Resolve<Timekeeper>();
TimeService timeService = context.Resolve<TimeService>();
CreateDateIndexes op = context.Resolve<CreateDateIndexes>()
.WithFormats("yyyy-MM", "yyyy")
@ -206,11 +206,11 @@ public class CreateDateIndexesTests : TemporalTestBase
List<Entity> input = new()
{
new Entity().Add("page1")
.Add(timekeeper.CreateInstant(2021, 1, 2)),
.Add(timeService.CreateInstant(2021, 1, 2)),
new Entity().Add("page2")
.Add(timekeeper.CreateInstant(2021, 2, 2)),
.Add(timeService.CreateInstant(2021, 2, 2)),
new Entity().Add("page3")
.Add(timekeeper.CreateInstant(2022, 1, 2)),
.Add(timeService.CreateInstant(2022, 1, 2)),
};
List<Tuple<string, List<string>?, List<string>?>> actual =
@ -250,7 +250,7 @@ public class CreateDateIndexesTests : TemporalTestBase
public void YearOnlyIndexes()
{
using TemporalTestContext context = this.CreateContext();
Timekeeper timekeeper = context.Resolve<Timekeeper>();
TimeService timeService = context.Resolve<TimeService>();
CreateDateIndexes op = context.Resolve<CreateDateIndexes>()
.WithFormats("yyyy")
@ -259,11 +259,11 @@ public class CreateDateIndexesTests : TemporalTestBase
List<Entity> input = new()
{
new Entity().Add("page1")
.Add(timekeeper.CreateInstant(2021, 1, 2)),
.Add(timeService.CreateInstant(2021, 1, 2)),
new Entity().Add("page2")
.Add(timekeeper.CreateInstant(2021, 2, 2)),
.Add(timeService.CreateInstant(2021, 2, 2)),
new Entity().Add("page3")
.Add(timekeeper.CreateInstant(2022, 1, 2)),
.Add(timeService.CreateInstant(2022, 1, 2)),
};
List<Tuple<string, List<string>?, List<string>?>> actual =

View file

@ -22,10 +22,10 @@ public class FilterOutFutureInstantTests : TemporalTestBase
{
// Create the context and set the timestamp to a constant value.
using TemporalTestContext context = this.CreateContext();
Timekeeper timekeeper = context.Resolve<Timekeeper>();
TimeService timeService = context.Resolve<TimeService>();
var now = Instant.FromUtc(2000, 6, 1, 0, 0);
timekeeper.Clock = new FakeClock(now);
timeService.Clock = new FakeClock(now);
// Create the entities.
List<Entity> input = new()
@ -52,10 +52,10 @@ public class FilterOutFutureInstantTests : TemporalTestBase
{
// Create the context and set the timestamp to a constant value.
using TemporalTestContext context = this.CreateContext();
Timekeeper timekeeper = context.Resolve<Timekeeper>();
TimeService timeService = context.Resolve<TimeService>();
var now = Instant.FromUtc(2000, 6, 1, 0, 0);
timekeeper.Clock = new FakeClock(now);
timeService.Clock = new FakeClock(now);
// Create the entities.
List<Entity> input = new()