import type { docs_v1 } from "@googleapis/docs" import * as z from "zod" import { defineAction } from "../../automation/actions" import { GOOGLE_IMAGE_URL_SCHEMA } from "../google/lib" import { GOOGLE_DOCS_READ_REQUIREMENT, GOOGLE_DOCS_WRITE_REQUIREMENT, GOOGLE_DOCUMENT_SCHEMA, GOOGLE_DOCUMENT_UPDATE_SCHEMA, getGoogleDocsApi, googleDocsRange, googleDocsWriteControl, hexToGoogleColor, normalizeGoogleDocument, normalizeGoogleDocumentId, normalizeGoogleDocumentUpdate, } from "./lib" const DOCUMENT_INPUT = z.object({ document: z.string().trim().min(1) }) const WRITE_CONTROL_INPUT = { requiredRevisionId: z.string().min(1).optional(), targetRevisionId: z.string().min(1).optional(), } const RANGE_INPUT = { endIndex: z.number().int().positive(), startIndex: z.number().int().positive(), tabId: z.string().min(1).optional(), } const LOCATION_INPUT = { index: z.number().int().positive().optional(), tabId: z.string().min(1).optional(), } const COLOR_SCHEMA = z.string().regex(/^#[\dA-Fa-f]{6}$/) const RANGE_ORDER_MESSAGE = "The end index must follow the start index." const WRITE_CONTROL_MESSAGE = "Provide either requiredRevisionId or targetRevisionId, not both." export const createGoogleDocument = defineAction("Create Google document") .describe("Creates a Google Docs document.") .account("google", GOOGLE_DOCS_WRITE_REQUIREMENT) .input(z.object({ title: z.string().trim().min(1) })) .output(GOOGLE_DOCUMENT_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const { data } = await getGoogleDocsApi(account.secret).documents.create({ requestBody: { title: input.title }, }) return normalizeGoogleDocument(data) }) export const getGoogleDocument = defineAction("Get Google document") .describe("Gets document metadata, tabs, and plain text.") .account("google", GOOGLE_DOCS_READ_REQUIREMENT) .input( DOCUMENT_INPUT.extend({ includeTabs: z.boolean().optional(), suggestionsViewMode: z .enum([ "defaultForCurrentAccess", "suggestionsInline", "previewSuggestionsAccepted", "previewWithoutSuggestions", ]) .optional(), }), ) .output(GOOGLE_DOCUMENT_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const { data } = await getGoogleDocsApi(account.secret).documents.get({ documentId: normalizeGoogleDocumentId(input.document), includeTabsContent: input.includeTabs ?? true, suggestionsViewMode: toSuggestionsViewMode(input.suggestionsViewMode), }) return normalizeGoogleDocument(data) }) export const appendGoogleDocumentText = documentUpdateAction( "Append Google document text", DOCUMENT_INPUT.extend({ tabId: z.string().min(1).optional(), text: z.string().min(1), ...WRITE_CONTROL_INPUT, }), (input) => ({ insertText: { endOfSegmentLocation: { tabId: input.tabId }, text: input.text, }, }), ) export const insertGoogleDocumentText = documentUpdateAction( "Insert Google document text", DOCUMENT_INPUT.extend({ index: z.number().int().positive(), tabId: z.string().min(1).optional(), text: z.string().min(1), ...WRITE_CONTROL_INPUT, }), (input) => ({ insertText: { location: { index: input.index, tabId: input.tabId }, text: input.text, }, }), ) export const replaceGoogleDocumentText = documentUpdateAction( "Replace Google document text", z.object({ document: z.string().trim().min(1), matchCase: z.boolean().optional(), replaceText: z.string(), searchText: z.string().min(1), tabIds: z.string().min(1).array().optional(), ...WRITE_CONTROL_INPUT, }), (input) => ({ replaceAllText: { containsText: { matchCase: input.matchCase ?? false, text: input.searchText, }, replaceText: input.replaceText, tabsCriteria: input.tabIds ? { tabIds: input.tabIds } : undefined, }, }), ) export const deleteGoogleDocumentContent = documentUpdateAction( "Delete Google document content", DOCUMENT_INPUT.extend({ ...RANGE_INPUT, ...WRITE_CONTROL_INPUT }).refine( hasAdvancingRange, RANGE_ORDER_MESSAGE, ), (input) => ({ deleteContentRange: { range: googleDocsRange(input) } }), ) const TEXT_STYLE_INPUT = DOCUMENT_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(), underline: z.boolean().optional(), ...RANGE_INPUT, ...WRITE_CONTROL_INPUT, }) export const formatGoogleDocumentText = documentUpdateAction( "Format Google document text", TEXT_STYLE_INPUT.refine( hasTextStyle, "Provide a text style to update.", ).refine(hasAdvancingRange, RANGE_ORDER_MESSAGE), (input) => { const textStyle = { backgroundColor: input.backgroundColor ? hexToGoogleColor(input.backgroundColor) : undefined, bold: input.bold, fontSize: input.fontSize ? { magnitude: input.fontSize, unit: "PT" } : undefined, foregroundColor: input.foregroundColor ? hexToGoogleColor(input.foregroundColor) : undefined, italic: input.italic, link: input.linkUrl === undefined ? undefined : input.linkUrl === null ? {} : { url: input.linkUrl }, strikethrough: input.strikethrough, underline: input.underline, weightedFontFamily: input.fontFamily ? { fontFamily: input.fontFamily } : undefined, } return { updateTextStyle: { fields: Object.entries(textStyle) .filter(([, value]) => value !== undefined) .map(([key]) => key) .join(","), range: googleDocsRange(input), textStyle, }, } }, ) const PARAGRAPH_STYLE_INPUT = DOCUMENT_INPUT.extend({ alignment: z.enum(["start", "center", "end", "justified"]).optional(), direction: z.enum(["leftToRight", "rightToLeft"]).optional(), keepLinesTogether: z.boolean().optional(), keepWithNext: z.boolean().optional(), lineSpacing: z.number().positive().optional(), namedStyle: z .enum([ "normalText", "title", "subtitle", "heading1", "heading2", "heading3", "heading4", "heading5", "heading6", ]) .optional(), ...RANGE_INPUT, ...WRITE_CONTROL_INPUT, }) export const formatGoogleDocumentParagraph = documentUpdateAction( "Format Google document paragraph", PARAGRAPH_STYLE_INPUT.refine( hasParagraphStyle, "Provide a paragraph style to update.", ).refine(hasAdvancingRange, RANGE_ORDER_MESSAGE), (input) => { const paragraphStyle = { alignment: input.alignment ? input.alignment.toUpperCase() : undefined, direction: input.direction === "rightToLeft" ? "RIGHT_TO_LEFT" : input.direction ? "LEFT_TO_RIGHT" : undefined, keepLinesTogether: input.keepLinesTogether, keepWithNext: input.keepWithNext, lineSpacing: input.lineSpacing, namedStyleType: input.namedStyle ? toNamedStyle(input.namedStyle) : undefined, } return { updateParagraphStyle: { fields: Object.entries(paragraphStyle) .filter(([, value]) => value !== undefined) .map(([key]) => key) .join(","), paragraphStyle, range: googleDocsRange(input), }, } }, ) export const insertGoogleDocumentInlineImage = documentUpdateAction( "Insert Google document image", DOCUMENT_INPUT.extend({ height: z.number().positive().optional(), imageUrl: GOOGLE_IMAGE_URL_SCHEMA, ...LOCATION_INPUT, width: z.number().positive().optional(), ...WRITE_CONTROL_INPUT, }), (input) => ({ insertInlineImage: { endOfSegmentLocation: input.index ? undefined : { tabId: input.tabId }, location: input.index ? { index: input.index, tabId: input.tabId } : undefined, objectSize: input.width || input.height ? { height: input.height ? { magnitude: input.height, unit: "PT" } : undefined, width: input.width ? { magnitude: input.width, unit: "PT" } : undefined, } : undefined, uri: input.imageUrl, }, }), ) export const insertGoogleDocumentTable = documentUpdateAction( "Insert Google document table", DOCUMENT_INPUT.extend({ columns: z.number().int().min(1).max(20), ...LOCATION_INPUT, rows: z.number().int().min(1).max(20), ...WRITE_CONTROL_INPUT, }), (input) => ({ insertTable: { columns: input.columns, endOfSegmentLocation: input.index ? undefined : { tabId: input.tabId }, location: input.index ? { index: input.index, tabId: input.tabId } : undefined, rows: input.rows, }, }), ) export const insertGoogleDocumentPageBreak = documentUpdateAction( "Insert Google document page break", DOCUMENT_INPUT.extend({ ...LOCATION_INPUT, ...WRITE_CONTROL_INPUT }), (input) => ({ insertPageBreak: { endOfSegmentLocation: input.index ? undefined : { tabId: input.tabId }, location: input.index ? { index: input.index, tabId: input.tabId } : undefined, }, }), ) /** * Defines one atomic Docs batch-update action. * * @param name - Action name. * @param schema - Public action input schema. * @param request - Provider request builder. */ function documentUpdateAction>( name: string, schema: z.ZodType, request: (input: TInput) => docs_v1.Schema$Request, ) { return defineAction(name) .describe("Applies one atomic update to a Google document.") .account("google", GOOGLE_DOCS_WRITE_REQUIREMENT) .input(schema.refine(hasSingleWriteControl, WRITE_CONTROL_MESSAGE)) .output(GOOGLE_DOCUMENT_UPDATE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const { data } = await getGoogleDocsApi( account.secret, ).documents.batchUpdate({ documentId: normalizeGoogleDocumentId(String(input.document)), requestBody: { requests: [request(input)], writeControl: googleDocsWriteControl(input), }, }) return normalizeGoogleDocumentUpdate(data) }) } /** * Returns whether any Docs 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 Docs range advances beyond its starting index. * * @param input - Range boundaries. * @param input.endIndex - Exclusive range end. * @param input.startIndex - Inclusive range start. */ function hasAdvancingRange(input: { endIndex: number; startIndex: number }) { return input.endIndex > input.startIndex } /** * Returns whether an update selects at most one Docs revision control. * * @param input - Update input with optional revision controls. */ function hasSingleWriteControl(input: Record) { return ( input.requiredRevisionId === undefined || input.targetRevisionId === undefined ) } /** * Returns whether any Docs paragraph style was supplied. * * @param input - Parsed paragraph-style input. */ function hasParagraphStyle(input: z.infer) { return ( input.alignment !== undefined || input.direction !== undefined || input.keepLinesTogether !== undefined || input.keepWithNext !== undefined || input.lineSpacing !== undefined || input.namedStyle !== undefined ) } /** * Converts the public suggestions mode to the provider enum value. * * @param value - Public camel-case mode. */ function toSuggestionsViewMode(value?: string) { return value ? value.replaceAll(/([A-Z])/g, "_$1").toUpperCase() : undefined } /** * Converts a public named style to the provider enum value. * * @param value - Public camel-case named style. */ function toNamedStyle(value: string) { return value.replaceAll(/([A-Z\d])/g, "_$1").toUpperCase() }