diff --git a/YKanBan.Tests/Export/MarkdownExporterTests.cs b/YKanBan.Tests/Export/MarkdownExporterTests.cs
index 6c98aa7..03a695e 100644
--- a/YKanBan.Tests/Export/MarkdownExporterTests.cs
+++ b/YKanBan.Tests/Export/MarkdownExporterTests.cs
@@ -81,8 +81,8 @@ public class MarkdownExporterTests {
/// The UI culture.
/// 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();
@@ -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
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;
}
diff --git a/YKanBan/Assets/Locales/Resources.resx b/YKanBan/Assets/Locales/Resources.resx
index c2c72d5..71f9f26 100644
--- a/YKanBan/Assets/Locales/Resources.resx
+++ b/YKanBan/Assets/Locales/Resources.resx
@@ -415,9 +415,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 aeee3f3..55924e0 100644
--- a/YKanBan/Assets/Locales/Resources.zh-Hans.resx
+++ b/YKanBan/Assets/Locales/Resources.zh-Hans.resx
@@ -415,9 +415,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 5576cb0..d2277b0 100644
--- a/YKanBan/Export/MarkdownExporter.cs
+++ b/YKanBan/Export/MarkdownExporter.cs
@@ -7,10 +7,14 @@ 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 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.
///
public static class MarkdownExporter {
///
@@ -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 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 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 {
///
/// 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.
///
/// Workspace display name.
/// Export moment.
/// The file name.
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);
}
+ ///
+ /// 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.
+ ///
+ /// The character to test.
+ /// when the character must not appear in a file name.
+ private static bool IsInvalidFileNameCharacter(char character) =>
+ character < ' ' || character is '<' or '>' or ':' or '"' or '/' or '\\' or '|' or '?' or '*';
+
///
/// Appends one card block: heading, body, tags line and timestamps line.
///
@@ -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 {
}
///
- /// Formats a table cell: single line with escaped pipe characters.
+ /// Formats a table cell: escaped backslashes and 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) =>
+ // 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", "
").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.