Files
TREK/shared/src/book/book-ops-core.ts
T
Maurice 4570ace78c feat(journey): the shared book engine lays out, edits and designs whole books
Every element kind and Studio tool gets an op, the auto layout and its
templates move from the client to shared with a parity fixture, and a pure
design engine turns directives into a finished book with design findings.
2026-10-22 21:34:15 +00:00

260 lines
10 KiB
TypeScript

import type { BookTravelContext } from './book-travel';
import {
MAX_SPREAD_ELEMENTS,
MAX_TEXT_LENGTH,
bookSpreadSchema,
type BookDocument,
type BookElement,
type BookSpread,
type BookTextElement,
} from './book.schema';
import type { z } from 'zod';
/**
* What the book edit engine answers with, and the pieces its page and spread
* operations share. A refusal is a value, never an exception: nothing thrown
* in here leaves `caught`.
*/
export const BOOK_OP_ERROR_CODES = [
'invalid_op',
'spread_not_found',
'element_not_found',
'wrong_kind',
'locked',
'protected_spread',
'index_out_of_range',
'photo_not_in_gallery',
'entry_not_found',
'slot_not_found',
'layout_not_found',
'layout_role',
'too_many_elements',
'too_many_spreads',
'parked_full',
'empty_frame',
'caption_missing',
'text_required',
'no_cover',
'travel_unavailable',
'icon_unknown',
'not_enough_elements',
'layouts_full',
] as const;
export type BookOpErrorCode = (typeof BOOK_OP_ERROR_CODES)[number];
export interface BookOpError {
code: BookOpErrorCode;
/** One sentence a model can act on. */
message: string;
/** Which operation of the call, from 0; null when the call as a whole was refused. */
opIndex: number | null;
elementId?: string;
}
/** What the engine knows about a gallery photo. */
export interface BookPhotoInfo {
photoId: number;
/** The original's size in pixels when known. Never a thumbnail's: the print check reads it. */
width?: number | null;
height?: number | null;
caption?: string | null;
/** A photo by another contributor, drawn as an empty frame in a page image. */
hidden?: boolean;
/** Up to five `#rrggbb` or `#rrggbb:share` colours of the picture, most of it first. */
palette?: readonly string[];
/** An 8 x 8 grid of edge density, row by row, one hex nibble (0 calm to f busy) per cell. */
busy?: string;
/** Where the picture's detail gathers (an edge-energy centroid), 0..1 of each edge; not a face detector. */
salient?: { x: number; y: number };
/** The picture's mean brightness, 0..255, when known. */
luma?: number;
}
/** The journey's words a text can be bound to. */
export interface BookJourneyWords {
title: string;
subtitle?: string | null;
entries?: readonly { id: number; title?: string | null; story?: string | null; location?: string | null }[];
/** The cover photo the journey names, for a layout that rebuilds a page from its entry. */
coverPhotoId?: number | null;
}
export interface BookOpsContext {
/** The journey gallery. When given, a photo an operation brings in must be in it. */
photos?: readonly BookPhotoInfo[];
journey?: BookJourneyWords;
/** Mints element and spread ids; tests pass a counter. */
newId?: (prefix: string) => string;
/**
* The journey's figures, route and entries, for the travel elements (maps,
* figures, countries, marks, lists) and for laying a page out again from its
* entry. Without it those operations are refused with `travel_unavailable`.
*/
travel?: BookTravelContext;
}
/**
* `created` names the elements and pages a call made. `dropped` names the
* elements whose content was on the page or parked with it before and is in
* neither place after (`droppedIds`): deleted by a `remove` with `park: false`,
* or left out by a layout, which carries photos and texts and drops shapes,
* maps, badges, stats, lists and icons, exactly as the Studio's layout picker
* does. The operations on whole pages (add, move, delete, restore) answer an
* empty list; a deleted page is named in `spreadIds`.
*/
export type BookEditResult =
| { ok: true; doc: BookDocument; spreadIds: string[]; created: string[]; dropped: string[] }
| { ok: false; error: BookOpError };
/** A refusal on its way out of an operation, to `caught`. */
class BookOpFailure extends Error {
constructor(readonly error: BookOpError) {
super(error.message);
}
}
export const failOp = (code: BookOpErrorCode, message: string, opIndex: number | null, elementId?: string): Error =>
new BookOpFailure({ code, message, opIndex, ...(elementId ? { elementId } : {}) });
/** Run `fn`, turning a refusal into a result. Anything else is a bug and is not swallowed. */
export function caught(fn: () => BookEditResult): BookEditResult {
try {
return fn();
} catch (e) {
if (e instanceof BookOpFailure) return { ok: false, error: e.error };
throw e;
}
}
/** The first issue of a failed parse, as a refusal; `opAt` is where the operation's index sits in the path. */
export function invalidOp(error: z.ZodError, opAt: number | null): BookOpError {
const issue = error.issues[0];
const path = issue?.path ?? [];
const at = path.length ? ` at ${path.map(String).join('.')}` : '';
const index = opAt === null ? undefined : path[opAt];
return {
code: 'invalid_op',
message: `${issue?.message ?? 'invalid operation'}${at}`,
opIndex: typeof index === 'number' ? index : null,
};
}
export const round2 = (v: number) => Math.round(v * 100) / 100;
/** Ids present in `after` and in no spread of `before`: the elements and pages a call made. */
export function createdIds(before: readonly BookSpread[], after: readonly BookSpread[]): string[] {
const ids = (list: readonly BookSpread[]) =>
list.flatMap((s) => [s.id, ...[...s.elements, ...s.parked].map((e) => e.id)]);
const old = new Set(ids(before));
return ids(after).filter((id) => !old.has(id));
}
/**
* Ids of elements in `before` whose content no spread of `after` holds any
* more, placed or parked: kept by id, or for a photo by its picture and for a
* text by its words, since a layout carries those into new frames with new
* ids. An empty frame or a blank text carries nothing and is never named.
*/
export function droppedIds(before: readonly BookSpread[], after: readonly BookSpread[]): string[] {
const all = (list: readonly BookSpread[]) => list.flatMap((s) => [...s.elements, ...s.parked]);
const kept = all(after);
const ids = new Set(kept.map((e) => e.id));
const photos = new Set(kept.flatMap((e) => (e.kind === 'photo' && e.photoId != null ? [e.photoId] : [])));
const words = new Set(kept.flatMap((e) => (e.kind === 'text' ? [e.text] : [])));
return all(before)
.filter((e) => {
if (ids.has(e.id)) return false;
if (e.kind === 'photo') return e.photoId != null && !photos.has(e.photoId);
if (e.kind === 'text') return e.text.trim() !== '' && !words.has(e.text);
return true;
})
.map((e) => e.id);
}
/**
* A page the engine is about to hand back, checked against the document's own
* schema, so a call can never answer with a page the save route would refuse.
* The page itself goes back as it was built; the parse only judges it.
*/
export function checkedSpread(spread: BookSpread, opIndex: number | null): BookSpread {
const parsed = bookSpreadSchema.safeParse(spread);
if (parsed.success) return spread;
const issue = parsed.error.issues[0];
const at = issue?.path.length ? ` at ${issue.path.map(String).join('.')}` : '';
throw failOp('invalid_op', `the page would not be valid: ${issue?.message ?? 'invalid page'}${at}`, opIndex);
}
/** A journey entry an operation refers to, checked against the journey when the context lists its entries. */
export function journeyEntry(entryId: number, ctx: BookOpsContext, opIndex: number | null): void {
const entries = ctx.journey?.entries;
if (entries && !entries.some((e) => e.id === entryId)) {
throw failOp('entry_not_found', `entry ${entryId} is not in this journey`, opIndex);
}
}
/** A photo an operation brings in, checked against the gallery when the context has one. */
export function galleryPhoto(photoId: number, ctx: BookOpsContext, opIndex: number | null): BookPhotoInfo | undefined {
if (!ctx.photos) return undefined;
const info = ctx.photos.find((p) => p.photoId === photoId);
if (!info) throw failOp('photo_not_in_gallery', `photo ${photoId} is not in this journey's gallery`, opIndex);
return info;
}
/** An element on the page by id; a parked one is named as parked. */
export function pageElement(spread: BookSpread, id: string, opIndex: number): BookElement {
const el = spread.elements.find((e) => e.id === id);
if (el) return el;
const parked = spread.parked.some((e) => e.id === id);
const message = parked
? `${id} is parked, not on the page; lay the page out again to bring it back`
: `no element ${id} on this page`;
throw failOp('element_not_found', message, opIndex, id);
}
/** The element itself, or a refusal when it is locked. */
export function unlockedElement<E extends BookElement>(el: E, opIndex: number): E {
if (el.locked) throw failOp('locked', `${el.id} is locked; it is left as it is`, opIndex, el.id);
return el;
}
/** A refusal when `add` more elements would not fit on the page. */
export function roomFor(spread: BookSpread, add: number, opIndex: number): void {
if (spread.elements.length + add > MAX_SPREAD_ELEMENTS) {
throw failOp('too_many_elements', `a page holds at most ${MAX_SPREAD_ELEMENTS} elements`, opIndex);
}
}
type Binding = NonNullable<BookTextElement['binding']>;
/**
* The words a binding reads, from what the context knows, or undefined.
*
* Dates and coordinates are set in a reader's language and notation, which the
* engine does not know: those need their words given.
*/
export function bookBoundWords(binding: Binding, ctx: BookOpsContext): string | undefined {
const j = ctx.journey;
const pick = (v: string | null | undefined) => (v && v.trim() ? v.slice(0, MAX_TEXT_LENGTH) : undefined);
switch (binding.source) {
case 'journey.title':
return pick(j?.title);
case 'journey.subtitle':
return pick(j?.subtitle);
case 'photo.caption':
return pick(ctx.photos?.find((p) => p.photoId === binding.photoId)?.caption);
case 'entry.title':
case 'entry.story':
case 'entry.location': {
if (binding.source === 'entry.location' && binding.format) return undefined;
const entry = j?.entries?.find((e) => e.id === binding.entryId);
if (!entry) return undefined;
if (binding.source === 'entry.title') return pick(entry.title || entry.location);
return pick(binding.source === 'entry.story' ? entry.story?.trim() : entry.location);
}
default:
return undefined;
}
}