Compare commits

...

7 Commits

Author SHA1 Message Date
GeorgeDong32
c319a4f4d0 feat(backup): add debug logs and simplify S3 filename logic 2025-10-24 17:47:56 +08:00
GeorgeDong32
980f20fcca feat(settings): add single-file overwrite options and sync maxBackups 2025-10-24 17:47:56 +08:00
GeorgeDong32
f846a27418 feat(settings): add confirmation and validation for WebDAV single-file
Add confirmation dialog when enabling single-file overwrite to ensure the
user understands behavior (fixed filename, overwrites previous backup,
keeps only latest). Initialize related state with safe defaults to avoid
undefined values. Add inline help text recommending use cases.

Introduce filename input handling: trim and validate the entered name,
reject invalid characters, Windows reserved names, and overly long names;
show toast errors for invalid input. Persist validated filename on blur.

Wire up onChange handler for the filename input and disable the field
unless overwrite mode and compatible backup settings are active. These
changes improve UX and prevent accidental data loss or invalid filenames.
2025-10-24 17:47:56 +08:00
GeorgeDong32
d74d66dcbf 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.
2025-10-24 17:47:56 +08:00
GeorgeDong32
a2c1011c55 style(backup): fix style 2025-10-24 17:47:56 +08:00
GeorgeDong32
06cba0e3bf style(backup): fix style 2025-10-24 17:47:56 +08:00
GeorgeDong32
ae392eb2ef feat(backup): add single-file overwrite options for backups 2025-10-24 17:47:56 +08:00
15 changed files with 1148 additions and 44 deletions

View File

@@ -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}`)

View File

@@ -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",

View File

@@ -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": "清除缓存",

View File

@@ -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": "清除快取",

View File

@@ -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 />

View File

@@ -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>
</>
)}
<>

View File

@@ -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 />

View File

@@ -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 />

View File

@@ -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)

View File

@@ -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 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 backupFileName = customFileName || `cherry-studio.${timestamp}.${deviceType}.zip`
const finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.zip`
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 {
// 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 {

View File

@@ -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

View File

@@ -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,

View File

@@ -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'

View 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')
})
})
})

View 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)
}