diff --git a/src/JSCalendar.Net/Converters/DurationJsonConverter.cs b/src/JSCalendar.Net/Converters/DurationJsonConverter.cs index 9e71ade..fb5b496 100644 --- a/src/JSCalendar.Net/Converters/DurationJsonConverter.cs +++ b/src/JSCalendar.Net/Converters/DurationJsonConverter.cs @@ -48,7 +48,7 @@ private static Duration ParseDuration(string durationString) }; } - private static int? ParseIntGroup(Group group) + private static int? ParseIntGroup(System.Text.RegularExpressions.Group group) { if (group.Success && int.TryParse(group.Value, out var value)) return value; diff --git a/src/JSCalendar.Net/Converters/JSCalendarObjectConverter.cs b/src/JSCalendar.Net/Converters/JSCalendarObjectConverter.cs new file mode 100644 index 0000000..51216e0 --- /dev/null +++ b/src/JSCalendar.Net/Converters/JSCalendarObjectConverter.cs @@ -0,0 +1,41 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace JSCalendar.Net.Converters; + +/// +/// JSON converter for IJSCalendarObject interface to support polymorphic deserialization. +/// This converter reads the @type property to determine whether to deserialize an Event or Task. +/// +public class JSCalendarObjectConverter : JsonConverter +{ + public override IJSCalendarObject? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // Read the JSON as a JsonDocument to inspect the @type property + using var jsonDoc = JsonDocument.ParseValue(ref reader); + var root = jsonDoc.RootElement; + + // Check for @type property + if (!root.TryGetProperty("@type", out var typeProperty)) + { + throw new JsonException("Missing @type property in JSCalendar object"); + } + + var typeName = typeProperty.GetString(); + + // Deserialize based on the @type value + var json = root.GetRawText(); + return typeName switch + { + "Event" => JsonSerializer.Deserialize(json, options), + "Task" => JsonSerializer.Deserialize(json, options), + _ => throw new JsonException($"Unknown JSCalendar object type: {typeName}") + }; + } + + public override void Write(Utf8JsonWriter writer, IJSCalendarObject value, JsonSerializerOptions options) + { + // Serialize the concrete type + JsonSerializer.Serialize(writer, value, value.GetType(), options); + } +} diff --git a/src/JSCalendar.Net/Event.cs b/src/JSCalendar.Net/Event.cs index ed28137..998081a 100644 --- a/src/JSCalendar.Net/Event.cs +++ b/src/JSCalendar.Net/Event.cs @@ -7,7 +7,7 @@ namespace JSCalendar.Net; /// Represents a calendar event in JSCalendar format (RFC 8984). /// This is the main type for calendar events. /// -public sealed class Event +public sealed class Event : IJSCalendarObject { // Metadata Properties (Section 4.1) diff --git a/src/JSCalendar.Net/Group.cs b/src/JSCalendar.Net/Group.cs new file mode 100644 index 0000000..de030b3 --- /dev/null +++ b/src/JSCalendar.Net/Group.cs @@ -0,0 +1,120 @@ +using System.Text.Json.Serialization; + +namespace JSCalendar.Net; + +/// +/// Represents a collection of events and/or tasks in JSCalendar format (RFC 8984 Section 2.3). +/// A Group is used to organize related calendar objects together. +/// +public sealed class Group +{ + // Metadata Properties (Section 4.1) + + /// + /// Type identifier. MUST be "Group". + /// + [JsonPropertyName("@type")] + public string Type { get; init; } = "Group"; + + /// + /// Globally unique identifier for this group (Section 4.1.2). + /// REQUIRED property. + /// + [JsonPropertyName("uid")] + public required string Uid { get; init; } + + /// + /// Producer identifier that created this group (Section 4.1.4). + /// + [JsonPropertyName("prodId")] + public string? ProdId { get; init; } + + /// + /// Date and time this group was created (Section 4.1.5). + /// + [JsonPropertyName("created")] + public DateTimeOffset? Created { get; init; } + + /// + /// Date and time this group was last updated (Section 4.1.6). + /// REQUIRED property. + /// + [JsonPropertyName("updated")] + public required DateTimeOffset Updated { get; init; } + + // What and Where Properties (Section 4.2) + + /// + /// Short summary or name of the group (Section 4.2.1). + /// Default: empty String + /// + [JsonPropertyName("title")] + public string Title { get; init; } = ""; + + /// + /// Detailed description of the group (Section 4.2.2). + /// Default: empty String + /// + [JsonPropertyName("description")] + public string Description { get; init; } = ""; + + /// + /// Content type of the description (Section 4.2.3). + /// Default: "text/plain" + /// + [JsonPropertyName("descriptionContentType")] + public string DescriptionContentType { get; init; } = "text/plain"; + + /// + /// Links to external resources associated with this group (Section 4.2.7). + /// + [JsonPropertyName("links")] + public Dictionary? Links { get; init; } + + /// + /// Language tag for this group (Section 4.2.8). + /// + [JsonPropertyName("locale")] + public string? Locale { get; init; } + + /// + /// Keywords or tags for this group (Section 4.2.9). + /// + [JsonPropertyName("keywords")] + public Dictionary? Keywords { get; init; } + + /// + /// Categories for this group (Section 4.2.10). + /// + [JsonPropertyName("categories")] + public Dictionary? Categories { get; init; } + + /// + /// Color to use when displaying this group (Section 4.2.11). + /// + [JsonPropertyName("color")] + public string? Color { get; init; } + + // Time Zone Properties (Section 4.7) + + /// + /// Time zone definitions referenced by entries in this group (Section 4.7.2). + /// + [JsonPropertyName("timeZones")] + public Dictionary? TimeZones { get; init; } + + // Group-specific Properties (Section 5.3) + + /// + /// Collection of Event and/or Task objects in this group (Section 5.3.1). + /// REQUIRED property. + /// + [JsonPropertyName("entries")] + public required List Entries { get; init; } + + /// + /// Source URI from which updated versions of this group may be retrieved (Section 5.3.2). + /// + [JsonPropertyName("source")] + public string? Source { get; init; } +} diff --git a/src/JSCalendar.Net/IJSCalendarObject.cs b/src/JSCalendar.Net/IJSCalendarObject.cs new file mode 100644 index 0000000..dc4ede4 --- /dev/null +++ b/src/JSCalendar.Net/IJSCalendarObject.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; +using JSCalendar.Net.Converters; + +namespace JSCalendar.Net; + +/// +/// Marker interface for JSCalendar objects that can be entries in a Group. +/// This interface is implemented by Event and Task objects. +/// +[JsonConverter(typeof(JSCalendarObjectConverter))] +public interface IJSCalendarObject +{ + /// + /// Type identifier for this JSCalendar object. + /// + string Type { get; } + + /// + /// Globally unique identifier for this object. + /// + string Uid { get; } + + /// + /// Date and time this object was last updated. + /// + DateTimeOffset Updated { get; } +} diff --git a/src/JSCalendar.Net/Task.cs b/src/JSCalendar.Net/Task.cs index ab52d0e..82cdb23 100644 --- a/src/JSCalendar.Net/Task.cs +++ b/src/JSCalendar.Net/Task.cs @@ -6,7 +6,7 @@ namespace JSCalendar.Net; /// /// Represents a task/todo item in JSCalendar format (RFC 8984 Section 2.2). /// -public sealed class Task +public sealed class Task : IJSCalendarObject { // Metadata Properties (Section 4.1) diff --git a/tests/JSCalendar.Net.Tests/GroupTests.cs b/tests/JSCalendar.Net.Tests/GroupTests.cs new file mode 100644 index 0000000..75fef93 --- /dev/null +++ b/tests/JSCalendar.Net.Tests/GroupTests.cs @@ -0,0 +1,187 @@ +using System.Text.Json; + +namespace JSCalendar.Net.Tests; + +public class GroupTests +{ + private readonly JsonSerializerOptions _options = new() { WriteIndented = false }; + + [Fact] + public void Group_MinimalProperties_SerializesCorrectly() + { + // Arrange + var group = new Group + { + Uid = "group-123", + Updated = new DateTimeOffset(2024, 12, 1, 10, 0, 0, TimeSpan.Zero), + Entries = [] + }; + + // Act + var json = JsonSerializer.Serialize(group, _options); + + // Assert + Assert.Contains("\"@type\":\"Group\"", json); + Assert.Contains("\"uid\":\"group-123\"", json); + Assert.Contains("\"updated\":\"2024-12-01T10:00:00+00:00\"", json); + Assert.Contains("\"entries\":[]", json); + } + + [Fact] + public void Group_WithEventAndTask_SerializesCorrectly() + { + // Arrange + var evt = new Event + { + Uid = "event-001", + Updated = new DateTimeOffset(2024, 12, 1, 10, 0, 0, TimeSpan.Zero), + Start = new LocalDateTime(new DateTime(2024, 12, 15, 14, 0, 0)), + Title = "Team Meeting" + }; + + var task = new Task + { + Uid = "task-001", + Updated = new DateTimeOffset(2024, 12, 1, 10, 0, 0, TimeSpan.Zero), + Title = "Prepare slides" + }; + + var group = new Group + { + Uid = "group-123", + Updated = new DateTimeOffset(2024, 12, 1, 10, 0, 0, TimeSpan.Zero), + Title = "Work Items", + Entries = [evt, task] + }; + + // Act + var json = JsonSerializer.Serialize(group, _options); + + // Assert + Assert.Contains("\"@type\":\"Group\"", json); + Assert.Contains("\"uid\":\"group-123\"", json); + Assert.Contains("\"title\":\"Work Items\"", json); + Assert.Contains("\"entries\":[", json); + Assert.Contains("\"@type\":\"Event\"", json); + Assert.Contains("\"@type\":\"Task\"", json); + Assert.Contains("\"uid\":\"event-001\"", json); + Assert.Contains("\"uid\":\"task-001\"", json); + } + + [Fact] + public void Group_WithAllProperties_SerializesCorrectly() + { + // Arrange + var group = new Group + { + Uid = "group-456", + Created = new DateTimeOffset(2024, 12, 1, 10, 0, 0, TimeSpan.Zero), + Updated = new DateTimeOffset(2024, 12, 1, 10, 0, 0, TimeSpan.Zero), + ProdId = "//example.com//calendar//EN", + Title = "Conference Events", + Description = "All events related to the annual conference", + DescriptionContentType = "text/plain", + Locale = "en", + Color = "blue", + Keywords = new Dictionary + { + ["conference"] = true, + ["annual"] = true + }, + Categories = new Dictionary + { + ["http://example.com/categories/work"] = true + }, + Links = new Dictionary + { + ["website"] = new Link + { + Href = "https://conference.example.com" + } + }, + Source = "https://calendar.example.com/groups/456", + Entries = [] + }; + + // Act + var json = JsonSerializer.Serialize(group, _options); + + // Assert + Assert.Contains("\"@type\":\"Group\"", json); + Assert.Contains("\"uid\":\"group-456\"", json); + Assert.Contains("\"title\":\"Conference Events\"", json); + Assert.Contains("\"description\":\"All events related to the annual conference\"", json); + Assert.Contains("\"locale\":\"en\"", json); + Assert.Contains("\"color\":\"blue\"", json); + Assert.Contains("\"keywords\":{\"conference\":true,\"annual\":true}", json); + Assert.Contains("\"source\":\"https://calendar.example.com/groups/456\"", json); + } + + [Fact] + public void Group_Deserialize_WorksCorrectly() + { + // Arrange + var json = """ + { + "@type": "Group", + "uid": "group-789", + "updated": "2024-12-01T10:00:00+00:00", + "title": "My Group", + "entries": [ + { + "@type": "Event", + "uid": "event-001", + "updated": "2024-12-01T10:00:00+00:00", + "start": "2024-12-01T10:00:00+00:00", + "title": "Event Title" + }, + { + "@type": "Task", + "uid": "task-001", + "updated": "2024-12-02T11:00:00Z", + "title": "Task Title" + } + ] + } + """; + + // Act + var group = JsonSerializer.Deserialize(json); + + // Assert + Assert.NotNull(group); + Assert.Equal("Group", group.Type); + Assert.Equal("group-789", group.Uid); + Assert.Equal("My Group", group.Title); + Assert.NotNull(group.Entries); + Assert.Equal(2, group.Entries.Count); + + // Check first entry is Event + var firstEntry = group.Entries[0]; + Assert.Equal("Event", firstEntry.Type); + Assert.Equal("event-001", firstEntry.Uid); + + // Check second entry is Task + var secondEntry = group.Entries[1]; + Assert.Equal("Task", secondEntry.Type); + Assert.Equal("task-001", secondEntry.Uid); + } + + [Fact] + public void Group_DefaultValues_AreCorrect() + { + // Arrange & Act + var group = new Group + { + Uid = "test-group", + Updated = DateTimeOffset.UtcNow, + Entries = [] + }; + + // Assert + Assert.Equal("Group", group.Type); + Assert.Equal("", group.Title); + Assert.Equal("", group.Description); + Assert.Equal("text/plain", group.DescriptionContentType); + } +}