feat(studio): add_book_pages, set_book_cover and design_book's cover take the decorated templates too

This commit is contained in:
Maurice
2026-10-24 09:48:04 +02:00
parent d2287772f3
commit 080396a6ef
9 changed files with 76 additions and 32 deletions
+2 -2
View File
@@ -160,7 +160,7 @@ export class JourneyBookMcp {
@Tool({
name: 'add_book_pages',
description: `Add 1 to ${BOOK_OP_LIMITS.pagesPerCall} empty double spreads after afterSpreadId, always kept between the first and the last page, laid out on template when one is named and tied to the journey entry entryId when given. ${WRITE_ANSWER}`,
description: `Add 1 to ${BOOK_OP_LIMITS.pagesPerCall} empty double spreads after afterSpreadId, always kept between the first and the last page, laid out on template when one is named (a built-in or decorated double-spread layout from list_book_layouts) and tied to the journey entry entryId when given. ${WRITE_ANSWER}`,
inputSchema: { journeyId: idSchema, ...addBookPagesArgs },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: journeyAddonOn,
@@ -208,7 +208,7 @@ export class JourneyBookMcp {
@Tool({
name: 'set_book_cover',
description: `The cover of a journey's book: its layout (a single page template), its photo (from the journey's gallery, into a free frame, else a full bleed one behind everything), its title and its subtitle. A title or subtitle bound to the journey's own stays bound unless other words are given. With returnPrevious the answer also holds the cover as it was, under previous_spreads, and a digest of the cover as the write left it under page_digests (for restore_book_page expectedPages). ${WRITE_ANSWER}`,
description: `The cover of a journey's book: its layout (a built-in or decorated single-page template from list_book_layouts), its photo (from the journey's gallery, into a free frame, else a full bleed one behind everything), its title and its subtitle. A title or subtitle bound to the journey's own stays bound unless other words are given. With returnPrevious the answer also holds the cover as it was, under previous_spreads, and a digest of the cover as the write left it under page_digests (for restore_book_page expectedPages). ${WRITE_ANSWER}`,
inputSchema: { journeyId: idSchema, ...setBookCoverArgs },
annotations: TOOL_ANNOTATIONS_WRITE,
when: journeyAddonOn,
+2 -2
View File
@@ -1,7 +1,7 @@
import {
BOOK_FINDING_CODES,
BOOK_OP_LIMITS,
bookCoverTemplateIdSchema,
bookAnyTemplateIdSchema,
bookPageOpSchema,
bookSpreadIdSchema,
} from '../../book/book-ops.schema';
@@ -63,7 +63,7 @@ export type BookCardLayout = z.infer<typeof bookCardLayoutSchema>;
/** `set_book_cover`'s arguments, null where the call leaves a part of the cover as it is. */
export const bookCardCoverSchema = z.object({
template: bookCoverTemplateIdSchema.nullable(),
template: bookAnyTemplateIdSchema.nullable(),
photoId: idSchema.nullable(),
title: z.string().max(MAX_BOOK_TITLE).nullable(),
subtitle: z.string().max(MAX_BOOK_TITLE).nullable(),
+4 -13
View File
@@ -7,7 +7,6 @@ import {
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 {
BOOK_BINDING_HINT,
bookFontIdSchema,
@@ -76,16 +75,6 @@ export const BOOK_OP_LIMITS = {
findings: 40,
} as const;
const idsOf = (list: readonly { id: string }[]) => list.map((t) => t.id) as [string, ...string[]];
/** The spread layouts, by id. */
export const bookTemplateIdSchema = z
.enum(idsOf(BOOK_TEMPLATES))
.describe('A built-in double page layout by id; list_book_layouts says what each one holds.');
/** The single-page (cover and back) layouts, by id. */
export const bookCoverTemplateIdSchema = z
.enum(idsOf(BOOK_COVER_TEMPLATES))
.describe('A built-in cover or back layout by id; list_book_layouts says what each one holds.');
/**
* A layout of either set, built-in or decorated: which one fits is the page's
* question, answered when the operation runs. A string checked against the
@@ -278,7 +267,8 @@ export const addBookPagesArgs = {
/** The new pages go after this one, kept inside the covers. */
afterSpreadId: bookSpreadIdSchema,
count: z.number().int().min(1).max(BOOK_OP_LIMITS.pagesPerCall).optional(),
template: bookTemplateIdSchema.optional(),
/** A double-spread layout, built-in or decorated; a single-page one is refused when the page is laid. */
template: bookAnyTemplateIdSchema.optional(),
entryId: idSchema.optional(),
};
export const moveBookPageArgs = {
@@ -296,7 +286,8 @@ export const deleteBookPagesArgs = {
expectedVersion,
};
export const setBookCoverArgs = {
template: bookCoverTemplateIdSchema.optional(),
/** A single-page layout, built-in or decorated; a double-spread one is refused when the cover is laid. */
template: bookAnyTemplateIdSchema.optional(),
photoId: idSchema.optional(),
title: z.string().max(MAX_BOOK_TITLE).optional(),
subtitle: z.string().max(MAX_BOOK_TITLE).optional(),
+20
View File
@@ -108,6 +108,19 @@ describe('add, move and delete', () => {
expect(doc.spreads[3]?.elements.length).toBeGreaterThan(0);
});
it('BOOKPG-029: lays added pages out on a decorated template, and refuses a cover template for a double spread', () => {
const doc = ok(
addBookPages(book(1), { afterSpreadId: 'sp-in1', count: 2, template: 'hero-script-word' }, { newId }),
);
expect(doc.spreads[3]?.elements.length).toBeGreaterThan(0);
expect(doc.spreads[4]?.elements.length).toBe(doc.spreads[3]?.elements.length);
const wrong = addBookPages(book(1), { afterSpreadId: 'sp-in1', template: 'poster-pop-sunburst' }, { newId });
expect(refused(wrong)).toBe('layout_role');
expect(refused(addBookPages(book(1), { afterSpreadId: 'sp-in1', template: 'no-such' }, { newId }))).toBe(
'invalid_op',
);
});
it('BOOKPG-020: ties an added page only to an entry the journey has, when the journey is known', () => {
const journey = { title: 'Iceland', entries: [{ id: 7 }] };
const added = ok(addBookPages(book(1), { afterSpreadId: 'sp-in1', entryId: 7 }, { newId, journey }));
@@ -336,6 +349,13 @@ describe('the cover', () => {
expect(title).toBeDefined();
});
it('BOOKPG-030: lays the cover out on a decorated cover template, and refuses a double-spread one', () => {
const doc = ok(setBookCover(book(1), { template: 'poster-pop-sunburst', title: 'Iceland 2026' }, { newId }));
expect(coverOf(doc).elements.length).toBeGreaterThan(1);
expect(textsOf(doc).some((t) => t.text === 'Iceland 2026')).toBe(true);
expect(refused(setBookCover(book(1), { template: 'hero-script-word' }, { newId }))).toBe('layout_role');
});
it('BOOKPG-016: keeps a bound title bound when the words are what the journey says, and the subtitle apart', () => {
const base = book(1);
const bound = bookTextElementSchema.parse({
+15 -9
View File
@@ -1,5 +1,5 @@
import { bookElementId } from './book-ids';
import { applyBookTemplate, classifyBookTexts, findBookTemplate } from './book-layouts';
import { classifyBookTexts } from './book-layouts';
import { applyBookOps } from './book-ops';
import {
caught,
@@ -62,17 +62,23 @@ export function addBookPages(doc: BookDocument, args: unknown, ctx: BookOpsConte
}
const { first, limit } = innerSpreadBounds(doc.spreads);
const at = Math.min(Math.max(after + 1, first), limit);
const tpl = template === undefined ? undefined : findBookTemplate(template);
const added = Array.from({ length: count }, () => {
const empty = { ...blankSpread('inner', ctx.newId ?? bookElementId), entryId: entryId ?? null };
return checkedSpread(tpl ? applyBookTemplate(empty, tpl, doc.page) : empty, null);
});
const spreads = [...doc.spreads.slice(0, at), ...added, ...doc.spreads.slice(at)];
const added = Array.from({ length: count }, () =>
checkedSpread({ ...blankSpread('inner', ctx.newId ?? bookElementId), entryId: entryId ?? null }, null),
);
let next: BookDocument = { ...doc, spreads: [...doc.spreads.slice(0, at), ...added, ...doc.spreads.slice(at)] };
// Laid out as edit_book_page lays a page, so a decorated template takes the book's theme and words too.
if (template !== undefined) {
for (const spread of added) {
const r = applyBookOps(next, { spreadId: spread.id, ops: [{ op: 'layout', template }] }, ctx);
if (r.ok === false) throw failOp(r.error.code, r.error.message, null, r.error.elementId);
next = r.doc;
}
}
return {
ok: true,
doc: { ...doc, spreads },
doc: next,
spreadIds: added.map((s) => s.id),
created: createdIds(doc.spreads, spreads),
created: createdIds(doc.spreads, next.spreads),
dropped: [],
};
});
@@ -356,6 +356,17 @@ describe('design_book', () => {
expect(JSON.stringify(again.doc)).toBe(JSON.stringify(banded.doc));
});
it('BOOKDSN-032: lays the cover out on a decorated cover template, and leaves a double-spread one out with a note', () => {
const poster = designBook(null, input('rich'), { style: 'minimal', cover: { template: 'poster-pop-sunburst' } });
const cover = poster.doc.spreads[0] as BookSpread;
expect(cover.elements.some((e) => e.kind === 'photo')).toBe(true);
expect(cover.elements.some((e) => e.kind === 'text' && e.binding?.source === 'journey.title')).toBe(true);
const again = designBook(null, input('rich'), { style: 'minimal', cover: { template: 'poster-pop-sunburst' } });
expect(JSON.stringify(again.doc)).toBe(JSON.stringify(poster.doc));
const spread = designBook(null, input('rich'), { style: 'minimal', cover: { template: 'hero-script-word' } });
expect(spread.notes.some((n) => n.includes('hero-script-word'))).toBe(true);
});
it('BOOKDSN-014: stays within the spread cap beside many kept pages, the closing pages kept', () => {
const stored = storedBook();
const lockedPage = stored.spreads[2] as BookSpread;
@@ -1,6 +1,6 @@
import { idSchema } from '../../common/primitives.schema';
import { bookFontIdSchema, bookMetricSchema, bookPagePresetSchema } from '../book-op-enums';
import { bookCoverTemplateIdSchema } from '../book-ops.schema';
import { bookAnyTemplateIdSchema } from '../book-ops.schema';
import { MAX_BOOK_TITLE } from '../book.schema';
import { bookThemeCatalogue } from '../theme/book-theme-describe';
import { BOOK_THEME_IDS } from '../theme/book-theme.schema';
@@ -84,7 +84,7 @@ export const bookDesignDirectivesSchema = z.strictObject({
.optional(),
cover: z
.strictObject({
template: bookCoverTemplateIdSchema.optional(),
template: bookAnyTemplateIdSchema.optional(),
archetype: bookCoverArchetypeSchema.optional(),
lockup: bookCoverLockupSchema.optional(),
title: z.string().max(MAX_BOOK_TITLE).optional(),
+18 -2
View File
@@ -6,6 +6,8 @@ import { formatBookDate } from '../book-text';
import { bookTravelCountries, bookTravelMap, bookTravelStats } from '../book-travel';
import type { BookElement, BookMetric, BookSpread, BookTextElement } from '../book.schema';
import type { JourneyStats } from '../journey-stats.schema';
import { applyDecoratedTemplate } from '../templates/template-apply';
import { findDecoratedTemplate } from '../templates/template-catalogue';
import {
bleedPage,
frameOf,
@@ -90,8 +92,22 @@ export function composeCover(
*/
function withCoverTemplate(c: ComposeCtx, cover: BookSpread): BookSpread {
const wanted = c.directives.cover?.template;
const tpl = wanted ? BOOK_COVER_TEMPLATES.find((t) => t.id === wanted) : undefined;
if (!tpl) return cover;
if (!wanted) return cover;
const decorated = findDecoratedTemplate(wanted);
if (decorated) {
if (decorated.kind !== 'single') {
c.notes.push(`${wanted} is a double-spread template, so cover.template is left out`);
return cover;
}
// A designed page keeps its own faces and colours, in the book's theme, and takes the cover's photo and words.
const opts = { theme: c.look.theme, travel: c.travel, locale: c.locale, newId: c.newId };
return { ...applyDecoratedTemplate(cover, decorated, c.page, opts), parked: [] };
}
const tpl = BOOK_COVER_TEMPLATES.find((t) => t.id === wanted);
if (!tpl) {
c.notes.push(`${wanted} is a double-spread layout, so cover.template is left out`);
return cover;
}
const laid = applyBookTemplate(cover, tpl, c.page);
const elements = laid.elements.map((e): BookElement => {
if (e.kind === 'text') return { ...e, id: c.newId('t'), color: c.look.ink };
+2 -2
View File
@@ -221,11 +221,11 @@ Requires `journey:read` or `journey:write` scope.
| `list_book_layouts` | The layouts a page can take: templates for double spreads and single pages, the decorated templates (whole designed pages, each with its style, tags, photo frames and the roles its words go into), photo grids, and the book's own saved layouts, with what each does to what is on the page. |
| `list_book_assets` | Search the Studio's catalogues by `kind`, each row with its id, a one-line description and its tags: `sticker` (illustrations of landmarks, travel things, plants, food, paper and tape, doodles and ornaments, with their proportions and colour slots; `edit_book_page` places one with `add` and recolours it with `style`), `shape` (the outlines a shape draws and a photo is cut to, marked when one holds a photo or takes a cut edge), `font` (the typefaces by role: display, heading, body, script, mono, with their weights), `lockup` (the set titles, badges, postmarks and quotes `add_lockup` places, with the word roles each takes), `theme` (the twelve built-in themes with their palette, typefaces and motifs) and `pattern` (the repeats a paint takes). Layouts come from `list_book_layouts`. |
| `create_book` | Lay down a blank book (cover, first page, one double spread, last page, back) for a journey that has none, in a page format of your choice. Needs edit access. |
| `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. |
| `add_book_pages` | Add up to 10 empty double spreads, always between the first and the last page, optionally on a built-in or decorated 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 built-in or decorated template, a grid or a saved 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. |
| `set_book_cover` | The cover's layout (a built-in or decorated cover template), 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. |
| `set_book_setup` | The page format (a preset, or width and height in millimetres, bleed, safe margin) and the page numbers. Elements keep their size and place, as in the Studio. The answer holds the setup as it was. Needs edit access. |