From 8e144b9154720d923490c812e0aebe54854c58c5 Mon Sep 17 00:00:00 2001 From: doyaGu Date: Sat, 3 Oct 2026 08:12:53 -0400 Subject: [PATCH] fix(export): write LF line endings and keep titles on one line AppendLine wrote CRLF on Windows; the document now uses LF everywhere and converts CRLF/CR inside card bodies and column descriptions. Column titles and the workspace name are collapsed to one line like card titles. --- YKanBan.Tests/Export/MarkdownExporterTests.cs | 57 +++++++++++++++++-- YKanBan/Export/MarkdownExporter.cs | 51 +++++++++++------ 2 files changed, 85 insertions(+), 23 deletions(-) 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. ///