diff --git a/YKanBan.Tests/Export/MarkdownExporterTests.cs b/YKanBan.Tests/Export/MarkdownExporterTests.cs index 646b1bd..2b5e267 100644 --- a/YKanBan.Tests/Export/MarkdownExporterTests.cs +++ b/YKanBan.Tests/Export/MarkdownExporterTests.cs @@ -181,6 +181,21 @@ public class MarkdownExporterTests } } + [TestMethod] + [DataRow("D:\\", "D")] + [DataRow("ac|d?e*f\"g", "abcdefg")] + [DataRow("/", null)] + public void FileNameDropsCharactersThatAreInvalidInFileNames(string workspaceName, string? expectedName) + { + string fileName = MarkdownExporter.BuildFileName(workspaceName, ExportedAt); + + CultureInfo culture = Resources.Culture ?? CultureInfo.CurrentUICulture; + Assert.AreEqual( + string.Format(culture, Resources.Export_FileNamePattern, expectedName ?? Resources.App_Name, ExportedAt), + fileName); + Assert.AreEqual(-1, fileName.IndexOfAny(Path.GetInvalidFileNameChars())); + } + [TestMethod] public void TagTableCellsEscapePipesAndLineBreaks() { diff --git a/YKanBan/Export/MarkdownExporter.cs b/YKanBan/Export/MarkdownExporter.cs index 32077dd..8f86996 100644 --- a/YKanBan/Export/MarkdownExporter.cs +++ b/YKanBan/Export/MarkdownExporter.cs @@ -67,7 +67,10 @@ 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. @@ -75,9 +78,23 @@ public static class MarkdownExporter 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. /// diff --git a/docs/PLAN.md b/docs/PLAN.md index c373feb..f74b778 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -16,7 +16,7 @@ - 网络共享明确不支持;命令行参数边界;macOS 无参启动行为写入文档(§2.3、§4.1、附录) - 补充:导出转义规则、卡片列表虚拟化、模态窗口 FlowDirection、`Resources` 类名使用规则、CI osx-x64 运行器(§5、§7、§8) - 依赖:Avalonia 11.3.6 → 11.3.22,Semi.Avalonia 11.2.1.10 → 11.3.22(§7) -- 实现回补:搜索括号嵌套上限 64 层、单个表达式至多 256 个原子,空短语、短语紧贴文本、`key:` 后空白均为非法(§6.1、§6.2);导出表格单元格转义 `\`(§5.8);初始化中途锁冲突进锁冲突页、数据库文件缺失不静默新建(§4.4);app.json 读不出 / 备份写不出 / 退出写盘失败均不阻断启动与退出(§4.3) +- 实现回补:搜索括号嵌套上限 64 层、单个表达式至多 256 个原子,空短语、短语紧贴文本、`key:` 后空白均为非法(§6.1、§6.2);导出表格单元格转义 `\`、默认文件名剔除非法字符(§5.8);初始化中途锁冲突进锁冲突页、数据库文件缺失不静默新建(§4.4);app.json 读不出 / 备份写不出 / 退出写盘失败均不阻断启动与退出(§4.3) - 里程碑:标注 M0–M4 已完成,新增 **M4R(v1.9 回补)**(§9) ## 1. 项目概述 @@ -223,7 +223,7 @@ card_tags: (card_id, tag_id) 复合主键,双向 ON DELETE CASCADE ### 5.8 导出 -- 点击后弹系统保存文件对话框,默认文件名按当前语言生成(模式为资源键,**精确到秒**):英文 `MyRepo-export-20260930-142537.md`;中文 `MyRepo-导出-20260930-142537.md` +- 点击后弹系统保存文件对话框,默认文件名按当前语言生成(模式为资源键,**精确到秒**):英文 `MyRepo-export-20260930-142537.md`;中文 `MyRepo-导出-20260930-142537.md`;工作区名中任一平台的文件名非法字符(`<>:"/\|?*` 与控制字符)剔除,剔除后为空(如工作区位于 `/`)则用 `YKanBan` - 人类可读格式,无回导需求;模板文本直接读取 ResX 资源(见 §8),导出语言跟随当前界面语言 - 导出范围为工作区全量数据,不受当前搜索筛选与卡片排序选项影响:列按 id 升序,列内卡片按 id 升序 - 全文时间戳(导出时间与卡片创建/修改时间)格式随语言:英文 `yyyy-MM-dd HH:mm:ss`;中文 `yyyy年M月d日 HH:mm`