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.