Compare commits
7 Commits
libsql
...
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) {
|
async backupToS3(_: Electron.IpcMainInvokeEvent, data: string, s3Config: S3Config) {
|
||||||
const os = require('os')
|
// Use the filename provided by frontend, or a simple default (no timestamp generation here)
|
||||||
const deviceName = os.hostname ? os.hostname() : 'device'
|
const filename = s3Config.fileName || 'cherry-studio.backup.zip'
|
||||||
const timestamp = new Date()
|
|
||||||
.toISOString()
|
|
||||||
.replace(/[-:T.Z]/g, '')
|
|
||||||
.slice(0, 14)
|
|
||||||
const filename = s3Config.fileName || `cherry-studio.backup.${deviceName}.${timestamp}.zip`
|
|
||||||
|
|
||||||
logger.debug(`Starting S3 backup to ${filename}`)
|
logger.debug(`Starting S3 backup to ${filename}`)
|
||||||
|
|
||||||
|
|||||||
@@ -2900,7 +2900,27 @@
|
|||||||
},
|
},
|
||||||
"backup": {
|
"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_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": {
|
"clear_cache": {
|
||||||
"button": "Clear Cache",
|
"button": "Clear Cache",
|
||||||
|
|||||||
@@ -2900,7 +2900,27 @@
|
|||||||
},
|
},
|
||||||
"backup": {
|
"backup": {
|
||||||
"skip_file_data_help": "备份时跳过备份图片、知识库等数据文件,仅备份聊天记录和设置。减少空间占用,加快备份速度",
|
"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": {
|
"clear_cache": {
|
||||||
"button": "清除缓存",
|
"button": "清除缓存",
|
||||||
|
|||||||
@@ -2900,7 +2900,27 @@
|
|||||||
},
|
},
|
||||||
"backup": {
|
"backup": {
|
||||||
"skip_file_data_help": "備份時跳過備份圖片、知識庫等數據文件,僅備份聊天記錄和設置。減少空間佔用,加快備份速度",
|
"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": {
|
"clear_cache": {
|
||||||
"button": "清除快取",
|
"button": "清除快取",
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import {
|
|||||||
setLocalBackupDir as _setLocalBackupDir,
|
setLocalBackupDir as _setLocalBackupDir,
|
||||||
setLocalBackupMaxBackups as _setLocalBackupMaxBackups,
|
setLocalBackupMaxBackups as _setLocalBackupMaxBackups,
|
||||||
setLocalBackupSkipBackupFile as _setLocalBackupSkipBackupFile,
|
setLocalBackupSkipBackupFile as _setLocalBackupSkipBackupFile,
|
||||||
setLocalBackupSyncInterval as _setLocalBackupSyncInterval
|
setLocalBackupSyncInterval as _setLocalBackupSyncInterval,
|
||||||
|
setLocalSingleFileName as _setLocalSingleFileName,
|
||||||
|
setLocalSingleFileOverwrite as _setLocalSingleFileOverwrite
|
||||||
} from '@renderer/store/settings'
|
} from '@renderer/store/settings'
|
||||||
import { AppInfo } from '@renderer/types'
|
import { AppInfo } from '@renderer/types'
|
||||||
import { Button, Input, Switch, Tooltip } from 'antd'
|
import { Button, Input, Switch, Tooltip } from 'antd'
|
||||||
@@ -32,12 +34,18 @@ const LocalBackupSettings: React.FC = () => {
|
|||||||
localBackupDir: localBackupDirSetting,
|
localBackupDir: localBackupDirSetting,
|
||||||
localBackupSyncInterval: localBackupSyncIntervalSetting,
|
localBackupSyncInterval: localBackupSyncIntervalSetting,
|
||||||
localBackupMaxBackups: localBackupMaxBackupsSetting,
|
localBackupMaxBackups: localBackupMaxBackupsSetting,
|
||||||
localBackupSkipBackupFile: localBackupSkipBackupFileSetting
|
localBackupSkipBackupFile: localBackupSkipBackupFileSetting,
|
||||||
|
localSingleFileOverwrite: localSingleFileOverwriteSetting,
|
||||||
|
localSingleFileName: localSingleFileNameSetting
|
||||||
} = useSettings()
|
} = useSettings()
|
||||||
|
|
||||||
const [localBackupDir, setLocalBackupDir] = useState<string | undefined>(localBackupDirSetting)
|
const [localBackupDir, setLocalBackupDir] = useState<string | undefined>(localBackupDirSetting)
|
||||||
const [resolvedLocalBackupDir, setResolvedLocalBackupDir] = useState<string | undefined>(undefined)
|
const [resolvedLocalBackupDir, setResolvedLocalBackupDir] = useState<string | undefined>(undefined)
|
||||||
const [localBackupSkipBackupFile, setLocalBackupSkipBackupFile] = useState<boolean>(localBackupSkipBackupFileSetting)
|
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 [backupManagerVisible, setBackupManagerVisible] = useState(false)
|
||||||
|
|
||||||
const [syncInterval, setSyncInterval] = useState<number>(localBackupSyncIntervalSetting)
|
const [syncInterval, setSyncInterval] = useState<number>(localBackupSyncIntervalSetting)
|
||||||
@@ -61,6 +69,11 @@ const LocalBackupSettings: React.FC = () => {
|
|||||||
|
|
||||||
const { localBackupSync } = useAppSelector((state) => state.backup)
|
const { localBackupSync } = useAppSelector((state) => state.backup)
|
||||||
|
|
||||||
|
// 同步 maxBackups 状态
|
||||||
|
useEffect(() => {
|
||||||
|
setMaxBackups(localBackupMaxBackupsSetting)
|
||||||
|
}, [localBackupMaxBackupsSetting])
|
||||||
|
|
||||||
const onSyncIntervalChange = (value: number) => {
|
const onSyncIntervalChange = (value: number) => {
|
||||||
setSyncInterval(value)
|
setSyncInterval(value)
|
||||||
dispatch(_setLocalBackupSyncInterval(value))
|
dispatch(_setLocalBackupSyncInterval(value))
|
||||||
@@ -140,6 +153,68 @@ const LocalBackupSettings: React.FC = () => {
|
|||||||
dispatch(_setLocalBackupSkipBackupFile(value))
|
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 () => {
|
const handleBrowseDirectory = async () => {
|
||||||
try {
|
try {
|
||||||
const newLocalBackupDir = await window.api.select({
|
const newLocalBackupDir = await window.api.select({
|
||||||
@@ -282,6 +357,58 @@ const LocalBackupSettings: React.FC = () => {
|
|||||||
<SettingRow>
|
<SettingRow>
|
||||||
<SettingHelpText>{t('settings.data.backup.skip_file_data_help')}</SettingHelpText>
|
<SettingHelpText>{t('settings.data.backup.skip_file_data_help')}</SettingHelpText>
|
||||||
</SettingRow>
|
</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 && (
|
{localBackupSync && syncInterval > 0 && (
|
||||||
<>
|
<>
|
||||||
<SettingDivider />
|
<SettingDivider />
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
setNutstoreAutoSync,
|
setNutstoreAutoSync,
|
||||||
setNutstoreMaxBackups,
|
setNutstoreMaxBackups,
|
||||||
setNutstorePath,
|
setNutstorePath,
|
||||||
|
setNutstoreSingleFileName,
|
||||||
|
setNutstoreSingleFileOverwrite,
|
||||||
setNutstoreSkipBackupFile,
|
setNutstoreSkipBackupFile,
|
||||||
setNutstoreSyncInterval,
|
setNutstoreSyncInterval,
|
||||||
setNutstoreToken
|
setNutstoreToken
|
||||||
@@ -44,7 +46,9 @@ const NutstoreSettings: FC = () => {
|
|||||||
nutstoreAutoSync,
|
nutstoreAutoSync,
|
||||||
nutstoreSyncState,
|
nutstoreSyncState,
|
||||||
nutstoreSkipBackupFile,
|
nutstoreSkipBackupFile,
|
||||||
nutstoreMaxBackups
|
nutstoreMaxBackups,
|
||||||
|
nutstoreSingleFileOverwrite,
|
||||||
|
nutstoreSingleFileName
|
||||||
} = useAppSelector((state) => state.nutstore)
|
} = useAppSelector((state) => state.nutstore)
|
||||||
|
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
@@ -55,12 +59,20 @@ const NutstoreSettings: FC = () => {
|
|||||||
const [checkConnectionLoading, setCheckConnectionLoading] = useState(false)
|
const [checkConnectionLoading, setCheckConnectionLoading] = useState(false)
|
||||||
const [nsConnected, setNsConnected] = useState<boolean>(false)
|
const [nsConnected, setNsConnected] = useState<boolean>(false)
|
||||||
const [syncInterval, setSyncInterval] = useState<number>(nutstoreSyncInterval)
|
const [syncInterval, setSyncInterval] = useState<number>(nutstoreSyncInterval)
|
||||||
|
const [maxBackups, setMaxBackups] = useState<number>(nutstoreMaxBackups)
|
||||||
const [nutSkipBackupFile, setNutSkipBackupFile] = useState<boolean>(nutstoreSkipBackupFile)
|
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 [backupManagerVisible, setBackupManagerVisible] = useState(false)
|
||||||
|
|
||||||
const nutstoreSSOHandler = useNutstoreSSO()
|
const nutstoreSSOHandler = useNutstoreSSO()
|
||||||
const { setTimeoutTimer } = useTimer()
|
const { setTimeoutTimer } = useTimer()
|
||||||
|
|
||||||
|
// 同步 maxBackups 状态
|
||||||
|
useEffect(() => {
|
||||||
|
setMaxBackups(nutstoreMaxBackups)
|
||||||
|
}, [nutstoreMaxBackups])
|
||||||
|
|
||||||
const handleClickNutstoreSSO = useCallback(async () => {
|
const handleClickNutstoreSSO = useCallback(async () => {
|
||||||
const ssoUrl = await window.api.nutstore.getSSOUrl()
|
const ssoUrl = await window.api.nutstore.getSSOUrl()
|
||||||
window.open(ssoUrl, '_blank')
|
window.open(ssoUrl, '_blank')
|
||||||
@@ -142,9 +154,72 @@ const NutstoreSettings: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onMaxBackupsChange = (value: number) => {
|
const onMaxBackupsChange = (value: number) => {
|
||||||
|
setMaxBackups(value)
|
||||||
dispatch(setNutstoreMaxBackups(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 () => {
|
const handleClickPathChange = async () => {
|
||||||
if (!nutstoreToken) {
|
if (!nutstoreToken) {
|
||||||
return
|
return
|
||||||
@@ -336,6 +411,60 @@ const NutstoreSettings: FC = () => {
|
|||||||
<SettingRow>
|
<SettingRow>
|
||||||
<SettingHelpText>{t('settings.data.backup.skip_file_data_help')}</SettingHelpText>
|
<SettingHelpText>{t('settings.data.backup.skip_file_data_help')}</SettingHelpText>
|
||||||
</SettingRow>
|
</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 { S3Config } from '@renderer/types'
|
||||||
import { Button, Input, Switch, Tooltip } from 'antd'
|
import { Button, Input, Switch, Tooltip } from 'antd'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { FC, useState } from 'react'
|
import { FC, useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||||
@@ -30,7 +30,9 @@ const S3Settings: FC = () => {
|
|||||||
root: s3RootInit = '',
|
root: s3RootInit = '',
|
||||||
syncInterval: s3SyncIntervalInit = 0,
|
syncInterval: s3SyncIntervalInit = 0,
|
||||||
maxBackups: s3MaxBackupsInit = 5,
|
maxBackups: s3MaxBackupsInit = 5,
|
||||||
skipBackupFile: s3SkipBackupFileInit = false
|
skipBackupFile: s3SkipBackupFileInit = false,
|
||||||
|
singleFileOverwrite: s3SingleFileOverwriteInit = false,
|
||||||
|
singleFileName: s3SingleFileNameInit = ''
|
||||||
} = s3
|
} = s3
|
||||||
|
|
||||||
const [endpoint, setEndpoint] = useState<string | undefined>(s3EndpointInit)
|
const [endpoint, setEndpoint] = useState<string | undefined>(s3EndpointInit)
|
||||||
@@ -40,6 +42,8 @@ const S3Settings: FC = () => {
|
|||||||
const [secretAccessKey, setSecretAccessKey] = useState<string | undefined>(s3SecretAccessKeyInit)
|
const [secretAccessKey, setSecretAccessKey] = useState<string | undefined>(s3SecretAccessKeyInit)
|
||||||
const [root, setRoot] = useState<string | undefined>(s3RootInit)
|
const [root, setRoot] = useState<string | undefined>(s3RootInit)
|
||||||
const [skipBackupFile, setSkipBackupFile] = useState<boolean>(s3SkipBackupFileInit)
|
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 [backupManagerVisible, setBackupManagerVisible] = useState(false)
|
||||||
|
|
||||||
const [syncInterval, setSyncInterval] = useState<number>(s3SyncIntervalInit)
|
const [syncInterval, setSyncInterval] = useState<number>(s3SyncIntervalInit)
|
||||||
@@ -52,6 +56,11 @@ const S3Settings: FC = () => {
|
|||||||
|
|
||||||
const { s3Sync } = useAppSelector((state) => state.backup)
|
const { s3Sync } = useAppSelector((state) => state.backup)
|
||||||
|
|
||||||
|
// 同步 maxBackups 状态
|
||||||
|
useEffect(() => {
|
||||||
|
setMaxBackups(s3MaxBackupsInit)
|
||||||
|
}, [s3MaxBackupsInit])
|
||||||
|
|
||||||
const onSyncIntervalChange = (value: number) => {
|
const onSyncIntervalChange = (value: number) => {
|
||||||
setSyncInterval(value)
|
setSyncInterval(value)
|
||||||
dispatch(setS3Partial({ syncInterval: value, autoSync: value !== 0 }))
|
dispatch(setS3Partial({ syncInterval: value, autoSync: value !== 0 }))
|
||||||
@@ -81,6 +90,68 @@ const S3Settings: FC = () => {
|
|||||||
dispatch(setS3Partial({ skipBackupFile: value }))
|
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 = () => {
|
const renderSyncStatus = () => {
|
||||||
if (!endpoint) return null
|
if (!endpoint) return null
|
||||||
|
|
||||||
@@ -260,6 +331,58 @@ const S3Settings: FC = () => {
|
|||||||
<SettingRow>
|
<SettingRow>
|
||||||
<SettingHelpText>{t('settings.data.s3.skipBackupFile.help')}</SettingHelpText>
|
<SettingHelpText>{t('settings.data.s3.skipBackupFile.help')}</SettingHelpText>
|
||||||
</SettingRow>
|
</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 && (
|
{syncInterval > 0 && (
|
||||||
<>
|
<>
|
||||||
<SettingDivider />
|
<SettingDivider />
|
||||||
|
|||||||
@@ -14,13 +14,15 @@ import {
|
|||||||
setWebdavMaxBackups as _setWebdavMaxBackups,
|
setWebdavMaxBackups as _setWebdavMaxBackups,
|
||||||
setWebdavPass as _setWebdavPass,
|
setWebdavPass as _setWebdavPass,
|
||||||
setWebdavPath as _setWebdavPath,
|
setWebdavPath as _setWebdavPath,
|
||||||
|
setWebdavSingleFileName as _setWebdavSingleFileName,
|
||||||
|
setWebdavSingleFileOverwrite as _setWebdavSingleFileOverwrite,
|
||||||
setWebdavSkipBackupFile as _setWebdavSkipBackupFile,
|
setWebdavSkipBackupFile as _setWebdavSkipBackupFile,
|
||||||
setWebdavSyncInterval as _setWebdavSyncInterval,
|
setWebdavSyncInterval as _setWebdavSyncInterval,
|
||||||
setWebdavUser as _setWebdavUser
|
setWebdavUser as _setWebdavUser
|
||||||
} from '@renderer/store/settings'
|
} from '@renderer/store/settings'
|
||||||
import { Button, Input, Switch, Tooltip } from 'antd'
|
import { Button, Input, Switch, Tooltip } from 'antd'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { FC, useState } from 'react'
|
import { FC, useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||||
@@ -34,7 +36,9 @@ const WebDavSettings: FC = () => {
|
|||||||
webdavSyncInterval: webDAVSyncInterval,
|
webdavSyncInterval: webDAVSyncInterval,
|
||||||
webdavMaxBackups: webDAVMaxBackups,
|
webdavMaxBackups: webDAVMaxBackups,
|
||||||
webdavSkipBackupFile: webdDAVSkipBackupFile,
|
webdavSkipBackupFile: webdDAVSkipBackupFile,
|
||||||
webdavDisableStream: webDAVDisableStream
|
webdavDisableStream: webDAVDisableStream,
|
||||||
|
webdavSingleFileOverwrite: webDAVSingleFileOverwrite,
|
||||||
|
webdavSingleFileName: webDAVSingleFileName
|
||||||
} = useSettings()
|
} = useSettings()
|
||||||
|
|
||||||
const [webdavHost, setWebdavHost] = useState<string | undefined>(webDAVHost)
|
const [webdavHost, setWebdavHost] = useState<string | undefined>(webDAVHost)
|
||||||
@@ -43,6 +47,10 @@ const WebDavSettings: FC = () => {
|
|||||||
const [webdavPath, setWebdavPath] = useState<string | undefined>(webDAVPath)
|
const [webdavPath, setWebdavPath] = useState<string | undefined>(webDAVPath)
|
||||||
const [webdavSkipBackupFile, setWebdavSkipBackupFile] = useState<boolean>(webdDAVSkipBackupFile)
|
const [webdavSkipBackupFile, setWebdavSkipBackupFile] = useState<boolean>(webdDAVSkipBackupFile)
|
||||||
const [webdavDisableStream, setWebdavDisableStream] = useState<boolean>(webDAVDisableStream)
|
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 [backupManagerVisible, setBackupManagerVisible] = useState(false)
|
||||||
|
|
||||||
const [syncInterval, setSyncInterval] = useState<number>(webDAVSyncInterval)
|
const [syncInterval, setSyncInterval] = useState<number>(webDAVSyncInterval)
|
||||||
@@ -55,6 +63,11 @@ const WebDavSettings: FC = () => {
|
|||||||
|
|
||||||
const { webdavSync } = useAppSelector((state) => state.backup)
|
const { webdavSync } = useAppSelector((state) => state.backup)
|
||||||
|
|
||||||
|
// 同步 maxBackups 状态
|
||||||
|
useEffect(() => {
|
||||||
|
setMaxBackups(webDAVMaxBackups)
|
||||||
|
}, [webDAVMaxBackups])
|
||||||
|
|
||||||
// 把之前备份的文件定时上传到 webdav,首先先配置 webdav 的 host, port, user, pass, path
|
// 把之前备份的文件定时上传到 webdav,首先先配置 webdav 的 host, port, user, pass, path
|
||||||
|
|
||||||
const onSyncIntervalChange = (value: number) => {
|
const onSyncIntervalChange = (value: number) => {
|
||||||
@@ -84,6 +97,68 @@ const WebDavSettings: FC = () => {
|
|||||||
dispatch(_setWebdavDisableStream(value))
|
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 = () => {
|
const renderSyncStatus = () => {
|
||||||
if (!webdavHost) return null
|
if (!webdavHost) return null
|
||||||
|
|
||||||
@@ -236,6 +311,58 @@ const WebDavSettings: FC = () => {
|
|||||||
<SettingRow>
|
<SettingRow>
|
||||||
<SettingHelpText>{t('settings.data.webdav.disableStream.help')}</SettingHelpText>
|
<SettingHelpText>{t('settings.data.webdav.disableStream.help')}</SettingHelpText>
|
||||||
</SettingRow>
|
</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 && (
|
{webdavSync && syncInterval > 0 && (
|
||||||
<>
|
<>
|
||||||
<SettingDivider />
|
<SettingDivider />
|
||||||
|
|||||||
@@ -6,10 +6,23 @@ import store from '@renderer/store'
|
|||||||
import { setLocalBackupSyncState, setS3SyncState, setWebDAVSyncState } from '@renderer/store/backup'
|
import { setLocalBackupSyncState, setS3SyncState, setWebDAVSyncState } from '@renderer/store/backup'
|
||||||
import { S3Config, WebDavConfig } from '@renderer/types'
|
import { S3Config, WebDavConfig } from '@renderer/types'
|
||||||
import { uuid } from '@renderer/utils'
|
import { uuid } from '@renderer/utils'
|
||||||
|
import { generateOverwriteFilename, generateTimestampedFilename, shouldSkipCleanup } from '@renderer/utils/backupUtils'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
import { NotificationService } from './NotificationService'
|
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')
|
const logger = loggerService.withContext('BackupService')
|
||||||
|
|
||||||
// 重试删除S3文件的辅助函数
|
// 重试删除S3文件的辅助函数
|
||||||
@@ -168,7 +181,9 @@ export async function backupToWebdav({
|
|||||||
webdavPath,
|
webdavPath,
|
||||||
webdavMaxBackups,
|
webdavMaxBackups,
|
||||||
webdavSkipBackupFile,
|
webdavSkipBackupFile,
|
||||||
webdavDisableStream
|
webdavDisableStream,
|
||||||
|
webdavSingleFileOverwrite,
|
||||||
|
webdavSingleFileName
|
||||||
} = store.getState().settings
|
} = store.getState().settings
|
||||||
let deviceType = 'unknown'
|
let deviceType = 'unknown'
|
||||||
let hostname = 'unknown'
|
let hostname = 'unknown'
|
||||||
@@ -179,8 +194,19 @@ export async function backupToWebdav({
|
|||||||
logger.error('Failed to get device type or hostname:', error as Error)
|
logger.error('Failed to get device type or hostname:', error as Error)
|
||||||
}
|
}
|
||||||
const timestamp = dayjs().format('YYYYMMDDHHmmss')
|
const timestamp = dayjs().format('YYYYMMDDHHmmss')
|
||||||
const backupFileName = customFileName || `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip`
|
let finalFileName: string
|
||||||
const finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.zip`
|
|
||||||
|
// 覆盖式单文件备份(仅在自动备份流程且保留份数=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()
|
const backupData = await getBackupData()
|
||||||
|
|
||||||
// 上传文件
|
// 上传文件
|
||||||
@@ -212,8 +238,8 @@ export async function backupToWebdav({
|
|||||||
})
|
})
|
||||||
showMessage && window.toast.success(i18n.t('message.backup.success'))
|
showMessage && window.toast.success(i18n.t('message.backup.success'))
|
||||||
|
|
||||||
// 清理旧备份文件
|
// 使用工具函数判断是否<EFBFBD><EFBFBD><EFBFBD>过清理
|
||||||
if (webdavMaxBackups > 0) {
|
if (webdavMaxBackups > 0 && !shouldSkipCleanup(autoBackupProcess, webdavMaxBackups, webdavSingleFileOverwrite)) {
|
||||||
try {
|
try {
|
||||||
// 获取所有备份文件
|
// 获取所有备份文件
|
||||||
const files = await window.api.backup.listWebdavFiles({
|
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)
|
logger.error('Failed to get device type or hostname:', error as Error)
|
||||||
}
|
}
|
||||||
const timestamp = dayjs().format('YYYYMMDDHHmmss')
|
const timestamp = dayjs().format('YYYYMMDDHHmmss')
|
||||||
const backupFileName = customFileName || `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip`
|
let finalFileName: string
|
||||||
const finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.zip`
|
|
||||||
|
// 覆盖式单文件备份(仅在自动备份流程且保留份数=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()
|
const backupData = await getBackupData()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -384,7 +421,11 @@ export async function backupToS3({
|
|||||||
showMessage && window.toast.success(i18n.t('message.backup.success'))
|
showMessage && window.toast.success(i18n.t('message.backup.success'))
|
||||||
|
|
||||||
// 清理旧备份文件
|
// 清理旧备份文件
|
||||||
if (s3Config.maxBackups > 0) {
|
// 使用工具函数判断是否跳过清理
|
||||||
|
if (
|
||||||
|
s3Config.maxBackups > 0 &&
|
||||||
|
!shouldSkipCleanup(autoBackupProcess, s3Config.maxBackups, s3Config.singleFileOverwrite)
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
// 获取所有备份文件
|
// 获取所有备份文件
|
||||||
const files = await window.api.backup.listS3Files(s3Config)
|
const files = await window.api.backup.listS3Files(s3Config)
|
||||||
@@ -939,7 +980,9 @@ export async function backupToLocal({
|
|||||||
const {
|
const {
|
||||||
localBackupDir: localBackupDirSetting,
|
localBackupDir: localBackupDirSetting,
|
||||||
localBackupMaxBackups,
|
localBackupMaxBackups,
|
||||||
localBackupSkipBackupFile
|
localBackupSkipBackupFile,
|
||||||
|
localSingleFileOverwrite,
|
||||||
|
localSingleFileName
|
||||||
} = store.getState().settings
|
} = store.getState().settings
|
||||||
const localBackupDir = await window.api.resolvePath(localBackupDirSetting)
|
const localBackupDir = await window.api.resolvePath(localBackupDirSetting)
|
||||||
let deviceType = 'unknown'
|
let deviceType = 'unknown'
|
||||||
@@ -951,8 +994,19 @@ export async function backupToLocal({
|
|||||||
logger.error('Failed to get device type or hostname:', error as Error)
|
logger.error('Failed to get device type or hostname:', error as Error)
|
||||||
}
|
}
|
||||||
const timestamp = dayjs().format('YYYYMMDDHHmmss')
|
const timestamp = dayjs().format('YYYYMMDDHHmmss')
|
||||||
const backupFileName = customFileName || `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip`
|
let finalFileName: string
|
||||||
const finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.zip`
|
|
||||||
|
// 覆盖式单文件备份(仅在自动备份流程且保留份数=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()
|
const backupData = await getBackupData()
|
||||||
|
|
||||||
try {
|
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 {
|
try {
|
||||||
// Get all backup files
|
// Get all backup files
|
||||||
const files = await window.api.backup.listLocalBackupFiles(localBackupDir)
|
const files = await window.api.backup.listLocalBackupFiles(localBackupDir)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { NUTSTORE_HOST } from '@shared/config/nutstore'
|
|||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { type CreateDirectoryOptions } from 'webdav'
|
import { type CreateDirectoryOptions } from 'webdav'
|
||||||
|
|
||||||
|
import { shouldSkipCleanup, validateAndSanitizeFilename } from '../utils/backupUtils'
|
||||||
import { getBackupData, handleData } from './BackupService'
|
import { getBackupData, handleData } from './BackupService'
|
||||||
|
|
||||||
const logger = loggerService.withContext('NutstoreService')
|
const logger = loggerService.withContext('NutstoreService')
|
||||||
@@ -109,10 +110,12 @@ async function cleanupOldBackups(webdavConfig: WebDavConfig, maxBackups: number)
|
|||||||
|
|
||||||
export async function backupToNutstore({
|
export async function backupToNutstore({
|
||||||
showMessage = false,
|
showMessage = false,
|
||||||
customFileName = ''
|
customFileName = '',
|
||||||
|
isAutoBackup = false
|
||||||
}: {
|
}: {
|
||||||
showMessage?: boolean
|
showMessage?: boolean
|
||||||
customFileName?: string
|
customFileName?: string
|
||||||
|
isAutoBackup?: boolean
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const nutstoreToken = getNutstoreToken()
|
const nutstoreToken = getNutstoreToken()
|
||||||
if (!nutstoreToken) {
|
if (!nutstoreToken) {
|
||||||
@@ -135,21 +138,37 @@ export async function backupToNutstore({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[backupToNutstore] Failed to get device type:', error as 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 backupData = await getBackupData()
|
||||||
const finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.zip`
|
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
|
isManualBackupRunning = true
|
||||||
|
|
||||||
store.dispatch(setNutstoreSyncState({ syncing: true, lastSyncError: null }))
|
store.dispatch(setNutstoreSyncState({ syncing: true, lastSyncError: null }))
|
||||||
|
|
||||||
const backupData = await getBackupData()
|
|
||||||
const skipBackupFile = store.getState().nutstore.nutstoreSkipBackupFile
|
|
||||||
const maxBackups = store.getState().nutstore.nutstoreMaxBackups
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 先清理旧备份
|
// Skip cleanup for single file overwrite mode when maxBackups is 1
|
||||||
await cleanupOldBackups(config, maxBackups)
|
if (!shouldSkipCleanup(maxBackups, singleFileOverwrite)) {
|
||||||
|
// 先清理旧备份
|
||||||
|
await cleanupOldBackups(config, maxBackups)
|
||||||
|
}
|
||||||
|
|
||||||
const isSuccess = await window.api.backup.backupToWebdav(backupData, {
|
const isSuccess = await window.api.backup.backupToWebdav(backupData, {
|
||||||
...config,
|
...config,
|
||||||
@@ -264,7 +283,7 @@ export async function startNutstoreAutoSync() {
|
|||||||
isAutoBackupRunning = true
|
isAutoBackupRunning = true
|
||||||
try {
|
try {
|
||||||
logger.verbose('[Nutstore AutoSync] Starting auto backup...')
|
logger.verbose('[Nutstore AutoSync] Starting auto backup...')
|
||||||
await backupToNutstore({ showMessage: false })
|
await backupToNutstore({ showMessage: false, isAutoBackup: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[Nutstore AutoSync] Auto backup failed:', error as Error)
|
logger.error('[Nutstore AutoSync] Auto backup failed:', error as Error)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export interface NutstoreState {
|
|||||||
nutstoreSyncState: NutstoreSyncState
|
nutstoreSyncState: NutstoreSyncState
|
||||||
nutstoreSkipBackupFile: boolean
|
nutstoreSkipBackupFile: boolean
|
||||||
nutstoreMaxBackups: number
|
nutstoreMaxBackups: number
|
||||||
|
nutstoreSingleFileOverwrite: boolean
|
||||||
|
nutstoreSingleFileName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: NutstoreState = {
|
const initialState: NutstoreState = {
|
||||||
@@ -25,7 +27,9 @@ const initialState: NutstoreState = {
|
|||||||
lastSyncError: null
|
lastSyncError: null
|
||||||
},
|
},
|
||||||
nutstoreSkipBackupFile: false,
|
nutstoreSkipBackupFile: false,
|
||||||
nutstoreMaxBackups: 0
|
nutstoreMaxBackups: 0,
|
||||||
|
nutstoreSingleFileOverwrite: false,
|
||||||
|
nutstoreSingleFileName: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const nutstoreSlice = createSlice({
|
const nutstoreSlice = createSlice({
|
||||||
@@ -52,6 +56,12 @@ const nutstoreSlice = createSlice({
|
|||||||
},
|
},
|
||||||
setNutstoreMaxBackups: (state, action: PayloadAction<number>) => {
|
setNutstoreMaxBackups: (state, action: PayloadAction<number>) => {
|
||||||
state.nutstoreMaxBackups = action.payload
|
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,
|
setNutstoreSyncInterval,
|
||||||
setNutstoreSyncState,
|
setNutstoreSyncState,
|
||||||
setNutstoreSkipBackupFile,
|
setNutstoreSkipBackupFile,
|
||||||
setNutstoreMaxBackups
|
setNutstoreMaxBackups,
|
||||||
|
setNutstoreSingleFileOverwrite,
|
||||||
|
setNutstoreSingleFileName
|
||||||
} = nutstoreSlice.actions
|
} = nutstoreSlice.actions
|
||||||
|
|
||||||
export default nutstoreSlice.reducer
|
export default nutstoreSlice.reducer
|
||||||
|
|||||||
@@ -119,6 +119,9 @@ export interface SettingsState {
|
|||||||
webdavMaxBackups: number
|
webdavMaxBackups: number
|
||||||
webdavSkipBackupFile: boolean
|
webdavSkipBackupFile: boolean
|
||||||
webdavDisableStream: boolean
|
webdavDisableStream: boolean
|
||||||
|
// 覆盖式单文件备份(WebDAV)
|
||||||
|
webdavSingleFileOverwrite?: boolean
|
||||||
|
webdavSingleFileName?: string
|
||||||
translateModelPrompt: string
|
translateModelPrompt: string
|
||||||
autoTranslateWithSpace: boolean
|
autoTranslateWithSpace: boolean
|
||||||
showTranslateConfirm: boolean
|
showTranslateConfirm: boolean
|
||||||
@@ -210,6 +213,9 @@ export interface SettingsState {
|
|||||||
localBackupSyncInterval: number
|
localBackupSyncInterval: number
|
||||||
localBackupMaxBackups: number
|
localBackupMaxBackups: number
|
||||||
localBackupSkipBackupFile: boolean
|
localBackupSkipBackupFile: boolean
|
||||||
|
// 覆盖式单文件备份(Local)
|
||||||
|
localSingleFileOverwrite?: boolean
|
||||||
|
localSingleFileName?: string
|
||||||
defaultPaintingProvider: PaintingProvider
|
defaultPaintingProvider: PaintingProvider
|
||||||
s3: S3Config
|
s3: S3Config
|
||||||
// Developer mode
|
// Developer mode
|
||||||
@@ -306,6 +312,8 @@ export const initialState: SettingsState = {
|
|||||||
webdavMaxBackups: 0,
|
webdavMaxBackups: 0,
|
||||||
webdavSkipBackupFile: false,
|
webdavSkipBackupFile: false,
|
||||||
webdavDisableStream: false,
|
webdavDisableStream: false,
|
||||||
|
webdavSingleFileOverwrite: false,
|
||||||
|
webdavSingleFileName: '',
|
||||||
translateModelPrompt: TRANSLATE_PROMPT,
|
translateModelPrompt: TRANSLATE_PROMPT,
|
||||||
autoTranslateWithSpace: false,
|
autoTranslateWithSpace: false,
|
||||||
showTranslateConfirm: true,
|
showTranslateConfirm: true,
|
||||||
@@ -389,6 +397,8 @@ export const initialState: SettingsState = {
|
|||||||
localBackupSyncInterval: 0,
|
localBackupSyncInterval: 0,
|
||||||
localBackupMaxBackups: 0,
|
localBackupMaxBackups: 0,
|
||||||
localBackupSkipBackupFile: false,
|
localBackupSkipBackupFile: false,
|
||||||
|
localSingleFileOverwrite: false,
|
||||||
|
localSingleFileName: '',
|
||||||
defaultPaintingProvider: 'zhipu',
|
defaultPaintingProvider: 'zhipu',
|
||||||
s3: {
|
s3: {
|
||||||
endpoint: '',
|
endpoint: '',
|
||||||
@@ -400,7 +410,9 @@ export const initialState: SettingsState = {
|
|||||||
autoSync: false,
|
autoSync: false,
|
||||||
syncInterval: 0,
|
syncInterval: 0,
|
||||||
maxBackups: 0,
|
maxBackups: 0,
|
||||||
skipBackupFile: false
|
skipBackupFile: false,
|
||||||
|
singleFileOverwrite: false,
|
||||||
|
singleFileName: ''
|
||||||
},
|
},
|
||||||
|
|
||||||
// Developer mode
|
// Developer mode
|
||||||
@@ -556,6 +568,12 @@ const settingsSlice = createSlice({
|
|||||||
setWebdavDisableStream: (state, action: PayloadAction<boolean>) => {
|
setWebdavDisableStream: (state, action: PayloadAction<boolean>) => {
|
||||||
state.webdavDisableStream = action.payload
|
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 }>) => {
|
setCodeExecution: (state, action: PayloadAction<{ enabled?: boolean; timeoutMinutes?: number }>) => {
|
||||||
if (action.payload.enabled !== undefined) {
|
if (action.payload.enabled !== undefined) {
|
||||||
state.codeExecution.enabled = action.payload.enabled
|
state.codeExecution.enabled = action.payload.enabled
|
||||||
@@ -816,6 +834,12 @@ const settingsSlice = createSlice({
|
|||||||
setLocalBackupSkipBackupFile: (state, action: PayloadAction<boolean>) => {
|
setLocalBackupSkipBackupFile: (state, action: PayloadAction<boolean>) => {
|
||||||
state.localBackupSkipBackupFile = action.payload
|
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>) => {
|
setDefaultPaintingProvider: (state, action: PayloadAction<PaintingProvider>) => {
|
||||||
state.defaultPaintingProvider = action.payload
|
state.defaultPaintingProvider = action.payload
|
||||||
},
|
},
|
||||||
@@ -903,6 +927,8 @@ export const {
|
|||||||
setWebdavMaxBackups,
|
setWebdavMaxBackups,
|
||||||
setWebdavSkipBackupFile,
|
setWebdavSkipBackupFile,
|
||||||
setWebdavDisableStream,
|
setWebdavDisableStream,
|
||||||
|
setWebdavSingleFileOverwrite,
|
||||||
|
setWebdavSingleFileName,
|
||||||
setCodeExecution,
|
setCodeExecution,
|
||||||
setCodeEditor,
|
setCodeEditor,
|
||||||
setCodeViewer,
|
setCodeViewer,
|
||||||
@@ -974,6 +1000,8 @@ export const {
|
|||||||
setLocalBackupSyncInterval,
|
setLocalBackupSyncInterval,
|
||||||
setLocalBackupMaxBackups,
|
setLocalBackupMaxBackups,
|
||||||
setLocalBackupSkipBackupFile,
|
setLocalBackupSkipBackupFile,
|
||||||
|
setLocalSingleFileOverwrite,
|
||||||
|
setLocalSingleFileName,
|
||||||
setDefaultPaintingProvider,
|
setDefaultPaintingProvider,
|
||||||
setS3,
|
setS3,
|
||||||
setS3Partial,
|
setS3Partial,
|
||||||
|
|||||||
@@ -456,6 +456,10 @@ export type WebDavConfig = {
|
|||||||
fileName?: string
|
fileName?: string
|
||||||
skipBackupFile?: boolean
|
skipBackupFile?: boolean
|
||||||
disableStream?: boolean
|
disableStream?: boolean
|
||||||
|
/** 当自动备份且保留份数=1时,是否启用覆盖式单文件备份 */
|
||||||
|
singleFileOverwrite?: boolean
|
||||||
|
/** 覆盖式单文件备份的自定义文件名(可选,默认使用不带时间戳的设备名+主机名) */
|
||||||
|
singleFileName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AppInfo = {
|
export type AppInfo = {
|
||||||
@@ -873,6 +877,10 @@ export type S3Config = {
|
|||||||
autoSync: boolean
|
autoSync: boolean
|
||||||
syncInterval: number
|
syncInterval: number
|
||||||
maxBackups: number
|
maxBackups: number
|
||||||
|
/** 当自动备份且保留份数=1时,是否启用覆盖式单文件备份 */
|
||||||
|
singleFileOverwrite?: boolean
|
||||||
|
/** 覆盖式单文件备份的自定义文件名(可选,默认使用不带时间戳的设备名+主机名) */
|
||||||
|
singleFileName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { Message } from './newMessage'
|
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