From 180ade77340075ff3a05e61df91f76bb9f6bc140 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 23 Feb 2026 14:48:21 +0100 Subject: [PATCH] fix: implement auto-save for editor content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor was not persisting changes to disk. Edits would be lost when closing and reopening a note. Changes: - Add save_note_content Tauri command (Rust) with read-only file check - Add save_note_content mock handler for browser testing - Add useAutoSave hook with 500ms debounce and flush-on-tab-switch - Wire up BlockNote onChange → markdown serialization → auto-save - Add restoreWikilinksInBlocks utility (reverse of injectWikilinks) to convert wikilink nodes back to [[target]] before markdown export - Extract shared walkBlocks helper to eliminate code duplication - Suppress onChange during programmatic content swaps (tab switching) - 12 new frontend tests + 5 new Rust tests (all 469 + 174 passing) Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/lib.rs | 6 ++ src-tauri/src/vault.rs | 103 +++++++++++++++++++++++ src/App.tsx | 12 +++ src/components/Editor.tsx | 48 ++++++++++- src/hooks/useAutoSave.test.ts | 152 ++++++++++++++++++++++++++++++++++ src/hooks/useAutoSave.ts | 67 +++++++++++++++ src/mock-tauri.ts | 7 ++ src/utils/wikilinks.test.ts | 88 +++++++++++++++++++- src/utils/wikilinks.ts | 47 +++++++++-- 9 files changed, 518 insertions(+), 12 deletions(-) create mode 100644 src/hooks/useAutoSave.test.ts create mode 100644 src/hooks/useAutoSave.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 90a5902c..ab88378f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,6 +23,11 @@ fn get_note_content(path: String) -> Result { vault::get_note_content(&path) } +#[tauri::command] +fn save_note_content(path: String, content: String) -> Result<(), String> { + vault::save_note_content(&path, &content) +} + #[tauri::command] fn update_frontmatter( path: String, @@ -165,6 +170,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ list_vault, get_note_content, + save_note_content, update_frontmatter, delete_frontmatter_property, rename_note, diff --git a/src-tauri/src/vault.rs b/src-tauri/src/vault.rs index a2266de9..32d36bb5 100644 --- a/src-tauri/src/vault.rs +++ b/src-tauri/src/vault.rs @@ -561,6 +561,33 @@ pub fn get_note_content(path: &str) -> Result { fs::read_to_string(file_path).map_err(|e| format!("Failed to read {}: {}", path, e)) } +/// Save the full content (frontmatter + body) of a note to disk. +pub fn save_note_content(path: &str, content: &str) -> Result<(), String> { + let file_path = Path::new(path); + validate_save_path(file_path, path)?; + fs::write(file_path, content) + .map_err(|e| format!("Failed to save {}: {}", path, e)) +} + +fn validate_save_path(file_path: &Path, display_path: &str) -> Result<(), String> { + let parent_missing = file_path.parent().is_some_and(|p| !p.exists()); + if parent_missing { + return Err(format!( + "Parent directory does not exist: {}", + file_path.parent().unwrap().display() + )); + } + let is_readonly = file_path.exists() + && fs::metadata(file_path) + .map_err(|e| format!("Failed to read file metadata: {}", e))? + .permissions() + .readonly(); + if is_readonly { + return Err(format!("File is read-only: {}", display_path)); + } + Ok(()) +} + /// Scan a directory recursively for .md files and return VaultEntry for each. pub fn scan_vault(vault_path: &str) -> Result, String> { let path = Path::new(vault_path); @@ -1999,6 +2026,82 @@ References: assert_eq!(entry.is_a, Some("Type".to_string())); } + // --- save_note_content tests --- + + #[test] + fn test_save_note_content_writes_file() { + let dir = TempDir::new().unwrap(); + let file_path = dir.path().join("test.md"); + let content = "---\nIs A: Note\n---\n# Test\n\nHello, world!"; + // Create file first + create_test_file(dir.path(), "test.md", "original content"); + + let result = save_note_content(file_path.to_str().unwrap(), content); + assert!(result.is_ok()); + + let saved = fs::read_to_string(&file_path).unwrap(); + assert_eq!(saved, content); + } + + #[test] + fn test_save_note_content_creates_new_file() { + let dir = TempDir::new().unwrap(); + let file_path = dir.path().join("new-note.md"); + let content = "---\nIs A: Note\n---\n# New Note\n\nContent here."; + + let result = save_note_content(file_path.to_str().unwrap(), content); + assert!(result.is_ok()); + assert!(file_path.exists()); + + let saved = fs::read_to_string(&file_path).unwrap(); + assert_eq!(saved, content); + } + + #[test] + fn test_save_note_content_nonexistent_parent() { + let result = save_note_content("/nonexistent/parent/dir/file.md", "content"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Parent directory does not exist")); + } + + #[test] + fn test_save_note_content_readonly_file() { + let dir = TempDir::new().unwrap(); + let file_path = dir.path().join("readonly.md"); + create_test_file(dir.path(), "readonly.md", "original"); + + // Make file read-only + let mut perms = fs::metadata(&file_path).unwrap().permissions(); + perms.set_readonly(true); + fs::set_permissions(&file_path, perms).unwrap(); + + let result = save_note_content(file_path.to_str().unwrap(), "new content"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("read-only")); + + // Cleanup: restore write permissions so tempdir can clean up + let mut perms = fs::metadata(&file_path).unwrap().permissions(); + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + fs::set_permissions(&file_path, perms).unwrap(); + } + + #[test] + fn test_save_note_content_preserves_frontmatter() { + let dir = TempDir::new().unwrap(); + let file_path = dir.path().join("note.md"); + let original = "---\nIs A: Project\nStatus: Active\n---\n# My Project\n\nOriginal body."; + create_test_file(dir.path(), "note.md", original); + + let updated = "---\nIs A: Project\nStatus: Active\n---\n# My Project\n\nUpdated body with changes."; + save_note_content(file_path.to_str().unwrap(), updated).unwrap(); + + let saved = fs::read_to_string(&file_path).unwrap(); + assert!(saved.contains("Is A: Project")); + assert!(saved.contains("Status: Active")); + assert!(saved.contains("Updated body with changes")); + } + // Frontmatter update/delete tests are in frontmatter.rs // --- save_image tests --- diff --git a/src/App.tsx b/src/App.tsx index 19225e20..5239a1f5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import { GitHubVaultModal } from './components/GitHubVaultModal' import { useVaultLoader } from './hooks/useVaultLoader' import { useSettings } from './hooks/useSettings' import { useNoteActions, generateUntitledName } from './hooks/useNoteActions' +import { useAutoSave } from './hooks/useAutoSave' import { useAppKeyboard } from './hooks/useAppKeyboard' import { useViewMode } from './hooks/useViewMode' import { useEntryActions } from './hooks/useEntryActions' @@ -80,6 +81,15 @@ function App() { const notes = useNoteActions({ addEntry: vault.addEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry }) + const updateTabAndContent = useCallback((path: string, content: string) => { + vault.updateContent(path, content) + notes.setTabs((prev: { entry: { path: string }; content: string }[]) => + prev.map((t) => t.entry.path === path ? { ...t, content } : t) + ) + }, [vault, notes]) + + const { debouncedSave, flush: flushSave } = useAutoSave(updateTabAndContent) + const entryActions = useEntryActions({ entries: vault.entries, updateEntry: vault.updateEntry, @@ -215,6 +225,8 @@ function App() { onArchiveNote={entryActions.handleArchiveNote} onUnarchiveNote={entryActions.handleUnarchiveNote} onRenameTab={handleRenameTab} + onContentChange={debouncedSave} + onFlushSave={flushSave} /> diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 0e8c7eb6..8c18c990 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -14,7 +14,7 @@ import { ResizeHandle } from './ResizeHandle' import { TabBar } from './TabBar' import { BreadcrumbBar } from './BreadcrumbBar' import { useEditorTheme } from '../hooks/useTheme' -import { splitFrontmatter, preProcessWikilinks, injectWikilinks, countWords } from '../utils/wikilinks' +import { splitFrontmatter, preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks, countWords } from '../utils/wikilinks' import './Editor.css' import './EditorTheme.css' @@ -55,6 +55,8 @@ interface EditorProps { onArchiveNote?: (path: string) => void onUnarchiveNote?: (path: string) => void onRenameTab?: (path: string, newTitle: string) => void + onContentChange?: (path: string, content: string) => void + onFlushSave?: () => void } // --- Custom Inline Content: WikiLink --- @@ -124,7 +126,7 @@ const schema = BlockNoteSchema.create({ }) /** Single BlockNote editor view — content is swapped via replaceBlocks */ -function SingleEditorView({ editor, entries, onNavigateWikilink }: { editor: ReturnType; entries: VaultEntry[]; onNavigateWikilink: (target: string) => void }) { +function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { editor: ReturnType; entries: VaultEntry[]; onNavigateWikilink: (target: string) => void; onChange?: () => void }) { const navigateRef = useRef(onNavigateWikilink) useEffect(() => { navigateRef.current = onNavigateWikilink }, [onNavigateWikilink]) const { cssVars } = useEditorTheme() @@ -181,6 +183,7 @@ function SingleEditorView({ editor, entries, onNavigateWikilink }: { editor: Ret (null) @@ -264,6 +269,37 @@ export const Editor = memo(function Editor({ return cleanup }, [editor]) + // Suppress auto-save during programmatic content swaps (tab switching / initial load) + const suppressChangeRef = useRef(false) + + // Keep refs to callbacks for the onChange handler + const onContentChangeRef = useRef(onContentChange) + onContentChangeRef.current = onContentChange + const tabsRef = useRef(tabs) + tabsRef.current = tabs + + // onChange handler: serialize editor blocks → markdown, reconstruct full file, call save + const handleEditorChange = useCallback(() => { + if (suppressChangeRef.current) return + const path = prevActivePathRef.current + if (!path) return + + const tab = tabsRef.current.find(t => t.entry.path === path) + if (!tab) return + + // Convert blocks → markdown, restoring wikilinks first + const blocks = editor.document + const restored = restoreWikilinksInBlocks(blocks) + const bodyMarkdown = editor.blocksToMarkdownLossy(restored) + + // Reconstruct the full file: preserve original frontmatter + title heading + const [frontmatter] = splitFrontmatter(tab.content) + const title = tab.entry.title + const fullContent = `${frontmatter}# ${title}\n\n${bodyMarkdown}` + + onContentChangeRef.current?.(path, fullContent) + }, [editor]) + // Swap document content when active tab changes. // Uses queueMicrotask to defer BlockNote mutations outside React's commit phase, // avoiding flushSync-inside-lifecycle errors that silently prevent content from rendering. @@ -274,6 +310,8 @@ export const Editor = memo(function Editor({ // Save current editor state for the tab we're leaving if (prevPath && prevPath !== activeTabPath && editorMountedRef.current) { cache.set(prevPath, editor.document) + // Flush any pending debounced save for the tab we're leaving + onFlushSave?.() } prevActivePathRef.current = activeTabPath @@ -284,6 +322,7 @@ export const Editor = memo(function Editor({ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote's PartialBlock generic is extremely complex const applyBlocks = (blocks: any[]) => { + suppressChangeRef.current = true try { const current = editor.document if (current.length > 0 && blocks.length > 0) { @@ -299,6 +338,10 @@ export const Editor = memo(function Editor({ } catch (err2) { console.error('Fallback also failed:', err2) } + } finally { + // Re-enable change detection on next microtask, after BlockNote + // finishes its internal state updates from the content swap + queueMicrotask(() => { suppressChangeRef.current = false }) } } @@ -532,6 +575,7 @@ export const Editor = memo(function Editor({ editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} + onChange={handleEditorChange} /> )} diff --git a/src/hooks/useAutoSave.test.ts b/src/hooks/useAutoSave.test.ts new file mode 100644 index 00000000..a3f672de --- /dev/null +++ b/src/hooks/useAutoSave.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useAutoSave } from './useAutoSave' + +const mockInvokeFn = vi.fn(() => Promise.resolve(null)) + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), +})) + +vi.mock('../mock-tauri', () => ({ + isTauri: () => false, + mockInvoke: (cmd: string, args?: Record) => mockInvokeFn(cmd, args), + updateMockContent: vi.fn(), +})) + +describe('useAutoSave', () => { + let updateContent: ReturnType + + beforeEach(() => { + vi.useFakeTimers() + updateContent = vi.fn() + mockInvokeFn.mockClear() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('debounces save calls by 500ms', async () => { + const { result } = renderHook(() => useAutoSave(updateContent)) + + act(() => { + result.current.debouncedSave('/test/note.md', 'content v1') + }) + + // Not saved yet + expect(mockInvokeFn).not.toHaveBeenCalled() + expect(updateContent).not.toHaveBeenCalled() + + // Advance 300ms — still not saved + act(() => { vi.advanceTimersByTime(300) }) + expect(mockInvokeFn).not.toHaveBeenCalled() + + // Advance to 500ms — now saved + await act(async () => { vi.advanceTimersByTime(200) }) + + expect(mockInvokeFn).toHaveBeenCalledWith('save_note_content', { + path: '/test/note.md', + content: 'content v1', + }) + expect(updateContent).toHaveBeenCalledWith('/test/note.md', 'content v1') + }) + + it('only saves the latest content when debounce resets', async () => { + const { result } = renderHook(() => useAutoSave(updateContent)) + + act(() => { + result.current.debouncedSave('/test/note.md', 'version 1') + }) + act(() => { vi.advanceTimersByTime(200) }) + + // Second call resets the timer + act(() => { + result.current.debouncedSave('/test/note.md', 'version 2') + }) + + // Advance past original deadline + act(() => { vi.advanceTimersByTime(300) }) + expect(mockInvokeFn).not.toHaveBeenCalled() + + // Advance to new deadline (200 + 500 = 700ms from second call) + await act(async () => { vi.advanceTimersByTime(200) }) + + expect(mockInvokeFn).toHaveBeenCalledTimes(1) + expect(mockInvokeFn).toHaveBeenCalledWith('save_note_content', { + path: '/test/note.md', + content: 'version 2', + }) + }) + + it('flush() immediately saves pending content', async () => { + const { result } = renderHook(() => useAutoSave(updateContent)) + + act(() => { + result.current.debouncedSave('/test/note.md', 'flush me') + }) + + expect(mockInvokeFn).not.toHaveBeenCalled() + + // Flush before debounce fires + await act(async () => { + result.current.flush() + }) + + expect(mockInvokeFn).toHaveBeenCalledWith('save_note_content', { + path: '/test/note.md', + content: 'flush me', + }) + }) + + it('saves multiple paths when switching notes rapidly', async () => { + const { result } = renderHook(() => useAutoSave(updateContent)) + + act(() => { + result.current.debouncedSave('/test/note-a.md', 'content A') + result.current.debouncedSave('/test/note-b.md', 'content B') + }) + + await act(async () => { vi.advanceTimersByTime(500) }) + + expect(mockInvokeFn).toHaveBeenCalledTimes(2) + expect(mockInvokeFn).toHaveBeenCalledWith('save_note_content', { + path: '/test/note-a.md', + content: 'content A', + }) + expect(mockInvokeFn).toHaveBeenCalledWith('save_note_content', { + path: '/test/note-b.md', + content: 'content B', + }) + }) + + it('handles save errors gracefully without crashing', async () => { + mockInvokeFn.mockRejectedValueOnce(new Error('File is read-only')) + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { result } = renderHook(() => useAutoSave(updateContent)) + + act(() => { + result.current.debouncedSave('/test/readonly.md', 'content') + }) + + await act(async () => { vi.advanceTimersByTime(500) }) + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Auto-save failed'), + expect.any(Error), + ) + consoleSpy.mockRestore() + }) + + it('does nothing when flush is called with no pending saves', async () => { + const { result } = renderHook(() => useAutoSave(updateContent)) + + await act(async () => { + result.current.flush() + }) + + expect(mockInvokeFn).not.toHaveBeenCalled() + expect(updateContent).not.toHaveBeenCalled() + }) +}) diff --git a/src/hooks/useAutoSave.ts b/src/hooks/useAutoSave.ts new file mode 100644 index 00000000..8bda0c42 --- /dev/null +++ b/src/hooks/useAutoSave.ts @@ -0,0 +1,67 @@ +import { useCallback, useRef } from 'react' +import { invoke } from '@tauri-apps/api/core' +import { isTauri, mockInvoke, updateMockContent } from '../mock-tauri' + +const DEBOUNCE_MS = 500 + +async function persistContent(path: string, content: string): Promise { + if (isTauri()) { + await invoke('save_note_content', { path, content }) + } else { + await mockInvoke('save_note_content', { path, content }) + } +} + +/** + * Hook that provides a debounced auto-save function for note content. + * Calls the Tauri backend (or mock) to persist the full markdown (frontmatter + body) + * after 500ms of inactivity. + * + * @param updateContent - callback to also update in-memory allContent state + */ +export function useAutoSave(updateContent: (path: string, content: string) => void) { + const timerRef = useRef | null>(null) + // Track the latest pending content per path so a flush always saves the most recent version + const pendingRef = useRef>(new Map()) + + const save = useCallback(async (path: string, content: string) => { + try { + await persistContent(path, content) + if (!isTauri()) { + updateMockContent(path, content) + } + updateContent(path, content) + } catch (err) { + console.error(`Auto-save failed for ${path}:`, err) + } + }, [updateContent]) + + const debouncedSave = useCallback((path: string, content: string) => { + pendingRef.current.set(path, content) + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => { + timerRef.current = null + // Save all pending paths (handles rapid note switching) + const pending = new Map(pendingRef.current) + pendingRef.current.clear() + for (const [p, c] of pending) { + save(p, c) + } + }, DEBOUNCE_MS) + }, [save]) + + /** Immediately flush any pending saves (call before closing a tab or switching notes) */ + const flush = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + const pending = new Map(pendingRef.current) + pendingRef.current.clear() + for (const [p, c] of pending) { + save(p, c) + } + }, [save]) + + return { debouncedSave, flush } +} diff --git a/src/mock-tauri.ts b/src/mock-tauri.ts index 003deb2a..4bcf3481 100644 --- a/src/mock-tauri.ts +++ b/src/mock-tauri.ts @@ -1681,6 +1681,13 @@ const mockHandlers: Record any> = { stop_reason: 'end_turn', } }, + save_note_content: (args: { path: string; content: string }) => { + MOCK_CONTENT[args.path] = args.content + if (typeof window !== 'undefined') { + window.__mockContent = MOCK_CONTENT + } + return null + }, save_image: (args: { vault_path?: string; filename: string; data: string }) => { // Return a plausible file path matching the real Rust backend behavior const vault = args.vault_path ?? '/Users/luca/Laputa' diff --git a/src/utils/wikilinks.test.ts b/src/utils/wikilinks.test.ts index 899ea953..b2cc16b8 100644 --- a/src/utils/wikilinks.test.ts +++ b/src/utils/wikilinks.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { preProcessWikilinks, injectWikilinks, splitFrontmatter, countWords } from './wikilinks' +import { preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks, splitFrontmatter, countWords } from './wikilinks' interface TestBlock { type?: string @@ -196,3 +196,89 @@ describe('countWords', () => { expect(countWords(content)).toBe(8) }) }) + +describe('restoreWikilinksInBlocks', () => { + it('converts wikilink nodes back to [[target]] text', () => { + const blocks = [{ + content: [ + { type: 'text', text: 'See ' }, + { type: 'wikilink', props: { target: 'My Note' }, content: undefined }, + { type: 'text', text: ' for details' }, + ], + }] + + const result = restoreWikilinksInBlocks(blocks) as TestBlock[] + expect(result[0].content).toHaveLength(3) + expect(result[0].content![0]).toEqual({ type: 'text', text: 'See ' }) + expect(result[0].content![1]).toEqual({ type: 'text', text: '[[My Note]]' }) + expect(result[0].content![2]).toEqual({ type: 'text', text: ' for details' }) + }) + + it('handles multiple wikilinks in one block', () => { + const blocks = [{ + content: [ + { type: 'wikilink', props: { target: 'A' }, content: undefined }, + { type: 'text', text: ' and ' }, + { type: 'wikilink', props: { target: 'B' }, content: undefined }, + ], + }] + + const result = restoreWikilinksInBlocks(blocks) as TestBlock[] + expect(result[0].content![0]).toEqual({ type: 'text', text: '[[A]]' }) + expect(result[0].content![1]).toEqual({ type: 'text', text: ' and ' }) + expect(result[0].content![2]).toEqual({ type: 'text', text: '[[B]]' }) + }) + + it('recursively processes children blocks', () => { + const blocks = [{ + content: [], + children: [{ + content: [ + { type: 'wikilink', props: { target: 'Nested' }, content: undefined }, + ], + }], + }] + + const result = restoreWikilinksInBlocks(blocks) as TestBlock[] + expect(result[0].children![0].content![0]).toEqual({ type: 'text', text: '[[Nested]]' }) + }) + + it('passes through non-wikilink content unchanged', () => { + const blocks = [{ + content: [ + { type: 'text', text: 'plain text' }, + { type: 'link', text: 'a link', href: 'http://example.com' }, + ], + }] + + const result = restoreWikilinksInBlocks(blocks) as TestBlock[] + expect(result[0].content![0]).toEqual({ type: 'text', text: 'plain text' }) + expect(result[0].content![1]).toEqual({ type: 'link', text: 'a link', href: 'http://example.com' }) + }) + + it('handles blocks without content', () => { + const blocks = [{ type: 'heading', props: { level: 1 } }] + const result = restoreWikilinksInBlocks(blocks as unknown[]) as TestBlock[] + expect(result[0].type).toBe('heading') + }) + + it('is the inverse of injectWikilinks for simple cases', () => { + const WL_START = '\u2039WIKILINK:' + const WL_END = '\u203A' + + // Start with placeholder text + const blocks = [{ + content: [ + { type: 'text', text: `before ${WL_START}Target${WL_END} after` }, + ], + }] + + // inject → restore should produce [[Target]] text + const injected = injectWikilinks(blocks) as TestBlock[] + const restored = restoreWikilinksInBlocks(injected) as TestBlock[] + + // Find the text that was the wikilink + const texts = restored[0].content!.map(n => n.text).join('') + expect(texts).toContain('[[Target]]') + }) +}) diff --git a/src/utils/wikilinks.ts b/src/utils/wikilinks.ts index 3d656159..dbbc25fc 100644 --- a/src/utils/wikilinks.ts +++ b/src/utils/wikilinks.ts @@ -23,17 +23,34 @@ interface InlineItem { [key: string]: unknown } +type ContentTransform = (content: InlineItem[]) => InlineItem[] + +/** Walk blocks recursively, applying a transform to each block's inline content */ +function walkBlocks(blocks: unknown[], transform: ContentTransform, clone = false): unknown[] { + return (blocks as BlockLike[]).map(block => { + const b = clone ? { ...block } : block + if (b.content && Array.isArray(b.content)) { + b.content = transform(b.content) + } + if (b.children && Array.isArray(b.children)) { + b.children = walkBlocks(b.children, transform, clone) as BlockLike[] + } + return b + }) +} + /** Walk blocks and replace placeholder text with wikilink inline content */ export function injectWikilinks(blocks: unknown[]): unknown[] { - return (blocks as BlockLike[]).map(block => { - if (block.content && Array.isArray(block.content)) { - block.content = expandWikilinksInContent(block.content) - } - if (block.children && Array.isArray(block.children)) { - block.children = injectWikilinks(block.children) as BlockLike[] - } - return block - }) + return walkBlocks(blocks, expandWikilinksInContent) +} + +/** + * Deep-clone blocks and convert wikilink inline content back to [[target]] text. + * This is the reverse of injectWikilinks — used before blocksToMarkdownLossy + * so that wikilinks survive the markdown round-trip. + */ +export function restoreWikilinksInBlocks(blocks: unknown[]): unknown[] { + return walkBlocks(blocks, collapseWikilinksInContent, true) } function expandWikilinksInContent(content: InlineItem[]): InlineItem[] { @@ -65,6 +82,18 @@ function expandWikilinksInContent(content: InlineItem[]): InlineItem[] { return result } +function collapseWikilinksInContent(content: InlineItem[]): InlineItem[] { + const result: InlineItem[] = [] + for (const item of content) { + if (item.type === 'wikilink' && item.props?.target) { + result.push({ type: 'text', text: `[[${item.props.target}]]` }) + } else { + result.push(item) + } + } + return result +} + /** Strip YAML frontmatter from markdown, returning [frontmatter, body] */ export function splitFrontmatter(content: string): [string, string] { if (!content.startsWith('---')) return ['', content]