diff --git a/src/App.tsx b/src/App.tsx index 709f3fa45..5bb083d27 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -94,18 +94,11 @@ function App() { async function hydrateScheduledImportAfterOpen(proj: WikiProject): Promise { try { const savedScheduledImport = await loadScheduledImportConfig(proj.path) + const { normalizeScheduledImportConfigForProject } = await import("@/lib/scheduled-import") if (!isCurrentProject(proj)) return - if (savedScheduledImport) { - // Migrate relative path to absolute (backward compatibility) - let path = savedScheduledImport.path - if (path && !path.startsWith("/") && !path.match(/^[a-zA-Z]:[/\\]/)) { - path = `${proj.path}/${path}` - } - useWikiStore.getState().setScheduledImportConfig({ - ...savedScheduledImport, - path, - }) - } + useWikiStore.getState().setScheduledImportConfig( + normalizeScheduledImportConfigForProject(proj.path, savedScheduledImport), + ) const scheduledImportConfig = useWikiStore.getState().scheduledImportConfig if (!isCurrentProject(proj)) return @@ -435,7 +428,7 @@ function App() { setActiveView("wiki") useWikiStore.getState().setScheduledImportConfig({ enabled: false, - path: `${proj.path}/raw/sources`, + path: "", interval: 60, lastScan: null, }) diff --git a/src/components/settings/sections/scheduled-import-section.tsx b/src/components/settings/sections/scheduled-import-section.tsx index 3e194a2e6..9549244d8 100644 --- a/src/components/settings/sections/scheduled-import-section.tsx +++ b/src/components/settings/sections/scheduled-import-section.tsx @@ -8,7 +8,7 @@ import { Folder, Play, RefreshCw } from "lucide-react" import type { SettingsDraft, DraftSetter } from "../settings-types" import { useWikiStore } from "@/stores/wiki-store" import { - isProjectManagedScheduledImportPath, + getScheduledImportPathIssue, resolveImportPath, scanAndImport, } from "@/lib/scheduled-import" @@ -53,14 +53,14 @@ export function ScheduledImportSection({ draft, setDraft }: Props) { const lastScanDate = scheduledImportConfig.lastScan ? new Date(scheduledImportConfig.lastScan).toLocaleString() : t("settings.sections.scheduledImport.never", { defaultValue: "Never" }) - const managedPathSelected = Boolean( - project && - draft.scheduledImportPath && - isProjectManagedScheduledImportPath( - project.path, - resolveImportPath(project.path, draft.scheduledImportPath), - ), - ) + const scheduledImportPath = draft.scheduledImportPath.trim() + const pathIssue = project && scheduledImportPath + ? getScheduledImportPathIssue( + project.path, + resolveImportPath(project.path, scheduledImportPath), + ) + : null + const managedPathSelected = pathIssue !== null return (
@@ -111,7 +111,9 @@ export function ScheduledImportSection({ draft, setDraft }: Props) { setDraft("scheduledImportPath", e.target.value)} - placeholder="raw/sources" + placeholder={t("settings.sections.scheduledImport.directoryPlaceholder", { + defaultValue: "Select an external folder", + })} disabled={!draft.scheduledImportEnabled} className="flex-1" /> @@ -133,14 +135,29 @@ export function ScheduledImportSection({ draft, setDraft }: Props) { "Choose an external folder outside the current LLM Wiki project. Project folders are already handled by source folder monitoring.", })}

- {managedPathSelected && ( + {!scheduledImportPath && ( +

+ {t("settings.sections.scheduledImport.pathRequired", { + defaultValue: "Choose a directory outside the current project.", + })} +

+ )} + {pathIssue === "inside-project" && (

- {t("settings.sections.scheduledImport.managedPathWarning", { + {t("settings.sections.scheduledImport.insideProjectWarning", { defaultValue: "This path is inside the current LLM Wiki project and is already managed by source folder monitoring. Pick an external folder to avoid duplicate scans.", })}

)} + {pathIssue === "contains-project" && ( +

+ {t("settings.sections.scheduledImport.containsProjectWarning", { + defaultValue: + "This path contains the current LLM Wiki project. Pick a folder that does not include the project to avoid scanning project files.", + })} +

+ )}
diff --git a/src/components/settings/settings-view.tsx b/src/components/settings/settings-view.tsx index 8df7bf904..8fcbc063d 100644 --- a/src/components/settings/settings-view.tsx +++ b/src/components/settings/settings-view.tsx @@ -29,6 +29,10 @@ import { loadSourceWatchConfig, saveLanguage, saveTheme, loadTheme } from "@/lib import { applyTheme, type AppTheme } from "@/lib/theme" import type { SettingsDraft, DraftSetter } from "./settings-types" import { normalizeSourceWatchConfig } from "@/lib/source-watch-config" +import { + getScheduledImportPathIssue, + resolveImportPath, +} from "@/lib/scheduled-import" import { LlmProviderSection } from "./sections/llm-provider-section" import { EmbeddingSection } from "./sections/embedding-section" import { MultimodalSection } from "./sections/multimodal-section" @@ -102,21 +106,9 @@ function initialDraft( generalConfig: ReturnType["generalConfig"], maxHistoryMessages: number, uiLanguage: string, - projectPath?: string, theme?: AppTheme, zoomLevel?: number, ): SettingsDraft { - // Show absolute path: if stored path is empty, show default using project path - // If stored path is relative (legacy), prepend project path - // If stored path is absolute, show as-is - let displayPath = scheduledImport.path || "" - if (!displayPath && projectPath) { - displayPath = `${projectPath}/raw/sources` - } else if (displayPath && projectPath && !displayPath.startsWith("/") && !displayPath.match(/^[a-zA-Z]:[/\\]/)) { - // Legacy relative path - prepend project path for display - displayPath = `${projectPath}/${displayPath}` - } - return { provider: llm.provider, apiKey: llm.apiKey, @@ -156,7 +148,10 @@ function initialDraft( proxyUrl: proxy.url, proxyBypassLocal: proxy.bypassLocal, scheduledImportEnabled: scheduledImport.enabled, - scheduledImportPath: displayPath, + // Empty means no directory was selected. Do not synthesize a project-local + // default here: doing so turns a harmless empty config into an invalid path + // and can persist that path when the user saves unrelated settings. + scheduledImportPath: scheduledImport.path || "", scheduledImportInterval: scheduledImport.interval, sourceWatchConfig: normalizeSourceWatchConfig(sourceWatch), mineruEnabled: mineru.enabled, @@ -239,7 +234,6 @@ export function SettingsView() { generalConfig, maxHistoryMessages, i18n.language, - project?.path, ), ) @@ -296,7 +290,6 @@ export function SettingsView() { generalConfig, maxHistoryMessages, prev.uiLanguage, - project?.path, prev.theme, prev.zoomLevel, ), @@ -321,6 +314,17 @@ export function SettingsView() { setDraftState((prev) => ({ ...prev, [key]: value })) }, []) + const scheduledImportPath = draft.scheduledImportPath.trim() + const scheduledImportPathIssue = project && scheduledImportPath + ? getScheduledImportPathIssue( + project.path, + resolveImportPath(project.path, scheduledImportPath), + ) + : null + const scheduledImportInvalid = draft.scheduledImportEnabled && ( + !scheduledImportPath || scheduledImportPathIssue !== null + ) + useEffect(() => { setSaveError(null) }, [active]) @@ -404,7 +408,9 @@ export function SettingsView() { const newSourceWatch = normalizeSourceWatchConfig(draft.sourceWatchConfig) const newScheduledImport = { enabled: draft.scheduledImportEnabled, - path: draft.scheduledImportPath, + // Keep valid external selections while disabled, but clean up invalid + // project-local values persisted by older releases. + path: scheduledImportPathIssue ? "" : scheduledImportPath, interval: Math.max(1, Math.min(1440, draft.scheduledImportInterval || 60)), lastScan: scheduledImportConfig.lastScan, } @@ -614,6 +620,7 @@ export function SettingsView() { setGeneralConfig, setMaxHistoryMessages, currentTheme, + scheduledImportPath, ]) const body = useMemo(() => { @@ -717,14 +724,26 @@ export function SettingsView() { {active !== "about" && active !== "llm" && (
-

- {saveError +

+ {scheduledImportInvalid + ? t("settings.sections.scheduledImport.invalidSave", { + defaultValue: "Choose a valid external directory before saving.", + }) + : saveError ? t("settings.saveFailed") : saved ? t("settings.savedTick") : t("settings.changeHint")}

-
diff --git a/src/i18n/en.json b/src/i18n/en.json index 994c8a17a..8edf1be5f 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -731,8 +731,12 @@ "description": "Automatically monitor a directory and import new or modified files at regular intervals.", "enable": "Enable scheduled import", "directory": "Monitor Directory", + "directoryPlaceholder": "Select an external folder", "directoryHelp": "Choose an external folder outside the current LLM Wiki project. Project folders such as raw, raw/sources, wiki, and .llm-wiki are skipped because source folder monitoring already handles them.", - "managedPathWarning": "This path is inside the current LLM Wiki project and is already managed by source folder monitoring. Pick an external folder to avoid duplicate scans.", + "pathRequired": "Choose a directory outside the current project.", + "insideProjectWarning": "This path is inside the current LLM Wiki project and is already managed by source folder monitoring. Pick an external folder to avoid duplicate scans.", + "containsProjectWarning": "This path contains the current LLM Wiki project. Pick a folder that does not include the project to avoid scanning project files.", + "invalidSave": "Choose a valid external directory before saving.", "privacyNotice": "Files from the selected directory may be copied into this project and sent to your configured LLM during ingest. Removed files are not automatically deleted from the project.", "browse": "Browse", "selectDirectory": "Select Directory to Monitor", diff --git a/src/i18n/zh.json b/src/i18n/zh.json index fefbd11ec..b2f02f7ff 100644 --- a/src/i18n/zh.json +++ b/src/i18n/zh.json @@ -731,8 +731,12 @@ "description": "自动监控指定目录,按固定间隔导入新增或修改的文件。", "enable": "启用定时导入", "directory": "监控目录", + "directoryPlaceholder": "请选择项目外部目录", "directoryHelp": "请选择当前 LLM Wiki 项目之外的外部文件夹。raw、raw/sources、wiki、.llm-wiki 等项目内部目录会被跳过,因为资料文件夹监控已经负责这些内容。", - "managedPathWarning": "该路径位于当前 LLM Wiki 项目内部,已经由资料文件夹监控负责。请选择外部文件夹,避免重复扫描。", + "pathRequired": "请选择当前项目之外的目录。", + "insideProjectWarning": "该路径位于当前 LLM Wiki 项目内部,已经由资料文件夹监控负责。请选择外部文件夹,避免重复扫描。", + "containsProjectWarning": "该路径包含当前 LLM Wiki 项目。请选择不包含当前项目的文件夹,避免扫描项目文件。", + "invalidSave": "请选择有效的项目外部目录后再保存。", "privacyNotice": "所选目录中的文件可能会被复制到当前项目,并在提取时发送给你配置的 LLM。源目录中删除文件不会自动删除项目内已导入内容。", "browse": "浏览", "selectDirectory": "选择监控目录", diff --git a/src/lib/scheduled-import.test.ts b/src/lib/scheduled-import.test.ts index 4b866416c..d0d8596b2 100644 --- a/src/lib/scheduled-import.test.ts +++ b/src/lib/scheduled-import.test.ts @@ -38,7 +38,9 @@ vi.mock("@/lib/project-store", () => ({ })) import { + getScheduledImportPathIssue, isProjectManagedScheduledImportPath, + normalizeScheduledImportConfigForProject, resolveImportPath, scheduledImportDestinationForFile, scanAndImport, @@ -66,6 +68,55 @@ describe("scheduled import path handling", () => { ) }) + it("keeps an empty configured path empty", () => { + expect(resolveImportPath(projectPath, "")).toBe("") + expect(resolveImportPath(projectPath, " ")).toBe("") + }) + + it("uses an empty path when a project has no saved config", () => { + expect(normalizeScheduledImportConfigForProject(projectPath, null)).toEqual({ + enabled: false, + path: "", + interval: 60, + lastScan: null, + }) + }) + + it("clears legacy project-managed paths during hydration", () => { + expect(normalizeScheduledImportConfigForProject(projectPath, { + enabled: false, + path: `${projectPath}/raw/sources`, + interval: 30, + lastScan: 123, + })).toEqual({ + enabled: false, + path: "", + interval: 30, + lastScan: 123, + }) + + expect(normalizeScheduledImportConfigForProject(projectPath, { + enabled: true, + path: "/Users/me", + interval: 15, + lastScan: null, + })).toEqual({ + enabled: true, + path: "", + interval: 15, + lastScan: null, + }) + }) + + it("retains a valid external directory during hydration", () => { + expect(normalizeScheduledImportConfigForProject(projectPath, { + enabled: false, + path: "/Users/me/inbox", + interval: 60, + lastScan: null, + }).path).toBe("/Users/me/inbox") + }) + it("preserves nested relative paths for external directories", () => { const dest = scheduledImportDestinationForFile( projectPath, @@ -131,6 +182,21 @@ describe("scheduled import path handling", () => { ).toBe(false) }) + it("distinguishes paths inside the project from paths containing it", () => { + expect( + getScheduledImportPathIssue(projectPath, `${projectPath}/inbox`), + ).toBe("inside-project") + expect( + getScheduledImportPathIssue(projectPath, "/Users/me"), + ).toBe("contains-project") + expect( + getScheduledImportPathIssue(projectPath, "/"), + ).toBe("contains-project") + expect( + getScheduledImportPathIssue(projectPath, "/Users/me/inbox"), + ).toBeNull() + }) + it("detects Windows project paths case-insensitively", () => { expect( isProjectManagedScheduledImportPath( diff --git a/src/lib/scheduled-import.ts b/src/lib/scheduled-import.ts index 27873aae3..ee2725a43 100644 --- a/src/lib/scheduled-import.ts +++ b/src/lib/scheduled-import.ts @@ -111,10 +111,12 @@ function cloneDb(db: ImportDb): ImportDb { function isPathInside(path: string, parent: string): boolean { const normalizedPath = dbDirectoryKey(path) - const normalizedParent = dbDirectoryKey(parent).replace(/\/+$/, "") + const parentKey = dbDirectoryKey(parent) + const normalizedParent = parentKey === "/" ? parentKey : parentKey.replace(/\/+$/, "") + const parentPrefix = normalizedParent === "/" ? "/" : `${normalizedParent}/` return ( normalizedPath === normalizedParent || - normalizedPath.startsWith(`${normalizedParent}/`) + normalizedPath.startsWith(parentPrefix) ) } @@ -126,16 +128,46 @@ export function isProjectManagedScheduledImportPath( projectPath: string, importPath: string, ): boolean { - const project = normalizePath(projectPath).replace(/\/+$/, "") - const root = normalizePath(importPath).replace(/\/+$/, "") - return ( - root === project || - isPathInside(project, root) || - isPathInside(root, projectSubpath(project, "raw")) || - isPathInside(root, projectSubpath(project, "raw/sources")) || - isPathInside(root, projectSubpath(project, "wiki")) || - isPathInside(root, projectSubpath(project, ".llm-wiki")) - ) + return getScheduledImportPathIssue(projectPath, importPath) !== null +} + +export type ScheduledImportPathIssue = "inside-project" | "contains-project" + +export function normalizeScheduledImportConfigForProject( + projectPath: string, + config: ScheduledImportConfig | null, +): ScheduledImportConfig { + if (!config) { + return { + enabled: false, + path: "", + interval: 60, + lastScan: null, + } + } + + const path = resolveImportPath(projectPath, config.path) + return { + ...config, + // Older builds could persist the project's own raw/sources directory as + // the scheduled-import path. Clear any self-referential value while + // retaining a valid external directory for later re-enabling. + path: path && !isProjectManagedScheduledImportPath(projectPath, path) ? path : "", + } +} + +export function getScheduledImportPathIssue( + projectPath: string, + importPath: string, +): ScheduledImportPathIssue | null { + const normalizedProject = normalizePath(projectPath) + const normalizedRoot = normalizePath(importPath) + const project = normalizedProject === "/" ? normalizedProject : normalizedProject.replace(/\/+$/, "") + const root = normalizedRoot === "/" ? normalizedRoot : normalizedRoot.replace(/\/+$/, "") + if (!project || !root) return null + if (isPathInside(root, project)) return "inside-project" + if (isPathInside(project, root)) return "contains-project" + return null } function notifyManagedScheduledImportPath(project: WikiProject, importRoot: string): void { @@ -240,7 +272,8 @@ export function shouldSkipScheduledImportConfigFile(path: string): boolean { } export function resolveImportPath(projectPath: string, configPath: string): string { - const path = normalizePath(configPath || "raw/sources") + const path = normalizePath(configPath.trim()) + if (!path) return "" if (isAbsolutePath(path)) { return path } diff --git a/src/stores/wiki-store.ts b/src/stores/wiki-store.ts index 58c718633..8a5fef68d 100644 --- a/src/stores/wiki-store.ts +++ b/src/stores/wiki-store.ts @@ -195,7 +195,7 @@ interface ProxyConfig { interface ScheduledImportConfig { enabled: boolean - path: string // 监控目录的相对路径(相对于项目根目录),空字符串表示使用默认的 "raw" + path: string // 监控目录;空字符串表示尚未选择目录 interval: number // 扫描间隔(分钟) lastScan: number | null // 上次扫描时间戳 }