mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
feat(journey): the assistant designs the whole book as one proposal
design_book, revise_book_design and apply_book_design work on a draft, the apply keeps the old book as a revision for undo, skips pages changed since, and broadcasts once after the commit. Book level tools set the page setup, duplicate pages and save layouts.
This commit is contained in:
@@ -1,9 +1,14 @@
|
||||
import { bookElementIdSchema, bookSpreadIdSchema } from '../book/book-ops.schema';
|
||||
import { idParamSchema, idSchema } from '../common/primitives.schema';
|
||||
import { assistantPageRenderSchema, assistantPageViewSchema } from './assistant-vision.schema';
|
||||
import {
|
||||
assistantCardSchema,
|
||||
assistantCardWriteSchema,
|
||||
ATLAS_APPLIED_ENTITIES,
|
||||
ATLAS_VERB_KEYS,
|
||||
BOOK_APPLIED_ENTITIES,
|
||||
BOOK_DESIGN_VERB_KEYS,
|
||||
BOOK_VERB_KEYS,
|
||||
BOOKING_APPLIED_ENTITIES,
|
||||
BOOKING_VERB_KEYS,
|
||||
BUDGET_APPLIED_ENTITIES,
|
||||
@@ -116,6 +121,8 @@ export const ASSISTANT_CHANGE_VERB_KEYS = [
|
||||
...VACAY_VERB_KEYS,
|
||||
...JOURNEY_VERB_KEYS,
|
||||
...PACKING_VERB_KEYS,
|
||||
...BOOK_VERB_KEYS,
|
||||
...BOOK_DESIGN_VERB_KEYS,
|
||||
] as const;
|
||||
export const assistantChangeVerbKeySchema = z.enum(ASSISTANT_CHANGE_VERB_KEYS);
|
||||
export type AssistantChangeVerbKey = z.infer<typeof assistantChangeVerbKeySchema>;
|
||||
@@ -202,6 +209,9 @@ export const assistantFocusSchema = z
|
||||
dayId: idSchema.optional(),
|
||||
placeIds: z.array(idSchema).max(20).optional(),
|
||||
view: assistantMapViewSchema.optional(),
|
||||
/** In the Studio: the page (spread) on screen, and the elements selected on it. */
|
||||
spreadId: bookSpreadIdSchema.optional(),
|
||||
elementIds: z.array(bookElementIdSchema).max(30).optional(),
|
||||
})
|
||||
.strict();
|
||||
export type AssistantFocus = z.infer<typeof assistantFocusSchema>;
|
||||
@@ -220,6 +230,8 @@ export const assistantChatRequestSchema = assistantClientCtxSchema
|
||||
text: z.string().trim().min(1).max(32_000),
|
||||
focus: assistantFocusSchema.optional(),
|
||||
mentions: z.array(assistantMentionSchema).max(50).optional(),
|
||||
/** The client draws a Studio page when the turn asks for one (the Studio's chat, assistant-vision.schema.ts). */
|
||||
renders: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
export type AssistantChatRequest = z.infer<typeof assistantChatRequestSchema>;
|
||||
@@ -249,6 +261,8 @@ export type AssistantDecision = z.infer<typeof assistantDecisionSchema>;
|
||||
export const assistantDecisionsRequestSchema = assistantClientCtxSchema
|
||||
.extend({
|
||||
decisions: z.array(assistantDecisionSchema).min(1).max(50),
|
||||
/** As on a chat turn: the client draws a Studio page when the turn asks for one. */
|
||||
renders: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
export type AssistantDecisionsRequest = z.infer<typeof assistantDecisionsRequestSchema>;
|
||||
@@ -382,6 +396,7 @@ const assistantAppliedEntitySchema = z.enum([
|
||||
...VACAY_APPLIED_ENTITIES,
|
||||
...JOURNEY_APPLIED_ENTITIES,
|
||||
...PACKING_APPLIED_ENTITIES,
|
||||
...BOOK_APPLIED_ENTITIES,
|
||||
]);
|
||||
|
||||
/** An applied change as the server writes it: what it touched, and until when it can be undone. */
|
||||
@@ -579,6 +594,8 @@ function messagePartSchema<
|
||||
z.object({ type: z.literal('data-trek-card'), data: card }),
|
||||
// Only on a user message.
|
||||
z.object({ type: z.literal('data-trek-mentions'), data: assistantMentionsSchema }),
|
||||
// A Studio page the turn looked at: what it was and its description, never the image.
|
||||
z.object({ type: z.literal('data-trek-page-view'), data: assistantPageViewSchema }),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -679,6 +696,13 @@ export const uiStreamChunkSchema = z.discriminatedUnion('type', [
|
||||
data: assistantActivitySchema,
|
||||
transient: z.literal(true).optional(),
|
||||
}),
|
||||
// A Studio page the turn asks the Studio tab to draw and upload; the message never keeps it.
|
||||
z.object({
|
||||
type: z.literal('data-trek-render-request'),
|
||||
data: assistantPageRenderSchema,
|
||||
transient: z.literal(true).optional(),
|
||||
}),
|
||||
z.object({ type: z.literal('data-trek-page-view'), data: assistantPageViewSchema }),
|
||||
z.object({
|
||||
type: z.literal('data-trek-quota'),
|
||||
data: z.object({ used: z.number().int().nonnegative(), limit: z.number().int().nonnegative() }),
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { ASSISTANT_CHANGE_VERB_KEYS, assistantChangePreviewSchema } from '../assistant.schema';
|
||||
import { BOOK_DESIGN_CARD_LIMITS, bookDesignCardSchema } from './book-design.card';
|
||||
import { BOOK_DESIGN_VERB_KEYS } from './book-design.contract';
|
||||
import { storedTrekCardSchema, trekCardSchema } from './index';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// BKDCARD-001 to BKDCARD-004
|
||||
|
||||
function card(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
kind: 'bookDesign',
|
||||
change: 'proposed',
|
||||
journeyId: 4,
|
||||
designId: 'd-1',
|
||||
baseVersion: 7,
|
||||
style: 'editorial',
|
||||
paper: '#faf8f4',
|
||||
ink: '#1a1a1a',
|
||||
accent: '#b45309',
|
||||
heading: 'playfair',
|
||||
body: 'garamond',
|
||||
spreads: [
|
||||
{
|
||||
spreadId: 'sp-1',
|
||||
index: 0,
|
||||
action: 'replace',
|
||||
beat: 'cover',
|
||||
chapter: null,
|
||||
entryId: null,
|
||||
photoCount: 1,
|
||||
severe: 0,
|
||||
warn: 0,
|
||||
},
|
||||
{
|
||||
spreadId: 'sp-9',
|
||||
index: 3,
|
||||
action: 'insert',
|
||||
beat: 'hero',
|
||||
chapter: 'Alfama',
|
||||
entryId: 12,
|
||||
photoCount: 1,
|
||||
severe: 0,
|
||||
warn: 1,
|
||||
},
|
||||
],
|
||||
more: 0,
|
||||
leftOut: 3,
|
||||
revisionId: null,
|
||||
skipped: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('the book design card', () => {
|
||||
it('BKDCARD-001: reads a design card through the union, as itself', () => {
|
||||
const parsed = trekCardSchema.parse(card());
|
||||
expect(parsed.kind).toBe('bookDesign');
|
||||
expect(bookDesignCardSchema.parse(parsed).spreads).toHaveLength(2);
|
||||
expect(storedTrekCardSchema.parse(card({ change: 'applied', revisionId: 5 }))).toMatchObject({ revisionId: 5 });
|
||||
});
|
||||
|
||||
it('BKDCARD-002: a field past its bound, an unknown style or reason, reads as no card', () => {
|
||||
const page = card().spreads[1];
|
||||
const longChapter = [{ ...page, chapter: 'x'.repeat(BOOK_DESIGN_CARD_LIMITS.chapter + 1) }];
|
||||
expect(storedTrekCardSchema.parse(card({ spreads: longChapter }))).toBeNull();
|
||||
expect(storedTrekCardSchema.parse(card({ style: 'baroque' }))).toBeNull();
|
||||
expect(storedTrekCardSchema.parse(card({ skipped: [{ spreadId: 'sp-1', reason: 'tired' }] }))).toBeNull();
|
||||
const many = Array.from({ length: BOOK_DESIGN_CARD_LIMITS.spreads + 1 }, () => page);
|
||||
expect(storedTrekCardSchema.parse(card({ spreads: many }))).toBeNull();
|
||||
});
|
||||
|
||||
it('BKDCARD-003: rides on a change preview with its own verb', () => {
|
||||
const preview = assistantChangePreviewSchema.parse({
|
||||
toolCallId: 'call_1',
|
||||
verbKey: 'designBook',
|
||||
subject: '',
|
||||
rows: [],
|
||||
dayId: null,
|
||||
placeIds: [],
|
||||
undoable: true,
|
||||
card: card(),
|
||||
});
|
||||
expect([preview.verbKey, preview.card?.kind]).toEqual(['designBook', 'bookDesign']);
|
||||
});
|
||||
|
||||
it('BKDCARD-004: names the verbs of the book-level changes in the shared enum', () => {
|
||||
expect(ASSISTANT_CHANGE_VERB_KEYS).toEqual(expect.arrayContaining([...BOOK_DESIGN_VERB_KEYS]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { BOOK_DESIGN_SKIP_REASONS } from '../../book/book-design-tools.schema';
|
||||
import { bookDesignIdSchema, bookSpreadIdSchema } from '../../book/book-ops.schema';
|
||||
import { BOOK_FONTS_IDS } from '../../book/book.schema';
|
||||
import { BOOK_DESIGN_STYLES } from '../../book/design/design-directives.schema';
|
||||
import { BOOK_DESIGN_ACTIONS } from '../../book/design/design-merge';
|
||||
import { BOOK_DESIGN_BEATS } from '../../book/design/design-pacing';
|
||||
import { idSchema } from '../../common/primitives.schema';
|
||||
import { cardCountSchema, cardHexColorSchema, cardText } from './card-parts';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* A whole-book design as the Studio's chat shows it: the book's look (style,
|
||||
* paper, ink, accent, the two typefaces) and the plan page by page, what
|
||||
* each page becomes and how many findings the server's check left on it.
|
||||
* `none` is the answer of `design_book` (a draft, nothing proposed yet),
|
||||
* `proposed` the held `apply_book_design`, `applied` its receipt with the
|
||||
* revision that takes it back and the pages it left as they were. The pages
|
||||
* themselves are drawn from the draft (GET /api/journeys/:id/book-designs/:designId),
|
||||
* never carried in the card.
|
||||
*/
|
||||
|
||||
export const BOOK_DESIGN_CARD_LIMITS = { spreads: 120, chapter: 40 } as const;
|
||||
|
||||
export const BOOK_DESIGN_CARD_CHANGES = ['none', 'proposed', 'applied'] as const;
|
||||
|
||||
/** One page of the plan as a card names it. */
|
||||
export const bookDesignCardSpreadSchema = z.object({
|
||||
spreadId: bookSpreadIdSchema,
|
||||
index: cardCountSchema,
|
||||
action: z.enum(BOOK_DESIGN_ACTIONS),
|
||||
beat: z.enum(BOOK_DESIGN_BEATS),
|
||||
chapter: cardText(BOOK_DESIGN_CARD_LIMITS.chapter).nullable(),
|
||||
entryId: idSchema.nullable(),
|
||||
photoCount: cardCountSchema,
|
||||
severe: cardCountSchema,
|
||||
warn: cardCountSchema,
|
||||
});
|
||||
export type BookDesignCardSpread = z.infer<typeof bookDesignCardSpreadSchema>;
|
||||
|
||||
export const bookDesignCardSchema = z.object({
|
||||
kind: z.literal('bookDesign'),
|
||||
change: z.enum(BOOK_DESIGN_CARD_CHANGES),
|
||||
journeyId: idSchema,
|
||||
designId: bookDesignIdSchema,
|
||||
/** The book's version the draft was made against; null for a journey without a book. */
|
||||
baseVersion: cardCountSchema.nullable(),
|
||||
style: z.enum(BOOK_DESIGN_STYLES),
|
||||
paper: cardHexColorSchema,
|
||||
ink: cardHexColorSchema,
|
||||
accent: cardHexColorSchema,
|
||||
heading: z.enum(BOOK_FONTS_IDS),
|
||||
body: z.enum(BOOK_FONTS_IDS),
|
||||
spreads: z.array(bookDesignCardSpreadSchema).max(BOOK_DESIGN_CARD_LIMITS.spreads),
|
||||
/** Plan pages past the ones listed. */
|
||||
more: cardCountSchema,
|
||||
/** Photos of the journey the design left out. */
|
||||
leftOut: cardCountSchema,
|
||||
/** Once applied: the revision the undo restores. */
|
||||
revisionId: idSchema.nullable(),
|
||||
/** Once applied: the pages the apply left as they were, and why. */
|
||||
skipped: z
|
||||
.array(z.object({ spreadId: bookSpreadIdSchema, reason: z.enum(BOOK_DESIGN_SKIP_REASONS) }))
|
||||
.max(BOOK_DESIGN_CARD_LIMITS.spreads),
|
||||
});
|
||||
export type BookDesignCard = z.infer<typeof bookDesignCardSchema>;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { bookDesignCardSchema } from './book-design.card';
|
||||
|
||||
export * from './book-design.card';
|
||||
|
||||
/**
|
||||
* What the assistant's whole-book design brings to the chat: the card of a
|
||||
* design and its proposal, and the verbs of the book-level changes (the
|
||||
* design applied, the page format and numbers, a page copied, a layout kept
|
||||
* or taken away). They touch pages of the book, so they name the photo
|
||||
* book's own entity (`book_page`, book.contract.ts) and add none.
|
||||
*/
|
||||
export const BOOK_DESIGN_CARD_SCHEMAS = [bookDesignCardSchema] as const;
|
||||
export const BOOK_DESIGN_VERB_KEYS = [
|
||||
'designBook',
|
||||
'setBookSetup',
|
||||
'duplicateBookPage',
|
||||
'saveBookLayout',
|
||||
'removeBookLayout',
|
||||
] as const;
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ATLAS_CARD_SCHEMAS } from './atlas.contract';
|
||||
import { BOOK_DESIGN_CARD_SCHEMAS } from './book-design.contract';
|
||||
import { BOOK_CARD_SCHEMAS } from './book.contract';
|
||||
import { BOOKING_CARD_SCHEMAS } from './booking.contract';
|
||||
import { BUDGET_CARD_SCHEMAS } from './budget.contract';
|
||||
import { COLLAB_CARD_SCHEMAS } from './collab.contract';
|
||||
@@ -13,6 +15,8 @@ import { z } from 'zod';
|
||||
|
||||
export * from './card-parts';
|
||||
export * from './atlas.contract';
|
||||
export * from './book.contract';
|
||||
export * from './book-design.contract';
|
||||
export * from './booking.contract';
|
||||
export * from './budget.contract';
|
||||
export * from './collab.contract';
|
||||
@@ -41,6 +45,8 @@ export const trekCardSchema = z.discriminatedUnion('kind', [
|
||||
...VACAY_CARD_SCHEMAS,
|
||||
...JOURNEY_CARD_SCHEMAS,
|
||||
...PACKING_CARD_SCHEMAS,
|
||||
...BOOK_CARD_SCHEMAS,
|
||||
...BOOK_DESIGN_CARD_SCHEMAS,
|
||||
]);
|
||||
export type TrekCard = z.infer<typeof trekCardSchema>;
|
||||
export type TrekCardKind = TrekCard['kind'];
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
BOOK_DESIGN_LIMITS,
|
||||
applyBookDesignRequestSchema,
|
||||
bookDesignAnswerSchema,
|
||||
bookDesignApplyResultSchema,
|
||||
bookDesignDraftResponseSchema,
|
||||
bookDesignSelectionRequestSchema,
|
||||
designBookRequestSchema,
|
||||
restoreBookLayoutRequestSchema,
|
||||
restoreBookRevisionRequestSchema,
|
||||
reviseBookDesignRequestSchema,
|
||||
} from './book-design-tools.schema';
|
||||
import { removeBookLayoutRequestSchema, setBookSetupRequestSchema } from './book-ops.schema';
|
||||
import { blankBook } from './book-pages';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// BKDTOOL-001 to BKDTOOL-006
|
||||
|
||||
const row = {
|
||||
spreadId: 'sp-1',
|
||||
index: 0,
|
||||
action: 'replace',
|
||||
beat: 'cover',
|
||||
chapter: null,
|
||||
entryId: null,
|
||||
photoIds: [3],
|
||||
findings: { severe: 0, warn: 1 },
|
||||
};
|
||||
|
||||
const answer = {
|
||||
design_id: 'd-1',
|
||||
journey_id: 4,
|
||||
base_version: 7,
|
||||
expires_at: '2026-10-22T18:00:00.000Z',
|
||||
plan: [row],
|
||||
more_rows: 0,
|
||||
selected: ['sp-1'],
|
||||
style: { style: 'minimal', paper: '#ffffff', ink: '#1a1a1a', accent: '#336699', heading: 'inter', body: 'inter' },
|
||||
photos: { used: 3, left_out: [{ photoId: 9, reason: 'near_duplicate' }], left_out_more: 0 },
|
||||
findings: [],
|
||||
notes: ['the book holds 150 spreads'],
|
||||
};
|
||||
|
||||
describe('the design tools', () => {
|
||||
it('BKDTOOL-001: design_book takes no directives or bounded ones, and nothing it does not know', () => {
|
||||
expect(designBookRequestSchema.parse({}).directives).toBeUndefined();
|
||||
const parsed = designBookRequestSchema.parse({ directives: { style: 'editorial', pages: { max: 12 } } });
|
||||
expect(parsed.directives).toMatchObject({ style: 'editorial', density: 'balanced', keep: 'locked' });
|
||||
expect(designBookRequestSchema.safeParse({ directives: { mood: 'calm' } }).success).toBe(false);
|
||||
expect(designBookRequestSchema.safeParse({ journeyId: 3 }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('BKDTOOL-002: a revision names its draft and page and takes the ops edit_book_page takes', () => {
|
||||
const ops = [{ op: 'background', color: '#f4efe6' }];
|
||||
expect(reviseBookDesignRequestSchema.safeParse({ designId: 'd-1', spreadId: 'sp-1', ops }).success).toBe(true);
|
||||
expect(reviseBookDesignRequestSchema.safeParse({ designId: 'd-1', spreadId: 'sp-1', ops: [] }).success).toBe(false);
|
||||
expect(reviseBookDesignRequestSchema.safeParse({ designId: '../d', spreadId: 'sp-1', ops }).success).toBe(false);
|
||||
expect(applyBookDesignRequestSchema.safeParse({ designId: 'd-1' }).success).toBe(true);
|
||||
expect(applyBookDesignRequestSchema.safeParse({ designId: 'd-1', expectedVersion: -1 }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('BKDTOOL-003: the undo targets name what they put back and the version they need', () => {
|
||||
expect(restoreBookRevisionRequestSchema.safeParse({ revisionId: 3, expectedVersion: 8 }).success).toBe(true);
|
||||
expect(restoreBookRevisionRequestSchema.safeParse({ revisionId: 3 }).success).toBe(false);
|
||||
const layout = { layout: '{"id":"ly-1"}', expectedVersion: 2 };
|
||||
expect(restoreBookLayoutRequestSchema.safeParse(layout).success).toBe(true);
|
||||
expect(restoreBookLayoutRequestSchema.safeParse({ layout: '', expectedVersion: 2 }).success).toBe(false);
|
||||
expect(setBookSetupRequestSchema.safeParse({ safe: 6, expectedVersion: 4 }).success).toBe(true);
|
||||
expect(removeBookLayoutRequestSchema.safeParse({ layoutId: 'ly-1', expectedVersion: 4 }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('BKDTOOL-004: the selection is a closed list of page ids, as long as a book and its drops', () => {
|
||||
const ids = Array.from({ length: BOOK_DESIGN_LIMITS.selection }, (_, i) => `sp-${i}`);
|
||||
expect(bookDesignSelectionRequestSchema.safeParse({ spreadIds: ids }).success).toBe(true);
|
||||
expect(bookDesignSelectionRequestSchema.safeParse({ spreadIds: [...ids, 'sp-x'] }).success).toBe(false);
|
||||
expect(bookDesignSelectionRequestSchema.safeParse({ spreadIds: ['sp 1'] }).success).toBe(false);
|
||||
expect(bookDesignSelectionRequestSchema.safeParse({ spreadIds: [], all: true }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('BKDTOOL-005: a design answer reads as itself, its plan rows bounded', () => {
|
||||
expect(bookDesignAnswerSchema.parse(answer)).toEqual(answer);
|
||||
const long = { ...answer, plan: [{ ...row, chapter: 'x'.repeat(81) }] };
|
||||
expect(bookDesignAnswerSchema.safeParse(long).success).toBe(false);
|
||||
const draft = { designId: 'd-1', baseVersion: null, expiresAt: answer.expires_at, plan: [row], selection: [] };
|
||||
const response = { ...draft, document: blankBook({ title: 'Lisbon' }) };
|
||||
expect(bookDesignDraftResponseSchema.safeParse(response).success).toBe(true);
|
||||
});
|
||||
|
||||
it('BKDTOOL-006: an apply answers a book write with its revision (none for a new book) and the pages applied and left', () => {
|
||||
const write = { version: 8, spread_ids: ['sp-1'], pages: [], findings: [], dropped: [] };
|
||||
const applied = { ...write, design_id: 'd-1', revision_id: 12, applied: ['sp-1'], skipped: [] };
|
||||
expect(bookDesignApplyResultSchema.safeParse(applied).success).toBe(true);
|
||||
expect(bookDesignApplyResultSchema.safeParse({ ...applied, revision_id: null }).success).toBe(true);
|
||||
const skipped = [{ spreadId: 'sp-2', reason: 'changed_since' }];
|
||||
expect(bookDesignApplyResultSchema.safeParse({ ...applied, skipped }).success).toBe(true);
|
||||
const unknown = [{ spreadId: 'sp-2', reason: 'tired' }];
|
||||
expect(bookDesignApplyResultSchema.safeParse({ ...applied, skipped: unknown }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { idSchema } from '../common/primitives.schema';
|
||||
import {
|
||||
bookDesignIdSchema,
|
||||
bookFindingSchema,
|
||||
bookSpreadIdSchema,
|
||||
bookSpreadSummarySchema,
|
||||
bookWriteResultSchema,
|
||||
editBookPageArgs,
|
||||
} from './book-ops.schema';
|
||||
import { BOOK_FONTS_IDS, MAX_SPREADS, MAX_SPREAD_ELEMENTS, bookDocumentSchema } from './book.schema';
|
||||
import { BOOK_DESIGN_STYLES, bookDesignDirectivesSchema } from './design/design-directives.schema';
|
||||
import { BOOK_DESIGN_ACTIONS } from './design/design-merge';
|
||||
import { BOOK_DESIGN_BEATS } from './design/design-pacing';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* The whole-book design as the assistant's tools take and answer it
|
||||
* (journey-studio-wp13.md section 6): `design_book` composes a draft the
|
||||
* server keeps, `revise_book_design` changes one page of it, `get_book_design`
|
||||
* reads it again, `apply_book_design` writes the chosen pages in one
|
||||
* transaction and keeps the book as it was as a revision, which
|
||||
* `restore_book_revision` puts back. The draft's selection travels over REST
|
||||
* for the proposal card. Plain shapes for the tools, strict request schemas
|
||||
* beside them, as in book-ops.schema.ts.
|
||||
*/
|
||||
|
||||
export const BOOK_DESIGN_LIMITS = {
|
||||
/** Plan rows one answer lists; the rest are counted. */
|
||||
planRows: 150,
|
||||
/** Photos left out one answer names. */
|
||||
leftOut: 60,
|
||||
findings: 40,
|
||||
notes: 20,
|
||||
/** Pages of the book an apply answers with; every applied id is still named. */
|
||||
pages: 24,
|
||||
/** Pages a selection names at most: every page of a book and every page it drops. */
|
||||
selection: MAX_SPREADS * 2,
|
||||
/** Revisions a journey keeps, the newest first. */
|
||||
revisions: 5,
|
||||
} as const;
|
||||
|
||||
/** Why a page the design would change was not changed by its apply. */
|
||||
export const BOOK_DESIGN_SKIP_REASONS = ['excluded', 'changed_since', 'photo_gone'] as const;
|
||||
export type BookDesignSkipReason = (typeof BOOK_DESIGN_SKIP_REASONS)[number];
|
||||
|
||||
const version = z.number().int().nonnegative();
|
||||
const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/);
|
||||
|
||||
export const designBookArgs = { directives: bookDesignDirectivesSchema.optional() };
|
||||
export const getBookDesignArgs = { designId: bookDesignIdSchema };
|
||||
/** One page of a draft changed with the operations `edit_book_page` takes. */
|
||||
export const reviseBookDesignArgs = {
|
||||
designId: bookDesignIdSchema,
|
||||
spreadId: bookSpreadIdSchema,
|
||||
ops: editBookPageArgs.ops,
|
||||
};
|
||||
export const applyBookDesignArgs = { designId: bookDesignIdSchema, expectedVersion: version.optional() };
|
||||
/** The assistant's undo of an applied design: the book put back as the apply found it. */
|
||||
export const restoreBookRevisionArgs = { revisionId: idSchema, expectedVersion: version };
|
||||
/**
|
||||
* The assistant's undo of `remove_book_layout`: the layout it answered under
|
||||
* `previous_layout`, as JSON, parsed again like every argument. A string keeps
|
||||
* the tool's schema small; every client loads it with tools/list.
|
||||
*/
|
||||
export const restoreBookLayoutArgs = { layout: z.string().min(2).max(400_000), expectedVersion: version };
|
||||
|
||||
export const designBookRequestSchema = z.strictObject(designBookArgs);
|
||||
export const getBookDesignRequestSchema = z.strictObject(getBookDesignArgs);
|
||||
export const reviseBookDesignRequestSchema = z.strictObject(reviseBookDesignArgs);
|
||||
export const applyBookDesignRequestSchema = z.strictObject(applyBookDesignArgs);
|
||||
export const restoreBookRevisionRequestSchema = z.strictObject(restoreBookRevisionArgs);
|
||||
export const restoreBookLayoutRequestSchema = z.strictObject(restoreBookLayoutArgs);
|
||||
|
||||
export type DesignBookRequest = z.infer<typeof designBookRequestSchema>;
|
||||
export type GetBookDesignRequest = z.infer<typeof getBookDesignRequestSchema>;
|
||||
export type ReviseBookDesignRequest = z.infer<typeof reviseBookDesignRequestSchema>;
|
||||
export type ApplyBookDesignRequest = z.infer<typeof applyBookDesignRequestSchema>;
|
||||
export type RestoreBookRevisionRequest = z.infer<typeof restoreBookRevisionRequestSchema>;
|
||||
export type RestoreBookLayoutRequest = z.infer<typeof restoreBookLayoutRequestSchema>;
|
||||
|
||||
/** One page of a design's plan, as an answer and the draft route carry it. */
|
||||
export const bookDesignPlanRowSchema = z.object({
|
||||
spreadId: bookSpreadIdSchema,
|
||||
index: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(MAX_SPREADS * 2),
|
||||
action: z.enum(BOOK_DESIGN_ACTIONS),
|
||||
beat: z.enum(BOOK_DESIGN_BEATS),
|
||||
chapter: z.string().max(80).nullable(),
|
||||
entryId: z.number().int().positive().nullable(),
|
||||
photoIds: z.array(z.number().int().positive()).max(MAX_SPREAD_ELEMENTS),
|
||||
findings: z.object({ severe: z.number().int().nonnegative(), warn: z.number().int().nonnegative() }),
|
||||
});
|
||||
export type BookDesignPlanRowOut = z.infer<typeof bookDesignPlanRowSchema>;
|
||||
|
||||
/** The book's look a design chose: its style, colours and two typefaces. */
|
||||
export const bookDesignStyleSchema = z.object({
|
||||
style: z.enum(BOOK_DESIGN_STYLES),
|
||||
paper: hex,
|
||||
ink: hex,
|
||||
accent: hex,
|
||||
heading: z.enum(BOOK_FONTS_IDS),
|
||||
body: z.enum(BOOK_FONTS_IDS),
|
||||
});
|
||||
export type BookDesignStyleOut = z.infer<typeof bookDesignStyleSchema>;
|
||||
|
||||
/** `design_book` and `get_book_design`: the draft in words, nothing of the book written. */
|
||||
export const bookDesignAnswerSchema = z.object({
|
||||
design_id: bookDesignIdSchema,
|
||||
journey_id: idSchema,
|
||||
/** The book's version the draft was made against; null for a journey without a book. */
|
||||
base_version: version.nullable(),
|
||||
/** When the server forgets the draft (ISO); design_book makes it again. */
|
||||
expires_at: z.string(),
|
||||
plan: z.array(bookDesignPlanRowSchema).max(BOOK_DESIGN_LIMITS.planRows),
|
||||
/** Plan rows past those listed. */
|
||||
more_rows: z.number().int().nonnegative(),
|
||||
/** The pages apply_book_design writes unless the person takes some out. */
|
||||
selected: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
|
||||
style: bookDesignStyleSchema,
|
||||
photos: z.object({
|
||||
used: z.number().int().nonnegative(),
|
||||
left_out: z
|
||||
.array(z.object({ photoId: z.number().int().positive(), reason: z.string().max(40) }))
|
||||
.max(BOOK_DESIGN_LIMITS.leftOut),
|
||||
left_out_more: z.number().int().nonnegative(),
|
||||
}),
|
||||
findings: z.array(bookFindingSchema).max(BOOK_DESIGN_LIMITS.findings),
|
||||
notes: z.array(z.string().max(300)).max(BOOK_DESIGN_LIMITS.notes),
|
||||
});
|
||||
export type BookDesignAnswer = z.infer<typeof bookDesignAnswerSchema>;
|
||||
|
||||
/** `revise_book_design`: the page of the draft as it is now, with its findings. */
|
||||
export const bookDesignRevisionAnswerSchema = z.object({
|
||||
design_id: bookDesignIdSchema,
|
||||
page: bookSpreadSummarySchema,
|
||||
action: z.enum(BOOK_DESIGN_ACTIONS),
|
||||
findings: z.array(bookFindingSchema).max(BOOK_DESIGN_LIMITS.findings),
|
||||
});
|
||||
export type BookDesignRevisionAnswer = z.infer<typeof bookDesignRevisionAnswerSchema>;
|
||||
|
||||
/** A page the apply left as it was, and why. */
|
||||
export const bookDesignSkippedSchema = z.object({
|
||||
spreadId: bookSpreadIdSchema,
|
||||
reason: z.enum(BOOK_DESIGN_SKIP_REASONS),
|
||||
});
|
||||
export type BookDesignSkipped = z.infer<typeof bookDesignSkippedSchema>;
|
||||
|
||||
/**
|
||||
* `apply_book_design`: a book write's answer (the first pages it changed),
|
||||
* the revision that takes it back, the pages it changed and the ones it left.
|
||||
*/
|
||||
export const bookDesignApplyResultSchema = bookWriteResultSchema.extend({
|
||||
design_id: bookDesignIdSchema,
|
||||
/** The revision that takes the apply back; null when the journey had no book before it. */
|
||||
revision_id: idSchema.nullable(),
|
||||
applied: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
|
||||
skipped: z.array(bookDesignSkippedSchema).max(BOOK_DESIGN_LIMITS.selection),
|
||||
});
|
||||
export type BookDesignApplyResult = z.infer<typeof bookDesignApplyResultSchema>;
|
||||
|
||||
/** PUT /api/journeys/:journeyId/book-designs/:designId/selection: the pages the apply writes. */
|
||||
export const bookDesignSelectionRequestSchema = z.strictObject({
|
||||
spreadIds: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
|
||||
});
|
||||
export type BookDesignSelectionRequest = z.infer<typeof bookDesignSelectionRequestSchema>;
|
||||
|
||||
export const bookDesignSelectionResponseSchema = z.object({
|
||||
selection: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
|
||||
});
|
||||
export type BookDesignSelectionResponse = z.infer<typeof bookDesignSelectionResponseSchema>;
|
||||
|
||||
/** GET /api/journeys/:journeyId/book-designs/:designId: the draft for the proposal card's pages. */
|
||||
export const bookDesignDraftResponseSchema = z.object({
|
||||
designId: bookDesignIdSchema,
|
||||
baseVersion: version.nullable(),
|
||||
expiresAt: z.string(),
|
||||
document: bookDocumentSchema,
|
||||
plan: z.array(bookDesignPlanRowSchema).max(BOOK_DESIGN_LIMITS.selection),
|
||||
selection: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
|
||||
});
|
||||
export type BookDesignDraftResponse = z.infer<typeof bookDesignDraftResponseSchema>;
|
||||
Reference in New Issue
Block a user