cherry-studio/src/renderer/src/utils/export.ts
one 09f820cd48 test: more unit tests (#5130)
* test: more unit tests

- Adjust vitest configuration to handle main process and renderer process tests separately
- Add unit tests for main process utils
- Add unit tests for the renderer process
- Add three component tests to verify vitest usage: `DragableList`, `Scrollbar`, `QuickPanelView`
- Add an e2e startup test to verify playwright usage
- Extract `splitApiKeyString` and add tests for it
- Add and format some comments

* fix: mock individual properties

* test: add tests for CustomTag

* test: add tests for ExpandableText

* test: conditional rendering tooltip of tag

* chore: update dependencies
2025-05-26 16:50:26 +08:00

641 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Client } from '@notionhq/client'
import db from '@renderer/databases'
import i18n from '@renderer/i18n'
import { getMessageTitle } from '@renderer/services/MessagesService'
import store from '@renderer/store'
import { setExportState } from '@renderer/store/runtime'
import type { Topic } from '@renderer/types'
import type { Message } from '@renderer/types/newMessage'
import { removeSpecialCharactersForFileName } from '@renderer/utils/file'
import { convertMathFormula } from '@renderer/utils/markdown'
import { getCitationContent, getMainTextContent, getThinkingContent } from '@renderer/utils/messageUtils/find'
import { markdownToBlocks } from '@tryfabric/martian'
import dayjs from 'dayjs'
//TODO: 添加对思考内容的支持
/**
* 从消息内容中提取标题,限制长度并处理换行和标点符号。用于导出功能。
* @param {string} str 输入字符串
* @param {number} [length=80] 标题最大长度,默认为 80
* @returns {string} 提取的标题
*/
export function getTitleFromString(str: string, length: number = 80) {
let title = str.trimStart().split('\n')[0]
if (title.includes('。')) {
title = title.split('。')[0]
} else if (title.includes('')) {
title = title.split('')[0]
} else if (title.includes('.')) {
title = title.split('.')[0]
} else if (title.includes(',')) {
title = title.split(',')[0]
}
if (title.length > length) {
title = title.slice(0, length)
}
if (!title) {
title = str.slice(0, length)
}
return title
}
const createBaseMarkdown = (message: Message, includeReasoning: boolean = false) => {
const { forceDollarMathInMarkdown } = store.getState().settings
const roleText = message.role === 'user' ? '🧑‍💻 User' : '🤖 Assistant'
const titleSection = `### ${roleText}`
let reasoningSection = ''
if (includeReasoning) {
let reasoningContent = getThinkingContent(message)
if (reasoningContent) {
if (reasoningContent.startsWith('<think>\n')) {
reasoningContent = reasoningContent.substring(8)
} else if (reasoningContent.startsWith('<think>')) {
reasoningContent = reasoningContent.substring(7)
}
reasoningContent = reasoningContent.replace(/\n/g, '<br>')
if (forceDollarMathInMarkdown) {
reasoningContent = convertMathFormula(reasoningContent)
}
reasoningSection = `<details style="background-color: #f5f5f5; padding: 5px; border-radius: 10px; margin-bottom: 10px;">
<summary>${i18n.t('common.reasoning_content')}</summary><hr>
${reasoningContent}
</details>`
}
}
const content = getMainTextContent(message)
const citation = getCitationContent(message)
const contentSection = forceDollarMathInMarkdown ? convertMathFormula(content) : content
return { titleSection, reasoningSection, contentSection, citation }
}
export const messageToMarkdown = (message: Message) => {
const { titleSection, contentSection, citation } = createBaseMarkdown(message)
return [titleSection, '', contentSection, citation].join('\n\n')
}
// 保留接口用于其它导出方法使用
export const messageToMarkdownWithReasoning = (message: Message) => {
const { titleSection, reasoningSection, contentSection, citation } = createBaseMarkdown(message, true)
return [titleSection, '', reasoningSection + contentSection, citation].join('\n\n')
}
export const messagesToMarkdown = (messages: Message[], exportReasoning?: boolean) => {
return messages
.map((message) => (exportReasoning ? messageToMarkdownWithReasoning(message) : messageToMarkdown(message)))
.join('\n\n---\n\n')
}
export const topicToMarkdown = async (topic: Topic, exportReasoning?: boolean) => {
const topicName = `# ${topic.name}`
const topicMessages = await db.topics.get(topic.id)
if (topicMessages) {
return topicName + '\n\n' + messagesToMarkdown(topicMessages.messages, exportReasoning)
}
return ''
}
export const exportTopicAsMarkdown = async (topic: Topic, exportReasoning?: boolean) => {
const { markdownExportPath } = store.getState().settings
if (!markdownExportPath) {
try {
const fileName = removeSpecialCharactersForFileName(topic.name) + '.md'
const markdown = await topicToMarkdown(topic, exportReasoning)
const result = await window.api.file.save(fileName, markdown)
if (result) {
window.message.success({
content: i18n.t('message.success.markdown.export.specified'),
key: 'markdown-success'
})
}
} catch (error: any) {
window.message.error({ content: i18n.t('message.error.markdown.export.specified'), key: 'markdown-error' })
}
} 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)
await window.api.file.write(markdownExportPath + '/' + fileName, markdown)
window.message.success({ content: i18n.t('message.success.markdown.export.preconf'), key: 'markdown-success' })
} catch (error: any) {
window.message.error({ content: i18n.t('message.error.markdown.export.preconf'), key: 'markdown-error' })
}
}
}
export const exportMessageAsMarkdown = async (message: Message, exportReasoning?: boolean) => {
const { markdownExportPath } = store.getState().settings
if (!markdownExportPath) {
try {
const title = await getMessageTitle(message)
const fileName = removeSpecialCharactersForFileName(title) + '.md'
const markdown = exportReasoning ? messageToMarkdownWithReasoning(message) : messageToMarkdown(message)
const result = await window.api.file.save(fileName, markdown)
if (result) {
window.message.success({
content: i18n.t('message.success.markdown.export.specified'),
key: 'markdown-success'
})
}
} catch (error: any) {
window.message.error({ content: i18n.t('message.error.markdown.export.specified'), key: 'markdown-error' })
}
} 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) : messageToMarkdown(message)
await window.api.file.write(markdownExportPath + '/' + fileName, markdown)
window.message.success({ content: i18n.t('message.success.markdown.export.preconf'), key: 'markdown-success' })
} catch (error: any) {
window.message.error({ content: i18n.t('message.error.markdown.export.preconf'), key: 'markdown-error' })
}
}
}
const convertMarkdownToNotionBlocks = async (markdown: string) => {
return markdownToBlocks(markdown)
}
// 修改 splitNotionBlocks 函数
const splitNotionBlocks = (blocks: any[]) => {
const { notionAutoSplit, notionSplitSize } = store.getState().settings
// 如果未开启自动分页,返回单页
if (!notionAutoSplit) {
return [blocks]
}
const pages: any[][] = []
let currentPage: any[] = []
blocks.forEach((block) => {
if (currentPage.length >= notionSplitSize) {
window.message.info({ content: i18n.t('message.info.notion.block_reach_limit'), key: 'notion-block-reach-limit' })
pages.push(currentPage)
currentPage = []
}
currentPage.push(block)
})
if (currentPage.length > 0) {
pages.push(currentPage)
}
return pages
}
export const exportTopicToNotion = async (topic: Topic) => {
const { isExporting } = store.getState().runtime.export
if (isExporting) {
window.message.warning({ content: i18n.t('message.warn.notion.exporting'), key: 'notion-exporting' })
return
}
setExportState({
isExporting: true
})
const { notionDatabaseID, notionApiKey } = store.getState().settings
if (!notionApiKey || !notionDatabaseID) {
window.message.error({ content: i18n.t('message.error.notion.no_api_key'), key: 'notion-no-apikey-error' })
return
}
try {
const notion = new Client({ auth: notionApiKey })
const markdown = await topicToMarkdown(topic)
const allBlocks = await convertMarkdownToNotionBlocks(markdown)
const blockPages = splitNotionBlocks(allBlocks)
if (blockPages.length === 0) {
throw new Error('No content to export')
}
// 创建主页面和子页面
let mainPageResponse: any = null
let parentBlockId: string | null = null
for (let i = 0; i < blockPages.length; i++) {
const pageTitle = topic.name
const pageBlocks = blockPages[i]
// 导出进度提示
window.message.loading({
content: i18n.t('message.loading.notion.exporting_progress', {
current: i + 1,
total: blockPages.length
}),
key: 'notion-export-progress'
})
if (i === 0) {
const response = await notion.pages.create({
parent: { database_id: notionDatabaseID },
properties: {
[store.getState().settings.notionPageNameKey || 'Name']: {
title: [{ text: { content: pageTitle } }]
}
},
children: pageBlocks
})
mainPageResponse = response
parentBlockId = response.id
} else {
if (!parentBlockId) {
throw new Error('Parent block ID is null')
}
await notion.blocks.children.append({
block_id: parentBlockId,
children: pageBlocks
})
}
}
window.message.success({ content: i18n.t('message.success.notion.export'), key: 'notion-export-progress' })
return mainPageResponse
} catch (error: any) {
window.message.error({ content: i18n.t('message.error.notion.export'), key: 'notion-export-progress' })
return null
} finally {
setExportState({
isExporting: false
})
}
}
export const exportMarkdownToNotion = async (title: string, content: string) => {
const { isExporting } = store.getState().runtime.export
if (isExporting) {
window.message.warning({ content: i18n.t('message.warn.notion.exporting'), key: 'notion-exporting' })
return
}
setExportState({ isExporting: true })
const { notionDatabaseID, notionApiKey } = store.getState().settings
if (!notionApiKey || !notionDatabaseID) {
window.message.error({ content: i18n.t('message.error.notion.no_api_key'), key: 'notion-no-apikey-error' })
return
}
try {
const notion = new Client({ auth: notionApiKey })
const notionBlocks = await convertMarkdownToNotionBlocks(content)
if (notionBlocks.length === 0) {
throw new Error('No content to export')
}
const response = await notion.pages.create({
parent: { database_id: notionDatabaseID },
properties: {
[store.getState().settings.notionPageNameKey || 'Name']: {
title: [{ text: { content: title } }]
}
},
children: notionBlocks as any[]
})
window.message.success({ content: i18n.t('message.success.notion.export'), key: 'notion-success' })
return response
} catch (error: any) {
window.message.error({ content: i18n.t('message.error.notion.export'), key: 'notion-error' })
return null
} finally {
setExportState({
isExporting: false
})
}
}
export const exportMarkdownToYuque = async (title: string, content: string) => {
const { isExporting } = store.getState().runtime.export
const { yuqueToken, yuqueRepoId } = store.getState().settings
if (isExporting) {
window.message.warning({ content: i18n.t('message.warn.yuque.exporting'), key: 'yuque-exporting' })
return
}
if (!yuqueToken || !yuqueRepoId) {
window.message.error({ content: i18n.t('message.error.yuque.no_config'), key: 'yuque-no-config-error' })
return
}
setExportState({ isExporting: true })
try {
const response = await fetch(`https://www.yuque.com/api/v2/repos/${yuqueRepoId}/docs`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Auth-Token': yuqueToken,
'User-Agent': 'CherryAI'
},
body: JSON.stringify({
title: title,
slug: Date.now().toString(), // 使用时间戳作为唯一slug
format: 'markdown',
body: content
})
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
const doc_id = data.data.id
const tocResponse = await fetch(`https://www.yuque.com/api/v2/repos/${yuqueRepoId}/toc`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Auth-Token': yuqueToken,
'User-Agent': 'CherryAI'
},
body: JSON.stringify({
action: 'appendNode',
action_mode: 'sibling',
doc_ids: [doc_id]
})
})
if (!tocResponse.ok) {
throw new Error(`HTTP error! status: ${tocResponse.status}`)
}
window.message.success({
content: i18n.t('message.success.yuque.export'),
key: 'yuque-success'
})
return data
} catch (error: any) {
window.message.error({
content: i18n.t('message.error.yuque.export'),
key: 'yuque-error'
})
return null
} finally {
setExportState({ isExporting: false })
}
}
/**
* 导出Markdown到Obsidian
* @param attributes 文档属性
* @param attributes.title 标题
* @param attributes.created 创建时间
* @param attributes.source 来源
* @param attributes.tags 标签
* @param attributes.processingMethod 处理方式
* @param attributes.folder 选择的文件夹路径或文件路径
* @param attributes.vault 选择的Vault名称
*/
export const exportMarkdownToObsidian = async (attributes: any) => {
try {
// 从参数获取Vault名称
const obsidianValut = attributes.vault
let obsidianFolder = attributes.folder || ''
let isMarkdownFile = false
if (!obsidianValut) {
window.message.error(i18n.t('chat.topics.export.obsidian_not_configured'))
return
}
if (!attributes.title) {
window.message.error(i18n.t('chat.topics.export.obsidian_title_required'))
return
}
// 检查是否选择了.md文件
if (obsidianFolder && obsidianFolder.endsWith('.md')) {
isMarkdownFile = true
}
let filePath = ''
// 如果是.md文件直接使用该文件路径
if (isMarkdownFile) {
filePath = obsidianFolder
} else {
// 否则构建路径
//构建保存路径添加以 / 结尾
if (obsidianFolder && !obsidianFolder.endsWith('/')) {
obsidianFolder = obsidianFolder + '/'
}
//构建文件名
const fileName = transformObsidianFileName(attributes.title)
filePath = obsidianFolder + fileName + '.md'
}
let obsidianUrl = `obsidian://new?file=${encodeURIComponent(filePath)}&vault=${encodeURIComponent(obsidianValut)}&clipboard`
if (attributes.processingMethod === '3') {
obsidianUrl += '&overwrite=true'
} else if (attributes.processingMethod === '2') {
obsidianUrl += '&prepend=true'
} else if (attributes.processingMethod === '1') {
obsidianUrl += '&append=true'
}
window.open(obsidianUrl)
window.message.success(i18n.t('chat.topics.export.obsidian_export_success'))
} catch (error) {
console.error('导出到Obsidian失败:', error)
window.message.error(i18n.t('chat.topics.export.obsidian_export_failed'))
}
}
/**
* 生成Obsidian文件名,源自 Obsidian Web Clipper 官方实现,修改了一些细节
* @param fileName
* @returns
*/
function transformObsidianFileName(fileName: string): string {
const platform = window.navigator.userAgent
const isWindows = /win/i.test(platform)
const isMac = /mac/i.test(platform)
// 删除Obsidian 全平台无效字符
let sanitized = fileName.replace(/[#|\\^\\[\]]/g, '')
if (isWindows) {
// Windows 的清理
sanitized = sanitized
.replace(/[<>:"\\/\\|?*]/g, '') // 移除无效字符
.replace(/^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i, '_$1$2') // 避免保留名称
.replace(/[\s.]+$/, '') // 移除结尾的空格和句点
} else if (isMac) {
// Mac 的清理
sanitized = sanitized
.replace(/[/:\u0020-\u007E]/g, '') // 移除无效字符
.replace(/^\./, '_') // 避免以句点开头
} else {
// Linux 或其他系统
sanitized = sanitized
.replace(/[<>:"\\/\\|?*]/g, '') // 移除无效字符
.replace(/^\./, '_') // 避免以句点开头
}
// 所有平台的通用操作
sanitized = sanitized
.replace(/^\.+/, '') // 移除开头的句点
.trim() // 移除前后空格
.slice(0, 245) // 截断为 245 个字符,留出空间以追加 ' 1.md'
// 确保文件名不为空
if (sanitized.length === 0) {
sanitized = 'Untitled'
}
return sanitized
}
export const exportMarkdownToJoplin = async (title: string, content: string) => {
const { joplinUrl, joplinToken } = store.getState().settings
if (!joplinUrl || !joplinToken) {
window.message.error(i18n.t('message.error.joplin.no_config'))
return
}
try {
const baseUrl = joplinUrl.endsWith('/') ? joplinUrl : `${joplinUrl}/`
const response = await fetch(`${baseUrl}notes?token=${joplinToken}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: title,
body: content,
source: 'Cherry Studio'
})
})
if (!response.ok) {
throw new Error('service not available')
}
const data = await response.json()
if (data?.error) {
throw new Error('response error')
}
window.message.success(i18n.t('message.success.joplin.export'))
return
} catch (error) {
window.message.error(i18n.t('message.error.joplin.export'))
return
}
}
/**
* 导出Markdown到思源笔记
* @param title 笔记标题
* @param content 笔记内容
*/
export const exportMarkdownToSiyuan = async (title: string, content: string) => {
const { isExporting } = store.getState().runtime.export
const { siyuanApiUrl, siyuanToken, siyuanBoxId, siyuanRootPath } = store.getState().settings
if (isExporting) {
window.message.warning({ content: i18n.t('message.warn.siyuan.exporting'), key: 'siyuan-exporting' })
return
}
if (!siyuanApiUrl || !siyuanToken || !siyuanBoxId) {
window.message.error({ content: i18n.t('message.error.siyuan.no_config'), key: 'siyuan-no-config-error' })
return
}
setExportState({ isExporting: true })
try {
// test connection
const testResponse = await fetch(`${siyuanApiUrl}/api/notebook/lsNotebooks`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Token ${siyuanToken}`
}
})
if (!testResponse.ok) {
throw new Error('API请求失败')
}
const testData = await testResponse.json()
if (testData.code !== 0) {
throw new Error(`${testData.msg || i18n.t('message.error.unknown')}`)
}
// 确保根路径以/开头
const rootPath = siyuanRootPath?.startsWith('/') ? siyuanRootPath : `/${siyuanRootPath || 'CherryStudio'}`
// 创建文档
const docTitle = `${title.replace(/[#|\\^\\[\]]/g, '')}`
const docPath = `${rootPath}/${docTitle}`
// 创建文档
await createSiyuanDoc(siyuanApiUrl, siyuanToken, siyuanBoxId, docPath, content)
window.message.success({
content: i18n.t('message.success.siyuan.export'),
key: 'siyuan-success'
})
} catch (error) {
console.error('导出到思源笔记失败:', error)
window.message.error({
content: i18n.t('message.error.siyuan.export') + (error instanceof Error ? `: ${error.message}` : ''),
key: 'siyuan-error'
})
} finally {
setExportState({ isExporting: false })
}
}
/**
* 创建思源笔记文档
*/
async function createSiyuanDoc(
apiUrl: string,
token: string,
boxId: string,
path: string,
markdown: string
): Promise<string> {
const response = await fetch(`${apiUrl}/api/filetree/createDocWithMd`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Token ${token}`
},
body: JSON.stringify({
notebook: boxId,
path: path,
markdown: markdown
})
})
const data = await response.json()
if (data.code !== 0) {
throw new Error(`${data.msg || i18n.t('message.error.unknown')}`)
}
return data.data
}