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:
lucaronin
2026-02-23 14:48:21 +01:00
parent 1e19f226d4
commit 180ade7734
9 changed files with 518 additions and 12 deletions

View File

@@ -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]]')
})
})

View File

@@ -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]