Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 5 additions & 12 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,18 +94,11 @@ function App() {
async function hydrateScheduledImportAfterOpen(proj: WikiProject): Promise<void> {
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
Expand Down Expand Up @@ -435,7 +428,7 @@ function App() {
setActiveView("wiki")
useWikiStore.getState().setScheduledImportConfig({
enabled: false,
path: `${proj.path}/raw/sources`,
path: "",
interval: 60,
lastScan: null,
})
Expand Down
41 changes: 29 additions & 12 deletions src/components/settings/sections/scheduled-import-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 (
<div className="space-y-6">
Expand Down Expand Up @@ -111,7 +111,9 @@ export function ScheduledImportSection({ draft, setDraft }: Props) {
<Input
value={draft.scheduledImportPath}
onChange={(e) => 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"
/>
Expand All @@ -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.",
})}
</p>
{managedPathSelected && (
{!scheduledImportPath && (
<p className={`text-xs ${draft.scheduledImportEnabled ? "text-destructive" : "text-muted-foreground"}`}>
{t("settings.sections.scheduledImport.pathRequired", {
defaultValue: "Choose a directory outside the current project.",
})}
</p>
)}
{pathIssue === "inside-project" && (
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/40 dark:text-amber-200">
{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.",
})}
</p>
)}
{pathIssue === "contains-project" && (
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/40 dark:text-amber-200">
{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.",
})}
</p>
)}
</div>

<div className="space-y-2">
Expand Down
57 changes: 38 additions & 19 deletions src/components/settings/settings-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -102,21 +106,9 @@ function initialDraft(
generalConfig: ReturnType<typeof useWikiStore.getState>["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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -239,7 +234,6 @@ export function SettingsView() {
generalConfig,
maxHistoryMessages,
i18n.language,
project?.path,
),
)

Expand Down Expand Up @@ -296,7 +290,6 @@ export function SettingsView() {
generalConfig,
maxHistoryMessages,
prev.uiLanguage,
project?.path,
prev.theme,
prev.zoomLevel,
),
Expand All @@ -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])
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -614,6 +620,7 @@ export function SettingsView() {
setGeneralConfig,
setMaxHistoryMessages,
currentTheme,
scheduledImportPath,
])

const body = useMemo(() => {
Expand Down Expand Up @@ -717,14 +724,26 @@ export function SettingsView() {
{active !== "about" && active !== "llm" && (
<div className="shrink-0 border-t bg-background/80 backdrop-blur px-8 py-3">
<div className="mx-auto flex max-w-2xl items-center justify-between gap-4">
<p className={`text-xs ${saveError ? "text-destructive" : "text-muted-foreground"}`}>
{saveError
<p className={`text-xs ${saveError || scheduledImportInvalid ? "text-destructive" : "text-muted-foreground"}`}>
{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")}
</p>
<Button onClick={handleSave}>
<Button
onClick={handleSave}
disabled={scheduledImportInvalid}
title={scheduledImportInvalid
? t("settings.sections.scheduledImport.invalidSave", {
defaultValue: "Choose a valid external directory before saving.",
})
: undefined}
>
{saved ? t("settings.saved") : t("settings.save")}
</Button>
</div>
Expand Down
6 changes: 5 additions & 1 deletion src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion src/i18n/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "选择监控目录",
Expand Down
66 changes: 66 additions & 0 deletions src/lib/scheduled-import.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ vi.mock("@/lib/project-store", () => ({
}))

import {
getScheduledImportPathIssue,
isProjectManagedScheduledImportPath,
normalizeScheduledImportConfigForProject,
resolveImportPath,
scheduledImportDestinationForFile,
scanAndImport,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
Loading