Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Ical.Net.Tests/PeriodListWrapperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -213,9 +213,9 @@ public void AddRPeriod_ShouldCreate_DedicatePeriodList()

Assert.That(serialized,
Does.Contain(
"RDATE;VALUE=PERIOD:20250102/20250105,20250501/20250510,20250501/P9D\r\n" +
"RDATE;VALUE=PERIOD:20250202T000000Z/20250202T060000Z,20250601T120000Z/2025\r\n" +
" 0601T140000Z\r\n" +
"RDATE;VALUE=PERIOD:20250102/20250105,20250501/20250510,20250501/P9D" + SerializationConstants.LineBreak +
"RDATE;VALUE=PERIOD:20250202T000000Z/20250202T060000Z,20250601T120000Z/20250" + SerializationConstants.LineBreak +
" 601T140000Z" + SerializationConstants.LineBreak +
"RDATE;TZID=Europe/Vienna;VALUE=PERIOD:20250601T120000/PT8H"));

// A flattened list of all dates
Expand Down
5 changes: 3 additions & 2 deletions Ical.Net.Tests/RecurrenceWithRDateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using Ical.Net.Collections;
using Ical.Net.DataTypes;
using Ical.Net.Serialization;
using Ical.Net.Utility;
using NUnit.Framework;

namespace Ical.Net.Tests;
Expand Down Expand Up @@ -152,7 +153,7 @@ public void RDate_PeriodsWithTimezone_AreProcessedCorrectly()
Assert.That(occurrences[1].Period.EffectiveDuration, Is.EqualTo(new Duration(hours: 4)));
Assert.That(occurrences[2].Period.EffectiveDuration, Is.EqualTo(new Duration(hours: 5)));
// Line folding is used for long lines
Assert.That(ics, Does.Contain("RDATE;TZID=America/New_York;VALUE=PERIOD:20231002T100000/PT4H,20231003T100\r\n 000/PT5H"));
Assert.That(ics, Does.Contain("RDATE;TZID=America/New_York;VALUE=PERIOD:20231002T100000/PT4H,20231003T1000\r\n 00/PT5H"));
});

// Deserialization
Expand Down Expand Up @@ -342,7 +343,7 @@ public void RDate_LargeNumberOfDates_ShouldBeLineFolded()
// First folded line is 75 characters long
Assert.That(ics, Does.Contain("RDATE:20231002T100000,20231003T100000,20231004T100000,20231005T100000,2023"));
// Last folded line
Assert.That(ics, Does.Contain(" T100000,20240106T100000,20240107T100000,20240108T100000,20240109T100000"));
Assert.That(ics, Does.Contain(" 00,20240107T100000,20240108T100000,20240109T100000"));
});
}

Expand Down
75 changes: 51 additions & 24 deletions Ical.Net.Tests/TextUtilTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
//

using System.Collections;
using System.Linq;
using System.Text;
using Ical.Net.Utility;
using NUnit.Framework;

Expand All @@ -12,38 +14,63 @@ namespace Ical.Net.Tests;
public class TextUtilTests
{
[Test, TestCaseSource(nameof(FoldLines_TestCases))]
public string FoldLines_Tests(string incoming) => TextUtil.FoldLines(incoming);
public string FoldLines_Tests(string input)
{
var sb = new StringBuilder(input.Length);
sb.FoldLines(input);
return sb.ToString();
}

public static IEnumerable FoldLines_TestCases()
{
yield return new TestCaseData("Short")
.Returns("Short" + SerializationConstants.LineBreak)
yield return new TestCaseData("No folding")
.Returns("No folding" + SerializationConstants.LineBreak)
.SetName("Short string remains unfolded");

const string moderatelyLongReturns =
"HelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHell" + SerializationConstants.LineBreak
+ " oWorld" + SerializationConstants.LineBreak;
const string exactly85OctetsReturns =
"HelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHello" + SerializationConstants.LineBreak
+ " WorldHello" + SerializationConstants.LineBreak;

yield return new TestCaseData("HelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHello")
.Returns(exactly85OctetsReturns)
.SetName("10 Octets remainder gets folded");

yield return new TestCaseData(
" HelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorld ")
.Returns(" HelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorld " +
SerializationConstants.LineBreak + " " + SerializationConstants.LineBreak)
.SetName("85 Octets long string with leading and trailing whitespace is folded without trimming");

var exactly310Octets = string.Concat(Enumerable.Repeat("HelloWorld", 31));

const string exactly310OctetsReturns =
"HelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHello" + SerializationConstants.LineBreak +
" WorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorl" + SerializationConstants.LineBreak +
" dHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHel" + SerializationConstants.LineBreak +
" loWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWo" + SerializationConstants.LineBreak +
" rldHelloWorld" + SerializationConstants.LineBreak;

yield return new TestCaseData("HelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorld")
.Returns(moderatelyLongReturns)
.SetName("Long string is folded");
yield return new TestCaseData(exactly310Octets)
.Returns(exactly310OctetsReturns)
.SetName("310 Octets long string is split onto multiple lines at a width of 75 octets");

yield return new TestCaseData(" HelloWorldHelloWorldHelloWorldHelloWorldHelloWorld ")
.Returns("HelloWorldHelloWorldHelloWorldHelloWorldHelloWorld" + SerializationConstants.LineBreak)
.SetName("Long string with front and rear whitespace is trimmed and fits in the allotted width");
const string exactly75Octets = "HelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHello";
const string exactly75OctetsReturns = "HelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHello" + SerializationConstants.LineBreak;

const string reallyLong =
"HelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorld";
yield return new TestCaseData(exactly75Octets)
.Returns(exactly75OctetsReturns)
.SetName("String with exactly 75 octets remains unfolded");

const string reallyLongReturns =
"HelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHell" + SerializationConstants.LineBreak
+ " oWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWo" + SerializationConstants.LineBreak
+ " rldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorld" + SerializationConstants.LineBreak
+ " HelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHelloWorldHel" + SerializationConstants.LineBreak
+ " loWorldHelloWorld" + SerializationConstants.LineBreak;
// String containing multi-byte characters with 210 Octets
// (Chinese "Hello World" repeated 10 times)
const string multiByteString210Octets = "こんにちは世界こんにちは世界こんにちは世界こんにちは世界こんにちは世界こんにちは世界こんにちは世界こんにちは世界こんにちは世界こんにちは世界";
const string multiByteString210OctetsReturns =
"こんにちは世界こんにちは世界こんにちは世界こんにち" + SerializationConstants.LineBreak +
" は世界こんにちは世界こんにちは世界こんにちは世界" + SerializationConstants.LineBreak +
" こんにちは世界こんにちは世界こんにちは世界" + SerializationConstants.LineBreak;

yield return new TestCaseData(reallyLong)
.Returns(reallyLongReturns)
.SetName("Really long string is split onto multiple lines at a width of 75 chars, prefixed with a space");
yield return new TestCaseData(multiByteString210Octets)
.Returns(multiByteString210OctetsReturns)
.SetName("String with multi-byte characters exceeding 75 octets is folded correctly");
}
}
}
6 changes: 3 additions & 3 deletions Ical.Net/Serialization/ComponentSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public override string SerializeToString(object obj)

var sb = new StringBuilder();
var upperName = c.Name.ToUpperInvariant();
sb.Append(TextUtil.FoldLines($"BEGIN:{upperName}"));
sb.FoldLines($"BEGIN:{upperName}");

// Get a serializer factory
var sf = GetService<ISerializerFactory>();
Expand All @@ -56,7 +56,7 @@ public override string SerializeToString(object obj)
sb.Append(serializer.SerializeToString(child));
}

sb.Append(TextUtil.FoldLines($"END:{upperName}"));
sb.FoldLines($"END:{upperName}");
return sb.ToString();
}

Expand All @@ -79,4 +79,4 @@ public int Compare(ICalendarProperty x, ICalendarProperty y)
: string.Compare(x.Name, y.Name, StringComparison.OrdinalIgnoreCase);
}
}
}
}
101 changes: 50 additions & 51 deletions Ical.Net/Serialization/PropertySerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,66 +39,65 @@ public PropertySerializer(SerializationContext ctx) : base(ctx) { }
var result = new StringBuilder();
foreach (var v in prop.Values.Where(value => value != null))
{
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reducing cognitive complexity by splitting up this method

// Get a serializer to serialize the property's value.
// If we can't serialize the property's value, the next step is worthless anyway.
var valueSerializer = sf.Build(v.GetType(), SerializationContext) as IStringSerializer;

// Iterate through each value to be serialized,
// and give it a property (with parameters).
// FIXME: this isn't always the way this is accomplished.
// Multiple values can often be serialized within the
// same property. How should we fix this?

// NOTE:
// We Serialize the property's value first, as during
// serialization it may modify our parameters.
// FIXME: the "parameter modification" operation should
// be separated from serialization. Perhaps something
// like PreSerialize(), etc.
var value = valueSerializer?.SerializeToString(v);

// Get the list of parameters we'll be serializing
var parameterList = prop.Parameters;
if (v is ICalendarDataType)
{
parameterList = ((ICalendarDataType) v).Parameters;
}
SerializeValue(result, prop, v, sf);
}

// The TZID property of an RDATE/EXDATE collection is owned by the PeriodList that contains it.
// It is allowed to have multiple EXDATE or RDATE collections, each with a different TZID.
// Using RecurrenceDate and ExceptionDate classes ensures, that all Periods in the
// PeriodList have the same TZID and PeriodKind, which allows PeriodList be serialized in one go.
// Here, to determine the timezone, we can safely use the first Period's timezone.
if (v is PeriodList periodList && periodList[0].TzId != null && periodList[0].TzId != "UTC" &&
parameterList.All(p => string.Equals("TZID", p.Value, StringComparison.OrdinalIgnoreCase)))
{
parameterList.Set("TZID", periodList[0].TzId);
}
// Pop the object off the serialization context.
SerializationContext.Pop();
return result.ToString();
}

var sb = new StringBuilder();
sb.Append(prop.Name);
if (parameterList.Any())
private void SerializeValue(StringBuilder result, ICalendarProperty prop, object value, ISerializerFactory sf)
{
// Get a serializer to serialize the property's value.
// If we can't serialize the property's value, the next step is worthless anyway.
var valueSerializer = sf.Build(value.GetType(), SerializationContext) as IStringSerializer;

// Iterate through each value to be serialized,
// and give it a property (with parameters).
// FIXME: this isn't always the way this is accomplished.
// Multiple values can often be serialized within the
// same property. How should we fix this?

// NOTE:
// We Serialize the property's value first, as during
// serialization it may modify our parameters.
// FIXME: the "parameter modification" operation should
// be separated from serialization. Perhaps something
// like PreSerialize(), etc.
var serializedValue = valueSerializer?.SerializeToString(value);

// Get the list of parameters we'll be serializing
var parameterList = GetParameterList(prop, value);

var sb = new StringBuilder();
sb.Append(prop.Name);
if (parameterList.Count != 0)
{
// Get a serializer for parameters
var parameterSerializer = sf.Build(typeof(CalendarParameter), SerializationContext) as IStringSerializer;
if (parameterSerializer != null)
{
// Get a serializer for parameters
var parameterSerializer = sf.Build(typeof(CalendarParameter), SerializationContext) as IStringSerializer;
if (parameterSerializer != null)
sb.Append(';');
var first = true;
// Serialize each parameter and append to the StringBuilder
foreach (var param in parameterList)
{
// Serialize each parameter
// Separate parameters with semicolons
sb.Append(";");
sb.Append(string.Join(";", parameterList.Select(param => parameterSerializer.SerializeToString(param))));
if (!first) sb.Append(';');

sb.Append(parameterSerializer.SerializeToString(param));
first = false;
}
}
sb.Append(":");
sb.Append(value);

result.Append(TextUtil.FoldLines(sb.ToString()));
}
sb.Append(':');
sb.Append(serializedValue);

// Pop the object off the serialization context.
SerializationContext.Pop();
return result.ToString();
result.FoldLines(sb.ToString());
}

private static IParameterCollection GetParameterList(ICalendarProperty prop, object value)
=> value is ICalendarDataType dataType ? dataType.Parameters : prop.Parameters;

public override object? Deserialize(TextReader tr) => null;
}
55 changes: 43 additions & 12 deletions Ical.Net/Utility/TextUtil.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,61 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using Ical.Net.Serialization;

namespace Ical.Net.Utility;

internal static class TextUtil
{
/// <summary> Folds lines at 75 characters, and prepends the next line with a space per RFC https://tools.ietf.org/html/rfc5545#section-3.1 </summary>
public static string FoldLines(string incoming)
/// <summary>
/// Folds lines to 75 octets. It appends a CrLf, and prepends the next line with a space.
/// per RFC https://tools.ietf.org/html/rfc5545#section-3.1
/// </summary>
public static void FoldLines(this StringBuilder result, string inputLine)
{
//The spec says nothing about trimming, but it seems reasonable...
var trimmed = incoming.Trim();
if (trimmed.Length <= 75)
if (Encoding.UTF8.GetByteCount(inputLine) <= 75)
{
return trimmed + SerializationConstants.LineBreak;
result.Append(inputLine);
result.Append(SerializationConstants.LineBreak);
return;
}

const int takeLimit = 74;
// Use ArrayPool<char> to rent arrays for processing
// Cannot use Span<char> and stackalloc because netstandard2.0 does not support it
var currentLineArray = new char[76]; // 75 characters + 1 space

var currentLineIndex = 0;
var byteCount = 0;
var charCount = 0;

while (charCount < inputLine.Length)
{
var currentCharByteCount = Encoding.UTF8.GetByteCount([inputLine[charCount]]);

if (byteCount + currentCharByteCount > 75)
{
result.Append(currentLineArray, 0, currentLineIndex);
result.Append(SerializationConstants.LineBreak);

var firstLine = trimmed.Substring(0, takeLimit);
var remainder = trimmed.Substring(takeLimit, trimmed.Length - takeLimit);
currentLineIndex = 0;
byteCount = 1;
currentLineArray[currentLineIndex++] = ' ';
}

currentLineArray[currentLineIndex++] = inputLine[charCount];
byteCount += currentCharByteCount;
charCount++;
}

// Append the remaining characters to the result
if (currentLineIndex > 0)
{
result.Append(currentLineArray, 0, currentLineIndex);
}

var chunkedRemainder = string.Join(SerializationConstants.LineBreak + " ", Chunk(remainder));
return firstLine + SerializationConstants.LineBreak + " " + chunkedRemainder + SerializationConstants.LineBreak;
result.Append(SerializationConstants.LineBreak);
}

public static IEnumerable<string> Chunk(string str, int chunkSize = 73)
Expand Down Expand Up @@ -74,4 +105,4 @@ public static TextReader Normalize(string s, SerializationContext ctx)

public static bool Contains(this string haystack, string needle, StringComparison stringComparison)
=> haystack.IndexOf(needle, stringComparison) >= 0;
}
}
Loading