Переглянути джерело

fix(novel): 修复章节保存双文件与误删问题

切换章节时不再重命名为中文文件名,仅保存为正式章节时重命名
flush 按路径校验 chapter_number,避免跨章节内容污染
取消过期 autosave 并在重命名后清理 chapter-NNN 草稿

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 3 місяців тому
батько
коміт
84878b8860

+ 0 - 16
src/components/layout/preview-panel.spec.ts

@@ -1,16 +0,0 @@
-import { describe, expect, it } from "vitest"
-import { shouldSkipEmptyChapterFlush } from "./preview-panel"
-
-describe("shouldSkipEmptyChapterFlush", () => {
-  it("skips flushing empty markdown when disk snapshot still has content", () => {
-    expect(shouldSkipEmptyChapterFlush("", "---\ntype: chapter\n---\n\n# 第1章\n\n正文")).toBe(true)
-  })
-
-  it("allows flushing intentional empty edits when nothing was loaded", () => {
-    expect(shouldSkipEmptyChapterFlush("", "")).toBe(false)
-  })
-
-  it("allows flushing non-empty markdown", () => {
-    expect(shouldSkipEmptyChapterFlush("# 第1章\n\n正文", "")).toBe(false)
-  })
-})

+ 60 - 44
src/components/layout/preview-panel.tsx

@@ -32,6 +32,12 @@ import {
 } from "@/lib/novel/de-ai-skill-library"
 } from "@/lib/novel/de-ai-skill-library"
 import { startOutlineIngestTask } from "@/lib/novel/outline-generation"
 import { startOutlineIngestTask } from "@/lib/novel/outline-generation"
 import { streamChat } from "@/lib/llm-client"
 import { streamChat } from "@/lib/llm-client"
+import {
+  extractChapterNumberFromMarkdown,
+  getDraftChapterPath,
+  resolveChapterFlushMarkdown,
+  shouldSyncChapterOnLeave,
+} from "@/lib/novel/chapter-path-sync"
 import { makeChapterFileName, makeDefaultChapterTitle } from "@/lib/wiki-filename"
 import { makeChapterFileName, makeDefaultChapterTitle } from "@/lib/wiki-filename"
 import { getPreviewContentContainerClass, shouldUseCompactChapterToolbar } from "@/lib/workspace-layout"
 import { getPreviewContentContainerClass, shouldUseCompactChapterToolbar } from "@/lib/workspace-layout"
 import { useOutlineGenerationStore, type OutlineGenerationTask } from "@/stores/outline-generation-store"
 import { useOutlineGenerationStore, type OutlineGenerationTask } from "@/stores/outline-generation-store"
@@ -95,17 +101,6 @@ async function getCanonicalChapterPath(currentPath: string, markdown: string, ch
   return getUniqueSiblingPath(getDirName(currentPath), makeChapterFileName(title, chapterNumber), currentPath)
   return getUniqueSiblingPath(getDirName(currentPath), makeChapterFileName(title, chapterNumber), currentPath)
 }
 }
 
 
-/** Avoid flushing an empty in-memory snapshot over a chapter that still has disk content. */
-export function shouldSkipEmptyChapterFlush(markdown: string, lastLoaded: string): boolean {
-  return !markdown.trim() && Boolean(lastLoaded.trim())
-}
-
-function extractChapterNumberFromMarkdown(markdown: string): number | null {
-  const { frontmatter } = parseFrontmatter(markdown)
-  if (!frontmatter || typeof frontmatter !== "object") return null
-  return parseChapterMeta(frontmatter as Record<string, unknown>)?.chapterNumber ?? null
-}
-
 function formatWritingBodyWithIndent(markdown: string): string {
 function formatWritingBodyWithIndent(markdown: string): string {
   return formatChapterWriting(markdown)
   return formatChapterWriting(markdown)
   /*
   /*
@@ -201,6 +196,7 @@ export function PreviewPanel() {
   const setFinalChapterSave = useWikiStore((s) => s.setFinalChapterSave)
   const setFinalChapterSave = useWikiStore((s) => s.setFinalChapterSave)
   const outlineTasks = useOutlineGenerationStore((s) => s.tasks)
   const outlineTasks = useOutlineGenerationStore((s) => s.tasks)
   const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
   const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+  const saveGenerationRef = useRef(0)
   const [isSavingFinal, setIsSavingFinal] = useState(false)
   const [isSavingFinal, setIsSavingFinal] = useState(false)
   const [saveStatus, setSaveStatus] = useState<string>("")
   const [saveStatus, setSaveStatus] = useState<string>("")
   const [showSnapshot, setShowSnapshot] = useState(false)
   const [showSnapshot, setShowSnapshot] = useState(false)
@@ -236,6 +232,7 @@ export function PreviewPanel() {
   // marker if read_file had returned one for a missing/locked file. We
   // marker if read_file had returned one for a missing/locked file. We
   // skip save when the incoming markdown equals the last-loaded content.
   // skip save when the incoming markdown equals the last-loaded content.
   const lastLoadedRef = useRef<string>("")
   const lastLoadedRef = useRef<string>("")
+  const lastLoadedByPathRef = useRef<Map<string, string>>(new Map())
   const fileContentRef = useRef(fileContent)
   const fileContentRef = useRef(fileContent)
   const selectedFileRef = useRef<string | null>(selectedFile)
   const selectedFileRef = useRef<string | null>(selectedFile)
   const deAiSkillMemoryWarningRef = useRef("")
   const deAiSkillMemoryWarningRef = useRef("")
@@ -252,6 +249,11 @@ export function PreviewPanel() {
     fileContentRef.current = fileContent
     fileContentRef.current = fileContent
   }, [fileContent])
   }, [fileContent])
 
 
+  const rememberLoadedChapter = useCallback((path: string, markdown: string) => {
+    lastLoadedRef.current = markdown
+    lastLoadedByPathRef.current.set(path, markdown)
+  }, [])
+
   useEffect(() => {
   useEffect(() => {
     setChapterDeAiSkillId(undefined)
     setChapterDeAiSkillId(undefined)
     deAiSkillMemoryWarningRef.current = ""
     deAiSkillMemoryWarningRef.current = ""
@@ -277,18 +279,31 @@ export function PreviewPanel() {
     }
     }
   }, [deAiSkillPickerOpen])
   }, [deAiSkillPickerOpen])
 
 
-  const syncChapterToCanonicalPath = useCallback(async (path: string, markdown: string) => {
+  const syncChapterToCanonicalPath = useCallback(async (
+    path: string,
+    markdown: string,
+    options?: { renameToCanonical?: boolean },
+  ) => {
     const normalized = normalizeChapterWriting(markdown)
     const normalized = normalizeChapterWriting(markdown)
     const chapterNumber = extractChapterNumberFromMarkdown(normalized)
     const chapterNumber = extractChapterNumberFromMarkdown(normalized)
-    const targetPath = await getCanonicalChapterPath(path, normalized, chapterNumber)
+    const renameToCanonical = options?.renameToCanonical ?? false
+    const targetPath = renameToCanonical
+      ? await getCanonicalChapterPath(path, normalized, chapterNumber)
+      : path
 
 
     await writeFileAtomic(targetPath, normalized)
     await writeFileAtomic(targetPath, normalized)
-    if (targetPath !== path) {
+    if (renameToCanonical && targetPath !== path) {
       if (useWikiStore.getState().selectedFile === path) {
       if (useWikiStore.getState().selectedFile === path) {
         selectedFileRef.current = targetPath
         selectedFileRef.current = targetPath
         useWikiStore.getState().setSelectedFile(targetPath)
         useWikiStore.getState().setSelectedFile(targetPath)
       }
       }
       await deleteFile(path)
       await deleteFile(path)
+      if (chapterNumber !== null) {
+        const draftPath = getDraftChapterPath(getDirName(targetPath), chapterNumber)
+        if (draftPath !== targetPath && draftPath !== path && await fileExists(draftPath)) {
+          await deleteFile(draftPath)
+        }
+      }
       if (project) {
       if (project) {
         try {
         try {
           const tree = await listDirectory(normalizePath(project.path))
           const tree = await listDirectory(normalizePath(project.path))
@@ -299,15 +314,15 @@ export function PreviewPanel() {
       }
       }
     }
     }
 
 
+    rememberLoadedChapter(targetPath, normalized)
     if (useWikiStore.getState().selectedFile === targetPath) {
     if (useWikiStore.getState().selectedFile === targetPath) {
       setFileContent(normalized)
       setFileContent(normalized)
       fileContentRef.current = normalized
       fileContentRef.current = normalized
-      lastLoadedRef.current = normalized
     }
     }
 
 
     bumpDataVersion()
     bumpDataVersion()
     return { targetPath, markdown: normalized }
     return { targetPath, markdown: normalized }
-  }, [project, setFileContent, setFileTree, bumpDataVersion])
+  }, [project, rememberLoadedChapter, setFileContent, setFileTree, bumpDataVersion])
 
 
   const flushChapterBeforeLeave = useCallback(async (path: string | null, markdown: string) => {
   const flushChapterBeforeLeave = useCallback(async (path: string | null, markdown: string) => {
     if (!path || !isChapterPath(path)) return
     if (!path || !isChapterPath(path)) return
@@ -316,14 +331,11 @@ export function PreviewPanel() {
       clearTimeout(saveTimerRef.current)
       clearTimeout(saveTimerRef.current)
       saveTimerRef.current = null
       saveTimerRef.current = null
     }
     }
-    const storeMarkdown = useWikiStore.getState().fileContent
-    const resolvedMarkdown = markdown.trim()
-      ? markdown
-      : (storeMarkdown.trim() || lastLoadedRef.current)
-    if (shouldSkipEmptyChapterFlush(resolvedMarkdown, lastLoadedRef.current)) return
-    if (resolvedMarkdown === lastLoadedRef.current) return
+    const lastLoadedForPath = lastLoadedByPathRef.current.get(path) ?? ""
+    const resolvedMarkdown = resolveChapterFlushMarkdown(path, markdown, lastLoadedByPathRef.current)
+    if (!shouldSyncChapterOnLeave(path, markdown, lastLoadedForPath)) return
     try {
     try {
-      await syncChapterToCanonicalPath(path, resolvedMarkdown)
+      await syncChapterToCanonicalPath(path, resolvedMarkdown, { renameToCanonical: false })
     } catch (err) {
     } catch (err) {
       console.error("切换章节前同步文件失败:", err)
       console.error("切换章节前同步文件失败:", err)
     }
     }
@@ -331,6 +343,11 @@ export function PreviewPanel() {
 
 
   useEffect(() => {
   useEffect(() => {
     let cancelled = false
     let cancelled = false
+    if (saveTimerRef.current) {
+      clearTimeout(saveTimerRef.current)
+      saveTimerRef.current = null
+    }
+    saveGenerationRef.current += 1
     const previousFile = selectedFileRef.current
     const previousFile = selectedFileRef.current
     const previousContent = fileContentRef.current
     const previousContent = fileContentRef.current
     console.log("[PreviewPanel][debug] useEffect triggered", { selectedFile, previousFile, previousContentLength: previousContent?.length })
     console.log("[PreviewPanel][debug] useEffect triggered", { selectedFile, previousFile, previousContentLength: previousContent?.length })
@@ -369,14 +386,13 @@ export function PreviewPanel() {
 
 
     setFileContent("")
     setFileContent("")
     fileContentRef.current = ""
     fileContentRef.current = ""
-    lastLoadedRef.current = ""
     setSaveStatus("")
     setSaveStatus("")
 
 
     readFile(selectedFile)
     readFile(selectedFile)
       .then((content) => {
       .then((content) => {
         console.log("[PreviewPanel][debug] readFile success", { selectedFile, contentLength: content?.length, cancelled, storeSelectedFile: useWikiStore.getState().selectedFile })
         console.log("[PreviewPanel][debug] readFile success", { selectedFile, contentLength: content?.length, cancelled, storeSelectedFile: useWikiStore.getState().selectedFile })
         if (cancelled || useWikiStore.getState().selectedFile !== selectedFile) return
         if (cancelled || useWikiStore.getState().selectedFile !== selectedFile) return
-        lastLoadedRef.current = content
+        rememberLoadedChapter(selectedFile, content)
         setFileContent(content)
         setFileContent(content)
         setSaveStatus("")
         setSaveStatus("")
         setLoadedFilePath(selectedFile)
         setLoadedFilePath(selectedFile)
@@ -385,6 +401,7 @@ export function PreviewPanel() {
         console.log("[PreviewPanel][debug] readFile error", { selectedFile, err, cancelled, storeSelectedFile: useWikiStore.getState().selectedFile })
         console.log("[PreviewPanel][debug] readFile error", { selectedFile, err, cancelled, storeSelectedFile: useWikiStore.getState().selectedFile })
         if (cancelled || useWikiStore.getState().selectedFile !== selectedFile) return
         if (cancelled || useWikiStore.getState().selectedFile !== selectedFile) return
         lastLoadedRef.current = ""
         lastLoadedRef.current = ""
+        lastLoadedByPathRef.current.delete(selectedFile)
         setFileContent(`Error loading file: ${err}`)
         setFileContent(`Error loading file: ${err}`)
         setSaveStatus("")
         setSaveStatus("")
         setLoadedFilePath(selectedFile)
         setLoadedFilePath(selectedFile)
@@ -393,7 +410,7 @@ export function PreviewPanel() {
       console.log("[PreviewPanel][debug] useEffect cleanup", { selectedFile })
       console.log("[PreviewPanel][debug] useEffect cleanup", { selectedFile })
       cancelled = true
       cancelled = true
     }
     }
-  }, [selectedFile, setFileContent, flushChapterBeforeLeave])
+  }, [selectedFile, rememberLoadedChapter, setFileContent, flushChapterBeforeLeave])
 
 
   useEffect(() => {
   useEffect(() => {
     return () => {
     return () => {
@@ -406,30 +423,29 @@ export function PreviewPanel() {
 
 
   const handleSave = useCallback(
   const handleSave = useCallback(
     (markdown: string) => {
     (markdown: string) => {
-      if (!selectedFile) return
-      const persistedMarkdown = isChapterPath(selectedFile)
+      const pathAtSave = selectedFileRef.current
+      if (!pathAtSave) return
+      const persistedMarkdown = isChapterPath(pathAtSave)
         ? normalizeChapterWriting(markdown)
         ? normalizeChapterWriting(markdown)
         : markdown
         : markdown
       setFileContent(markdown)
       setFileContent(markdown)
       fileContentRef.current = markdown
       fileContentRef.current = markdown
-      // Ignore no-op saves from the editor's initial re-emit. Only write
-      // when the user has actually changed the content relative to the
-      // last disk read.
-      if (persistedMarkdown === lastLoadedRef.current) return
+      const lastLoadedForPath = lastLoadedByPathRef.current.get(pathAtSave) ?? lastLoadedRef.current
+      if (persistedMarkdown === lastLoadedForPath) return
       if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
       if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
+      const generation = saveGenerationRef.current
       saveTimerRef.current = setTimeout(() => {
       saveTimerRef.current = setTimeout(() => {
-        writeFileAtomic(selectedFile, persistedMarkdown)
+        if (generation !== saveGenerationRef.current) return
+        if (pathAtSave !== selectedFileRef.current) return
+        writeFileAtomic(pathAtSave, persistedMarkdown)
           .then(() => {
           .then(() => {
-            // Our own write becomes the new "last loaded" — subsequent
-            // re-emits from Milkdown that match this content must not
-            // trigger another save.
-            lastLoadedRef.current = persistedMarkdown
+            rememberLoadedChapter(pathAtSave, persistedMarkdown)
             bumpDataVersion()
             bumpDataVersion()
           })
           })
           .catch((err) => console.error("保存失败:", err))
           .catch((err) => console.error("保存失败:", err))
       }, 1000)
       }, 1000)
     },
     },
-    [selectedFile, setFileContent, bumpDataVersion]
+    [rememberLoadedChapter, setFileContent, bumpDataVersion]
   )
   )
 
 
   const chapterFrontmatter = useMemo(() => {
   const chapterFrontmatter = useMemo(() => {
@@ -623,7 +639,7 @@ export function PreviewPanel() {
     setChapterTitleEditing(false)
     setChapterTitleEditing(false)
     if (nextTitle === chapterDisplayTitle) return
     if (nextTitle === chapterDisplayTitle) return
     try {
     try {
-      await syncChapterToCanonicalPath(selectedFile, updateChapterHeading(fileContent, nextTitle))
+      await syncChapterToCanonicalPath(selectedFile, updateChapterHeading(fileContent, nextTitle), { renameToCanonical: true })
     } catch (error) {
     } catch (error) {
       console.error("章节标题同步失败:", error)
       console.error("章节标题同步失败:", error)
     }
     }
@@ -696,7 +712,7 @@ export function PreviewPanel() {
         saveTimerRef.current = null
         saveTimerRef.current = null
       }
       }
 
 
-      const markdownToSave = fileContent.trim() ? fileContent : lastLoadedRef.current
+      const markdownToSave = fileContent.trim() ? fileContent : (lastLoadedByPathRef.current.get(selectedFile) ?? lastLoadedRef.current)
       if (!markdownToSave.trim()) {
       if (!markdownToSave.trim()) {
         updatePhase(false, null)
         updatePhase(false, null)
         setSaveStatus("章节内容为空,无法保存为正式章节")
         setSaveStatus("章节内容为空,无法保存为正式章节")
@@ -705,10 +721,10 @@ export function PreviewPanel() {
       }
       }
 
 
       const updatedMarkdown = updateChapterStatus(markdownToSave, "final")
       const updatedMarkdown = updateChapterStatus(markdownToSave, "final")
-      const syncResult = await syncChapterToCanonicalPath(selectedFile, updatedMarkdown)
+      const syncResult = await syncChapterToCanonicalPath(selectedFile, updatedMarkdown, { renameToCanonical: true })
       const targetPath = syncResult.targetPath
       const targetPath = syncResult.targetPath
       savePath = targetPath
       savePath = targetPath
-      lastLoadedRef.current = syncResult.markdown
+      rememberLoadedChapter(targetPath, syncResult.markdown)
       fileContentRef.current = syncResult.markdown
       fileContentRef.current = syncResult.markdown
       setFileContent(syncResult.markdown)
       setFileContent(syncResult.markdown)
 
 
@@ -826,14 +842,14 @@ export function PreviewPanel() {
     if (!selectedFile || !canFormatWriting) return
     if (!selectedFile || !canFormatWriting) return
     const formatted = normalizeChapterWriting(fileContent)
     const formatted = normalizeChapterWriting(fileContent)
     setFileContent(formatted)
     setFileContent(formatted)
-    lastLoadedRef.current = formatted
+    rememberLoadedChapter(selectedFile, formatted)
     try {
     try {
       await writeFileAtomic(selectedFile, formatted)
       await writeFileAtomic(selectedFile, formatted)
       bumpDataVersion()
       bumpDataVersion()
     } catch (err) {
     } catch (err) {
       console.error("格式化写作内容失败:", err)
       console.error("格式化写作内容失败:", err)
     }
     }
-  }, [canFormatWriting, fileContent, selectedFile, setFileContent, bumpDataVersion])
+  }, [canFormatWriting, fileContent, rememberLoadedChapter, selectedFile, setFileContent, bumpDataVersion])
 
 
   const handleIngestOutline = useCallback(() => {
   const handleIngestOutline = useCallback(() => {
     if (!project || !selectedFile || !canIngestOutline || isOutlineIngesting) return
     if (!project || !selectedFile || !canIngestOutline || isOutlineIngesting) return

+ 85 - 0
src/lib/novel/chapter-path-sync.spec.ts

@@ -0,0 +1,85 @@
+import { describe, expect, it } from "vitest"
+import {
+  chapterContentMatchesPath,
+  getDraftChapterPath,
+  isDraftChapterPath,
+  resolveChapterFlushMarkdown,
+  shouldSkipEmptyChapterFlush,
+  shouldSyncChapterOnLeave,
+} from "@/lib/novel/chapter-path-sync"
+
+const CH45 = `---
+type: chapter
+chapter_number: 45
+chapter_status: draft
+title: "第45章-空情之眼"
+---
+
+# 第45章 空情之眼
+
+第四十五章正文。`
+
+const CH46 = `---
+type: chapter
+chapter_number: 46
+chapter_status: draft
+title: "第46章-逆火第一轮"
+---
+
+# 第46章 逆火第一轮
+
+第四十六章正文。`
+
+describe("isDraftChapterPath", () => {
+  it("detects AI draft chapter filenames", () => {
+    expect(isDraftChapterPath("/proj/wiki/chapters/chapter-046.md")).toBe(true)
+    expect(isDraftChapterPath("/proj/wiki/chapters/第46章-逆火第一轮.md")).toBe(false)
+  })
+})
+
+describe("chapterContentMatchesPath", () => {
+  it("rejects syncing chapter 46 content onto chapter 45 path", () => {
+    expect(chapterContentMatchesPath("/proj/wiki/chapters/第45章-空情之眼.md", CH46)).toBe(false)
+    expect(chapterContentMatchesPath("/proj/wiki/chapters/chapter-046.md", CH46)).toBe(true)
+  })
+})
+
+describe("resolveChapterFlushMarkdown", () => {
+  it("falls back to per-path snapshot instead of unrelated store content", () => {
+    const snapshots = new Map([
+      ["/proj/wiki/chapters/第45章-空情之眼.md", CH45],
+      ["/proj/wiki/chapters/chapter-046.md", CH46],
+    ])
+    expect(resolveChapterFlushMarkdown("/proj/wiki/chapters/第45章-空情之眼.md", "", snapshots)).toBe(CH45)
+  })
+})
+
+describe("shouldSyncChapterOnLeave", () => {
+  it("skips empty flush when the path still has loaded content", () => {
+    expect(shouldSyncChapterOnLeave("/proj/wiki/chapters/第45章-空情之眼.md", "", CH45)).toBe(false)
+  })
+
+  it("skips cross-chapter contamination", () => {
+    expect(shouldSyncChapterOnLeave("/proj/wiki/chapters/第45章-空情之眼.md", CH46, CH45)).toBe(false)
+  })
+
+  it("allows unchanged content skip", () => {
+    expect(shouldSyncChapterOnLeave("/proj/wiki/chapters/chapter-046.md", CH46, CH46)).toBe(false)
+  })
+})
+
+describe("shouldSkipEmptyChapterFlush", () => {
+  it("skips flushing empty markdown when disk snapshot still has content", () => {
+    expect(shouldSkipEmptyChapterFlush("", CH45)).toBe(true)
+  })
+
+  it("allows flushing intentional empty edits when nothing was loaded", () => {
+    expect(shouldSkipEmptyChapterFlush("", "")).toBe(false)
+  })
+})
+
+describe("getDraftChapterPath", () => {
+  it("builds the draft alias path for cleanup", () => {
+    expect(getDraftChapterPath("/proj/wiki/chapters", 46)).toBe("/proj/wiki/chapters/chapter-046.md")
+  })
+})

+ 60 - 0
src/lib/novel/chapter-path-sync.ts

@@ -0,0 +1,60 @@
+import { parseFrontmatter } from "@/lib/frontmatter"
+import { parseChapterMeta } from "@/lib/novel/chapter-meta"
+import { extractChapterNumber } from "@/lib/novel/chapter-utils"
+
+/** AI / chat draft files such as `chapter-046.md`. */
+export function isDraftChapterPath(path: string): boolean {
+  const name = path.replace(/\\/g, "/").split("/").pop() ?? ""
+  return /^chapter-\d+\.md$/i.test(name)
+}
+
+export function getDraftChapterPath(chapterDir: string, chapterNumber: number): string {
+  return `${chapterDir.replace(/\\/g, "/").replace(/\/$/, "")}/chapter-${String(chapterNumber).padStart(3, "0")}.md`
+}
+
+export function extractChapterNumberFromPath(path: string): number | null {
+  const stem = (path.replace(/\\/g, "/").split("/").pop() ?? "").replace(/\.md$/i, "")
+  const draftMatch = stem.match(/^chapter-(\d+)$/i)
+  if (draftMatch?.[1]) return Number.parseInt(draftMatch[1], 10)
+  return extractChapterNumber(stem)
+}
+
+export function extractChapterNumberFromMarkdown(markdown: string): number | null {
+  const { frontmatter } = parseFrontmatter(markdown)
+  if (!frontmatter || typeof frontmatter !== "object") return null
+  return parseChapterMeta(frontmatter as Record<string, unknown>)?.chapterNumber ?? null
+}
+
+/** Avoid flushing an empty in-memory snapshot over a chapter that still has disk content. */
+export function shouldSkipEmptyChapterFlush(markdown: string, lastLoadedForPath: string): boolean {
+  return !markdown.trim() && Boolean(lastLoadedForPath.trim())
+}
+
+export function chapterContentMatchesPath(path: string, markdown: string): boolean {
+  const pathNumber = extractChapterNumberFromPath(path)
+  const markdownNumber = extractChapterNumberFromMarkdown(markdown)
+  if (pathNumber === null || markdownNumber === null) return true
+  return pathNumber === markdownNumber
+}
+
+export function resolveChapterFlushMarkdown(
+  path: string,
+  markdown: string,
+  lastLoadedByPath: ReadonlyMap<string, string>,
+): string {
+  const trimmed = markdown.trim()
+  if (trimmed) return markdown
+  return lastLoadedByPath.get(path) ?? ""
+}
+
+export function shouldSyncChapterOnLeave(
+  path: string,
+  markdown: string,
+  lastLoadedForPath: string,
+): boolean {
+  const resolved = resolveChapterFlushMarkdown(path, markdown, new Map([[path, lastLoadedForPath]]))
+  if (!resolved.trim()) return false
+  if (shouldSkipEmptyChapterFlush(resolved, lastLoadedForPath)) return false
+  if (!chapterContentMatchesPath(path, resolved)) return false
+  return resolved !== lastLoadedForPath
+}