diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index c7db3d0d..4d11d083 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 9c121266..e6ddc746 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -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": "アシスタント設定", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index accb7127..d3640317 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -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": "Настройки ассистентов", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 1e896407..496809a3 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -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": "助手设置", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index faa2178b..e9c2f5de 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -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": "助手設定", diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index 107ae99b..03374989 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -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) => { 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) + } } ] } diff --git a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx index 9082dc2d..79d8776c 100644 --- a/src/renderer/src/pages/home/Tabs/TopicsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/TopicsTab.tsx @@ -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 = ({ 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) + } } ] } diff --git a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx index 7a17b16b..29fd53b9 100644 --- a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx @@ -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('data') + //joplin icon needs to be updated into iconfont + const JoplinIcon = () => ( + + + + ) + const menuItems = [ { key: 'data', title: 'settings.data.data.title', icon: }, { key: 'webdav', title: 'settings.data.webdav.title', icon: }, @@ -53,6 +61,12 @@ const DataSettings: FC = () => { key: 'obsidian', title: 'settings.data.obsidian.title', icon: + }, + { + key: 'joplin', + title: 'settings.data.joplin.title', + //joplin icon needs to be updated into iconfont + icon: } ] @@ -191,6 +205,7 @@ const DataSettings: FC = () => { {menu === 'notion' && } {menu === 'yuque' && } {menu === 'obsidian' && } + {menu === 'joplin' && } ) diff --git a/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx b/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx new file mode 100644 index 00000000..766316d1 --- /dev/null +++ b/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx @@ -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) => { + dispatch(setJoplinToken(e.target.value)) + } + + const handleJoplinUrlChange = (e: React.ChangeEvent) => { + dispatch(setJoplinUrl(e.target.value)) + } + + const handleJoplinUrlBlur = (e: React.FocusEvent) => { + 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 ( + + {t('settings.data.joplin.title')} + + + {t('settings.data.joplin.url')} + + + + + + + + {t('settings.data.joplin.token')} + + + + + + + + + + + ) +} + +export default JoplinSettings diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index ae90d919..21c03934 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -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) => { state.obsidianUrl = action.payload + }, + setJoplinToken: (state, action: PayloadAction) => { + state.joplinToken = action.payload + }, + setJoplinUrl: (state, action: PayloadAction) => { + state.joplinUrl = action.payload } } }) @@ -420,7 +430,9 @@ export const { setYuqueRepoId, setYuqueUrl, setObsidianApiKey, - setObsidianUrl + setObsidianUrl, + setJoplinToken, + setJoplinUrl } = settingsSlice.actions export default settingsSlice.reducer diff --git a/src/renderer/src/utils/export.ts b/src/renderer/src/utils/export.ts index 0046d81e..b483e0e2 100644 --- a/src/renderer/src/utils/export.ts +++ b/src/renderer/src/utils/export.ts @@ -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 + } +}