From bd879d5f9e5bac66b7c2b2c45e07a978975cd5c6 Mon Sep 17 00:00:00 2001 From: wait Date: Thu, 2 Apr 2026 20:57:21 +0800 Subject: [PATCH] fix: resolve Obsidian EPERM on macOS by using native folder picker (#4242) (#4924) - Replace text input with native dialog.showOpenDialog for vault path selection - Add path validity detection with three-state UI (unselected/valid/invalid) - Fix YAML frontmatter parsing failure with Chinese/special characters - Fix ENOENT error when vault subdirectory doesn't exist - Add AI summary (description) to Obsidian frontmatter export - Add author fallback to feed title when entry author is empty - Increase filename truncation limit from 20 to 80 characters for CJK titles Co-authored-by: Claude Opus 4.6 --- .../layer/main/src/ipc/services/app.ts | 19 ++++++ .../main/src/ipc/services/integration.ts | 15 +++-- .../modules/command/commands/integration.tsx | 4 +- .../settings/tabs/integration/index.tsx | 67 +++++++++++++++++-- locales/settings/en.json | 4 ++ locales/settings/zh-CN.json | 4 ++ 6 files changed, 100 insertions(+), 13 deletions(-) diff --git a/apps/desktop/layer/main/src/ipc/services/app.ts b/apps/desktop/layer/main/src/ipc/services/app.ts index 134ed9bb8..2076fa685 100644 --- a/apps/desktop/layer/main/src/ipc/services/app.ts +++ b/apps/desktop/layer/main/src/ipc/services/app.ts @@ -285,4 +285,23 @@ export class AppService extends IpcService { getCacheSize(_context: IpcContext) { return getCacheSize() } + + @IpcMethod() + async selectDirectory(_context: IpcContext): Promise { + const result = await dialog.showOpenDialog({ + properties: ["openDirectory"], + }) + if (result.canceled || result.filePaths.length === 0) return null + return result.filePaths[0]! + } + + @IpcMethod() + async checkPathExists(_context: IpcContext, input: string): Promise { + try { + await fsp.access(input) + return true + } catch { + return false + } + } } diff --git a/apps/desktop/layer/main/src/ipc/services/integration.ts b/apps/desktop/layer/main/src/ipc/services/integration.ts index 035fb9035..5fe256aad 100644 --- a/apps/desktop/layer/main/src/ipc/services/integration.ts +++ b/apps/desktop/layer/main/src/ipc/services/integration.ts @@ -66,24 +66,29 @@ export class IntegrationService extends IpcService { author: string publishedAt: string vaultPath: string + description?: string }, ) { try { - const { url, title, content, author, publishedAt, vaultPath } = input + const { url, title, content, author, publishedAt, vaultPath, description } = input const fileName = `${sanitizeFileName(title || publishedAt) .trim() - .slice(0, 20)}.md` + .slice(0, 80)}.md` const filePath = path.join(vaultPath, fileName) const exists = existsSync(filePath) if (exists) { return { success: false, error: "File already exists" } } + await fsp.mkdir(path.dirname(filePath), { recursive: true }) + + const yamlEscape = (s: string) => `"${s.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"` + const markdown = `--- -url: ${url} -author: ${author} -publishedAt: ${publishedAt} +url: ${yamlEscape(url)} +author: ${yamlEscape(author)} +publishedAt: ${yamlEscape(publishedAt)}${description ? `\ndescription: ${yamlEscape(description)}` : ""} --- # ${title} diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx index b52c894e1..896a1184a 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx @@ -11,6 +11,7 @@ import { import { IN_ELECTRON } from "@follow/shared/constants" import { getEntry } from "@follow/store/entry/getter" import type { EntryModel } from "@follow/store/entry/types" +import { getFeedById } from "@follow/store/feed/getter" import { getSummary } from "@follow/store/summary/getters" import { tracker } from "@follow/tracker" import { useMutation, useQuery } from "@tanstack/react-query" @@ -321,9 +322,10 @@ const useRegisterObsidianCommands = () => { url: entry.url || "", title: entry.title || "", content: markdownContent, - author: entry.author || "", + author: entry.author || getFeedById(entry.feedId)?.title || "", publishedAt: entry.publishedAt.toISOString() || "", vaultPath: obsidianVaultPath, + description: getDescription(entry), }) }, }), diff --git a/apps/desktop/layer/renderer/src/modules/settings/tabs/integration/index.tsx b/apps/desktop/layer/renderer/src/modules/settings/tabs/integration/index.tsx index 5b495b563..4fec83da8 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/tabs/integration/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/tabs/integration/index.tsx @@ -13,7 +13,8 @@ import { SimpleIconsZotero, } from "@follow/components/ui/platform-icon/icons.js" import { IN_ELECTRON } from "@follow/shared/constants" -import { useCallback, useEffect, useMemo, useState } from "react" +import type { FC } from "react" +import { useEffect, useMemo, useState } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" @@ -22,12 +23,13 @@ import { setIntegrationSetting, useIntegrationSettingValue, } from "~/atoms/settings/integration" +import { ipcServices } from "~/lib/client" import { downloadJsonFile, selectJsonFile } from "~/lib/export" import { getFetchAdapter } from "~/modules/integration/fetch-adapter" import { createSetting } from "../../helper/builder" import { useSetSettingCanSync } from "../../modal/hooks" -import { SettingSectionTitle } from "../../section" +import { SettingItemGroup, SettingSectionTitle } from "../../section" import { CustomIntegrationSection } from "./CustomIntegrationSection" const { defineSettingItem, SettingBuilder } = createSetting( @@ -35,6 +37,61 @@ const { defineSettingItem, SettingBuilder } = createSetting( useIntegrationSettingValue, setIntegrationSetting, ) +const ObsidianVaultPathPicker: FC = () => { + const vaultPath = useIntegrationSettingValue().obsidianVaultPath + const { t } = useTranslation("settings") + const [pathValid, setPathValid] = useState(null) + + useEffect(() => { + if (!vaultPath) { + setPathValid(null) + return + } + ipcServices?.app.checkPathExists(vaultPath).then((exists) => { + setPathValid(exists) + }) + }, [vaultPath]) + + const handleBrowse = async () => { + const selected = await ipcServices?.app.selectDirectory() + if (selected) { + setIntegrationSetting("obsidianVaultPath", selected) + } + } + + const buttonText = !vaultPath + ? t("integration.obsidian.vaultPath.select") + : pathValid === false + ? t("integration.obsidian.vaultPath.reselect") + : t("integration.obsidian.vaultPath.change") + + return ( + +
+ +
+ + {vaultPath && ( + + {vaultPath} + {pathValid === false && ( + + + {t("integration.obsidian.vaultPath.invalid")} + + )} + + )} +
+
+
+ ) +} + export const SettingIntegration = () => { const { t } = useTranslation("settings") const setSync = useSetSettingCanSync() @@ -100,11 +157,7 @@ export const SettingIntegration = () => { label: t("integration.obsidian.enable.label"), description: t("integration.obsidian.enable.description"), }), - defineSettingItem("obsidianVaultPath", { - label: t("integration.obsidian.vaultPath.label"), - vertical: true, - description: t("integration.obsidian.vaultPath.description"), - }), + ObsidianVaultPathPicker, ], }, { diff --git a/locales/settings/en.json b/locales/settings/en.json index 600f8be5f..00a806c98 100644 --- a/locales/settings/en.json +++ b/locales/settings/en.json @@ -472,8 +472,12 @@ "integration.obsidian.enable.description": "Display 'Save to Obsidian' button when available.", "integration.obsidian.enable.label": "Enable", "integration.obsidian.title": "Obsidian", + "integration.obsidian.vaultPath.change": "Change", "integration.obsidian.vaultPath.description": "The path to your Obsidian vault.", + "integration.obsidian.vaultPath.invalid": "Path not available", "integration.obsidian.vaultPath.label": "Obsidian Vault Path", + "integration.obsidian.vaultPath.reselect": "Reselect", + "integration.obsidian.vaultPath.select": "Select Folder", "integration.outline.collection.description": "The UUID or urlId of the collection where the documents is saved.", "integration.outline.collection.label": "Outline Collection", "integration.outline.enable.description": "Display 'Save to Outline' button when available.", diff --git a/locales/settings/zh-CN.json b/locales/settings/zh-CN.json index 4072df4fe..56834dca8 100644 --- a/locales/settings/zh-CN.json +++ b/locales/settings/zh-CN.json @@ -472,8 +472,12 @@ "integration.obsidian.enable.description": "显示「保存到 Obsidian」按钮(如果可用)。", "integration.obsidian.enable.label": "启用", "integration.obsidian.title": "Obsidian", + "integration.obsidian.vaultPath.change": "更改", "integration.obsidian.vaultPath.description": "你的 Obsidian 仓库的路径。", + "integration.obsidian.vaultPath.invalid": "路径不可用", "integration.obsidian.vaultPath.label": "Obsidian 仓库路径", + "integration.obsidian.vaultPath.reselect": "重新选择", + "integration.obsidian.vaultPath.select": "选择文件夹", "integration.outline.collection.description": "保存文档的文档集的 UUID 或 urlId。", "integration.outline.collection.label": "Outline 文档集", "integration.outline.enable.description": "显示「保存到 Outline」按钮(如果可用)。",