fix(export): sanitize the suggested file name and harden Markdown escaping
This commit is contained in:
1 parent
1140cd500b
commit
157f597eed
4 files changed
+122
-26
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 <br>. 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 <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) =>
|
||||
// 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.
|
||||
|
||||
Reference in new issue
Block a user