mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-31 00:10:22 +08:00
feat(settings): single-file overwrite and name validation
Initialize single-file overwrite and name state with safe defaults introduce a confirmation modal when enabling overwrite to warn users about the behavioral change (fixed filename, overwriting, keeping only the latest backup). Add onChange/onBlur handlers for the single-file name input that trim and validate the filename (disallow invalid characters, reserved Windows names, and overly long names) and show toast errors when validation fails. Wire dispatch updates only after confirmation or successful validation. Enhance help text for overwrite and the filename field to clarify recommended usage and behavior.
This commit is contained in:
parent
a2c1011c55
commit
d74d66dcbf
@ -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.<hostname>.<device>.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",
|
||||
|
||||
@ -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.<hostname>.<device>.zip",
|
||||
"help": "• 留空将使用默认格式:cherry-studio.[主机名].[设备类型].zip\n• 支持的变量:{hostname} - 主机名,{device} - 设备类型\n• 不支持的字符:<>:\"/\\|?*\n• 最大长度:250个字符",
|
||||
"invalid_chars": "文件名包含无效字符",
|
||||
"reserved": "文件名是系统保留名称",
|
||||
"too_long": "文件名过长"
|
||||
}
|
||||
},
|
||||
"clear_cache": {
|
||||
"button": "清除缓存",
|
||||
|
||||
@ -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.<hostname>.<device>.zip",
|
||||
"help": "• 留空將使用預設格式:cherry-studio.[主機名].[設備類型].zip\n• 支援的變數:{hostname} - 主機名,{device} - 設備類型\n• 不支援的字元:<>:\"/\\|?*\n• 最大長度:250個字元",
|
||||
"invalid_chars": "文件名包含無效字元",
|
||||
"reserved": "文件名是系統保留名稱",
|
||||
"too_long": "文件名過長"
|
||||
}
|
||||
},
|
||||
"clear_cache": {
|
||||
"button": "清除快取",
|
||||
|
||||
@ -42,8 +42,8 @@ const S3Settings: FC = () => {
|
||||
const [secretAccessKey, setSecretAccessKey] = useState<string | undefined>(s3SecretAccessKeyInit)
|
||||
const [root, setRoot] = useState<string | undefined>(s3RootInit)
|
||||
const [skipBackupFile, setSkipBackupFile] = useState<boolean>(s3SkipBackupFileInit)
|
||||
const [singleFileOverwrite, setSingleFileOverwrite] = useState<boolean>(!!s3SingleFileOverwriteInit)
|
||||
const [singleFileName, setSingleFileName] = useState<string | undefined>(s3SingleFileNameInit)
|
||||
const [singleFileOverwrite, setSingleFileOverwrite] = useState<boolean>(s3SingleFileOverwriteInit ?? false)
|
||||
const [singleFileName, setSingleFileName] = useState<string>(s3SingleFileNameInit ?? '')
|
||||
const [backupManagerVisible, setBackupManagerVisible] = useState(false)
|
||||
|
||||
const [syncInterval, setSyncInterval] = useState<number>(s3SyncIntervalInit)
|
||||
|
||||
@ -456,6 +456,10 @@ export type WebDavConfig = {
|
||||
fileName?: string
|
||||
skipBackupFile?: boolean
|
||||
disableStream?: boolean
|
||||
/** 当自动备份且保留份数=1时,是否启用覆盖式单文件备份 */
|
||||
singleFileOverwrite?: boolean
|
||||
/** 覆盖式单文件备份的自定义文件名(可选,默认使用不带时间戳的设备名+主机名) */
|
||||
singleFileName?: string
|
||||
}
|
||||
|
||||
export type AppInfo = {
|
||||
|
||||
302
src/renderer/src/utils/__tests__/backupUtils.test.ts
Normal file
302
src/renderer/src/utils/__tests__/backupUtils.test.ts
Normal file
@ -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<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')
|
||||
})
|
||||
|
||||
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<backup>', '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')
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user