feat(schedules)!: reworked schedule names and added a new style
This commit is contained in:
parent
070cf2bfb8
commit
82e1bc3c28
|
@ -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,
|
||||||
|
Timekeeper timekeeper)
|
||||||
|
{
|
||||||
|
// 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, timekeeper);
|
||||||
|
}
|
||||||
|
}
|
79
src/MfGames.Nitride.Temporal.Schedules/IndexedSchedule.cs
Normal file
79
src/MfGames.Nitride.Temporal.Schedules/IndexedSchedule.cs
Normal 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,
|
||||||
|
Timekeeper timekeeper)
|
||||||
|
{
|
||||||
|
// 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 = 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,27 +4,22 @@ using System.Text.RegularExpressions;
|
||||||
using MfGames.Gallium;
|
using MfGames.Gallium;
|
||||||
using MfGames.Nitride.Generators;
|
using MfGames.Nitride.Generators;
|
||||||
|
|
||||||
using NodaTime;
|
|
||||||
|
|
||||||
using TimeSpanParserUtil;
|
|
||||||
|
|
||||||
using Zio;
|
using Zio;
|
||||||
|
|
||||||
namespace MfGames.Nitride.Temporal.Schedules;
|
namespace MfGames.Nitride.Temporal.Schedules;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A schedule that uses the `UPath` of the entity to determine an offset from
|
/// A common base for a schedule that is built off the path associated with
|
||||||
/// a starting point and calculates the information from there.
|
/// the entity.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[WithProperties]
|
[WithProperties]
|
||||||
public partial class NumericalPathSchedule : ISchedule
|
public abstract partial class PathRegexScheduleBase : ISchedule
|
||||||
{
|
{
|
||||||
public NumericalPathSchedule()
|
protected PathRegexScheduleBase()
|
||||||
{
|
{
|
||||||
this.PathRegex = @"^.*?(\d+)[^\d]*$";
|
this.PathRegex = @"^.*?(\d+)[^\d]*$";
|
||||||
this.GetPath = entity => entity.Get<UPath>().ToString();
|
this.GetPath = entity => entity.Get<UPath>().ToString();
|
||||||
this.CaptureGroup = 1;
|
this.CaptureGroup = 1;
|
||||||
this.CaptureOffset = -1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -49,46 +44,15 @@ public partial class NumericalPathSchedule : ISchedule
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? PathRegex { get; set; }
|
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 or blank or "immediate", then this will be instant
|
|
||||||
/// (TimeSpan.Zero).
|
|
||||||
/// </summary>
|
|
||||||
public virtual TimeSpan SchedulePeriodTimeSpan
|
|
||||||
{
|
|
||||||
get => string.IsNullOrWhiteSpace(this.SchedulePeriod)
|
|
||||||
|| this.SchedulePeriod.Equals(
|
|
||||||
"immediate",
|
|
||||||
StringComparison.InvariantCultureIgnoreCase)
|
|
||||||
? 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 />
|
/// <inheritdoc />
|
||||||
public virtual Entity Apply(
|
public virtual Entity Apply(
|
||||||
Entity entity,
|
Entity entity,
|
||||||
Timekeeper timekeeper)
|
Timekeeper timekeeper)
|
||||||
{
|
{
|
||||||
// Get the path and match it.
|
// Get the path and match it.
|
||||||
string? path = this.GetPath(entity);
|
string path = this.GetPath(entity);
|
||||||
Regex? regex = this.GetRegex();
|
Regex? regex = this.GetRegex();
|
||||||
Match? match = regex?.Match(path)
|
Match match = regex?.Match(path)
|
||||||
?? throw new NullReferenceException(
|
?? throw new NullReferenceException(
|
||||||
"PathRegex was not configured for the "
|
"PathRegex was not configured for the "
|
||||||
+ this.GetType().Name
|
+ this.GetType().Name
|
||||||
|
@ -118,19 +82,8 @@ public partial class NumericalPathSchedule : ISchedule
|
||||||
|
|
||||||
number += this.CaptureOffset;
|
number += this.CaptureOffset;
|
||||||
|
|
||||||
// Figure out the time from the start.
|
// Pass it onto the extending class.
|
||||||
DateTime start = this.ScheduleStart
|
return this.Apply(entity, number, timekeeper);
|
||||||
?? 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 />
|
/// <inheritdoc />
|
||||||
|
@ -138,23 +91,28 @@ public partial class NumericalPathSchedule : ISchedule
|
||||||
{
|
{
|
||||||
string path = this.GetPath(entity);
|
string path = this.GetPath(entity);
|
||||||
Regex? regex = this.GetRegex();
|
Regex? regex = this.GetRegex();
|
||||||
bool match = regex?.IsMatch(path) ?? false;
|
Match match = regex?.Match(path)
|
||||||
|
?? throw new NullReferenceException(
|
||||||
|
"PathRegex was not configured for the "
|
||||||
|
+ this.GetType().Name
|
||||||
|
+ ".");
|
||||||
|
|
||||||
return match;
|
return match.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Regex? GetRegex()
|
/// <summary>
|
||||||
|
/// Applies the schedule according to the normalized number (after
|
||||||
|
/// CaptureOffset).
|
||||||
|
/// </summary>
|
||||||
|
protected abstract Entity Apply(
|
||||||
|
Entity entity,
|
||||||
|
int number,
|
||||||
|
Timekeeper timekeeper);
|
||||||
|
|
||||||
|
private Regex? GetRegex()
|
||||||
{
|
{
|
||||||
return this.PathRegex == null
|
return this.PathRegex == null
|
||||||
? null
|
? null
|
||||||
: new Regex(this.PathRegex);
|
: new Regex(this.PathRegex);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected virtual Entity Apply(
|
|
||||||
Entity entity,
|
|
||||||
int number,
|
|
||||||
Instant instant)
|
|
||||||
{
|
|
||||||
return entity.Set(instant);
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -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,
|
||||||
|
Timekeeper timekeeper)
|
||||||
|
{
|
||||||
|
// 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 = 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -57,14 +57,28 @@ The following properties are available (along with their corresponding `With*` m
|
||||||
- Used to determine when the site is being generated.
|
- Used to determine when the site is being generated.
|
||||||
- Defaults to the one provided by `MfGames.Nitride.Temporal`.
|
- 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 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
|
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
|
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.
|
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
|
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
|
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.
|
point plus the "period" for every one past the first.
|
||||||
|
@ -81,7 +95,7 @@ var entities = new List<Entity>
|
||||||
};
|
};
|
||||||
var schedules = new List<ISchedule>
|
var schedules = new List<ISchedule>
|
||||||
{
|
{
|
||||||
new NumericalPathSchedule
|
new PeriodicPathRegexSchedule
|
||||||
{
|
{
|
||||||
PathRegex = "chapter-(\d+),
|
PathRegex = "chapter-(\d+),
|
||||||
ScheduleStart = DateTime.Parse("2023-01-01"),
|
ScheduleStart = DateTime.Parse("2023-01-01"),
|
||||||
|
@ -96,7 +110,7 @@ return entities.Run(op.WithSchedules(schedules));
|
||||||
This will have chapter-01 have an `Instant` component set to 2023-01-01, the second
|
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.
|
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`
|
- `Func<Entity, string> GetPath`
|
||||||
- An override to allow retrieving a different function.
|
- An override to allow retrieving a different function.
|
||||||
|
@ -127,7 +141,7 @@ There is also a virtual method for applying the schedule.
|
||||||
|
|
||||||
#### Overriding Logic
|
#### 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
|
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.
|
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
|
```yaml
|
||||||
schedules:
|
schedules:
|
||||||
# Patron and Ko-Fi subscribers get it all at once
|
# Patron and Ko-Fi subscribers get it all at once
|
||||||
- pathRegex: chapters/chapter-\d+
|
- pathRegex: chapters/chapter-(\d+)
|
||||||
scheduleDate: 2025-01-01
|
scheduleStart: 2025-01-01
|
||||||
schedulePeriod: instant # Because we overrode the default to be weekly.
|
schedulePeriod: instant # Because we overrode the default to be weekly.
|
||||||
access: subscribers
|
access: subscribers
|
||||||
# The first fifteen chapters (01-15) were released at the rate of one
|
# 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
|
# per week starting in 2024. This will replace all the schedules above
|
||||||
# it.
|
# it.
|
||||||
- path: chapters/chapter-(0\d|1[1-5])
|
- path: chapters/chapter-(0\d|1[1-5])
|
||||||
scheduleDate: 2030-01-01
|
scheduleStart: 2030-01-01
|
||||||
access: public
|
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.
|
||||||
|
|
35
src/MfGames.Nitride.Temporal.Schedules/TimeSpanHelper.cs
Normal file
35
src/MfGames.Nitride.Temporal.Schedules/TimeSpanHelper.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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.
|
||||||
|
Timekeeper time = context.Resolve<Timekeeper>();
|
||||||
|
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.
|
||||||
|
Timekeeper time = context.Resolve<Timekeeper>();
|
||||||
|
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.
|
||||||
|
Timekeeper time = context.Resolve<Timekeeper>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,17 +6,17 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\MfGames.Nitride.Temporal.Schedules\MfGames.Nitride.Temporal.Schedules.csproj"/>
|
<ProjectReference Include="..\..\src\MfGames.Nitride.Temporal.Schedules\MfGames.Nitride.Temporal.Schedules.csproj" />
|
||||||
<ProjectReference Include="..\MfGames.Nitride.Tests\MfGames.Nitride.Tests.csproj"/>
|
<ProjectReference Include="..\MfGames.Nitride.Tests\MfGames.Nitride.Tests.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CompareNETObjects" Version="4.78.0"/>
|
<PackageReference Include="CompareNETObjects" Version="4.78.0" />
|
||||||
<PackageReference Include="MfGames.Gallium" Version="0.4.0"/>
|
<PackageReference Include="MfGames.Gallium" Version="0.4.0" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.1"/>
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.1" />
|
||||||
<PackageReference Include="JunitXml.TestLogger" Version="3.0.114"/>
|
<PackageReference Include="JunitXml.TestLogger" Version="3.0.114" />
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.2"/>
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.2" />
|
||||||
<PackageReference Include="xunit" Version="2.4.2"/>
|
<PackageReference Include="xunit" Version="2.4.2" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
@ -25,7 +25,7 @@
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="YamlDotNet" Version="12.0.0"/>
|
<PackageReference Include="YamlDotNet" Version="12.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
@ -18,9 +18,9 @@ using Zio;
|
||||||
|
|
||||||
namespace MfGames.Nitride.Temporal.Schedules.Tests;
|
namespace MfGames.Nitride.Temporal.Schedules.Tests;
|
||||||
|
|
||||||
public class NumericalPathScheduleTests : TemporalSchedulesTestBase
|
public class PeriodicPathRegexScheduleTest : TemporalSchedulesTestBase
|
||||||
{
|
{
|
||||||
public NumericalPathScheduleTests(ITestOutputHelper output)
|
public PeriodicPathRegexScheduleTest(ITestOutputHelper output)
|
||||||
: base(output)
|
: base(output)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
@ -52,7 +52,7 @@ public class NumericalPathScheduleTests : TemporalSchedulesTestBase
|
||||||
" schedulePeriod: 1 week",
|
" schedulePeriod: 1 week",
|
||||||
" access: public",
|
" 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
|
// Create the operation and run it, but treat it as being set after the
|
||||||
// second but before the third item.
|
// second but before the third item.
|
||||||
|
@ -100,7 +100,7 @@ public class NumericalPathScheduleTests : TemporalSchedulesTestBase
|
||||||
new Entity().SetAll((UPath)"/chapter-03.md", new TestModel()),
|
new Entity().SetAll((UPath)"/chapter-03.md", new TestModel()),
|
||||||
};
|
};
|
||||||
|
|
||||||
var schedules = new List<TestSchedule>
|
var schedules = new List<TestRegexSchedule>
|
||||||
{
|
{
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
|
@ -155,7 +155,7 @@ public class NumericalPathScheduleTests : TemporalSchedulesTestBase
|
||||||
new Entity().SetAll((UPath)"/chapter-13.md", new TestModel()),
|
new Entity().SetAll((UPath)"/chapter-13.md", new TestModel()),
|
||||||
};
|
};
|
||||||
|
|
||||||
var schedules = new List<TestSchedule>
|
var schedules = new List<TestRegexSchedule>
|
||||||
{
|
{
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
|
@ -211,7 +211,7 @@ public class NumericalPathScheduleTests : TemporalSchedulesTestBase
|
||||||
new Entity().SetAll((UPath)"/chapter-03.md", new TestModel()),
|
new Entity().SetAll((UPath)"/chapter-03.md", new TestModel()),
|
||||||
};
|
};
|
||||||
|
|
||||||
var schedules = new List<TestSchedule>
|
var schedules = new List<TestRegexSchedule>
|
||||||
{
|
{
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
|
@ -262,12 +262,12 @@ public class NumericalPathScheduleTests : TemporalSchedulesTestBase
|
||||||
{
|
{
|
||||||
public string? Access { get; set; } = "private";
|
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";
|
this.SchedulePeriod = "1 week";
|
||||||
}
|
}
|
Reference in a new issue