fix(export): sanitize the suggested file name and harden Markdown escaping

This commit is contained in:
yyc12345 committed 2026-10-04 16:16:01 +08:00
1 parent 1140cd500b
commit 157f597eed
4 files changed
+122 -26

No files matched your search

+60 -3
View File
@@ -81,8 +81,8 @@ public class MarkdownExporterTests {
/// <param name="culture">The UI culture.</param>
/// <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();
@@ -181,7 +181,64 @@ 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]
public void TagTableCellsEscapeBackslashes() {
CultureInfo? original = Resources.Culture;
try {
Resources.Culture = new CultureInfo("en");
var tag = new TagModel(1, "a\\b", RgbColor.Parse("#112233"), "c\\|d");
string markdown = MarkdownExporter.Export(
"W",
[new ColumnModel(1, "C", string.Empty, 1, 1)],
[],
[(tag, 0L)],
ExportedAt);
StringAssert.Contains(markdown, "| a\\\\b | #112233 | c\\\\\\|d | 0 |");
} finally {
Resources.Culture = original;
}
}
[TestMethod]
public void TagNameWithBacktickUsesLongerFence() {
CultureInfo? original = Resources.Culture;
try {
Resources.Culture = new CultureInfo("en");
var tag = new TagModel(1, "a`b", 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, "``a`b``");
} finally {
Resources.Culture = original;
}
}
[TestMethod]
public void FileNameDropsInvalidCharactersAndFallsBackToAppName() {
CultureInfo? original = Resources.Culture;
try {
Resources.Culture = new CultureInfo("en");
// A drive root workspace name "D:\" must not leak ':' or '\' into the file name.
string name = MarkdownExporter.BuildFileName("D:\\", ExportedAt);
Assert.IsFalse(name.Contains(':'));
Assert.IsFalse(name.Contains('\\'));
// A name made only of invalid characters falls back to the application name.
string fallback = MarkdownExporter.BuildFileName("///", ExportedAt);
StringAssert.StartsWith(fallback, Resources.App_Name);
} finally {
Resources.Culture = original;
}
-3
View File
@@ -415,9 +415,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>
@@ -415,9 +415,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>
+62 -17
View File
@@ -7,10 +7,14 @@ 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 one time format come from the ResX resources in the
/// current UI language; every timestamp (export time and card created/modified
/// times) uses that per-language pattern. The document uses the platform's
/// newline. Card bodies and column descriptions are emitted verbatim; 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 '|' 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 {
/// <summary>
@@ -33,17 +37,19 @@ 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(culture, Resources.Export_WorkspaceHeading, SingleLine(workspaceName)));
builder.AppendLine(string.Format(
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) {
IReadOnlyList<CardModel> cardsInColumn = cards.Where(card => card.ColumnId == column.Id).ToArray();
builder.AppendLine(string.Format(culture, Resources.Export_ColumnHeading, column.Title, cardsInColumn.Count));
// 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).OrderBy(card => card.Id).ToArray();
builder.AppendLine(string.Format(culture, Resources.Export_ColumnHeading, SingleLine(column.Title), cardsInColumn.Count));
if (column.Description.Length > 0) {
builder.AppendLine(SingleLine(column.Description));
builder.AppendLine(column.Description);
}
builder.AppendLine();
@@ -58,16 +64,32 @@ public static class MarkdownExporter {
/// <summary>
/// Builds the default export file name in the UI language, for example
/// "MyRepo-export-20260930-142537.md".
/// "MyRepo-export-20260930-142537.md". Characters that are not allowed in a
/// file name on any supported platform are dropped from the workspace name
/// (a workspace at a drive root is named "D:\"); a name left empty falls
/// back to the application name.
/// </summary>
/// <param name="workspaceName">Workspace display name.</param>
/// <param name="exportedAt">Export moment.</param>
/// <returns>The file name.</returns>
public static string BuildFileName(string workspaceName, DateTimeOffset exportedAt) {
CultureInfo culture = Resources.Culture ?? CultureInfo.CurrentUICulture;
return string.Format(culture, Resources.Export_FileNamePattern, workspaceName, exportedAt);
string safeName = new(workspaceName.Where(character => !IsInvalidFileNameCharacter(character)).ToArray());
if (safeName.Length == 0) {
safeName = Resources.App_Name;
}
return string.Format(culture, Resources.Export_FileNamePattern, safeName, exportedAt);
}
/// <summary>
/// Tells whether a character is invalid in a file name on Windows, Linux or
/// macOS; the Windows set is the widest, so the result is the same on every platform.
/// </summary>
/// <param name="character">The character to test.</param>
/// <returns><see langword="true"/> when the character must not appear in a file name.</returns>
private static bool IsInvalidFileNameCharacter(char character) =>
character < ' ' || character is '<' or '>' or ':' or '"' or '/' or '\\' or '|' or '?' or '*';
/// <summary>
/// Appends one card block: heading, body, tags line and timestamps line.
/// </summary>
@@ -85,12 +107,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();
}
@@ -115,11 +137,34 @@ public static class MarkdownExporter {
}
/// <summary>
/// Formats a table cell: single line with escaped pipe characters.
/// Formats a table cell: escaped backslashes and 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) =>
// Backslashes first: the table splitter reads "\\" as an escaped backslash, so the
// '|' after a literal backslash would otherwise end the cell.
text.Replace("\\", "\\\\").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.