fix: align markdown export escaping and time format with plan
This commit is contained in:
1 parent
4b56297135
commit
cde9e8e20c
4 files changed
+154
-23
No files matched your search
@@ -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
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 <br>. 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 <br>.
|
||||
/// </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.
|
||||
|
||||
Reference in new issue
Block a user