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
This commit is contained in:
pj committed 2026-06-22 13:32:33 -04:00
1 parent b63951f814
commit 608ba2f21b
4 files changed
+24 -3

No files matched your search

+3
View File
@@ -274,6 +274,9 @@ export function EditorView() {
content={chapter.content} content={chapter.content}
onChange={(content) => setChapterContent(chapter.id, content)} onChange={(content) => setChapterContent(chapter.id, content)}
onReady={setEditor} onReady={setEditor}
onContentError={() =>
setNotice("This chapter contains content Margin couldn't fully read; editing may drop the unrecognized parts.")
}
/> />
</article> </article>
<FloatingToolbar editor={editor} /> <FloatingToolbar editor={editor} />
+6 -1
View File
@@ -10,11 +10,14 @@ interface EditorProps {
content: JSONContent; content: JSONContent;
onChange: (content: JSONContent) => void; onChange: (content: JSONContent) => void;
onReady: (editor: TiptapEditor | null) => 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); const onChangeRef = useRef(onChange);
onChangeRef.current = onChange; onChangeRef.current = onChange;
const onContentErrorRef = useRef(onContentError);
onContentErrorRef.current = onContentError;
const latest = useRef<ChapterPosition | null>(null); const latest = useRef<ChapterPosition | null>(null);
@@ -22,7 +25,9 @@ export function Editor({ bookId, chapterId, content, onChange, onReady }: Editor
extensions: editorExtensions, extensions: editorExtensions,
content, content,
immediatelyRender: false, immediatelyRender: false,
enableContentCheck: true,
editorProps: { attributes: { class: "prose" } }, editorProps: { attributes: { class: "prose" } },
onContentError: ({ error }) => onContentErrorRef.current(error),
onUpdate: ({ editor }) => onChangeRef.current(editor.getJSON()), onUpdate: ({ editor }) => onChangeRef.current(editor.getJSON()),
}); });
+7 -2
View File
@@ -1,6 +1,6 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { isDesktop } from "./ipc"; 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 { export interface BookSummary {
id: string; id: string;
@@ -16,11 +16,16 @@ export async function listBooks(): Promise<BookSummary[]> {
export async function loadBook(id: string): Promise<Book> { export async function loadBook(id: string): Promise<Book> {
const contents = await invoke<string>("load_book", { id }); const contents = await invoke<string>("load_book", { id });
let book: Book;
try { try {
return JSON.parse(contents) as Book; book = JSON.parse(contents) as Book;
} catch { } catch {
throw new Error("the book file is corrupt or unreadable"); 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<void> { export async function saveBook(book: Book): Promise<void> {
+8
View File
@@ -115,6 +115,14 @@ export interface Book {
chapters: Chapter[]; 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 { function paragraph(text: string): JSONContent {
return { type: "paragraph", content: [{ type: "text", text }] }; return { type: "paragraph", content: [{ type: "text", text }] };
} }