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 <noreply@anthropic.com>
This commit is contained in:
wait 2026-04-02 20:57:21 +08:00 committed by GitHub
parent 037a912a9b
commit bd879d5f9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 100 additions and 13 deletions

View File

@ -285,4 +285,23 @@ export class AppService extends IpcService {
getCacheSize(_context: IpcContext) {
return getCacheSize()
}
@IpcMethod()
async selectDirectory(_context: IpcContext): Promise<string | null> {
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<boolean> {
try {
await fsp.access(input)
return true
} catch {
return false
}
}
}

View File

@ -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}

View File

@ -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),
})
},
}),

View File

@ -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<boolean | null>(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 (
<SettingItemGroup>
<div className="mb-2 mt-4 flex flex-col gap-3">
<label className="shrink-0 text-sm font-medium leading-none">
{t("integration.obsidian.vaultPath.label")}
</label>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleBrowse}>
{buttonText}
</Button>
{vaultPath && (
<span className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 truncate text-xs text-text-secondary">{vaultPath}</span>
{pathValid === false && (
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-red">
<i className="i-mgc-warning-cute-re" />
{t("integration.obsidian.vaultPath.invalid")}
</span>
)}
</span>
)}
</div>
</div>
</SettingItemGroup>
)
}
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,
],
},
{

View File

@ -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.",

View File

@ -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」按钮如果可用。",