diff --git a/client/src/components/Studio/SaveIndicator.tsx b/client/src/components/Studio/SaveIndicator.tsx
index 4f44c37ce..a2813fd53 100644
--- a/client/src/components/Studio/SaveIndicator.tsx
+++ b/client/src/components/Studio/SaveIndicator.tsx
@@ -15,13 +15,15 @@ import type { SaveState } from './useBookStore'
* which is exactly the wrong habit for the one time it says something else.
*/
export function SaveIndicator({
- state, t, onAcceptTheirs, onKeepMine, onRetry,
+ state, t, onAcceptTheirs, onKeepMine, onRetry, onReload = reloadStudio,
}: {
state: SaveState
t: (k: string) => string
onAcceptTheirs: (current: BookRecord) => void
onKeepMine: (current: BookRecord) => void
onRetry: () => void
+ /** Load this Studio again, for a book a newer one saved. A full reload by default. */
+ onReload?: () => void
}) {
if (state.status === 'conflict') {
return (
@@ -43,6 +45,24 @@ export function SaveIndicator({
)
}
+ if (state.status === 'newer') {
+ /*
+ * The one state with a way out the user has to take themselves: a newer
+ * Studio saved this book, this tab cannot write it without losing what
+ * that one added, and only a reload brings the newer Studio here. Loud
+ * like a conflict, because until then nothing done here is kept.
+ */
+ return (
+
+
+ {t('journey.studio.saveNewer')}
+
+
+ )
+ }
+
if (state.status === 'readonly') {
/*
* Not an error and not retryable: this person was invited to read the
@@ -86,3 +106,8 @@ export function SaveIndicator({
return null
}
+
+/** The default way back to a current Studio: the page again, which loads the newest build. */
+function reloadStudio() {
+ window.location.reload()
+}
diff --git a/client/src/components/Studio/SpreadView.tsx b/client/src/components/Studio/SpreadView.tsx
index fa3f4d8b9..132323891 100644
--- a/client/src/components/Studio/SpreadView.tsx
+++ b/client/src/components/Studio/SpreadView.tsx
@@ -260,7 +260,10 @@ function PhotoView({ el, big, print, dropLabel }: {
src={photoSrc(el.photoId!, big)}
alt=""
draggable={false}
- loading="lazy"
+ // Lazy on the canvas, where a long book scrolls past; eager in print,
+ // where every sheet is laid out at once and a picture the browser put
+ // off is a picture missing from the PDF.
+ loading={print ? 'eager' : 'lazy'}
style={{
width: '100%',
height: '100%',
diff --git a/client/src/components/Studio/printReady.test.ts b/client/src/components/Studio/printReady.test.ts
new file mode 100644
index 000000000..403a4a685
--- /dev/null
+++ b/client/src/components/Studio/printReady.test.ts
@@ -0,0 +1,75 @@
+/**
+ * FE-STUDIO-PRINT-001 to FE-STUDIO-PRINT-004
+ *
+ * When the print view of a book may be printed: every picture in, the SVG
+ * ones too wherever they sit (a map marker, a photo behind a clip path), and
+ * a ceiling that gives a book of many photographs longer before the Save
+ * button gives up waiting.
+ */
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { printReadyCeilingMs, svgImagesToWait, whenReady } from './printReady';
+
+const SVG = 'http://www.w3.org/2000/svg';
+const XLINK = 'http://www.w3.org/1999/xlink';
+
+/** A print document as the frame holds it, built from markup. */
+function printDoc(markup: string): Document {
+ const doc = document.implementation.createHTMLDocument('print');
+ doc.body.innerHTML = markup;
+ return doc;
+}
+
+/** An iframe whose document and window are the given ones, as the print view's frame is once it loaded. */
+function frameOf(doc: Document, win: Partial = {}): HTMLIFrameElement {
+ const iframe = document.createElement('iframe');
+ Object.defineProperty(iframe, 'contentDocument', { value: doc, configurable: true });
+ Object.defineProperty(iframe, 'contentWindow', { value: win, configurable: true });
+ return iframe;
+}
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe('the pictures to wait for', () => {
+ it('FE-STUDIO-PRINT-001: finds an SVG image inside a clip path and in the defs, not only a marker photo', () => {
+ const doc = printDoc(
+ ``
+ );
+ expect(svgImagesToWait(doc).map((el) => el.getAttribute('href'))).toEqual(['/a.jpg', '/b.jpg', '/c.jpg']);
+ });
+
+ it('FE-STUDIO-PRINT-002: takes an xlink source, and leaves out an image that names none and would never load', () => {
+ const doc = printDoc(``);
+ const linked = doc.createElementNS(SVG, 'image');
+ linked.setAttributeNS(XLINK, 'xlink:href', '/x.jpg');
+ doc.querySelector('svg')?.appendChild(linked);
+ expect(svgImagesToWait(doc)).toEqual([linked]);
+ });
+});
+
+describe('the ceiling', () => {
+ it('FE-STUDIO-PRINT-003: waits eight seconds for an ordinary book and fifteen for one of more than 40 photos', () => {
+ expect(printReadyCeilingMs(0)).toBe(8000);
+ expect(printReadyCeilingMs(40)).toBe(8000);
+ expect(printReadyCeilingMs(41)).toBe(15000);
+ });
+
+ it('FE-STUDIO-PRINT-004: holds a large book past eight seconds while an image is still loading', async () => {
+ vi.useFakeTimers();
+ const many = Array.from({ length: 41 }, (_, i) => ``).join('');
+ const doc = printDoc(``);
+ let ready = false;
+ // A window without its own timers: the wait falls back to the global one, which the fake timers drive.
+ void whenReady(frameOf(doc)).then(() => {
+ ready = true;
+ });
+
+ await vi.advanceTimersByTimeAsync(8100);
+ expect(ready).toBe(false);
+ await vi.advanceTimersByTimeAsync(7000);
+ expect(ready).toBe(true);
+ });
+});
diff --git a/client/src/components/Studio/printReady.ts b/client/src/components/Studio/printReady.ts
new file mode 100644
index 000000000..54dc29547
--- /dev/null
+++ b/client/src/components/Studio/printReady.ts
@@ -0,0 +1,77 @@
+/**
+ * When the print view of a book is actually printable.
+ *
+ * Both halves matter and neither is covered by the load event: a photograph
+ * still decoding prints as an empty box, and a face still loading prints in the
+ * fallback, the exact substitution the bundled typefaces exist to prevent.
+ */
+
+/** How long the print view waits at most, and the photo count past which a book gets the longer wait. */
+const PRINT_READY = {
+ ceilingMs: 8000,
+ largeCeilingMs: 15000,
+ largeBookPhotos: 40,
+} as const;
+
+/**
+ * The longest the Save button stays off for a book of `photos` pictures. A
+ * book of a few dozen photographs from a slow provider can take longer than
+ * eight seconds to decode; one that has not finished must not go to the press.
+ */
+export function printReadyCeilingMs(photos: number): number {
+ return photos > PRINT_READY.largeBookPhotos ? PRINT_READY.largeCeilingMs : PRINT_READY.ceilingMs;
+}
+
+/** What `whenReady` reads off the print document: its `` elements, its SVG and its fonts. */
+interface PrintDocument {
+ images: ArrayLike;
+ querySelectorAll?: (selector: string) => ArrayLike;
+ fonts?: { ready: Promise };
+}
+
+const XLINK = 'http://www.w3.org/1999/xlink';
+
+/**
+ * The SVG pictures to wait for: every `` that names a source, wherever
+ * it sits. `doc.images` does not collect them, and they are not only the
+ * photographs in a map's markers: a photo cut to a mask or set behind clipped
+ * type is an `` inside a `` or ``, which the selector
+ * reaches just the same. One that names no source would never fire an event,
+ * so it is left out rather than left to hold the button until the ceiling.
+ */
+export function svgImagesToWait(doc: PrintDocument): Element[] {
+ const found = Array.from(doc.querySelectorAll?.('image') ?? []);
+ return found.filter((el) => !!(el.getAttribute('href') || el.getAttributeNS(XLINK, 'href')));
+}
+
+/** Resolves when the element has loaded or failed: a picture that will not load must not hold the button hostage. */
+function settle(el: Element): Promise {
+ return new Promise((resolve) => {
+ el.addEventListener('load', () => resolve(), { once: true });
+ el.addEventListener('error', () => resolve(), { once: true });
+ });
+}
+
+/**
+ * Wait until the print view's pictures and faces are in, or until the
+ * ceiling, which rises for a book with many photographs. Without `complete`
+ * on an SVG image there is no way to tell one that is still coming from one
+ * whose events fired before this ran, and a Save button that never enables is
+ * worse than a marker that prints blank.
+ */
+export async function whenReady(iframe: HTMLIFrameElement): Promise {
+ const win = iframe.contentWindow;
+ const doc = iframe.contentDocument as PrintDocument | null;
+ if (!win || !doc) return;
+
+ const imgs = Array.from(doc.images);
+ const svg = svgImagesToWait(doc);
+ const images = imgs.map((img) => (img.complete ? Promise.resolve() : settle(img)));
+
+ const wait = win.setTimeout?.bind(win) ?? setTimeout;
+ const ceiling = new Promise((resolve) => {
+ wait(resolve, printReadyCeilingMs(imgs.length + svg.length));
+ });
+
+ await Promise.race([Promise.all([...images, ...svg.map(settle), doc.fonts?.ready ?? Promise.resolve()]), ceiling]);
+}
diff --git a/client/src/components/Studio/printSheets.ts b/client/src/components/Studio/printSheets.ts
index 099e3299b..f5ef2b745 100644
--- a/client/src/components/Studio/printSheets.ts
+++ b/client/src/components/Studio/printSheets.ts
@@ -23,6 +23,8 @@
* standalone PWA. An overlay with a `srcdoc` iframe works everywhere.
*/
+import { whenReady } from './printReady'
+
/*
* The only literal colours in Studio, and deliberately so.
*
@@ -254,47 +256,3 @@ ${singleRule(input)}
return close
}
-
-/**
- * When the document is actually printable.
- *
- * Both halves matter and neither is covered by the load event: a photograph
- * still decoding prints as an empty box, and a face still loading prints in the
- * fallback — the exact substitution the bundled typefaces exist to prevent.
- */
-async function whenReady(iframe: HTMLIFrameElement): Promise {
- const win = iframe.contentWindow
- const doc = iframe.contentDocument
- if (!win || !doc) return
-
- const settle = (el: Element) => new Promise(resolve => {
- // A picture that will not load must not hold the button hostage — better a
- // book with one gap than a dialog that never finishes.
- el.addEventListener('load', () => resolve(), { once: true })
- el.addEventListener('error', () => resolve(), { once: true })
- })
-
- const images = Array.from(doc.images).map(img => (img.complete ? Promise.resolve() : settle(img)))
-
- /*
- * The photographs inside the map's markers are SVG ``, which
- * `doc.images` does not collect and which has no `complete` to ask, so there
- * is nothing to do but wait on the events. Missing them meant a book could go
- * to print with its route beads still blank.
- */
- const svgImages = Array.from(doc.querySelectorAll?.('image') ?? []).map(settle)
-
- /*
- * And a ceiling over the lot. Without `complete` to fall back on there is no
- * way to tell a picture that is still coming from one whose events already
- * fired before this ran, and a Save button that never enables is worse than a
- * marker that prints blank.
- */
- const wait = win.setTimeout?.bind(win) ?? setTimeout
- const ceiling = new Promise(resolve => { wait(resolve, 8000) })
-
- await Promise.race([
- Promise.all([...images, ...svgImages, doc.fonts?.ready ?? Promise.resolve()]),
- ceiling,
- ])
-}
diff --git a/client/src/components/Studio/useBookStore.ts b/client/src/components/Studio/useBookStore.ts
index 50a364333..01d3ae56d 100644
--- a/client/src/components/Studio/useBookStore.ts
+++ b/client/src/components/Studio/useBookStore.ts
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { BookDocument, BookRecord } from '@trek/shared'
-import { normalizeBookDocument } from '@trek/shared'
+import { BOOK_SCHEMA_NEWER_CODE, readBookDocument, stampBookSchemaRev } from '@trek/shared'
import { journeyApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket'
@@ -54,6 +54,16 @@ import { addListener, removeListener } from '../../api/websocket'
* either: it is remembered and read once that work has reached the server,
* because the save response and the broadcast travel on different channels and
* either can arrive first.
+ *
+ * ── Why a book from a newer Studio is never written ───────────────────────
+ *
+ * A document says which revision of the format wrote it (book-schema-rev.ts).
+ * This build reads a newer one as far as it can, and what it cannot read is
+ * already gone from the copy it holds: saving that copy would delete the rest
+ * for everyone. So such a book is shown and never written, the same way a
+ * viewer's is, until a reload brings the Studio that wrote it. The server
+ * refuses the save as well, which covers a tab that loaded the book before the
+ * newer Studio saved it.
*/
/** How long the document has to stay still before it is written. */
@@ -69,6 +79,8 @@ export type SaveState =
| { status: 'conflict'; current: BookRecord }
/** The journey is open to this user, but they may not write it (role 'viewer'). */
| { status: 'readonly' }
+ /** The book was saved by a newer Studio than this one: shown, never written, until a reload. */
+ | { status: 'newer' }
| { status: 'error' }
/** What the editor needs to know about a version it is handed from elsewhere. */
@@ -176,7 +188,12 @@ export function useBookStore(
* agreed document by identity, so a consumer that normalised on its own
* would hand back a different object and turn every open into a save.
*/
- const book = res.book ? { ...res.book, document: normalizeBookDocument(res.book.document) } : null
+ const read = res.book ? readBookDocument(res.book.document) : null
+ const book = res.book && read ? { ...res.book, document: read.document } : null
+ if (read?.newer) {
+ blocked.current = true
+ setState({ status: 'newer' })
+ }
setRecord(book)
version.current = book?.version ?? null
bookId.current = book?.id ?? null
@@ -201,7 +218,8 @@ export function useBookStore(
try {
const saved = await journeyApi.saveBook(journeyId, {
title: next.title,
- document: next.document,
+ // Stamped on the way out only: `synced` below keeps the editor's own object.
+ document: stampBookSchemaRev(next.document),
baseVersion: version.current ?? undefined,
})
setRecord(saved)
@@ -217,8 +235,12 @@ export function useBookStore(
* not need a second request: the editor can offer to take their version
* or keep going from here, and either way it can say whose work it is.
*/
- const res = (err as { response?: { status?: number; data?: { current?: BookRecord } } }).response
- if (res?.status === 409 && res.data?.current) {
+ const res = (err as { response?: { status?: number; data?: { current?: BookRecord; code?: string } } }).response
+ if (res?.status === 409 && res.data?.code === BOOK_SCHEMA_NEWER_CODE) {
+ // A newer Studio saved the book since this tab read it: nothing here may be written over it.
+ blocked.current = true
+ setState({ status: 'newer' })
+ } else if (res?.status === 409 && res.data?.current) {
blocked.current = true
setState({ status: 'conflict', current: res.data.current })
} else if (res?.status === 403) {
@@ -305,15 +327,15 @@ export function useBookStore(
/** Take the other version, discarding the local one. */
const acceptTheirs = useCallback((current: BookRecord) => {
- const document = normalizeBookDocument(current.document)
- blocked.current = false
+ const { document, newer } = readBookDocument(current.document)
+ blocked.current = newer
setRecord({ ...current, document })
version.current = current.version
bookId.current = current.id
synced.current = document
latest.current = document
pending.current = null
- setState({ status: 'idle' })
+ setState(newer ? { status: 'newer' } : { status: 'idle' })
// A save from elsewhere passed over while the conflict was open may be
// newer than the record it carried.
catchUp.current()
@@ -388,8 +410,12 @@ export function useBookStore(
if (sameBook && !isNewer(res.book.version)) return
// Normalised for the same reason the initial load is: a document
// written by an older build is missing whatever the contract has
- // gained since.
- const document = normalizeBookDocument(res.book.document)
+ // gained since. One written by a newer build is shown and held.
+ const { document, newer } = readBookDocument(res.book.document)
+ if (newer) {
+ blocked.current = true
+ setState({ status: 'newer' })
+ }
const held = version.current
const taken = res.book.version
// Undoable only when every save it folds in, past the version held,
diff --git a/client/src/pages/journeyStudio/useJourneyStudio.test.tsx b/client/src/pages/journeyStudio/useJourneyStudio.test.tsx
index a4ae91a96..634247151 100644
--- a/client/src/pages/journeyStudio/useJourneyStudio.test.tsx
+++ b/client/src/pages/journeyStudio/useJourneyStudio.test.tsx
@@ -68,7 +68,7 @@ function seedJourney() {
beforeEach(() => {
seedJourney()
buildBook.mockClear()
- useStudioStore.setState({ doc: null, selection: [], activeSpread: 0, past: [], future: [] })
+ useStudioStore.setState({ doc: null, selection: [], activeSpread: 0, past: [], future: [], readOnly: false })
vi.spyOn(journeyApi, 'getBook').mockResolvedValue({ book: null } as never)
vi.spyOn(journeyApi, 'saveBook').mockResolvedValue({} as never)
vi.spyOn(journeyApi, 'stats').mockResolvedValue(stats() as never)
@@ -295,6 +295,31 @@ describe('pictures and stops', () => {
expect(result.current.canEdit).toBe(false)
})
+
+ /*
+ * A book a newer Studio saved has already lost, in this copy, what this build
+ * cannot read. It is shown and never changed, for every role, and let go once
+ * Studio closes so the next book opens as it should.
+ */
+ it('FE-STUDIO-REV-001: holds a book a newer Studio saved read-only until Studio closes', async () => {
+ const document = { ...shared.blankBook({ title: 'Iceland' }), schemaRev: shared.BOOK_SCHEMA_REV + 1 }
+ vi.mocked(journeyApi.getBook).mockResolvedValue({
+ book: { id: 3, journeyId: 9, title: 'Iceland', version: 4, updatedAt: '', updatedBy: 1, document },
+ } as never)
+ const { result, unmount } = await mountStudio()
+
+ await waitFor(() => expect(result.current.saveState.status).toBe('newer'))
+ expect(result.current.canEdit).toBe(false)
+ expect(result.current.canUndo).toBe(false)
+ expect(useStudioStore.getState().readOnly).toBe(true)
+ const before = useStudioStore.getState().doc
+ act(() => { useStudioStore.getState().commit(d => ({ ...d, title: 'edited' })) })
+ expect(useStudioStore.getState().doc).toBe(before)
+ expect(journeyApi.saveBook).not.toHaveBeenCalled()
+
+ unmount()
+ expect(useStudioStore.getState().readOnly).toBe(false)
+ })
})
/**
@@ -355,7 +380,7 @@ describe('saves from elsewhere', () => {
const { ok } = await openAndFlush()
expect(ok).toBe(true)
const calls = vi.mocked(journeyApi.saveBook).mock.calls
- expect(calls[calls.length - 1][1].document.title).toBe('edited')
+ expect((calls[calls.length - 1][1].document as { title?: string }).title).toBe('edited')
})
it('FE-STUDIO-WIRE-002: a save of this user keeps the page by its id and is one undo away', async () => {
diff --git a/client/src/pages/journeyStudio/useJourneyStudio.ts b/client/src/pages/journeyStudio/useJourneyStudio.ts
index f720b10c0..c2cc618a0 100644
--- a/client/src/pages/journeyStudio/useJourneyStudio.ts
+++ b/client/src/pages/journeyStudio/useJourneyStudio.ts
@@ -78,6 +78,7 @@ export function useJourneyStudio() {
const past = useStudioStore(s => s.past)
const future = useStudioStore(s => s.future)
const commit = useStudioStore(s => s.commit)
+ const setReadOnly = useStudioStore(s => s.setReadOnly)
const journeyId = Number(id)
const backTo = `/journey/${id}`
@@ -117,6 +118,21 @@ export function useJourneyStudio() {
// every render — depending on that made the save effect fire per render.
const { queueSave, saveNow, loaded: bookLoaded } = book
+ /*
+ * A book a newer Studio saved is shown and never changed here.
+ *
+ * This build read it as far as it could, and what it could not read is gone
+ * from its copy, so an edit made now could only ever be saved by deleting
+ * the rest. The store refuses edits while this holds and the controls that
+ * add to the book go with `canEdit`; the save indicator asks for a reload.
+ * Released when Studio closes, so the next book opens as it should.
+ */
+ const newerBook = book.state.status === 'newer'
+ useEffect(() => {
+ setReadOnly(newerBook)
+ return () => setReadOnly(false)
+ }, [newerBook, setReadOnly])
+
/*
* Who else is in here, and where their pointers are.
*
@@ -595,9 +611,10 @@ export function useJourneyStudio() {
/*
* A viewer may open the book and change nothing: not the pages, not the
* journey behind them. The server refuses either anyway; this keeps the
- * controls that would only ever answer with a 403 off the screen.
+ * controls that would only ever answer with a 403 off the screen. A book a
+ * newer Studio saved is held the same way, for everyone (see `newerBook`).
*/
- const canEdit = (journey?.my_role ?? 'owner') !== 'viewer'
+ const canEdit = (journey?.my_role ?? 'owner') !== 'viewer' && !newerBook
const toast = useToast()
/**
@@ -798,8 +815,8 @@ export function useJourneyStudio() {
undo,
redo,
- canUndo: past.length > 0,
- canRedo: future.length > 0,
+ canUndo: !newerBook && past.length > 0,
+ canRedo: !newerBook && future.length > 0,
pxPerMm: PX_PER_MM,
}
diff --git a/client/src/store/studioStore.readOnly.test.ts b/client/src/store/studioStore.readOnly.test.ts
new file mode 100644
index 000000000..db8e97778
--- /dev/null
+++ b/client/src/store/studioStore.readOnly.test.ts
@@ -0,0 +1,59 @@
+/**
+ * FE-STUDIO-READONLY-001 to FE-STUDIO-READONLY-002
+ *
+ * A book held as it is (one a newer Studio saved): every edit, undo and redo
+ * is refused while it holds, and a version from elsewhere is still taken, so
+ * the page on screen stays current.
+ */
+import { blankBook, type BookDocument } from '@trek/shared';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+
+import { useStudioStore } from './studioStore';
+
+const STUDIO = useStudioStore.getState();
+
+function book(title = 'Lisbon'): BookDocument {
+ let seq = 0;
+ return blankBook({ title, newId: (prefix) => `${prefix}-${++seq}` });
+}
+
+beforeEach(() => {
+ useStudioStore.setState({ ...STUDIO, doc: null, past: [], future: [], readOnly: false });
+});
+
+afterEach(() => {
+ useStudioStore.getState().setReadOnly(false);
+});
+
+describe('a book held as it is', () => {
+ it('FE-STUDIO-READONLY-001: refuses edits, undo and redo while held, and takes them again once let go', () => {
+ const store = useStudioStore.getState();
+ store.load(book());
+ store.commit((d) => ({ ...d, title: 'one' }));
+ const edited = useStudioStore.getState().doc;
+
+ store.setReadOnly(true);
+ store.commit((d) => ({ ...d, title: 'two' }));
+ store.apply((d) => ({ ...d, title: 'three' }));
+ store.undo();
+ expect(useStudioStore.getState().doc).toBe(edited);
+ expect(useStudioStore.getState().canUndo()).toBe(false);
+
+ store.setReadOnly(false);
+ expect(useStudioStore.getState().canUndo()).toBe(true);
+ store.undo();
+ expect(useStudioStore.getState().doc?.title).toBe('Lisbon');
+ expect(useStudioStore.getState().canRedo()).toBe(true);
+ });
+
+ it('FE-STUDIO-READONLY-002: still takes a version saved elsewhere, so the page shown stays current', () => {
+ const store = useStudioStore.getState();
+ store.load(book());
+ store.setReadOnly(true);
+ const theirs = book('Porto');
+ store.adoptRemote(theirs, { undoable: false });
+ expect(useStudioStore.getState().doc).toBe(theirs);
+ store.load(book('Faro'));
+ expect(useStudioStore.getState().doc?.title).toBe('Faro');
+ });
+});
diff --git a/client/src/store/studioStore.ts b/client/src/store/studioStore.ts
index 65d6bbdeb..dc26774b8 100644
--- a/client/src/store/studioStore.ts
+++ b/client/src/store/studioStore.ts
@@ -37,6 +37,15 @@ interface StudioState {
* 4.2). Not part of the book or its history; opening a book clears it.
*/
hintSpread: string | null
+ /**
+ * Whether the open book is held as it is. True for a book a newer Studio
+ * saved (useBookStore's `newer`): this build has already dropped what
+ * it cannot read from its copy, so every edit, undo and redo is refused
+ * rather than shown as work that can never be saved. Loading and taking a
+ * remote version still work, so the page stays current while it is shown.
+ */
+ readOnly: boolean
+ setReadOnly: (readOnly: boolean) => void
load: (doc: BookDocument) => void
/**
@@ -135,7 +144,9 @@ export const useStudioStore = create((set, get) => ({
gestureBase: null,
clipboard: null,
hintSpread: null,
+ readOnly: false,
+ setReadOnly: readOnly => set({ readOnly, gestureBase: null }),
load: doc => set({ doc, selection: [], activeSpread: 0, past: [], future: [], gestureBase: null, hintSpread: null }),
adoptRemote: (doc, opts) => set(s => {
if (!s.doc) return { doc, selection: [], activeSpread: 0, past: [], future: [], gestureBase: null }
@@ -180,13 +191,13 @@ export const useStudioStore = create((set, get) => ({
}),
commit: fn => set(s => {
- if (!s.doc) return {}
+ if (!s.doc || s.readOnly) return {}
const next = fn(s.doc)
if (next === s.doc) return {}
return { doc: next, past: [...s.past, s.doc].slice(-HISTORY_LIMIT), future: [] }
}),
- apply: fn => set(s => (s.doc ? { doc: fn(s.doc) } : {})),
+ apply: fn => set(s => (s.doc && !s.readOnly ? { doc: fn(s.doc) } : {})),
updateElement: (spreadIndex, id, patch) => get().apply(doc =>
replaceSpread(doc, spreadIndex, sp => ({
@@ -420,16 +431,16 @@ export const useStudioStore = create((set, get) => ({
undo: () => set(s => {
const prev = s.past[s.past.length - 1]
- if (!prev || !s.doc) return {}
+ if (!prev || !s.doc || s.readOnly) return {}
return { doc: prev, past: s.past.slice(0, -1), future: [s.doc, ...s.future].slice(0, HISTORY_LIMIT), selection: [] }
}),
redo: () => set(s => {
const next = s.future[0]
- if (!next || !s.doc) return {}
+ if (!next || !s.doc || s.readOnly) return {}
return { doc: next, future: s.future.slice(1), past: [...s.past, s.doc].slice(-HISTORY_LIMIT), selection: [] }
}),
- canUndo: () => get().past.length > 0,
- canRedo: () => get().future.length > 0,
+ canUndo: () => !get().readOnly && get().past.length > 0,
+ canRedo: () => !get().readOnly && get().future.length > 0,
}))
diff --git a/client/tests/unit/studio/SaveIndicator.test.tsx b/client/tests/unit/studio/SaveIndicator.test.tsx
index 477dbbebe..86775829a 100644
--- a/client/tests/unit/studio/SaveIndicator.test.tsx
+++ b/client/tests/unit/studio/SaveIndicator.test.tsx
@@ -99,3 +99,19 @@ describe('a conflict', () => {
expect(screen.getByRole('alert')).toBeTruthy()
})
})
+
+describe('a book a newer Studio saved', () => {
+ /*
+ * Nothing done in this tab is kept until it reloads, so the state is loud
+ * like a conflict and offers the one way out there is.
+ */
+ it('is announced, says why, and reloads on request', () => {
+ const onReload = vi.fn()
+ draw({ status: 'newer' }, { onReload })
+
+ expect(screen.getByRole('alert')).toBeTruthy()
+ expect(screen.getByText('journey.studio.saveNewer')).toBeTruthy()
+ fireEvent.click(screen.getByText('journey.studio.saveReload'))
+ expect(onReload).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/client/tests/unit/studio/SpreadView.test.tsx b/client/tests/unit/studio/SpreadView.test.tsx
index 1264332de..9396ce15e 100644
--- a/client/tests/unit/studio/SpreadView.test.tsx
+++ b/client/tests/unit/studio/SpreadView.test.tsx
@@ -121,6 +121,17 @@ describe('photo frames', () => {
expect(container.querySelector('img')).not.toBeNull()
})
+ /*
+ * The print view lays every sheet out at once; a picture the browser put off
+ * until it scrolled into view is a picture missing from the PDF.
+ */
+ it('loads a photo lazily on the canvas and at once in print', () => {
+ const { container: canvas } = draw([photo()])
+ expect(canvas.querySelector('img')?.getAttribute('loading')).toBe('lazy')
+ const { container: printing } = draw([photo()], { print: true })
+ expect(printing.querySelector('img')?.getAttribute('loading')).toBe('eager')
+ })
+
it('cuts a masked photo with an objectBoundingBox clip, so it follows a resize', () => {
const { container } = draw([photo({ mask: 'heart' })])
const clip = container.querySelector('clipPath')!
diff --git a/client/tests/unit/studio/useBookStore.test.tsx b/client/tests/unit/studio/useBookStore.test.tsx
index 1b0419dd0..bb6d4947b 100644
--- a/client/tests/unit/studio/useBookStore.test.tsx
+++ b/client/tests/unit/studio/useBookStore.test.tsx
@@ -776,3 +776,88 @@ describe('unmounting', () => {
expect(api.saveBook).not.toHaveBeenCalled()
})
})
+
+// -- A book a newer Studio saved ----------------------------------------------
+
+/*
+ * This build reads such a book as far as it can, and what it cannot read is
+ * already gone from its copy. Showing it is fine; writing it would delete the
+ * rest for everyone, so nothing is written until a reload.
+ */
+describe('a book a newer Studio saved', () => {
+ const newer = (title = 'newer') => ({ ...doc(title), schemaRev: 99 }) as unknown as BookDocument
+
+ /** A 409 for a book a newer Studio saved since this tab read it. */
+ function newerError() {
+ return Object.assign(new Error('Request failed'), {
+ response: { status: 409, data: { error: 'Book was saved by a newer version of TREK Studio', code: 'book_schema_newer', schemaRev: 2 } },
+ })
+ }
+
+ it('FE-STUDIO-REV-002: opens it, says so, and writes nothing, not even on flush', async () => {
+ api.getBook.mockResolvedValue({ book: record({ version: 5, document: newer() }) })
+ const { result } = await mount()
+
+ expect(result.current.state.status).toBe('newer')
+ expect(result.current.record?.document.title).toBe('newer')
+ act(() => { result.current.queueSave(doc('edited'), 'T') })
+ await tick()
+ let ok: boolean | undefined
+ await act(async () => { ok = await result.current.flush() })
+
+ expect(ok).toBe(false)
+ expect(api.saveBook).not.toHaveBeenCalled()
+ })
+
+ it('FE-STUDIO-REV-003: stops on the server refusing a save over a newer book, and does not try again', async () => {
+ api.saveBook.mockRejectedValue(newerError())
+ const { result } = await mount()
+
+ act(() => { result.current.queueSave(doc('a'), 'T') })
+ await tick()
+ expect(result.current.state.status).toBe('newer')
+
+ act(() => { result.current.queueSave(doc('b'), 'T') })
+ await tick()
+ expect(api.saveBook).toHaveBeenCalledTimes(1)
+ })
+
+ it('FE-STUDIO-REV-004: shows a newer version another editor saved, and holds it the same way', async () => {
+ api.getBook.mockResolvedValue({ book: record({ version: 4, document: doc('ours') }) })
+ const onRemote = vi.fn()
+ const { result } = renderHook(() => useBookStore(9, onRemote))
+ await tick(0)
+ api.getBook.mockResolvedValue({ book: record({ version: 6, document: newer('theirs') }) })
+
+ await remoteSave(6)
+
+ expect(onRemote.mock.calls[0][0].title).toBe('theirs')
+ expect(result.current.state.status).toBe('newer')
+ act(() => { result.current.queueSave(doc('mine'), 'T') })
+ await tick()
+ expect(api.saveBook).not.toHaveBeenCalled()
+ })
+
+ it('FE-STUDIO-REV-005: taking their side of a conflict holds the book when theirs is newer', async () => {
+ const theirs = record({ version: 6, document: newer('theirs') })
+ api.saveBook.mockRejectedValueOnce(conflictError(theirs))
+ const { result } = await mount()
+
+ act(() => { result.current.queueSave(doc('mine'), 'T') })
+ await tick()
+ expect(result.current.state.status).toBe('conflict')
+
+ act(() => { result.current.acceptTheirs(theirs) })
+ expect(result.current.state.status).toBe('newer')
+ })
+
+ /* A book of today goes out exactly as it went out before the revision existed. */
+ it('FE-STUDIO-REV-006: sends a book of today without a revision in it', async () => {
+ const { result } = await mount()
+
+ act(() => { result.current.queueSave(doc('a'), 'T') })
+ await tick()
+
+ expect(api.saveBook.mock.calls[0][1].document).not.toHaveProperty('schemaRev')
+ })
+})
diff --git a/server/src/nest/journey/journey-book.service.ts b/server/src/nest/journey/journey-book.service.ts
index 779e97106..ea14b5863 100644
--- a/server/src/nest/journey/journey-book.service.ts
+++ b/server/src/nest/journey/journey-book.service.ts
@@ -8,7 +8,14 @@ import { JourneyDomainService } from './journey-domain.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import type { BookDocument, BookRecord, BookSummary } from '@trek/shared';
-import { BOOK_DESIGN_LIMITS, normalizeBookDocument } from '@trek/shared';
+import {
+ BOOK_DESIGN_LIMITS,
+ BOOK_SCHEMA_NEWER_CODE,
+ BOOK_SCHEMA_REV,
+ bookSchemaRev,
+ normalizeBookDocument,
+ stampBookSchemaRev,
+} from '@trek/shared';
/**
* What an edit of the book hands back to `editBook`: the record's title and
@@ -59,6 +66,15 @@ interface BookEditOptions extends BookWriteOrigin {
*
* The alternative — last write wins — is not a simpler version of this. It is
* the same thing with the failure moved somewhere nobody sees it.
+ *
+ * ── Format revisions ─────────────────────────────────────────────────────
+ *
+ * A book says which revision of the format wrote it (book-schema-rev.ts in
+ * @trek/shared). A save from a writer older than the stored book is refused,
+ * because that writer has already dropped whatever it could not read; so is
+ * one newer than this server, which would drop the same on the way in. The
+ * book tools write only books this server reads whole, and stamp what they
+ * write with its revision.
*/
@Injectable()
export class JourneyBookService {
@@ -166,6 +182,7 @@ export class JourneyBookService {
userId: number,
input: { title: string; document: unknown; baseVersion?: number },
): Promise<{ record: BookRecord } | { conflict: BookRecord }> {
+ refuseNewerThanServer(bookSchemaRev(input.document));
const document = JSON.stringify(normalizeBookDocument(input.document));
// M1 (rule 11/24) — JB3's existing-link read and JB4's insert are
@@ -187,6 +204,10 @@ export class JourneyBookService {
return { record: (await this.byId(id))! };
}
+ // An older writer has already lost what it could not read; it does not get to save that loss.
+ const stored = storedSchemaRev(existing.document);
+ if (stored > bookSchemaRev(input.document)) throw newerBook(stored);
+
/*
* The version goes in the WHERE clause rather than being checked first.
* Read-then-write leaves a window between the two in which another save can
@@ -249,11 +270,15 @@ export class JourneyBookService {
if (options.expectedVersion !== undefined && current?.version !== options.expectedVersion) {
throw bookMoved(current);
}
+ // `current` is the book as this server reads it, which for a newer book is the book minus what it dropped.
+ if (row) refuseNewerThanServer(storedSchemaRev(row.document));
const next = await edit(current);
+ // What the edit hands back is checked too: a kept revision from a newer server, put back after a downgrade, would land stripped yet still stamped newer.
+ refuseNewerThanServer(bookSchemaRev(next.document));
if (current && next.unchanged) {
return { record: current, result: next.result, wrote: false, revisionId: null };
}
- const document = JSON.stringify(normalizeBookDocument(next.document));
+ const document = JSON.stringify(normalizeBookDocument(stampBookSchemaRev(next.document)));
if (!row) {
const id = await this.booksRepo.insertBook(journeyId, next.title, document, userId);
return { record: await this.written(id), result: next.result, wrote: true, revisionId: null };
@@ -341,6 +366,31 @@ function bookMoved(current: BookRecord | null): DomainError {
});
}
+/**
+ * The book was written by a newer revision of the format than the writer (or
+ * this server) knows. 409 like a moved book, with the code the Studio reads to
+ * open the book read-only and ask for a reload; no `current`, because there is
+ * nothing here an older writer could safely take.
+ */
+function newerBook(schemaRev: number): DomainError {
+ return new DomainError(409, 'Book was saved by a newer version of TREK Studio', {
+ code: BOOK_SCHEMA_NEWER_CODE,
+ details: { code: BOOK_SCHEMA_NEWER_CODE, schemaRev },
+ mcpMessage:
+ 'This book was saved by a newer version of TREK than this server runs. Changing it here would drop what that version added, so it stays as it is.',
+ });
+}
+
+/** Refuse a book written by a revision this server cannot read whole. */
+function refuseNewerThanServer(schemaRev: number): void {
+ if (schemaRev > BOOK_SCHEMA_REV) throw newerBook(schemaRev);
+}
+
+/** The revision a stored row's document says it was written by; broken JSON reads as 1, like its document. */
+function storedSchemaRev(raw: string): number {
+ return bookSchemaRev(safeParse(raw));
+}
+
/** JSON that will not parse is an empty document, never an exception. */
function safeParse(raw: string): unknown {
try {
diff --git a/server/tests/e2e/journey-book.e2e.test.ts b/server/tests/e2e/journey-book.e2e.test.ts
index 988eef893..104343fe9 100644
--- a/server/tests/e2e/journey-book.e2e.test.ts
+++ b/server/tests/e2e/journey-book.e2e.test.ts
@@ -9,14 +9,18 @@
* viewer's write is refused with the tool's own words, and an operation the
* engine refuses with the operation it names; a REST save and an MCP write
* send the same event to the same users, REST leaving out the tab that saved
- * and MCP none; and the tools are gone while the Journey addon is off.
+ * and MCP none; the tools are gone while the Journey addon is off; and a book
+ * a newer Studio wrote is refused with 409 and its code over both, even when
+ * its document holds what this build cannot parse.
*/
import { ADDON_IDS } from '../../src/addons';
import { buildApp } from '../../src/bootstrap';
+import { JourneyBooks } from '../../src/db/entities/JourneyBooks.entity';
import { createLocalMcpServer, McpRegistryService } from '../../src/nest-mcp';
import { withRequestContext } from '../../src/nest/database/request-context';
import { authCookie } from '../helpers/auth';
import { addJourneyContributor, makeJourney } from '../helpers/factories/journeys';
+import { findRow, updateRows } from '../helpers/factories/rows';
import { setAddonEnabled } from '../helpers/factories/settings';
import { makeUser } from '../helpers/factories/users';
import { spyOnRealtime, type RealtimeSpies } from '../helpers/fake-realtime';
@@ -198,4 +202,62 @@ describe('Studio book over MCP and REST', () => {
expect(names.filter((name) => BOOK_TOOLS.includes(name))).toEqual([]);
expect(names.length).toBeGreaterThan(0);
});
+
+ it('JBE2E-005: a book a newer Studio saved refuses a REST save with 409 and its code, and an MCP write in its words', async () => {
+ const { owner, journey, inner } = await journeyWithBook();
+ const before = await request(app.getHttpServer())
+ .get(`/api/journeys/${journey.id}/book`)
+ .set('Cookie', authCookie(owner.id));
+ const row = await findRow(orm, JourneyBooks, { journey: journey.id });
+ const newer = JSON.stringify({ ...JSON.parse(String(row?.document)), schemaRev: 2 });
+ await updateRows(orm, JourneyBooks, { journey: journey.id }, { document: newer });
+
+ const saved = await request(app.getHttpServer())
+ .put(`/api/journeys/${journey.id}/book`)
+ .set('Cookie', authCookie(owner.id))
+ .send({ title: before.body.book.title, document: before.body.book.document, baseVersion: 1 });
+ expect(saved.status).toBe(409);
+ expect(saved.body).toEqual({
+ error: 'Book was saved by a newer version of TREK Studio',
+ code: 'book_schema_newer',
+ schemaRev: 2,
+ });
+
+ const tool = await callTool(owner.id, 'edit_book_page', {
+ journeyId: journey.id,
+ spreadId: inner,
+ ops: [{ op: 'background', color: '#202020' }],
+ });
+ expect(tool).toEqual({
+ error:
+ 'This book was saved by a newer version of TREK than this server runs. Changing it here would drop what that version added, so it stays as it is.',
+ });
+ expect(sent()).toEqual([]);
+ const after = await findRow(orm, JourneyBooks, { journey: journey.id });
+ expect([after?.version, after?.document]).toEqual([1, newer]);
+ });
+
+ it('JBE2E-006: a save of a book a newer Studio wrote, with a kind this one cannot read, is a 409 with its code, not a 400', async () => {
+ const { owner, journey } = await journeyWithBook();
+ const before = await request(app.getHttpServer())
+ .get(`/api/journeys/${journey.id}/book`)
+ .set('Cookie', authCookie(owner.id));
+ const document = before.body.book.document as { spreads: Array<{ elements: unknown[] }> };
+ const frame = { x: 10, y: 10, w: 40, h: 40 };
+ document.spreads[0]?.elements.push({ id: 'from-later', kind: 'sticker', frame, stickerId: 'lemon' });
+
+ const saved = await request(app.getHttpServer())
+ .put(`/api/journeys/${journey.id}/book`)
+ .set('Cookie', authCookie(owner.id))
+ .send({ title: before.body.book.title, document: { ...document, schemaRev: 2 }, baseVersion: 1 });
+ expect(saved.status).toBe(409);
+ expect(saved.body).toEqual({
+ error: 'Book was saved by a newer version of TREK Studio',
+ code: 'book_schema_newer',
+ schemaRev: 2,
+ });
+ expect(sent()).toEqual([]);
+ const after = await findRow(orm, JourneyBooks, { journey: journey.id });
+ expect(after?.version).toBe(1);
+ });
});
diff --git a/server/tests/unit/nest/journey-book.service.test.ts b/server/tests/unit/nest/journey-book.service.test.ts
index b7e8cbe4f..6f3af3e61 100644
--- a/server/tests/unit/nest/journey-book.service.test.ts
+++ b/server/tests/unit/nest/journey-book.service.test.ts
@@ -620,6 +620,116 @@ describe('editBook', () => {
});
});
+// -- Format revisions ---------------------------------------------------------
+
+/*
+ * A book written by a newer revision of the format than the writer knows. The
+ * older writer has already dropped what it could not read, so its save, from
+ * an old tab or a replayed offline copy, must not land; nor may the book tools
+ * of a server that cannot read the book whole.
+ */
+describe('format revisions', () => {
+ /** A journey whose stored book says it was written by revision `schemaRev`. */
+ async function storedAt(schemaRev: number) {
+ const { user } = createUser(testDb);
+ const journey = createJourney(testDb, user.id);
+ await insertRow(await sharedTestOrm(testDb), JourneyBooks, {
+ journey: journey.id,
+ title: 'T',
+ document: JSON.stringify({ ...doc('newer'), schemaRev }),
+ version: 1,
+ });
+ return { user, journey };
+ }
+
+ const refusalOf = (thrown: unknown) =>
+ thrown instanceof DomainError ? [thrown.getStatus(), thrown.code, thrown.toBody().code] : thrown;
+
+ it('JBOOK-011: refuses a save from a writer older than the stored book with 409 and the code, writing and sending nothing', async () => {
+ const { user, journey } = await storedAt(2);
+ const rec = await recordSends();
+ try {
+ const thrown = await catchDomainError(() =>
+ books.saveBook(journey.id, user.id, { title: 'T', document: doc('older'), baseVersion: 1 }),
+ );
+ expect(refusalOf(thrown)).toEqual([409, 'book_schema_newer', 'book_schema_newer']);
+ expect(thrown instanceof DomainError && thrown.toBody()).toMatchObject({
+ error: 'Book was saved by a newer version of TREK Studio',
+ schemaRev: 2,
+ });
+ expect(rec.sent).toEqual([]);
+ const stored = await books.getBook(journey.id, user.id);
+ expect([stored?.version, stored?.document.title, stored?.document.schemaRev]).toEqual([1, 'newer', 2]);
+ } finally {
+ rec.restore();
+ }
+ });
+
+ it('JBOOK-012: refuses a document newer than this server reads, first save and later save alike', async () => {
+ const { user } = createUser(testDb);
+ const journey = createJourney(testDb, user.id);
+ const ahead = { ...doc('ahead'), schemaRev: 9 };
+ const first = await catchDomainError(() => books.saveBook(journey.id, user.id, { title: 'T', document: ahead }));
+ expect(refusalOf(first)).toEqual([409, 'book_schema_newer', 'book_schema_newer']);
+ expect(await books.getBook(journey.id, user.id)).toBeNull();
+
+ await books.saveBook(journey.id, user.id, { title: 'T', document: doc('one') });
+ const later = await catchDomainError(() =>
+ books.saveBook(journey.id, user.id, { title: 'T', document: ahead, baseVersion: 1 }),
+ );
+ expect(refusalOf(later)).toEqual([409, 'book_schema_newer', 'book_schema_newer']);
+ expect((await books.getBook(journey.id, user.id))?.document.title).toBe('one');
+ });
+
+ it('JBOOK-013: a save at the stored revision or above it lands as before, and a book of today stores no revision', async () => {
+ const { user, journey } = await storedAt(1);
+ const same = await books.saveBook(journey.id, user.id, {
+ title: 'T',
+ document: { ...doc('same'), schemaRev: 1 },
+ baseVersion: 1,
+ });
+ expect(same && 'record' in same && same.record.version).toBe(2);
+
+ const plain = await books.saveBook(journey.id, user.id, { title: 'T', document: doc('plain'), baseVersion: 2 });
+ expect(plain && 'record' in plain && plain.record.version).toBe(3);
+ const row = await findRow(await sharedTestOrm(testDb), JourneyBooks, { journey: journey.id });
+ expect(JSON.parse(String(row?.document))).not.toHaveProperty('schemaRev');
+ });
+
+ it('JBOOK-014: the book tools leave a book this server cannot read whole alone, before any edit runs', async () => {
+ const { user, journey } = await storedAt(2);
+ const rec = await recordSends();
+ try {
+ const edit = vi.fn(() => ({ title: 'T', document: normalizeBookDocument(doc('tool')), result: null }));
+ const thrown = await catchDomainError(() => books.editBook(journey.id, user.id, edit));
+ expect(refusalOf(thrown)).toEqual([409, 'book_schema_newer', 'book_schema_newer']);
+ expect(thrown instanceof DomainError && thrown.mcpMessage).toMatch(/newer version of TREK/);
+ expect(edit).not.toHaveBeenCalled();
+ expect(rec.sent).toEqual([]);
+ expect((await books.getBook(journey.id, user.id))?.document.title).toBe('newer');
+ } finally {
+ rec.restore();
+ }
+ });
+
+ it('JBOOK-015: an edit that hands back a document newer than this server reads is refused, the stored book untouched', async () => {
+ const { user, journey } = await storedAt(1);
+ const rec = await recordSends();
+ try {
+ const ahead = normalizeBookDocument({ ...doc('ahead'), schemaRev: 2 });
+ const thrown = await catchDomainError(() =>
+ books.editBook(journey.id, user.id, () => ({ title: 'T', document: ahead, result: null })),
+ );
+ expect(refusalOf(thrown)).toEqual([409, 'book_schema_newer', 'book_schema_newer']);
+ expect(rec.sent).toEqual([]);
+ const stored = await books.getBook(journey.id, user.id);
+ expect([stored?.version, stored?.document.title, stored?.document.schemaRev]).toEqual([1, 'newer', 1]);
+ } finally {
+ rec.restore();
+ }
+ });
+});
+
// -- Deleting -----------------------------------------------------------------
describe('deleting', () => {
diff --git a/server/tests/unit/nest/journey/journey-book-design.service.test.ts b/server/tests/unit/nest/journey/journey-book-design.service.test.ts
index be98bf8bf..9fbaf1644 100644
--- a/server/tests/unit/nest/journey/journey-book-design.service.test.ts
+++ b/server/tests/unit/nest/journey/journey-book-design.service.test.ts
@@ -15,7 +15,7 @@
* stale version, and forgets the draft; a journey without a book gets a new
* one and no revision; restoring the revision puts the book back only at the
* version the apply left, and never writes a revision that does not read
- * back; five revisions are kept.
+ * back or that a newer TREK kept; five revisions are kept.
*/
import { JourneyBookRevisions } from '../../../../src/db/entities/JourneyBookRevisions.entity';
import { JourneyEntryPhotos } from '../../../../src/db/entities/JourneyEntryPhotos.entity';
@@ -321,6 +321,25 @@ describe('taking a design back', () => {
expect(told()).toEqual([]);
});
+ it('JBDSN-011: a revision kept by a newer TREK is refused, never put back stripped of what this one cannot read', async () => {
+ const { journey, owner } = await crew();
+ const answer = await designs.design(journey.id, owner.id);
+ const applied = await designs.apply(journey.id, owner.id, { designId: answer.design_id });
+ const revisionId = applied.revision_id ?? 0;
+ const kept = await findRow(orm, JourneyBookRevisions, { id: revisionId });
+ const newer = JSON.stringify({ ...JSON.parse(String(kept?.document)), schemaRev: 2 });
+ await updateRows(orm, JourneyBookRevisions, { id: revisionId }, { document: newer });
+ t.realtime.reset();
+
+ const back = { revisionId, expectedVersion: applied.version };
+ expect(await refusal(() => designs.restoreRevision(journey.id, owner.id, back))).toEqual({
+ status: 409,
+ code: 'book_schema_newer',
+ });
+ expect(await version(journey.id, owner.id)).toBe(applied.version);
+ expect(told()).toEqual([]);
+ });
+
it('JBDSN-009: a journey keeps its newest five revisions', async () => {
const { journey, owner } = await crew();
const books = t.get(JourneyBookService);
diff --git a/shared/src/book/book-layouts.parity.spec.ts b/shared/src/book/book-layouts.parity.spec.ts
index 4d86c6298..e5856c93c 100644
--- a/shared/src/book/book-layouts.parity.spec.ts
+++ b/shared/src/book/book-layouts.parity.spec.ts
@@ -1,5 +1,6 @@
import { BOOK_GRIDS, bookGridElements, defaultBookGridBox } from './book-grids';
import { BOOK_COVER_TEMPLATES, BOOK_TEMPLATES, applyBookTemplate, type BookTemplate } from './book-layouts';
+import { normalizeBookDocument } from './book-normalize';
import {
applySavedBookLayout,
bookLayoutFromSpread,
@@ -7,7 +8,7 @@ import {
bookLayoutRole,
nextBookLayoutName,
} from './book-saved-layouts';
-import { normalizeBookDocument, type BookDocument } from './book.schema';
+import type { BookDocument } from './book.schema';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
diff --git a/shared/src/book/book-normalize.ts b/shared/src/book/book-normalize.ts
new file mode 100644
index 000000000..aacb405d2
--- /dev/null
+++ b/shared/src/book/book-normalize.ts
@@ -0,0 +1,68 @@
+import {
+ MAX_BOOK_LAYOUTS,
+ MAX_SPREAD_ELEMENTS,
+ bookDocumentSchema,
+ bookElementSchema,
+ bookLayoutSchema,
+ type BookDocument,
+} from './book.schema';
+
+/**
+ * Read a stored document without ever throwing.
+ *
+ * Same shape as `normalizeAppearance`: a book that cannot be parsed must still
+ * open — an editor that refuses to load someone's work because one field drifted
+ * is worse than one that drops the field.
+ *
+ * ── Why there are two attempts ───────────────────────────────────────────
+ *
+ * Dropping *the field* was the intent; dropping the whole book was what the
+ * code did. `elements` is an array of a discriminated union, so one element the
+ * contract cannot read fails its spread, fails the array, fails the document —
+ * and this function then handed back an empty book. It is not hypothetical: a
+ * mood mark carried the journal's key in a field capped at two characters, so
+ * every book with one on a page refused to save, and would have opened blank.
+ * Both the client's load seam and the server's read *and* write path run
+ * through here, which means the empty book was also what got written down.
+ *
+ * So a document that fails whole is tried again with the unreadable elements
+ * removed, and only a document that fails even then is given up on. Losing one
+ * decoration to a version skew is a shrug; losing the book is not.
+ */
+export function normalizeBookDocument(raw: unknown): BookDocument {
+ const parsed = bookDocumentSchema.safeParse(raw);
+ if (parsed.success) return parsed.data;
+
+ const salvaged = bookDocumentSchema.safeParse(withoutUnreadableElements(raw));
+ if (salvaged.success) return salvaged.data;
+
+ return bookDocumentSchema.parse({});
+}
+
+function withoutUnreadableElements(raw: unknown): unknown {
+ if (!raw || typeof raw !== 'object') return raw;
+ const doc = raw as { spreads?: unknown };
+ if (!Array.isArray(doc.spreads)) return raw;
+
+ const readable = (list: unknown) =>
+ Array.isArray(list)
+ ? // Trimmed to the cap as well as filtered: a spread that grew past 60
+ // fails the array the same way an unreadable element fails the union, and
+ // losing the sixty-first decoration beats losing the book.
+ list.filter((el) => bookElementSchema.safeParse(el).success).slice(0, MAX_SPREAD_ELEMENTS)
+ : list;
+
+ const layouts = (doc as { layouts?: unknown }).layouts;
+ return {
+ ...doc,
+ // A saved layout is a convenience: one that no longer reads is dropped, not the book.
+ ...(Array.isArray(layouts)
+ ? { layouts: layouts.filter((l) => bookLayoutSchema.safeParse(l).success).slice(0, MAX_BOOK_LAYOUTS) }
+ : {}),
+ spreads: doc.spreads.map((sp) => {
+ if (!sp || typeof sp !== 'object') return sp;
+ const spread = sp as { elements?: unknown; parked?: unknown };
+ return { ...spread, elements: readable(spread.elements), parked: readable(spread.parked) };
+ }),
+ };
+}
diff --git a/shared/src/book/book-schema-rev.spec.ts b/shared/src/book/book-schema-rev.spec.ts
new file mode 100644
index 000000000..ec4e7b3fb
--- /dev/null
+++ b/shared/src/book/book-schema-rev.spec.ts
@@ -0,0 +1,112 @@
+import { normalizeBookDocument } from './book-normalize';
+import {
+ BOOK_SCHEMA_REV,
+ bookSchemaRev,
+ isBookNewerThanReader,
+ readBookDocument,
+ stampBookSchemaRev,
+} from './book-schema-rev';
+import { bookSaveRequestSchema } from './book-store.schema';
+import { bookDocumentSchema } from './book.schema';
+
+import { describe, expect, it } from 'vitest';
+
+// BKREV-001 to BKREV-007
+
+/**
+ * The reader guard: a document says which revision of the format wrote it, a
+ * reader older than that opens it without saving it, and a book saved today
+ * stores exactly what it stored before the field existed.
+ */
+
+const frame = { x: 12, y: 18, w: 60, h: 40 };
+
+const doc = (over: Record = {}) => ({
+ version: 1,
+ title: 'Lisbon',
+ page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
+ spreads: [
+ {
+ id: 's1',
+ role: 'inner',
+ background: null,
+ entryId: null,
+ elements: [{ id: 't1', kind: 'text', frame, text: 'Alfama' }],
+ parked: [],
+ },
+ ],
+ ...over,
+});
+
+describe('the revision a document was written by', () => {
+ it('BKREV-001: reads 1 for every document from before the field, and for anything that is not a whole number', () => {
+ expect(BOOK_SCHEMA_REV).toBe(1);
+ expect(bookSchemaRev(doc())).toBe(1);
+ expect(bookSchemaRev(null)).toBe(1);
+ expect(bookSchemaRev('{"schemaRev":4}')).toBe(1);
+ for (const odd of [0, -2, 2.5, '3', Number.NaN, null]) expect(bookSchemaRev(doc({ schemaRev: odd }))).toBe(1);
+ expect(bookSchemaRev(doc({ schemaRev: 3 }))).toBe(3);
+ });
+
+ it('BKREV-002: keeps the revision through the contract, and lets a broken one fall back without losing the book', () => {
+ expect(bookDocumentSchema.parse(doc({ schemaRev: 2 })).schemaRev).toBe(2);
+ const odd = bookDocumentSchema.safeParse(doc({ schemaRev: 'two' }));
+ expect(odd.success).toBe(true);
+ expect(odd.success && odd.data.schemaRev).toBeUndefined();
+ expect(odd.success && odd.data.spreads[0]?.elements).toHaveLength(1);
+ });
+
+ it('BKREV-003: leaves a book saved today as it was, with no revision written into it', () => {
+ const today = normalizeBookDocument(doc());
+ expect('schemaRev' in today).toBe(false);
+ expect(stampBookSchemaRev(today)).toBe(today);
+ expect(JSON.stringify(stampBookSchemaRev(today))).toBe(JSON.stringify(today));
+ });
+});
+
+describe('a reader older than the document', () => {
+ it('BKREV-004: knows the document is newer than it, read from the raw value even when the rest will not parse', () => {
+ expect(isBookNewerThanReader(doc({ schemaRev: 2 }))).toBe(true);
+ expect(isBookNewerThanReader(doc({ schemaRev: 1 }))).toBe(false);
+ expect(isBookNewerThanReader(doc())).toBe(false);
+ // A reader of a later revision reads the same document as its own.
+ expect(isBookNewerThanReader(doc({ schemaRev: 2 }), 2)).toBe(false);
+ const unreadable = doc({ schemaRev: 5, page: 'not a page' });
+ expect(isBookNewerThanReader(unreadable)).toBe(true);
+ });
+
+ it('BKREV-005: reads a newer book as far as it can and says so, so nothing it dropped is saved', () => {
+ const sticker = { id: 'x1', kind: 'sticker', frame, stickerId: 'lemon' };
+ const raw = doc({ schemaRev: 2 });
+ const spread = (raw.spreads as { elements: unknown[] }[])[0];
+ if (spread) spread.elements.push(sticker);
+ const read = readBookDocument(raw);
+ expect(read.newer).toBe(true);
+ expect(read.document.schemaRev).toBe(2);
+ expect(read.document.spreads[0]?.elements.map((e) => e.id)).toEqual(['t1']);
+ expect(readBookDocument(doc()).newer).toBe(false);
+ });
+
+ it('BKREV-006: a writer stamps its own revision once that is above 1, and never lowers one', () => {
+ const today = normalizeBookDocument(doc());
+ expect(stampBookSchemaRev(today, 2).schemaRev).toBe(2);
+ const later = normalizeBookDocument(doc({ schemaRev: 3 }));
+ expect(stampBookSchemaRev(later, 2)).toBe(later);
+ expect(stampBookSchemaRev(later, 2).schemaRev).toBe(3);
+ });
+});
+
+describe('a save request carrying a newer book', () => {
+ it('BKREV-007: reaches the server as its revision alone, so the save is refused there rather than at the pipe', () => {
+ const sticker = { id: 'x1', kind: 'sticker', frame, stickerId: 'lemon' };
+ const raw = doc({ schemaRev: 2 });
+ const spread = (raw.spreads as { elements: unknown[] }[])[0];
+ if (spread) spread.elements.push(sticker);
+ expect(bookSaveRequestSchema.parse({ title: 'T', document: raw }).document).toEqual({ schemaRev: 2 });
+ // A book of today still reads whole, revision or none.
+ const today = bookSaveRequestSchema.parse({ title: 'T', document: doc() }).document;
+ expect(today).toEqual(bookDocumentSchema.parse(doc()));
+ expect(bookSaveRequestSchema.parse({ document: doc({ schemaRev: 1 }) }).document).toMatchObject({ schemaRev: 1 });
+ expect(bookSaveRequestSchema.safeParse({ document: { schemaRev: 1, spreads: 'no' } }).success).toBe(false);
+ });
+});
diff --git a/shared/src/book/book-schema-rev.ts b/shared/src/book/book-schema-rev.ts
new file mode 100644
index 000000000..9a3adfa8c
--- /dev/null
+++ b/shared/src/book/book-schema-rev.ts
@@ -0,0 +1,76 @@
+import { normalizeBookDocument } from './book-normalize';
+import type { BookDocument } from './book.schema';
+
+/**
+ * Which revision of the book format a document was written by, and what a
+ * reader does with one that is newer than it knows.
+ *
+ * ── Why a revision beside `version` ──────────────────────────────────────
+ *
+ * `version` is the format itself and stays 1: every document ever saved
+ * reads under it, and nothing here asks for a migration. What grows is the
+ * vocabulary inside it, a new element kind, a new font, a new field the
+ * renderer draws, and an older reader cannot tell any of those from noise.
+ * `normalizeBookDocument` drops an element it cannot read and the contract
+ * strips a field it does not know, both on purpose, so a damaged book still
+ * opens. Saving what is left is the part that must not happen: an old tab, an
+ * offline copy or a replayed request would write the loss down for everyone.
+ *
+ * So the document says which revision wrote it. A reader that meets a newer
+ * one opens it read-only and asks for a reload, and the server refuses a save
+ * from a writer older than the book it would replace.
+ *
+ * ── When to bump ─────────────────────────────────────────────────────────
+ *
+ * `BOOK_SCHEMA_REV` goes up by one in the change that adds an element kind,
+ * an enum value or a drawn field to book.schema.ts, never for a change an
+ * older reader survives whole. A writer stamps its own revision on what it
+ * saves only once that is above 1, so a book saved today stores exactly what
+ * it stored before this existed.
+ */
+export const BOOK_SCHEMA_REV = 1;
+
+/** The `code` a refused save carries in its body when the stored book is newer than the writer. */
+export const BOOK_SCHEMA_NEWER_CODE = 'book_schema_newer';
+
+/**
+ * The revision a document says it was written by, read from the raw value
+ * rather than the parsed one: a document so new that it fails to parse whole
+ * still says how new it is. Anything that is not a whole number from 1 up,
+ * and every document from before the field, reads as 1.
+ */
+export function bookSchemaRev(raw: unknown): number {
+ if (!raw || typeof raw !== 'object') return 1;
+ const rev = (raw as { schemaRev?: unknown }).schemaRev;
+ return typeof rev === 'number' && Number.isInteger(rev) && rev >= 1 ? rev : 1;
+}
+
+/** Whether a document was written by a newer revision than `readerRev` (this build's by default). */
+export function isBookNewerThanReader(raw: unknown, readerRev: number = BOOK_SCHEMA_REV): boolean {
+ return bookSchemaRev(raw) > readerRev;
+}
+
+/**
+ * The document as a writer of `writerRev` saves it: stamped with that
+ * revision, unless it already says as much or more. A document at revision 1
+ * is handed back as it is, so nothing changes on disk until the first bump.
+ */
+export function stampBookSchemaRev(doc: T, writerRev: number = BOOK_SCHEMA_REV): T {
+ return bookSchemaRev(doc) >= writerRev ? doc : { ...doc, schemaRev: writerRev };
+}
+
+/** A stored document as a reader holds it: normalised, and whether it was written by a newer revision. */
+export interface BookRead {
+ document: BookDocument;
+ /** True when the document is newer than the reader: show it, never save it. */
+ newer: boolean;
+}
+
+/**
+ * Read a stored document the way normalizeBookDocument does, and say whether
+ * it is newer than this reader. The normalised copy of a newer book has lost
+ * what this build cannot read, which is why `newer` has to travel with it.
+ */
+export function readBookDocument(raw: unknown, readerRev: number = BOOK_SCHEMA_REV): BookRead {
+ return { document: normalizeBookDocument(raw), newer: isBookNewerThanReader(raw, readerRev) };
+}
diff --git a/shared/src/book/book-store.schema.ts b/shared/src/book/book-store.schema.ts
index 2a18aaec4..9ce41e824 100644
--- a/shared/src/book/book-store.schema.ts
+++ b/shared/src/book/book-store.schema.ts
@@ -1,3 +1,4 @@
+import { BOOK_SCHEMA_REV } from './book-schema-rev';
import { bookDocumentSchema } from './book.schema';
import { z } from 'zod';
@@ -42,9 +43,23 @@ export const bookRecordSchema = bookSummarySchema.extend({
});
export type BookRecord = z.infer;
+/**
+ * A document a newer TREK wrote, as far as a save request carries it: only the
+ * revision. Its new element kinds or values would fail `bookDocumentSchema` at
+ * the pipe and end in a 400, so it is taken first and handed to the service
+ * this bare, where the save is refused with 409 `book_schema_newer` before
+ * anything is written. Nothing of the rest is needed for that refusal.
+ */
+const newerBookDocumentSchema = z.object({
+ schemaRev: z
+ .number()
+ .int()
+ .min(BOOK_SCHEMA_REV + 1),
+});
+
export const bookSaveRequestSchema = z.object({
title: z.string().max(200).default(''),
- document: bookDocumentSchema,
+ document: z.union([newerBookDocumentSchema, bookDocumentSchema]),
/**
* The version this edit was made against.
*
diff --git a/shared/src/book/book.schema.spec.ts b/shared/src/book/book.schema.spec.ts
index 6f6d195d9..bad46f599 100644
--- a/shared/src/book/book.schema.spec.ts
+++ b/shared/src/book/book.schema.spec.ts
@@ -1,3 +1,4 @@
+import { normalizeBookDocument } from './book-normalize';
import {
MAX_BOOK_LAYOUTS,
MAX_SPREAD_ELEMENTS,
@@ -7,7 +8,6 @@ import {
bookSpreadSchema,
bookStatsElementSchema,
bookTextElementSchema,
- normalizeBookDocument,
} from './book.schema';
import { describe, expect, it } from 'vitest';
diff --git a/shared/src/book/book.schema.ts b/shared/src/book/book.schema.ts
index 004bdb632..e4974876b 100644
--- a/shared/src/book/book.schema.ts
+++ b/shared/src/book/book.schema.ts
@@ -936,6 +936,8 @@ export type BookLayout = z.infer;
export const bookDocumentSchema = z.object({
/** In the document, not in a column: a format bump must not need a migration. */
version: z.literal(1).catch(1).default(1),
+ /** The format revision that wrote it (book-schema-rev.ts); absent, or unreadable and so dropped, reads as 1. */
+ schemaRev: z.number().int().min(1).optional().catch(undefined),
title: z.string().max(MAX_BOOK_TITLE).default(''),
page: bookPageSetupSchema.default(() => bookPageSetupSchema.parse({})),
spreads: z.array(bookSpreadSchema).max(MAX_SPREADS).default([]),
@@ -943,63 +945,3 @@ export const bookDocumentSchema = z.object({
layouts: z.array(bookLayoutSchema).max(MAX_BOOK_LAYOUTS).optional(),
});
export type BookDocument = z.infer;
-
-/**
- * Read a stored document without ever throwing.
- *
- * Same shape as `normalizeAppearance`: a book that cannot be parsed must still
- * open — an editor that refuses to load someone's work because one field drifted
- * is worse than one that drops the field.
- *
- * ── Why there are two attempts ───────────────────────────────────────────
- *
- * Dropping *the field* was the intent; dropping the whole book was what the
- * code did. `elements` is an array of a discriminated union, so one element the
- * contract cannot read fails its spread, fails the array, fails the document —
- * and this function then handed back an empty book. It is not hypothetical: a
- * mood mark carried the journal's key in a field capped at two characters, so
- * every book with one on a page refused to save, and would have opened blank.
- * Both the client's load seam and the server's read *and* write path run
- * through here, which means the empty book was also what got written down.
- *
- * So a document that fails whole is tried again with the unreadable elements
- * removed, and only a document that fails even then is given up on. Losing one
- * decoration to a version skew is a shrug; losing the book is not.
- */
-export function normalizeBookDocument(raw: unknown): BookDocument {
- const parsed = bookDocumentSchema.safeParse(raw);
- if (parsed.success) return parsed.data;
-
- const salvaged = bookDocumentSchema.safeParse(withoutUnreadableElements(raw));
- if (salvaged.success) return salvaged.data;
-
- return bookDocumentSchema.parse({});
-}
-
-function withoutUnreadableElements(raw: unknown): unknown {
- if (!raw || typeof raw !== 'object') return raw;
- const doc = raw as { spreads?: unknown };
- if (!Array.isArray(doc.spreads)) return raw;
-
- const readable = (list: unknown) =>
- Array.isArray(list)
- ? // Trimmed to the cap as well as filtered: a spread that grew past 60
- // fails the array the same way an unreadable element fails the union, and
- // losing the sixty-first decoration beats losing the book.
- list.filter((el) => bookElementSchema.safeParse(el).success).slice(0, MAX_SPREAD_ELEMENTS)
- : list;
-
- const layouts = (doc as { layouts?: unknown }).layouts;
- return {
- ...doc,
- // A saved layout is a convenience: one that no longer reads is dropped, not the book.
- ...(Array.isArray(layouts)
- ? { layouts: layouts.filter((l) => bookLayoutSchema.safeParse(l).success).slice(0, MAX_BOOK_LAYOUTS) }
- : {}),
- spreads: doc.spreads.map((sp) => {
- if (!sp || typeof sp !== 'object') return sp;
- const spread = sp as { elements?: unknown; parked?: unknown };
- return { ...spread, elements: readable(spread.elements), parked: readable(spread.parked) };
- }),
- };
-}
diff --git a/shared/src/i18n/ar/journey.ts b/shared/src/i18n/ar/journey.ts
index 1bea54c6b..01727b704 100644
--- a/shared/src/i18n/ar/journey.ts
+++ b/shared/src/i18n/ar/journey.ts
@@ -677,6 +677,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'تدوير لليسار',
'journey.studio.rotateRight': 'تدوير لليمين',
'journey.studio.saveReadOnly': 'للقراءة فقط، لا يتم الحفظ',
+ 'journey.studio.saveNewer': 'حفظ إصدار أحدث من Studio هذا الكتاب. أعد التحميل لتعديله.',
+ 'journey.studio.saveReload': 'إعادة التحميل',
'journey.studio.filterPhotos': 'تصفية الصور',
'journey.studio.filterAll': 'كل الصور',
'journey.studio.filterLoose': 'ليست ضمن إدخال',
diff --git a/shared/src/i18n/az/journey.ts b/shared/src/i18n/az/journey.ts
index bc94e6bd8..1f4889208 100644
--- a/shared/src/i18n/az/journey.ts
+++ b/shared/src/i18n/az/journey.ts
@@ -712,6 +712,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Sola fırlat',
'journey.studio.rotateRight': 'Sağa fırlat',
'journey.studio.saveReadOnly': 'Yalnız oxuma rejimi — yadda saxlanılmadı',
+ 'journey.studio.saveNewer': 'Bu kitabı Studio-nun daha yeni versiyası saxlayıb. Redaktə etmək üçün yenidən yükləyin.',
+ 'journey.studio.saveReload': 'Yenidən yüklə',
'journey.studio.filterPhotos': 'Fotoları filtrlə',
'journey.studio.filterAll': 'Bütün fotolar',
diff --git a/shared/src/i18n/br/journey.ts b/shared/src/i18n/br/journey.ts
index 3dc90bb88..069aa74cd 100644
--- a/shared/src/i18n/br/journey.ts
+++ b/shared/src/i18n/br/journey.ts
@@ -652,6 +652,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Girar à esquerda',
'journey.studio.rotateRight': 'Girar à direita',
'journey.studio.saveReadOnly': 'Somente leitura, nada é salvo',
+ 'journey.studio.saveNewer': 'Uma versão mais recente do Studio salvou este livro. Recarregue para editar.',
+ 'journey.studio.saveReload': 'Recarregar',
'journey.studio.filterPhotos': 'Filtrar fotos',
'journey.studio.filterAll': 'Todas as fotos',
'journey.studio.filterLoose': 'Sem entrada',
diff --git a/shared/src/i18n/ca/journey.ts b/shared/src/i18n/ca/journey.ts
index b80f67e45..8833bc29f 100644
--- a/shared/src/i18n/ca/journey.ts
+++ b/shared/src/i18n/ca/journey.ts
@@ -653,6 +653,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': "Gira cap a l'esquerra",
'journey.studio.rotateRight': 'Gira cap a la dreta',
'journey.studio.saveReadOnly': 'Només lectura, no es desa',
+ 'journey.studio.saveNewer': 'Una versió més recent de Studio ha desat aquest llibre. Recarrega per editar-lo.',
+ 'journey.studio.saveReload': 'Recarrega',
'journey.studio.filterPhotos': 'Filtra les fotos',
'journey.studio.filterAll': 'Totes les fotos',
'journey.studio.filterLoose': 'Sense entrada',
diff --git a/shared/src/i18n/cs/journey.ts b/shared/src/i18n/cs/journey.ts
index 526a8eda1..b00ca9b98 100644
--- a/shared/src/i18n/cs/journey.ts
+++ b/shared/src/i18n/cs/journey.ts
@@ -655,6 +655,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Otočit doleva',
'journey.studio.rotateRight': 'Otočit doprava',
'journey.studio.saveReadOnly': 'Jen ke čtení, neukládá se',
+ 'journey.studio.saveNewer': 'Tuto knihu uložila novější verze aplikace Studio. Pro úpravy ji načtěte znovu.',
+ 'journey.studio.saveReload': 'Načíst znovu',
'journey.studio.filterPhotos': 'Filtrovat fotografie',
'journey.studio.filterAll': 'Všechny fotografie',
'journey.studio.filterLoose': 'Bez záznamu',
diff --git a/shared/src/i18n/de/journey.ts b/shared/src/i18n/de/journey.ts
index 718e9318a..aa677a73d 100644
--- a/shared/src/i18n/de/journey.ts
+++ b/shared/src/i18n/de/journey.ts
@@ -654,6 +654,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Nach links drehen',
'journey.studio.rotateRight': 'Nach rechts drehen',
'journey.studio.saveReadOnly': 'Nur lesen, nichts wird gespeichert',
+ 'journey.studio.saveNewer': 'Eine neuere Studio-Version hat dieses Buch gespeichert. Zum Bearbeiten neu laden.',
+ 'journey.studio.saveReload': 'Neu laden',
'journey.studio.filterPhotos': 'Fotos filtern',
'journey.studio.filterAll': 'Alle Fotos',
'journey.studio.filterLoose': 'Keinem Eintrag zugeordnet',
diff --git a/shared/src/i18n/en/journey.ts b/shared/src/i18n/en/journey.ts
index 7155d39cc..44396d75a 100644
--- a/shared/src/i18n/en/journey.ts
+++ b/shared/src/i18n/en/journey.ts
@@ -648,6 +648,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Rotate left',
'journey.studio.rotateRight': 'Rotate right',
'journey.studio.saveReadOnly': 'Read-only, not saved',
+ 'journey.studio.saveNewer': 'A newer Studio saved this book. Reload to edit it.',
+ 'journey.studio.saveReload': 'Reload',
'journey.studio.filterPhotos': 'Filter photos',
'journey.studio.filterAll': 'All photos',
'journey.studio.filterLoose': 'Not in an entry',
diff --git a/shared/src/i18n/es/journey.ts b/shared/src/i18n/es/journey.ts
index c53dea348..3c1f50d37 100644
--- a/shared/src/i18n/es/journey.ts
+++ b/shared/src/i18n/es/journey.ts
@@ -652,6 +652,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Girar a la izquierda',
'journey.studio.rotateRight': 'Girar a la derecha',
'journey.studio.saveReadOnly': 'Solo lectura, no se guarda',
+ 'journey.studio.saveNewer': 'Una versión más reciente de Studio guardó este libro. Recarga para editarlo.',
+ 'journey.studio.saveReload': 'Recargar',
'journey.studio.filterPhotos': 'Filtrar fotos',
'journey.studio.filterAll': 'Todas las fotos',
'journey.studio.filterLoose': 'Sin entrada',
diff --git a/shared/src/i18n/et/journey.ts b/shared/src/i18n/et/journey.ts
index 7d3f6d72d..d99c3eb78 100644
--- a/shared/src/i18n/et/journey.ts
+++ b/shared/src/i18n/et/journey.ts
@@ -649,6 +649,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Pööra vasakule',
'journey.studio.rotateRight': 'Pööra paremale',
'journey.studio.saveReadOnly': 'Ainult lugemiseks, salvestamata',
+ 'journey.studio.saveNewer': 'Selle raamatu salvestas Studio uuem versioon. Muutmiseks laadi leht uuesti.',
+ 'journey.studio.saveReload': 'Laadi uuesti',
'journey.studio.filterPhotos': 'Filtreeri fotosid',
'journey.studio.filterAll': 'Kõik fotod',
'journey.studio.filterLoose': 'Sissekandega sidumata',
diff --git a/shared/src/i18n/fr/journey.ts b/shared/src/i18n/fr/journey.ts
index f0f4082d7..935c78440 100644
--- a/shared/src/i18n/fr/journey.ts
+++ b/shared/src/i18n/fr/journey.ts
@@ -651,6 +651,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Pivoter à gauche',
'journey.studio.rotateRight': 'Pivoter à droite',
'journey.studio.saveReadOnly': "Lecture seule, rien n'est enregistré",
+ 'journey.studio.saveNewer': 'Une version plus récente de Studio a enregistré ce livre. Rechargez pour le modifier.',
+ 'journey.studio.saveReload': 'Recharger',
'journey.studio.filterPhotos': 'Filtrer les photos',
'journey.studio.filterAll': 'Toutes les photos',
'journey.studio.filterLoose': 'Sans entrée',
diff --git a/shared/src/i18n/gr/journey.ts b/shared/src/i18n/gr/journey.ts
index e4f391249..e882e9d77 100644
--- a/shared/src/i18n/gr/journey.ts
+++ b/shared/src/i18n/gr/journey.ts
@@ -657,6 +657,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Περιστροφή αριστερά',
'journey.studio.rotateRight': 'Περιστροφή δεξιά',
'journey.studio.saveReadOnly': 'Μόνο για ανάγνωση, δεν αποθηκεύεται',
+ 'journey.studio.saveNewer': 'Μια νεότερη έκδοση του Studio αποθήκευσε αυτό το βιβλίο. Φορτώστε ξανά για επεξεργασία.',
+ 'journey.studio.saveReload': 'Επαναφόρτωση',
'journey.studio.filterPhotos': 'Φιλτράρισμα φωτογραφιών',
'journey.studio.filterAll': 'Όλες οι φωτογραφίες',
'journey.studio.filterLoose': 'Χωρίς καταχώρηση',
diff --git a/shared/src/i18n/hu/journey.ts b/shared/src/i18n/hu/journey.ts
index 7c7162448..ac8c8acd5 100644
--- a/shared/src/i18n/hu/journey.ts
+++ b/shared/src/i18n/hu/journey.ts
@@ -649,6 +649,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Forgatás balra',
'journey.studio.rotateRight': 'Forgatás jobbra',
'journey.studio.saveReadOnly': 'Csak olvasható, nem mentődik',
+ 'journey.studio.saveNewer': 'Ezt a könyvet a Studio egy újabb verziója mentette. Töltsd újra a szerkesztéshez.',
+ 'journey.studio.saveReload': 'Újratöltés',
'journey.studio.filterPhotos': 'Fotók szűrése',
'journey.studio.filterAll': 'Összes fotó',
'journey.studio.filterLoose': 'Nincs bejegyzésben',
diff --git a/shared/src/i18n/id/journey.ts b/shared/src/i18n/id/journey.ts
index a2629e2aa..fbfd1208f 100644
--- a/shared/src/i18n/id/journey.ts
+++ b/shared/src/i18n/id/journey.ts
@@ -641,6 +641,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Putar ke kiri',
'journey.studio.rotateRight': 'Putar ke kanan',
'journey.studio.saveReadOnly': 'Hanya baca, tidak disimpan',
+ 'journey.studio.saveNewer': 'Buku ini disimpan oleh versi Studio yang lebih baru. Muat ulang untuk mengeditnya.',
+ 'journey.studio.saveReload': 'Muat ulang',
'journey.studio.filterPhotos': 'Filter foto',
'journey.studio.filterAll': 'Semua foto',
'journey.studio.filterLoose': 'Tidak dalam entri',
diff --git a/shared/src/i18n/it/journey.ts b/shared/src/i18n/it/journey.ts
index 705f79afb..c7dfd2148 100644
--- a/shared/src/i18n/it/journey.ts
+++ b/shared/src/i18n/it/journey.ts
@@ -651,6 +651,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Ruota a sinistra',
'journey.studio.rotateRight': 'Ruota a destra',
'journey.studio.saveReadOnly': 'Sola lettura, non si salva',
+ 'journey.studio.saveNewer': 'Una versione più recente di Studio ha salvato questo libro. Ricarica per modificarlo.',
+ 'journey.studio.saveReload': 'Ricarica',
'journey.studio.filterPhotos': 'Filtra foto',
'journey.studio.filterAll': 'Tutte le foto',
'journey.studio.filterLoose': 'Senza voce',
diff --git a/shared/src/i18n/ja/journey.ts b/shared/src/i18n/ja/journey.ts
index 45f09404f..1a2ba79fa 100644
--- a/shared/src/i18n/ja/journey.ts
+++ b/shared/src/i18n/ja/journey.ts
@@ -639,6 +639,9 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': '左に回転',
'journey.studio.rotateRight': '右に回転',
'journey.studio.saveReadOnly': '閲覧のみ・保存されません',
+ 'journey.studio.saveNewer':
+ 'この本は新しいバージョンの Studio で保存されました。編集するには再読み込みしてください。',
+ 'journey.studio.saveReload': '再読み込み',
'journey.studio.filterPhotos': '写真を絞り込む',
'journey.studio.filterAll': 'すべての写真',
'journey.studio.filterLoose': 'エントリーに属さない',
diff --git a/shared/src/i18n/ko/journey.ts b/shared/src/i18n/ko/journey.ts
index 1021d0a11..f02b15b35 100644
--- a/shared/src/i18n/ko/journey.ts
+++ b/shared/src/i18n/ko/journey.ts
@@ -636,6 +636,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': '왼쪽으로 회전',
'journey.studio.rotateRight': '오른쪽으로 회전',
'journey.studio.saveReadOnly': '읽기 전용, 저장되지 않음',
+ 'journey.studio.saveNewer': '이 책은 더 새로운 버전의 Studio에서 저장되었습니다. 편집하려면 새로고침하세요.',
+ 'journey.studio.saveReload': '새로고침',
'journey.studio.filterPhotos': '사진 필터',
'journey.studio.filterAll': '모든 사진',
'journey.studio.filterLoose': '항목에 없음',
diff --git a/shared/src/i18n/nl/journey.ts b/shared/src/i18n/nl/journey.ts
index 5b45477ab..8ba8e65ab 100644
--- a/shared/src/i18n/nl/journey.ts
+++ b/shared/src/i18n/nl/journey.ts
@@ -652,6 +652,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Naar links draaien',
'journey.studio.rotateRight': 'Naar rechts draaien',
'journey.studio.saveReadOnly': 'Alleen lezen, wordt niet opgeslagen',
+ 'journey.studio.saveNewer': 'Een nieuwere versie van Studio heeft dit boek opgeslagen. Herlaad om het te bewerken.',
+ 'journey.studio.saveReload': 'Herladen',
'journey.studio.filterPhotos': "Foto's filteren",
'journey.studio.filterAll': "Alle foto's",
'journey.studio.filterLoose': 'Niet in een vermelding',
diff --git a/shared/src/i18n/pl/journey.ts b/shared/src/i18n/pl/journey.ts
index 86084c44f..f764bc19d 100644
--- a/shared/src/i18n/pl/journey.ts
+++ b/shared/src/i18n/pl/journey.ts
@@ -668,6 +668,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Obróć w lewo',
'journey.studio.rotateRight': 'Obróć w prawo',
'journey.studio.saveReadOnly': 'Tylko do odczytu, nie zapisuje się',
+ 'journey.studio.saveNewer': 'Nowsza wersja Studio zapisała tę książkę. Odśwież, aby ją edytować.',
+ 'journey.studio.saveReload': 'Odśwież',
'journey.studio.filterPhotos': 'Filtruj zdjęcia',
'journey.studio.filterAll': 'Wszystkie zdjęcia',
'journey.studio.filterLoose': 'Bez wpisu',
diff --git a/shared/src/i18n/ru/journey.ts b/shared/src/i18n/ru/journey.ts
index 1fdb5e06d..a8376de98 100644
--- a/shared/src/i18n/ru/journey.ts
+++ b/shared/src/i18n/ru/journey.ts
@@ -667,6 +667,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Повернуть влево',
'journey.studio.rotateRight': 'Повернуть вправо',
'journey.studio.saveReadOnly': 'Только чтение, не сохраняется',
+ 'journey.studio.saveNewer': 'Эту книгу сохранила более новая версия Studio. Перезагрузите, чтобы редактировать.',
+ 'journey.studio.saveReload': 'Перезагрузить',
'journey.studio.filterPhotos': 'Фильтр фото',
'journey.studio.filterAll': 'Все фото',
'journey.studio.filterLoose': 'Без записи',
diff --git a/shared/src/i18n/sk/journey.ts b/shared/src/i18n/sk/journey.ts
index 6977e3edf..dace52dc6 100644
--- a/shared/src/i18n/sk/journey.ts
+++ b/shared/src/i18n/sk/journey.ts
@@ -660,6 +660,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Otočiť doľava',
'journey.studio.rotateRight': 'Otočiť doprava',
'journey.studio.saveReadOnly': 'Iba na čítanie, neukladá sa',
+ 'journey.studio.saveNewer': 'Túto knihu uložila novšia verzia aplikácie Studio. Na úpravy ju načítajte znova.',
+ 'journey.studio.saveReload': 'Načítať znova',
'journey.studio.filterPhotos': 'Filtrovať fotografie',
'journey.studio.filterAll': 'Všetky fotografie',
'journey.studio.filterLoose': 'Bez záznamu',
diff --git a/shared/src/i18n/sv/journey.ts b/shared/src/i18n/sv/journey.ts
index 32b9c519a..1b398975a 100644
--- a/shared/src/i18n/sv/journey.ts
+++ b/shared/src/i18n/sv/journey.ts
@@ -648,6 +648,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Rotera åt vänster',
'journey.studio.rotateRight': 'Rotera åt höger',
'journey.studio.saveReadOnly': 'Skrivskyddad, sparas inte',
+ 'journey.studio.saveNewer': 'En nyare version av Studio har sparat den här boken. Ladda om för att redigera.',
+ 'journey.studio.saveReload': 'Ladda om',
'journey.studio.filterPhotos': 'Filtrera foton',
'journey.studio.filterAll': 'Alla foton',
'journey.studio.filterLoose': 'Inte i något inlägg',
diff --git a/shared/src/i18n/th/journey.ts b/shared/src/i18n/th/journey.ts
index da1863b35..4371d2359 100644
--- a/shared/src/i18n/th/journey.ts
+++ b/shared/src/i18n/th/journey.ts
@@ -633,6 +633,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'หมุนไปทางซ้าย',
'journey.studio.rotateRight': 'หมุนไปทางขวา',
'journey.studio.saveReadOnly': 'อ่านอย่างเดียว ไม่ได้บันทึก',
+ 'journey.studio.saveNewer': 'หนังสือเล่มนี้ถูกบันทึกโดย Studio เวอร์ชันที่ใหม่กว่า โปรดโหลดใหม่เพื่อแก้ไข',
+ 'journey.studio.saveReload': 'โหลดใหม่',
'journey.studio.filterPhotos': 'กรองรูปภาพ',
'journey.studio.filterAll': 'รูปภาพทั้งหมด',
'journey.studio.filterLoose': 'ไม่อยู่ในรายการ',
diff --git a/shared/src/i18n/tr/journey.ts b/shared/src/i18n/tr/journey.ts
index 6e6c2c50c..2d6080fd0 100644
--- a/shared/src/i18n/tr/journey.ts
+++ b/shared/src/i18n/tr/journey.ts
@@ -650,6 +650,9 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Sola döndür',
'journey.studio.rotateRight': 'Sağa döndür',
'journey.studio.saveReadOnly': 'Salt okunur, kaydedilmiyor',
+ 'journey.studio.saveNewer':
+ 'Bu kitap Studio’nun daha yeni bir sürümüyle kaydedildi. Düzenlemek için yeniden yükleyin.',
+ 'journey.studio.saveReload': 'Yeniden yükle',
'journey.studio.filterPhotos': 'Fotoğrafları filtrele',
'journey.studio.filterAll': 'Tüm fotoğraflar',
'journey.studio.filterLoose': 'Bir kayıtta değil',
diff --git a/shared/src/i18n/uk/journey.ts b/shared/src/i18n/uk/journey.ts
index 014bfab8d..de9829bf8 100644
--- a/shared/src/i18n/uk/journey.ts
+++ b/shared/src/i18n/uk/journey.ts
@@ -671,6 +671,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Повернути ліворуч',
'journey.studio.rotateRight': 'Повернути праворуч',
'journey.studio.saveReadOnly': 'Лише для читання, не зберігається',
+ 'journey.studio.saveNewer': 'Цю книгу збережено новішою версією Studio. Перезавантажте, щоб редагувати.',
+ 'journey.studio.saveReload': 'Перезавантажити',
'journey.studio.filterPhotos': 'Фільтр фото',
'journey.studio.filterAll': 'Усі фото',
'journey.studio.filterLoose': 'Без запису',
diff --git a/shared/src/i18n/vi/journey.ts b/shared/src/i18n/vi/journey.ts
index 9b8806c9d..d63c99d66 100644
--- a/shared/src/i18n/vi/journey.ts
+++ b/shared/src/i18n/vi/journey.ts
@@ -640,6 +640,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': 'Xoay trái',
'journey.studio.rotateRight': 'Xoay phải',
'journey.studio.saveReadOnly': 'Chỉ đọc, không lưu',
+ 'journey.studio.saveNewer': 'Cuốn sách này đã được lưu bằng phiên bản Studio mới hơn. Tải lại để chỉnh sửa.',
+ 'journey.studio.saveReload': 'Tải lại',
'journey.studio.filterPhotos': 'Lọc ảnh',
'journey.studio.filterAll': 'Tất cả ảnh',
'journey.studio.filterLoose': 'Không thuộc mục nào',
diff --git a/shared/src/i18n/zh-TW/journey.ts b/shared/src/i18n/zh-TW/journey.ts
index f7c28f9e2..35fb85eac 100644
--- a/shared/src/i18n/zh-TW/journey.ts
+++ b/shared/src/i18n/zh-TW/journey.ts
@@ -629,6 +629,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': '向左旋轉',
'journey.studio.rotateRight': '向右旋轉',
'journey.studio.saveReadOnly': '唯讀,不會儲存',
+ 'journey.studio.saveNewer': '此書由較新版本的 Studio 儲存。請重新載入後再編輯。',
+ 'journey.studio.saveReload': '重新載入',
'journey.studio.filterPhotos': '篩選照片',
'journey.studio.filterAll': '全部照片',
'journey.studio.filterLoose': '不屬於任何條目',
diff --git a/shared/src/i18n/zh/journey.ts b/shared/src/i18n/zh/journey.ts
index 775958ff6..2290db112 100644
--- a/shared/src/i18n/zh/journey.ts
+++ b/shared/src/i18n/zh/journey.ts
@@ -629,6 +629,8 @@ const journey: TranslationStrings = {
'journey.studio.rotateLeft': '向左旋转',
'journey.studio.rotateRight': '向右旋转',
'journey.studio.saveReadOnly': '只读,不会保存',
+ 'journey.studio.saveNewer': '此书由更新版本的 Studio 保存。请重新加载后再编辑。',
+ 'journey.studio.saveReload': '重新加载',
'journey.studio.filterPhotos': '筛选照片',
'journey.studio.filterAll': '全部照片',
'journey.studio.filterLoose': '不属于任何条目',
diff --git a/shared/src/index.ts b/shared/src/index.ts
index f01e4251d..2fe1b26e8 100644
--- a/shared/src/index.ts
+++ b/shared/src/index.ts
@@ -116,6 +116,8 @@ export * from './journey/journey-recording-prep.schema';
export * from './journey/journey-map.schema';
export * from './journey/trip-journal-link.schema';
export * from './book/book.schema';
+export * from './book/book-normalize';
+export * from './book/book-schema-rev';
export * from './book/journey-stats.schema';
export * from './book/book-store.schema';
export * from './book/book-ids';