feat(studio): a book saved by a newer Studio opens read-only instead of losing what it cannot read

- A book carries schemaRev. The reader that meets a newer one opens the book
  read-only with a reload banner and saves nothing, where it used to drop the
  elements it could not read on the next save.
- The server refuses a save, a restore or a tool edit that would write an older
  revision over a newer book or one newer than itself (409 book_schema_newer),
  on REST and through every book tool alike.
- normalizeBookDocument moves to book-normalize.ts.
- Print waits for photos eagerly and up to 15 s for an export of more than 40
  photos, and skips images without a source.
This commit is contained in:
Maurice
2026-10-23 14:14:54 +02:00
parent 3292a092ae
commit 30382dc366
52 changed files with 1036 additions and 135 deletions
+26 -1
View File
@@ -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 (
<div className="st-save is-conflict" role="alert">
<AlertTriangle size={13} />
<span>{t('journey.studio.saveNewer')}</span>
<button type="button" onClick={onReload}>
{t('journey.studio.saveReload')}
</button>
</div>
)
}
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()
}
+4 -1
View File
@@ -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%',
@@ -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<Window> = {}): 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(
`<svg xmlns="${SVG}"><defs><clipPath id="c"><image href="/a.jpg"/></clipPath>` +
`<image id="d" href="/b.jpg"/></defs><image href="/c.jpg"/></svg>`
);
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(`<svg xmlns="${SVG}"><image/></svg>`);
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) => `<image href="/p${i}.jpg"/>`).join('');
const doc = printDoc(`<svg xmlns="${SVG}">${many}</svg>`);
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);
});
});
@@ -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 `<img>` elements, its SVG and its fonts. */
interface PrintDocument {
images: ArrayLike<Element & { complete?: boolean }>;
querySelectorAll?: (selector: string) => ArrayLike<Element>;
fonts?: { ready: Promise<unknown> };
}
const XLINK = 'http://www.w3.org/1999/xlink';
/**
* The SVG pictures to wait for: every `<image>` 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 `<image>` inside a `<clipPath>` or `<defs>`, 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<void> {
return new Promise<void>((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<void> {
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<void>((resolve) => {
wait(resolve, printReadyCeilingMs(imgs.length + svg.length));
});
await Promise.race([Promise.all([...images, ...svg.map(settle), doc.fonts?.ready ?? Promise.resolve()]), ceiling]);
}
+2 -44
View File
@@ -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<void> {
const win = iframe.contentWindow
const doc = iframe.contentDocument
if (!win || !doc) return
const settle = (el: Element) => new Promise<void>(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 `<image>`, 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<void>(resolve => { wait(resolve, 8000) })
await Promise.race([
Promise.all([...images, ...svgImages, doc.fonts?.ready ?? Promise.resolve()]),
ceiling,
])
}
+36 -10
View File
@@ -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,
@@ -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 () => {
@@ -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,
}
@@ -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');
});
});
+17 -6
View File
@@ -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<StudioState>((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<StudioState>((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<StudioState>((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,
}))
@@ -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)
})
})
@@ -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')!
@@ -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')
})
})
@@ -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 {
+63 -1
View File
@@ -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);
});
});
@@ -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', () => {
@@ -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);
+2 -1
View File
@@ -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';
+68
View File
@@ -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) };
}),
};
}
+112
View File
@@ -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<string, unknown> = {}) => ({
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);
});
});
+76
View File
@@ -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<T extends BookDocument>(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) };
}
+16 -1
View File
@@ -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<typeof bookRecordSchema>;
/**
* 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.
*
+1 -1
View File
@@ -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';
+2 -60
View File
@@ -936,6 +936,8 @@ export type BookLayout = z.infer<typeof bookLayoutSchema>;
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<typeof bookDocumentSchema>;
/**
* 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) };
}),
};
}
+2
View File
@@ -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': 'ليست ضمن إدخال',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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': 'Χωρίς καταχώρηση',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+3
View File
@@ -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': 'エントリーに属さない',
+2
View File
@@ -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': '항목에 없음',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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': 'Без записи',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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': 'ไม่อยู่ในรายการ',
+3
View File
@@ -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',
+2
View File
@@ -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': 'Без запису',
+2
View File
@@ -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',
+2
View File
@@ -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': '不屬於任何條目',
+2
View File
@@ -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': '不属于任何条目',
+2
View File
@@ -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';