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(`${many}`); + 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';