import { AppConnectionValueForAuthProperty, OAuth2PropertyValue, OAuth2Props, PieceAuth, PiecePropValueSchema, Property, ShortTextProperty, StaticPropsValue } from '@activepieces/pieces-framework'; import { httpClient, HttpMethod, AuthenticationType, HttpRequest, } from '@activepieces/pieces-common'; import { isNil } from '@activepieces/pieces-framework'; import { AppConnectionType } from '@activepieces/pieces-framework'; import { isString } from '@activepieces/pieces-framework'; import { JWT, OAuth2Client } from 'google-auth-library'; import { mapRowsToColumnLabels } from '../triggers/helpers'; export type GoogleSheetsAuthValue = AppConnectionValueForAuthProperty export const googleSheetsCommon = { baseUrl: 'https://sheets.googleapis.com/v4/spreadsheets', getGoogleSheetRows, findSheetName, deleteRow, clearSheet, getHeaderRow, }; export async function findSheetName( auth: GoogleSheetsAuthValue, spreadsheetId: string, sheetId: string | number, ) { const sheets = await listSheetsName(auth, spreadsheetId); const sheetName = sheets.find((f) => f.properties.sheetId == sheetId)?.properties.title; if (!sheetName) { throw Error(`Sheet with ID ${sheetId} not found in spreadsheet ${spreadsheetId}`); } return sheetName; } async function listSheetsName(auth: GoogleSheetsAuthValue, spreadsheet_id: string) { return ( await httpClient.sendRequest<{ sheets: { properties: { title: string; sheetId: number } }[]; }>({ method: HttpMethod.GET, url: `https://sheets.googleapis.com/v4/spreadsheets/` + spreadsheet_id, authentication: { type: AuthenticationType.BEARER_TOKEN, token: await getAccessToken(auth), }, }) ).body.sheets; } type GetGoogleSheetRowsProps = { spreadsheetId: string; auth: GoogleSheetsAuthValue; sheetId: number; rowIndex_s: number | undefined; rowIndex_e: number | undefined; headerRow?: number; }; async function getGoogleSheetRows({ spreadsheetId, auth, sheetId, rowIndex_s, rowIndex_e, headerRow = 1, }: GetGoogleSheetRowsProps): Promise<{ row: number; values: { [x: string]: string } }[]> { const sheetName = await findSheetName(auth, spreadsheetId, sheetId); if (!sheetName) { return []; } let range = ''; if (rowIndex_s !== undefined) { range = `!A${rowIndex_s}:ZZZ`; } if (rowIndex_s !== undefined && rowIndex_e !== undefined) { range = `!A${rowIndex_s}:ZZZ${rowIndex_e}`; } const rowsResponse = await httpClient.sendRequest<{ values: [string[]][] }>({ method: HttpMethod.GET, url: `${googleSheetsCommon.baseUrl}/${spreadsheetId}/values/${encodeURIComponent(`${sheetName}${range}`)}`, authentication: { type: AuthenticationType.BEARER_TOKEN, token: await getAccessToken(auth), }, }); if (rowsResponse.body.values === undefined) return []; const headerResponse = await httpClient.sendRequest<{ values: [string[]][] }>({ method: HttpMethod.GET, url: `${googleSheetsCommon.baseUrl}/${spreadsheetId}/values/${encodeURIComponent(`${sheetName}!A${headerRow}:ZZZ${headerRow}`)}`, authentication: { type: AuthenticationType.BEARER_TOKEN, token: await getAccessToken(auth), }, }); if (!headerResponse.body.values) { throw new Error(`Unable to read headers from row ${headerRow} in sheet "${sheetName}". The row appears to be empty or inaccessible.`); } const headers = headerResponse.body.values[0] ?? []; const headerCount = headers.length; const startingRow = rowIndex_s ? rowIndex_s - 1 : 0; const labeledRowValues = mapRowsToColumnLabels( rowsResponse.body.values, startingRow, headerCount, ); return labeledRowValues; } type GetHeaderRowProps = { spreadsheetId: string; auth: GoogleSheetsAuthValue; sheetId: number; }; export async function getHeaderRow({ spreadsheetId, auth, sheetId, }: GetHeaderRowProps): Promise { const rows = await getGoogleSheetRows({ spreadsheetId, auth, sheetId, rowIndex_s: 1, rowIndex_e: 1, headerRow: 1, }); if (rows.length === 0) { return undefined; } return objectToArray(rows[0].values); } export const columnToLabel = (columnIndex: number) => { const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; let label = ''; while (columnIndex >= 0) { label = alphabet[columnIndex % 26] + label; columnIndex = Math.floor(columnIndex / 26) - 1; } return label; }; export const labelToColumn = (label: string) => { const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; let column = 0; for (let i = 0; i < label.length; i++) { column += (alphabet.indexOf(label[i]) + 1) * Math.pow(26, label.length - i - 1); } return column - 1; }; export function objectToArray(obj: { [x: string]: any }) { const maxIndex = Math.max(...Object.keys(obj).map((key) => labelToColumn(key))); const arr = new Array(maxIndex + 1); for (const key in obj) { arr[labelToColumn(key)] = obj[key]; } return arr; } export function stringifyArray(object: unknown[]): string[] { return object.map((val) => { if (isString(val)) { return val; } return JSON.stringify(val); }); } export async function mapRowsToHeaderNames( rows:any[], useHeaderNames: boolean, spreadsheetId: string, sheetId: number, headerRow: number, auth: GoogleSheetsAuthValue, ): Promise { if (!useHeaderNames) { return rows; } const headerRows = await getGoogleSheetRows({ spreadsheetId, auth, sheetId, rowIndex_s: headerRow, rowIndex_e: headerRow, }); if (headerRows.length === 0) { return rows; } const headers = Object.values(headerRows[0].values); if (headers.length === 0) { return rows; } // map rows to use header names as keys instead of column letters return rows.map(row => { const newValues: Record = {}; Object.keys(row.values).forEach((columnLetter) => { const columnIndex = labelToColumn(columnLetter); const headerName = headers[columnIndex]; if (headerName) { newValues[headerName] = row.values[columnLetter]; } else{ newValues[columnLetter] = row.values[columnLetter]; } }); return { ...row, values: newValues }; }); } async function deleteRow( spreadsheetId: string, sheetId: number, rowIndex: number, auth: GoogleSheetsAuthValue, ) { const request: HttpRequest = { method: HttpMethod.POST, url: `${googleSheetsCommon.baseUrl}/${spreadsheetId}/:batchUpdate`, authentication: { type: AuthenticationType.BEARER_TOKEN, token: await getAccessToken(auth), }, body: { requests: [ { deleteDimension: { range: { sheetId: sheetId, dimension: 'ROWS', startIndex: rowIndex, endIndex: rowIndex + 1, }, }, }, ], }, }; await httpClient.sendRequest(request); } async function clearSheet( spreadsheetId: string, sheetId: number, auth: GoogleSheetsAuthValue, rowIndex: number, numOfRows: number, ) { const request: HttpRequest = { method: HttpMethod.POST, url: `${googleSheetsCommon.baseUrl}/${spreadsheetId}/:batchUpdate`, authentication: { type: AuthenticationType.BEARER_TOKEN, token: await getAccessToken(auth), }, body: { requests: [ { deleteDimension: { range: { sheetId: sheetId, dimension: 'ROWS', startIndex: rowIndex, endIndex: rowIndex + numOfRows + 1, }, }, }, ], }, }; return await httpClient.sendRequest(request); } export enum ValueInputOption { RAW = 'RAW', USER_ENTERED = 'USER_ENTERED', } export enum Dimension { ROWS = 'ROWS', COLUMNS = 'COLUMNS', } export async function createGoogleClient(auth: GoogleSheetsAuthValue): Promise { if(auth.type === AppConnectionType.CUSTOM_AUTH) { const serviceAccount = JSON.parse(auth.props.serviceAccount); return new JWT({ email: serviceAccount.client_email, key: serviceAccount.private_key, scopes: googleSheetsScopes, subject: auth.props.userEmail, }); } const authClient = new OAuth2Client(); authClient.setCredentials(auth); return authClient; } export const getAccessToken = async (auth: GoogleSheetsAuthValue): Promise => { if(auth.type === AppConnectionType.CUSTOM_AUTH) { const googleClient = await createGoogleClient(auth); const response = await googleClient.getAccessToken(); if(response.token) { return response.token; } else { throw new Error('Could not retrieve access token from service account json'); } } return auth.access_token; } export function areSheetIdsValid(spreadsheetId: string | null | undefined, sheetId: string | number | null | undefined): boolean { return !isNil(spreadsheetId) && spreadsheetId !== "" && !isNil(sheetId) && sheetId !== ""; } export const googleSheetsScopes = [ 'https://www.googleapis.com/auth/spreadsheets', 'https://www.googleapis.com/auth/drive.readonly', 'https://www.googleapis.com/auth/drive', 'email', ] export const googleSheetsAuth =[PieceAuth.OAuth2({ description: '', authUrl: 'https://accounts.google.com/o/oauth2/auth', tokenUrl: 'https://oauth2.googleapis.com/token', required: true, scope:googleSheetsScopes , }), PieceAuth.CustomAuth({ displayName: 'Service Account (Advanced)', description: 'Authenticate via service account from https://console.cloud.google.com/ > IAM & Admin > Service Accounts > Create Service Account > Keys > Add key.

You can optionally use domain-wide delegation (https://support.google.com/a/answer/162106?hl=en#zippy=%2Cset-up-domain-wide-delegation-for-a-client) to access spreadsheets without adding the service account to each one.

**Note:** Without a user email, the service account only has access to files/folders you explicitly share with it.', required: true, props: { serviceAccount: Property.ShortText({ displayName: 'Service Account JSON Key', required: true, } ) , userEmail: Property.ShortText({ displayName: 'User Email', required: false, description: 'Email address of the user to impersonate for domain-wide delegation.', }),}, validate: async ({auth})=>{ try{ await getAccessToken({ type: AppConnectionType.CUSTOM_AUTH, props: {...auth} }); }catch(e){ return { valid: false, error: (e as Error).message, }; } return { valid: true, }; } })];