mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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': 'ليست ضمن إدخال',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Χωρίς καταχώρηση',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'エントリーに属さない',
|
||||
|
||||
@@ -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': '항목에 없음',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Без записи',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'ไม่อยู่ในรายการ',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Без запису',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '不屬於任何條目',
|
||||
|
||||
@@ -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': '不属于任何条目',
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user