fix: align markdown export escaping and time format with plan

This commit is contained in:
doyaGu committed 2026-10-03 02:56:18 -04:00
1 parent 4b56297135
commit cde9e8e20c
4 files changed
+154 -23

No files matched your search

+113 -3
View File
@@ -84,8 +84,8 @@ public class MarkdownExporterTests
/// <returns>The expected Markdown document.</returns>
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<br>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
{
-3
View File
@@ -142,9 +142,6 @@
<data name="Export_TimePattern" xml:space="preserve">
<value>yyyy-MM-dd HH:mm:ss</value>
</data>
<data name="Export_CardTimePattern" xml:space="preserve">
<value>yyyy-MM-dd HH:mm</value>
</data>
<data name="Export_WorkspaceHeading" xml:space="preserve">
<value># {0}</value>
</data>
@@ -142,9 +142,6 @@
<data name="Export_TimePattern" xml:space="preserve">
<value>yyyy年M月d日 HH:mm</value>
</data>
<data name="Export_CardTimePattern" xml:space="preserve">
<value>yyyy年M月d日 HH:mm</value>
</data>
<data name="Export_WorkspaceHeading" xml:space="preserve">
<value># {0}</value>
</data>
+41 -14
View File
@@ -6,10 +6,12 @@ namespace YKanBan.Export;
/// <summary>
/// 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 &lt;br&gt;. The
/// output is meant for reading only — there is no re-import.
/// </summary>
public static class MarkdownExporter
{
@@ -17,7 +19,7 @@ public static class MarkdownExporter
/// Renders the whole board to Markdown.
/// </summary>
/// <param name="workspaceName">Workspace display name (the folder name).</param>
/// <param name="columns">All columns, in id order; empty columns are kept.</param>
/// <param name="columns">All columns; emitted in id order, empty columns kept.</param>
/// <param name="cards">All cards; grouped by column, id order within a column.</param>
/// <param name="tags">All tags with usage counts, in id order.</param>
/// <param name="exportedAt">Export moment in local time.</param>
@@ -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<CardModel> cardsInColumn = cards.Where(card => card.ColumnId == column.Id).ToArray();
IReadOnlyList<CardModel> 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
}
/// <summary>
/// Formats a table cell: single line with escaped pipe characters.
/// Formats a table cell: escaped pipe characters, line breaks as &lt;br&gt;.
/// </summary>
/// <param name="text">The raw cell text.</param>
/// <returns>The escaped single-line text.</returns>
private static string Cell(string text) => SingleLine(text).Replace("|", "\\|");
private static string Cell(string text) =>
text.Replace("|", "\\|").Replace("\r\n", "<br>").Replace("\r", "<br>").Replace("\n", "<br>");
/// <summary>
/// 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.
/// </summary>
/// <param name="text">The raw text.</param>
/// <returns>The inline code span.</returns>
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;
}
/// <summary>
/// Collapses line breaks so the text stays on one Markdown line.