Compare commits
2 Commits
feat/backu
...
feat/image
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42ee8a68ce | ||
|
|
d521a88d30 |
305
docs/EXPORT_IMAGES_PLAN.md
Normal file
305
docs/EXPORT_IMAGES_PLAN.md
Normal file
@@ -0,0 +1,305 @@
|
||||
# 对话图片导出功能设计方案
|
||||
|
||||
## 一、需求背景
|
||||
|
||||
随着多模态AI模型的普及,用户在对话中使用图片的频率增加。当前的导出功能只处理文本内容,图片被完全忽略,需要增加图片导出能力。
|
||||
|
||||
## 二、现状分析
|
||||
|
||||
### 2.1 图片存储机制
|
||||
|
||||
当前系统中图片有两种存储方式:
|
||||
|
||||
1. **用户上传的图片**
|
||||
- 存储位置:本地文件系统
|
||||
- 访问方式:通过 `FileMetadata.path` 字段,使用 `file://` 协议
|
||||
- 数据结构:`ImageMessageBlock.file`
|
||||
|
||||
2. **AI生成的图片**
|
||||
- 存储位置:内存中的Base64字符串
|
||||
- 访问方式:`ImageMessageBlock.metadata.generateImageResponse.images` 数组
|
||||
- 数据格式:Base64编码的图片数据
|
||||
|
||||
### 2.2 现有导出功能
|
||||
|
||||
当前支持的导出格式:
|
||||
- Markdown(本地文件/指定路径)
|
||||
- Word文档(.docx)
|
||||
- Notion(需要API配置)
|
||||
- 语雀(需要API配置)
|
||||
- Obsidian(带弹窗配置)
|
||||
- Joplin(需要API配置)
|
||||
- 思源笔记(需要API配置)
|
||||
- 笔记工作区
|
||||
- 纯文本
|
||||
- 图片截图
|
||||
|
||||
### 2.3 导出菜单问题
|
||||
|
||||
1. **设置分散**:导出相关设置分布在多个地方
|
||||
2. **每次导出可能需要不同配置**:如是否包含推理内容、是否包含引用等
|
||||
3. **缺乏统一的导出界面**:除Obsidian外,其他格式直接执行导出
|
||||
|
||||
## 三、解决方案
|
||||
|
||||
### 3.1 第一阶段:图片导出功能实现
|
||||
|
||||
#### 3.1.1 导出模式设计
|
||||
|
||||
提供两种图片导出模式供用户选择:
|
||||
|
||||
**模式1:Base64嵌入模式**
|
||||
```markdown
|
||||

|
||||
```
|
||||
- 优点:单文件、便于分享、保证完整性
|
||||
- 缺点:文件体积大、部分编辑器不支持、性能较差
|
||||
|
||||
**模式2:文件夹模式**
|
||||
```
|
||||
导出结构:
|
||||
conversation_2024-01-21/
|
||||
├── conversation.md
|
||||
└── images/
|
||||
├── user_upload_1.png
|
||||
├── ai_generated_1.png
|
||||
└── ...
|
||||
```
|
||||
Markdown中使用相对路径:
|
||||
```markdown
|
||||

|
||||
```
|
||||
- 优点:文件体积小、兼容性好、性能优秀
|
||||
- 缺点:需要管理多个文件、分享需打包
|
||||
|
||||
#### 3.1.2 核心功能实现
|
||||
|
||||
1. **新增图片处理工具函数** (`utils/export.ts`)
|
||||
```typescript
|
||||
// 处理消息中的所有图片块
|
||||
export async function processImageBlocks(
|
||||
message: Message,
|
||||
mode: 'base64' | 'folder',
|
||||
outputDir?: string
|
||||
): Promise<ImageExportResult[]>
|
||||
|
||||
// 将file://协议的图片转换为Base64
|
||||
export async function convertFileToBase64(filePath: string): Promise<string>
|
||||
|
||||
// 保存图片到指定文件夹
|
||||
export async function saveImageToFolder(
|
||||
image: string | Buffer,
|
||||
outputDir: string,
|
||||
fileName: string
|
||||
): Promise<string>
|
||||
|
||||
// 在Markdown中插入图片引用
|
||||
export function insertImageIntoMarkdown(
|
||||
markdown: string,
|
||||
images: ImageExportResult[]
|
||||
): string
|
||||
```
|
||||
|
||||
2. **更新现有导出函数**
|
||||
- `messageToMarkdown()`: 增加图片处理参数
|
||||
- `topicToMarkdown()`: 批量处理话题中的图片
|
||||
- `exportTopicAsMarkdown()`: 支持图片导出选项
|
||||
|
||||
3. **图片元数据保留**
|
||||
- AI生成图片:保存prompt信息
|
||||
- 用户上传图片:保留原始文件名
|
||||
- 添加图片索引和时间戳
|
||||
|
||||
### 3.2 第二阶段:统一导出弹窗(后续实施)
|
||||
|
||||
#### 3.2.1 弹窗设计
|
||||
|
||||
创建统一的导出配置弹窗 `UnifiedExportDialog`:
|
||||
|
||||
```typescript
|
||||
interface ExportDialogProps {
|
||||
// 导出内容
|
||||
content: {
|
||||
message?: Message
|
||||
messages?: Message[]
|
||||
topic?: Topic
|
||||
rawContent?: string
|
||||
}
|
||||
|
||||
// 导出格式
|
||||
format: ExportFormat
|
||||
|
||||
// 通用配置
|
||||
options: {
|
||||
includeReasoning?: boolean // 包含推理内容
|
||||
excludeCitations?: boolean // 排除引用
|
||||
imageExportMode?: 'base64' | 'folder' | 'none' // 图片导出模式
|
||||
imageQuality?: number // 图片质量(0-100)
|
||||
maxImageSize?: number // 最大图片尺寸
|
||||
}
|
||||
|
||||
// 格式特定配置
|
||||
formatOptions?: {
|
||||
// Markdown特定
|
||||
markdownPath?: string
|
||||
|
||||
// Notion特定
|
||||
notionDatabase?: string
|
||||
notionPageName?: string
|
||||
|
||||
// Obsidian特定
|
||||
obsidianVault?: string
|
||||
obsidianFolder?: string
|
||||
processingMethod?: string
|
||||
|
||||
// 其他格式配置...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2.2 交互流程
|
||||
|
||||
1. 用户点击导出按钮
|
||||
2. 弹出统一导出弹窗
|
||||
3. 用户选择导出格式
|
||||
4. 根据格式显示相应配置选项
|
||||
5. 用户调整配置
|
||||
6. 点击确认执行导出
|
||||
|
||||
#### 3.2.3 优势
|
||||
|
||||
1. **配置集中管理**:所有导出配置在一个界面完成
|
||||
2. **动态配置**:每次导出可以调整不同设置
|
||||
3. **用户体验统一**:所有格式使用相同的交互模式
|
||||
4. **易于扩展**:新增导出格式只需添加配置项
|
||||
|
||||
## 四、实施计划
|
||||
|
||||
### Phase 1: 基础图片导出(本次实施)
|
||||
- [x] 创建设计文档
|
||||
- [ ] 实现图片处理工具函数
|
||||
- [ ] 更新Markdown导出支持图片
|
||||
- [ ] 添加图片导出模式设置
|
||||
- [ ] 测试不同场景
|
||||
|
||||
### Phase 2: 扩展格式支持
|
||||
- [ ] Word文档图片嵌入
|
||||
- [ ] Obsidian图片处理
|
||||
- [ ] Joplin图片上传
|
||||
- [ ] 思源笔记图片支持
|
||||
|
||||
### Phase 3: 统一导出弹窗
|
||||
- [ ] 设计弹窗UI组件
|
||||
- [ ] 实现配置管理逻辑
|
||||
- [ ] 迁移现有导出功能
|
||||
- [ ] 添加配置持久化
|
||||
|
||||
### Phase 4: 高级功能
|
||||
- [ ] 图片压缩优化
|
||||
- [ ] 批量导出进度显示
|
||||
- [ ] 导出历史记录
|
||||
- [ ] 导出模板系统
|
||||
|
||||
## 五、技术细节
|
||||
|
||||
### 5.1 图片格式转换
|
||||
|
||||
```typescript
|
||||
// Base64转换示例
|
||||
async function imageToBase64(imagePath: string): Promise<string> {
|
||||
if (imagePath.startsWith('file://')) {
|
||||
const actualPath = imagePath.slice(7)
|
||||
const buffer = await fs.readFile(actualPath)
|
||||
const mimeType = getMimeType(actualPath)
|
||||
return `data:${mimeType};base64,${buffer.toString('base64')}`
|
||||
}
|
||||
return imagePath // 已经是Base64
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 文件夹结构生成
|
||||
|
||||
```typescript
|
||||
async function createExportFolder(topicName: string): Promise<string> {
|
||||
const timestamp = dayjs().format('YYYY-MM-DD-HH-mm-ss')
|
||||
const folderName = `${sanitizeFileName(topicName)}_${timestamp}`
|
||||
const exportPath = path.join(getExportDir(), folderName)
|
||||
|
||||
await fs.mkdir(path.join(exportPath, 'images'), { recursive: true })
|
||||
return exportPath
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Markdown图片引用更新
|
||||
|
||||
```typescript
|
||||
function updateMarkdownImages(
|
||||
markdown: string,
|
||||
imageMap: Map<string, string>
|
||||
): string {
|
||||
let updatedMarkdown = markdown
|
||||
|
||||
for (const [originalPath, newPath] of imageMap) {
|
||||
// 替换图片引用
|
||||
const regex = new RegExp(`!\\[([^\\]]*)\\]\\(${escapeRegex(originalPath)}\\)`, 'g')
|
||||
updatedMarkdown = updatedMarkdown.replace(
|
||||
regex,
|
||||
``
|
||||
)
|
||||
}
|
||||
|
||||
return updatedMarkdown
|
||||
}
|
||||
```
|
||||
|
||||
## 六、注意事项
|
||||
|
||||
1. **性能考虑**
|
||||
- 大量图片时使用异步处理
|
||||
- 提供进度反馈
|
||||
- 实现取消操作
|
||||
|
||||
2. **兼容性**
|
||||
- 检测目标应用对图片格式的支持
|
||||
- 提供降级方案
|
||||
|
||||
3. **安全性**
|
||||
- 验证文件路径合法性
|
||||
- 限制图片大小
|
||||
- 清理临时文件
|
||||
|
||||
4. **用户体验**
|
||||
- 清晰的配置说明
|
||||
- 合理的默认值
|
||||
- 错误提示友好
|
||||
|
||||
## 七、后续优化
|
||||
|
||||
1. **Notion图片支持**(需要调研)
|
||||
- 研究Notion API的图片上传能力
|
||||
- 评估 `@notionhq/client` 库的图片处理功能
|
||||
- 可能需要先上传到图床再引用
|
||||
|
||||
2. **智能压缩**
|
||||
- 根据图片内容自动选择压缩算法
|
||||
- 保持图片质量的同时减小体积
|
||||
|
||||
3. **批量导出**
|
||||
- 支持多个话题同时导出
|
||||
- 生成导出报告
|
||||
|
||||
4. **云存储集成**
|
||||
- 支持直接上传到云存储
|
||||
- 生成分享链接
|
||||
|
||||
## 八、参考资料
|
||||
|
||||
- [Notion API Documentation](https://developers.notion.com/)
|
||||
- [Obsidian URI Protocol](https://help.obsidian.md/Extending+Obsidian/Obsidian+URI)
|
||||
- [Joplin Web Clipper API](https://joplinapp.org/api/references/rest_api/)
|
||||
- [思源笔记 API](https://github.com/siyuan-note/siyuan/blob/master/API.md)
|
||||
|
||||
---
|
||||
|
||||
*文档创建日期:2025-01-21*
|
||||
*最后更新:2025-01-21*
|
||||
@@ -191,6 +191,11 @@ export enum IpcChannel {
|
||||
File_StartWatcher = 'file:startWatcher',
|
||||
File_StopWatcher = 'file:stopWatcher',
|
||||
File_ShowInFolder = 'file:showInFolder',
|
||||
// Image export specific channels
|
||||
File_ReadBinary = 'file:readBinary',
|
||||
File_WriteBinary = 'file:writeBinary',
|
||||
File_CopyFile = 'file:copyFile',
|
||||
File_CreateDirectory = 'file:createDirectory',
|
||||
|
||||
// file service
|
||||
FileService_Upload = 'file-service:upload',
|
||||
|
||||
@@ -531,6 +531,23 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.File_StopWatcher, fileManager.stopFileWatcher.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_ShowInFolder, fileManager.showInFolder.bind(fileManager))
|
||||
|
||||
// Image export specific handlers
|
||||
ipcMain.handle(IpcChannel.File_ReadBinary, async (_, filePath: string) => {
|
||||
return fs.promises.readFile(filePath)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.File_WriteBinary, async (_, filePath: string, buffer: Buffer) => {
|
||||
return fs.promises.writeFile(filePath, buffer)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.File_CopyFile, async (_, sourcePath: string, destPath: string) => {
|
||||
return fs.promises.copyFile(sourcePath, destPath)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.File_CreateDirectory, async (_, dirPath: string) => {
|
||||
return fs.promises.mkdir(dirPath, { recursive: true })
|
||||
})
|
||||
|
||||
// file service
|
||||
ipcMain.handle(IpcChannel.FileService_Upload, async (_, provider: Provider, file: FileMetadata) => {
|
||||
const service = FileServiceManager.getInstance().getService(provider)
|
||||
|
||||
@@ -617,8 +617,13 @@ class BackupManager {
|
||||
}
|
||||
|
||||
async backupToS3(_: Electron.IpcMainInvokeEvent, data: string, s3Config: S3Config) {
|
||||
// Use the filename provided by frontend, or a simple default (no timestamp generation here)
|
||||
const filename = s3Config.fileName || 'cherry-studio.backup.zip'
|
||||
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`
|
||||
|
||||
logger.debug(`Starting S3 backup to ${filename}`)
|
||||
|
||||
|
||||
@@ -205,7 +205,14 @@ const api = {
|
||||
ipcRenderer.on('file-change', listener)
|
||||
return () => ipcRenderer.off('file-change', listener)
|
||||
},
|
||||
showInFolder: (path: string): Promise<void> => ipcRenderer.invoke(IpcChannel.File_ShowInFolder, path)
|
||||
showInFolder: (path: string): Promise<void> => ipcRenderer.invoke(IpcChannel.File_ShowInFolder, path),
|
||||
// Image export specific methods
|
||||
readBinary: (filePath: string): Promise<Buffer> => ipcRenderer.invoke(IpcChannel.File_ReadBinary, filePath),
|
||||
writeBinary: (filePath: string, buffer: Buffer): Promise<void> =>
|
||||
ipcRenderer.invoke(IpcChannel.File_WriteBinary, filePath, buffer),
|
||||
copyFile: (sourcePath: string, destPath: string): Promise<void> =>
|
||||
ipcRenderer.invoke(IpcChannel.File_CopyFile, sourcePath, destPath),
|
||||
createDirectory: (dirPath: string): Promise<void> => ipcRenderer.invoke(IpcChannel.File_CreateDirectory, dirPath)
|
||||
},
|
||||
fs: {
|
||||
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding),
|
||||
|
||||
@@ -2900,27 +2900,7 @@
|
||||
},
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
"skip_file_data_title": "Slim Backup"
|
||||
},
|
||||
"clear_cache": {
|
||||
"button": "Clear Cache",
|
||||
|
||||
@@ -2900,27 +2900,7 @@
|
||||
},
|
||||
"backup": {
|
||||
"skip_file_data_help": "备份时跳过备份图片、知识库等数据文件,仅备份聊天记录和设置。减少空间占用,加快备份速度",
|
||||
"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": "文件名过长"
|
||||
}
|
||||
"skip_file_data_title": "精简备份"
|
||||
},
|
||||
"clear_cache": {
|
||||
"button": "清除缓存",
|
||||
|
||||
@@ -2900,27 +2900,7 @@
|
||||
},
|
||||
"backup": {
|
||||
"skip_file_data_help": "備份時跳過備份圖片、知識庫等數據文件,僅備份聊天記錄和設置。減少空間佔用,加快備份速度",
|
||||
"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": "文件名過長"
|
||||
}
|
||||
"skip_file_data_title": "精簡備份"
|
||||
},
|
||||
"clear_cache": {
|
||||
"button": "清除快取",
|
||||
|
||||
@@ -13,9 +13,7 @@ import {
|
||||
setLocalBackupDir as _setLocalBackupDir,
|
||||
setLocalBackupMaxBackups as _setLocalBackupMaxBackups,
|
||||
setLocalBackupSkipBackupFile as _setLocalBackupSkipBackupFile,
|
||||
setLocalBackupSyncInterval as _setLocalBackupSyncInterval,
|
||||
setLocalSingleFileName as _setLocalSingleFileName,
|
||||
setLocalSingleFileOverwrite as _setLocalSingleFileOverwrite
|
||||
setLocalBackupSyncInterval as _setLocalBackupSyncInterval
|
||||
} from '@renderer/store/settings'
|
||||
import { AppInfo } from '@renderer/types'
|
||||
import { Button, Input, Switch, Tooltip } from 'antd'
|
||||
@@ -34,18 +32,12 @@ const LocalBackupSettings: React.FC = () => {
|
||||
localBackupDir: localBackupDirSetting,
|
||||
localBackupSyncInterval: localBackupSyncIntervalSetting,
|
||||
localBackupMaxBackups: localBackupMaxBackupsSetting,
|
||||
localBackupSkipBackupFile: localBackupSkipBackupFileSetting,
|
||||
localSingleFileOverwrite: localSingleFileOverwriteSetting,
|
||||
localSingleFileName: localSingleFileNameSetting
|
||||
localBackupSkipBackupFile: localBackupSkipBackupFileSetting
|
||||
} = 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)
|
||||
@@ -69,11 +61,6 @@ const LocalBackupSettings: React.FC = () => {
|
||||
|
||||
const { localBackupSync } = useAppSelector((state) => state.backup)
|
||||
|
||||
// 同步 maxBackups 状态
|
||||
useEffect(() => {
|
||||
setMaxBackups(localBackupMaxBackupsSetting)
|
||||
}, [localBackupMaxBackupsSetting])
|
||||
|
||||
const onSyncIntervalChange = (value: number) => {
|
||||
setSyncInterval(value)
|
||||
dispatch(_setLocalBackupSyncInterval(value))
|
||||
@@ -153,68 +140,6 @@ 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({
|
||||
@@ -357,58 +282,6 @@ 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 />
|
||||
|
||||
@@ -5,13 +5,16 @@ import { RootState, useAppDispatch } from '@renderer/store'
|
||||
import {
|
||||
setExcludeCitationsInExport,
|
||||
setForceDollarMathInMarkdown,
|
||||
setImageExportMaxSize,
|
||||
setImageExportMode,
|
||||
setImageExportQuality,
|
||||
setmarkdownExportPath,
|
||||
setShowModelNameInMarkdown,
|
||||
setShowModelProviderInMarkdown,
|
||||
setStandardizeCitationsInExport,
|
||||
setUseTopicNamingForMessageTitle
|
||||
} from '@renderer/store/settings'
|
||||
import { Button, Switch } from 'antd'
|
||||
import { Button, Select, Slider, Switch } from 'antd'
|
||||
import Input from 'antd/es/input/Input'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -31,6 +34,9 @@ const MarkdownExportSettings: FC = () => {
|
||||
const showModelProviderInMarkdown = useSelector((state: RootState) => state.settings.showModelProviderInMarkdown)
|
||||
const excludeCitationsInExport = useSelector((state: RootState) => state.settings.excludeCitationsInExport)
|
||||
const standardizeCitationsInExport = useSelector((state: RootState) => state.settings.standardizeCitationsInExport)
|
||||
const imageExportMode = useSelector((state: RootState) => state.settings.imageExportMode)
|
||||
const imageExportQuality = useSelector((state: RootState) => state.settings.imageExportQuality)
|
||||
const imageExportMaxSize = useSelector((state: RootState) => state.settings.imageExportMaxSize)
|
||||
|
||||
const handleSelectFolder = async () => {
|
||||
const path = await window.api.file.selectFolder()
|
||||
@@ -67,6 +73,18 @@ const MarkdownExportSettings: FC = () => {
|
||||
dispatch(setStandardizeCitationsInExport(checked))
|
||||
}
|
||||
|
||||
const handleImageExportModeChange = (value: 'base64' | 'folder' | 'none') => {
|
||||
dispatch(setImageExportMode(value))
|
||||
}
|
||||
|
||||
const handleImageExportQualityChange = (value: number) => {
|
||||
dispatch(setImageExportQuality(value))
|
||||
}
|
||||
|
||||
const handleImageExportMaxSizeChange = (value: number) => {
|
||||
dispatch(setImageExportMaxSize(value))
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>{t('settings.data.markdown_export.title')}</SettingTitle>
|
||||
@@ -142,6 +160,58 @@ const MarkdownExportSettings: FC = () => {
|
||||
<SettingRow>
|
||||
<SettingHelpText>{t('settings.data.markdown_export.standardize_citations.help')}</SettingHelpText>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.markdown_export.image_export_mode.title')}</SettingRowTitle>
|
||||
<Select value={imageExportMode} onChange={handleImageExportModeChange} style={{ width: 200 }}>
|
||||
<Select.Option value="none">{t('settings.data.markdown_export.image_export_mode.none')}</Select.Option>
|
||||
<Select.Option value="base64">{t('settings.data.markdown_export.image_export_mode.base64')}</Select.Option>
|
||||
<Select.Option value="folder">{t('settings.data.markdown_export.image_export_mode.folder')}</Select.Option>
|
||||
</Select>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>{t('settings.data.markdown_export.image_export_mode.help')}</SettingHelpText>
|
||||
</SettingRow>
|
||||
{imageExportMode !== 'none' && (
|
||||
<>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.markdown_export.image_quality.title')}</SettingRowTitle>
|
||||
<HStack alignItems="center" gap="10px" style={{ width: 315 }}>
|
||||
<Slider
|
||||
min={10}
|
||||
max={100}
|
||||
step={5}
|
||||
value={imageExportQuality}
|
||||
onChange={handleImageExportQualityChange}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<span>{imageExportQuality}%</span>
|
||||
</HStack>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>{t('settings.data.markdown_export.image_quality.help')}</SettingHelpText>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.markdown_export.image_max_size.title')}</SettingRowTitle>
|
||||
<HStack alignItems="center" gap="10px" style={{ width: 315 }}>
|
||||
<Slider
|
||||
min={512}
|
||||
max={4096}
|
||||
step={256}
|
||||
value={imageExportMaxSize}
|
||||
onChange={handleImageExportMaxSizeChange}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<span>{imageExportMaxSize}px</span>
|
||||
</HStack>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>{t('settings.data.markdown_export.image_max_size.help')}</SettingHelpText>
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
</SettingGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,8 +20,6 @@ import {
|
||||
setNutstoreAutoSync,
|
||||
setNutstoreMaxBackups,
|
||||
setNutstorePath,
|
||||
setNutstoreSingleFileName,
|
||||
setNutstoreSingleFileOverwrite,
|
||||
setNutstoreSkipBackupFile,
|
||||
setNutstoreSyncInterval,
|
||||
setNutstoreToken
|
||||
@@ -46,9 +44,7 @@ const NutstoreSettings: FC = () => {
|
||||
nutstoreAutoSync,
|
||||
nutstoreSyncState,
|
||||
nutstoreSkipBackupFile,
|
||||
nutstoreMaxBackups,
|
||||
nutstoreSingleFileOverwrite,
|
||||
nutstoreSingleFileName
|
||||
nutstoreMaxBackups
|
||||
} = useAppSelector((state) => state.nutstore)
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
@@ -59,20 +55,12 @@ 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')
|
||||
@@ -154,72 +142,9 @@ 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
|
||||
@@ -411,60 +336,6 @@ const NutstoreSettings: FC = () => {
|
||||
<SettingRow>
|
||||
<SettingHelpText>{t('settings.data.backup.skip_file_data_help')}</SettingHelpText>
|
||||
</SettingRow>
|
||||
{/* 覆盖式单文件备份,仅在自动备份开启且保留份数=1时推荐启用 */}
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>
|
||||
{t('settings.data.backup.singleFileOverwrite.title') || '覆盖式单文件备份(同名覆盖)'}
|
||||
</SettingRowTitle>
|
||||
<Switch
|
||||
checked={nutSingleFileOverwrite}
|
||||
onChange={onSingleFileOverwriteChange}
|
||||
disabled={!(syncInterval > 0 && maxBackups === 1)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>
|
||||
{t('settings.data.backup.singleFileOverwrite.help') || (
|
||||
<div>
|
||||
<p>当自动备份开启且保留份数为1时,使用固定文件名每次覆盖。</p>
|
||||
<p style={{ marginTop: 8, fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
推荐场景:只需要保留最新备份,节省坚果云存储空间
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingHelpText>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>
|
||||
{t('settings.data.backup.singleFileName.title') || '自定义文件名(可选)'}
|
||||
</SettingRowTitle>
|
||||
<Input
|
||||
placeholder={
|
||||
t('settings.data.backup.singleFileName.placeholder') || '如:cherry-studio.<hostname>.<device>.zip'
|
||||
}
|
||||
value={nutSingleFileName}
|
||||
onChange={(e) => onSingleFileNameChange(e.target.value)}
|
||||
onBlur={onSingleFileNameBlur}
|
||||
style={{ width: 300 }}
|
||||
disabled={!nutSingleFileOverwrite || !(syncInterval > 0 && maxBackups === 1)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>
|
||||
{t('settings.data.backup.singleFileName.help') || (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
<p>• 留空将使用默认格式:cherry-studio.[主机名].[设备类型].zip</p>
|
||||
<p>
|
||||
• 支持的变量:{`{hostname}`} - 主机名,{`{device}`} - 设备类型
|
||||
</p>
|
||||
<p>• 不支持的字符:{'<>:"/\\|?*'}</p>
|
||||
<p>• 最大长度:250个字符</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingHelpText>
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
<>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { setS3Partial } from '@renderer/store/settings'
|
||||
import { S3Config } from '@renderer/types'
|
||||
import { Button, Input, Switch, Tooltip } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||
@@ -30,9 +30,7 @@ const S3Settings: FC = () => {
|
||||
root: s3RootInit = '',
|
||||
syncInterval: s3SyncIntervalInit = 0,
|
||||
maxBackups: s3MaxBackupsInit = 5,
|
||||
skipBackupFile: s3SkipBackupFileInit = false,
|
||||
singleFileOverwrite: s3SingleFileOverwriteInit = false,
|
||||
singleFileName: s3SingleFileNameInit = ''
|
||||
skipBackupFile: s3SkipBackupFileInit = false
|
||||
} = s3
|
||||
|
||||
const [endpoint, setEndpoint] = useState<string | undefined>(s3EndpointInit)
|
||||
@@ -42,8 +40,6 @@ 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)
|
||||
@@ -56,11 +52,6 @@ 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 }))
|
||||
@@ -90,68 +81,6 @@ 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
|
||||
|
||||
@@ -331,58 +260,6 @@ const S3Settings: FC = () => {
|
||||
<SettingRow>
|
||||
<SettingHelpText>{t('settings.data.s3.skipBackupFile.help')}</SettingHelpText>
|
||||
</SettingRow>
|
||||
{/* 覆盖式单文件备份,仅在自动备份开启且保留份数=1时推荐启用 */}
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>
|
||||
{t('settings.data.backup.singleFileOverwrite.title') || '覆盖式单文件备份(同名覆盖)'}
|
||||
</SettingRowTitle>
|
||||
<Switch
|
||||
checked={singleFileOverwrite}
|
||||
onChange={onSingleFileOverwriteChange}
|
||||
disabled={!(syncInterval > 0 && maxBackups === 1)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>
|
||||
{t('settings.data.backup.singleFileOverwrite.help') || (
|
||||
<div>
|
||||
<p>当自动备份开启且保留份数为1时,使用固定文件名每次覆盖。</p>
|
||||
<p style={{ marginTop: 8, fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
推荐场景:只需要保留最新备份,节省存储空间(S3会直接覆盖同键对象)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingHelpText>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.backup.singleFileName.title') || '自定义文件名(可选)'}</SettingRowTitle>
|
||||
<Input
|
||||
placeholder={
|
||||
t('settings.data.backup.singleFileName.placeholder') || '如:cherry-studio.<hostname>.<device>.zip'
|
||||
}
|
||||
value={singleFileName}
|
||||
onChange={(e) => onSingleFileNameChange(e.target.value)}
|
||||
onBlur={onSingleFileNameBlur}
|
||||
style={{ width: 300 }}
|
||||
disabled={!singleFileOverwrite || !(syncInterval > 0 && maxBackups === 1)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>
|
||||
{t('settings.data.backup.singleFileName.help') || (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
<p>• 留空将使用默认格式:cherry-studio.[主机名].[设备类型].zip</p>
|
||||
<p>
|
||||
• 支持的变量:{`{hostname}`} - 主机名,{`{device}`} - 设备类型
|
||||
</p>
|
||||
<p>• 不支持的字符:{'<>:"/\\|?*'}</p>
|
||||
<p>• 最大长度:250个字符</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingHelpText>
|
||||
</SettingRow>
|
||||
{syncInterval > 0 && (
|
||||
<>
|
||||
<SettingDivider />
|
||||
|
||||
@@ -14,15 +14,13 @@ 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, useEffect, useState } from 'react'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||
@@ -36,9 +34,7 @@ const WebDavSettings: FC = () => {
|
||||
webdavSyncInterval: webDAVSyncInterval,
|
||||
webdavMaxBackups: webDAVMaxBackups,
|
||||
webdavSkipBackupFile: webdDAVSkipBackupFile,
|
||||
webdavDisableStream: webDAVDisableStream,
|
||||
webdavSingleFileOverwrite: webDAVSingleFileOverwrite,
|
||||
webdavSingleFileName: webDAVSingleFileName
|
||||
webdavDisableStream: webDAVDisableStream
|
||||
} = useSettings()
|
||||
|
||||
const [webdavHost, setWebdavHost] = useState<string | undefined>(webDAVHost)
|
||||
@@ -47,10 +43,6 @@ 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)
|
||||
@@ -63,11 +55,6 @@ 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) => {
|
||||
@@ -97,68 +84,6 @@ 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
|
||||
|
||||
@@ -311,58 +236,6 @@ const WebDavSettings: FC = () => {
|
||||
<SettingRow>
|
||||
<SettingHelpText>{t('settings.data.webdav.disableStream.help')}</SettingHelpText>
|
||||
</SettingRow>
|
||||
{/* 覆盖式单文件备份,仅在自动备份开启且保留份数=1时推荐启用 */}
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>
|
||||
{t('settings.data.backup.singleFileOverwrite.title') || '覆盖式单文件备份(同名覆盖)'}
|
||||
</SettingRowTitle>
|
||||
<Switch
|
||||
checked={webdavSingleFileOverwrite}
|
||||
onChange={onSingleFileOverwriteChange}
|
||||
disabled={!(syncInterval > 0 && maxBackups === 1)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>
|
||||
{t('settings.data.backup.singleFileOverwrite.help') || (
|
||||
<div>
|
||||
<p>当自动备份开启且保留份数为1时,使用固定文件名每次覆盖。</p>
|
||||
<p style={{ marginTop: 8, fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
推荐场景:只需要保留最新备份,节省存储空间
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingHelpText>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.backup.singleFileName.title') || '自定义文件名(可选)'}</SettingRowTitle>
|
||||
<Input
|
||||
placeholder={
|
||||
t('settings.data.backup.singleFileName.placeholder') || '如:cherry-studio.<hostname>.<device>.zip'
|
||||
}
|
||||
value={webdavSingleFileName}
|
||||
onChange={(e) => onSingleFileNameChange(e.target.value)}
|
||||
onBlur={onSingleFileNameBlur}
|
||||
style={{ width: 300 }}
|
||||
disabled={!webdavSingleFileOverwrite || !(syncInterval > 0 && maxBackups === 1)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>
|
||||
{t('settings.data.backup.singleFileName.help') || (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
<p>• 留空将使用默认格式:cherry-studio.[主机名].[设备类型].zip</p>
|
||||
<p>
|
||||
• 支持的变量:{`{hostname}`} - 主机名,{`{device}`} - 设备类型
|
||||
</p>
|
||||
<p>• 不支持的字符:{'<>:"/\\|?*'}</p>
|
||||
<p>• 最大长度:250个字符</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingHelpText>
|
||||
</SettingRow>
|
||||
{webdavSync && syncInterval > 0 && (
|
||||
<>
|
||||
<SettingDivider />
|
||||
|
||||
@@ -6,23 +6,10 @@ 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文件的辅助函数
|
||||
@@ -181,9 +168,7 @@ export async function backupToWebdav({
|
||||
webdavPath,
|
||||
webdavMaxBackups,
|
||||
webdavSkipBackupFile,
|
||||
webdavDisableStream,
|
||||
webdavSingleFileOverwrite,
|
||||
webdavSingleFileName
|
||||
webdavDisableStream
|
||||
} = store.getState().settings
|
||||
let deviceType = 'unknown'
|
||||
let hostname = 'unknown'
|
||||
@@ -194,19 +179,8 @@ export async function backupToWebdav({
|
||||
logger.error('Failed to get device type or hostname:', error as Error)
|
||||
}
|
||||
const timestamp = dayjs().format('YYYYMMDDHHmmss')
|
||||
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 backupFileName = customFileName || `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip`
|
||||
const finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.zip`
|
||||
const backupData = await getBackupData()
|
||||
|
||||
// 上传文件
|
||||
@@ -238,8 +212,8 @@ export async function backupToWebdav({
|
||||
})
|
||||
showMessage && window.toast.success(i18n.t('message.backup.success'))
|
||||
|
||||
// 使用工具函数判断是否<EFBFBD><EFBFBD><EFBFBD>过清理
|
||||
if (webdavMaxBackups > 0 && !shouldSkipCleanup(autoBackupProcess, webdavMaxBackups, webdavSingleFileOverwrite)) {
|
||||
// 清理旧备份文件
|
||||
if (webdavMaxBackups > 0) {
|
||||
try {
|
||||
// 获取所有备份文件
|
||||
const files = await window.api.backup.listWebdavFiles({
|
||||
@@ -379,19 +353,8 @@ export async function backupToS3({
|
||||
logger.error('Failed to get device type or hostname:', error as Error)
|
||||
}
|
||||
const timestamp = dayjs().format('YYYYMMDDHHmmss')
|
||||
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 backupFileName = customFileName || `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip`
|
||||
const finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.zip`
|
||||
const backupData = await getBackupData()
|
||||
|
||||
try {
|
||||
@@ -421,11 +384,7 @@ export async function backupToS3({
|
||||
showMessage && window.toast.success(i18n.t('message.backup.success'))
|
||||
|
||||
// 清理旧备份文件
|
||||
// 使用工具函数判断是否跳过清理
|
||||
if (
|
||||
s3Config.maxBackups > 0 &&
|
||||
!shouldSkipCleanup(autoBackupProcess, s3Config.maxBackups, s3Config.singleFileOverwrite)
|
||||
) {
|
||||
if (s3Config.maxBackups > 0) {
|
||||
try {
|
||||
// 获取所有备份文件
|
||||
const files = await window.api.backup.listS3Files(s3Config)
|
||||
@@ -980,9 +939,7 @@ export async function backupToLocal({
|
||||
const {
|
||||
localBackupDir: localBackupDirSetting,
|
||||
localBackupMaxBackups,
|
||||
localBackupSkipBackupFile,
|
||||
localSingleFileOverwrite,
|
||||
localSingleFileName
|
||||
localBackupSkipBackupFile
|
||||
} = store.getState().settings
|
||||
const localBackupDir = await window.api.resolvePath(localBackupDirSetting)
|
||||
let deviceType = 'unknown'
|
||||
@@ -994,19 +951,8 @@ export async function backupToLocal({
|
||||
logger.error('Failed to get device type or hostname:', error as Error)
|
||||
}
|
||||
const timestamp = dayjs().format('YYYYMMDDHHmmss')
|
||||
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 backupFileName = customFileName || `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip`
|
||||
const finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.zip`
|
||||
const backupData = await getBackupData()
|
||||
|
||||
try {
|
||||
@@ -1035,11 +981,8 @@ export async function backupToLocal({
|
||||
})
|
||||
}
|
||||
|
||||
// 使用工具函数判断是否跳过清理
|
||||
if (
|
||||
localBackupMaxBackups > 0 &&
|
||||
!shouldSkipCleanup(autoBackupProcess, localBackupMaxBackups, localSingleFileOverwrite)
|
||||
) {
|
||||
// Clean up old backups if maxBackups is set
|
||||
if (localBackupMaxBackups > 0) {
|
||||
try {
|
||||
// Get all backup files
|
||||
const files = await window.api.backup.listLocalBackupFiles(localBackupDir)
|
||||
|
||||
@@ -7,7 +7,6 @@ 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')
|
||||
@@ -110,12 +109,10 @@ async function cleanupOldBackups(webdavConfig: WebDavConfig, maxBackups: number)
|
||||
|
||||
export async function backupToNutstore({
|
||||
showMessage = false,
|
||||
customFileName = '',
|
||||
isAutoBackup = false
|
||||
customFileName = ''
|
||||
}: {
|
||||
showMessage?: boolean
|
||||
customFileName?: string
|
||||
isAutoBackup?: boolean
|
||||
} = {}) {
|
||||
const nutstoreToken = getNutstoreToken()
|
||||
if (!nutstoreToken) {
|
||||
@@ -138,37 +135,21 @@ 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 name = customFileName || `cherry-studio.${timestamp}.${deviceType}.zip`
|
||||
finalFileName = name.endsWith('.zip') ? name : `${name}.zip`
|
||||
}
|
||||
const timestamp = dayjs().format('YYYYMMDDHHmmss')
|
||||
const backupFileName = customFileName || `cherry-studio.${timestamp}.${deviceType}.zip`
|
||||
const finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.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)
|
||||
}
|
||||
// 先清理旧备份
|
||||
await cleanupOldBackups(config, maxBackups)
|
||||
|
||||
const isSuccess = await window.api.backup.backupToWebdav(backupData, {
|
||||
...config,
|
||||
@@ -283,7 +264,7 @@ export async function startNutstoreAutoSync() {
|
||||
isAutoBackupRunning = true
|
||||
try {
|
||||
logger.verbose('[Nutstore AutoSync] Starting auto backup...')
|
||||
await backupToNutstore({ showMessage: false, isAutoBackup: true })
|
||||
await backupToNutstore({ showMessage: false })
|
||||
} catch (error) {
|
||||
logger.error('[Nutstore AutoSync] Auto backup failed:', error as Error)
|
||||
} finally {
|
||||
|
||||
@@ -12,8 +12,6 @@ export interface NutstoreState {
|
||||
nutstoreSyncState: NutstoreSyncState
|
||||
nutstoreSkipBackupFile: boolean
|
||||
nutstoreMaxBackups: number
|
||||
nutstoreSingleFileOverwrite: boolean
|
||||
nutstoreSingleFileName: string
|
||||
}
|
||||
|
||||
const initialState: NutstoreState = {
|
||||
@@ -27,9 +25,7 @@ const initialState: NutstoreState = {
|
||||
lastSyncError: null
|
||||
},
|
||||
nutstoreSkipBackupFile: false,
|
||||
nutstoreMaxBackups: 0,
|
||||
nutstoreSingleFileOverwrite: false,
|
||||
nutstoreSingleFileName: ''
|
||||
nutstoreMaxBackups: 0
|
||||
}
|
||||
|
||||
const nutstoreSlice = createSlice({
|
||||
@@ -56,12 +52,6 @@ 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
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -73,9 +63,7 @@ export const {
|
||||
setNutstoreSyncInterval,
|
||||
setNutstoreSyncState,
|
||||
setNutstoreSkipBackupFile,
|
||||
setNutstoreMaxBackups,
|
||||
setNutstoreSingleFileOverwrite,
|
||||
setNutstoreSingleFileName
|
||||
setNutstoreMaxBackups
|
||||
} = nutstoreSlice.actions
|
||||
|
||||
export default nutstoreSlice.reducer
|
||||
|
||||
@@ -119,9 +119,6 @@ export interface SettingsState {
|
||||
webdavMaxBackups: number
|
||||
webdavSkipBackupFile: boolean
|
||||
webdavDisableStream: boolean
|
||||
// 覆盖式单文件备份(WebDAV)
|
||||
webdavSingleFileOverwrite?: boolean
|
||||
webdavSingleFileName?: string
|
||||
translateModelPrompt: string
|
||||
autoTranslateWithSpace: boolean
|
||||
showTranslateConfirm: boolean
|
||||
@@ -154,6 +151,10 @@ export interface SettingsState {
|
||||
notionExportReasoning: boolean
|
||||
excludeCitationsInExport: boolean
|
||||
standardizeCitationsInExport: boolean
|
||||
// Image export settings
|
||||
imageExportMode: 'base64' | 'folder' | 'none'
|
||||
imageExportQuality: number
|
||||
imageExportMaxSize: number
|
||||
yuqueToken: string | null
|
||||
yuqueUrl: string | null
|
||||
yuqueRepoId: string | null
|
||||
@@ -213,9 +214,6 @@ export interface SettingsState {
|
||||
localBackupSyncInterval: number
|
||||
localBackupMaxBackups: number
|
||||
localBackupSkipBackupFile: boolean
|
||||
// 覆盖式单文件备份(Local)
|
||||
localSingleFileOverwrite?: boolean
|
||||
localSingleFileName?: string
|
||||
defaultPaintingProvider: PaintingProvider
|
||||
s3: S3Config
|
||||
// Developer mode
|
||||
@@ -312,8 +310,6 @@ export const initialState: SettingsState = {
|
||||
webdavMaxBackups: 0,
|
||||
webdavSkipBackupFile: false,
|
||||
webdavDisableStream: false,
|
||||
webdavSingleFileOverwrite: false,
|
||||
webdavSingleFileName: '',
|
||||
translateModelPrompt: TRANSLATE_PROMPT,
|
||||
autoTranslateWithSpace: false,
|
||||
showTranslateConfirm: true,
|
||||
@@ -341,6 +337,10 @@ export const initialState: SettingsState = {
|
||||
notionExportReasoning: false,
|
||||
excludeCitationsInExport: false,
|
||||
standardizeCitationsInExport: false,
|
||||
// Image export settings
|
||||
imageExportMode: 'none',
|
||||
imageExportQuality: 85,
|
||||
imageExportMaxSize: 2048,
|
||||
yuqueToken: '',
|
||||
yuqueUrl: '',
|
||||
yuqueRepoId: '',
|
||||
@@ -397,8 +397,6 @@ export const initialState: SettingsState = {
|
||||
localBackupSyncInterval: 0,
|
||||
localBackupMaxBackups: 0,
|
||||
localBackupSkipBackupFile: false,
|
||||
localSingleFileOverwrite: false,
|
||||
localSingleFileName: '',
|
||||
defaultPaintingProvider: 'zhipu',
|
||||
s3: {
|
||||
endpoint: '',
|
||||
@@ -410,9 +408,7 @@ export const initialState: SettingsState = {
|
||||
autoSync: false,
|
||||
syncInterval: 0,
|
||||
maxBackups: 0,
|
||||
skipBackupFile: false,
|
||||
singleFileOverwrite: false,
|
||||
singleFileName: ''
|
||||
skipBackupFile: false
|
||||
},
|
||||
|
||||
// Developer mode
|
||||
@@ -568,12 +564,6 @@ 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
|
||||
@@ -734,6 +724,16 @@ const settingsSlice = createSlice({
|
||||
setStandardizeCitationsInExport: (state, action: PayloadAction<boolean>) => {
|
||||
state.standardizeCitationsInExport = action.payload
|
||||
},
|
||||
// Image export settings actions
|
||||
setImageExportMode: (state, action: PayloadAction<'base64' | 'folder' | 'none'>) => {
|
||||
state.imageExportMode = action.payload
|
||||
},
|
||||
setImageExportQuality: (state, action: PayloadAction<number>) => {
|
||||
state.imageExportQuality = action.payload
|
||||
},
|
||||
setImageExportMaxSize: (state, action: PayloadAction<number>) => {
|
||||
state.imageExportMaxSize = action.payload
|
||||
},
|
||||
setYuqueToken: (state, action: PayloadAction<string>) => {
|
||||
state.yuqueToken = action.payload
|
||||
},
|
||||
@@ -834,12 +834,6 @@ 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
|
||||
},
|
||||
@@ -927,8 +921,6 @@ export const {
|
||||
setWebdavMaxBackups,
|
||||
setWebdavSkipBackupFile,
|
||||
setWebdavDisableStream,
|
||||
setWebdavSingleFileOverwrite,
|
||||
setWebdavSingleFileName,
|
||||
setCodeExecution,
|
||||
setCodeEditor,
|
||||
setCodeViewer,
|
||||
@@ -966,6 +958,9 @@ export const {
|
||||
setNotionExportReasoning,
|
||||
setExcludeCitationsInExport,
|
||||
setStandardizeCitationsInExport,
|
||||
setImageExportMode,
|
||||
setImageExportQuality,
|
||||
setImageExportMaxSize,
|
||||
setYuqueToken,
|
||||
setYuqueRepoId,
|
||||
setYuqueUrl,
|
||||
@@ -1000,8 +995,6 @@ export const {
|
||||
setLocalBackupSyncInterval,
|
||||
setLocalBackupMaxBackups,
|
||||
setLocalBackupSkipBackupFile,
|
||||
setLocalSingleFileOverwrite,
|
||||
setLocalSingleFileName,
|
||||
setDefaultPaintingProvider,
|
||||
setS3,
|
||||
setS3Partial,
|
||||
|
||||
@@ -456,10 +456,6 @@ export type WebDavConfig = {
|
||||
fileName?: string
|
||||
skipBackupFile?: boolean
|
||||
disableStream?: boolean
|
||||
/** 当自动备份且保留份数=1时,是否启用覆盖式单文件备份 */
|
||||
singleFileOverwrite?: boolean
|
||||
/** 覆盖式单文件备份的自定义文件名(可选,默认使用不带时间戳的设备名+主机名) */
|
||||
singleFileName?: string
|
||||
}
|
||||
|
||||
export type AppInfo = {
|
||||
@@ -877,10 +873,6 @@ export type S3Config = {
|
||||
autoSync: boolean
|
||||
syncInterval: number
|
||||
maxBackups: number
|
||||
/** 当自动备份且保留份数=1时,是否启用覆盖式单文件备份 */
|
||||
singleFileOverwrite?: boolean
|
||||
/** 覆盖式单文件备份的自定义文件名(可选,默认使用不带时间戳的设备名+主机名) */
|
||||
singleFileName?: string
|
||||
}
|
||||
|
||||
export type { Message } from './newMessage'
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
/**
|
||||
* 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import dayjs from 'dayjs'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { appendBlocks } from 'notion-helper'
|
||||
|
||||
import { createExportFolderStructure, processImageBlocks } from './exportImages'
|
||||
|
||||
const logger = loggerService.withContext('Utils:export')
|
||||
|
||||
// 全局的导出状态获取函数
|
||||
@@ -263,12 +265,20 @@ const formatCitationsAsFootnotes = (citations: string): string => {
|
||||
return footnotes.join('\n\n')
|
||||
}
|
||||
|
||||
const createBaseMarkdown = (
|
||||
const createBaseMarkdown = async (
|
||||
message: Message,
|
||||
includeReasoning: boolean = false,
|
||||
excludeCitations: boolean = false,
|
||||
normalizeCitations: boolean = true
|
||||
): { titleSection: string; reasoningSection: string; contentSection: string; citation: string } => {
|
||||
normalizeCitations: boolean = true,
|
||||
imageMode: 'base64' | 'folder' | 'none' = 'none',
|
||||
imageOutputDir?: string
|
||||
): Promise<{
|
||||
titleSection: string
|
||||
reasoningSection: string
|
||||
contentSection: string
|
||||
citation: string
|
||||
imageSection: string
|
||||
}> => {
|
||||
const { forceDollarMathInMarkdown } = store.getState().settings
|
||||
const roleText = getRoleText(message.role, message.model?.name, message.model?.provider)
|
||||
const titleSection = `## ${roleText}`
|
||||
@@ -310,45 +320,98 @@ const createBaseMarkdown = (
|
||||
citation = formatCitationsAsFootnotes(citation)
|
||||
}
|
||||
|
||||
return { titleSection, reasoningSection, contentSection: processedContent, citation }
|
||||
// 处理图片
|
||||
let imageSection = ''
|
||||
if (imageMode !== 'none') {
|
||||
try {
|
||||
const imageResults = await processImageBlocks(message, imageMode, imageOutputDir)
|
||||
if (imageResults.length > 0) {
|
||||
imageSection = imageResults.map((img) => ``).join('\n\n')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to process images:', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
return { titleSection, reasoningSection, contentSection: processedContent, citation, imageSection }
|
||||
}
|
||||
|
||||
export const messageToMarkdown = (message: Message, excludeCitations?: boolean): string => {
|
||||
const { excludeCitationsInExport, standardizeCitationsInExport } = store.getState().settings
|
||||
export const messageToMarkdown = async (
|
||||
message: Message,
|
||||
excludeCitations?: boolean,
|
||||
imageMode?: 'base64' | 'folder' | 'none',
|
||||
imageOutputDir?: string
|
||||
): Promise<string> => {
|
||||
const { excludeCitationsInExport, standardizeCitationsInExport, imageExportMode } = store.getState().settings
|
||||
const shouldExcludeCitations = excludeCitations ?? excludeCitationsInExport
|
||||
const { titleSection, contentSection, citation } = createBaseMarkdown(
|
||||
const actualImageMode = imageMode ?? imageExportMode ?? 'none'
|
||||
const { titleSection, contentSection, citation, imageSection } = await createBaseMarkdown(
|
||||
message,
|
||||
false,
|
||||
shouldExcludeCitations,
|
||||
standardizeCitationsInExport
|
||||
standardizeCitationsInExport,
|
||||
actualImageMode,
|
||||
imageOutputDir
|
||||
)
|
||||
return [titleSection, '', contentSection, citation].join('\n')
|
||||
// Place images after the title and before content
|
||||
const sections = [titleSection]
|
||||
if (imageSection) {
|
||||
sections.push('', imageSection)
|
||||
}
|
||||
sections.push('', contentSection)
|
||||
if (citation) {
|
||||
sections.push(citation)
|
||||
}
|
||||
return sections.join('\n')
|
||||
}
|
||||
|
||||
export const messageToMarkdownWithReasoning = (message: Message, excludeCitations?: boolean): string => {
|
||||
const { excludeCitationsInExport, standardizeCitationsInExport } = store.getState().settings
|
||||
export const messageToMarkdownWithReasoning = async (
|
||||
message: Message,
|
||||
excludeCitations?: boolean,
|
||||
imageMode?: 'base64' | 'folder' | 'none',
|
||||
imageOutputDir?: string
|
||||
): Promise<string> => {
|
||||
const { excludeCitationsInExport, standardizeCitationsInExport, imageExportMode } = store.getState().settings
|
||||
const shouldExcludeCitations = excludeCitations ?? excludeCitationsInExport
|
||||
const { titleSection, reasoningSection, contentSection, citation } = createBaseMarkdown(
|
||||
const actualImageMode = imageMode ?? imageExportMode ?? 'none'
|
||||
const { titleSection, reasoningSection, contentSection, citation, imageSection } = await createBaseMarkdown(
|
||||
message,
|
||||
true,
|
||||
shouldExcludeCitations,
|
||||
standardizeCitationsInExport
|
||||
standardizeCitationsInExport,
|
||||
actualImageMode,
|
||||
imageOutputDir
|
||||
)
|
||||
return [titleSection, '', reasoningSection, contentSection, citation].join('\n')
|
||||
// Place images after the title and before reasoning
|
||||
const sections = [titleSection]
|
||||
if (imageSection) {
|
||||
sections.push('', imageSection)
|
||||
}
|
||||
if (reasoningSection) {
|
||||
sections.push('', reasoningSection)
|
||||
}
|
||||
sections.push(contentSection)
|
||||
if (citation) {
|
||||
sections.push(citation)
|
||||
}
|
||||
return sections.join('\n')
|
||||
}
|
||||
|
||||
export const messagesToMarkdown = (
|
||||
export const messagesToMarkdown = async (
|
||||
messages: Message[],
|
||||
exportReasoning?: boolean,
|
||||
excludeCitations?: boolean
|
||||
): string => {
|
||||
return messages
|
||||
.map((message) =>
|
||||
exportReasoning
|
||||
? messageToMarkdownWithReasoning(message, excludeCitations)
|
||||
: messageToMarkdown(message, excludeCitations)
|
||||
)
|
||||
.join('\n---\n')
|
||||
excludeCitations?: boolean,
|
||||
imageMode?: 'base64' | 'folder' | 'none',
|
||||
imageOutputDir?: string
|
||||
): Promise<string> => {
|
||||
const markdownParts: string[] = []
|
||||
for (const message of messages) {
|
||||
const markdown = exportReasoning
|
||||
? await messageToMarkdownWithReasoning(message, excludeCitations, imageMode, imageOutputDir)
|
||||
: await messageToMarkdown(message, excludeCitations, imageMode, imageOutputDir)
|
||||
markdownParts.push(markdown)
|
||||
}
|
||||
return markdownParts.join('\n---\n')
|
||||
}
|
||||
|
||||
const formatMessageAsPlainText = (message: Message): string => {
|
||||
@@ -370,14 +433,23 @@ const messagesToPlainText = (messages: Message[]): string => {
|
||||
export const topicToMarkdown = async (
|
||||
topic: Topic,
|
||||
exportReasoning?: boolean,
|
||||
excludeCitations?: boolean
|
||||
excludeCitations?: boolean,
|
||||
imageMode?: 'base64' | 'folder' | 'none',
|
||||
imageOutputDir?: string
|
||||
): Promise<string> => {
|
||||
const topicName = `# ${topic.name}`
|
||||
|
||||
const messages = await fetchTopicMessages(topic.id)
|
||||
|
||||
if (messages && messages.length > 0) {
|
||||
return topicName + '\n\n' + messagesToMarkdown(messages, exportReasoning, excludeCitations)
|
||||
const messagesMarkdown = await messagesToMarkdown(
|
||||
messages,
|
||||
exportReasoning,
|
||||
excludeCitations,
|
||||
imageMode,
|
||||
imageOutputDir
|
||||
)
|
||||
return topicName + '\n\n' + messagesMarkdown
|
||||
}
|
||||
|
||||
return topicName
|
||||
@@ -407,34 +479,43 @@ export const exportTopicAsMarkdown = async (
|
||||
|
||||
setExportingState(true)
|
||||
|
||||
const { markdownExportPath } = store.getState().settings
|
||||
if (!markdownExportPath) {
|
||||
try {
|
||||
const fileName = removeSpecialCharactersForFileName(topic.name) + '.md'
|
||||
const markdown = await topicToMarkdown(topic, exportReasoning, excludeCitations)
|
||||
const result = await window.api.file.save(fileName, markdown)
|
||||
if (result) {
|
||||
window.toast.success(i18n.t('message.success.markdown.export.specified'))
|
||||
const { markdownExportPath, imageExportMode } = store.getState().settings
|
||||
|
||||
try {
|
||||
// Handle folder mode - create folder structure
|
||||
if (imageExportMode === 'folder') {
|
||||
const { rootDir, imagesDir } = await createExportFolderStructure(topic.name, markdownExportPath || undefined)
|
||||
|
||||
// Generate markdown with images in folder mode
|
||||
const markdown = await topicToMarkdown(topic, exportReasoning, excludeCitations, 'folder', imagesDir)
|
||||
|
||||
// Save markdown to the root directory
|
||||
const markdownPath = `${rootDir}/conversation.md`
|
||||
await window.api.file.write(markdownPath, markdown)
|
||||
|
||||
window.toast.success(i18n.t('message.success.markdown.export.specified'))
|
||||
} else {
|
||||
// Base64 mode or no images - traditional export
|
||||
if (!markdownExportPath) {
|
||||
const fileName = removeSpecialCharactersForFileName(topic.name) + '.md'
|
||||
const markdown = await topicToMarkdown(topic, exportReasoning, excludeCitations, imageExportMode)
|
||||
const result = await window.api.file.save(fileName, markdown)
|
||||
if (result) {
|
||||
window.toast.success(i18n.t('message.success.markdown.export.specified'))
|
||||
}
|
||||
} else {
|
||||
const timestamp = dayjs().format('YYYY-MM-DD-HH-mm-ss')
|
||||
const fileName = removeSpecialCharactersForFileName(topic.name) + ` ${timestamp}.md`
|
||||
const markdown = await topicToMarkdown(topic, exportReasoning, excludeCitations, imageExportMode)
|
||||
await window.api.file.write(markdownExportPath + '/' + fileName, markdown)
|
||||
window.toast.success(i18n.t('message.success.markdown.export.preconf'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
window.toast.error(i18n.t('message.error.markdown.export.specified'))
|
||||
logger.error('Failed to export topic as markdown:', error)
|
||||
} finally {
|
||||
setExportingState(false)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const timestamp = dayjs().format('YYYY-MM-DD-HH-mm-ss')
|
||||
const fileName = removeSpecialCharactersForFileName(topic.name) + ` ${timestamp}.md`
|
||||
const markdown = await topicToMarkdown(topic, exportReasoning, excludeCitations)
|
||||
await window.api.file.write(markdownExportPath + '/' + fileName, markdown)
|
||||
window.toast.success(i18n.t('message.success.markdown.export.preconf'))
|
||||
} catch (error: any) {
|
||||
window.toast.error(i18n.t('message.error.markdown.export.preconf'))
|
||||
logger.error('Failed to export topic as markdown:', error)
|
||||
} finally {
|
||||
setExportingState(false)
|
||||
}
|
||||
} catch (error: any) {
|
||||
window.toast.error(i18n.t('message.error.markdown.export.specified'))
|
||||
logger.error('Failed to export topic as markdown:', error)
|
||||
} finally {
|
||||
setExportingState(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,40 +531,50 @@ export const exportMessageAsMarkdown = async (
|
||||
|
||||
setExportingState(true)
|
||||
|
||||
const { markdownExportPath } = store.getState().settings
|
||||
if (!markdownExportPath) {
|
||||
try {
|
||||
const title = await getMessageTitle(message)
|
||||
const fileName = removeSpecialCharactersForFileName(title) + '.md'
|
||||
const { markdownExportPath, imageExportMode } = store.getState().settings
|
||||
const title = await getMessageTitle(message)
|
||||
|
||||
try {
|
||||
// Handle folder mode for single message
|
||||
if (imageExportMode === 'folder') {
|
||||
const { rootDir, imagesDir } = await createExportFolderStructure(title, markdownExportPath || undefined)
|
||||
|
||||
// Generate markdown with images in folder mode
|
||||
const markdown = exportReasoning
|
||||
? messageToMarkdownWithReasoning(message, excludeCitations)
|
||||
: messageToMarkdown(message, excludeCitations)
|
||||
const result = await window.api.file.save(fileName, markdown)
|
||||
if (result) {
|
||||
window.toast.success(i18n.t('message.success.markdown.export.specified'))
|
||||
? await messageToMarkdownWithReasoning(message, excludeCitations, 'folder', imagesDir)
|
||||
: await messageToMarkdown(message, excludeCitations, 'folder', imagesDir)
|
||||
|
||||
// Save markdown to the root directory
|
||||
const markdownPath = `${rootDir}/message.md`
|
||||
await window.api.file.write(markdownPath, markdown)
|
||||
|
||||
window.toast.success(i18n.t('message.success.markdown.export.specified'))
|
||||
} else {
|
||||
// Base64 mode or no images - traditional export
|
||||
if (!markdownExportPath) {
|
||||
const fileName = removeSpecialCharactersForFileName(title) + '.md'
|
||||
const markdown = exportReasoning
|
||||
? await messageToMarkdownWithReasoning(message, excludeCitations, imageExportMode)
|
||||
: await messageToMarkdown(message, excludeCitations, imageExportMode)
|
||||
const result = await window.api.file.save(fileName, markdown)
|
||||
if (result) {
|
||||
window.toast.success(i18n.t('message.success.markdown.export.specified'))
|
||||
}
|
||||
} else {
|
||||
const timestamp = dayjs().format('YYYY-MM-DD-HH-mm-ss')
|
||||
const fileName = removeSpecialCharactersForFileName(title) + ` ${timestamp}.md`
|
||||
const markdown = exportReasoning
|
||||
? await messageToMarkdownWithReasoning(message, excludeCitations, imageExportMode)
|
||||
: await messageToMarkdown(message, excludeCitations, imageExportMode)
|
||||
await window.api.file.write(markdownExportPath + '/' + fileName, markdown)
|
||||
window.toast.success(i18n.t('message.success.markdown.export.preconf'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
window.toast.error(i18n.t('message.error.markdown.export.specified'))
|
||||
logger.error('Failed to export message as markdown:', error)
|
||||
} finally {
|
||||
setExportingState(false)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const timestamp = dayjs().format('YYYY-MM-DD-HH-mm-ss')
|
||||
const title = await getMessageTitle(message)
|
||||
const fileName = removeSpecialCharactersForFileName(title) + ` ${timestamp}.md`
|
||||
const markdown = exportReasoning
|
||||
? messageToMarkdownWithReasoning(message, excludeCitations)
|
||||
: messageToMarkdown(message, excludeCitations)
|
||||
await window.api.file.write(markdownExportPath + '/' + fileName, markdown)
|
||||
window.toast.success(i18n.t('message.success.markdown.export.preconf'))
|
||||
} catch (error: any) {
|
||||
window.toast.error(i18n.t('message.error.markdown.export.preconf'))
|
||||
logger.error('Failed to export message as markdown:', error)
|
||||
} finally {
|
||||
setExportingState(false)
|
||||
}
|
||||
} catch (error: any) {
|
||||
window.toast.error(i18n.t('message.error.markdown.export.specified'))
|
||||
logger.error('Failed to export message as markdown:', error)
|
||||
} finally {
|
||||
setExportingState(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
321
src/renderer/src/utils/exportImages.ts
Normal file
321
src/renderer/src/utils/exportImages.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import { loggerService } from '@logger'
|
||||
import type { ImageMessageBlock, Message } from '@renderer/types/newMessage'
|
||||
import { findImageBlocks } from '@renderer/utils/messageUtils/find'
|
||||
import dayjs from 'dayjs'
|
||||
import * as path from 'path'
|
||||
|
||||
const logger = loggerService.withContext('Utils:exportImages')
|
||||
|
||||
export interface ImageExportResult {
|
||||
originalPath: string
|
||||
exportedPath: string
|
||||
alt: string
|
||||
isBase64: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a file:// protocol image to Base64
|
||||
* @param filePath The file:// protocol path
|
||||
* @returns Base64 encoded image string
|
||||
*/
|
||||
export async function convertFileToBase64(filePath: string): Promise<string> {
|
||||
try {
|
||||
if (!filePath.startsWith('file://')) {
|
||||
throw new Error('Invalid file protocol')
|
||||
}
|
||||
|
||||
const actualPath = filePath.slice(7) // Remove 'file://' prefix
|
||||
const fileContent = await window.api.file.readBinary(actualPath)
|
||||
|
||||
// Determine MIME type based on file extension
|
||||
const ext = path.extname(actualPath).toLowerCase()
|
||||
let mimeType = 'image/jpeg'
|
||||
switch (ext) {
|
||||
case '.png':
|
||||
mimeType = 'image/png'
|
||||
break
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
mimeType = 'image/jpeg'
|
||||
break
|
||||
case '.gif':
|
||||
mimeType = 'image/gif'
|
||||
break
|
||||
case '.webp':
|
||||
mimeType = 'image/webp'
|
||||
break
|
||||
case '.svg':
|
||||
mimeType = 'image/svg+xml'
|
||||
break
|
||||
}
|
||||
|
||||
return `data:${mimeType};base64,${fileContent.toString('base64')}`
|
||||
} catch (error) {
|
||||
logger.error('Failed to convert file to Base64:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an image to a specified folder
|
||||
* @param image Image data (Base64 or file path)
|
||||
* @param outputDir Output directory
|
||||
* @param fileName File name for the saved image
|
||||
* @returns Path to the saved image
|
||||
*/
|
||||
export async function saveImageToFolder(image: string, outputDir: string, fileName: string): Promise<string> {
|
||||
try {
|
||||
const imagePath = path.join(outputDir, fileName)
|
||||
|
||||
if (image.startsWith('data:')) {
|
||||
// Base64 image - write directly as Base64 string, let main process handle conversion
|
||||
await window.api.file.write(imagePath, image)
|
||||
} else if (image.startsWith('file://')) {
|
||||
// File protocol image - copy file
|
||||
const sourcePath = image.slice(7)
|
||||
await window.api.file.copyFile(sourcePath, imagePath)
|
||||
} else {
|
||||
throw new Error('Unsupported image format')
|
||||
}
|
||||
|
||||
return imagePath
|
||||
} catch (error) {
|
||||
logger.error('Failed to save image to folder:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique filename for an image
|
||||
* @param index Image index
|
||||
* @param isUserUpload Whether the image was uploaded by user
|
||||
* @param originalName Original filename (if available)
|
||||
* @returns Generated filename
|
||||
*/
|
||||
function generateImageFileName(index: number, isUserUpload: boolean, originalName?: string): string {
|
||||
const prefix = isUserUpload ? 'user_' : 'ai_'
|
||||
|
||||
if (originalName && isUserUpload) {
|
||||
// Try to preserve original filename for user uploads
|
||||
const sanitized = originalName.replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||
return `${prefix}${index}_${sanitized}`
|
||||
}
|
||||
|
||||
// Generate timestamp-based name
|
||||
const timestamp = Date.now()
|
||||
return `${prefix}${index}_${timestamp}.png`
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract image alt text from metadata
|
||||
* @param block Image block
|
||||
* @returns Alt text for the image
|
||||
*/
|
||||
function getImageAltText(block: ImageMessageBlock): string {
|
||||
// Try to use prompt for AI generated images
|
||||
if (block.metadata?.prompt) {
|
||||
return block.metadata.prompt.slice(0, 100) // Limit alt text length
|
||||
}
|
||||
|
||||
// Use original filename for user uploads
|
||||
if (block.file?.origin_name) {
|
||||
return block.file.origin_name
|
||||
}
|
||||
|
||||
return 'Image'
|
||||
}
|
||||
|
||||
/**
|
||||
* Process image blocks from a message
|
||||
* @param message Message containing image blocks
|
||||
* @param mode Export mode: 'base64' | 'folder' | 'none'
|
||||
* @param outputDir Output directory (required for 'folder' mode)
|
||||
* @returns Array of processed image results
|
||||
*/
|
||||
export async function processImageBlocks(
|
||||
message: Message,
|
||||
mode: 'base64' | 'folder' | 'none',
|
||||
outputDir?: string
|
||||
): Promise<ImageExportResult[]> {
|
||||
if (mode === 'none') {
|
||||
return []
|
||||
}
|
||||
|
||||
const imageBlocks = findImageBlocks(message)
|
||||
if (imageBlocks.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const results: ImageExportResult[] = []
|
||||
// For future image quality and size optimization
|
||||
// const { imageExportQuality, imageExportMaxSize } = store.getState().settings
|
||||
|
||||
for (let i = 0; i < imageBlocks.length; i++) {
|
||||
const block = imageBlocks[i]
|
||||
const alt = getImageAltText(block)
|
||||
|
||||
try {
|
||||
// Handle AI generated images (stored as Base64)
|
||||
if (block.metadata?.generateImageResponse?.images) {
|
||||
const images = block.metadata.generateImageResponse.images
|
||||
|
||||
for (let j = 0; j < images.length; j++) {
|
||||
const imageData = images[j]
|
||||
|
||||
if (mode === 'base64') {
|
||||
// Already in Base64 format
|
||||
results.push({
|
||||
originalPath: imageData,
|
||||
exportedPath: imageData,
|
||||
alt: `${alt} ${j + 1}`,
|
||||
isBase64: true
|
||||
})
|
||||
} else if (mode === 'folder' && outputDir) {
|
||||
// Save Base64 to file
|
||||
const fileName = generateImageFileName(i * 10 + j, false)
|
||||
await saveImageToFolder(imageData, outputDir, fileName)
|
||||
results.push({
|
||||
originalPath: imageData,
|
||||
exportedPath: `./images/${fileName}`,
|
||||
alt: `${alt} ${j + 1}`,
|
||||
isBase64: false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle user uploaded images (stored as file paths)
|
||||
if (block.file?.path) {
|
||||
const filePath = `file://${block.file.path}`
|
||||
|
||||
if (mode === 'base64') {
|
||||
// Convert to Base64
|
||||
const base64Data = await convertFileToBase64(filePath)
|
||||
results.push({
|
||||
originalPath: filePath,
|
||||
exportedPath: base64Data,
|
||||
alt,
|
||||
isBase64: true
|
||||
})
|
||||
} else if (mode === 'folder' && outputDir) {
|
||||
// Copy to folder
|
||||
const fileName = generateImageFileName(i, true, block.file.origin_name)
|
||||
await saveImageToFolder(filePath, outputDir, fileName)
|
||||
results.push({
|
||||
originalPath: filePath,
|
||||
exportedPath: `./images/${fileName}`,
|
||||
alt,
|
||||
isBase64: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Handle URL images (if any)
|
||||
if (block.url) {
|
||||
if (mode === 'base64') {
|
||||
// If it's already a data URL, use it directly
|
||||
if (block.url.startsWith('data:')) {
|
||||
results.push({
|
||||
originalPath: block.url,
|
||||
exportedPath: block.url,
|
||||
alt,
|
||||
isBase64: true
|
||||
})
|
||||
} else {
|
||||
// For HTTP URLs, we'd need to fetch and convert
|
||||
// This is left as a future enhancement
|
||||
logger.warn('HTTP URL images not yet supported:', block.url)
|
||||
}
|
||||
} else if (mode === 'folder' && outputDir) {
|
||||
// Save URL image to file (future enhancement)
|
||||
logger.warn('Saving HTTP URL images not yet supported:', block.url)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to process image block ${i}:`, error as Error)
|
||||
// Continue processing other images even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert images into Markdown content
|
||||
* @param markdown Original markdown content
|
||||
* @param images Processed image results
|
||||
* @param messageId Message ID for reference
|
||||
* @returns Markdown with images inserted
|
||||
*/
|
||||
export function insertImagesIntoMarkdown(markdown: string, images: ImageExportResult[]): string {
|
||||
if (images.length === 0) {
|
||||
return markdown
|
||||
}
|
||||
|
||||
// Build image markdown
|
||||
const imageMarkdown = images.map((img) => ``).join('\n\n')
|
||||
|
||||
// Insert images after the message header
|
||||
// Look for the first line break after ## header
|
||||
const headerMatch = markdown.match(/^##\s+.+\n/)
|
||||
if (headerMatch) {
|
||||
const insertPos = headerMatch[0].length
|
||||
return markdown.slice(0, insertPos) + '\n' + imageMarkdown + '\n' + markdown.slice(insertPos)
|
||||
}
|
||||
|
||||
// If no header found, prepend images
|
||||
return imageMarkdown + '\n\n' + markdown
|
||||
}
|
||||
|
||||
/**
|
||||
* Create export folder structure for topic/conversation
|
||||
* @param topicName Topic name
|
||||
* @param baseExportPath Base export path
|
||||
* @returns Created folder paths
|
||||
*/
|
||||
export async function createExportFolderStructure(
|
||||
topicName: string,
|
||||
baseExportPath?: string
|
||||
): Promise<{ rootDir: string; imagesDir: string }> {
|
||||
const timestamp = dayjs().format('YYYY-MM-DD-HH-mm-ss')
|
||||
const sanitizedName = topicName.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 50)
|
||||
const folderName = `${sanitizedName}_${timestamp}`
|
||||
|
||||
const exportPath = baseExportPath || (await window.api.file.selectFolder())
|
||||
if (!exportPath) {
|
||||
throw new Error('No export path selected')
|
||||
}
|
||||
|
||||
const rootDir = path.join(exportPath, folderName)
|
||||
const imagesDir = path.join(rootDir, 'images')
|
||||
|
||||
// Create directories
|
||||
await window.api.file.createDirectory(rootDir)
|
||||
await window.api.file.createDirectory(imagesDir)
|
||||
|
||||
return { rootDir, imagesDir }
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all images in multiple messages
|
||||
* @param messages Array of messages
|
||||
* @param mode Export mode
|
||||
* @param outputDir Output directory for folder mode
|
||||
* @returns Map of message ID to image results
|
||||
*/
|
||||
export async function processMessagesImages(
|
||||
messages: Message[],
|
||||
mode: 'base64' | 'folder' | 'none',
|
||||
outputDir?: string
|
||||
): Promise<Map<string, ImageExportResult[]>> {
|
||||
const resultsMap = new Map<string, ImageExportResult[]>()
|
||||
|
||||
for (const message of messages) {
|
||||
const imageResults = await processImageBlocks(message, mode, outputDir)
|
||||
if (imageResults.length > 0) {
|
||||
resultsMap.set(message.id, imageResults)
|
||||
}
|
||||
}
|
||||
|
||||
return resultsMap
|
||||
}
|
||||
Reference in New Issue
Block a user