import type { slides_v1 } from "@googleapis/slides" import * as z from "zod" import { defineAction } from "../../automation/actions" import { GOOGLE_IMAGE_URL_SCHEMA } from "../google/lib" import { GOOGLE_PRESENTATION_SCHEMA, GOOGLE_PRESENTATION_UPDATE_SCHEMA, GOOGLE_SLIDES_READ_REQUIREMENT, GOOGLE_SLIDES_WRITE_REQUIREMENT, getGoogleSlidesApi, googleSlidesElementProperties, googleSlidesTextRange, hexToSlidesColor, normalizeGooglePresentation, normalizeGooglePresentationId, normalizeGooglePresentationUpdate, } from "./lib" const PRESENTATION_INPUT = z.object({ presentation: z.string().trim().min(1), }) const WRITE_CONTROL_INPUT = { requiredRevisionId: z.string().min(1).optional(), } const OBJECT_INPUT = PRESENTATION_INPUT.extend({ objectId: z.string().min(1) }) const RANGE_INPUT = { endIndex: z.number().int().nonnegative().optional(), startIndex: z.number().int().nonnegative().optional(), } const TABLE_CELL_INPUT = { columnIndex: z.number().int().nonnegative().optional(), rowIndex: z.number().int().nonnegative().optional(), } const ELEMENT_INPUT = { height: z.number().positive().optional(), objectId: z .string() .regex(/^[\w][\w:-]{4,49}$/) .optional(), pageObjectId: z.string().min(1), width: z.number().positive().optional(), x: z.number().optional(), y: z.number().optional(), } const COLOR_SCHEMA = z.string().regex(/^#[\dA-Fa-f]{6}$/) export const createGooglePresentation = defineAction( "Create Google presentation", ) .describe("Creates a Google Slides presentation.") .account("google", GOOGLE_SLIDES_WRITE_REQUIREMENT) .input(z.object({ title: z.string().trim().min(1) })) .output(GOOGLE_PRESENTATION_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const { data } = await getGoogleSlidesApi( account.secret, ).presentations.create({ requestBody: { title: input.title } }) return normalizeGooglePresentation(data) }) export const getGooglePresentation = defineAction("Get Google presentation") .describe("Gets presentation metadata and slide identifiers.") .account("google", GOOGLE_SLIDES_READ_REQUIREMENT) .input(PRESENTATION_INPUT) .output(GOOGLE_PRESENTATION_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const { data } = await getGoogleSlidesApi(account.secret).presentations.get( { presentationId: normalizeGooglePresentationId(input.presentation), }, ) return normalizeGooglePresentation(data) }) export const createGoogleSlide = presentationUpdateAction( "Create Google slide", PRESENTATION_INPUT.extend({ insertionIndex: z.number().int().nonnegative().optional(), layout: z .enum([ "blank", "captionOnly", "mainPoint", "oneColumnText", "sectionHeader", "sectionTitleAndDescription", "title", "titleAndBody", "titleAndTwoColumns", "titleOnly", ]) .optional(), objectId: z .string() .regex(/^[\w][\w:-]{4,49}$/) .optional(), ...WRITE_CONTROL_INPUT, }), (input) => ({ createSlide: { insertionIndex: input.insertionIndex, objectId: input.objectId, slideLayoutReference: { predefinedLayout: toConstant(input.layout ?? "blank"), }, }, }), ) export const duplicateGoogleSlide = presentationUpdateAction( "Duplicate Google slide", OBJECT_INPUT.extend({ newObjectId: z .string() .regex(/^[\w][\w:-]{4,49}$/) .optional(), ...WRITE_CONTROL_INPUT, }), (input) => ({ duplicateObject: { objectId: input.objectId, objectIds: input.newObjectId ? { [input.objectId]: input.newObjectId } : undefined, }, }), ) export const deleteGoogleSlidesObject = presentationUpdateAction( "Delete Google Slides object", OBJECT_INPUT.extend(WRITE_CONTROL_INPUT), (input) => ({ deleteObject: { objectId: input.objectId } }), "unsafe", ) export const createGoogleSlidesShape = presentationUpdateAction( "Create Google Slides shape", PRESENTATION_INPUT.extend({ ...ELEMENT_INPUT, shapeType: z .enum(["ellipse", "rectangle", "roundRectangle", "textBox", "triangle"]) .optional(), ...WRITE_CONTROL_INPUT, }), (input) => ({ createShape: { elementProperties: googleSlidesElementProperties(input), objectId: input.objectId, shapeType: toConstant(input.shapeType ?? "textBox"), }, }), ) export const createGoogleSlidesImage = presentationUpdateAction( "Create Google Slides image", PRESENTATION_INPUT.extend({ ...ELEMENT_INPUT, imageUrl: GOOGLE_IMAGE_URL_SCHEMA, ...WRITE_CONTROL_INPUT, }), (input) => ({ createImage: { elementProperties: googleSlidesElementProperties(input), objectId: input.objectId, url: input.imageUrl, }, }), ) export const createGoogleSlidesTable = presentationUpdateAction( "Create Google Slides table", PRESENTATION_INPUT.extend({ columns: z.number().int().min(1).max(20), ...ELEMENT_INPUT, rows: z.number().int().min(1).max(20), ...WRITE_CONTROL_INPUT, }), (input) => ({ createTable: { columns: input.columns, elementProperties: googleSlidesElementProperties(input), objectId: input.objectId, rows: input.rows, }, }), ) export const insertGoogleSlidesText = presentationUpdateAction( "Insert Google Slides text", OBJECT_INPUT.extend({ ...TABLE_CELL_INPUT, index: z.number().int().nonnegative().optional(), text: z.string().min(1), ...WRITE_CONTROL_INPUT, }), (input) => ({ insertText: { cellLocation: tableCell(input), insertionIndex: input.index, objectId: input.objectId, text: input.text, }, }), ) export const deleteGoogleSlidesText = presentationUpdateAction( "Delete Google Slides text", OBJECT_INPUT.extend({ ...TABLE_CELL_INPUT, ...RANGE_INPUT, ...WRITE_CONTROL_INPUT, }).refine(hasValidTextRange, { message: "endIndex must be greater than startIndex.", path: ["endIndex"], }), (input) => ({ deleteText: { cellLocation: tableCell(input), objectId: input.objectId, textRange: googleSlidesTextRange(input), }, }), "unsafe", ) export const replaceGoogleSlidesText = presentationUpdateAction( "Replace Google Slides text", PRESENTATION_INPUT.extend({ matchCase: z.boolean().optional(), pageObjectIds: z.string().min(1).array().optional(), replaceText: z.string(), searchText: z.string().min(1), ...WRITE_CONTROL_INPUT, }), (input) => ({ replaceAllText: { containsText: { matchCase: input.matchCase ?? false, text: input.searchText, }, pageObjectIds: input.pageObjectIds, replaceText: input.replaceText, }, }), ) const TEXT_STYLE_INPUT = OBJECT_INPUT.extend({ backgroundColor: COLOR_SCHEMA.optional(), bold: z.boolean().optional(), fontFamily: z.string().min(1).optional(), fontSize: z.number().positive().optional(), foregroundColor: COLOR_SCHEMA.optional(), italic: z.boolean().optional(), linkUrl: z.url().nullable().optional(), strikethrough: z.boolean().optional(), ...TABLE_CELL_INPUT, ...RANGE_INPUT, underline: z.boolean().optional(), ...WRITE_CONTROL_INPUT, }).refine(hasValidTextRange, { message: "endIndex must be greater than startIndex.", path: ["endIndex"], }) export const formatGoogleSlidesText = presentationUpdateAction( "Format Google Slides text", TEXT_STYLE_INPUT.refine(hasTextStyle, "Provide a text style to update."), (input) => { const style = { backgroundColor: input.backgroundColor ? hexToSlidesColor(input.backgroundColor) : undefined, bold: input.bold, fontFamily: input.fontFamily, fontSize: input.fontSize ? { magnitude: input.fontSize, unit: "PT" } : undefined, foregroundColor: input.foregroundColor ? hexToSlidesColor(input.foregroundColor) : undefined, italic: input.italic, link: input.linkUrl === undefined ? undefined : input.linkUrl === null ? {} : { url: input.linkUrl }, strikethrough: input.strikethrough, underline: input.underline, } return { updateTextStyle: { cellLocation: tableCell(input), fields: Object.entries(style) .filter(([, value]) => value !== undefined) .map(([key]) => key) .join(","), objectId: input.objectId, style, textRange: googleSlidesTextRange(input), }, } }, ) export const updateGoogleSlidesObjectTransform = presentationUpdateAction( "Transform Google Slides object", OBJECT_INPUT.extend({ applyMode: z.enum(["absolute", "relative"]).optional(), scaleX: z.number().optional(), scaleY: z.number().optional(), shearX: z.number().optional(), shearY: z.number().optional(), translateX: z.number().optional(), translateY: z.number().optional(), ...WRITE_CONTROL_INPUT, }), (input) => ({ updatePageElementTransform: { applyMode: input.applyMode?.toUpperCase() ?? "ABSOLUTE", objectId: input.objectId, transform: { scaleX: input.scaleX ?? 1, scaleY: input.scaleY ?? 1, shearX: input.shearX ?? 0, shearY: input.shearY ?? 0, translateX: input.translateX ?? 0, translateY: input.translateY ?? 0, unit: "PT", }, }, }), ({ applyMode }) => (applyMode === "relative" ? "unsafe" : "safe"), ) export const replaceGoogleSlidesImage = presentationUpdateAction( "Replace Google Slides image", OBJECT_INPUT.extend({ imageUrl: GOOGLE_IMAGE_URL_SCHEMA, replaceMethod: z.enum(["centerCrop", "centerInside"]).optional(), ...WRITE_CONTROL_INPUT, }), (input) => ({ replaceImage: { imageObjectId: input.objectId, imageReplaceMethod: toConstant(input.replaceMethod ?? "centerCrop"), url: input.imageUrl, }, }), ) /** * Defines one atomic Slides batch-update action. * * @param name - Action name. * @param schema - Public action input schema. * @param request - Provider request builder. * @param replaySafety - Whether replaying the request is safe. */ function presentationUpdateAction>( name: string, schema: z.ZodType, request: (input: TInput) => slides_v1.Schema$Request, replaySafety: | "safe" | "unsafe" | ((input: TInput) => "safe" | "unsafe") = "unsafe", ) { return defineAction(name) .describe("Applies one atomic update to a Google presentation.") .account("google", GOOGLE_SLIDES_WRITE_REQUIREMENT) .input(schema) .output(GOOGLE_PRESENTATION_UPDATE_SCHEMA) .retry({ replaySafety }) .handler(async ({ account, input }) => { const { data } = await getGoogleSlidesApi( account.secret, ).presentations.batchUpdate({ presentationId: normalizeGooglePresentationId( String(input.presentation), ), requestBody: { requests: [request(input)], writeControl: typeof input.requiredRevisionId === "string" ? { requiredRevisionId: input.requiredRevisionId } : undefined, }, }) return normalizeGooglePresentationUpdate(data) }) } /** * Creates an optional provider table-cell selector. * * @param input - Optional row and column indexes. * @param input.columnIndex - Zero-based table column index. * @param input.rowIndex - Zero-based table row index. */ function tableCell(input: { columnIndex?: number; rowIndex?: number }) { return input.rowIndex === undefined && input.columnIndex === undefined ? undefined : { columnIndex: input.columnIndex ?? 0, rowIndex: input.rowIndex ?? 0 } } /** * Returns whether any Slides text style was supplied. * * @param input - Parsed text-style input. */ function hasTextStyle(input: z.infer) { return ( input.backgroundColor !== undefined || input.bold !== undefined || input.fontFamily !== undefined || input.fontSize !== undefined || input.foregroundColor !== undefined || input.italic !== undefined || input.linkUrl !== undefined || input.strikethrough !== undefined || input.underline !== undefined ) } /** * Returns whether a fixed text range advances beyond its start. * * @param input - Optional fixed-range indexes. * @param input.endIndex - Exclusive range end. * @param input.startIndex - Inclusive range start. */ function hasValidTextRange(input: { endIndex?: number; startIndex?: number }) { return ( input.endIndex === undefined || input.endIndex > (input.startIndex ?? 0) ) } /** * Converts a public camel-case enum to the provider constant. * * @param value - Public camel-case value. */ function toConstant(value: string) { return value.replaceAll(/([A-Z])/g, "_$1").toUpperCase() }