import { auth, docs, type docs_v1 } from "@googleapis/docs" 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_DOCS_HOSTS = new Set([ "docs.google.com", "docs.googleusercontent.com", ]) export const GOOGLE_DOCS_READ_REQUIREMENT = scope.any( "https://www.googleapis.com/auth/drive", "https://www.googleapis.com/auth/documents", "https://www.googleapis.com/auth/drive.readonly", "https://www.googleapis.com/auth/drive.file", "https://www.googleapis.com/auth/documents.readonly", ) export const GOOGLE_DOCS_WRITE_REQUIREMENT = scope.any( "https://www.googleapis.com/auth/drive", "https://www.googleapis.com/auth/drive.file", "https://www.googleapis.com/auth/documents", ) export const GOOGLE_DOCUMENT_TAB_SCHEMA = z.object({ get childTabs(): z.ZodArray { return GOOGLE_DOCUMENT_TAB_SCHEMA.array() }, index: z.number().int().nonnegative(), tabId: z.string(), text: z.string(), title: z.string(), }) export const GOOGLE_DOCUMENT_SCHEMA = z.object({ documentId: z.string(), revisionId: z.string().optional(), tabs: GOOGLE_DOCUMENT_TAB_SCHEMA.array(), title: z.string(), url: z.url(), }) export const GOOGLE_DOCUMENT_UPDATE_SCHEMA = z.object({ documentId: z.string(), revisionId: z.string().optional(), }) /** * Creates the official Docs client from a refreshed Google secret. * * @param secret - Refreshed Google account secret. */ export function getGoogleDocsApi(secret: Record) { const { accessToken } = GOOGLE_SECRET_SCHEMA.parse(secret) return docs({ auth: new auth.OAuth2({ credentials: { access_token: accessToken } }), version: "v1", }) } /** * Accepts a raw document ID or standard Google Docs URL. * * @param document - Raw document ID or URL. * @throws When a URL is not a recognizable Google Docs document URL. */ export function normalizeGoogleDocumentId(document: string) { const value = document.trim() if (!URL.canParse(value)) return value const url = new URL(value) if (!GOOGLE_DOCS_HOSTS.has(url.hostname)) { throw new Error(`Expected a Google Docs URL, received "${value}".`) } const parts = url.pathname.split("/") const documentIndex = parts.indexOf("document") const id = documentIndex === -1 || parts[documentIndex + 1] !== "d" ? undefined : parts[documentIndex + 2] if (!id) throw new Error(`Could not find a document ID in "${value}".`) return id } /** * Converts a provider document into the stable public shape. * * @param document - Provider document response. * @throws When required document metadata is absent. */ export function normalizeGoogleDocument(document: docs_v1.Schema$Document) { if (!document.documentId || !document.title) { throw new Error("Google Docs returned incomplete document metadata.") } // Preserve one readable fallback for legacy responses without tab metadata. const providerTabs = document.tabs const tabs = providerTabs?.map(normalizeGoogleDocumentTab) ?? [ { childTabs: [], index: 0, tabId: "", text: readStructuralText(document.body?.content), title: document.title, }, ] const firstProviderTab = providerTabs?.[0] if ( tabs[0] && firstProviderTab && firstProviderTab.documentTab?.body?.content == null && document.body?.content ) { tabs[0] = { ...tabs[0], text: readStructuralText(document.body.content), } } return { documentId: document.documentId, revisionId: document.revisionId ?? undefined, tabs, title: document.title, url: `https://docs.google.com/document/d/${document.documentId}/edit`, } } /** * Converts a provider update response into the stable public shape. * * @param response - Provider batch-update response. * @throws When the response omits its document ID. */ export function normalizeGoogleDocumentUpdate( response: docs_v1.Schema$BatchUpdateDocumentResponse, ) { if (!response.documentId) { throw new Error("Google Docs returned an update without a document ID.") } return { documentId: response.documentId, revisionId: response.writeControl?.requiredRevisionId ?? response.writeControl?.targetRevisionId ?? undefined, } } /** * Selects the requested Docs revision guard. * * @param input - Optional required or target revision. * @param input.requiredRevisionId - Revision that must match exactly. * @param input.targetRevisionId - Revision the write should target. */ export function googleDocsWriteControl(input: { requiredRevisionId?: string targetRevisionId?: string }) { return input.requiredRevisionId ? { requiredRevisionId: input.requiredRevisionId } : input.targetRevisionId ? { targetRevisionId: input.targetRevisionId } : undefined } /** * Creates a provider text range. * * @param input - Start, end, and optional tab coordinates. * @param input.endIndex - Exclusive range end. * @param input.startIndex - Inclusive range start. * @param input.tabId - Optional document tab ID. */ export function googleDocsRange(input: { endIndex: number startIndex: number tabId?: string }) { return { endIndex: input.endIndex, startIndex: input.startIndex, tabId: input.tabId, } } /** * Converts a validated hex color to the Docs RGB shape. * * @param value - Six-digit hex color. */ export function hexToGoogleColor(value: string) { const hex = value.slice(1) return { color: { 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, }, }, } } /** * Converts one provider tab and its descendants. * * @param tab - Provider tab response. * @throws When required tab metadata is absent. */ function normalizeGoogleDocumentTab( tab: docs_v1.Schema$Tab, ): z.output { const properties = tab.tabProperties if (!properties?.tabId || properties.index == null || !properties.title) { throw new Error("Google Docs returned incomplete tab metadata.") } return { childTabs: (tab.childTabs ?? []).map(normalizeGoogleDocumentTab), index: properties.index, tabId: properties.tabId, text: readStructuralText(tab.documentTab?.body?.content), title: properties.title, } } /** * Extracts text recursively from Docs structural elements. * * @param content - Provider structural content. */ function readStructuralText( content?: docs_v1.Schema$StructuralElement[] | null, ): string { return (content ?? []) .flatMap((element) => [ ...(element.paragraph?.elements ?? []).map( ({ textRun }) => textRun?.content ?? "", ), ...readTableText(element.table), readStructuralText(element.tableOfContents?.content), ]) .join("") } /** * Extracts text from every cell in a Docs table. * * @param table - Provider table value. */ function readTableText(table?: docs_v1.Schema$Table | null) { return (table?.tableRows ?? []).flatMap(({ tableCells }) => (tableCells ?? []).map(({ content }) => readStructuralText(content)), ) }