diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index ab3bedef6a..f53b5b7dbe 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -2900,7 +2900,27 @@ }, "backup": { "skip_file_data_help": "Skip backing up data files such as pictures and knowledge bases during backup, and only back up chat records and settings. Reduce space occupancy and speed up the backup speed.", - "skip_file_data_title": "Slim Backup" + "skip_file_data_title": "Slim Backup", + "singleFileOverwrite": { + "title": "Single File Overwrite Backup", + "help": "When auto backup is enabled and max backups is 1, use a fixed filename for each overwrite.", + "confirm": { + "title": "Enable Overwrite Backup", + "content1": "After enabling, auto backup will:", + "item1": "Use a fixed filename without timestamp", + "item2": "Overwrite the file with the same name each time", + "item3": "Keep only the latest backup file", + "note": "Note: This setting only takes effect when auto backup is enabled and max backups is 1" + } + }, + "singleFileName": { + "title": "Custom Filename (Optional)", + "placeholder": "e.g., cherry-studio...zip", + "help": "• Leave empty to use default format: cherry-studio.[hostname].[deviceType].zip\n• Supported variables: {hostname} - hostname, {device} - device type\n• Unsupported characters: <>:\"/\\|?*\n• Maximum length: 250 characters", + "invalid_chars": "Filename contains invalid characters", + "reserved": "Filename is a system reserved name", + "too_long": "Filename is too long" + } }, "clear_cache": { "button": "Clear Cache", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index ed728ef2c3..30cc9b4315 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -2900,7 +2900,27 @@ }, "backup": { "skip_file_data_help": "备份时跳过备份图片、知识库等数据文件,仅备份聊天记录和设置。减少空间占用,加快备份速度", - "skip_file_data_title": "精简备份" + "skip_file_data_title": "精简备份", + "singleFileOverwrite": { + "title": "覆盖式单文件备份(同名覆盖)", + "help": "当自动备份开启且保留份数为1时,使用固定文件名每次覆盖。", + "confirm": { + "title": "启用覆盖式备份", + "content1": "启用后,自动备份将:", + "item1": "使用固定文件名,不再添加时间戳", + "item2": "每次备份都会覆盖同名文件", + "item3": "仅保留最新的一个备份文件", + "note": "注意:此设置仅在自动备份且保留份数为1时生效" + } + }, + "singleFileName": { + "title": "自定义文件名(可选)", + "placeholder": "如:cherry-studio...zip", + "help": "• 留空将使用默认格式:cherry-studio.[主机名].[设备类型].zip\n• 支持的变量:{hostname} - 主机名,{device} - 设备类型\n• 不支持的字符:<>:\"/\\|?*\n• 最大长度:250个字符", + "invalid_chars": "文件名包含无效字符", + "reserved": "文件名是系统保留名称", + "too_long": "文件名过长" + } }, "clear_cache": { "button": "清除缓存", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 89b51d5c1b..99977ca126 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -2900,7 +2900,27 @@ }, "backup": { "skip_file_data_help": "備份時跳過備份圖片、知識庫等數據文件,僅備份聊天記錄和設置。減少空間佔用,加快備份速度", - "skip_file_data_title": "精簡備份" + "skip_file_data_title": "精簡備份", + "singleFileOverwrite": { + "title": "覆蓋式單文件備份(同名覆蓋)", + "help": "當自動備份開啟且保留份數為1時,使用固定文件名每次覆蓋。", + "confirm": { + "title": "啟用覆蓋式備份", + "content1": "啟用後,自動備份將:", + "item1": "使用固定文件名,不再添加時間戳", + "item2": "每次備份都會覆蓋同名文件", + "item3": "僅保留最新的一個備份文件", + "note": "注意:此設定僅在自動備份且保留份數為1時生效" + } + }, + "singleFileName": { + "title": "自定義文件名(可選)", + "placeholder": "如:cherry-studio...zip", + "help": "• 留空將使用預設格式:cherry-studio.[主機名].[設備類型].zip\n• 支援的變數:{hostname} - 主機名,{device} - 設備類型\n• 不支援的字元:<>:\"/\\|?*\n• 最大長度:250個字元", + "invalid_chars": "文件名包含無效字元", + "reserved": "文件名是系統保留名稱", + "too_long": "文件名過長" + } }, "clear_cache": { "button": "清除快取", diff --git a/src/renderer/src/pages/settings/DataSettings/S3Settings.tsx b/src/renderer/src/pages/settings/DataSettings/S3Settings.tsx index d581612384..1f1bcb70e6 100644 --- a/src/renderer/src/pages/settings/DataSettings/S3Settings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/S3Settings.tsx @@ -42,8 +42,8 @@ const S3Settings: FC = () => { const [secretAccessKey, setSecretAccessKey] = useState(s3SecretAccessKeyInit) const [root, setRoot] = useState(s3RootInit) const [skipBackupFile, setSkipBackupFile] = useState(s3SkipBackupFileInit) - const [singleFileOverwrite, setSingleFileOverwrite] = useState(!!s3SingleFileOverwriteInit) - const [singleFileName, setSingleFileName] = useState(s3SingleFileNameInit) + const [singleFileOverwrite, setSingleFileOverwrite] = useState(s3SingleFileOverwriteInit ?? false) + const [singleFileName, setSingleFileName] = useState(s3SingleFileNameInit ?? '') const [backupManagerVisible, setBackupManagerVisible] = useState(false) const [syncInterval, setSyncInterval] = useState(s3SyncIntervalInit) diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 6ae0579d13..059db3fcaf 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -456,6 +456,10 @@ export type WebDavConfig = { fileName?: string skipBackupFile?: boolean disableStream?: boolean + /** 当自动备份且保留份数=1时,是否启用覆盖式单文件备份 */ + singleFileOverwrite?: boolean + /** 覆盖式单文件备份的自定义文件名(可选,默认使用不带时间戳的设备名+主机名) */ + singleFileName?: string } export type AppInfo = { diff --git a/src/renderer/src/utils/__tests__/backupUtils.test.ts b/src/renderer/src/utils/__tests__/backupUtils.test.ts new file mode 100644 index 0000000000..f5c914372d --- /dev/null +++ b/src/renderer/src/utils/__tests__/backupUtils.test.ts @@ -0,0 +1,302 @@ +/** + * Tests for backup utility functions + */ + +import { + generateDefaultFilename, + generateOverwriteFilename, + generateTimestampedFilename, + shouldSkipCleanup, + validateAndSanitizeFilename +} from '../backupUtils' + +describe('backupUtils', () => { + describe('validateAndSanitizeFilename', () => { + describe('基本功能测试', () => { + it('当文件名为 undefined 时应返回默认名称', () => { + const result = validateAndSanitizeFilename(undefined, 'default.zip') + expect(result).toBe('default.zip') + }) + + it('当文件名为空字符串时应返回默认名称', () => { + const result = validateAndSanitizeFilename('', 'default.zip') + expect(result).toBe('default.zip') + }) + + it('当文件名只包含空格时应返回默认名称', () => { + const result = validateAndSanitizeFilename(' ', 'default.zip') + expect(result).toBe('default.zip') + }) + + it('应自动添加 .zip 扩展名', () => { + const result = validateAndSanitizeFilename('backup', 'default.zip') + expect(result).toBe('backup.zip') + }) + + it('应保留已有的 .zip 扩展名', () => { + const result = validateAndSanitizeFilename('backup.zip', 'default.zip') + expect(result).toBe('backup.zip') + }) + + it('应处理大写的 .ZIP 扩展名', () => { + const result = validateAndSanitizeFilename('backup.ZIP', 'default.zip') + expect(result).toBe('backup.ZIP') + }) + + it('应修剪文件名前后的空格', () => { + const result = validateAndSanitizeFilename(' backup.zip ', 'default.zip') + expect(result).toBe('backup.zip') + }) + }) + + describe('无效字符测试', () => { + it('应拒绝包含 < 的文件名', () => { + const result = validateAndSanitizeFilename('backup', 'default.zip') + expect(result).toBe('default.zip') + }) + + it('应拒绝包含 > 的文件名', () => { + const result = validateAndSanitizeFilename('backup>test', 'default.zip') + expect(result).toBe('default.zip') + }) + + it('应拒绝包含 : 的文件名', () => { + const result = validateAndSanitizeFilename('backup:test', 'default.zip') + expect(result).toBe('default.zip') + }) + + it('应拒绝包含 " 的文件名', () => { + const result = validateAndSanitizeFilename('backup"test', 'default.zip') + expect(result).toBe('default.zip') + }) + + it('应拒绝包含 / 的文件名', () => { + const result = validateAndSanitizeFilename('backup/test', 'default.zip') + expect(result).toBe('default.zip') + }) + + it('应拒绝包含 \\ 的文件名', () => { + const result = validateAndSanitizeFilename('backup\\test', 'default.zip') + expect(result).toBe('default.zip') + }) + + it('应拒绝包含 | 的文件名', () => { + const result = validateAndSanitizeFilename('backup|test', 'default.zip') + expect(result).toBe('default.zip') + }) + + it('应拒绝包含 ? 的文件名', () => { + const result = validateAndSanitizeFilename('backup?test', 'default.zip') + expect(result).toBe('default.zip') + }) + + it('应拒绝包含 * 的文件名', () => { + const result = validateAndSanitizeFilename('backup*test', 'default.zip') + expect(result).toBe('default.zip') + }) + + it('应拒绝包含混合无效字符的文件名', () => { + const result = validateAndSanitizeFilename('backup<>:"/\\|?*test', 'default.zip') + expect(result).toBe('default.zip') + }) + }) + + describe('保留名称测试', () => { + it('应拒绝 Windows 保留名称 CON', () => { + const result = validateAndSanitizeFilename('CON', 'default.zip') + expect(result).toBe('default.zip') + }) + + it('应拒绝 Windows 保留名称 PRN', () => { + const result = validateAndSanitizeFilename('PRN', 'default.zip') + expect(result).toBe('default.zip') + }) + + it('应拒绝 Windows 保留名称 AUX', () => { + const result = validateAndSanitizeFilename('AUX', 'default.zip') + expect(result).toBe('default.zip') + }) + + it('应拒绝 Windows 保留名称 NUL', () => { + const result = validateAndSanitizeFilename('NUL', 'default.zip') + expect(result).toBe('default.zip') + }) + + it('应拒绝 Windows 保留名称 COM1', () => { + const result = validateAndSanitizeFilename('COM1', 'default.zip') + expect(result).toBe('default.zip') + }) + + it('应拒绝 Windows 保留名称 LPT1', () => { + const result = validateAndSanitizeFilename('LPT1', 'default.zip') + expect(result).toBe('default.zip') + }) + + it('应拒绝大小写的保留名称 con', () => { + const result = validateAndSanitizeFilename('con', 'default.zip') + expect(result).toBe('default.zip') + }) + + it('应拒绝带扩展名的保留名称 CON.zip', () => { + const result = validateAndSanitizeFilename('CON.zip', 'default.zip') + expect(result).toBe('default.zip') + }) + }) + + describe('长度限制测试', () => { + it('应截断过长的文件名', () => { + const longName = 'a'.repeat(260) + const result = validateAndSanitizeFilename(longName, 'default.zip') + expect(result.length).toBeLessThanOrEqual(254) // 250 chars + .zip + }) + + it('应正确处理正好250字符的文件名', () => { + const name = 'a'.repeat(246) + '.zip' // Total 250 chars + const result = validateAndSanitizeFilename(name, 'default.zip') + expect(result).toBe(name) + }) + + it('应截断251字符的文件名', () => { + const name = 'a'.repeat(247) + '.zip' // Total 251 chars + const result = validateAndSanitizeFilename(name, 'default.zip') + expect(result.length).toBe(254) + }) + }) + }) + + describe('shouldSkipCleanup', () => { + describe('各种组合场景测试', () => { + it('自动备份且单文件覆盖时应跳过清理', () => { + const result = shouldSkipCleanup(true, 1, true) + expect(result).toBe(true) + }) + + it('最大备份数大于1时不应跳过清理', () => { + const result = shouldSkipCleanup(true, 3, true) + expect(result).toBe(false) + }) + + it('非自动备份时不应跳过清理', () => { + const result = shouldSkipCleanup(false, 1, true) + expect(result).toBe(false) + }) + + it('单文件覆盖禁用时不应跳过清理', () => { + const result = shouldSkipCleanup(true, 1, false) + expect(result).toBe(false) + }) + + it('单文件覆盖为 undefined 时不应跳过清理', () => { + const result = shouldSkipCleanup(true, 1, undefined) + expect(result).toBe(false) + }) + + it('最大备份数为0时不应跳过清理', () => { + const result = shouldSkipCleanup(true, 0, true) + expect(result).toBe(false) + }) + + it('所有条件都为 false 时不应跳过清理', () => { + const result = shouldSkipCleanup(false, 0, false) + expect(result).toBe(false) + }) + }) + }) + + describe('generateDefaultFilename', () => { + it('应生成不带时间戳的默认文件名', () => { + const result = generateDefaultFilename('myhost', 'desktop') + expect(result).toBe('cherry-studio.myhost.desktop.zip') + }) + + it('应生成带时间戳的默认文件名', () => { + const result = generateDefaultFilename('myhost', 'desktop', '20240101120000') + expect(result).toBe('cherry-studio.myhost.desktop.20240101120000.zip') + }) + + it('应处理包含特殊字符的主机名', () => { + const result = generateDefaultFilename('my-host_pc', 'desktop') + expect(result).toBe('cherry-studio.my-host_pc.desktop.zip') + }) + }) + + describe('generateOverwriteFilename', () => { + it('应使用自定义文件名', () => { + const result = generateOverwriteFilename('my-backup', 'myhost', 'desktop') + expect(result).toBe('my-backup.zip') + }) + + it('应使用默认文件名当自定义文件名为空', () => { + const result = generateOverwriteFilename('', 'myhost', 'desktop') + expect(result).toBe('cherry-studio.myhost.desktop.zip') + }) + + it('应使用默认文件名当自定义文件名为 undefined', () => { + const result = generateOverwriteFilename(undefined, 'myhost', 'desktop') + expect(result).toBe('cherry-studio.myhost.desktop.zip') + }) + + it('应清理包含无效字符的自定义文件名', () => { + const result = generateOverwriteFilename('my', 'myhost', 'desktop') + expect(result).toBe('cherry-studio.myhost.desktop.zip') + }) + + it('应清理包含保留名称的自定义文件名', () => { + const result = generateOverwriteFilename('CON', 'myhost', 'desktop') + expect(result).toBe('cherry-studio.myhost.desktop.zip') + }) + + it('应截断过长的自定义文件名', () => { + const longName = 'a'.repeat(260) + const result = generateOverwriteFilename(longName, 'myhost', 'desktop') + expect(result.length).toBeLessThanOrEqual(254) + }) + + it('应保留自定义文件名的大小写', () => { + const result = generateOverwriteFilename('My-Backup.ZIP', 'myhost', 'desktop') + expect(result).toBe('My-Backup.ZIP') + }) + }) + + describe('generateTimestampedFilename', () => { + it('应使用自定义文件名作为基础并添加时间戳', () => { + const result = generateTimestampedFilename('my-backup', 'myhost', 'desktop', '20240101120000') + expect(result).toBe('my-backup.20240101120000.zip') + }) + + it('应使用默认文件名当自定义文件名为空', () => { + const result = generateTimestampedFilename('', 'myhost', 'desktop', '20240101120000') + expect(result).toBe('cherry-studio.myhost.desktop.20240101120000.zip') + }) + + it('应使用默认文件名当自定义文件名为 undefined', () => { + const result = generateTimestampedFilename(undefined, 'myhost', 'desktop', '20240101120000') + expect(result).toBe('cherry-studio.myhost.desktop.20240101120000.zip') + }) + + it('应使用默认文件名当自定义文件名只包含空格', () => { + const result = generateTimestampedFilename(' ', 'myhost', 'desktop', '20240101120000') + expect(result).toBe('cherry-studio.myhost.desktop.20240101120000.zip') + }) + + it('应修剪自定义文件名的前后空格', () => { + const result = generateTimestampedFilename(' my-backup ', 'myhost', 'desktop', '20240101120000') + expect(result).toBe('my-backup.20240101120000.zip') + }) + + it('应移除自定义文件名的 .zip 扩展名后添加时间戳', () => { + const result = generateTimestampedFilename('my-backup.zip', 'myhost', 'desktop', '20240101120000') + expect(result).toBe('my-backup.20240101120000.zip') + }) + + it('应处理自定义文件名的大写 .ZIP 扩展名', () => { + const result = generateTimestampedFilename('my-backup.ZIP', 'myhost', 'desktop', '20240101120000') + expect(result).toBe('my-backup.20240101120000.zip') + }) + + it('应生成正确的时间戳格式', () => { + const result = generateTimestampedFilename('backup', 'host', 'device', '20241231235959') + expect(result).toBe('backup.20241231235959.zip') + }) + }) +})