Compare commits

..

2 Commits

Author SHA1 Message Date
GeorgeDong32
42ee8a68ce feat(markdown): add image export controls and hook into export logic 2025-10-24 17:39:11 +08:00
GeorgeDong32
d521a88d30 feat(export): add image export options to markdown settings 2025-10-24 17:39:11 +08:00
22 changed files with 966 additions and 1233 deletions

305
docs/EXPORT_IMAGES_PLAN.md Normal file
View 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 导出模式设计
提供两种图片导出模式供用户选择:
**模式1Base64嵌入模式**
```markdown
![图片描述](data:image/png;base64,iVBORw0KGg...)
```
- 优点:单文件、便于分享、保证完整性
- 缺点:文件体积大、部分编辑器不支持、性能较差
**模式2文件夹模式**
```
导出结构:
conversation_2024-01-21/
├── conversation.md
└── images/
├── user_upload_1.png
├── ai_generated_1.png
└── ...
```
Markdown中使用相对路径
```markdown
![图片描述](./images/user_upload_1.png)
```
- 优点:文件体积小、兼容性好、性能优秀
- 缺点:需要管理多个文件、分享需打包
#### 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,
`![$1](${newPath})`
)
}
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*

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ import { setS3Partial } from '@renderer/store/settings'
import { S3Config } from '@renderer/types'
import { Button, Input, Switch, Tooltip } from 'antd'
import dayjs from 'dayjs'
import { FC, 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 />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => `![${img.alt}](${img.exportedPath})`).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)
}
}

View 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) => `![${img.alt}](${img.exportedPath})`).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
}