feat: 添加Notion导出自动分页功能 (#2098)

* fix: 长对话Notion导出失败(分页导出)

* feat: 添加Notion导出自动分页设置
This commit is contained in:
George·Dong 2025-02-23 06:38:35 +08:00 committed by kangfenmao
parent 6b34aac263
commit 5c2d936688
7 changed files with 254 additions and 7 deletions

View File

@ -604,6 +604,20 @@
"notion.page_name_key": "Page Title Field Name",
"notion.page_name_key_placeholder": "Enter page title field name, default is Name",
"notion.title": "Notion Configuration",
"notion.help": "Notion Configuration Documentation",
"notion.check": {
"button": "Check",
"fail": "Connection failed, please check network and Api_key and Database_id",
"success": "Connection successful",
"error": "Connection error, please check network configuration and Api_key and Database_id",
"empty_api_key": "Api_key is not configured",
"empty_database_id": "Database_id is not configured"
},
"notion.auto_split": "Auto split when exporting",
"notion.auto_split_tip": "Automatically split pages when exporting long topics to Notion",
"notion.split_size": "Split size",
"notion.split_size_placeholder": "Enter block limit per page (default 90)",
"notion.split_size_help": "Recommended: 90 for Free plan, 24990 for Plus plan, default is 90",
"title": "Data Settings",
"webdav": {
"autoSync": "Auto Backup",

View File

@ -604,6 +604,20 @@
"notion.page_name_key": "ページタイトルフィールド名",
"notion.page_name_key_placeholder": "ページタイトルフィールド名を入力してください。デフォルトは Name です",
"notion.title": "Notion 設定",
"notion.help": "Notion 設定ドキュメント",
"notion.check": {
"button": "確認",
"fail": "接続エラー、ネットワーク設定とApi_keyとDatabase_idを確認してください",
"success": "接続に成功しました。",
"error": "接続エラー、ネットワーク設定とApi_keyとDatabase_idを確認してください",
"empty_api_key": "Api_keyが設定されていません",
"empty_database_id": "Database_idが設定されていません"
},
"notion.auto_split": "내보내기 시 자동 분할",
"notion.auto_split_tip": "긴 주제를 Notion으로 내보낼 때 자동으로 페이지 분할",
"notion.split_size": "분할 크기",
"notion.split_size_placeholder": "페이지당 블록 제한 입력(기본값 90)",
"notion.split_size_help": "권장: 무료 플랜 90, Plus 플랜 24990, 기본값 90",
"title": "データ設定",
"webdav": {
"autoSync": "自動バックアップ",

View File

@ -401,7 +401,8 @@
"upgrade.success.button": "重启",
"upgrade.success.content": "重启用以完成升级",
"upgrade.success.title": "升级成功",
"warn.notion.exporting": "正在导出到Notion, 请勿重复请求导出!"
"warn.notion.exporting": "正在导出到Notion, 请勿重复请求导出!",
"info.notion.block_reach_limit": "对话过长正在分页导出到Notion"
},
"minapp": {
"sidebar.add.title": "添加到侧边栏",
@ -604,6 +605,20 @@
"notion.page_name_key": "页面标题字段名",
"notion.page_name_key_placeholder": "请输入页面标题字段名,默认为 Name",
"notion.title": "Notion 配置",
"notion.help": "Notion 配置文档",
"notion.check": {
"button": "检查",
"fail": "连接失败请检查网络及Api_key和Database_id是否正确",
"success": "连接成功",
"error": "连接异常请检查网络及Api_key和Database_id是否正确",
"empty_api_key": "未配置Api_key",
"empty_database_id": "未配置Database_id"
},
"notion.auto_split_tip": "当要导出的话题过长时自动分页导出到Notion",
"notion.auto_split": "导出对话时自动分页",
"notion.split_size": "自动分页大小",
"notion.split_size_placeholder": "请输入每页块数限制(默认90)",
"notion.split_size_help": "Notion免费版用户建议设置为90高级版用户建议设置为24990默认值为90",
"title": "数据设置",
"webdav": {
"autoSync": "自动备份",

View File

@ -604,6 +604,20 @@
"notion.page_name_key": "頁面標題欄位名稱",
"notion.page_name_key_placeholder": "請輸入頁面標題欄位名稱,預設為 Name",
"notion.title": "Notion 配置",
"notion.help": "Notion 配置文檔",
"notion.check": {
"button": "檢查",
"fail": "連接失敗請檢查網絡及Api_key和Database_id是否正確",
"success": "連線成功",
"error": "連接異常請檢查網絡及Api_key和Database_id是否正確",
"empty_api_key": "未配置Api_key",
"empty_database_id": "未配置Database_id"
},
"notion.auto_split": "導出對話時自動分頁",
"notion.auto_split_tip": "當要導出的話題過長時自動分頁導出到Notion",
"notion.split_size": "自動分頁大小",
"notion.split_size_placeholder": "請輸入每頁塊數限制(默認90)",
"notion.split_size_help": "Notion免費版用戶建議設置為90高級版用戶建議設置為24990默認值為90",
"title": "數據設定",
"webdav": {
"autoSync": "自動備份",

View File

@ -5,16 +5,30 @@ import MinApp from '@renderer/components/MinApp'
import { useTheme } from '@renderer/context/ThemeProvider'
import { backup, reset, restore } from '@renderer/services/BackupService'
import { RootState, useAppDispatch } from '@renderer/store'
import { setNotionApiKey, setNotionDatabaseID, setNotionPageNameKey } from '@renderer/store/settings'
import {
setNotionApiKey,
setNotionAutoSplit,
setNotionDatabaseID,
setNotionPageNameKey,
setNotionSplitSize
} from '@renderer/store/settings'
import { AppInfo } from '@renderer/types'
import { Button, Modal, Tooltip, Typography } from 'antd'
import { Button, InputNumber, Modal, Switch, Tooltip, Typography } from 'antd'
import Input from 'antd/es/input/Input'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import styled from 'styled-components'
import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
import {
SettingContainer,
SettingDivider,
SettingGroup,
SettingHelpText,
SettingRow,
SettingRowTitle,
SettingTitle
} from '..'
import WebDavSettings from './WebDavSettings'
// 新增的 NotionSettings 组件
@ -26,6 +40,8 @@ const NotionSettings: FC = () => {
const notionApiKey = useSelector((state: RootState) => state.settings.notionApiKey)
const notionDatabaseID = useSelector((state: RootState) => state.settings.notionDatabaseID)
const notionPageNameKey = useSelector((state: RootState) => state.settings.notionPageNameKey)
const notionAutoSplit = useSelector((state: RootState) => state.settings.notionAutoSplit)
const notionSplitSize = useSelector((state: RootState) => state.settings.notionSplitSize)
const handleNotionTokenChange = (e: React.ChangeEvent<HTMLInputElement>) => {
dispatch(setNotionApiKey(e.target.value))
@ -73,6 +89,16 @@ const NotionSettings: FC = () => {
})
}
const handleNotionAutoSplitChange = (checked: boolean) => {
dispatch(setNotionAutoSplit(checked))
}
const handleNotionSplitSizeChange = (value: number | null) => {
if (value !== null) {
dispatch(setNotionSplitSize(value))
}
}
return (
<SettingGroup theme={theme}>
<SettingTitle style={{ justifyContent: 'flex-start', gap: 10 }}>
@ -128,6 +154,37 @@ const NotionSettings: FC = () => {
</HStack>
</SettingRow>
<SettingDivider /> {/* 添加分割线 */}
<SettingRow>
<SettingRowTitle>
<Tooltip title={t('settings.data.notion.auto_split_tip')} placement="right">
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{t('settings.data.notion.auto_split')}
<InfoCircleOutlined style={{ cursor: 'pointer' }} />
</span>
</Tooltip>
</SettingRowTitle>
<Switch checked={notionAutoSplit} onChange={handleNotionAutoSplitChange} />
</SettingRow>
{notionAutoSplit && (
<>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.notion.split_size')}</SettingRowTitle>
<InputNumber
min={30}
max={25000}
value={notionSplitSize}
onChange={handleNotionSplitSizeChange}
keyboard={true}
controls={true}
style={{ width: 120 }}
/>
</SettingRow>
<SettingRow>
<SettingHelpText style={{ marginLeft: 10 }}>{t('settings.data.notion.split_size_help')}</SettingHelpText>
</SettingRow>
</>
)}
</SettingGroup>
)
}

View File

@ -71,6 +71,8 @@ export interface SettingsState {
notionApiKey: string | null
notionPageNameKey: string | null
thoughtAutoCollapse: boolean
notionAutoSplit: boolean
notionSplitSize: number
}
export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid'
@ -127,7 +129,9 @@ const initialState: SettingsState = {
notionDatabaseID: '',
notionApiKey: '',
notionPageNameKey: 'Name',
thoughtAutoCollapse: true
thoughtAutoCollapse: true,
notionAutoSplit: false,
notionSplitSize: 90
}
const settingsSlice = createSlice({
@ -293,6 +297,12 @@ const settingsSlice = createSlice({
},
setThoughtAutoCollapse: (state, action: PayloadAction<boolean>) => {
state.thoughtAutoCollapse = action.payload
},
setNotionAutoSplit: (state, action: PayloadAction<boolean>) => {
state.notionAutoSplit = action.payload
},
setNotionSplitSize: (state, action: PayloadAction<number>) => {
state.notionSplitSize = action.payload
}
}
})
@ -348,7 +358,9 @@ export const {
setNotionDatabaseID,
setNotionApiKey,
setNotionPageNameKey,
setThoughtAutoCollapse
setThoughtAutoCollapse,
setNotionAutoSplit,
setNotionSplitSize
} = settingsSlice.actions
export default settingsSlice.reducer

View File

@ -41,6 +41,127 @@ export const exportMessageAsMarkdown = async (message: Message) => {
window.api.file.save(fileName, 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
}
// 创建页面标题块
const createPageTitleBlocks = (title: string, pageNumber: number, totalPages: number) => {
return [
{
object: 'block',
type: 'heading_1',
heading_1: {
rich_text: [{ type: 'text', text: { content: `${title} (${pageNumber}/${totalPages})` } }]
}
},
{
object: 'block',
type: 'paragraph',
paragraph: {
rich_text: []
}
}
]
}
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 requestBody = JSON.stringify({ md: markdown })
const res = await fetch('https://md2notion.hilars.dev', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: requestBody
})
const data = await res.json()
const allBlocks = data
const blockPages = splitNotionBlocks(allBlocks)
if (blockPages.length === 0) {
throw new Error('No content to export')
}
// 创建主页面和子页面
let mainPageResponse: any = null
for (let i = 0; i < blockPages.length; i++) {
const pageTitle = blockPages.length > 1 ? `${topic.name} (${i + 1}/${blockPages.length})` : topic.name
const pageBlocks = blockPages[i]
const pageContent =
i === 0 ? pageBlocks : [...createPageTitleBlocks(topic.name, i + 1, blockPages.length), ...pageBlocks]
const response = await notion.pages.create({
parent: { database_id: notionDatabaseID },
properties: {
[store.getState().settings.notionPageNameKey || 'Name']: {
title: [{ text: { content: pageTitle } }]
}
},
children: pageContent
})
// 保存主页面响应
if (i === 0) {
mainPageResponse = response
}
}
window.message.success({ content: i18n.t('message.success.notion.export'), key: 'notion-success' })
return mainPageResponse
} catch (error: any) {
window.message.error({ content: i18n.t('message.error.notion.export'), key: 'notion-error' })
return null
} finally {
setExportState({
isExporting: false
})
}
}
export const exportMarkdownToNotion = async (title: string, content: string) => {
const { isExporting } = store.getState().runtime.export
@ -93,4 +214,4 @@ export const exportMarkdownToNotion = async (title: string, content: string) =>
isExporting: false
})
}
}
}