138 lines
4.6 KiB
C#
138 lines
4.6 KiB
C#
using System.Globalization;
|
|
using YamlDotNet.Serialization;
|
|
using YamlDotNet.Serialization.NamingConventions;
|
|
|
|
namespace Elternbeirat.Web.Services;
|
|
|
|
/// <summary>
|
|
/// Reads the calendar entries from <c>Content/events.yml</c> once at startup and
|
|
/// keeps them in memory. Registered as a singleton because the content lives in
|
|
/// the image and does not change at runtime.
|
|
/// </summary>
|
|
public sealed class EventService
|
|
{
|
|
private readonly IReadOnlyList<Event> _events;
|
|
|
|
/// <summary>
|
|
/// Reads and parses <c>Content/events.yml</c> below the application root.
|
|
/// A missing file yields an empty list rather than an error.
|
|
/// </summary>
|
|
public EventService(IWebHostEnvironment environment)
|
|
{
|
|
var path = Path.Combine(environment.ContentRootPath, "Content", "events.yml");
|
|
_events = File.Exists(path)
|
|
? Parse(File.ReadAllText(path))
|
|
: [];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Entries that start today or later, earliest first — the upcoming section.
|
|
/// "Today" is compared by date, so an entry earlier today still counts as
|
|
/// upcoming.
|
|
/// </summary>
|
|
public IReadOnlyList<Event> Upcoming =>
|
|
_events
|
|
.Where(e => DateOnly.FromDateTime(e.Start) >= DateOnly.FromDateTime(DateTime.Today))
|
|
.OrderBy(e => e.Start)
|
|
.ToList();
|
|
|
|
/// <summary>
|
|
/// Entries that already passed, most recent first — the past section shown
|
|
/// below the upcoming ones.
|
|
/// </summary>
|
|
public IReadOnlyList<Event> Past =>
|
|
_events
|
|
.Where(e => DateOnly.FromDateTime(e.Start) < DateOnly.FromDateTime(DateTime.Today))
|
|
.OrderByDescending(e => e.Start)
|
|
.ToList();
|
|
|
|
/// <summary>All entries in chronological order, for the ICS export.</summary>
|
|
public IReadOnlyList<Event> All =>
|
|
_events.OrderBy(e => e.Start).ToList();
|
|
|
|
private static List<Event> Parse(string yaml)
|
|
{
|
|
// Keys are lowercase single words (title, start, ...), which the camel
|
|
// case convention maps onto the PascalCase properties.
|
|
var deserializer = new DeserializerBuilder()
|
|
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
|
.IgnoreUnmatchedProperties()
|
|
.Build();
|
|
|
|
var raw = deserializer.Deserialize<List<EventEntry>?>(yaml) ?? [];
|
|
return raw
|
|
.Where(entry => entry.Title is not null && entry.Start is not null)
|
|
.Select(ToEvent)
|
|
.OfType<Event>()
|
|
.ToList();
|
|
}
|
|
|
|
private static Event? ToEvent(EventEntry entry)
|
|
{
|
|
if (!TryParseWhen(entry.Start!, out var start, out var hasTime))
|
|
{
|
|
// A malformed date drops the entry rather than crashing startup.
|
|
return null;
|
|
}
|
|
|
|
DateTime? end = null;
|
|
if (entry.End is not null && TryParseWhen(entry.End, out var parsedEnd, out _))
|
|
{
|
|
end = parsedEnd;
|
|
}
|
|
|
|
return new Event
|
|
{
|
|
Title = entry.Title!,
|
|
Start = start,
|
|
HasTime = hasTime,
|
|
End = end,
|
|
Location = entry.Location,
|
|
Note = entry.Note,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses a value that is either a date (<c>2026-03-15</c>) or a date with a
|
|
/// time (<c>2026-03-15 19:30</c>). Sets <paramref name="hasTime"/> so the
|
|
/// caller knows whether to treat the entry as all-day.
|
|
/// </summary>
|
|
private static bool TryParseWhen(string value, out DateTime when, out bool hasTime)
|
|
{
|
|
var trimmed = value.Trim();
|
|
|
|
if (DateOnly.TryParseExact(trimmed, "yyyy-MM-dd",
|
|
CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateOnly))
|
|
{
|
|
when = dateOnly.ToDateTime(TimeOnly.MinValue);
|
|
hasTime = false;
|
|
return true;
|
|
}
|
|
|
|
string[] formats = ["yyyy-MM-dd HH:mm", "yyyy-MM-dd'T'HH:mm"];
|
|
if (DateTime.TryParseExact(trimmed, formats,
|
|
CultureInfo.InvariantCulture, DateTimeStyles.None, out when))
|
|
{
|
|
hasTime = true;
|
|
return true;
|
|
}
|
|
|
|
when = default;
|
|
hasTime = false;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Raw shape of one YAML entry, before validation. String-typed on purpose so
|
|
/// we control date parsing and can distinguish date-only from date-and-time.
|
|
/// </summary>
|
|
private sealed class EventEntry
|
|
{
|
|
public string? Title { get; set; }
|
|
public string? Start { get; set; }
|
|
public string? End { get; set; }
|
|
public string? Location { get; set; }
|
|
public string? Note { get; set; }
|
|
}
|
|
}
|