mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
fix(studio): the book tools state every field once, and the server typechecks within its memory again
- The element, spread, document, page operation and card unions carry a name of their own, so the declarations of every schema that holds them refer to them instead of spelling them out again. The shared declarations shrink from 9 to 5.5 MB and the server's typecheck from 4.4 to 2.9 GB, so the build fits the image's 4 GB again. - edit_book_page and revise_book_design take add by what the element is, with an id of its own; its look follows with style by that id in the same call, every look field described once. A change an assistant card kept with its look on add still reads and runs. - restore_book_page states what every element has once, then each kind's own fields and the type the five travel kinds share, without defaults; the page is read as a spread again before it goes back. - The theme links are explained once in the page edit's description, and style points a text's face, a shape's paint and outline and a photo's crop look at their one full description. - The lockup roles live in a module of their own, so the operation schemas no longer import the lockup engine, whose cycle left the operations unset in the shared tests. - The theme switches no longer fall through a comment, the newer document test follows the current revision, the line inspector names its stroke keys, a text's anchor reads no physical side, two shapes record the proportions they are drawn at, and the photo look and line tests follow the slider and the folded ornaments.
This commit is contained in:
@@ -146,13 +146,11 @@ export function LineInspector({
|
||||
<Line label={t('journey.studio.strokeStyle')} wide>
|
||||
<Choice
|
||||
value={el.dash}
|
||||
options={(
|
||||
[
|
||||
['solid', 'strokeSolid'],
|
||||
['dashed', 'strokeDashed'],
|
||||
['dotted', 'strokeDotted'],
|
||||
] as const
|
||||
).map(([value, key]) => ({ value, label: t(`journey.studio.${key}`) }))}
|
||||
options={[
|
||||
{ value: 'solid' as const, label: t('journey.studio.strokeSolid') },
|
||||
{ value: 'dashed' as const, label: t('journey.studio.strokeDashed') },
|
||||
{ value: 'dotted' as const, label: t('journey.studio.strokeDotted') },
|
||||
]}
|
||||
onPick={(dash) => set({ dash })}
|
||||
/>
|
||||
</Line>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PHOTO_DUOTONES, PHOTO_LOOK_STARTS, bookPhotoElementSchema, type BookPhotoElement } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||
import { cleanup, fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||
import { PhotoCropExtras, PhotoLookSections } from './StudioPhotoLook';
|
||||
|
||||
/**
|
||||
@@ -40,9 +40,13 @@ describe('beside the crop', () => {
|
||||
expect(set).not.toHaveBeenCalled();
|
||||
fireEvent.pointerUp(slider);
|
||||
expect(set).toHaveBeenCalledWith({ zoom: 1.5 });
|
||||
fireEvent.change(slider, { target: { value: '1' } });
|
||||
fireEvent.blur(slider);
|
||||
expect(set).toHaveBeenLastCalledWith({ zoom: undefined });
|
||||
// A zoomed picture taken back to 100 % leaves the zoom out of the document.
|
||||
cleanup();
|
||||
const zoomed = mount('crop', photo({ zoom: 1.5 }));
|
||||
const back = screen.getByRole('slider');
|
||||
fireEvent.change(back, { target: { value: '1' } });
|
||||
fireEvent.blur(back);
|
||||
expect(zoomed.set).toHaveBeenLastCalledWith({ zoom: undefined });
|
||||
});
|
||||
|
||||
it('FE-STUDIO-LOOKUI-002: mirrors the picture and takes the mirror away again', () => {
|
||||
@@ -71,7 +75,9 @@ describe('beside the crop', () => {
|
||||
mount('crop', photo({ zoom: 2 }));
|
||||
expect(screen.getByText('journey.studio.photo.dpi 127')).toBeTruthy();
|
||||
expect(screen.getByText('journey.studio.photo.dpiLow 200')).toBeTruthy();
|
||||
expect(screen.getByRole('status').className).toContain('is-low');
|
||||
// The slider's own readout is an output, a status too; the warning is the one marked low.
|
||||
const warning = screen.getAllByRole('status').find((el) => el.classList.contains('st-photo-dpi'));
|
||||
expect(warning?.className).toContain('is-low');
|
||||
});
|
||||
|
||||
it('FE-STUDIO-LOOKUI-006: shows nothing for an empty frame', () => {
|
||||
|
||||
@@ -65,7 +65,9 @@ export function caseStyle(el: BookTextElement): {
|
||||
return {};
|
||||
}
|
||||
|
||||
const ANCHOR = { left: 'start', center: 'middle', right: 'end', justify: 'start' } as const;
|
||||
/** The SVG anchor of a text's alignment, in the page's own left to right coordinates; justified sets from the start. */
|
||||
const anchorOf = (align: BookTextElement['align']): 'start' | 'middle' | 'end' =>
|
||||
align === 'center' ? 'middle' : align === 'right' ? 'end' : 'start';
|
||||
/** How far across the frame, and along a curve, each anchor sits. */
|
||||
const ACROSS = { start: 0, middle: 0.5, end: 1 } as const;
|
||||
/** How far down the frame's spare room the block of lines sits. */
|
||||
@@ -76,13 +78,13 @@ export function lineAnchor(
|
||||
align: BookTextElement['align'],
|
||||
w: number
|
||||
): { x: number; anchor: 'start' | 'middle' | 'end' } {
|
||||
const anchor = ANCHOR[align];
|
||||
const anchor = anchorOf(align);
|
||||
return { x: w * ACROSS[anchor], anchor };
|
||||
}
|
||||
|
||||
/** The same anchor along a curve: where on the path the words are pinned. */
|
||||
export function curveAnchor(align: BookTextElement['align']): { offset: string; anchor: 'start' | 'middle' | 'end' } {
|
||||
const anchor = ANCHOR[align];
|
||||
const anchor = anchorOf(align);
|
||||
return { offset: `${ACROSS[anchor] * 100}%`, anchor };
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,8 @@ describe('on the page', () => {
|
||||
const { container } = render(<SpreadView spread={spread([rect, plain, photo])} page={page} />);
|
||||
const cut = container.querySelectorAll('svg path')[0];
|
||||
expect(cut.getAttribute('d')?.split('L').length).toBeGreaterThan(40);
|
||||
expect(container.querySelector('#c-p1 path')?.getAttribute('d')).toMatch(/^M0 0L/);
|
||||
// The clip's id is the photo's with a suffix of its own, so a spread drawn twice keeps them apart.
|
||||
expect(container.querySelector('clipPath[id^="c-p1-"] path')?.getAttribute('d')).toMatch(/^M0 0L/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,6 +124,11 @@ describe('the inspector', () => {
|
||||
expect(first().frame.h).toBeGreaterThan(before);
|
||||
fireEvent.click(getByText('journey.studio.swapEnds'));
|
||||
expect(first()).toMatchObject({ startCap: 'arrow', endCap: 'none' });
|
||||
// The ornaments stay folded away until a line has one.
|
||||
const ornaments = [...document.querySelectorAll('.st-section-head')].find(
|
||||
(b) => b.textContent === 'journey.studio.ornamentTitle'
|
||||
) as HTMLElement;
|
||||
fireEvent.click(ornaments);
|
||||
fireEvent.click(getByTitle('journey.studio.ornament.heart'));
|
||||
expect(first().ornament).toBe('heart');
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { JourneyBookEditService } from './journey-book-edit.service';
|
||||
import {
|
||||
addBookPagesArgs,
|
||||
BOOK_OP_LIMITS,
|
||||
BOOK_THEME_LINK_HINT,
|
||||
bookPageSetupForPreset,
|
||||
createBookArgs,
|
||||
deleteBookPagesArgs,
|
||||
@@ -195,7 +196,7 @@ export class JourneyBookMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'edit_book_page',
|
||||
description: `Change one page with 1 to ${BOOK_OP_LIMITS.opsPerCall} operations, run in order, all or nothing: layout (a template, grid, saved layout, or auto to lay the page out again from its entry), place_photo (a photo of the journey's gallery into a frame by elementId, by its slot among the page's frames, or into a new frame), photo_look (the crop as fit and focal point, mask, frame style, filter, radius), swap_photos, text (by elementId, or by role heading, body or meta, which creates the text when the page has none), caption (under a photo, bound to its caption in the journal unless words are given), frame (move, resize, turn), remove (parked with the page by default, park false deletes), background (a colour, or a paint: a colour with alpha, a linear or radial gradient, or one of twelve patterns; null is the paper), add (a new photo frame, text, shape, icon, sticker from list_book_assets, line, map, figures, country outlines, mark or list; a line runs through points on the spread in mm, with heads, dashes, a wave or an ornament; the journey's own values such as the route, figures and countries come from the journey, never from you), style (the look of one element of its kind: opacity, colours, a shape's paint, fonts, strokes, a cut edge on a shape or photo such as a stamp's perforation, a line's points and the like, for a text also its case, vertical place, outline, hard shadow, marker band, curve on an arc or a circle, a fill of a gallery photo or hollow letters, and shrink to fit, and for a photo its border, hard shadow, zoom, mirror, adjustments, duotone and cutout), arrange (to front, forward, backward, to back), align and distribute (several elements by their edges or centres, against each other, the page or the safe area), lock, duplicate, fill (a photo to the bleed of its page or the spread, behind everything), copy_from (elements of another page), refresh_travel (travel elements to the journey's current figures), add_grid (a photo grid as empty frames in a box), group and ungroup (two or more elements picked, moved, aligned and arranged as one, such as a title lockup; align and distribute move a group as one box), name (an element or a group, as the layers panel shows it), hide (left off the page and out of print, or shown again) and add_lockup (one of the Studio's text styles: a set title, badge, postmark, stamp, quote, caption or chapter mark of a few texts and shapes, its words given by role, every text fitted to its frame, placed as one group). A locked element refuses any operation that would change it but lock, ungroup, name and hide. The road a map draws between stops is a straight or bowed line; roads are not routed. ${LAYOUT_NOTES.templates} ${LAYOUT_NOTES.grids} ${UNITS} With returnPrevious the answer also holds the page as it was, under previous_spreads, and a digest of each page as the write left it under page_digests (for restore_book_page expectedPages). ${WRITE_ANSWER}`,
|
||||
description: `Change one page with 1 to ${BOOK_OP_LIMITS.opsPerCall} operations, run in order, all or nothing: layout (a template, grid, saved layout, or auto to lay the page out again from its entry), place_photo (a photo of the journey's gallery into a frame by elementId, by its slot among the page's frames, or into a new frame), photo_look (the crop as fit and focal point, mask, frame style, filter, radius), swap_photos, text (by elementId, or by role heading, body or meta, which creates the text when the page has none), caption (under a photo, bound to its caption in the journal unless words are given), frame (move, resize, turn), remove (parked with the page by default, park false deletes), background (a colour, or a paint: a colour with alpha, a linear or radial gradient, or one of twelve patterns; null is the paper), add (a new photo frame, text, shape, icon, sticker from list_book_assets, line, map, figures, country outlines, mark or list, by what it is: its look follows with style, naming the id given to it in add; a line runs through points on the spread in mm; the journey's own values such as the route, figures and countries come from the journey, never from you), style (the look of one element of its kind: opacity, colours, a shape's paint, fonts, strokes, a cut edge on a shape or photo such as a stamp's perforation, a line's points and the like, for a text also its case, vertical place, outline, hard shadow, marker band, curve on an arc or a circle, a fill of a gallery photo or hollow letters, and shrink to fit, for a photo its border, hard shadow, zoom, mirror, adjustments, duotone and cutout, and for a line its heads, dashes, a wave or an ornament; ${BOOK_THEME_LINK_HINT}), arrange (to front, forward, backward, to back), align and distribute (several elements by their edges or centres, against each other, the page or the safe area), lock, duplicate, fill (a photo to the bleed of its page or the spread, behind everything), copy_from (elements of another page), refresh_travel (travel elements to the journey's current figures), add_grid (a photo grid as empty frames in a box), group and ungroup (two or more elements picked, moved, aligned and arranged as one, such as a title lockup; align and distribute move a group as one box), name (an element or a group, as the layers panel shows it), hide (left off the page and out of print, or shown again) and add_lockup (one of the Studio's text styles: a set title, badge, postmark, stamp, quote, caption or chapter mark of a few texts and shapes, its words given by role, every text fitted to its frame, placed as one group). A locked element refuses any operation that would change it but lock, ungroup, name and hide. The road a map draws between stops is a straight or bowed line; roads are not routed. ${LAYOUT_NOTES.templates} ${LAYOUT_NOTES.grids} ${UNITS} With returnPrevious the answer also holds the page as it was, under previous_spreads, and a digest of each page as the write left it under page_digests (for restore_book_page expectedPages). ${WRITE_ANSWER}`,
|
||||
inputSchema: { journeyId: idSchema, ...editBookPageArgs },
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
when: journeyAddonOn,
|
||||
|
||||
@@ -104,14 +104,15 @@ describe('a sticker on a page', () => {
|
||||
it('JBASSET-003: edit_book_page places a sticker the library holds, in its proportions and the colours asked for', async () => {
|
||||
const { user: owner } = await makeUser(orm);
|
||||
const { journey, inner } = await bookFor(owner.id);
|
||||
const element = { kind: 'sticker' as const, stickerId: 'eiffelTower', colors: ['#1d3557'] };
|
||||
const element = { kind: 'sticker' as const, stickerId: 'eiffelTower' };
|
||||
// The look follows the add in the same call, by the id the add gave it.
|
||||
const colours = { kind: 'sticker' as const, colors: ['#1d3557'] };
|
||||
const ops = [
|
||||
{ op: 'add' as const, element, id: 'tower' },
|
||||
{ op: 'style' as const, elementId: 'tower', set: colours },
|
||||
];
|
||||
const written = bookWriteResultSchema.parse(
|
||||
json(
|
||||
await mcp.editBookPage(
|
||||
{ journeyId: journey.id, spreadId: inner, ops: [{ op: 'add', element }] },
|
||||
ctx(owner.id),
|
||||
),
|
||||
),
|
||||
json(await mcp.editBookPage({ journeyId: journey.id, spreadId: inner, ops }, ctx(owner.id))),
|
||||
);
|
||||
expect(written.spread_ids).toEqual([inner]);
|
||||
const page = json(await mcp.describeBookPage({ journeyId: journey.id, spreadId: inner }, ctx(owner.id)));
|
||||
|
||||
@@ -673,7 +673,7 @@ describe('format revisions', () => {
|
||||
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 ahead = { ...doc('ahead'), schemaRev: BOOK_SCHEMA_REV + 1 };
|
||||
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();
|
||||
|
||||
@@ -36,7 +36,7 @@ export * from './vacay.contract';
|
||||
* `<pkg>.contract.ts`; the server builds every card from the tool's own
|
||||
* answer and checks it against this union before it is sent.
|
||||
*/
|
||||
export const trekCardSchema = z.discriminatedUnion('kind', [
|
||||
const trekCardUnion = z.discriminatedUnion('kind', [
|
||||
...TOUR_CARD_SCHEMAS,
|
||||
...ROADTRIP_CARD_SCHEMAS,
|
||||
...BUDGET_CARD_SCHEMAS,
|
||||
@@ -51,6 +51,17 @@ export const trekCardSchema = z.discriminatedUnion('kind', [
|
||||
...BOOK_DESIGN_CARD_SCHEMAS,
|
||||
...HOME_CARD_SCHEMAS,
|
||||
]);
|
||||
type TrekCardUnion = typeof trekCardUnion;
|
||||
/**
|
||||
* The card union under a name of its own: the message, part and thread
|
||||
* schemas that hold cards then refer to it instead of spelling out every kind
|
||||
* again, which made the server's typecheck run out of memory. The one member
|
||||
* keeps the interface from being empty; it is the union's own.
|
||||
*/
|
||||
export interface TrekCardSchema extends TrekCardUnion {
|
||||
readonly options: TrekCardUnion['options'];
|
||||
}
|
||||
export const trekCardSchema: TrekCardSchema = trekCardUnion;
|
||||
export type TrekCard = z.infer<typeof trekCardSchema>;
|
||||
export type TrekCardKind = TrekCard['kind'];
|
||||
export type TrekCardOf<K extends TrekCardKind> = Extract<TrekCard, { kind: K }>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { idSchema } from '../common/primitives.schema';
|
||||
import { BOOK_EDGE_PROPS, LINE_PROPS } from './book-line-ops.schema';
|
||||
import { BOOK_EDGE_PROPS, LINE_PROPS, bookLinePointsInSchema } from './book-line-ops.schema';
|
||||
import {
|
||||
BOOK_BINDING_HINT,
|
||||
bookBadgeStyleSchema,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
bookRouteDashSchema,
|
||||
bookRouteStyleSchema,
|
||||
bookShapeGradientSchema,
|
||||
bookShapeIdRefSchema,
|
||||
bookShapeIdSchema,
|
||||
bookStatsLayoutSchema,
|
||||
bookStrokeStyleSchema,
|
||||
@@ -95,14 +96,16 @@ const TYPESET = {
|
||||
...THEMED_TYPESET_IN,
|
||||
};
|
||||
|
||||
// The page edit's photo_look says in full what each of these means; `style` points there.
|
||||
const AS_PHOTO_LOOK = 'As photo_look takes it.';
|
||||
const PHOTO_PROPS = {
|
||||
...COMMON,
|
||||
fit: bookPhotoFitSchema.optional(),
|
||||
fit: bookPhotoFitSchema.describe(AS_PHOTO_LOOK).optional(),
|
||||
focalX: unit01.optional(),
|
||||
focalY: unit01.optional(),
|
||||
mask: bookPhotoMaskSchema.optional(),
|
||||
frameStyle: bookFrameStyleSchema.optional(),
|
||||
filter: bookPhotoFilterSchema.optional(),
|
||||
mask: bookPhotoMaskSchema.describe(AS_PHOTO_LOOK).optional(),
|
||||
frameStyle: bookFrameStyleSchema.describe(AS_PHOTO_LOOK).optional(),
|
||||
filter: bookPhotoFilterSchema.describe(AS_PHOTO_LOOK).optional(),
|
||||
radius: z.number().finite().min(0).max(500).optional(),
|
||||
...BOOK_EDGE_PROPS,
|
||||
};
|
||||
@@ -118,13 +121,21 @@ export const TEXT_STYLE_FIELDS = {
|
||||
color: text.color.unwrap().optional(),
|
||||
};
|
||||
/** A text's look for `style` and `add`: its setting, its theme links and its effects (book-text-ops.schema.ts). */
|
||||
const TEXT_PROPS = { ...COMMON, ...TEXT_STYLE_FIELDS, ...THEMED_TEXT_IN, ...TEXT_EFFECT_PROPS };
|
||||
const TEXT_PROPS = {
|
||||
...COMMON,
|
||||
...TEXT_STYLE_FIELDS,
|
||||
// The text operation says what each face and weight is; here they point there, so the schema says it once.
|
||||
font: bookFontIdSchema.describe('A typeface id, as the text operation lists them.').optional(),
|
||||
weight: bookWeightSchema.describe('100 to 900, as for the text operation.').optional(),
|
||||
...THEMED_TEXT_IN,
|
||||
...TEXT_EFFECT_PROPS,
|
||||
};
|
||||
const SHAPE_PROPS = {
|
||||
...COMMON,
|
||||
fill: shape.fill.unwrap().optional(),
|
||||
gradient: bookShapeGradientSchema.optional(),
|
||||
/** Overrides fill and gradient when given; null takes the fill away. A plain fill or gradient clears it. */
|
||||
paint: bookPaintInSchema.nullable().optional(),
|
||||
paint: bookPaintInSchema.describe('A paint, as background takes it; null takes the fill away.').nullable().optional(),
|
||||
stroke: shape.stroke.unwrap().optional(),
|
||||
strokeWidth: z.number().finite().min(0).max(20).optional(),
|
||||
strokeStyle: bookStrokeStyleSchema.optional(),
|
||||
@@ -209,7 +220,7 @@ export const bookElementPropsSchema = z.discriminatedUnion('kind', [
|
||||
// The photo look only here, not on `add`: a new frame is styled after it exists, and the field list stays out of the add schema twice over.
|
||||
z.strictObject({ kind: z.literal('photo'), ...PHOTO_PROPS, ...BOOK_PHOTO_LOOK_PROPS }),
|
||||
z.strictObject({ kind: z.literal('text'), ...TEXT_PROPS }),
|
||||
z.strictObject({ kind: z.literal('shape'), shape: bookShapeIdSchema.optional(), ...SHAPE_PROPS }),
|
||||
z.strictObject({ kind: z.literal('shape'), shape: bookShapeIdRefSchema.optional(), ...SHAPE_PROPS }),
|
||||
z.strictObject({ kind: z.literal('icon'), ...ICON_PROPS }),
|
||||
z.strictObject({ kind: z.literal('sticker'), ...STICKER_PROPS }),
|
||||
z.strictObject({ kind: z.literal('map'), ...MAP_PROPS }),
|
||||
@@ -258,77 +269,109 @@ const listItem = z.strictObject({
|
||||
*/
|
||||
const mapScope = z.union([z.literal('journey'), z.strictObject({ tripId: idSchema })]);
|
||||
|
||||
/**
|
||||
* What to create: the kind, where its content comes from, and its look. A text
|
||||
/*
|
||||
* What a new element is, kind by kind: where its content comes from. A text
|
||||
* needs its words or a binding (the engine has no translated sample words); a
|
||||
* list needs an entry (its pros and cons) or its lines.
|
||||
*/
|
||||
export const bookNewElementSchema = z.discriminatedUnion('kind', [
|
||||
z.strictObject({ kind: z.literal('photo'), photoId: idSchema.optional(), ...PHOTO_PROPS }),
|
||||
z
|
||||
.strictObject({
|
||||
kind: z.literal('text'),
|
||||
preset: z
|
||||
.enum(BOOK_TEXT_PRESETS)
|
||||
.describe('A Studio text preset: heading, subheading, body, caption, or quote (the large quotation mark).')
|
||||
.optional(),
|
||||
text: z.string().max(2000).optional(),
|
||||
binding: bindingIn.optional(),
|
||||
...TEXT_PROPS,
|
||||
})
|
||||
.refine((v) => v.text !== undefined || v.binding !== undefined, { message: 'give the words or a binding' }),
|
||||
z.strictObject({ kind: z.literal('shape'), shape: bookShapeIdSchema, ...SHAPE_PROPS }),
|
||||
z.strictObject({ kind: z.literal('icon'), name: bookIconNameSchema, ...ICON_PROPS }),
|
||||
z.strictObject({ kind: z.literal('sticker'), stickerId: bookStickerIdSchema, ...STICKER_PROPS }),
|
||||
z.strictObject({
|
||||
kind: z.literal('map'),
|
||||
scope: mapScope,
|
||||
source: bookMapSourceSchema.optional(),
|
||||
...MAP_PROPS,
|
||||
}),
|
||||
z.strictObject({ kind: z.literal('stats'), metrics: METRICS, ...STATS_PROPS }),
|
||||
z.strictObject({
|
||||
kind: z.literal('countries'),
|
||||
codes: z.array(iso2).max(MAX_BOOK_COUNTRIES).optional(),
|
||||
...COUNTRIES_PROPS,
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal('badge'),
|
||||
const textContent = {
|
||||
preset: z
|
||||
.enum(BOOK_TEXT_PRESETS)
|
||||
.describe('A Studio text preset: heading, subheading, body, caption, or quote (the large quotation mark).')
|
||||
.optional(),
|
||||
text: z.string().max(2000).optional(),
|
||||
binding: bindingIn.describe('Words read from the journey, bound as the text operation binds them.').optional(),
|
||||
};
|
||||
const hasWords = (v: { text?: string; binding?: unknown }) => v.text !== undefined || v.binding !== undefined;
|
||||
const WORDS_NEEDED = { message: 'give the words or a binding' };
|
||||
const listContent = { entryId: idSchema.optional(), items: z.array(listItem).max(60).optional() };
|
||||
const oneListSource = (v: { entryId?: number; items?: unknown[] }) =>
|
||||
(v.entryId === undefined) !== (v.items === undefined);
|
||||
const ONE_LIST_SOURCE = { message: 'give an entryId or the items, not both' };
|
||||
const NEW_CONTENT = {
|
||||
photo: { photoId: idSchema.optional() },
|
||||
shape: { shape: bookShapeIdSchema },
|
||||
icon: { name: bookIconNameSchema },
|
||||
sticker: { stickerId: bookStickerIdSchema },
|
||||
map: { scope: mapScope, source: bookMapSourceSchema.optional() },
|
||||
stats: { metrics: METRICS },
|
||||
countries: { codes: z.array(iso2).max(MAX_BOOK_COUNTRIES).optional() },
|
||||
badge: {
|
||||
variant: bookBadgeVariantSchema,
|
||||
entryId: idSchema.optional(),
|
||||
code: z.string().max(24).optional(),
|
||||
text: z.string().max(200).optional(),
|
||||
sub: z.string().max(200).optional(),
|
||||
...BADGE_PROPS,
|
||||
}),
|
||||
z
|
||||
.strictObject({
|
||||
kind: z.literal('list'),
|
||||
entryId: idSchema.optional(),
|
||||
items: z.array(listItem).max(60).optional(),
|
||||
...LIST_PROPS,
|
||||
})
|
||||
.refine((v) => (v.entryId === undefined) !== (v.items === undefined), {
|
||||
message: 'give an entryId or the items, not both',
|
||||
}),
|
||||
},
|
||||
line: { points: bookLinePointsInSchema.optional() },
|
||||
};
|
||||
|
||||
/**
|
||||
* What to create with its look, as the engine runs `add`: the content and any
|
||||
* field `style` takes for the kind. A change kept in an assistant card holds
|
||||
* its operations in this form, so it reads whichever way it was written.
|
||||
*/
|
||||
export const bookNewElementSchema = z.discriminatedUnion('kind', [
|
||||
z.strictObject({ kind: z.literal('photo'), ...NEW_CONTENT.photo, ...PHOTO_PROPS }),
|
||||
z.strictObject({ kind: z.literal('text'), ...textContent, ...TEXT_PROPS }).refine(hasWords, WORDS_NEEDED),
|
||||
z.strictObject({ kind: z.literal('shape'), ...NEW_CONTENT.shape, ...SHAPE_PROPS }),
|
||||
z.strictObject({ kind: z.literal('icon'), ...NEW_CONTENT.icon, ...ICON_PROPS }),
|
||||
z.strictObject({ kind: z.literal('sticker'), ...NEW_CONTENT.sticker, ...STICKER_PROPS }),
|
||||
z.strictObject({ kind: z.literal('map'), ...NEW_CONTENT.map, ...MAP_PROPS }),
|
||||
z.strictObject({ kind: z.literal('stats'), ...NEW_CONTENT.stats, ...STATS_PROPS }),
|
||||
z.strictObject({ kind: z.literal('countries'), ...NEW_CONTENT.countries, ...COUNTRIES_PROPS }),
|
||||
z.strictObject({ kind: z.literal('badge'), ...NEW_CONTENT.badge, ...BADGE_PROPS }),
|
||||
z.strictObject({ kind: z.literal('list'), ...listContent, ...LIST_PROPS }).refine(oneListSource, ONE_LIST_SOURCE),
|
||||
z.strictObject({ kind: z.literal('line'), ...LINE_PROPS }),
|
||||
]);
|
||||
export type BookNewElement = z.infer<typeof bookNewElementSchema>;
|
||||
|
||||
/**
|
||||
* What the book tools take for a new element: its kind and content only. Its
|
||||
* look is `style`'s, every field described there once; this keeps the page
|
||||
* edit's tool schema, which every MCP client loads, within its size.
|
||||
*/
|
||||
const bookNewElementInSchema = z
|
||||
.discriminatedUnion('kind', [
|
||||
z.strictObject({ kind: z.literal('photo'), ...NEW_CONTENT.photo }),
|
||||
z.strictObject({ kind: z.literal('text'), ...textContent }).refine(hasWords, WORDS_NEEDED),
|
||||
z.strictObject({ kind: z.literal('shape'), ...NEW_CONTENT.shape }),
|
||||
z.strictObject({ kind: z.literal('icon'), ...NEW_CONTENT.icon }),
|
||||
z.strictObject({ kind: z.literal('sticker'), ...NEW_CONTENT.sticker }),
|
||||
z.strictObject({ kind: z.literal('map'), ...NEW_CONTENT.map }),
|
||||
z.strictObject({ kind: z.literal('stats'), ...NEW_CONTENT.stats }),
|
||||
z.strictObject({ kind: z.literal('countries'), ...NEW_CONTENT.countries }),
|
||||
z.strictObject({ kind: z.literal('badge'), ...NEW_CONTENT.badge }),
|
||||
z.strictObject({ kind: z.literal('list'), ...listContent }).refine(oneListSource, ONE_LIST_SOURCE),
|
||||
z.strictObject({ kind: z.literal('line'), ...NEW_CONTENT.line }),
|
||||
])
|
||||
.describe(
|
||||
'The kind and its content. Its look (colours, type, effects, the crop of a photo) is set with style: give the element an id here and style it by that id in the same call.',
|
||||
);
|
||||
|
||||
const hasProps = (set: Record<string, unknown>) => Object.keys(set).some((k) => k !== 'kind' && set[k] !== undefined);
|
||||
|
||||
const ADD_FIELDS = {
|
||||
frame: bookFrameInSchema.optional(),
|
||||
rotation: z.number().finite().min(-360).max(360).optional(),
|
||||
id: bookElementIdSchema
|
||||
.describe('An id of your own for the new element, unused on the page, so later operations of the call can name it.')
|
||||
.optional(),
|
||||
};
|
||||
/** Create an element; the frame defaults to the Studio panel's size, centred on the page. */
|
||||
const bookAddOpSchema = z.strictObject({ op: z.literal('add'), element: bookNewElementSchema, ...ADD_FIELDS });
|
||||
/** `add` as the book tools take it (book-ops.schema.ts joins it into their operations). */
|
||||
export const bookAddOpInSchema = z.strictObject({
|
||||
op: z.literal('add'),
|
||||
element: bookNewElementInSchema,
|
||||
...ADD_FIELDS,
|
||||
});
|
||||
|
||||
/**
|
||||
* The new page operations, joined into `bookPageOpSchema` (book-ops.schema.ts
|
||||
* spreads them into its union).
|
||||
* The element operations but `add`, which the engine's and the tools' unions
|
||||
* each take in their own form (book-ops.schema.ts).
|
||||
*/
|
||||
export const BOOK_ELEMENT_OP_SCHEMAS = [
|
||||
/** Create an element; the frame defaults to the Studio panel's size, centred on the page. */
|
||||
z.strictObject({
|
||||
op: z.literal('add'),
|
||||
element: bookNewElementSchema,
|
||||
frame: bookFrameInSchema.optional(),
|
||||
rotation: z.number().finite().min(-360).max(360).optional(),
|
||||
}),
|
||||
export const BOOK_ELEMENT_EDIT_OP_SCHEMAS = [
|
||||
/** Restyle an element; `set.kind` must be the element's kind. */
|
||||
z
|
||||
.strictObject({ op: z.literal('style'), elementId: bookElementIdSchema, set: bookElementPropsSchema })
|
||||
@@ -380,7 +423,14 @@ export const BOOK_ELEMENT_OP_SCHEMAS = [
|
||||
/** Bring travel figures up to date with the journey: the named elements, or every one on the page. */
|
||||
z.strictObject({ op: z.literal('refresh_travel'), elementIds: ids30.optional() }),
|
||||
/** A photo grid as empty frames in a box, the page's safe area by default. */
|
||||
z.strictObject({ op: z.literal('add_grid'), grid: bookGridIdSchema, box: bookFrameInSchema.optional() }),
|
||||
z.strictObject({
|
||||
op: z.literal('add_grid'),
|
||||
grid: bookGridIdSchema.describe('A grid id, as layout takes it.'),
|
||||
box: bookFrameInSchema.optional(),
|
||||
}),
|
||||
/** A lockup: a set title, badge, postmark, quote, caption or chapter mark (book-text-ops.schema.ts). */
|
||||
bookAddLockupOpSchema,
|
||||
] as const;
|
||||
|
||||
/** Every element operation as the engine runs it, joined into `bookPageOpSchema`. */
|
||||
export const BOOK_ELEMENT_OP_SCHEMAS = [bookAddOpSchema, ...BOOK_ELEMENT_EDIT_OP_SCHEMAS] as const;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AutoEntry } from './auto/auto-types';
|
||||
import { applyBookOps } from './book-ops';
|
||||
import type { BookOpsContext } from './book-ops-core';
|
||||
import type { BookPageOpInput } from './book-ops.schema';
|
||||
import { editBookPageRequestSchema, type BookPageOpInput } from './book-ops.schema';
|
||||
import { blankBook } from './book-pages';
|
||||
import type { BookTravelContext } from './book-travel';
|
||||
import {
|
||||
@@ -339,3 +339,28 @@ describe('travel figures, grids and the auto layout', () => {
|
||||
expect(find(doc, 't-1')).toMatchObject({ italic: true, leading: 2, tracking: 0.1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a new element and its look', () => {
|
||||
it('BOOKEL-018: add takes an id of its own, style names it in the same call, and a second element cannot take the id', () => {
|
||||
const doc = ok(
|
||||
run(book([]), [
|
||||
{ op: 'add', element: { kind: 'text', text: 'Reykjavik' }, id: 'title' },
|
||||
{ op: 'style', elementId: 'title', set: { kind: 'text', size: 40, color: '#c0392b' } },
|
||||
]),
|
||||
);
|
||||
expect(find(doc, 'title')).toMatchObject({ kind: 'text', text: 'Reykjavik', size: 40, color: '#c0392b' });
|
||||
const again = run(doc, [{ op: 'add', element: { kind: 'text', text: 'Again' }, id: 'title' }]);
|
||||
expect(refusal(again)).toBe('id_in_use');
|
||||
});
|
||||
|
||||
it('BOOKEL-019: the page edit takes add by what the element is, while a change kept with its look still runs', () => {
|
||||
const edit = (element: Record<string, unknown>) => ({ spreadId: 'sp-in', ops: [{ op: 'add', element }] });
|
||||
expect(editBookPageRequestSchema.safeParse(edit({ kind: 'text', text: 'Hi' })).success).toBe(true);
|
||||
expect(editBookPageRequestSchema.safeParse(edit({ kind: 'text', text: 'Hi', size: 30 })).success).toBe(false);
|
||||
expect(
|
||||
editBookPageRequestSchema.safeParse(edit({ kind: 'shape', shape: 'ellipse', fill: '#ff0000' })).success,
|
||||
).toBe(false);
|
||||
const kept = ok(run(book([]), [{ op: 'add', element: { kind: 'text', text: 'Hi', size: 30 }, id: 'kept' }]));
|
||||
expect(find(kept, 'kept')).toMatchObject({ size: 30 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* The roles the words of a lockup play (book-lockups.ts). On their own so the
|
||||
* operation schema that names them (book-text-ops.schema.ts) imports a list,
|
||||
* not the engine that sets a lockup, which reaches the findings and through
|
||||
* them the operation schemas again.
|
||||
*/
|
||||
export const BOOK_LOCKUP_ROLES = [
|
||||
'title',
|
||||
'subtitle',
|
||||
'kicker',
|
||||
'overlay',
|
||||
'year',
|
||||
'place',
|
||||
'dates',
|
||||
'quote',
|
||||
'caption',
|
||||
'number',
|
||||
'label',
|
||||
'coords',
|
||||
] as const;
|
||||
export type BookLockupRole = (typeof BOOK_LOCKUP_ROLES)[number];
|
||||
@@ -1,4 +1,5 @@
|
||||
import { groupElements } from './book-groups';
|
||||
import type { BookLockupRole } from './book-lockup-roles';
|
||||
import { BOOK_LOCKUPS } from './book-lockups.data';
|
||||
import type { BookTextCurve, BookTextTransform } from './book-text-effects.schema';
|
||||
import { solveBookTextFit } from './book-text-fit';
|
||||
@@ -42,21 +43,7 @@ import type {
|
||||
* a double click still reaches a single part.
|
||||
*/
|
||||
|
||||
export const BOOK_LOCKUP_ROLES = [
|
||||
'title',
|
||||
'subtitle',
|
||||
'kicker',
|
||||
'overlay',
|
||||
'year',
|
||||
'place',
|
||||
'dates',
|
||||
'quote',
|
||||
'caption',
|
||||
'number',
|
||||
'label',
|
||||
'coords',
|
||||
] as const;
|
||||
export type BookLockupRole = (typeof BOOK_LOCKUP_ROLES)[number];
|
||||
export { BOOK_LOCKUP_ROLES, type BookLockupRole } from './book-lockup-roles';
|
||||
export type BookLockupWords = Partial<Record<BookLockupRole, string>>;
|
||||
|
||||
/** The colours a lockup is set in, by what they do. */
|
||||
|
||||
@@ -79,6 +79,13 @@ export const bookPhotoMaskSchema = z
|
||||
.transform((s) => s as BookShapeId)
|
||||
.nullable()
|
||||
.describe('A shape id, as for a shape, to cut the photo to; null is the plain rectangle.');
|
||||
/** A shape id where `add` already lists them: checked against the same set, not listed a second time. */
|
||||
export const bookShapeIdRefSchema = z
|
||||
.string()
|
||||
.max(40)
|
||||
.refine((s) => SHAPE_IDS.has(s), { message: 'not a shape the Studio draws' })
|
||||
.transform((s) => s as BookShapeId)
|
||||
.describe("Another outline, by the ids add's shape lists.");
|
||||
export const bookFrameStyleSchema = photo.frameStyle
|
||||
.unwrap()
|
||||
.describe('polaroid (deep bottom border), white border, shadow, film or tape around it; none is a plain print.');
|
||||
|
||||
@@ -44,6 +44,7 @@ export const BOOK_OP_ERROR_CODES = [
|
||||
'not_enough_elements',
|
||||
'layouts_full',
|
||||
'group_not_found',
|
||||
'id_in_use',
|
||||
] as const;
|
||||
export type BookOpErrorCode = (typeof BOOK_OP_ERROR_CODES)[number];
|
||||
|
||||
|
||||
@@ -196,10 +196,15 @@ function withPaintProps(el: BookShapeElement, props: Record<string, unknown>): B
|
||||
|
||||
function opAdd(spread: BookSpread, op: Op<'add'>, env: ElementOpEnv): BookSpread {
|
||||
roomFor(spread, 1, env.opIndex);
|
||||
// An id of the caller's own lets the operations after this one style or group the element it makes.
|
||||
if (op.id !== undefined && [...spread.elements, ...spread.parked].some((e) => e.id === op.id)) {
|
||||
throw failOp('id_in_use', `${op.id} is already on this page; choose another id`, env.opIndex, op.id);
|
||||
}
|
||||
const blank = created(spread, op.element, op.frame ? roundFrame(op.frame) : null, env);
|
||||
const made = withProps(blank, lookOf(op.element), env.doc.theme);
|
||||
checkTextFillPhoto(made, env);
|
||||
const el = op.rotation !== undefined ? { ...made, rotation: op.rotation } : made;
|
||||
const turned = op.rotation !== undefined ? { ...made, rotation: op.rotation } : made;
|
||||
const el = op.id !== undefined ? { ...turned, id: op.id } : turned;
|
||||
const parked =
|
||||
el.kind === 'photo' && el.photoId != null
|
||||
? spread.parked.filter((p) => !(p.kind === 'photo' && p.photoId === el.photoId))
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { idSchema } from '../common/primitives.schema';
|
||||
import { BOOK_ELEMENT_OP_SCHEMAS, TEXT_STYLE_FIELDS, bindingNamesItsSource } from './book-element-ops.schema';
|
||||
import {
|
||||
BOOK_ELEMENT_EDIT_OP_SCHEMAS,
|
||||
BOOK_ELEMENT_OP_SCHEMAS,
|
||||
TEXT_STYLE_FIELDS,
|
||||
bindingNamesItsSource,
|
||||
bookAddOpInSchema,
|
||||
} from './book-element-ops.schema';
|
||||
import { BOOK_GROUP_OP_SCHEMAS } from './book-group-ops.schema';
|
||||
import { BOOK_COVER_TEMPLATES, BOOK_TEMPLATES } from './book-layouts';
|
||||
import {
|
||||
@@ -17,10 +23,20 @@ import {
|
||||
MAX_BOOK_TITLE,
|
||||
MAX_SPREADS,
|
||||
MAX_SPREAD_ELEMENTS,
|
||||
bookBadgeElementSchema,
|
||||
bookCountriesElementSchema,
|
||||
bookIconElementSchema,
|
||||
bookLayoutSchema,
|
||||
bookLineElementSchema,
|
||||
bookListElementSchema,
|
||||
bookMapElementSchema,
|
||||
bookPageNumbersSchema,
|
||||
bookPageSetupSchema,
|
||||
bookPhotoElementSchema,
|
||||
bookShapeElementSchema,
|
||||
bookSpreadSchema,
|
||||
bookStatsElementSchema,
|
||||
bookStickerElementSchema,
|
||||
bookTextElementSchema,
|
||||
} from './book.schema';
|
||||
import { PHOTO_PICK } from './photo-pick';
|
||||
@@ -89,8 +105,8 @@ const LOOK_KEYS = ['fit', 'focalX', 'focalY', 'mask', 'frameStyle', 'filter', 'r
|
||||
const FRAME_KEYS = ['x', 'y', 'w', 'h', 'rotation'] as const;
|
||||
const LAYOUT_KEYS = ['template', 'grid', 'saved', 'auto'] as const;
|
||||
|
||||
/** One edit of one page. Discriminated on `op`. */
|
||||
export const bookPageOpSchema = z.discriminatedUnion('op', [
|
||||
/** The operations on a page as a whole, the first of `bookPageOpSchema`'s. */
|
||||
const PAGE_OP_SCHEMAS = [
|
||||
/**
|
||||
* Lay the page out again: a built-in layout, a photo grid, one of the book's
|
||||
* own layouts, or `auto`, the Studio's "this page again" from the entry the
|
||||
@@ -198,10 +214,38 @@ export const bookPageOpSchema = z.discriminatedUnion('op', [
|
||||
paint: bookPaintInSchema.nullable().optional(),
|
||||
})
|
||||
.refine((v) => (v.color === undefined) !== (v.paint === undefined), { message: 'give the color or the paint' }),
|
||||
] as const;
|
||||
|
||||
/** One edit of one page, as the engine runs it. Discriminated on `op`. */
|
||||
const bookPageOpUnion = z.discriminatedUnion('op', [
|
||||
...PAGE_OP_SCHEMAS,
|
||||
...BOOK_ELEMENT_OP_SCHEMAS,
|
||||
...BOOK_GROUP_OP_SCHEMAS,
|
||||
]);
|
||||
type BookPageOpUnion = typeof bookPageOpUnion;
|
||||
/** The operations under a name of their own, for the same reason as `BookElementSchema` (book.schema.ts). */
|
||||
export interface BookPageOpSchema extends BookPageOpUnion {
|
||||
readonly options: BookPageOpUnion['options'];
|
||||
}
|
||||
export const bookPageOpSchema: BookPageOpSchema = bookPageOpUnion;
|
||||
export type BookPageOp = z.infer<typeof bookPageOpSchema>;
|
||||
|
||||
/**
|
||||
* The same operations as the book tools take them: `add` names what the
|
||||
* element is and `style` its look, so no look field is described twice in the
|
||||
* schema every MCP client loads. Each parses into a `BookPageOp`.
|
||||
*/
|
||||
const bookPageOpInUnion = z.discriminatedUnion('op', [
|
||||
...PAGE_OP_SCHEMAS,
|
||||
bookAddOpInSchema,
|
||||
...BOOK_ELEMENT_EDIT_OP_SCHEMAS,
|
||||
...BOOK_GROUP_OP_SCHEMAS,
|
||||
]);
|
||||
type BookPageOpInUnion = typeof bookPageOpInUnion;
|
||||
export interface BookPageOpInSchema extends BookPageOpInUnion {
|
||||
readonly options: BookPageOpInUnion['options'];
|
||||
}
|
||||
const bookPageOpInSchema: BookPageOpInSchema = bookPageOpInUnion;
|
||||
export type BookPageOpInput = z.input<typeof bookPageOpSchema>;
|
||||
|
||||
/*
|
||||
@@ -219,7 +263,7 @@ const expectedVersion = z.number().int().nonnegative().optional();
|
||||
|
||||
export const editBookPageArgs = {
|
||||
spreadId: bookSpreadIdSchema,
|
||||
ops: z.array(bookPageOpSchema).min(1).max(BOOK_OP_LIMITS.opsPerCall),
|
||||
ops: z.array(bookPageOpInSchema).min(1).max(BOOK_OP_LIMITS.opsPerCall),
|
||||
returnPrevious,
|
||||
};
|
||||
export const addBookPagesArgs = {
|
||||
@@ -292,8 +336,88 @@ export type BookPageDigest = z.infer<typeof bookPageDigestSchema>;
|
||||
* not hold the undo back; a page put back that they leave out or name as
|
||||
* gone keeps the version's check.
|
||||
*/
|
||||
/*
|
||||
* ── A page as restore_book_page takes it back ───────────────────────────
|
||||
*
|
||||
* The same page as `bookPreviousSpreadSchema`, written so its schema states
|
||||
* each field once: what every element has, then what each kind adds, and no
|
||||
* defaults, since the page comes back whole as the change answered it. A page
|
||||
* holds two lists of elements (placed and parked), and spelled out kind by
|
||||
* kind the tool's schema, which every client loads with tools/list, outgrew
|
||||
* its size. It takes the same values: restoreBookPage parses the page again as
|
||||
* a spread, which sets every default, before it puts it back.
|
||||
*/
|
||||
const ELEMENT_BASE = {
|
||||
id: true,
|
||||
frame: true,
|
||||
rotation: true,
|
||||
opacity: true,
|
||||
locked: true,
|
||||
group: true,
|
||||
hidden: true,
|
||||
} as const;
|
||||
|
||||
/** An element's fields with their defaults left to the spread's own parse. */
|
||||
function restoredFields(schema: z.ZodObject): z.ZodObject {
|
||||
const shape = Object.entries(schema.shape).map(([key, field]) => [
|
||||
key,
|
||||
field instanceof z.ZodDefault ? (field.unwrap() as z.ZodType).optional() : field,
|
||||
]);
|
||||
return z.object(Object.fromEntries(shape) as z.ZodRawShape);
|
||||
}
|
||||
const TRAVEL_TYPE = {
|
||||
font: true,
|
||||
color: true,
|
||||
accent: true,
|
||||
textScale: true,
|
||||
weight: true,
|
||||
stale: true,
|
||||
token: true,
|
||||
accentToken: true,
|
||||
fontRole: true,
|
||||
} as const;
|
||||
const kindIn = (schema: z.ZodObject) => restoredFields(schema.omit(ELEMENT_BASE));
|
||||
const travelIn = (schema: z.ZodObject) => restoredFields(schema.omit({ ...ELEMENT_BASE, ...TRAVEL_TYPE }));
|
||||
// Each union says what it holds: a described union stays one part of the intersection instead of
|
||||
// zod writing the shared fields out into every kind again.
|
||||
const restoredElementIn = z.intersection(
|
||||
restoredFields(bookPhotoElementSchema.pick(ELEMENT_BASE)),
|
||||
z
|
||||
.union([
|
||||
kindIn(bookPhotoElementSchema),
|
||||
kindIn(bookTextElementSchema),
|
||||
kindIn(bookShapeElementSchema),
|
||||
kindIn(bookIconElementSchema),
|
||||
kindIn(bookStickerElementSchema),
|
||||
kindIn(bookLineElementSchema),
|
||||
z.intersection(
|
||||
restoredFields(bookMapElementSchema.pick(TRAVEL_TYPE)),
|
||||
z
|
||||
.union([
|
||||
travelIn(bookMapElementSchema),
|
||||
travelIn(bookStatsElementSchema),
|
||||
travelIn(bookCountriesElementSchema),
|
||||
travelIn(bookBadgeElementSchema),
|
||||
travelIn(bookListElementSchema),
|
||||
])
|
||||
.describe('A travel element: the type the five share, then its own fields.'),
|
||||
),
|
||||
])
|
||||
.describe("What the element's kind adds to the fields every element has."),
|
||||
);
|
||||
const restoredElementsIn = z.array(restoredElementIn).max(MAX_SPREAD_ELEMENTS).optional();
|
||||
const restoredPageIn = z.object({
|
||||
index: bookPreviousSpreadSchema.shape.index,
|
||||
spread: z.object({
|
||||
...restoredFields(bookSpreadSchema.omit({ id: true, elements: true, parked: true })).shape,
|
||||
id: bookSpreadSchema.shape.id,
|
||||
elements: restoredElementsIn,
|
||||
parked: restoredElementsIn,
|
||||
}),
|
||||
});
|
||||
|
||||
export const restoreBookPageArgs = {
|
||||
pages: z.array(bookPreviousSpreadSchema).min(1).max(BOOK_OP_LIMITS.pagesPerCall),
|
||||
pages: z.array(restoredPageIn).min(1).max(BOOK_OP_LIMITS.pagesPerCall),
|
||||
expectedVersion: z.number().int().nonnegative(),
|
||||
expectedPages: z.array(bookPageDigestSchema).min(1).max(BOOK_OP_LIMITS.pagesPerCall).optional(),
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
bookDocumentSchema,
|
||||
bookPageSetupSchema,
|
||||
bookPhotoElementSchema,
|
||||
bookSpreadSchema,
|
||||
bookTextElementSchema,
|
||||
type BookDocument,
|
||||
type BookPhotoElement,
|
||||
@@ -258,6 +259,40 @@ describe('restore', () => {
|
||||
const newLock = { ...lockedText, id: 't-new' };
|
||||
expect(refused(restoreBookPage(doc, args({ ...page, elements: [lockedText, newLock] })))).toBe('locked');
|
||||
});
|
||||
|
||||
it('BOOKPG-027: reads a page put back as a spread reads it, every kind placed or parked, with or without its defaults', () => {
|
||||
const base = book(1);
|
||||
const frame = { x: 20, y: 20, w: 60, h: 40 };
|
||||
// As an undo kept from before a field existed would hold it: only what was set.
|
||||
const raw = {
|
||||
id: 'sp-in1',
|
||||
elements: [
|
||||
{ id: 'p1', kind: 'photo', frame, photoId: null },
|
||||
{ id: 't1', kind: 'text', frame, text: 'Hi', outline: { color: '#000000', width: 0.5 }, group: 'g1' },
|
||||
{ id: 's1', kind: 'shape', frame, shape: 'ellipse' },
|
||||
{ id: 'm1', kind: 'stats', frame, metrics: ['distance'], token: 'accent' },
|
||||
{
|
||||
id: 'l1',
|
||||
kind: 'line',
|
||||
frame,
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
parked: [{ id: 'b1', kind: 'badge', frame, text: '2 June' }],
|
||||
};
|
||||
const spread = bookSpreadSchema.parse(raw);
|
||||
for (const given of [spread, raw]) {
|
||||
const back = ok(restoreBookPage(base, { pages: [{ spread: given, index: 2 }], expectedVersion: 1 }));
|
||||
expect(back.spreads[2]).toEqual(spread);
|
||||
}
|
||||
const wrong = { ...raw, elements: [{ id: 't2', kind: 'text', frame, text: 'Hi', color: 'red' }] };
|
||||
expect(refused(restoreBookPage(base, { pages: [{ spread: wrong, index: 2 }], expectedVersion: 1 }))).toBe(
|
||||
'invalid_op',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the cover', () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { blankSpread, canEditSpread, innerSpreadBounds } from './book-pages';
|
||||
import {
|
||||
MAX_SPREADS,
|
||||
bookSpreadSchema,
|
||||
type BookDocument,
|
||||
type BookElement,
|
||||
type BookPhotoElement,
|
||||
@@ -140,8 +141,11 @@ export function deleteBookPages(doc: BookDocument, args: unknown): BookEditResul
|
||||
export function restoreBookPage(doc: BookDocument, args: unknown, ctx: BookOpsContext = {}): BookEditResult {
|
||||
const parsed = restoreBookPageRequestSchema.safeParse(args);
|
||||
if (!parsed.success) return { ok: false, error: invalidOp(parsed.error, null) };
|
||||
const pages = [...parsed.data.pages].sort((a, b) => a.index - b.index);
|
||||
return caught(() => {
|
||||
// Read again as the spread it is: the tool takes it in a shape that states each field once (book-ops.schema.ts).
|
||||
const pages = parsed.data.pages
|
||||
.map((p) => ({ index: p.index, spread: bookSpreadSchema.parse(p.spread) }))
|
||||
.sort((a, b) => a.index - b.index);
|
||||
let cur = doc;
|
||||
for (const { spread, index } of pages) {
|
||||
restorablePhotos(spread, cur, ctx);
|
||||
|
||||
@@ -176,13 +176,13 @@ export const BOOK_SHAPE_ASPECT: Readonly<Record<(typeof BOOK_SHAPES_MORE)[number
|
||||
'sunburst-12': 1,
|
||||
'sunburst-24': 1,
|
||||
'ribbon-ends': 3.7,
|
||||
'ribbon-arc': 3.03,
|
||||
'ribbon-arc': 3.09,
|
||||
'wavy-band': 3.13,
|
||||
'torn-strip': 4.78,
|
||||
'bubble-shout': 1.02,
|
||||
'bubble-side': 1.43,
|
||||
'bubble-capsule': 2.08,
|
||||
'arrow-curve': 3.77,
|
||||
'arrow-curve': 3.59,
|
||||
'arrow-circle': 1.12,
|
||||
'arrow-notched': 2,
|
||||
'arrow-thin': 5.56,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { idSchema } from '../common/primitives.schema';
|
||||
import { BOOK_LOCKUP_ROLES } from './book-lockups';
|
||||
import { BOOK_LOCKUP_ROLES } from './book-lockup-roles';
|
||||
import { BOOK_LOCKUPS } from './book-lockups.data';
|
||||
import { BOOK_TEXT_EFFECT_FIELDS } from './book-text-effects.schema';
|
||||
|
||||
|
||||
@@ -865,7 +865,7 @@ export const bookListElementSchema = z.object({
|
||||
*/
|
||||
export const MAX_SPREAD_ELEMENTS = 90;
|
||||
|
||||
export const bookElementSchema = z.discriminatedUnion('kind', [
|
||||
const bookElementUnion = z.discriminatedUnion('kind', [
|
||||
bookPhotoElementSchema,
|
||||
bookTextElementSchema,
|
||||
bookShapeElementSchema,
|
||||
@@ -878,6 +878,18 @@ export const bookElementSchema = z.discriminatedUnion('kind', [
|
||||
bookStickerElementSchema,
|
||||
bookLineElementSchema,
|
||||
]);
|
||||
type BookElementUnion = typeof bookElementUnion;
|
||||
/**
|
||||
* The element contract under a name of its own. The declarations of every
|
||||
* schema that holds elements (a spread, a document, the assistant's cards and
|
||||
* renders) then refer to it rather than spelling out all eleven kinds again;
|
||||
* those copies made the server's typecheck run out of memory. The one member
|
||||
* keeps the interface from being empty; it is the union's own.
|
||||
*/
|
||||
export interface BookElementSchema extends BookElementUnion {
|
||||
readonly options: BookElementUnion['options'];
|
||||
}
|
||||
export const bookElementSchema: BookElementSchema = bookElementUnion;
|
||||
export type BookElement = z.infer<typeof bookElementSchema>;
|
||||
export type BookIconElement = z.infer<typeof bookIconElementSchema>;
|
||||
export type BookStickerElement = z.infer<typeof bookStickerElementSchema>;
|
||||
@@ -899,7 +911,7 @@ export const bookGroupSchema = z.object({
|
||||
});
|
||||
export type BookGroup = z.infer<typeof bookGroupSchema>;
|
||||
|
||||
export const bookSpreadSchema = z.object({
|
||||
const bookSpreadObject = z.object({
|
||||
id: z.string().min(1),
|
||||
/**
|
||||
* Which leaf of the bound book this is.
|
||||
@@ -937,6 +949,12 @@ export const bookSpreadSchema = z.object({
|
||||
*/
|
||||
groups: z.array(bookGroupSchema).max(MAX_SPREAD_ELEMENTS).optional().catch(undefined),
|
||||
});
|
||||
type BookSpreadObject = typeof bookSpreadObject;
|
||||
/** The spread contract under a name of its own, for the same reason as `BookElementSchema`. */
|
||||
export interface BookSpreadSchema extends BookSpreadObject {
|
||||
readonly shape: BookSpreadObject['shape'];
|
||||
}
|
||||
export const bookSpreadSchema: BookSpreadSchema = bookSpreadObject;
|
||||
export type BookSpread = z.infer<typeof bookSpreadSchema>;
|
||||
|
||||
/**
|
||||
@@ -1031,7 +1049,7 @@ export const bookLayoutSchema = z.object({
|
||||
});
|
||||
export type BookLayout = z.infer<typeof bookLayoutSchema>;
|
||||
|
||||
export const bookDocumentSchema = z.object({
|
||||
const bookDocumentObject = 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. */
|
||||
@@ -1044,4 +1062,10 @@ export const bookDocumentSchema = z.object({
|
||||
/** Palette, typefaces and motifs; elements link to it through the fields of theme/book-theme.schema.ts. */
|
||||
theme: themed.bookThemeFieldSchema,
|
||||
});
|
||||
type BookDocumentObject = typeof bookDocumentObject;
|
||||
/** The document contract under a name of its own, for the same reason as `BookElementSchema`. */
|
||||
export interface BookDocumentSchema extends BookDocumentObject {
|
||||
readonly shape: BookDocumentObject['shape'];
|
||||
}
|
||||
export const bookDocumentSchema: BookDocumentSchema = bookDocumentObject;
|
||||
export type BookDocument = z.infer<typeof bookDocumentSchema>;
|
||||
|
||||
@@ -109,9 +109,9 @@ function isCoverTitle(el: Extract<BookElement, { kind: 'text' }>, spread: BookSp
|
||||
/** A designed element with its links to the look. */
|
||||
function linkToLook(el: BookElement, spread: BookSpread, look: BookDesignLook): BookElement {
|
||||
switch (el.kind) {
|
||||
// A line keeps the colour it was drawn in (book-line.schema.ts has no theme link).
|
||||
case 'photo':
|
||||
case 'sticker':
|
||||
// A line keeps the colour it was drawn in (book-line.schema.ts has no theme link).
|
||||
case 'line':
|
||||
return el;
|
||||
case 'text': {
|
||||
|
||||
@@ -59,9 +59,9 @@ function previousToken(hex: string, prev: BookTheme | null): BookColorToken | un
|
||||
/** The coloured inks an element is drawn in, as adoption would read them. */
|
||||
function chromaticInks(el: BookElement): string[] {
|
||||
switch (el.kind) {
|
||||
// A line keeps the colour it was drawn in (book-line.schema.ts has no theme link).
|
||||
case 'photo':
|
||||
case 'sticker':
|
||||
// A line keeps the colour it was drawn in (book-line.schema.ts has no theme link).
|
||||
case 'line':
|
||||
return [];
|
||||
case 'text':
|
||||
@@ -156,10 +156,10 @@ function adoptElement(
|
||||
): BookElement {
|
||||
if (el.locked) return el;
|
||||
switch (el.kind) {
|
||||
// A sticker keeps the colours of its slots: they are a drawing, not the page's inks.
|
||||
// A sticker keeps the colours of its slots: they are a drawing, not the page's inks. A line
|
||||
// keeps the colour it was drawn in (book-line.schema.ts has no theme link).
|
||||
case 'photo':
|
||||
case 'sticker':
|
||||
// A line keeps the colour it was drawn in (book-line.schema.ts has no theme link).
|
||||
case 'line':
|
||||
return el;
|
||||
case 'text': {
|
||||
|
||||
@@ -121,8 +121,10 @@ describe('adopting what is not linked', () => {
|
||||
const sp = page([photo(), scrim, white, locked]);
|
||||
const done = adoptSpread(sp, adoptionMap(book([sp]), null), null);
|
||||
const byId = new Map(done.elements.map((e) => [e.id, e]));
|
||||
expect(byId.get('sc')).toBe(scrim);
|
||||
expect(byId.get('l')).toBe(locked);
|
||||
// The page's own objects come back, the parse having copied the ones handed in.
|
||||
const own = new Map(sp.elements.map((e) => [e.id, e]));
|
||||
expect(byId.get('sc')).toBe(own.get('sc'));
|
||||
expect(byId.get('l')).toBe(own.get('l'));
|
||||
expect((byId.get('w') as BookElement & { token?: string }).token).toBeUndefined();
|
||||
expect('token' in (byId.get('w') as object)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -64,9 +64,9 @@ export function themedElement<E extends BookElement>(el: E, theme: BookTheme): E
|
||||
if (el.locked) return el;
|
||||
const next: Record<string, unknown> = { ...el };
|
||||
switch (el.kind) {
|
||||
// A line keeps the colour it was drawn in (book-line.schema.ts has no theme link).
|
||||
case 'photo':
|
||||
case 'sticker':
|
||||
// A line keeps the colour it was drawn in (book-line.schema.ts has no theme link).
|
||||
case 'line':
|
||||
return el;
|
||||
case 'text': {
|
||||
|
||||
@@ -149,24 +149,19 @@ export const bookFontSlotFieldSchema = z.enum(BOOK_FONT_SLOTS).optional().catch(
|
||||
/*
|
||||
* ── As the operations take them ─────────────────────────────────────────
|
||||
*
|
||||
* Strict and described, as every operation is: the tool schema is all a
|
||||
* model reads. Null on a link takes it away, so the element keeps its colour
|
||||
* and stops following the theme.
|
||||
* Strict, as every operation is. Null on a link takes it away, so the element
|
||||
* keeps its colour and stops following the theme.
|
||||
*/
|
||||
|
||||
export const bookColorTokenInSchema = z
|
||||
.enum(BOOK_COLOR_TOKENS)
|
||||
.nullable()
|
||||
.describe(
|
||||
"Link the colour to the book's theme: paper, ink, muted (the softer ink of captions), accent, accent2 or accent3. The colour is taken from the theme and follows it when the theme changes; null unlinks it and keeps the colour.",
|
||||
);
|
||||
export const bookColorTokenInSchema = z.enum(BOOK_COLOR_TOKENS).nullable();
|
||||
export const bookFontSlotInSchema = z.enum(BOOK_FONT_SLOTS).nullable();
|
||||
|
||||
export const bookFontSlotInSchema = z
|
||||
.enum(BOOK_FONT_SLOTS)
|
||||
.nullable()
|
||||
.describe(
|
||||
"Link the typeface to the theme's display (cover titles), heading, body or script face; it is set in that face now and whenever the theme changes. Null unlinks it.",
|
||||
);
|
||||
/**
|
||||
* What the links mean, said once in the page edit's description rather than
|
||||
* on each of the fourteen fields that carry one (the tool schema's size).
|
||||
*/
|
||||
export const BOOK_THEME_LINK_HINT =
|
||||
"token, strokeToken and accentToken link a colour to the book's theme (paper, ink, muted for captions, accent, accent2, accent3): it takes the theme's colour and follows it; fontRole links the typeface to the theme's display, heading, body or script face. null unlinks and keeps the colour or face.";
|
||||
|
||||
const paletteIn = z.strictObject({
|
||||
paper: hex.optional(),
|
||||
|
||||
@@ -224,7 +224,7 @@ Requires `journey:read` or `journey:write` scope.
|
||||
| `add_book_pages` | Add up to 10 empty double spreads, always between the first and the last page, optionally on a template or tied to an entry. Needs edit access. |
|
||||
| `move_book_page` | Move one double spread to another place between the first and the last page; the answer says where it stood. A move to where the page already is changes nothing. Needs edit access. |
|
||||
| `delete_book_pages` | Delete up to 10 double spreads. The cover, first, last and back pages stay. Needs edit access. |
|
||||
| `edit_book_page` | Change one page with up to 20 operations, all or nothing: a layout, a gallery photo into a frame, its crop and look, swapping photos, texts and captions, moving and resizing, removing, the page colour. Locked elements are left alone, and photos come only from the journey's gallery. Needs edit access. |
|
||||
| `edit_book_page` | Change one page with up to 20 operations, all or nothing: a layout, a gallery photo into a frame, its crop and look, swapping photos, texts and captions, moving and resizing, removing, the page colour. A new element is added by what it is, and its look follows with `style` by the id `add` gave it, in the same call. Locked elements are left alone, and photos come only from the journey's gallery. Needs edit access. |
|
||||
| `set_book_cover` | The cover's layout, photo, title and subtitle. Needs edit access. |
|
||||
| `suggest_book_photos` | Photos of the journey's gallery for a page or the cover, grouped by day and place, best first: near duplicates (bursts and near-identical pictures) are folded onto the sharpest, and possibly blurry or low-resolution photos are named rather than picked. Narrow it to one entry or day. Sharpness and likeness are measured once per photo in the background; it cannot judge faces or what a photo shows. Any member may use it. |
|
||||
| `restore_book_page` | Put the pages an earlier change answered under `previous_spreads` back as they were, all in one call, only while the book is still at the version that change answered. Photos must be in the gallery or the book, and locked elements stay as they are. Meant for undo. Needs edit access. |
|
||||
|
||||
Reference in New Issue
Block a user