Files
TREK/shared/src/book/book.schema.ts
T
Maurice 8fd46c4c18 feat: quality of life sweep from the feature discussions
Small requests from the discussions, each built end to end: planner and
map (cycling mode, date first headings, clear a day, stay times per day,
off-route stops, map lock, compact unplanned markers, pending stays,
cable car, transit quick picks), places (location filter, category from
OSM, open in maps, geo: link, image from an attachment, enrichment on
file import, places near a pin, place name language, Amap first in
China), lists and costs (packed counter, inherited sharing, Markdown
to-dos, payment notes, bookings CSV, my plan PDF), sharing (unplanned
places, travel only, without photos), journey (photo zoom, drafts,
photo order, trip suggestion, day jump), atlas (region counter, region
bucket list, places per country), vacay half company holidays, studio
copy and paste plus the book's own layouts, and RESTORE_FROM_BACKUP for
a first start restore.
2026-09-30 23:00:12 +02:00

907 lines
42 KiB
TypeScript

import { z } from 'zod';
/**
* The TREK Studio book document — what a photo book *is*, independent of how it
* is edited or rendered.
*
* Two decisions carry the rest of the format:
*
* 1. **Everything geometric is in millimetres.** Not pixels: a pixel means
* nothing to a print shop, and it changes meaning with the device pixel
* ratio and the browser zoom. CSS maps 1in onto 96px, 25.4mm and 72pt with a
* fixed ratio, so the editor can render the document at `transform: scale()`
* and the print renderer at 1:1 and land on the same page. Values are stored
* rounded to two decimals — 10µm, far below any imagesetter — so a JSON round
* trip cannot accumulate drift.
*
* 2. **The paint order is the array order.** `elements[0]` is at the back. No
* `zIndex` field that could disagree with itself, "bring to front" is a
* splice, and because the DOM paints in the same order the editor and the
* renderer agree for free.
*
* A text element may be *bound* to a piece of the journey (an entry's title, its
* story, a photo caption). A bound element re-reads its source when the book is
* opened, so fixing a typo in the journal fixes it in the book — until someone
* edits the text in Studio, which sets `overridden` and stops the sync.
*/
/**
* Two decimals of a millimetre. Anything finer is below print resolution.
*
* Bounded at ten metres in either direction, which is four orders of magnitude
* past the largest book anyone binds and still leaves room for an element
* parked well off the spread. Without a bound a stored `1e9` is a legal
* millimetre, and the editor and the PDF renderer both try to draw a sheet a
* thousand kilometres wide.
*/
const mm = z.number().finite().min(-10000).max(10000).transform(v => Math.round(v * 100) / 100);
const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'expected #rrggbb');
/**
* The limits a producer has to build to, named rather than written inline.
*
* Studio's auto layout is a producer: it pours journey data straight into a
* document, and a document over any one of these is refused whole by the save
* route — which the editor can only report as "couldn't save", for the rest of
* the session, because autosave keeps offering the same rejected book. So the
* caps are exported and the layout builds against them, instead of the two
* being written down twice and drifting apart.
*
* `MAX_SPREAD_ELEMENTS` belongs to this set and is declared with the spread it
* bounds, where the salvage pass reads it too.
*/
export const MAX_SPREADS = 150;
export const MAX_BOOK_TITLE = 200;
export const MAX_TEXT_LENGTH = 8000;
/** Codes on a map or a country page, and the names printed beside them. */
export const MAX_BOOK_COUNTRIES = 80;
export const MAX_COUNTRY_NAME = 80;
export const MAX_MAP_POINTS = 400;
export const MAX_MAP_POINT_LABEL = 120;
/** A recorded track: segments, and points within one segment. */
export const MAX_PATH_SEGMENTS = 40;
export const MAX_PATH_POINTS = 1200;
/**
* Every shape the book can draw — and, because a picture frame *is* a shape with
* a photograph inside it, every mask a photo can be cut to.
*
* One list rather than two. Canva keeps "shapes" and "frames" in separate
* panels, but underneath they are the same geometry, and duplicating it would
* mean a heart that exists as a frame but not as a shape, or the two drifting
* apart the first time one of them is adjusted.
*
* The first four entries are the shapes that existed before this list did. They
* stay at the front and keep their names, so a document saved by the earlier
* editor still parses.
*/
export const BOOK_SHAPES = [
'rect', 'ellipse', 'line', 'triangle',
'triangle-down', 'diamond', 'parallelogram', 'trapezoid',
'pentagon', 'hexagon', 'hexagon-flat', 'heptagon', 'octagon',
'arch', 'half-circle', 'quarter-circle', 'capsule', 'squircle',
'star-4', 'star-5', 'star-6', 'star-8', 'star-12', 'burst', 'seal', 'sparkle',
'arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'arrow-both',
'chevron-right', 'chevron-left', 'arrow-bent',
'bubble-round', 'bubble-square', 'bubble-oval', 'bubble-think',
'heart', 'cloud', 'cloud-puffy', 'drop', 'moon', 'sun',
'flower-5', 'flower-6', 'leaf', 'cross', 'plus', 'shield', 'gear',
'ticket', 'wave', 'mountain', 'compass', 'pin',
'blob-1', 'blob-2', 'blob-3', 'blob-4',
'banner-ribbon', 'banner-pennant', 'banner-bookmark', 'banner-flag',
] as const;
export type BookShapeId = (typeof BOOK_SHAPES)[number];
/**
* The families a book may be set in.
*
* `sans`, `serif` and `display` are the three the format started with and are
* kept by name, so every document written so far still parses — they now point
* at bundled faces rather than at whatever the rendering machine happened to
* have installed. See client/src/components/Studio/bookFonts.ts.
*/
export const BOOK_FONTS_IDS = [
'sans', 'inter', 'serif', 'garamond', 'playfair', 'display', 'bebas',
] as const;
export type BookFontFamily = (typeof BOOK_FONTS_IDS)[number];
export const bookFrameSchema = z.object({
/** Millimetres from the left edge of the spread. Negative means bleed. */
x: mm,
/** Millimetres from the top edge of the spread. */
y: mm,
w: mm.refine(v => v > 0, 'width must be positive'),
h: mm.refine(v => v > 0, 'height must be positive'),
});
export type BookFrame = z.infer<typeof bookFrameSchema>;
const elementBase = {
id: z.string().min(1),
frame: bookFrameSchema,
/** Degrees, clockwise. */
rotation: z.number().finite().default(0),
opacity: z.number().min(0).max(1).default(1),
locked: z.boolean().default(false),
};
export const bookPhotoElementSchema = z.object({
...elementBase,
kind: z.literal('photo'),
/**
* `trek_photos.id` — never a resolved URL, or the document would not survive a
* provider change and Studio could not warn about print resolution.
*
* Null is an empty frame: a template lays out where the pictures go before it
* knows which pictures those are, and a placeholder you can drop a photo onto
* is the whole point of a template.
*/
photoId: z.number().int().positive().nullable().default(null),
fit: z.enum(['cover', 'contain']).default('cover'),
/** Where the interesting part of the picture is, 0..1 of each edge. */
focalX: z.number().min(0).max(1).default(0.5),
focalY: z.number().min(0).max(1).default(0.5),
radius: mm.default(0),
filter: z.enum(['none', 'bw', 'warm', 'cool', 'fade', 'contrast']).default('none'),
/**
* Cut the picture to a shape. Null is the plain rectangle every photo starts
* as — and stays, unless someone asks for a heart.
*
* The mask is stretched to the frame rather than kept square, which is what
* makes a frame usable: you place a wide star across a spread and it is wide.
* A shape you cannot resize freely is an ornament, not a frame.
*/
mask: z.enum(BOOK_SHAPES).nullable().default(null),
/**
* Decoration *around* the picture — a Polaroid's thick chin, a print's white
* border, the shadow of a photo lying on a page.
*
* Not a mask and not a shape: it adds to the frame instead of cutting into it,
* and it is the one thing you cannot express with either.
*/
frameStyle: z.enum(['none', 'polaroid', 'white', 'shadow', 'film', 'tape']).default('none'),
});
export const bookTextElementSchema = z.object({
...elementBase,
kind: z.literal('text'),
text: z.string().max(MAX_TEXT_LENGTH).default(''),
font: z.enum(BOOK_FONTS_IDS).default('sans'),
/** Points. */
size: z.number().min(4).max(200).default(11),
weight: z.union([z.literal(400), z.literal(500), z.literal(600), z.literal(700)]).default(400),
italic: z.boolean().default(false),
align: z.enum(['left', 'center', 'right', 'justify']).default('left'),
/** Multiple of the font size. */
leading: z.number().min(0.7).max(3).default(1.45),
/** Ems, may be negative. */
tracking: z.number().min(-0.2).max(1).default(0),
color: hex.default('#1a1a1a'),
/** Where this text came from, when it came from the journey. */
binding: z
.object({
source: z.enum(['journey.title', 'journey.subtitle', 'entry.title', 'entry.story', 'entry.location', 'entry.date', 'photo.caption']),
entryId: z.number().int().optional(),
photoId: z.number().int().optional(),
/**
* How to set the source, where it can be set more than one way.
*
* Only `entry.location` uses it, and only because a stop is both a name
* and a point: absent means the place as it is written in the journal,
* `dms` and `decimal` mean its coordinates. It is a property of the
* binding rather than a second `source`, because both read the one field
* the journal actually records — and because a document written with an
* unknown *source* would fail the union and normalise to an empty book,
* while an unknown key inside the binding is dropped and the page
* survives. A print format is not worth a lost book.
*/
format: z.enum(['dms', 'decimal']).optional(),
/**
* The raw value this element was last set from — an ISO date, a
* `lat,lng` pair — for the sources whose words are *formatted* rather
* than copied.
*
* Without it, re-reading a date cannot tell a journal that changed from a
* page that was set in another language, or in small caps, or as part of
* a longer line: it would rewrite all four alike, in the reader's locale,
* on open. With it the question is exactly the right one — has the entry
* moved? — and an element from before this existed carries no value and
* is therefore left alone, which is the only safe thing to do to a book
* somebody has already printed.
*/
value: z.string().max(200).optional(),
})
.nullable()
.default(null),
/** True once a human edited the text; stops it re-reading its source. */
overridden: z.boolean().default(false),
});
export const bookShapeElementSchema = z.object({
...elementBase,
kind: z.literal('shape'),
shape: z.enum(BOOK_SHAPES).default('rect'),
fill: hex.nullable().default('#111827'),
/**
* Fade the fill out towards one edge. A cover almost always needs this: a
* flat panel behind the title cuts the photograph in half, while a fade lets
* the picture keep going and still leaves the words readable.
*/
gradient: z.enum(['none', 'up', 'down']).default('none'),
stroke: hex.nullable().default(null),
strokeWidth: mm.default(0),
/**
* A dashed rule is a different thing from a solid one — it reads as a fold, a
* route or a cut mark rather than as a line under a heading. The dash length
* follows the stroke width, so it stays proportional at any size.
*/
strokeStyle: z.enum(['solid', 'dashed', 'dotted']).default('solid'),
radius: mm.default(0),
});
/**
* ── The travel elements ────────────────────────────────────────────────────
*
* A photo book of a journey is not a photo album: the trip carries facts —
* which countries, how far, how long, which way round — and those facts make
* pages that photographs cannot. This is what Polarsteps' printed books do
* better than a generic book maker, and it is the one thing TREK is in a
* position to do well, because the data is already in the trips hanging off the
* journey.
*
* **Every one of them carries its own values.** The numbers are resolved when
* the element is placed and stored in the document, and the renderer reads them
* from there — it never fetches. Three reasons, and the third is the one that
* decides it:
*
* 1. The print renderer is headless Chromium loading a document. A page that
* needs a logged-in API call to know what it says is a page that prints
* blank on the day the token expires.
* 2. A book is a record of a trip as it was. Adding a stop next year should not
* silently rewrite the distance printed in last year's book.
* 3. It makes the elements ordinary. They move, resize, lock, undo and park like
* everything else, because they *are* like everything else.
*
* `stale` is how the editor offers the other behaviour without giving up any of
* that: it marks a snapshot that no longer matches the journey, and the
* inspector shows a refresh button. Updating is a choice, not a surprise.
*/
/** What the travel elements share with text: they are typeset, not drawn. */
const typeset = {
font: z.enum(BOOK_FONTS_IDS).default('sans'),
color: hex.default('#1a1a1a'),
/**
* The one colour that carries emphasis — the figure in a stat, the route on
* a map, the fill of a chip.
*
* Black by default, not the app's orange. A book is printed and a printed
* page is ink on paper; a colour that arrives without being chosen turns
* every travel element into the same accent whether or not the book wants
* one. Picking a colour is one click and is what the swatches are for — and
* the map's dark style overrides it anyway, because black lines on a dark
* map are lines nobody can see.
*/
accent: hex.default('#111111'),
/** Scales every piece of type in the element at once. 1 is the drawn default. */
textScale: z.number().min(0.4).max(3).default(1),
/**
* How heavy the type is set.
*
* The travel elements draw at several sizes at once — a figure over its
* label, a country name over its outline — and this moves all of them
* together, the way `textScale` moves their size. Without it the only way to
* lighten a stats block was to not use one.
*/
weight: z.union([z.literal(400), z.literal(500), z.literal(600), z.literal(700)]).default(700),
/** Set when the journey has moved on since these values were taken. */
stale: z.boolean().default(false),
};
/** Kilometres or miles. Stored metric; the element converts when it draws. */
export const bookUnitsSchema = z.enum(['metric', 'imperial']);
export const bookMapElementSchema = z.object({
...elementBase,
...typeset,
kind: z.literal('map'),
/**
* Where the map underneath comes from.
*
* `vector` draws country outlines from the bundled boundaries: it carries no
* licence, fetches nothing at render time, and prints sharp at any size,
* which is why it is the default and why a book can be exported offline.
*
* `tiles` and `static` use the map provider the instance is already
* configured with, for a book that wants real geography — streets, terrain,
* coastline detail an outline cannot give. Both fetch at render time and both
* carry an attribution the element prints, because the licence requires it
* and a book is a published work. A tiled map is also raster: it is cut for a
* zoom level, so printing it much larger than it was fetched goes soft.
*/
source: z.enum(['vector', 'tiles', 'static']).default('vector'),
/**
* The tile template, frozen when the element was placed.
*
* Kept in the document rather than read from settings at render time for the
* same reason the figures are: a page that resolves its own contents from an
* account's current settings is a page that changes when someone else edits
* a preference.
*/
tileUrl: z.string().max(500).default(''),
/** What the licence requires the page to say. Printed small, in a corner. */
attribution: z.string().max(200).default(''),
/**
* Zoom for the tiled sources. Null lets the element choose one that fits the
* route, which is right almost always and wrong when someone wants the
* street detail of a single city.
*/
zoom: z.number().int().min(0).max(19).nullable().default(null),
style: z.enum(['minimal', 'outline', 'dark', 'paper']).default('minimal'),
/** Country silhouettes under the route, drawn from the bundled boundaries. */
showLand: z.boolean().default(true),
showRoute: z.boolean().default(true),
showPins: z.boolean().default(true),
showLabels: z.boolean().default(false),
/**
* ── How the line is drawn ────────────────────────────────────────────
*
* `plain` is one stroke in the element's ink: what every map in every book
* made so far already draws, and the default for exactly that reason.
*
* `drawn` is the printed-atlas treatment — a light line over a dark casing,
* or the reverse on pale paper — which is what keeps a single route legible
* across near-black bathymetry and bright desert in the same picture. It
* cannot be the default: a stored book would change the day it was opened.
*/
routeStyle: z.enum(['plain', 'drawn']).default('plain'),
/**
* Whether a long leg bows.
*
* A straight line between two stops a continent apart is a claim about the
* journey that is obviously false, and drawing it as a shallow curve is how
* printed maps have always said "this part was a flight". Only ever applied
* to the inferred stop-to-stop line: bowing a recorded GPS track would draw
* a journey that did not happen.
*/
routeArc: z.enum(['straight', 'bow']).default('straight'),
/**
* Whether the bowed legs are dashed.
*
* The dash then MEANS something — this leg is inferred, not recorded — which
* is why it applies to the bowed legs alone rather than to the whole line.
*/
routeDash: z.enum(['solid', 'arcs']).default('solid'),
/**
* What a stop looks like.
*
* `photo` draws the stop as a small round picture with a white ring, from the
* photograph the point carries, and falls back to a dot where there is none.
* That fallback is the designed state rather than a failure: a printed page
* with a few plain dots between the pictures reads as a route, and one with
* empty rings reads as a bug.
*/
pinStyle: z.enum(['dot', 'photo']).default('dot'),
/** ISO-3166-1 alpha-2, the countries whose outlines to draw. */
countries: z.array(z.string().length(2)).max(MAX_BOOK_COUNTRIES).default([]),
/**
* The route, in order. Capped where a printed line stops gaining from more
* points — a book page cannot resolve four hundred stops anyway.
*/
points: z
.array(z.object({
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
label: z.string().max(MAX_MAP_POINT_LABEL).default(''),
/**
* One photograph from this stop, for a marker that shows it.
*
* `trek_photos.id`, never a resolved URL, for the same reason a photo
* element stores an id: a book has to survive a provider change. Null is
* ordinary and means the marker is a dot.
*/
photoId: z.number().int().positive().nullable().default(null),
}))
.max(MAX_MAP_POINTS)
.default([]),
/**
* The way the trip was actually travelled, where the trip knows it.
*
* `points` are the stops, and a line drawn straight between them is a claim
* about the journey that is almost always false: it crosses mountains,
* water and borders that the trip went around. TREK already stores routed
* geometry per place from the planner and from imported GPX, so where that
* exists the line follows the road instead of the ruler.
*
* Segments rather than one list, because two tracks that do not join must
* not be drawn as though they did: the gap between the end of one day's
* driving and the start of the next is a gap, not a leg.
*
* `[lat, lng]`, matching the order the tracks endpoint answers in, and thinned
* when it is frozen into the document: a printed line gains nothing from a
* point every fifteen metres, and a book carrying every GPS fix of a
* three-week trip is a document nobody can save.
*/
path: z
.array(z.array(z.tuple([z.number().min(-90).max(90), z.number().min(-180).max(180)])).max(MAX_PATH_POINTS))
.max(MAX_PATH_SEGMENTS)
.default([]),
/**
* The roads each leg actually took, when somebody asked for them.
*
* ── Why this is not `path` ───────────────────────────────────────────
*
* `path` is a recorded track: a GPS trace of where somebody went, which has
* no idea which stop it started at and is drawn instead of the stop chain.
* This is per leg — entry `i` is the way from stop `i` to stop `i + 1`, and
* `null` is a leg with no road answer, which is most journeys' flights.
*
* Per leg rather than one line, because a partial road route drawn as one
* would be a lie by omission: the renderer replaces the whole stop chain the
* moment it has a track, so a journey with roads for three legs out of twelve
* would print three legs and drop the other nine.
*
* Frozen into the document like every other travel figure. A page that asks a
* routing service for its line at print time is a page that changes when a
* road is rerouted, and prints empty when the export runs offline.
*/
roads: z
.array(z.array(z.tuple([z.number().min(-90).max(90), z.number().min(-180).max(180)])).max(200).nullable())
.max(400)
.default([]),
/**
* How much room to leave around what is drawn, as a share of its own extent.
*
* The view is fitted to the stops, not to the countries: a trip that stayed
* in Berlin is a map of Berlin, not a map of Germany with two dots on it.
* Country outlines are still drawn, they simply run off the edge like the
* geography they are.
*/
/*
* Half again the extent of the route, rather than a fifth.
*
* A map fitted tightly to its own stops is a diagram; the air around it is
* what makes it a place. The reference books all leave room for the coast to
* arrive and the neighbouring country to show, and a route that touches its
* own frame on two sides reads as cropped rather than as composed.
*/
fitPadding: z.number().min(0).max(4).default(0.5),
/**
* Fit to the countries rather than to the stops.
*
* The default, because it is the picture a travel book usually wants: the
* country, with the route inside it. Fitting to the stops is the other half
* of the choice and matters as soon as the trip is smaller than the country
* it happened in — a week in Berlin drawn as the whole of Germany is two
* dots in a large empty shape.
*/
fitToCountries: z.boolean().default(true),
/**
* The shape the map is cut to.
*
* `rect` is a map in a box, which is what a map usually is and what a full
* page wants. `country` cuts it to the outline of the countries themselves,
* so the picture *is* the country: no frame, no box, the page showing through
* around the coastline. That is the version worth putting next to a
* photograph, and it is the one thing a rectangle can never be.
*
* Cutting only makes sense against the whole outline, so it fits to the
* countries whatever `fitToCountries` says: a city-sized view cut to the
* shape of a country is an evenly coloured rectangle with a route on it.
*/
clip: z.enum(['rect', 'country']).default('rect'),
/**
* Which trip this map is of, when it is of one.
*
* Null is the whole journey, which is what a map has always been. A journey
* is a collection of trips, though, and printing two of them as one route
* draws a line from the last stop of the first to the first stop of the
* second: a leg nobody travelled, usually the longest one on the page.
*
* Stored rather than derived because the points are frozen into the element
* when it is placed. Without it, bringing a map up to date would quietly turn
* a map of Iceland back into a map of everywhere.
*/
tripId: z.number().int().positive().nullable().default(null),
});
export const BOOK_METRICS = ['distance', 'days', 'steps', 'photos', 'countries', 'places', 'furthest'] as const;
export type BookMetric = (typeof BOOK_METRICS)[number];
export const bookStatsElementSchema = z.object({
...elementBase,
...typeset,
kind: z.literal('stats'),
/** Which figures, in the order they are drawn. */
metrics: z.array(z.enum(BOOK_METRICS)).max(7).default(['distance', 'days', 'steps', 'photos']),
layout: z.enum(['grid', 'row', 'column']).default('grid'),
showIcons: z.boolean().default(true),
units: bookUnitsSchema.default('metric'),
/**
* Metric to value. Distance is metres, everything else a plain count.
*
* Anything that is not a metric is dropped rather than rejected: both readers
* only ever index by a `BOOK_METRICS` member, so a stray key is payload that
* gets persisted and re-served to every viewer without ever being drawn.
* Filtered instead of typed as `z.record(z.enum(BOOK_METRICS), …)`, because on
* zod 4 an enum key makes the record exhaustive — every metric would become
* required and every stats element written so far would fail, then be thrown
* away as unreadable.
*/
values: z.record(z.string(), z.number().finite()).default({})
.transform(v => Object.fromEntries(
Object.entries(v).filter(([k]) => (BOOK_METRICS as readonly string[]).includes(k)),
)),
});
export const bookCountriesElementSchema = z.object({
...elementBase,
...typeset,
kind: z.literal('countries'),
/** ISO-3166-1 alpha-2, in visit order. */
codes: z.array(z.string().length(2)).max(MAX_BOOK_COUNTRIES).default([]),
/** Names as resolved when placed, so the page does not depend on a lookup. */
names: z.array(z.string().max(MAX_COUNTRY_NAME)).max(MAX_BOOK_COUNTRIES).default([]),
layout: z.enum(['list', 'grid', 'column']).default('list'),
/** The silhouette behind each name — the thing that makes the page read as a map. */
showOutline: z.boolean().default(true),
showFlag: z.boolean().default(false),
showName: z.boolean().default(true),
align: z.enum(['left', 'center', 'right']).default('center'),
});
export const BOOK_BADGES = [
'flag', 'date', 'day', 'coords', 'country', 'distance', 'weather', 'altitude',
'mood',
] as const;
export type BookBadgeVariant = (typeof BOOK_BADGES)[number];
/**
* The small marks: a flag, a date set as a numeral, a "DAY 5" chip, a line of
* coordinates. Individually trivial, and collectively most of what makes a
* printed travel page look composed rather than typed.
*/
export const bookBadgeElementSchema = z.object({
...elementBase,
...typeset,
kind: z.literal('badge'),
variant: z.enum(BOOK_BADGES).default('date'),
/** The resolved value — "13", "48°51'N 2°21'E", "ICELAND". */
text: z.string().max(200).default(''),
/** The line under it — a month, a place, a unit. */
sub: z.string().max(200).default(''),
/**
* What the mark is *of*, when the drawing needs more than the words.
*
* ISO-3166-1 alpha-2 for a flag or a country silhouette, and the journal's
* own key for a mood or a weather mark — `amazing`, `partly` — which is what
* lets the icon and the palette come from the journey page rather than being
* listed a second time here.
*
* It was two characters once, back when only flags used it, and a mood mark
* placed against that contract took the whole book down with it: the save was
* refused and the document normalised to nothing. A country code is still two
* characters; the field is simply no longer only that.
*/
code: z.string().max(24).nullable().default(null),
style: z.enum(['plain', 'chip', 'outline', 'stacked']).default('plain'),
/**
* Let the mark pick its own text colour.
*
* The same bargain the page numbers make: automatic until somebody chooses,
* and choosing is what turns it off. A chip is a filled capsule, so its words
* have to answer to the fill rather than to the page: white on ink, ink on a
* pale accent. Doing that by hand means changing two colours every time you
* change one, and getting it wrong means a black day counter on a black chip,
* which is what happened before this existed.
*/
autoColor: z.boolean().default(true),
/**
* Whether the mark draws its icon, and whether it draws its words.
*
* Two flags rather than one three-valued mode, because that is what the
* renderer actually asks — and because a document written before they
* existed reads `undefined` as "draw it", which is what it always did. The
* panel offers the three combinations that mean something (both, icon only,
* words only); an empty mark is not one of them.
*/
showIcon: z.boolean().default(true),
showLabel: z.boolean().default(true),
/**
* The icon's own colour, and whether it picks it.
*
* Automatic is a mood's palette from the journal — the pink of "amazing",
* the amber of "good" — and the element's accent for everything else, which
* is what these marks have always drawn. It has to stay a flag rather than a
* nullable colour: every badge already in a book carries `iconColor`'s
* default once it is parsed, and reading that as a choice would repaint every
* mood mark in the field to a flat near-black.
*/
autoIconColor: z.boolean().default(true),
iconColor: hex.default('#111111'),
});
/**
* A drawing from the icon set the app is already built out of.
*
* Not a member of BOOK_SHAPES, and deliberately: a shape is a filled outline
* the editor scales by moving its points, while an icon is a stroked drawing of
* several parts that has to keep its proportions to stay legible — a compass
* stretched to twice its width stops reading as a compass. They also come from
* opposite places. A shape is drawn here and lives in this file; an icon is one
* of about fourteen hundred names in lucide, and listing them in the contract
* would mean a document that stops parsing the day the library renames one.
*
* So the name travels as a bounded string and the renderer resolves it, falling
* back the way the rest of TREK already does for a plugin's icon. A book that
* outlives an icon loses that drawing, not the page it was on.
*/
export const bookIconElementSchema = z.object({
...elementBase,
kind: z.literal('icon'),
/** A lucide export name, PascalCase — "Compass", "Plane", "MountainSnow". */
name: z.string().regex(/^[A-Z][A-Za-z0-9]*$/, 'expected a lucide icon name').max(60),
color: hex.default('#111827'),
/**
* How heavy the drawing is, against lucide's own 24-unit grid — not
* millimetres, which is why it is not called `strokeWidth`: three separate
* places in the client rescale a field of that name by the page width when a
* spread moves between books, and an icon's weight is not a length.
*/
lineWidth: z.number().min(0.25).max(4).default(2),
});
/**
* A journal entry's pros and cons, set as a page.
*
* TREK's entries already carry them as two lists, and two lists is exactly what
* a text element cannot express: run together as a paragraph they lose the
* pairing, and run as one column they lose which side each line is on. Two
* marked columns is how a travel diary has always printed this.
*/
export const bookListElementSchema = z.object({
...elementBase,
...typeset,
kind: z.literal('list'),
items: z
.array(z.object({
text: z.string().max(400),
/** Which column the line belongs to, and which mark it gets. */
tone: z.enum(['pro', 'con', 'plain']).default('plain'),
}))
.max(60)
.default([]),
/** Pros beside cons, or one column with the marks inline. */
layout: z.enum(['columns', 'stacked']).default('columns'),
showMarks: z.boolean().default(true),
/** A heading over each column — empty for none. */
proLabel: z.string().max(80).default(''),
conLabel: z.string().max(80).default(''),
});
/**
* How much may stand on one spread.
*
* A limit rather than a taste: the document is saved whole on every autosave,
* and a page nobody could design is a page somebody's browser has to serialise.
*
* Sixty was below what this app's own layouts draw. A full itinerary list is
* two columns of thirteen stops, and a stop costs three elements — its date,
* its name and the rule under it — so the spread the auto layout produces is
* eighty, and it was refused every time (#2085). A limit the product cannot
* build to is not a limit, it is a bug with a constant in front of it.
*
* Ninety rather than eighty, so the next row of anything does not land here
* again. It stays a serialisation limit: an element of this kind measures a few
* hundred bytes, which puts the largest document the contract admits at under
* four megabytes against the book route's eight (server/src/bootstrap.ts).
*/
export const MAX_SPREAD_ELEMENTS = 90;
export const bookElementSchema = z.discriminatedUnion('kind', [
bookPhotoElementSchema,
bookTextElementSchema,
bookShapeElementSchema,
bookMapElementSchema,
bookStatsElementSchema,
bookCountriesElementSchema,
bookBadgeElementSchema,
bookListElementSchema,
bookIconElementSchema,
]);
export type BookElement = z.infer<typeof bookElementSchema>;
export type BookIconElement = z.infer<typeof bookIconElementSchema>;
export type BookPhotoElement = z.infer<typeof bookPhotoElementSchema>;
export type BookTextElement = z.infer<typeof bookTextElementSchema>;
export type BookShapeElement = z.infer<typeof bookShapeElementSchema>;
export type BookMapElement = z.infer<typeof bookMapElementSchema>;
export type BookStatsElement = z.infer<typeof bookStatsElementSchema>;
export type BookCountriesElement = z.infer<typeof bookCountriesElementSchema>;
export type BookBadgeElement = z.infer<typeof bookBadgeElementSchema>;
export type BookListElement = z.infer<typeof bookListElementSchema>;
export type BookUnits = z.infer<typeof bookUnitsSchema>;
export const bookSpreadSchema = z.object({
id: z.string().min(1),
/**
* Which leaf of the bound book this is.
*
* 'inner' is a double spread; the other four are single pages. 'cover' and
* 'back' are the outside of the book. 'first' is the right-hand page a book
* opens onto, and 'last' the left-hand page before the back cover: the
* inside of each cover is blank in a bound book, so the first and the last
* inner page stand alone rather than facing anything (#2317). Both are
* optional so that a document from before they existed still reads.
*/
role: z.enum(['cover', 'first', 'inner', 'last', 'back']).default('inner'),
background: hex.nullable().default(null),
/** Back to front. */
elements: z.array(bookElementSchema).max(MAX_SPREAD_ELEMENTS).default([]),
/**
* Content that belongs to this spread but is not currently placed.
*
* Changing to a layout with fewer frames must not destroy the pictures the old
* one held — the user is trying an arrangement, not deleting their photographs,
* and a single wrong click on a text-only layout would otherwise cost them
* everything on the page. Parked elements come back the moment a layout with
* room for them is applied.
*/
parked: z.array(bookElementSchema).max(MAX_SPREAD_ELEMENTS).default([]),
/** The journey entry this spread was generated from, if any. */
entryId: z.number().int().nullable().default(null),
});
export type BookSpread = z.infer<typeof bookSpreadSchema>;
/**
* Page numbers.
*
* Set here, on the document, and filled by the renderer — which is the only
* place that knows what number a given page is. Letting pdf-lib stamp them
* afterwards would put a different number in the PDF than the one the editor
* showed, and a book you cannot proofread on screen is not much of an editor.
*
* `startAt` exists because the first inner spread is rarely page 1 in the
* finished object: the cover is a separate sheet, and some binders count it and
* some do not.
*/
export const bookPageNumbersSchema = z.object({
show: z.boolean().default(false),
/** Which number the first inner spread's left page carries. */
startAt: z.number().int().min(0).max(9999).default(2),
position: z.enum(['outer', 'inner', 'centre']).default('outer'),
/** Millimetres from the trim edge. */
margin: mm.default(12),
size: z.number().min(4).max(48).default(8),
/**
* Pick the folio colour from what it lands on, rather than using `color`.
*
* A book has dark pages and light ones, and one fixed colour is wrong on half
* of them — a grey folio disappears into a full-bleed night photograph and
* again into a black page. The renderer decides per page; `color` is what it
* uses when this is off, and choosing a colour turns it off.
*/
autoColor: z.boolean().default(true),
color: hex.default('#8a8578'),
font: z.enum(BOOK_FONTS_IDS).default('sans'),
});
export type BookPageNumbers = z.infer<typeof bookPageNumbersSchema>;
export const bookPageSetupSchema = z.object({
preset: z.enum(['square-210', 'square-300', 'a4-landscape', 'a4-portrait', 'a5-landscape', 'custom']).default('square-210'),
/**
* A single page. A double spread is drawn twice this wide.
*
* These four fall back rather than fail, like `version` below. The salvage
* pass only strips unreadable *elements*, so a page block the contract
* cannot read takes the whole book down to an empty one — and that empty
* book is what the server writes back. A nonsense dimension degrading to the
* preset is the smaller loss by a long way.
*/
pageWidth: mm.refine(v => v > 0, 'page width must be positive').catch(210).default(210),
pageHeight: mm.refine(v => v > 0, 'page height must be positive').catch(210).default(210),
bleed: mm.refine(v => v >= 0, 'bleed cannot be negative').catch(3).default(3),
safe: mm.refine(v => v >= 0, 'safe margin cannot be negative').catch(5).default(5),
pageNumbers: bookPageNumbersSchema.default(() => bookPageNumbersSchema.parse({})),
});
export type BookPageSetup = z.infer<typeof bookPageSetupSchema>;
/** How many layouts a book keeps for reuse (#2316). */
export const MAX_BOOK_LAYOUTS = 24;
/**
* A spread's arrangement, kept to lay other pages out with (#2316).
*
* The design as drawn, in the millimetres of the page it was drawn on, with the
* pictures taken out: applying it to another page pours that page's photos and
* words into it, and the page size it came from scales it onto a book whose
* trim changed since. Stored in the book rather than per person, so everyone
* editing the book sees and uses the same set.
*/
export const bookLayoutSchema = z.object({
id: z.string().min(1).max(40),
name: z.string().trim().min(1).max(60),
/** Double spread or single page: a layout only fits the kind it was drawn on. */
role: z.enum(['inner', 'single']).default('inner'),
pageWidth: z.number().positive().max(2000),
pageHeight: z.number().positive().max(2000),
background: hex.nullable().default(null),
elements: z.array(bookElementSchema).max(MAX_SPREAD_ELEMENTS).default([]),
});
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),
title: z.string().max(MAX_BOOK_TITLE).default(''),
page: bookPageSetupSchema.default(() => bookPageSetupSchema.parse({})),
spreads: z.array(bookSpreadSchema).max(MAX_SPREADS).default([]),
/** The book's own layouts (#2316). Optional so a document from before still reads. */
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) };
}),
};
}