From 608ba2f21b9eaac9a6c49647bb387e6ad219027f Mon Sep 17 00:00:00 2001 From: PJ Date: Mon, 22 Jun 2026 13:32:33 -0400 Subject: [PATCH] fix(persist): schema-version guard and editor content check A book file's declared schema was never read, so a file from a newer app version would be opened, normalized and autosaved over in the old format. Refuse to open books whose schema is newer than supported. Enable the editor content check so chapters with unreadable content warn the user instead of silently loading stripped content that the next edit would persist. Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk --- src/components/EditorView.tsx | 3 +++ src/editor/Editor.tsx | 7 ++++++- src/library.ts | 9 +++++++-- src/model/book.ts | 8 ++++++++ 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/components/EditorView.tsx b/src/components/EditorView.tsx index e98d81b..ec03970 100644 --- a/src/components/EditorView.tsx +++ b/src/components/EditorView.tsx @@ -274,6 +274,9 @@ export function EditorView() { content={chapter.content} onChange={(content) => setChapterContent(chapter.id, content)} onReady={setEditor} + onContentError={() => + setNotice("This chapter contains content Margin couldn't fully read; editing may drop the unrecognized parts.") + } /> diff --git a/src/editor/Editor.tsx b/src/editor/Editor.tsx index 972f96f..0a03738 100644 --- a/src/editor/Editor.tsx +++ b/src/editor/Editor.tsx @@ -10,11 +10,14 @@ interface EditorProps { content: JSONContent; onChange: (content: JSONContent) => void; onReady: (editor: TiptapEditor | null) => void; + onContentError: (error: Error) => void; } -export function Editor({ bookId, chapterId, content, onChange, onReady }: EditorProps) { +export function Editor({ bookId, chapterId, content, onChange, onReady, onContentError }: EditorProps) { const onChangeRef = useRef(onChange); onChangeRef.current = onChange; + const onContentErrorRef = useRef(onContentError); + onContentErrorRef.current = onContentError; const latest = useRef(null); @@ -22,7 +25,9 @@ export function Editor({ bookId, chapterId, content, onChange, onReady }: Editor extensions: editorExtensions, content, immediatelyRender: false, + enableContentCheck: true, editorProps: { attributes: { class: "prose" } }, + onContentError: ({ error }) => onContentErrorRef.current(error), onUpdate: ({ editor }) => onChangeRef.current(editor.getJSON()), }); diff --git a/src/library.ts b/src/library.ts index cdd84ef..e3a145e 100644 --- a/src/library.ts +++ b/src/library.ts @@ -1,6 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; import { isDesktop } from "./ipc"; -import { type Book, createBook, starterBook } from "./model/book"; +import { type Book, SCHEMA_VERSION, createBook, schemaVersion, starterBook } from "./model/book"; export interface BookSummary { id: string; @@ -16,11 +16,16 @@ export async function listBooks(): Promise { export async function loadBook(id: string): Promise { const contents = await invoke("load_book", { id }); + let book: Book; try { - return JSON.parse(contents) as Book; + book = JSON.parse(contents) as Book; } catch { throw new Error("the book file is corrupt or unreadable"); } + if (schemaVersion(book.schema) > SCHEMA_VERSION) { + throw new Error("this book was made with a newer version of Margin; update the app to open it"); + } + return book; } export async function saveBook(book: Book): Promise { diff --git a/src/model/book.ts b/src/model/book.ts index dc8017e..4e78be3 100644 --- a/src/model/book.ts +++ b/src/model/book.ts @@ -115,6 +115,14 @@ export interface Book { chapters: Chapter[]; } +export const SCHEMA_VERSION = 1; + +export function schemaVersion(schema: unknown): number { + if (typeof schema !== "string") return 0; + const match = /^margin\/(\d+)$/.exec(schema); + return match ? Number(match[1]) : 0; +} + function paragraph(text: string): JSONContent { return { type: "paragraph", content: [{ type: "text", text }] }; }