fix: implement auto-save for editor content
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 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,11 @@ fn get_note_content(path: String) -> Result<String, String> {
|
||||
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,
|
||||
|
||||
@@ -561,6 +561,33 @@ pub fn get_note_content(path: &str) -> Result<String, String> {
|
||||
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<Vec<VaultEntry>, 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 ---
|
||||
|
||||
12
src/App.tsx
12
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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<typeof useCreateBlockNote>; entries: VaultEntry[]; onNavigateWikilink: (target: string) => void }) {
|
||||
function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { editor: ReturnType<typeof useCreateBlockNote>; 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
|
||||
<BlockNoteView
|
||||
editor={editor}
|
||||
theme="light"
|
||||
onChange={onChange}
|
||||
>
|
||||
<SuggestionMenuController
|
||||
triggerCharacter="[["
|
||||
@@ -201,6 +204,8 @@ export const Editor = memo(function Editor({
|
||||
onTrashNote, onRestoreNote,
|
||||
onArchiveNote, onUnarchiveNote,
|
||||
onRenameTab,
|
||||
onContentChange,
|
||||
onFlushSave,
|
||||
}: EditorProps) {
|
||||
const [diffMode, setDiffMode] = useState(false)
|
||||
const [diffContent, setDiffContent] = useState<string | null>(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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
152
src/hooks/useAutoSave.test.ts
Normal file
152
src/hooks/useAutoSave.test.ts
Normal file
@@ -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<string, unknown>) => mockInvokeFn(cmd, args),
|
||||
updateMockContent: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('useAutoSave', () => {
|
||||
let updateContent: ReturnType<typeof vi.fn>
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
67
src/hooks/useAutoSave.ts
Normal file
67
src/hooks/useAutoSave.ts
Normal file
@@ -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<void> {
|
||||
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<ReturnType<typeof setTimeout> | null>(null)
|
||||
// Track the latest pending content per path so a flush always saves the most recent version
|
||||
const pendingRef = useRef<Map<string, string>>(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 }
|
||||
}
|
||||
@@ -1681,6 +1681,13 @@ const mockHandlers: Record<string, (args: any) => 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'
|
||||
|
||||
@@ -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]]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user