diff --git a/YKanBan.Tests/Export/MarkdownExporterTests.cs b/YKanBan.Tests/Export/MarkdownExporterTests.cs
index b89e9d5..9d8e51a 100644
--- a/YKanBan.Tests/Export/MarkdownExporterTests.cs
+++ b/YKanBan.Tests/Export/MarkdownExporterTests.cs
@@ -123,7 +123,7 @@ public class MarkdownExporterTests
}
///
- /// Normalizes CRLF line endings so expected and actual compare consistently.
+ /// Normalizes the expected document, built with the platform newline, to the exporter's LF.
///
/// The text to normalize.
/// The text with normalized line endings.
@@ -139,7 +139,7 @@ public class MarkdownExporterTests
try
{
Resources.Culture = culture;
- Assert.AreEqual(Normalize(BuildExpected(culture)), Normalize(ExportBoard()));
+ Assert.AreEqual(Normalize(BuildExpected(culture)), ExportBoard());
}
finally
{
@@ -225,7 +225,7 @@ public class MarkdownExporterTests
[(tag, 1L)],
ExportedAt);
- StringAssert.Contains(markdown, string.Format(Resources.Culture, Resources.Export_TagsLine, expected) + Environment.NewLine);
+ StringAssert.Contains(markdown, string.Format(Resources.Culture, Resources.Export_TagsLine, expected) + "\n");
}
finally
{
@@ -240,12 +240,12 @@ public class MarkdownExporterTests
try
{
Resources.Culture = new CultureInfo("en");
- string markdown = Normalize(MarkdownExporter.Export(
+ string markdown = MarkdownExporter.Export(
"W",
[new ColumnModel(1, "C", "line one\n# not a heading | pipe", 1, 1)],
[new CardModel(1, 1, "T", "body\n\n## raw | `md`", 1, 1, [])],
[],
- ExportedAt));
+ ExportedAt);
StringAssert.Contains(markdown, "\nline one\n# not a heading | pipe\n");
StringAssert.Contains(markdown, "\nbody\n\n## raw | `md`\n");
@@ -256,6 +256,53 @@ public class MarkdownExporterTests
}
}
+ [TestMethod]
+ public void LineEndingsAreLfOnEveryPlatform()
+ {
+ CultureInfo? original = Resources.Culture;
+ try
+ {
+ Resources.Culture = new CultureInfo("en");
+ string markdown = MarkdownExporter.Export(
+ "W",
+ [new ColumnModel(1, "C", "one\r\ntwo\rthree", 1, 1)],
+ [new CardModel(1, 1, "T", "a\r\nb", 1, 1, [])],
+ [],
+ ExportedAt);
+
+ Assert.IsFalse(markdown.Contains('\r'));
+ StringAssert.Contains(markdown, "\none\ntwo\nthree\n");
+ StringAssert.Contains(markdown, "\na\nb\n");
+ }
+ finally
+ {
+ Resources.Culture = original;
+ }
+ }
+
+ [TestMethod]
+ public void ColumnTitleIsCollapsedToOneLine()
+ {
+ CultureInfo? original = Resources.Culture;
+ try
+ {
+ Resources.Culture = new CultureInfo("en");
+ string markdown = MarkdownExporter.Export(
+ "W",
+ [new ColumnModel(1, "To\r\nDo\nNow", string.Empty, 1, 1)],
+ [],
+ [],
+ ExportedAt);
+
+ string heading = string.Format(Resources.Culture, Resources.Export_ColumnHeading, "To Do Now", 0);
+ StringAssert.Contains(markdown, "\n" + heading + "\n");
+ }
+ finally
+ {
+ Resources.Culture = original;
+ }
+ }
+
[TestMethod]
public void ColumnsAndCardsAreExportedInIdOrder()
{
diff --git a/YKanBan/Export/MarkdownExporter.cs b/YKanBan/Export/MarkdownExporter.cs
index 0fcfe10..d2a99fa 100644
--- a/YKanBan/Export/MarkdownExporter.cs
+++ b/YKanBan/Export/MarkdownExporter.cs
@@ -8,9 +8,10 @@ namespace YKanBan.Export;
/// Generates the human-readable Markdown snapshot of a whole board. All
/// template text and the time format come from the ResX resources in the current
/// UI language; every timestamp (export time and card created/modified times)
-/// uses that one per-language pattern. Card bodies and column descriptions are
-/// emitted verbatim; tag names become inline code with a long enough backtick
-/// fence; tag table cells escape '|' and turn line breaks into <br>. The
+/// uses that one per-language pattern. The whole document uses LF line endings
+/// on every platform. Card bodies and column descriptions are emitted verbatim
+/// apart from line endings; column and card titles are collapsed to one line;
+/// tag names become inline code with a long enough backtick fence; tag table cells escape '|' and turn line breaks into <br>. The
/// output is meant for reading only — there is no re-import.
///
public static class MarkdownExporter
@@ -36,10 +37,10 @@ public static class MarkdownExporter
var builder = new StringBuilder();
// Header: workspace name and the export timestamp in the UI language's format.
- builder.AppendLine(string.Format(culture, Resources.Export_WorkspaceHeading, workspaceName));
- builder.AppendLine(string.Format(
+ AppendLine(builder, string.Format(culture, Resources.Export_WorkspaceHeading, SingleLine(workspaceName)));
+ AppendLine(builder, string.Format(
culture, Resources.Export_ExportedAtLine, exportedAt.ToString(Resources.Export_TimePattern, culture)));
- builder.AppendLine();
+ AppendLine(builder);
// Board body: every column in id order, empty ones included; independent of
// the board's search filter and card sort option.
@@ -47,12 +48,12 @@ public static class MarkdownExporter
{
IReadOnlyList cardsInColumn =
cards.Where(card => card.ColumnId == column.Id).OrderBy(card => card.Id).ToArray();
- builder.AppendLine(string.Format(culture, Resources.Export_ColumnHeading, column.Title, cardsInColumn.Count));
+ AppendLine(builder, string.Format(culture, Resources.Export_ColumnHeading, SingleLine(column.Title), cardsInColumn.Count));
if (column.Description.Length > 0)
{
- builder.AppendLine(column.Description);
+ AppendLine(builder, ToLf(column.Description));
}
- builder.AppendLine();
+ AppendLine(builder);
foreach (CardModel card in cardsInColumn)
{
@@ -86,25 +87,25 @@ public static class MarkdownExporter
private static void AppendCard(StringBuilder builder, CardModel card, CultureInfo culture)
{
// Heading: "#id title"; an untitled card shows only the id.
- builder.AppendLine(card.Title.Length == 0
+ AppendLine(builder, card.Title.Length == 0
? $"### #{card.Id}"
: $"### #{card.Id} {SingleLine(card.Title)}");
if (card.Content.Length > 0)
{
- builder.AppendLine(card.Content);
+ AppendLine(builder, ToLf(card.Content));
}
if (card.Tags.Count > 0)
{
string names = string.Join(" ", card.Tags.Select(tag => InlineCode(tag.Name)));
- builder.AppendLine(string.Format(culture, Resources.Export_TagsLine, names));
+ AppendLine(builder, string.Format(culture, Resources.Export_TagsLine, names));
}
string created = ToLocalTime(card.CreatedAt).ToString(Resources.Export_TimePattern, culture);
string updated = ToLocalTime(card.UpdatedAt).ToString(Resources.Export_TimePattern, culture);
- builder.AppendLine(string.Format(culture, Resources.Export_CreatedModifiedLine, created, updated));
- builder.AppendLine();
+ AppendLine(builder, string.Format(culture, Resources.Export_CreatedModifiedLine, created, updated));
+ AppendLine(builder);
}
///
@@ -118,13 +119,13 @@ public static class MarkdownExporter
IReadOnlyList<(TagModel Tag, long UsageCount)> tags,
CultureInfo culture)
{
- builder.AppendLine(Resources.Export_TagsSummaryHeading);
- builder.AppendLine(Resources.Export_TagsTableHeader);
- builder.AppendLine("|---|---|---|---|");
+ AppendLine(builder, Resources.Export_TagsSummaryHeading);
+ AppendLine(builder, Resources.Export_TagsTableHeader);
+ AppendLine(builder, "|---|---|---|---|");
foreach ((TagModel tag, long usage) in tags)
{
// '|' and line breaks would break the table row; neutralize them.
- builder.AppendLine($"| {Cell(tag.Name)} | {tag.Color} | {Cell(tag.Description)} | {usage} |");
+ AppendLine(builder, $"| {Cell(tag.Name)} | {tag.Color} | {Cell(tag.Description)} | {usage} |");
}
}
@@ -158,6 +159,20 @@ public static class MarkdownExporter
return fence + padding + text + padding + fence;
}
+ ///
+ /// Appends a line terminated by LF, whatever the platform's newline.
+ ///
+ /// The output builder.
+ /// The line text; empty for a blank line.
+ private static void AppendLine(StringBuilder builder, string text = "") => builder.Append(text).Append('\n');
+
+ ///
+ /// Converts CRLF and lone CR line breaks to LF.
+ ///
+ /// The raw text.
+ /// The text with LF line breaks only.
+ private static string ToLf(string text) => text.Replace("\r\n", "\n").Replace('\r', '\n');
+
///
/// Collapses line breaks so the text stays on one Markdown line.
///