feat: export to Joplin (#3607)

This commit is contained in:
fullex 2025-03-19 20:07:53 +08:00 committed by GitHub
parent 424eb09995
commit eef141cbe7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 299 additions and 2 deletions

View File

@ -171,6 +171,7 @@
"topics.export.obsidian_export_failed": "Export failed",
"topics.export.obsidian_show_md_files": "Show MD Files",
"topics.export.obsidian_selected_path": "Selected Path",
"topics.export.joplin": "Export to Joplin",
"topics.list": "Topic List",
"topics.move_to": "Move to",
"topics.pinned": "Pinned Topics",
@ -441,6 +442,8 @@
"error.notion.no_api_key": "Notion ApiKey or Notion DatabaseID is not configured",
"error.yuque.export": "Failed to export to Yuque. Please check connection status and configuration according to documentation",
"error.yuque.no_config": "Yuque Token or Yuque Url is not configured",
"error.joplin.no_config": "Joplin Authorization Token or URL is not configured",
"error.joplin.export": "Failed to export to Joplin. Please keep Joplin running and check connection status or configuration",
"group.delete.content": "Deleting a group message will delete the user's question and all assistant's answers",
"group.delete.title": "Delete Group Message",
"ignore.knowledge.base": "Web search mode is enabled, ignore knowledge base",
@ -473,6 +476,7 @@
"success.markdown.export.specified": "Successfully exported the Markdown file",
"success.notion.export": "Successfully exported to Notion",
"success.yuque.export": "Successfully exported to Yuque",
"success.joplin.export": "Successfully exported to Joplin",
"switch.disabled": "Please wait for the current reply to complete",
"topic.added": "New topic added",
"upgrade.success.button": "Restart",
@ -790,6 +794,21 @@
"title": "Obsidian Configuration",
"api_key": "Obsidian API Key",
"api_key_placeholder": "Please enter the Obsidian API Key"
},
"joplin": {
"check": {
"button": "Check",
"empty_url": "Please enter Joplin Clipper Service URL",
"empty_token": "Please enter Joplin Authorization Token",
"fail": "Joplin connection verification failed",
"success": "Joplin connection verification successful"
},
"title": "Joplin Configuration",
"help": "In Joplin options, enable the web clipper (no browser extension needed), confirm the port, and copy the auth token here.",
"url": "Joplin Web Clipper Service URL",
"url_placeholder": "http://127.0.0.1:41184/",
"token": "Joplin Authorization Token",
"token_placeholder": "Joplin Authorization Token"
}
},
"display.assistant.title": "Assistant Settings",

View File

@ -171,6 +171,7 @@
"topics.export.obsidian_export_failed": "エクスポート失敗",
"topics.export.obsidian_show_md_files": "mdファイルを表示",
"topics.export.obsidian_selected_path": "選択済みパス",
"topics.export.joplin": "Joplin にエクスポート",
"topics.list": "トピックリスト",
"topics.move_to": "移動先",
"topics.pinned": "トピックを固定",
@ -441,6 +442,8 @@
"error.notion.no_api_key": "Notion ApiKey または Notion DatabaseID が設定されていません",
"error.yuque.export": "語雀へのエクスポートに失敗しました。接続状態と設定を確認してください",
"error.yuque.no_config": "語雀Token または 知識ベースID が設定されていません",
"error.joplin.no_config": "Joplin 認証トークン または URL が設定されていません",
"error.joplin.export": "Joplin へのエクスポートに失敗しました。Joplin が実行中であることを確認してください",
"group.delete.content": "分組メッセージを削除するとユーザーの質問と助け手の回答がすべて削除されます",
"group.delete.title": "分組メッセージを削除",
"loading.notion.preparing": "Notionへのエクスポートを準備中...",
@ -473,6 +476,7 @@
"success.markdown.export.specified": "Markdown ファイルを正常にエクスポートしました",
"success.notion.export": "Notionへのエクスポートに成功しました",
"success.yuque.export": "語雀へのエクスポートに成功しました",
"success.joplin.export": "Joplin へのエクスポートに成功しました",
"switch.disabled": "現在の応答が完了するまで切り替えを無効にします",
"topic.added": "新しいトピックが追加されました",
"upgrade.success.button": "再起動",
@ -790,6 +794,21 @@
"title": "Obsidian 設定",
"api_key": "Obsidian API Key",
"api_key_placeholder": "Obsidian API Key を入力してください"
},
"joplin": {
"check": {
"button": "確認",
"empty_url": "Joplin 剪輯服務 URL を先に入力してください",
"empty_token": "Joplin 認証トークン を先に入力してください",
"fail": "Joplin 接続確認に失敗しました",
"success": "Joplin 接続確認に成功しました"
},
"title": "Joplin 設定",
"help": "Joplin オプションで、剪輯サービスを有効にしてください。ポート番号を確認し、認証トークンをコピーしてください",
"url": "Joplin 剪輯服務 URL",
"url_placeholder": "http://127.0.0.1:41184/",
"token": "Joplin 認証トークン",
"token_placeholder": "Joplin 認証トークンを入力してください"
}
},
"display.assistant.title": "アシスタント設定",

View File

@ -171,6 +171,7 @@
"topics.export.obsidian_export_failed": "Экспорт не удалось",
"topics.export.obsidian_show_md_files": "Показать файлы MD",
"topics.export.obsidian_selected_path": "Выбранный путь",
"topics.export.joplin": "Экспорт в Joplin",
"topics.list": "Список топиков",
"topics.move_to": "Переместить в",
"topics.pinned": "Закрепленные темы",
@ -447,6 +448,8 @@
"error.notion.no_api_key": "Notion ApiKey или Notion DatabaseID не настроен",
"error.yuque.export": "Ошибка экспорта в Yuque, пожалуйста, проверьте состояние подключения и настройки в документации",
"error.yuque.no_config": "Yuque Token или Yuque Url не настроен",
"error.joplin.no_config": "Joplin Authorization Token или URL не настроен",
"error.joplin.export": "Не удалось экспортировать в Joplin, пожалуйста, убедитесь, что Joplin запущен и проверьте состояние подключения или настройки",
"group.delete.content": "Удаление группы сообщений удалит пользовательский вопрос и все ответы помощника",
"group.delete.title": "Удалить группу сообщений",
"ignore.knowledge.base": "Режим сети включен, игнорировать базу знаний",
@ -479,6 +482,7 @@
"success.markdown.export.specified": "Файл Markdown успешно экспортирован",
"success.notion.export": "Успешный экспорт в Notion",
"success.yuque.export": "Успешный экспорт в Yuque",
"success.joplin.export": "Успешный экспорт в Joplin",
"switch.disabled": "Пожалуйста, дождитесь завершения текущего ответа",
"topic.added": "Новый топик добавлен",
"upgrade.success.button": "Перезапустить",
@ -790,6 +794,21 @@
"title": "Настройка Obsidian",
"api_key": "API Key Obsidian",
"api_key_placeholder": "Введите API Key Obsidian"
},
"joplin": {
"check": {
"button": "Проверить",
"empty_url": "Сначала введите URL Joplin",
"empty_token": "Сначала введите токен Joplin",
"fail": "Не удалось проверить подключение к Joplin",
"success": "Подключение к Joplin успешно проверено"
},
"title": "Настройка Joplin",
"help": "Включите Joplin опцию, проверьте порт и скопируйте токен",
"url": "URL Joplin",
"url_placeholder": "http://127.0.0.1:41184/",
"token": "Токен Joplin",
"token_placeholder": "Введите токен Joplin"
}
},
"display.assistant.title": "Настройки ассистентов",

View File

@ -171,6 +171,7 @@
"topics.export.obsidian_export_failed": "导出失败",
"topics.export.obsidian_show_md_files": "显示md文件",
"topics.export.obsidian_selected_path": "已选择路径",
"topics.export.joplin": "导出到 Joplin",
"topics.list": "话题列表",
"topics.move_to": "移动到",
"topics.pinned": "固定话题",
@ -441,6 +442,8 @@
"error.notion.no_api_key": "未配置 Notion API Key 或 Notion Database ID",
"error.yuque.export": "导出语雀错误,请检查连接状态并对照文档检查配置",
"error.yuque.no_config": "未配置语雀 Token 或 知识库 URL",
"error.joplin.no_config": "未配置 Joplin 授权令牌 或 URL",
"error.joplin.export": "导出 Joplin 失败,请保持 Joplin 已运行并检查连接状态或检查配置",
"group.delete.content": "删除分组消息会删除用户提问和所有助手的回答",
"group.delete.title": "删除分组消息",
"ignore.knowledge.base": "联网模式开启,忽略知识库",
@ -473,6 +476,7 @@
"success.markdown.export.specified": "成功导出 Markdown 文件",
"success.notion.export": "成功导出到 Notion",
"success.yuque.export": "成功导出到语雀",
"success.joplin.export": "成功导出到 Joplin",
"switch.disabled": "请等待当前回复完成后操作",
"topic.added": "话题添加成功",
"upgrade.success.button": "重启",
@ -790,6 +794,21 @@
"title": "Obsidian 配置",
"api_key": "Obsidian API Key",
"api_key_placeholder": "请输入 Obsidian API Key"
},
"joplin": {
"check": {
"button": "检查",
"empty_url": "请先输入 Joplin 剪裁服务监听 URL",
"empty_token": "请先输入 Joplin 授权令牌",
"fail": "Joplin 连接验证失败",
"success": "Joplin 连接验证成功"
},
"title": "Joplin 配置",
"help": "在 Joplin 选项中,启用网页剪裁服务(无需安装浏览器插件),确认端口号,并复制授权令牌",
"url": "Joplin 剪裁服务监听 URL",
"url_placeholder": "http://127.0.0.1:41184/",
"token": "Joplin 授权令牌",
"token_placeholder": "请输入 Joplin 授权令牌"
}
},
"display.assistant.title": "助手设置",

View File

@ -171,6 +171,7 @@
"topics.export.obsidian_export_failed": "匯出失敗",
"topics.export.obsidian_show_md_files": "顯示md文件",
"topics.export.obsidian_selected_path": "已選擇路徑",
"topics.export.joplin": "匯出到 Joplin",
"topics.list": "話題列表",
"topics.move_to": "移動到",
"topics.pinned": "固定話題",
@ -441,6 +442,8 @@
"error.notion.no_api_key": "未設定 Notion API Key 或 Notion Database ID",
"error.yuque.export": "匯出語雀錯誤,請檢查連接狀態並對照文件檢查設定",
"error.yuque.no_config": "未設定語雀 Token 或知識庫 Url",
"error.joplin.no_config": "未設定 Joplin 授權Token 或 URL",
"error.joplin.export": "匯出 Joplin 失敗,請保持 Joplin 已運行並檢查連接狀態或檢查設定",
"group.delete.content": "刪除分組訊息會刪除使用者提問和所有助手的回答",
"group.delete.title": "刪除分組訊息",
"ignore.knowledge.base": "網路模式開啟,忽略知識庫",
@ -473,6 +476,7 @@
"success.markdown.export.specified": "成功導出 Markdown 文件",
"success.notion.export": "成功匯出到 Notion",
"success.yuque.export": "成功匯出到語雀",
"success.joplin.export": "成功匯出到 Joplin",
"switch.disabled": "請等待當前回覆完成",
"topic.added": "新話題已新增",
"upgrade.success.button": "重新啟動",
@ -790,6 +794,21 @@
"title": "Obsidian 設定",
"api_key": "Obsidian API Key",
"api_key_placeholder": "請輸入 Obsidian API Key"
},
"joplin": {
"check": {
"button": "檢查",
"empty_url": "請先輸入 Joplin 剪輯服務 URL",
"empty_token": "請先輸入 Joplin 授權Token",
"fail": "Joplin 連接驗證失敗",
"success": "Joplin 連接驗證成功"
},
"title": "Joplin 設定",
"help": "在 Joplin 選項中啟用剪輯服務無需安裝瀏覽器外掛確認埠編號並複製授權Token",
"url": "Joplin 剪輯服務 URL",
"url_placeholder": "http://127.0.0.1:41184/",
"token": "Joplin 授權Token",
"token_placeholder": "請輸入 Joplin 授權Token"
}
},
"display.assistant.title": "助手設定",

View File

@ -24,6 +24,7 @@ import { Message, Model } from '@renderer/types'
import { Assistant, Topic } from '@renderer/types'
import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL, removeTrailingDoubleSpaces } from '@renderer/utils'
import {
exportMarkdownToJoplin,
exportMarkdownToNotion,
exportMarkdownToYuque,
exportMessageAsMarkdown,
@ -222,6 +223,15 @@ const MessageMenubar: FC<Props> = (props) => {
const title = getMessageTitle(message)
await ObsidianExportPopup.show({ title, markdown })
}
},
{
label: t('chat.topics.export.joplin'),
key: 'joplin',
onClick: async () => {
const title = getMessageTitle(message)
const markdown = messageToMarkdown(message)
exportMarkdownToJoplin(title, markdown)
}
}
]
}

View File

@ -26,6 +26,7 @@ import { Assistant, Topic } from '@renderer/types'
import { removeSpecialCharactersForFileName } from '@renderer/utils'
import { copyTopicAsMarkdown } from '@renderer/utils/copy'
import {
exportMarkdownToJoplin,
exportMarkdownToYuque,
exportTopicAsMarkdown,
exportTopicToNotion,
@ -263,6 +264,14 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
const markdown = await topicToMarkdown(topic)
await ObsidianExportPopup.show({ title: topic.name, markdown })
}
},
{
label: t('chat.topics.export.joplin'),
key: 'joplin',
onClick: async () => {
const markdown = await topicToMarkdown(topic)
exportMarkdownToJoplin(topic.name, markdown)
}
}
]
}

View File

@ -22,6 +22,7 @@ import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
import JoplinSettings from './JoplinSettings'
import MarkdownExportSettings from './MarkdownExportSettings'
import NotionSettings from './NotionSettings'
import ObsidianSettings from './ObsidianSettings'
@ -35,6 +36,13 @@ const DataSettings: FC = () => {
const { theme } = useTheme()
const [menu, setMenu] = useState<string>('data')
//joplin icon needs to be updated into iconfont
const JoplinIcon = () => (
<svg viewBox="0 0 24 24" width="16" height="16" fill="grey" xmlns="http://www.w3.org/2000/svg">
<path d="M20.97 0h-8.9a.15.15 0 00-.16.15v2.83c0 .1.08.17.18.17h1.22c.49 0 .89.38.93.86V17.4l-.01.36-.05.29-.04.13a2.06 2.06 0 01-.38.7l-.02.03a2.08 2.08 0 01-.37.34c-.5.35-1.17.5-1.92.43a4.66 4.66 0 01-2.67-1.22 3.96 3.96 0 01-1.34-2.42c-.1-.78.14-1.47.65-1.93l.07-.05c.37-.31.84-.5 1.39-.55a.09.09 0 00.01 0l.3-.01.35.01h.02a4.39 4.39 0 011.5.44c.15.08.17 0 .18-.06V9.63a.26.26 0 00-.2-.26 7.5 7.5 0 00-6.76 1.61 6.37 6.37 0 00-2.03 5.5 8.18 8.18 0 002.71 5.08A9.35 9.35 0 0011.81 24c1.88 0 3.62-.64 4.9-1.81a6.32 6.32 0 002.06-4.3l.01-10.86V4.08a.95.95 0 01.95-.93h1.22a.17.17 0 00.17-.17V.15a.15.15 0 00-.15-.15z" />
</svg>
)
const menuItems = [
{ key: 'data', title: 'settings.data.data.title', icon: <DatabaseOutlined style={{ fontSize: 16 }} /> },
{ key: 'webdav', title: 'settings.data.webdav.title', icon: <CloudSyncOutlined style={{ fontSize: 16 }} /> },
@ -53,6 +61,12 @@ const DataSettings: FC = () => {
key: 'obsidian',
title: 'settings.data.obsidian.title',
icon: <i className="iconfont icon-obsidian" />
},
{
key: 'joplin',
title: 'settings.data.joplin.title',
//joplin icon needs to be updated into iconfont
icon: <JoplinIcon />
}
]
@ -191,6 +205,7 @@ const DataSettings: FC = () => {
{menu === 'notion' && <NotionSettings />}
{menu === 'yuque' && <YuqueSettings />}
{menu === 'obsidian' && <ObsidianSettings />}
{menu === 'joplin' && <JoplinSettings />}
</SettingContainer>
</Container>
)

View File

@ -0,0 +1,117 @@
import { InfoCircleOutlined } from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout'
import MinApp from '@renderer/components/MinApp'
import { useTheme } from '@renderer/context/ThemeProvider'
import { RootState, useAppDispatch } from '@renderer/store'
import { setJoplinToken, setJoplinUrl } from '@renderer/store/settings'
import { Button, Tooltip } from 'antd'
import Input from 'antd/es/input/Input'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
const JoplinSettings: FC = () => {
const { t } = useTranslation()
const { theme } = useTheme()
const dispatch = useAppDispatch()
const joplinToken = useSelector((state: RootState) => state.settings.joplinToken)
const joplinUrl = useSelector((state: RootState) => state.settings.joplinUrl)
const handleJoplinTokenChange = (e: React.ChangeEvent<HTMLInputElement>) => {
dispatch(setJoplinToken(e.target.value))
}
const handleJoplinUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
dispatch(setJoplinUrl(e.target.value))
}
const handleJoplinUrlBlur = (e: React.FocusEvent<HTMLInputElement>) => {
let url = e.target.value
// 确保URL以/结尾,但只在失去焦点时执行
if (url && !url.endsWith('/')) {
url = `${url}/`
dispatch(setJoplinUrl(url))
}
}
const handleJoplinConnectionCheck = async () => {
try {
if (!joplinToken) {
window.message.error(t('settings.data.joplin.check.empty_token'))
return
}
if (!joplinUrl) {
window.message.error(t('settings.data.joplin.check.empty_url'))
return
}
const response = await fetch(`${joplinUrl}notes?limit=1&token=${joplinToken}`)
const data = await response.json()
if (!response.ok || data?.error) {
window.message.error(t('settings.data.joplin.check.fail'))
return
}
window.message.success(t('settings.data.joplin.check.success'))
} catch (e) {
window.message.error(t('settings.data.joplin.check.fail'))
}
}
const handleJoplinHelpClick = () => {
MinApp.start({
id: 'joplin-help',
name: 'Joplin Help',
url: 'https://joplinapp.org/help/apps/clipper'
})
}
return (
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.data.joplin.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.joplin.url')}</SettingRowTitle>
<HStack alignItems="center" gap="5px" style={{ width: 315 }}>
<Input
type="text"
value={joplinUrl || ''}
onChange={handleJoplinUrlChange}
onBlur={handleJoplinUrlBlur}
style={{ width: 315 }}
placeholder={t('settings.data.joplin.url_placeholder')}
/>
</HStack>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle style={{ display: 'flex', alignItems: 'center' }}>
<span>{t('settings.data.joplin.token')}</span>
<Tooltip title={t('settings.data.joplin.help')} placement="left">
<InfoCircleOutlined
style={{ color: 'var(--color-text-2)', cursor: 'pointer', marginLeft: 4 }}
onClick={handleJoplinHelpClick}
/>
</Tooltip>
</SettingRowTitle>
<HStack alignItems="center" gap="5px" style={{ width: 315 }}>
<Input
type="password"
value={joplinToken || ''}
onChange={handleJoplinTokenChange}
style={{ width: 250 }}
placeholder={t('settings.data.joplin.token_placeholder')}
/>
<Button onClick={handleJoplinConnectionCheck}>{t('settings.data.joplin.check.button')}</Button>
</HStack>
</SettingRow>
</SettingGroup>
)
}
export default JoplinSettings

View File

@ -84,6 +84,8 @@ export interface SettingsState {
yuqueRepoId: string | null
obsidianApiKey: string | null
obsidianUrl: string | null
joplinToken: string | null
joplinUrl: string | null
}
export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid'
@ -152,7 +154,9 @@ const initialState: SettingsState = {
yuqueUrl: '',
yuqueRepoId: '',
obsidianApiKey: '',
obsidianUrl: ''
obsidianUrl: '',
joplinToken: '',
joplinUrl: ''
}
const settingsSlice = createSlice({
@ -353,6 +357,12 @@ const settingsSlice = createSlice({
},
setObsidianUrl: (state, action: PayloadAction<string>) => {
state.obsidianUrl = action.payload
},
setJoplinToken: (state, action: PayloadAction<string>) => {
state.joplinToken = action.payload
},
setJoplinUrl: (state, action: PayloadAction<string>) => {
state.joplinUrl = action.payload
}
}
})
@ -420,7 +430,9 @@ export const {
setYuqueRepoId,
setYuqueUrl,
setObsidianApiKey,
setObsidianUrl
setObsidianUrl,
setJoplinToken,
setJoplinUrl
} = settingsSlice.actions
export default settingsSlice.reducer

View File

@ -378,3 +378,42 @@ export const exportMarkdownToObsidian = async (
window.message.error(i18n.t('chat.topics.export.obsidian_export_failed'))
}
}
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
}
}