diff --git a/YKanBan.Tests/Export/MarkdownExporterTests.cs b/YKanBan.Tests/Export/MarkdownExporterTests.cs index c7f9a75..b89e9d5 100644 --- a/YKanBan.Tests/Export/MarkdownExporterTests.cs +++ b/YKanBan.Tests/Export/MarkdownExporterTests.cs @@ -84,8 +84,8 @@ public class MarkdownExporterTests /// The expected Markdown document. private static string BuildExpected(CultureInfo culture) { - string created = Local(2026, 9, 1, 10, 0).ToString(Resources.Export_CardTimePattern, culture); - string updated = Local(2026, 9, 28, 18, 30).ToString(Resources.Export_CardTimePattern, culture); + string created = Local(2026, 9, 1, 10, 0).ToString(Resources.Export_TimePattern, culture); + string updated = Local(2026, 9, 28, 18, 30).ToString(Resources.Export_TimePattern, culture); var builder = new StringBuilder(); @@ -196,7 +196,117 @@ public class MarkdownExporterTests [(tag, 0L)], ExportedAt); - StringAssert.Contains(markdown, "| a\\|b | #112233 | first second | 0 |"); + StringAssert.Contains(markdown, "| a\\|b | #112233 | first
second | 0 |"); + } + finally + { + Resources.Culture = original; + } + } + + [TestMethod] + [DataRow("plain", "`plain`")] + [DataRow("a`b", "``a`b``")] + [DataRow("a``b`c", "```a``b`c```")] + [DataRow("`x", "`` `x ``")] + [DataRow("x`", "`` x` ``")] + [DataRow("`", "`` ` ``")] + public void TagNamesUseLongEnoughBacktickFence(string name, string expected) + { + CultureInfo? original = Resources.Culture; + try + { + Resources.Culture = new CultureInfo("en"); + var tag = new TagModel(1, name, RgbColor.Parse("#112233"), string.Empty); + string markdown = MarkdownExporter.Export( + "W", + [new ColumnModel(1, "C", string.Empty, 1, 1)], + [new CardModel(1, 1, "T", string.Empty, 1, 1, [tag])], + [(tag, 1L)], + ExportedAt); + + StringAssert.Contains(markdown, string.Format(Resources.Culture, Resources.Export_TagsLine, expected) + Environment.NewLine); + } + finally + { + Resources.Culture = original; + } + } + + [TestMethod] + public void CardBodyAndColumnDescriptionAreVerbatim() + { + CultureInfo? original = Resources.Culture; + try + { + Resources.Culture = new CultureInfo("en"); + string markdown = Normalize(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)); + + StringAssert.Contains(markdown, "\nline one\n# not a heading | pipe\n"); + StringAssert.Contains(markdown, "\nbody\n\n## raw | `md`\n"); + } + finally + { + Resources.Culture = original; + } + } + + [TestMethod] + public void ColumnsAndCardsAreExportedInIdOrder() + { + CultureInfo? original = Resources.Culture; + try + { + Resources.Culture = new CultureInfo("en"); + string markdown = MarkdownExporter.Export( + "W", + [new ColumnModel(2, "Second", string.Empty, 1, 1), new ColumnModel(1, "First", string.Empty, 1, 1)], + [ + new CardModel(9, 1, "Nine", string.Empty, 1, 1, []), + new CardModel(3, 2, "Three", string.Empty, 1, 1, []), + new CardModel(4, 1, "Four", string.Empty, 1, 1, []), + ], + [], + ExportedAt); + + int first = markdown.IndexOf(string.Format(Resources.Culture, Resources.Export_ColumnHeading, "First", 2), StringComparison.Ordinal); + int second = markdown.IndexOf(string.Format(Resources.Culture, Resources.Export_ColumnHeading, "Second", 1), StringComparison.Ordinal); + int four = markdown.IndexOf("### #4 Four", StringComparison.Ordinal); + int nine = markdown.IndexOf("### #9 Nine", StringComparison.Ordinal); + int three = markdown.IndexOf("### #3 Three", StringComparison.Ordinal); + Assert.IsTrue(first >= 0 && first < four && four < nine && nine < second && second < three, markdown); + } + finally + { + Resources.Culture = original; + } + } + + [TestMethod] + [DataRow("en", "2026-09-30 14:25:37", "2026-09-01 10:00:05")] + [DataRow("zh-Hans", "2026年9月30日 14:25", "2026年9月1日 10:00")] + public void TimestampsUseThePlannedPerLanguageFormat(string language, string exportedAt, string cardTime) + { + CultureInfo? original = Resources.Culture; + try + { + Resources.Culture = new CultureInfo(language); + long cardSeconds = new DateTimeOffset(new DateTime(2026, 9, 1, 10, 0, 5)).ToUnixTimeSeconds(); + var exportMoment = new DateTimeOffset(new DateTime(2026, 9, 30, 14, 25, 37)); + string markdown = MarkdownExporter.Export( + "W", + [new ColumnModel(1, "C", string.Empty, 1, 1)], + [new CardModel(1, 1, "T", string.Empty, cardSeconds, cardSeconds, [])], + [], + exportMoment); + + StringAssert.Contains(markdown, string.Format(Resources.Culture, Resources.Export_ExportedAtLine, exportedAt)); + StringAssert.Contains(markdown, string.Format(Resources.Culture, Resources.Export_CreatedModifiedLine, cardTime, cardTime)); } finally { diff --git a/YKanBan/Assets/Locales/Resources.resx b/YKanBan/Assets/Locales/Resources.resx index 7150648..7962c94 100644 --- a/YKanBan/Assets/Locales/Resources.resx +++ b/YKanBan/Assets/Locales/Resources.resx @@ -142,9 +142,6 @@ yyyy-MM-dd HH:mm:ss - - yyyy-MM-dd HH:mm - # {0} diff --git a/YKanBan/Assets/Locales/Resources.zh-Hans.resx b/YKanBan/Assets/Locales/Resources.zh-Hans.resx index 1abc7d7..a53f829 100644 --- a/YKanBan/Assets/Locales/Resources.zh-Hans.resx +++ b/YKanBan/Assets/Locales/Resources.zh-Hans.resx @@ -142,9 +142,6 @@ yyyy年M月d日 HH:mm - - yyyy年M月d日 HH:mm - # {0} diff --git a/YKanBan/Export/MarkdownExporter.cs b/YKanBan/Export/MarkdownExporter.cs index e126545..0fcfe10 100644 --- a/YKanBan/Export/MarkdownExporter.cs +++ b/YKanBan/Export/MarkdownExporter.cs @@ -6,10 +6,12 @@ namespace YKanBan.Export; /// /// Generates the human-readable Markdown snapshot of a whole board. All -/// template text and time formats come from the ResX resources in the current -/// UI language; the export timestamp uses the per-language pattern, card -/// timestamps use the shared minute-precision pattern. The output is meant for -/// reading only — there is no re-import. +/// 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 +/// output is meant for reading only — there is no re-import. /// public static class MarkdownExporter { @@ -17,7 +19,7 @@ public static class MarkdownExporter /// Renders the whole board to Markdown. /// /// Workspace display name (the folder name). - /// All columns, in id order; empty columns are kept. + /// All columns; emitted in id order, empty columns kept. /// All cards; grouped by column, id order within a column. /// All tags with usage counts, in id order. /// Export moment in local time. @@ -39,14 +41,16 @@ public static class MarkdownExporter culture, Resources.Export_ExportedAtLine, exportedAt.ToString(Resources.Export_TimePattern, culture))); builder.AppendLine(); - // Board body: every column in id order, empty ones included. - foreach (ColumnModel column in columns) + // Board body: every column in id order, empty ones included; independent of + // the board's search filter and card sort option. + foreach (ColumnModel column in columns.OrderBy(column => column.Id)) { - IReadOnlyList cardsInColumn = cards.Where(card => card.ColumnId == column.Id).ToArray(); + 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)); if (column.Description.Length > 0) { - builder.AppendLine(SingleLine(column.Description)); + builder.AppendLine(column.Description); } builder.AppendLine(); @@ -93,12 +97,12 @@ public static class MarkdownExporter if (card.Tags.Count > 0) { - string names = string.Join(" ", card.Tags.Select(tag => $"`{tag.Name}`")); + string names = string.Join(" ", card.Tags.Select(tag => InlineCode(tag.Name))); builder.AppendLine(string.Format(culture, Resources.Export_TagsLine, names)); } - string created = ToLocalTime(card.CreatedAt).ToString(Resources.Export_CardTimePattern, culture); - string updated = ToLocalTime(card.UpdatedAt).ToString(Resources.Export_CardTimePattern, culture); + 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(); } @@ -125,11 +129,34 @@ public static class MarkdownExporter } /// - /// Formats a table cell: single line with escaped pipe characters. + /// Formats a table cell: escaped pipe characters, line breaks as <br>. /// /// The raw cell text. /// The escaped single-line text. - private static string Cell(string text) => SingleLine(text).Replace("|", "\\|"); + private static string Cell(string text) => + text.Replace("|", "\\|").Replace("\r\n", "
").Replace("\r", "
").Replace("\n", "
"); + + /// + /// Renders text as an inline code span whose backtick fence is one longer than + /// the longest backtick run inside, padded with spaces when the text starts or + /// ends with a backtick. + /// + /// The raw text. + /// The inline code span. + private static string InlineCode(string text) + { + int longestRun = 0; + int run = 0; + foreach (char character in text) + { + run = character == '`' ? run + 1 : 0; + longestRun = Math.Max(longestRun, run); + } + + string fence = new('`', longestRun + 1); + string padding = text.StartsWith('`') || text.EndsWith('`') ? " " : string.Empty; + return fence + padding + text + padding + fence; + } /// /// Collapses line breaks so the text stays on one Markdown line.