Compare commits
7 Commits
fix/valida
...
feat/backu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c319a4f4d0 | ||
|
|
980f20fcca | ||
|
|
f846a27418 | ||
|
|
d74d66dcbf | ||
|
|
a2c1011c55 | ||
|
|
06cba0e3bf | ||
|
|
ae392eb2ef |
@@ -617,13 +617,8 @@ class BackupManager {
|
||||
}
|
||||
|
||||
async backupToS3(_: Electron.IpcMainInvokeEvent, data: string, s3Config: S3Config) {
|
||||
const os = require('os')
|
||||
const deviceName = os.hostname ? os.hostname() : 'device'
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[-:T.Z]/g, '')
|
||||
.slice(0, 14)
|
||||
const filename = s3Config.fileName || `cherry-studio.backup.${deviceName}.${timestamp}.zip`
|
||||
// Use the filename provided by frontend, or a simple default (no timestamp generation here)
|
||||
const filename = s3Config.fileName || 'cherry-studio.backup.zip'
|
||||
|
||||
logger.debug(`Starting S3 backup to ${filename}`)
|
||||
|
||||
|
||||
@@ -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": "清除快取",
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
setLocalBackupDir as _setLocalBackupDir,
|
||||
setLocalBackupMaxBackups as _setLocalBackupMaxBackups,
|
||||
setLocalBackupSkipBackupFile as _setLocalBackupSkipBackupFile,
|
||||
setLocalBackupSyncInterval as _setLocalBackupSyncInterval
|
||||
setLocalBackupSyncInterval as _setLocalBackupSyncInterval,
|
||||
setLocalSingleFileName as _setLocalSingleFileName,
|
||||
setLocalSingleFileOverwrite as _setLocalSingleFileOverwrite
|
||||
} from '@renderer/store/settings'
|
||||
import { AppInfo } from '@renderer/types'
|
||||
import { Button, Input, Switch, Tooltip } from 'antd'
|
||||
@@ -32,12 +34,18 @@ const LocalBackupSettings: React.FC = () => {
|
||||
localBackupDir: localBackupDirSetting,
|
||||
localBackupSyncInterval: localBackupSyncIntervalSetting,
|
||||
localBackupMaxBackups: localBackupMaxBackupsSetting,
|
||||
localBackupSkipBackupFile: localBackupSkipBackupFileSetting
|
||||
localBackupSkipBackupFile: localBackupSkipBackupFileSetting,
|
||||
localSingleFileOverwrite: localSingleFileOverwriteSetting,
|
||||
localSingleFileName: localSingleFileNameSetting
|
||||
} = useSettings()
|
||||
|
||||
const [localBackupDir, setLocalBackupDir] = useState<string | undefined>(localBackupDirSetting)
|
||||
const [resolvedLocalBackupDir, setResolvedLocalBackupDir] = useState<string | undefined>(undefined)
|
||||
const [localBackupSkipBackupFile, setLocalBackupSkipBackupFile] = useState<boolean>(localBackupSkipBackupFileSetting)
|
||||
const [localSingleFileOverwrite, setLocalSingleFileOverwrite] = useState<boolean>(
|
||||
localSingleFileOverwriteSetting ?? false
|
||||
)
|
||||
const [localSingleFileName, setLocalSingleFileName] = useState<string>(localSingleFileNameSetting ?? '')
|
||||
const [backupManagerVisible, setBackupManagerVisible] = useState(false)
|
||||
|
||||
const [syncInterval, setSyncInterval] = useState<number>(localBackupSyncIntervalSetting)
|
||||
@@ -61,6 +69,11 @@ const LocalBackupSettings: React.FC = () => {
|
||||
|
||||
const { localBackupSync } = useAppSelector((state) => state.backup)
|
||||
|
||||
// 同步 maxBackups 状态
|
||||
useEffect(() => {
|
||||
setMaxBackups(localBackupMaxBackupsSetting)
|
||||
}, [localBackupMaxBackupsSetting])
|
||||
|
||||
const onSyncIntervalChange = (value: number) => {
|
||||
setSyncInterval(value)
|
||||
dispatch(_setLocalBackupSyncInterval(value))
|
||||
@@ -140,6 +153,68 @@ const LocalBackupSettings: React.FC = () => {
|
||||
dispatch(_setLocalBackupSkipBackupFile(value))
|
||||
}
|
||||
|
||||
const onSingleFileOverwriteChange = (value: boolean) => {
|
||||
// Only show confirmation when enabling
|
||||
if (value && !localSingleFileOverwrite) {
|
||||
window.modal.confirm({
|
||||
title: t('settings.data.backup.singleFileOverwrite.confirm.title') || '启用覆盖式备份',
|
||||
content: (
|
||||
<div>
|
||||
<p>{t('settings.data.backup.singleFileOverwrite.confirm.content1') || '启用后,自动备份将:'}</p>
|
||||
<ul style={{ marginLeft: 20, marginTop: 10 }}>
|
||||
<li>{t('settings.data.backup.singleFileOverwrite.confirm.item1') || '使用固定文件名,不再添加时间戳'}</li>
|
||||
<li>{t('settings.data.backup.singleFileOverwrite.confirm.item2') || '每次备份都会覆盖同名文件'}</li>
|
||||
<li>{t('settings.data.backup.singleFileOverwrite.confirm.item3') || '仅保留最新的一个备份文件'}</li>
|
||||
</ul>
|
||||
<p style={{ marginTop: 10, color: 'var(--text-secondary)' }}>
|
||||
{t('settings.data.backup.singleFileOverwrite.confirm.note') ||
|
||||
'注意:此设置仅在自动备份且保留份数为1时生效'}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
okText: t('common.confirm') || '确认',
|
||||
cancelText: t('common.cancel') || '取消',
|
||||
onOk: () => {
|
||||
setLocalSingleFileOverwrite(value)
|
||||
dispatch(_setLocalSingleFileOverwrite(value))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
setLocalSingleFileOverwrite(value)
|
||||
dispatch(_setLocalSingleFileOverwrite(value))
|
||||
}
|
||||
}
|
||||
|
||||
const onSingleFileNameChange = (value: string) => {
|
||||
setLocalSingleFileName(value)
|
||||
}
|
||||
|
||||
const onSingleFileNameBlur = () => {
|
||||
const trimmed = localSingleFileName.trim()
|
||||
// Validate filename
|
||||
if (trimmed) {
|
||||
// Check for invalid characters
|
||||
const invalidChars = /[<>:"/\\|?*]/
|
||||
if (invalidChars.test(trimmed)) {
|
||||
window.toast.error(t('settings.data.backup.singleFileName.invalid_chars') || '文件名包含无效字符')
|
||||
return
|
||||
}
|
||||
// Check for reserved names (Windows)
|
||||
const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i
|
||||
const nameWithoutExt = trimmed.replace(/\.zip$/i, '')
|
||||
if (reservedNames.test(nameWithoutExt)) {
|
||||
window.toast.error(t('settings.data.backup.singleFileName.reserved') || '文件名是系统保留名称')
|
||||
return
|
||||
}
|
||||
// Check length
|
||||
if (trimmed.length > 250) {
|
||||
window.toast.error(t('settings.data.backup.singleFileName.too_long') || '文件名过长')
|
||||
return
|
||||
}
|
||||
}
|
||||
dispatch(_setLocalSingleFileName(trimmed))
|
||||
}
|
||||
|
||||
const handleBrowseDirectory = async () => {
|
||||
try {
|
||||
const newLocalBackupDir = await window.api.select({
|
||||
@@ -282,6 +357,58 @@ const LocalBackupSettings: React.FC = () => {
|
||||
<SettingRow>
|
||||
<SettingHelpText>{t('settings.data.backup.skip_file_data_help')}</SettingHelpText>
|
||||
</SettingRow>
|
||||
{/* 覆盖式单文件备份,仅在自动备份开启且保留份数=1时推荐启用 */}
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>
|
||||
{t('settings.data.backup.singleFileOverwrite.title') || '覆盖式单文件备份(同名覆盖)'}
|
||||
</SettingRowTitle>
|
||||
<Switch
|
||||
checked={localSingleFileOverwrite}
|
||||
onChange={onSingleFileOverwriteChange}
|
||||
disabled={!(syncInterval > 0 && maxBackups === 1)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>
|
||||
{t('settings.data.backup.singleFileOverwrite.help') || (
|
||||
<div>
|
||||
<p>当自动备份开启且保留份数为1时,使用固定文件名每次覆盖。</p>
|
||||
<p style={{ marginTop: 8, fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
推荐场景:只需要保留最新备份,节省本地存储空间
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingHelpText>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.backup.singleFileName.title') || '自定义文件名(可选)'}</SettingRowTitle>
|
||||
<Input
|
||||
placeholder={
|
||||
t('settings.data.backup.singleFileName.placeholder') || '如:cherry-studio.<hostname>.<device>.zip'
|
||||
}
|
||||
value={localSingleFileName}
|
||||
onChange={(e) => onSingleFileNameChange(e.target.value)}
|
||||
onBlur={onSingleFileNameBlur}
|
||||
style={{ width: 300 }}
|
||||
disabled={!localSingleFileOverwrite || !(syncInterval > 0 && maxBackups === 1)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>
|
||||
{t('settings.data.backup.singleFileName.help') || (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
<p>• 留空将使用默认格式:cherry-studio.[主机名].[设备类型].zip</p>
|
||||
<p>
|
||||
• 支持的变量:{`{hostname}`} - 主机名,{`{device}`} - 设备类型
|
||||
</p>
|
||||
<p>• 不支持的字符:{'<>:"/\\|?*'}</p>
|
||||
<p>• 最大长度:250个字符</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingHelpText>
|
||||
</SettingRow>
|
||||
{localBackupSync && syncInterval > 0 && (
|
||||
<>
|
||||
<SettingDivider />
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
setNutstoreAutoSync,
|
||||
setNutstoreMaxBackups,
|
||||
setNutstorePath,
|
||||
setNutstoreSingleFileName,
|
||||
setNutstoreSingleFileOverwrite,
|
||||
setNutstoreSkipBackupFile,
|
||||
setNutstoreSyncInterval,
|
||||
setNutstoreToken
|
||||
@@ -44,7 +46,9 @@ const NutstoreSettings: FC = () => {
|
||||
nutstoreAutoSync,
|
||||
nutstoreSyncState,
|
||||
nutstoreSkipBackupFile,
|
||||
nutstoreMaxBackups
|
||||
nutstoreMaxBackups,
|
||||
nutstoreSingleFileOverwrite,
|
||||
nutstoreSingleFileName
|
||||
} = useAppSelector((state) => state.nutstore)
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
@@ -55,12 +59,20 @@ const NutstoreSettings: FC = () => {
|
||||
const [checkConnectionLoading, setCheckConnectionLoading] = useState(false)
|
||||
const [nsConnected, setNsConnected] = useState<boolean>(false)
|
||||
const [syncInterval, setSyncInterval] = useState<number>(nutstoreSyncInterval)
|
||||
const [maxBackups, setMaxBackups] = useState<number>(nutstoreMaxBackups)
|
||||
const [nutSkipBackupFile, setNutSkipBackupFile] = useState<boolean>(nutstoreSkipBackupFile)
|
||||
const [nutSingleFileOverwrite, setNutSingleFileOverwrite] = useState<boolean>(nutstoreSingleFileOverwrite ?? false)
|
||||
const [nutSingleFileName, setNutSingleFileName] = useState<string>(nutstoreSingleFileName ?? '')
|
||||
const [backupManagerVisible, setBackupManagerVisible] = useState(false)
|
||||
|
||||
const nutstoreSSOHandler = useNutstoreSSO()
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
|
||||
// 同步 maxBackups 状态
|
||||
useEffect(() => {
|
||||
setMaxBackups(nutstoreMaxBackups)
|
||||
}, [nutstoreMaxBackups])
|
||||
|
||||
const handleClickNutstoreSSO = useCallback(async () => {
|
||||
const ssoUrl = await window.api.nutstore.getSSOUrl()
|
||||
window.open(ssoUrl, '_blank')
|
||||
@@ -142,9 +154,72 @@ const NutstoreSettings: FC = () => {
|
||||
}
|
||||
|
||||
const onMaxBackupsChange = (value: number) => {
|
||||
setMaxBackups(value)
|
||||
dispatch(setNutstoreMaxBackups(value))
|
||||
}
|
||||
|
||||
const onSingleFileOverwriteChange = (value: boolean) => {
|
||||
// Only show confirmation when enabling
|
||||
if (value && !nutSingleFileOverwrite) {
|
||||
window.modal.confirm({
|
||||
title: t('settings.data.backup.singleFileOverwrite.confirm.title') || '启用覆盖式备份',
|
||||
content: (
|
||||
<div>
|
||||
<p>{t('settings.data.backup.singleFileOverwrite.confirm.content1') || '启用后,自动备份将:'}</p>
|
||||
<ul style={{ marginLeft: 20, marginTop: 10 }}>
|
||||
<li>{t('settings.data.backup.singleFileOverwrite.confirm.item1') || '使用固定文件名,不再添加时间戳'}</li>
|
||||
<li>{t('settings.data.backup.singleFileOverwrite.confirm.item2') || '每次备份都会覆盖同名文件'}</li>
|
||||
<li>{t('settings.data.backup.singleFileOverwrite.confirm.item3') || '仅保留最新的一个备份文件'}</li>
|
||||
</ul>
|
||||
<p style={{ marginTop: 10, color: 'var(--text-secondary)' }}>
|
||||
{t('settings.data.backup.singleFileOverwrite.confirm.note') ||
|
||||
'注意:此设置仅在自动备份且保留份数为1时生效'}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
okText: t('common.confirm') || '确认',
|
||||
cancelText: t('common.cancel') || '取消',
|
||||
onOk: () => {
|
||||
setNutSingleFileOverwrite(value)
|
||||
dispatch(setNutstoreSingleFileOverwrite(value))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
setNutSingleFileOverwrite(value)
|
||||
dispatch(setNutstoreSingleFileOverwrite(value))
|
||||
}
|
||||
}
|
||||
|
||||
const onSingleFileNameChange = (value: string) => {
|
||||
setNutSingleFileName(value)
|
||||
}
|
||||
|
||||
const onSingleFileNameBlur = () => {
|
||||
const trimmed = nutSingleFileName.trim()
|
||||
// Validate filename
|
||||
if (trimmed) {
|
||||
// Check for invalid characters
|
||||
const invalidChars = /[<>:"/\\|?*]/
|
||||
if (invalidChars.test(trimmed)) {
|
||||
window.toast.error(t('settings.data.backup.singleFileName.invalid_chars') || '文件名包含无效字符')
|
||||
return
|
||||
}
|
||||
// Check for reserved names (Windows)
|
||||
const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i
|
||||
const nameWithoutExt = trimmed.replace(/\.zip$/i, '')
|
||||
if (reservedNames.test(nameWithoutExt)) {
|
||||
window.toast.error(t('settings.data.backup.singleFileName.reserved') || '文件名是系统保留名称')
|
||||
return
|
||||
}
|
||||
// Check length
|
||||
if (trimmed.length > 250) {
|
||||
window.toast.error(t('settings.data.backup.singleFileName.too_long') || '文件名过长')
|
||||
return
|
||||
}
|
||||
}
|
||||
dispatch(setNutstoreSingleFileName(trimmed))
|
||||
}
|
||||
|
||||
const handleClickPathChange = async () => {
|
||||
if (!nutstoreToken) {
|
||||
return
|
||||
@@ -336,6 +411,60 @@ const NutstoreSettings: FC = () => {
|
||||
<SettingRow>
|
||||
<SettingHelpText>{t('settings.data.backup.skip_file_data_help')}</SettingHelpText>
|
||||
</SettingRow>
|
||||
{/* 覆盖式单文件备份,仅在自动备份开启且保留份数=1时推荐启用 */}
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>
|
||||
{t('settings.data.backup.singleFileOverwrite.title') || '覆盖式单文件备份(同名覆盖)'}
|
||||
</SettingRowTitle>
|
||||
<Switch
|
||||
checked={nutSingleFileOverwrite}
|
||||
onChange={onSingleFileOverwriteChange}
|
||||
disabled={!(syncInterval > 0 && maxBackups === 1)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>
|
||||
{t('settings.data.backup.singleFileOverwrite.help') || (
|
||||
<div>
|
||||
<p>当自动备份开启且保留份数为1时,使用固定文件名每次覆盖。</p>
|
||||
<p style={{ marginTop: 8, fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
推荐场景:只需要保留最新备份,节省坚果云存储空间
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingHelpText>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>
|
||||
{t('settings.data.backup.singleFileName.title') || '自定义文件名(可选)'}
|
||||
</SettingRowTitle>
|
||||
<Input
|
||||
placeholder={
|
||||
t('settings.data.backup.singleFileName.placeholder') || '如:cherry-studio.<hostname>.<device>.zip'
|
||||
}
|
||||
value={nutSingleFileName}
|
||||
onChange={(e) => onSingleFileNameChange(e.target.value)}
|
||||
onBlur={onSingleFileNameBlur}
|
||||
style={{ width: 300 }}
|
||||
disabled={!nutSingleFileOverwrite || !(syncInterval > 0 && maxBackups === 1)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>
|
||||
{t('settings.data.backup.singleFileName.help') || (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
<p>• 留空将使用默认格式:cherry-studio.[主机名].[设备类型].zip</p>
|
||||
<p>
|
||||
• 支持的变量:{`{hostname}`} - 主机名,{`{device}`} - 设备类型
|
||||
</p>
|
||||
<p>• 不支持的字符:{'<>:"/\\|?*'}</p>
|
||||
<p>• 最大长度:250个字符</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingHelpText>
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
<>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { setS3Partial } from '@renderer/store/settings'
|
||||
import { S3Config } from '@renderer/types'
|
||||
import { Button, Input, Switch, Tooltip } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { FC, useState } from 'react'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||
@@ -30,7 +30,9 @@ const S3Settings: FC = () => {
|
||||
root: s3RootInit = '',
|
||||
syncInterval: s3SyncIntervalInit = 0,
|
||||
maxBackups: s3MaxBackupsInit = 5,
|
||||
skipBackupFile: s3SkipBackupFileInit = false
|
||||
skipBackupFile: s3SkipBackupFileInit = false,
|
||||
singleFileOverwrite: s3SingleFileOverwriteInit = false,
|
||||
singleFileName: s3SingleFileNameInit = ''
|
||||
} = s3
|
||||
|
||||
const [endpoint, setEndpoint] = useState<string | undefined>(s3EndpointInit)
|
||||
@@ -40,6 +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 ?? false)
|
||||
const [singleFileName, setSingleFileName] = useState<string>(s3SingleFileNameInit ?? '')
|
||||
const [backupManagerVisible, setBackupManagerVisible] = useState(false)
|
||||
|
||||
const [syncInterval, setSyncInterval] = useState<number>(s3SyncIntervalInit)
|
||||
@@ -52,6 +56,11 @@ const S3Settings: FC = () => {
|
||||
|
||||
const { s3Sync } = useAppSelector((state) => state.backup)
|
||||
|
||||
// 同步 maxBackups 状态
|
||||
useEffect(() => {
|
||||
setMaxBackups(s3MaxBackupsInit)
|
||||
}, [s3MaxBackupsInit])
|
||||
|
||||
const onSyncIntervalChange = (value: number) => {
|
||||
setSyncInterval(value)
|
||||
dispatch(setS3Partial({ syncInterval: value, autoSync: value !== 0 }))
|
||||
@@ -81,6 +90,68 @@ const S3Settings: FC = () => {
|
||||
dispatch(setS3Partial({ skipBackupFile: value }))
|
||||
}
|
||||
|
||||
const onSingleFileOverwriteChange = (value: boolean) => {
|
||||
// Only show confirmation when enabling
|
||||
if (value && !singleFileOverwrite) {
|
||||
window.modal.confirm({
|
||||
title: t('settings.data.backup.singleFileOverwrite.confirm.title') || '启用覆盖式备份',
|
||||
content: (
|
||||
<div>
|
||||
<p>{t('settings.data.backup.singleFileOverwrite.confirm.content1') || '启用后,自动备份将:'}</p>
|
||||
<ul style={{ marginLeft: 20, marginTop: 10 }}>
|
||||
<li>{t('settings.data.backup.singleFileOverwrite.confirm.item1') || '使用固定文件名,不再添加时间戳'}</li>
|
||||
<li>{t('settings.data.backup.singleFileOverwrite.confirm.item2') || '每次备份都会覆盖同名文件'}</li>
|
||||
<li>{t('settings.data.backup.singleFileOverwrite.confirm.item3') || '仅保留最新的一个备份文件'}</li>
|
||||
</ul>
|
||||
<p style={{ marginTop: 10, color: 'var(--text-secondary)' }}>
|
||||
{t('settings.data.backup.singleFileOverwrite.confirm.note') ||
|
||||
'注意:此设置仅在自动备份且保留份数为1时生效'}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
okText: t('common.confirm') || '确认',
|
||||
cancelText: t('common.cancel') || '取消',
|
||||
onOk: () => {
|
||||
setSingleFileOverwrite(value)
|
||||
dispatch(setS3Partial({ singleFileOverwrite: value }))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
setSingleFileOverwrite(value)
|
||||
dispatch(setS3Partial({ singleFileOverwrite: value }))
|
||||
}
|
||||
}
|
||||
|
||||
const onSingleFileNameChange = (value: string) => {
|
||||
setSingleFileName(value)
|
||||
}
|
||||
|
||||
const onSingleFileNameBlur = () => {
|
||||
const trimmed = singleFileName.trim()
|
||||
// Validate filename
|
||||
if (trimmed) {
|
||||
// Check for invalid characters
|
||||
const invalidChars = /[<>:"/\\|?*]/
|
||||
if (invalidChars.test(trimmed)) {
|
||||
window.toast.error(t('settings.data.backup.singleFileName.invalid_chars') || '文件名包含无效字符')
|
||||
return
|
||||
}
|
||||
// Check for reserved names (Windows)
|
||||
const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i
|
||||
const nameWithoutExt = trimmed.replace(/\.zip$/i, '')
|
||||
if (reservedNames.test(nameWithoutExt)) {
|
||||
window.toast.error(t('settings.data.backup.singleFileName.reserved') || '文件名是系统保留名称')
|
||||
return
|
||||
}
|
||||
// Check length
|
||||
if (trimmed.length > 250) {
|
||||
window.toast.error(t('settings.data.backup.singleFileName.too_long') || '文件名过长')
|
||||
return
|
||||
}
|
||||
}
|
||||
dispatch(setS3Partial({ singleFileName: trimmed }))
|
||||
}
|
||||
|
||||
const renderSyncStatus = () => {
|
||||
if (!endpoint) return null
|
||||
|
||||
@@ -260,6 +331,58 @@ const S3Settings: FC = () => {
|
||||
<SettingRow>
|
||||
<SettingHelpText>{t('settings.data.s3.skipBackupFile.help')}</SettingHelpText>
|
||||
</SettingRow>
|
||||
{/* 覆盖式单文件备份,仅在自动备份开启且保留份数=1时推荐启用 */}
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>
|
||||
{t('settings.data.backup.singleFileOverwrite.title') || '覆盖式单文件备份(同名覆盖)'}
|
||||
</SettingRowTitle>
|
||||
<Switch
|
||||
checked={singleFileOverwrite}
|
||||
onChange={onSingleFileOverwriteChange}
|
||||
disabled={!(syncInterval > 0 && maxBackups === 1)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>
|
||||
{t('settings.data.backup.singleFileOverwrite.help') || (
|
||||
<div>
|
||||
<p>当自动备份开启且保留份数为1时,使用固定文件名每次覆盖。</p>
|
||||
<p style={{ marginTop: 8, fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
推荐场景:只需要保留最新备份,节省存储空间(S3会直接覆盖同键对象)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingHelpText>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.backup.singleFileName.title') || '自定义文件名(可选)'}</SettingRowTitle>
|
||||
<Input
|
||||
placeholder={
|
||||
t('settings.data.backup.singleFileName.placeholder') || '如:cherry-studio.<hostname>.<device>.zip'
|
||||
}
|
||||
value={singleFileName}
|
||||
onChange={(e) => onSingleFileNameChange(e.target.value)}
|
||||
onBlur={onSingleFileNameBlur}
|
||||
style={{ width: 300 }}
|
||||
disabled={!singleFileOverwrite || !(syncInterval > 0 && maxBackups === 1)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>
|
||||
{t('settings.data.backup.singleFileName.help') || (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
<p>• 留空将使用默认格式:cherry-studio.[主机名].[设备类型].zip</p>
|
||||
<p>
|
||||
• 支持的变量:{`{hostname}`} - 主机名,{`{device}`} - 设备类型
|
||||
</p>
|
||||
<p>• 不支持的字符:{'<>:"/\\|?*'}</p>
|
||||
<p>• 最大长度:250个字符</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingHelpText>
|
||||
</SettingRow>
|
||||
{syncInterval > 0 && (
|
||||
<>
|
||||
<SettingDivider />
|
||||
|
||||
@@ -14,13 +14,15 @@ import {
|
||||
setWebdavMaxBackups as _setWebdavMaxBackups,
|
||||
setWebdavPass as _setWebdavPass,
|
||||
setWebdavPath as _setWebdavPath,
|
||||
setWebdavSingleFileName as _setWebdavSingleFileName,
|
||||
setWebdavSingleFileOverwrite as _setWebdavSingleFileOverwrite,
|
||||
setWebdavSkipBackupFile as _setWebdavSkipBackupFile,
|
||||
setWebdavSyncInterval as _setWebdavSyncInterval,
|
||||
setWebdavUser as _setWebdavUser
|
||||
} from '@renderer/store/settings'
|
||||
import { Button, Input, Switch, Tooltip } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { FC, useState } from 'react'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||
@@ -34,7 +36,9 @@ const WebDavSettings: FC = () => {
|
||||
webdavSyncInterval: webDAVSyncInterval,
|
||||
webdavMaxBackups: webDAVMaxBackups,
|
||||
webdavSkipBackupFile: webdDAVSkipBackupFile,
|
||||
webdavDisableStream: webDAVDisableStream
|
||||
webdavDisableStream: webDAVDisableStream,
|
||||
webdavSingleFileOverwrite: webDAVSingleFileOverwrite,
|
||||
webdavSingleFileName: webDAVSingleFileName
|
||||
} = useSettings()
|
||||
|
||||
const [webdavHost, setWebdavHost] = useState<string | undefined>(webDAVHost)
|
||||
@@ -43,6 +47,10 @@ const WebDavSettings: FC = () => {
|
||||
const [webdavPath, setWebdavPath] = useState<string | undefined>(webDAVPath)
|
||||
const [webdavSkipBackupFile, setWebdavSkipBackupFile] = useState<boolean>(webdDAVSkipBackupFile)
|
||||
const [webdavDisableStream, setWebdavDisableStream] = useState<boolean>(webDAVDisableStream)
|
||||
const [webdavSingleFileOverwrite, setWebdavSingleFileOverwrite] = useState<boolean>(
|
||||
webDAVSingleFileOverwrite ?? false
|
||||
)
|
||||
const [webdavSingleFileName, setWebdavSingleFileName] = useState<string>(webDAVSingleFileName ?? '')
|
||||
const [backupManagerVisible, setBackupManagerVisible] = useState(false)
|
||||
|
||||
const [syncInterval, setSyncInterval] = useState<number>(webDAVSyncInterval)
|
||||
@@ -55,6 +63,11 @@ const WebDavSettings: FC = () => {
|
||||
|
||||
const { webdavSync } = useAppSelector((state) => state.backup)
|
||||
|
||||
// 同步 maxBackups 状态
|
||||
useEffect(() => {
|
||||
setMaxBackups(webDAVMaxBackups)
|
||||
}, [webDAVMaxBackups])
|
||||
|
||||
// 把之前备份的文件定时上传到 webdav,首先先配置 webdav 的 host, port, user, pass, path
|
||||
|
||||
const onSyncIntervalChange = (value: number) => {
|
||||
@@ -84,6 +97,68 @@ const WebDavSettings: FC = () => {
|
||||
dispatch(_setWebdavDisableStream(value))
|
||||
}
|
||||
|
||||
const onSingleFileOverwriteChange = (value: boolean) => {
|
||||
// Only show confirmation when enabling
|
||||
if (value && !webdavSingleFileOverwrite) {
|
||||
window.modal.confirm({
|
||||
title: t('settings.data.backup.singleFileOverwrite.confirm.title') || '启用覆盖式备份',
|
||||
content: (
|
||||
<div>
|
||||
<p>{t('settings.data.backup.singleFileOverwrite.confirm.content1') || '启用后,自动备份将:'}</p>
|
||||
<ul style={{ marginLeft: 20, marginTop: 10 }}>
|
||||
<li>{t('settings.data.backup.singleFileOverwrite.confirm.item1') || '使用固定文件名,不再添加时间戳'}</li>
|
||||
<li>{t('settings.data.backup.singleFileOverwrite.confirm.item2') || '每次备份都会覆盖同名文件'}</li>
|
||||
<li>{t('settings.data.backup.singleFileOverwrite.confirm.item3') || '仅保留最新的一个备份文件'}</li>
|
||||
</ul>
|
||||
<p style={{ marginTop: 10, color: 'var(--text-secondary)' }}>
|
||||
{t('settings.data.backup.singleFileOverwrite.confirm.note') ||
|
||||
'注意:此设置仅在自动备份且保留份数为1时生效'}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
okText: t('common.confirm') || '确认',
|
||||
cancelText: t('common.cancel') || '取消',
|
||||
onOk: () => {
|
||||
setWebdavSingleFileOverwrite(value)
|
||||
dispatch(_setWebdavSingleFileOverwrite(value))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
setWebdavSingleFileOverwrite(value)
|
||||
dispatch(_setWebdavSingleFileOverwrite(value))
|
||||
}
|
||||
}
|
||||
|
||||
const onSingleFileNameChange = (value: string) => {
|
||||
setWebdavSingleFileName(value)
|
||||
}
|
||||
|
||||
const onSingleFileNameBlur = () => {
|
||||
const trimmed = webdavSingleFileName.trim()
|
||||
// Validate filename
|
||||
if (trimmed) {
|
||||
// Check for invalid characters
|
||||
const invalidChars = /[<>:"/\\|?*]/
|
||||
if (invalidChars.test(trimmed)) {
|
||||
window.toast.error(t('settings.data.backup.singleFileName.invalid_chars') || '文件名包含无效字符')
|
||||
return
|
||||
}
|
||||
// Check for reserved names (Windows)
|
||||
const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i
|
||||
const nameWithoutExt = trimmed.replace(/\.zip$/i, '')
|
||||
if (reservedNames.test(nameWithoutExt)) {
|
||||
window.toast.error(t('settings.data.backup.singleFileName.reserved') || '文件名是系统保留名称')
|
||||
return
|
||||
}
|
||||
// Check length
|
||||
if (trimmed.length > 250) {
|
||||
window.toast.error(t('settings.data.backup.singleFileName.too_long') || '文件名过长')
|
||||
return
|
||||
}
|
||||
}
|
||||
dispatch(_setWebdavSingleFileName(trimmed))
|
||||
}
|
||||
|
||||
const renderSyncStatus = () => {
|
||||
if (!webdavHost) return null
|
||||
|
||||
@@ -236,6 +311,58 @@ const WebDavSettings: FC = () => {
|
||||
<SettingRow>
|
||||
<SettingHelpText>{t('settings.data.webdav.disableStream.help')}</SettingHelpText>
|
||||
</SettingRow>
|
||||
{/* 覆盖式单文件备份,仅在自动备份开启且保留份数=1时推荐启用 */}
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>
|
||||
{t('settings.data.backup.singleFileOverwrite.title') || '覆盖式单文件备份(同名覆盖)'}
|
||||
</SettingRowTitle>
|
||||
<Switch
|
||||
checked={webdavSingleFileOverwrite}
|
||||
onChange={onSingleFileOverwriteChange}
|
||||
disabled={!(syncInterval > 0 && maxBackups === 1)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>
|
||||
{t('settings.data.backup.singleFileOverwrite.help') || (
|
||||
<div>
|
||||
<p>当自动备份开启且保留份数为1时,使用固定文件名每次覆盖。</p>
|
||||
<p style={{ marginTop: 8, fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
推荐场景:只需要保留最新备份,节省存储空间
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingHelpText>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.backup.singleFileName.title') || '自定义文件名(可选)'}</SettingRowTitle>
|
||||
<Input
|
||||
placeholder={
|
||||
t('settings.data.backup.singleFileName.placeholder') || '如:cherry-studio.<hostname>.<device>.zip'
|
||||
}
|
||||
value={webdavSingleFileName}
|
||||
onChange={(e) => onSingleFileNameChange(e.target.value)}
|
||||
onBlur={onSingleFileNameBlur}
|
||||
style={{ width: 300 }}
|
||||
disabled={!webdavSingleFileOverwrite || !(syncInterval > 0 && maxBackups === 1)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>
|
||||
{t('settings.data.backup.singleFileName.help') || (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
<p>• 留空将使用默认格式:cherry-studio.[主机名].[设备类型].zip</p>
|
||||
<p>
|
||||
• 支持的变量:{`{hostname}`} - 主机名,{`{device}`} - 设备类型
|
||||
</p>
|
||||
<p>• 不支持的字符:{'<>:"/\\|?*'}</p>
|
||||
<p>• 最大长度:250个字符</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingHelpText>
|
||||
</SettingRow>
|
||||
{webdavSync && syncInterval > 0 && (
|
||||
<>
|
||||
<SettingDivider />
|
||||
|
||||
@@ -6,10 +6,23 @@ import store from '@renderer/store'
|
||||
import { setLocalBackupSyncState, setS3SyncState, setWebDAVSyncState } from '@renderer/store/backup'
|
||||
import { S3Config, WebDavConfig } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import { generateOverwriteFilename, generateTimestampedFilename, shouldSkipCleanup } from '@renderer/utils/backupUtils'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { NotificationService } from './NotificationService'
|
||||
|
||||
// Define specific error types for better error handling
|
||||
export class BackupError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly type: 'network' | 'permission' | 'storage' | 'validation' | 'unknown',
|
||||
public readonly originalError?: Error
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'BackupError'
|
||||
}
|
||||
}
|
||||
|
||||
const logger = loggerService.withContext('BackupService')
|
||||
|
||||
// 重试删除S3文件的辅助函数
|
||||
@@ -168,7 +181,9 @@ export async function backupToWebdav({
|
||||
webdavPath,
|
||||
webdavMaxBackups,
|
||||
webdavSkipBackupFile,
|
||||
webdavDisableStream
|
||||
webdavDisableStream,
|
||||
webdavSingleFileOverwrite,
|
||||
webdavSingleFileName
|
||||
} = store.getState().settings
|
||||
let deviceType = 'unknown'
|
||||
let hostname = 'unknown'
|
||||
@@ -179,8 +194,19 @@ export async function backupToWebdav({
|
||||
logger.error('Failed to get device type or hostname:', error as Error)
|
||||
}
|
||||
const timestamp = dayjs().format('YYYYMMDDHHmmss')
|
||||
const backupFileName = customFileName || `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip`
|
||||
const finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.zip`
|
||||
let finalFileName: string
|
||||
|
||||
// 覆盖式单文件备份(仅在自动备份流程且保留份数=1时生效)
|
||||
logger.debug(
|
||||
`[WebDAV Backup] Overwrite check: autoBackupProcess=${autoBackupProcess}, maxBackups=${webdavMaxBackups}, singleFileOverwrite=${webdavSingleFileOverwrite}`
|
||||
)
|
||||
if (autoBackupProcess && webdavMaxBackups === 1 && webdavSingleFileOverwrite) {
|
||||
finalFileName = generateOverwriteFilename(webdavSingleFileName, hostname, deviceType)
|
||||
logger.debug(`[WebDAV Backup] Using overwrite filename: ${finalFileName}`)
|
||||
} else {
|
||||
finalFileName = generateTimestampedFilename(customFileName, hostname, deviceType, timestamp)
|
||||
logger.debug(`[WebDAV Backup] Using timestamped filename: ${finalFileName}`)
|
||||
}
|
||||
const backupData = await getBackupData()
|
||||
|
||||
// 上传文件
|
||||
@@ -212,8 +238,8 @@ export async function backupToWebdav({
|
||||
})
|
||||
showMessage && window.toast.success(i18n.t('message.backup.success'))
|
||||
|
||||
// 清理旧备份文件
|
||||
if (webdavMaxBackups > 0) {
|
||||
// 使用工具函数判断是否<EFBFBD><EFBFBD><EFBFBD>过清理
|
||||
if (webdavMaxBackups > 0 && !shouldSkipCleanup(autoBackupProcess, webdavMaxBackups, webdavSingleFileOverwrite)) {
|
||||
try {
|
||||
// 获取所有备份文件
|
||||
const files = await window.api.backup.listWebdavFiles({
|
||||
@@ -353,8 +379,19 @@ export async function backupToS3({
|
||||
logger.error('Failed to get device type or hostname:', error as Error)
|
||||
}
|
||||
const timestamp = dayjs().format('YYYYMMDDHHmmss')
|
||||
const backupFileName = customFileName || `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip`
|
||||
const finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.zip`
|
||||
let finalFileName: string
|
||||
|
||||
// 覆盖式单文件备份(仅在自动备份流程且保留份数=1时生效)
|
||||
logger.debug(
|
||||
`[S3 Backup] Overwrite check: autoBackupProcess=${autoBackupProcess}, maxBackups=${s3Config.maxBackups}, singleFileOverwrite=${s3Config.singleFileOverwrite}`
|
||||
)
|
||||
if (autoBackupProcess && s3Config.maxBackups === 1 && s3Config.singleFileOverwrite) {
|
||||
finalFileName = generateOverwriteFilename(s3Config.singleFileName, hostname, deviceType)
|
||||
logger.debug(`[S3 Backup] Using overwrite filename: ${finalFileName}`)
|
||||
} else {
|
||||
finalFileName = generateTimestampedFilename(customFileName, hostname, deviceType, timestamp)
|
||||
logger.debug(`[S3 Backup] Using timestamped filename: ${finalFileName}`)
|
||||
}
|
||||
const backupData = await getBackupData()
|
||||
|
||||
try {
|
||||
@@ -384,7 +421,11 @@ export async function backupToS3({
|
||||
showMessage && window.toast.success(i18n.t('message.backup.success'))
|
||||
|
||||
// 清理旧备份文件
|
||||
if (s3Config.maxBackups > 0) {
|
||||
// 使用工具函数判断是否跳过清理
|
||||
if (
|
||||
s3Config.maxBackups > 0 &&
|
||||
!shouldSkipCleanup(autoBackupProcess, s3Config.maxBackups, s3Config.singleFileOverwrite)
|
||||
) {
|
||||
try {
|
||||
// 获取所有备份文件
|
||||
const files = await window.api.backup.listS3Files(s3Config)
|
||||
@@ -939,7 +980,9 @@ export async function backupToLocal({
|
||||
const {
|
||||
localBackupDir: localBackupDirSetting,
|
||||
localBackupMaxBackups,
|
||||
localBackupSkipBackupFile
|
||||
localBackupSkipBackupFile,
|
||||
localSingleFileOverwrite,
|
||||
localSingleFileName
|
||||
} = store.getState().settings
|
||||
const localBackupDir = await window.api.resolvePath(localBackupDirSetting)
|
||||
let deviceType = 'unknown'
|
||||
@@ -951,8 +994,19 @@ export async function backupToLocal({
|
||||
logger.error('Failed to get device type or hostname:', error as Error)
|
||||
}
|
||||
const timestamp = dayjs().format('YYYYMMDDHHmmss')
|
||||
const backupFileName = customFileName || `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip`
|
||||
const finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.zip`
|
||||
let finalFileName: string
|
||||
|
||||
// 覆盖式单文件备份(仅在自动备份流程且保留份数=1时生效)
|
||||
logger.debug(
|
||||
`[Local Backup] Overwrite check: autoBackupProcess=${autoBackupProcess}, maxBackups=${localBackupMaxBackups}, singleFileOverwrite=${localSingleFileOverwrite}`
|
||||
)
|
||||
if (autoBackupProcess && localBackupMaxBackups === 1 && localSingleFileOverwrite) {
|
||||
finalFileName = generateOverwriteFilename(localSingleFileName, hostname, deviceType)
|
||||
logger.debug(`[Local Backup] Using overwrite filename: ${finalFileName}`)
|
||||
} else {
|
||||
finalFileName = generateTimestampedFilename(customFileName, hostname, deviceType, timestamp)
|
||||
logger.debug(`[Local Backup] Using timestamped filename: ${finalFileName}`)
|
||||
}
|
||||
const backupData = await getBackupData()
|
||||
|
||||
try {
|
||||
@@ -981,8 +1035,11 @@ export async function backupToLocal({
|
||||
})
|
||||
}
|
||||
|
||||
// Clean up old backups if maxBackups is set
|
||||
if (localBackupMaxBackups > 0) {
|
||||
// 使用工具函数判断是否跳过清理
|
||||
if (
|
||||
localBackupMaxBackups > 0 &&
|
||||
!shouldSkipCleanup(autoBackupProcess, localBackupMaxBackups, localSingleFileOverwrite)
|
||||
) {
|
||||
try {
|
||||
// Get all backup files
|
||||
const files = await window.api.backup.listLocalBackupFiles(localBackupDir)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { NUTSTORE_HOST } from '@shared/config/nutstore'
|
||||
import dayjs from 'dayjs'
|
||||
import { type CreateDirectoryOptions } from 'webdav'
|
||||
|
||||
import { shouldSkipCleanup, validateAndSanitizeFilename } from '../utils/backupUtils'
|
||||
import { getBackupData, handleData } from './BackupService'
|
||||
|
||||
const logger = loggerService.withContext('NutstoreService')
|
||||
@@ -109,10 +110,12 @@ async function cleanupOldBackups(webdavConfig: WebDavConfig, maxBackups: number)
|
||||
|
||||
export async function backupToNutstore({
|
||||
showMessage = false,
|
||||
customFileName = ''
|
||||
customFileName = '',
|
||||
isAutoBackup = false
|
||||
}: {
|
||||
showMessage?: boolean
|
||||
customFileName?: string
|
||||
isAutoBackup?: boolean
|
||||
} = {}) {
|
||||
const nutstoreToken = getNutstoreToken()
|
||||
if (!nutstoreToken) {
|
||||
@@ -135,21 +138,37 @@ export async function backupToNutstore({
|
||||
} catch (error) {
|
||||
logger.error('[backupToNutstore] Failed to get device type:', error as Error)
|
||||
}
|
||||
const timestamp = dayjs().format('YYYYMMDDHHmmss')
|
||||
const backupFileName = customFileName || `cherry-studio.${timestamp}.${deviceType}.zip`
|
||||
const finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.zip`
|
||||
|
||||
const backupData = await getBackupData()
|
||||
const skipBackupFile = store.getState().nutstore.nutstoreSkipBackupFile
|
||||
const maxBackups = store.getState().nutstore.nutstoreMaxBackups
|
||||
const singleFileOverwrite = store.getState().nutstore.nutstoreSingleFileOverwrite
|
||||
const singleFileName = store.getState().nutstore.nutstoreSingleFileName
|
||||
|
||||
// Handle filename generation
|
||||
let finalFileName: string
|
||||
if (isAutoBackup && singleFileOverwrite && maxBackups === 1) {
|
||||
// Use overwrite logic for auto backup when single file overwrite is enabled
|
||||
const hostname = await window.api.system.getHostname()
|
||||
const name = await validateAndSanitizeFilename(singleFileName, hostname, deviceType)
|
||||
finalFileName = name
|
||||
} else {
|
||||
// Use timestamped logic for manual backup or when overwrite is disabled
|
||||
const timestamp = dayjs().format('YYYYMMDDHHmmss')
|
||||
const name = customFileName || `cherry-studio.${timestamp}.${deviceType}.zip`
|
||||
finalFileName = name.endsWith('.zip') ? name : `${name}.zip`
|
||||
}
|
||||
|
||||
isManualBackupRunning = true
|
||||
|
||||
store.dispatch(setNutstoreSyncState({ syncing: true, lastSyncError: null }))
|
||||
|
||||
const backupData = await getBackupData()
|
||||
const skipBackupFile = store.getState().nutstore.nutstoreSkipBackupFile
|
||||
const maxBackups = store.getState().nutstore.nutstoreMaxBackups
|
||||
|
||||
try {
|
||||
// 先清理旧备份
|
||||
await cleanupOldBackups(config, maxBackups)
|
||||
// Skip cleanup for single file overwrite mode when maxBackups is 1
|
||||
if (!shouldSkipCleanup(maxBackups, singleFileOverwrite)) {
|
||||
// 先清理旧备份
|
||||
await cleanupOldBackups(config, maxBackups)
|
||||
}
|
||||
|
||||
const isSuccess = await window.api.backup.backupToWebdav(backupData, {
|
||||
...config,
|
||||
@@ -264,7 +283,7 @@ export async function startNutstoreAutoSync() {
|
||||
isAutoBackupRunning = true
|
||||
try {
|
||||
logger.verbose('[Nutstore AutoSync] Starting auto backup...')
|
||||
await backupToNutstore({ showMessage: false })
|
||||
await backupToNutstore({ showMessage: false, isAutoBackup: true })
|
||||
} catch (error) {
|
||||
logger.error('[Nutstore AutoSync] Auto backup failed:', error as Error)
|
||||
} finally {
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface NutstoreState {
|
||||
nutstoreSyncState: NutstoreSyncState
|
||||
nutstoreSkipBackupFile: boolean
|
||||
nutstoreMaxBackups: number
|
||||
nutstoreSingleFileOverwrite: boolean
|
||||
nutstoreSingleFileName: string
|
||||
}
|
||||
|
||||
const initialState: NutstoreState = {
|
||||
@@ -25,7 +27,9 @@ const initialState: NutstoreState = {
|
||||
lastSyncError: null
|
||||
},
|
||||
nutstoreSkipBackupFile: false,
|
||||
nutstoreMaxBackups: 0
|
||||
nutstoreMaxBackups: 0,
|
||||
nutstoreSingleFileOverwrite: false,
|
||||
nutstoreSingleFileName: ''
|
||||
}
|
||||
|
||||
const nutstoreSlice = createSlice({
|
||||
@@ -52,6 +56,12 @@ const nutstoreSlice = createSlice({
|
||||
},
|
||||
setNutstoreMaxBackups: (state, action: PayloadAction<number>) => {
|
||||
state.nutstoreMaxBackups = action.payload
|
||||
},
|
||||
setNutstoreSingleFileOverwrite: (state, action: PayloadAction<boolean>) => {
|
||||
state.nutstoreSingleFileOverwrite = action.payload
|
||||
},
|
||||
setNutstoreSingleFileName: (state, action: PayloadAction<string>) => {
|
||||
state.nutstoreSingleFileName = action.payload
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -63,7 +73,9 @@ export const {
|
||||
setNutstoreSyncInterval,
|
||||
setNutstoreSyncState,
|
||||
setNutstoreSkipBackupFile,
|
||||
setNutstoreMaxBackups
|
||||
setNutstoreMaxBackups,
|
||||
setNutstoreSingleFileOverwrite,
|
||||
setNutstoreSingleFileName
|
||||
} = nutstoreSlice.actions
|
||||
|
||||
export default nutstoreSlice.reducer
|
||||
|
||||
@@ -119,6 +119,9 @@ export interface SettingsState {
|
||||
webdavMaxBackups: number
|
||||
webdavSkipBackupFile: boolean
|
||||
webdavDisableStream: boolean
|
||||
// 覆盖式单文件备份(WebDAV)
|
||||
webdavSingleFileOverwrite?: boolean
|
||||
webdavSingleFileName?: string
|
||||
translateModelPrompt: string
|
||||
autoTranslateWithSpace: boolean
|
||||
showTranslateConfirm: boolean
|
||||
@@ -210,6 +213,9 @@ export interface SettingsState {
|
||||
localBackupSyncInterval: number
|
||||
localBackupMaxBackups: number
|
||||
localBackupSkipBackupFile: boolean
|
||||
// 覆盖式单文件备份(Local)
|
||||
localSingleFileOverwrite?: boolean
|
||||
localSingleFileName?: string
|
||||
defaultPaintingProvider: PaintingProvider
|
||||
s3: S3Config
|
||||
// Developer mode
|
||||
@@ -306,6 +312,8 @@ export const initialState: SettingsState = {
|
||||
webdavMaxBackups: 0,
|
||||
webdavSkipBackupFile: false,
|
||||
webdavDisableStream: false,
|
||||
webdavSingleFileOverwrite: false,
|
||||
webdavSingleFileName: '',
|
||||
translateModelPrompt: TRANSLATE_PROMPT,
|
||||
autoTranslateWithSpace: false,
|
||||
showTranslateConfirm: true,
|
||||
@@ -389,6 +397,8 @@ export const initialState: SettingsState = {
|
||||
localBackupSyncInterval: 0,
|
||||
localBackupMaxBackups: 0,
|
||||
localBackupSkipBackupFile: false,
|
||||
localSingleFileOverwrite: false,
|
||||
localSingleFileName: '',
|
||||
defaultPaintingProvider: 'zhipu',
|
||||
s3: {
|
||||
endpoint: '',
|
||||
@@ -400,7 +410,9 @@ export const initialState: SettingsState = {
|
||||
autoSync: false,
|
||||
syncInterval: 0,
|
||||
maxBackups: 0,
|
||||
skipBackupFile: false
|
||||
skipBackupFile: false,
|
||||
singleFileOverwrite: false,
|
||||
singleFileName: ''
|
||||
},
|
||||
|
||||
// Developer mode
|
||||
@@ -556,6 +568,12 @@ const settingsSlice = createSlice({
|
||||
setWebdavDisableStream: (state, action: PayloadAction<boolean>) => {
|
||||
state.webdavDisableStream = action.payload
|
||||
},
|
||||
setWebdavSingleFileOverwrite: (state, action: PayloadAction<boolean>) => {
|
||||
state.webdavSingleFileOverwrite = action.payload
|
||||
},
|
||||
setWebdavSingleFileName: (state, action: PayloadAction<string>) => {
|
||||
state.webdavSingleFileName = action.payload
|
||||
},
|
||||
setCodeExecution: (state, action: PayloadAction<{ enabled?: boolean; timeoutMinutes?: number }>) => {
|
||||
if (action.payload.enabled !== undefined) {
|
||||
state.codeExecution.enabled = action.payload.enabled
|
||||
@@ -816,6 +834,12 @@ const settingsSlice = createSlice({
|
||||
setLocalBackupSkipBackupFile: (state, action: PayloadAction<boolean>) => {
|
||||
state.localBackupSkipBackupFile = action.payload
|
||||
},
|
||||
setLocalSingleFileOverwrite: (state, action: PayloadAction<boolean>) => {
|
||||
state.localSingleFileOverwrite = action.payload
|
||||
},
|
||||
setLocalSingleFileName: (state, action: PayloadAction<string>) => {
|
||||
state.localSingleFileName = action.payload
|
||||
},
|
||||
setDefaultPaintingProvider: (state, action: PayloadAction<PaintingProvider>) => {
|
||||
state.defaultPaintingProvider = action.payload
|
||||
},
|
||||
@@ -903,6 +927,8 @@ export const {
|
||||
setWebdavMaxBackups,
|
||||
setWebdavSkipBackupFile,
|
||||
setWebdavDisableStream,
|
||||
setWebdavSingleFileOverwrite,
|
||||
setWebdavSingleFileName,
|
||||
setCodeExecution,
|
||||
setCodeEditor,
|
||||
setCodeViewer,
|
||||
@@ -974,6 +1000,8 @@ export const {
|
||||
setLocalBackupSyncInterval,
|
||||
setLocalBackupMaxBackups,
|
||||
setLocalBackupSkipBackupFile,
|
||||
setLocalSingleFileOverwrite,
|
||||
setLocalSingleFileName,
|
||||
setDefaultPaintingProvider,
|
||||
setS3,
|
||||
setS3Partial,
|
||||
|
||||
@@ -456,6 +456,10 @@ export type WebDavConfig = {
|
||||
fileName?: string
|
||||
skipBackupFile?: boolean
|
||||
disableStream?: boolean
|
||||
/** 当自动备份且保留份数=1时,是否启用覆盖式单文件备份 */
|
||||
singleFileOverwrite?: boolean
|
||||
/** 覆盖式单文件备份的自定义文件名(可选,默认使用不带时间戳的设备名+主机名) */
|
||||
singleFileName?: string
|
||||
}
|
||||
|
||||
export type AppInfo = {
|
||||
@@ -873,6 +877,10 @@ export type S3Config = {
|
||||
autoSync: boolean
|
||||
syncInterval: number
|
||||
maxBackups: number
|
||||
/** 当自动备份且保留份数=1时,是否启用覆盖式单文件备份 */
|
||||
singleFileOverwrite?: boolean
|
||||
/** 覆盖式单文件备份的自定义文件名(可选,默认使用不带时间戳的设备名+主机名) */
|
||||
singleFileName?: string
|
||||
}
|
||||
|
||||
export type { Message } from './newMessage'
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
117
src/renderer/src/utils/backupUtils.ts
Normal file
117
src/renderer/src/utils/backupUtils.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Backup utility functions for validating and processing backup filenames
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validates and sanitizes custom backup filename
|
||||
* @param filename - The custom filename provided by user
|
||||
* @param defaultName - The default filename to fall back to
|
||||
* @returns A safe filename with .zip extension
|
||||
*/
|
||||
export function validateAndSanitizeFilename(filename: string | undefined, defaultName: string): string {
|
||||
// If filename is not provided or empty after trimming, use default
|
||||
if (!filename || filename.trim() === '') {
|
||||
return ensureZipExtension(defaultName)
|
||||
}
|
||||
|
||||
const sanitized = filename.trim()
|
||||
|
||||
// Check for invalid characters
|
||||
const invalidChars = /[<>:"/\\|?*]/
|
||||
if (invalidChars.test(sanitized)) {
|
||||
// Invalid characters, use default name
|
||||
return ensureZipExtension(defaultName)
|
||||
}
|
||||
|
||||
// Check for reserved names (Windows)
|
||||
const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i
|
||||
const nameWithoutExt = sanitized.replace(/\.zip$/i, '')
|
||||
if (reservedNames.test(nameWithoutExt)) {
|
||||
// Reserved name, use default name
|
||||
return ensureZipExtension(defaultName)
|
||||
}
|
||||
|
||||
// Check length (limit to 255 characters for most filesystems)
|
||||
if (sanitized.length > 250) {
|
||||
// Leave room for .zip extension
|
||||
// Filename is too long, truncate
|
||||
return ensureZipExtension(sanitized.substring(0, 250))
|
||||
}
|
||||
|
||||
return ensureZipExtension(sanitized)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the filename has a .zip extension
|
||||
* @param filename - The filename to check
|
||||
* @returns Filename with .zip extension
|
||||
*/
|
||||
function ensureZipExtension(filename: string): string {
|
||||
return filename.toLowerCase().endsWith('.zip') ? filename : `${filename}.zip`
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if backup cleanup should be skipped based on configuration
|
||||
* @param autoBackupProcess - Whether this is an automatic backup process
|
||||
* @param maxBackups - Maximum number of backups to keep
|
||||
* @param singleFileOverwrite - Whether single file overwrite is enabled
|
||||
* @returns True if cleanup should be skipped
|
||||
*/
|
||||
export function shouldSkipCleanup(
|
||||
autoBackupProcess: boolean,
|
||||
maxBackups: number,
|
||||
singleFileOverwrite?: boolean
|
||||
): boolean {
|
||||
return autoBackupProcess && maxBackups === 1 && !!singleFileOverwrite
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a default backup filename based on device information
|
||||
* @param hostname - Device hostname
|
||||
* @param deviceType - Device type
|
||||
* @param timestamp - Optional timestamp (for non-overwrite mode)
|
||||
* @returns Generated filename
|
||||
*/
|
||||
export function generateDefaultFilename(hostname: string, deviceType: string, timestamp?: string): string {
|
||||
const base = `cherry-studio.${hostname}.${deviceType}`
|
||||
return timestamp ? `${base}.${timestamp}.zip` : `${base}.zip`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates backup filename for overwrite mode
|
||||
* @param customFileName - Custom filename provided by user
|
||||
* @param hostname - Device hostname
|
||||
* @param deviceType - Device type
|
||||
* @returns Filename for overwrite mode
|
||||
*/
|
||||
export function generateOverwriteFilename(
|
||||
customFileName: string | undefined,
|
||||
hostname: string,
|
||||
deviceType: string
|
||||
): string {
|
||||
const defaultName = generateDefaultFilename(hostname, deviceType)
|
||||
return validateAndSanitizeFilename(customFileName, defaultName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates backup filename for timestamped mode
|
||||
* @param customFileName - Custom filename provided by user
|
||||
* @param hostname - Device hostname
|
||||
* @param deviceType - Device type
|
||||
* @param timestamp - Timestamp string
|
||||
* @returns Filename for timestamped mode
|
||||
*/
|
||||
export function generateTimestampedFilename(
|
||||
customFileName: string | undefined,
|
||||
hostname: string,
|
||||
deviceType: string,
|
||||
timestamp: string
|
||||
): string {
|
||||
if (customFileName && customFileName.trim()) {
|
||||
// If custom filename is provided, use it as base and add timestamp
|
||||
const base = customFileName.trim().replace(/\.zip$/i, '')
|
||||
return `${base}.${timestamp}.zip`
|
||||
}
|
||||
|
||||
return generateDefaultFilename(hostname, deviceType, timestamp)
|
||||
}
|
||||
Reference in New Issue
Block a user