import { auth, slides, type slides_v1 } from "@googleapis/slides" import { integrationScope as scope } from "../../automation/integrations" import * as z from "zod" const GOOGLE_SECRET_SCHEMA = z.object({ accessToken: z.string().min(1) }) const GOOGLE_SLIDES_HOSTS = new Set(["docs.google.com", "slides.google.com"]) export const GOOGLE_SLIDES_READ_REQUIREMENT = scope.any( "https://www.googleapis.com/auth/drive", "https://www.googleapis.com/auth/presentations", "https://www.googleapis.com/auth/drive.readonly", "https://www.googleapis.com/auth/drive.file", "https://www.googleapis.com/auth/presentations.readonly", ) export const GOOGLE_SLIDES_WRITE_REQUIREMENT = scope.any( "https://www.googleapis.com/auth/drive", "https://www.googleapis.com/auth/drive.file", "https://www.googleapis.com/auth/presentations", ) export const GOOGLE_PRESENTATION_SCHEMA = z.object({ locale: z.string().optional(), pageSize: z .object({ height: z .object({ magnitude: z.number(), unit: z.string().min(1) }) .optional(), width: z .object({ magnitude: z.number(), unit: z.string().min(1) }) .optional(), }) .optional(), presentationId: z.string(), revisionId: z.string().optional(), slides: z .object({ index: z.number().int().nonnegative(), objectId: z.string() }) .array(), title: z.string(), url: z.url(), }) export const GOOGLE_PRESENTATION_UPDATE_SCHEMA = z.object({ objectIds: z.string().array(), occurrencesChanged: z.number().int().nonnegative(), presentationId: z.string(), revisionId: z.string().optional(), }) /** * Creates the official Slides client from a refreshed Google secret. * * @param secret - Refreshed Google account secret. */ export function getGoogleSlidesApi(secret: Record) { const { accessToken } = GOOGLE_SECRET_SCHEMA.parse(secret) return slides({ auth: new auth.OAuth2({ credentials: { access_token: accessToken } }), version: "v1", }) } /** * Accepts a raw presentation ID or standard Google Slides URL. * * @param presentation - Raw presentation ID or URL. * @throws When a URL is not a recognizable Google Slides presentation URL. */ export function normalizeGooglePresentationId(presentation: string) { const value = presentation.trim() if (!URL.canParse(value)) return value const url = new URL(value) if (!GOOGLE_SLIDES_HOSTS.has(url.hostname)) { throw new Error(`Expected a Google Slides URL, received "${value}".`) } const parts = url.pathname.split("/") const presentationIndex = parts.indexOf("presentation") const id = presentationIndex === -1 || parts[presentationIndex + 1] !== "d" ? undefined : parts[presentationIndex + 2] if (!id) throw new Error(`Could not find a presentation ID in "${value}".`) return id } /** * Converts a provider presentation into the stable public shape. * * @param presentation - Provider presentation response. * @throws When required presentation metadata is absent. */ export function normalizeGooglePresentation( presentation: slides_v1.Schema$Presentation, ) { if (!presentation.presentationId || !presentation.title) { throw new Error("Google Slides returned incomplete presentation metadata.") } const height = normalizeGoogleSlidesDimension(presentation.pageSize?.height) const width = normalizeGoogleSlidesDimension(presentation.pageSize?.width) return { locale: presentation.locale ?? undefined, pageSize: height || width ? { height, width } : undefined, presentationId: presentation.presentationId, revisionId: presentation.revisionId ?? undefined, slides: (presentation.slides ?? []).flatMap((slide, index) => slide.objectId ? [{ index, objectId: slide.objectId }] : [], ), title: presentation.title, url: `https://docs.google.com/presentation/d/${presentation.presentationId}/edit`, } } /** * Preserves one provider dimension and its measurement unit. * * @param dimension - Provider page dimension. */ function normalizeGoogleSlidesDimension( dimension: slides_v1.Schema$Dimension | null | undefined, ) { return typeof dimension?.magnitude === "number" && dimension.unit ? { magnitude: dimension.magnitude, unit: dimension.unit } : undefined } /** * Converts a provider update response into the stable public shape. * * @param response - Provider batch-update response. * @throws When the response omits its presentation ID. */ export function normalizeGooglePresentationUpdate( response: slides_v1.Schema$BatchUpdatePresentationResponse, ) { if (!response.presentationId) { throw new Error( "Google Slides returned an update without a presentation ID.", ) } return { objectIds: (response.replies ?? []).flatMap((reply) => { const id = reply.createImage?.objectId ?? reply.createLine?.objectId ?? reply.createShape?.objectId ?? reply.createSheetsChart?.objectId ?? reply.createSlide?.objectId ?? reply.createTable?.objectId ?? reply.createVideo?.objectId ?? reply.duplicateObject?.objectId ?? reply.groupObjects?.objectId return id ? [id] : [] }), occurrencesChanged: (response.replies ?? []).reduce( (total, reply) => total + (reply.replaceAllText?.occurrencesChanged ?? 0) + (reply.replaceAllShapesWithImage?.occurrencesChanged ?? 0) + (reply.replaceAllShapesWithSheetsChart?.occurrencesChanged ?? 0), 0, ), presentationId: response.presentationId, revisionId: response.writeControl?.requiredRevisionId ?? undefined, } } /** * Creates provider element placement and sizing properties. * * @param input - Page, size, and position coordinates. * @param input.height - Optional element height in points. * @param input.pageObjectId - Parent slide object ID. * @param input.width - Optional element width in points. * @param input.x - Optional horizontal offset in points. * @param input.y - Optional vertical offset in points. */ export function googleSlidesElementProperties(input: { height?: number pageObjectId: string width?: number x?: number y?: number }) { return { pageObjectId: input.pageObjectId, size: input.width || input.height ? { height: input.height ? { magnitude: input.height, unit: "PT" } : undefined, width: input.width ? { magnitude: input.width, unit: "PT" } : undefined, } : undefined, transform: input.x !== undefined || input.y !== undefined ? { scaleX: 1, scaleY: 1, translateX: input.x ?? 0, translateY: input.y ?? 0, unit: "PT", } : undefined, } } /** * Creates a provider presentation text range. * * @param input - Optional start and end coordinates. * @param input.endIndex - Exclusive range end. * @param input.startIndex - Inclusive range start. */ export function googleSlidesTextRange(input: { endIndex?: number startIndex?: number }) { if (input.startIndex === undefined && input.endIndex === undefined) { return { type: "ALL" } } if (input.endIndex === undefined) { return { startIndex: input.startIndex, type: "FROM_START_INDEX" } } return { endIndex: input.endIndex, startIndex: input.startIndex ?? 0, type: "FIXED_RANGE", } } /** * Converts a validated hex color to the Slides RGB shape. * * @param value - Six-digit hex color. */ export function hexToSlidesColor(value: string) { const hex = value.slice(1) return { opaqueColor: { rgbColor: { blue: Number.parseInt(hex.slice(4, 6), 16) / 255, green: Number.parseInt(hex.slice(2, 4), 16) / 255, red: Number.parseInt(hex.slice(0, 2), 16) / 255, }, }, } }