feat: add persistent document handling and protect note drafts

Add HTTP PaddleOCR-VL settings and shared document previews for chat and knowledge imports. Preserve attachment originals and parsed assets across drafts, messages and queues; support explicit image OCR, selecting document images into a conversation draft, and restoring queued input for editing.

Protect unsaved note bodies and titles during navigation and asynchronous updates. Match canvas analysis to source revisions, invalidate affected comments, preserve annotations during pagination, and provide a resizable record index with saved width preferences.

Existing OCR settings retain local behavior. Historical attachments without saved originals are not reconstructed. Advanced OCR options depend on the configured service; image sending depends on the effective model and Runtime.

Validation: full typecheck and lint passed; 477 focused tests passed across 12 files; staged whitespace checks passed. The full repository suite was not rerun for this commit.

Release note: Keep note drafts safe while switching workspaces, resize the record index, and revisit saved document attachments with OCR previews and selected images in chat.
This commit is contained in:
mesalogo
2026-09-19 16:51:10 +08:00
parent 17186b0c11
commit 935a8f56bf
95 changed files with 6232 additions and 553 deletions
@@ -0,0 +1,96 @@
import Quill from 'quill'
import { createRoot } from 'react-dom/client'
import { MagicNotesWorkspace } from '../../src/renderer/src/MagicNotesWorkspace'
import type { DesktopApi } from '../../src/shared/contracts'
import type { MagicNoteDetail } from '../../src/shared/magic-notes-contracts'
import { magicNoteAnalyzeSchema } from '../../src/shared/magic-notes-contracts'
import i18n from '../../src/renderer/src/i18n'
import 'quill/dist/quill.snow.css'
import '../../src/renderer/src/styles.css'
import '../../src/renderer/src/magic-canvas.css'
async function notesNavigationRegression() {
await i18n.changeLanguage('en-US')
const note: MagicNoteDetail = {
id: '00000000-0000-4000-8000-000000000601', title: 'Navigation note', preview: '',
entryCount: 1, pinned: false, revision: 1, createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z', entries: [{
id: '00000000-0000-4000-8000-000000000602', noteId: '00000000-0000-4000-8000-000000000601',
content: { version: 1, ops: [{ insert: 'Saved entry\n' }] }, plainText: 'Saved entry',
comments: [], revision: 1, createdAt: '2026-08-01T00:00:00.000Z', updatedAt: '2026-08-01T00:00:00.000Z',
}],
}
let finishAnalysis!: (detail: MagicNoteDetail) => void
let requestLeave: ((leave: () => void) => void) | undefined
let leaves = 0
const errors: string[] = []
window.goodbuddy = {
magicNotes: {
list: async () => ({ notes: [note] }), get: async () => note,
listTodos: async () => ({ todos: [] }),
analyze: (...[entryId, options]: Parameters<DesktopApi['magicNotes']['analyze']>) => {
magicNoteAnalyzeSchema.parse({ entryId, ...options })
return new Promise(resolve => { finishAnalysis = resolve })
},
onChanged: () => () => {}, onAnalysisEvent: () => () => {},
},
updates: { getSettings: async () => ({ magicNoteCommentMode: 'after-save-manual', magicNoteCommentFormat: 'combined' }) },
} as unknown as DesktopApi
const host = document.createElement('div')
document.body.append(host)
const root = createRoot(host)
root.render(<MagicNotesWorkspace onBeforeLeave={requester => { requestLeave = requester }} onNotify={message => {
if (message.tone === 'error') errors.push(message.message)
}} />)
const wait = async (predicate: () => unknown) => {
for (let i = 0; i < 400; i++) {
if (predicate()) return
await new Promise(resolve => setTimeout(resolve, 25))
}
throw new Error('Timed out waiting for notes UI')
}
const button = (text: string) => [...host.querySelectorAll<HTMLButtonElement>('button')].find(item => item.textContent?.trim() === text)!
const frame = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
await wait(() => host.querySelector('[id^="magic-note-select-"]'))
host.querySelector<HTMLButtonElement>('[id^="magic-note-select-"]')!.click()
await wait(() => button('Analyze with AI'))
button('Analyze with AI').click()
await wait(() => finishAnalysis)
const title = host.querySelector<HTMLInputElement>('.magic-note-detail-header input')!
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(title, 'Title during AI')
title.dispatchEvent(new Event('input', { bubbles: true }))
await frame()
finishAnalysis({ ...note, revision: 2 })
await frame()
const titlePreserved = title.value === 'Title during AI'
requestLeave!(() => { leaves++ })
await wait(() => host.querySelector('[role="alertdialog"]'))
button('Continue editing').click()
await frame()
const continuedTitle = title.value
// Restore the title without saving, then exercise the actual canvas flush path.
title.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await frame()
button('Canvas').click()
await wait(() => host.querySelector<HTMLButtonElement>('[data-tool="flow-text"]')?.disabled === false)
host.querySelector<HTMLButtonElement>('[data-tool="flow-text"]')!.click()
await wait(() => host.querySelector('.canvas-flow-quill'))
await frame()
const quill = Quill.find(host.querySelector('.canvas-flow-quill')!) as Quill
quill.setText('Pending canvas flow\n', 'user')
requestLeave!(() => { leaves++ })
await wait(() => host.querySelector('[role="alertdialog"]'))
const canvasGuarded = leaves === 0
button('Continue editing').click()
await frame()
const canvasPreserved = quill.getText().includes('Pending canvas flow')
requestLeave!(() => { leaves++ })
await wait(() => host.querySelector('[role="alertdialog"]'))
button('Discard and switch').click()
await frame()
root.unmount()
host.remove()
return { errors, titlePreserved, continuedTitle, canvasGuarded, canvasPreserved, leaves }
}
Object.assign(window, { notesNavigationRegression })