From f91e7da0a1fbd2ef794543b732171e5affb26619 Mon Sep 17 00:00:00 2001 From: Tristan Zhang <82869104+ABucket@users.noreply.github.com> Date: Thu, 2 Oct 2025 15:09:11 +0800 Subject: [PATCH] feat: add notes export (#10488) * feat: add notes export * chore: fix lint error * feat: unified export interface for notes * fix: hide export reasoning when exporting notes * chore: fix lint error * chore: remove debug log --- .../src/components/ObsidianExportDialog.tsx | 17 +++--- .../components/Popups/ObsidianExportPopup.tsx | 2 + src/renderer/src/pages/notes/NotesSidebar.tsx | 53 +++++++++++++++++-- src/renderer/src/utils/export.ts | 48 +++++++++++++++++ 4 files changed, 111 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/components/ObsidianExportDialog.tsx b/src/renderer/src/components/ObsidianExportDialog.tsx index 0c3d1c0038..b55105c599 100644 --- a/src/renderer/src/components/ObsidianExportDialog.tsx +++ b/src/renderer/src/components/ObsidianExportDialog.tsx @@ -38,6 +38,7 @@ interface PopupContainerProps { message?: Message messages?: Message[] topic?: Topic + rawContent?: string } // 转换文件信息数组为树形结构 @@ -140,7 +141,8 @@ const PopupContainer: React.FC = ({ resolve, message, messages, - topic + topic, + rawContent }) => { const defaultObsidianVault = store.getState().settings.defaultObsidianVault const [state, setState] = useState({ @@ -229,7 +231,9 @@ const PopupContainer: React.FC = ({ return } let markdown = '' - if (topic) { + if (rawContent) { + markdown = rawContent + } else if (topic) { markdown = await topicToMarkdown(topic, exportReasoning) } else if (messages && messages.length > 0) { markdown = messagesToMarkdown(messages, exportReasoning) @@ -299,7 +303,6 @@ const PopupContainer: React.FC = ({ } } } - return ( = ({ - - - + {!rawContent && ( + + + + )} ) diff --git a/src/renderer/src/components/Popups/ObsidianExportPopup.tsx b/src/renderer/src/components/Popups/ObsidianExportPopup.tsx index aec5fcbaa8..9a00e311cf 100644 --- a/src/renderer/src/components/Popups/ObsidianExportPopup.tsx +++ b/src/renderer/src/components/Popups/ObsidianExportPopup.tsx @@ -9,6 +9,7 @@ interface ObsidianExportOptions { topic?: Topic message?: Message messages?: Message[] + rawContent?: string } export default class ObsidianExportPopup { @@ -24,6 +25,7 @@ export default class ObsidianExportPopup { topic={options.topic} message={options.message} messages={options.messages} + rawContent={options.rawContent} obsidianTags={''} open={true} resolve={(v) => { diff --git a/src/renderer/src/pages/notes/NotesSidebar.tsx b/src/renderer/src/pages/notes/NotesSidebar.tsx index 4588c37611..54a3f80cdb 100644 --- a/src/renderer/src/pages/notes/NotesSidebar.tsx +++ b/src/renderer/src/pages/notes/NotesSidebar.tsx @@ -6,11 +6,13 @@ import { useInPlaceEdit } from '@renderer/hooks/useInPlaceEdit' import { useKnowledgeBases } from '@renderer/hooks/useKnowledge' import { useActiveNode } from '@renderer/hooks/useNotesQuery' import NotesSidebarHeader from '@renderer/pages/notes/NotesSidebarHeader' -import { useAppSelector } from '@renderer/store' +import { RootState, useAppSelector } from '@renderer/store' import { selectSortType } from '@renderer/store/note' import { NotesSortType, NotesTreeNode } from '@renderer/types/note' +import { exportNote } from '@renderer/utils/export' import { useVirtualizer } from '@tanstack/react-virtual' import { Dropdown, Input, InputRef, MenuProps } from 'antd' +import { ItemType, MenuItemType } from 'antd/es/menu/interface' import { ChevronDown, ChevronRight, @@ -21,10 +23,12 @@ import { Folder, FolderOpen, Star, - StarOff + StarOff, + UploadIcon } from 'lucide-react' import { FC, memo, Ref, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' import styled from 'styled-components' interface NotesSidebarProps { @@ -213,6 +217,7 @@ const NotesSidebar: FC = ({ const { bases } = useKnowledgeBases() const { activeNode } = useActiveNode(notesTree) const sortType = useAppSelector(selectSortType) + const exportMenuOptions = useSelector((state: RootState) => state.settings.exportMenuOptions) const [editingNodeId, setEditingNodeId] = useState(null) const [draggedNodeId, setDraggedNodeId] = useState(null) const [dragOverNodeId, setDragOverNodeId] = useState(null) @@ -525,6 +530,48 @@ const NotesSidebar: FC = ({ onClick: () => { handleExportKnowledge(node) } + }, + { + label: t('chat.topics.export.title'), + key: 'export', + icon: , + children: [ + exportMenuOptions.markdown && { + label: t('chat.topics.export.md.label'), + key: 'markdown', + onClick: () => exportNote({ node, platform: 'markdown' }) + }, + exportMenuOptions.docx && { + label: t('chat.topics.export.word'), + key: 'word', + onClick: () => exportNote({ node, platform: 'docx' }) + }, + exportMenuOptions.notion && { + label: t('chat.topics.export.notion'), + key: 'notion', + onClick: () => exportNote({ node, platform: 'notion' }) + }, + exportMenuOptions.yuque && { + label: t('chat.topics.export.yuque'), + key: 'yuque', + onClick: () => exportNote({ node, platform: 'yuque' }) + }, + exportMenuOptions.obsidian && { + label: t('chat.topics.export.obsidian'), + key: 'obsidian', + onClick: () => exportNote({ node, platform: 'obsidian' }) + }, + exportMenuOptions.joplin && { + label: t('chat.topics.export.joplin'), + key: 'joplin', + onClick: () => exportNote({ node, platform: 'joplin' }) + }, + exportMenuOptions.siyuan && { + label: t('chat.topics.export.siyuan'), + key: 'siyuan', + onClick: () => exportNote({ node, platform: 'siyuan' }) + } + ].filter(Boolean) as ItemType[] } ) } @@ -543,7 +590,7 @@ const NotesSidebar: FC = ({ return baseMenuItems }, - [t, handleStartEdit, onToggleStar, handleExportKnowledge, handleDeleteNode] + [t, handleStartEdit, onToggleStar, handleExportKnowledge, handleDeleteNode, exportMenuOptions] ) const handleDropFiles = useCallback( diff --git a/src/renderer/src/utils/export.ts b/src/renderer/src/utils/export.ts index f3ce321d63..d50b5b0e5d 100644 --- a/src/renderer/src/utils/export.ts +++ b/src/renderer/src/utils/export.ts @@ -1082,3 +1082,51 @@ export const exportTopicToNotes = async (topic: Topic, folderPath: string): Prom throw error } } + +const exportNoteAsMarkdown = async (noteName: string, content: string): Promise => { + const markdown = `# ${noteName}\n\n${content}` + const fileName = removeSpecialCharactersForFileName(noteName) + '.md' + const result = await window.api.file.save(fileName, markdown) + if (result) { + window.toast.success(i18n.t('message.success.markdown.export.specified')) + } +} + +interface NoteExportOptions { + node: { name: string; externalPath: string } + platform: 'markdown' | 'docx' | 'notion' | 'yuque' | 'obsidian' | 'joplin' | 'siyuan' +} + +export const exportNote = async ({ node, platform }: NoteExportOptions): Promise => { + try { + const content = await window.api.file.readExternal(node.externalPath) + + switch (platform) { + case 'markdown': + return await exportNoteAsMarkdown(node.name, content) + case 'docx': + window.api.export.toWord(`# ${node.name}\n\n${content}`, removeSpecialCharactersForFileName(node.name)) + return + case 'notion': + await exportMessageToNotion(node.name, content) + return + case 'yuque': + await exportMarkdownToYuque(node.name, `# ${node.name}\n\n${content}`) + return + case 'obsidian': { + const { default: ObsidianExportPopup } = await import('@renderer/components/Popups/ObsidianExportPopup') + await ObsidianExportPopup.show({ title: node.name, processingMethod: '1', rawContent: content }) + return + } + case 'joplin': + await exportMarkdownToJoplin(node.name, content) + return + case 'siyuan': + await exportMarkdownToSiyuan(node.name, `# ${node.name}\n\n${content}`) + return + } + } catch (error) { + logger.error(`Failed to export note to ${platform}:`, error as Error) + throw error + } +}