Add Activepieces integration for workflow automation

- Add Activepieces fork with SmoothSchedule custom piece
- Create integrations app with Activepieces service layer
- Add embed token endpoint for iframe integration
- Create Automations page with embedded workflow builder
- Add sidebar visibility fix for embed mode
- Add list inactive customers endpoint to Public API
- Include SmoothSchedule triggers: event created/updated/cancelled
- Include SmoothSchedule actions: create/update/cancel events, list resources/services/customers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-18 22:59:37 -05:00
parent 9848268d34
commit 3aa7199503
16292 changed files with 1284892 additions and 4708 deletions

View File

@@ -0,0 +1,64 @@
import { createAction, Property } from '@activepieces/pieces-framework';
import { areSheetIdsValid, googleSheetsCommon } from '../common/common';
import { googleSheetsAuth } from '../common/common';
import { commonProps } from '../common/props';
export const clearSheetAction = createAction({
auth: googleSheetsAuth,
name: 'clear_sheet',
description: 'Clears all rows on an existing sheet',
displayName: 'Clear Sheet',
props: {
...commonProps,
is_first_row_headers: Property.Checkbox({
displayName: 'Is First row Headers?',
description: 'If the first row is headers',
required: true,
defaultValue: true,
}),
headerRow: Property.Number({
displayName: 'Header Row',
description: 'Which row contains the headers?',
required: true,
defaultValue: 1,
}),
},
async run({ propsValue, auth }) {
const { spreadsheetId, sheetId, is_first_row_headers:isFirstRowHeaders, headerRow } = propsValue;
if (!areSheetIdsValid(spreadsheetId, sheetId)) {
throw new Error('Please select a spreadsheet and sheet first.');
}
await googleSheetsCommon.findSheetName(
auth,
spreadsheetId as string,
sheetId as number
);
const rowsToDelete: number[] = [];
const values = await googleSheetsCommon.getGoogleSheetRows({
spreadsheetId: spreadsheetId as string,
auth: auth,
sheetId: sheetId as number,
rowIndex_s: 1,
rowIndex_e: undefined,
headerRow: headerRow,
});
for (const key in values) {
if (key === '0' && isFirstRowHeaders) {
continue;
}
rowsToDelete.push(parseInt(key) + 1);
}
const response = await googleSheetsCommon.clearSheet(
spreadsheetId as string,
sheetId as number,
auth,
isFirstRowHeaders ? 1 : 0,
rowsToDelete.length
);
return response.body;
},
});

View File

@@ -0,0 +1,32 @@
import { googleSheetsAuth } from '../common/common';
import { createAction } from '@activepieces/pieces-framework';
import { includeTeamDrivesProp, sheetIdProp, spreadsheetIdProp } from '../common/props';
import { google } from 'googleapis';
import { createGoogleClient } from '../common/common';
export const copyWorksheetAction = createAction({
auth: googleSheetsAuth,
name: 'copy-worksheet',
displayName: 'Copy Worksheet',
description: 'Creates a new worksheet by copying an existing one.',
props: {
includeTeamDrives: includeTeamDrivesProp(),
spreadsheetId: spreadsheetIdProp('Spreadsheet Containing the Worksheet to Copy', ''),
sheetId: sheetIdProp('Worksheet to Copy', ''),
desinationSpeadsheetId: spreadsheetIdProp('Spreadsheet to paste in', ''),
},
async run(context) {
const authClient = await createGoogleClient(context.auth);
const sheets = google.sheets({ version: 'v4', auth: authClient });
const response = await sheets.spreadsheets.sheets.copyTo({
spreadsheetId: context.propsValue.spreadsheetId,
sheetId: context.propsValue.sheetId,
requestBody: {
destinationSpreadsheetId: context.propsValue.desinationSpeadsheetId,
},
});
return response.data;
},
});

View File

@@ -0,0 +1,106 @@
import { googleSheetsAuth } from '../common/common';
import { createAction, Property } from '@activepieces/pieces-framework';
import {
areSheetIdsValid,
columnToLabel,
createGoogleClient,
getHeaderRow,
ValueInputOption,
} from '../common/common';
import { google } from 'googleapis';
import { OAuth2Client } from 'googleapis-common';
import { getWorkSheetName } from '../triggers/helpers';
import { commonProps } from '../common/props';
export const createColumnAction = createAction({
auth: googleSheetsAuth,
name: 'create-column',
displayName: 'Create Spreadsheet Column',
description: 'Adds a new column to a spreadsheet.',
props: {
...commonProps,
columnName: Property.ShortText({
displayName: 'Column Name',
required: true,
}),
columnIndex: Property.Number({
displayName: 'Column Index',
description:
'The column index starts from 1.For example, if you want to add a column to the third column, enter 3.Ff the input is less than 1 the column will be added after the last current column.',
required: false,
}),
},
async run(context) {
const { spreadsheetId, sheetId, columnName, columnIndex } = context.propsValue;
if (!areSheetIdsValid(spreadsheetId, sheetId)) {
throw new Error('Please select a spreadsheet and sheet first.');
}
const authClient = await createGoogleClient(context.auth);
const sheets = google.sheets({ version: 'v4', auth: authClient });
let columnLabel;
if (columnIndex && columnIndex > 0) {
await sheets.spreadsheets.batchUpdate({
spreadsheetId,
requestBody: {
requests: [
{
insertDimension: {
range: {
sheetId,
dimension: 'COLUMNS',
startIndex: columnIndex -1,
endIndex: columnIndex,
},
},
},
],
},
});
columnLabel = columnToLabel(columnIndex-1);
} else {
const headers = await getHeaderRow({
spreadsheetId:spreadsheetId as string,
sheetId :sheetId as number,
auth: context.auth,
});
const newColumnIndex = headers === undefined ? 0 : headers.length;
await sheets.spreadsheets.batchUpdate({
spreadsheetId,
requestBody: {
requests: [
{
insertDimension: {
range: {
sheetId,
dimension: 'COLUMNS',
startIndex: newColumnIndex,
endIndex: newColumnIndex + 1,
},
},
},
],
},
});
columnLabel = columnToLabel(newColumnIndex);
}
const sheetName = await getWorkSheetName(context.auth, spreadsheetId as string , sheetId as number);
const response = await sheets.spreadsheets.values.update({
range: `${sheetName}!${columnLabel}1`,
spreadsheetId,
valueInputOption: ValueInputOption.USER_ENTERED,
requestBody: {
values: [[columnName]],
},
});
return response.data;
},
});

View File

@@ -0,0 +1,122 @@
import {
AppConnectionValueForAuthProperty,
createAction,
PiecePropValueSchema,
Property,
} from '@activepieces/pieces-framework';
import {
AuthenticationType,
httpClient,
HttpMethod,
HttpRequest,
} from '@activepieces/pieces-common';
import { google } from 'googleapis';
import { includeTeamDrivesProp } from '../common/props';
import { createGoogleClient, getAccessToken, googleSheetsAuth } from '../common/common';
import { AppConnectionType, isNil } from '@activepieces/shared';
export const createSpreadsheetAction = createAction({
auth: googleSheetsAuth,
name: 'create-spreadsheet',
displayName: 'Create Spreadsheet',
description: 'Creates a blank spreadsheet.',
props: {
title: Property.ShortText({
displayName: 'Title',
description: 'The title of the new spreadsheet.',
required: true,
}),
includeTeamDrives: includeTeamDrivesProp(),
folder: Property.Dropdown({
auth: googleSheetsAuth,
displayName: 'Parent Folder',
description:
'The folder to create the worksheet in.By default, the new worksheet is created in the root folder of drive.',
required: false,
refreshers: ['auth', 'includeTeamDrives'],
options: async ({ auth, includeTeamDrives }) => {
if (!auth) {
return {
disabled: true,
options: [],
placeholder: 'Please authenticate first',
};
}
const authProp = auth;
let folders: { id: string; name: string }[] = [];
const isServiceAccountWithoutImpersonation = authProp.type === AppConnectionType.CUSTOM_AUTH && authProp.props.userEmail?.length === 0;
let pageToken = null;
do {
const request: HttpRequest = {
method: HttpMethod.GET,
url: `https://www.googleapis.com/drive/v3/files`,
queryParams: {
q: "mimeType='application/vnd.google-apps.folder' and trashed = false",
includeItemsFromAllDrives: includeTeamDrives || isServiceAccountWithoutImpersonation ? 'true' : 'false',
supportsAllDrives: 'true',
},
authentication: {
type: AuthenticationType.BEARER_TOKEN,
token: await getAccessToken(authProp),
},
};
if (pageToken) {
if (request.queryParams !== undefined) {
request.queryParams['pageToken'] = pageToken;
}
}
try {
const response = await httpClient.sendRequest<{
files: { id: string; name: string,teamDriveId?: string }[];
nextPageToken: string;
}>(request);
folders = folders.concat(response.body.files.filter(file => !isNil(file.teamDriveId) || !isServiceAccountWithoutImpersonation));
pageToken = response.body.nextPageToken;
} catch (e) {
throw new Error(`Failed to get folders\nError:${e}`);
}
} while (pageToken);
return {
disabled: false,
options: folders.map((folder: { id: string; name: string }) => {
return {
label: folder.name,
value: folder.id,
};
}),
};
},
}),
},
async run(context) {
const { title, folder } = context.propsValue;
const response = await createSpreadsheet(context.auth, title, folder);
const newSpreadsheetId = response.id;
return {
id: newSpreadsheetId,
};
},
});
async function createSpreadsheet(
auth: AppConnectionValueForAuthProperty<typeof googleSheetsAuth>,
title: string,
folderId?: string,
) {
const googleClient = await createGoogleClient(auth);
const driveApi = google.drive({ version: 'v3', auth: googleClient });
const response = await driveApi.files.create({
requestBody: {
name: title,
mimeType: 'application/vnd.google-apps.spreadsheet',
parents: folderId ? [folderId] : undefined,
},
supportsAllDrives: true,
});
return response.data;
}

View File

@@ -0,0 +1,62 @@
import { createAction, Property } from '@activepieces/pieces-framework';
import { createGoogleClient } from '../common/common';
import { googleSheetsAuth } from '../common/common';
import { includeTeamDrivesProp, spreadsheetIdProp } from '../common/props';
import { google } from 'googleapis';
export const createWorksheetAction = createAction({
auth: googleSheetsAuth,
name: 'create-worksheet',
displayName: 'Create Worksheet',
description:'Create a blank worksheet with a title.',
props: {
includeTeamDrives: includeTeamDrivesProp(),
spreadsheetId: spreadsheetIdProp('Spreadsheet',''),
title:Property.ShortText({
displayName:'Title',
description:'The title of the new worksheet.',
required:true
}),
headers:Property.Array({
displayName:'Headers',
required:false
})
},
async run(context){
const {spreadsheetId,title} = context.propsValue;
const headers = context.propsValue.headers as string[] ?? [];
const client = await createGoogleClient(context.auth);
const sheetsApi = google.sheets({ version: 'v4', auth: client });
const sheet = await sheetsApi.spreadsheets.batchUpdate({
spreadsheetId:spreadsheetId,
requestBody:{
requests:[
{
addSheet:{
properties:{
title:title
},
},
}
]
}
});
const addHeadersResponse = await sheetsApi.spreadsheets.values.append({
spreadsheetId,
range:`${context.propsValue.title}!A1`,
valueInputOption:'RAW',
requestBody:{
majorDimension:'ROWS',
values:[headers]
}
});
return {
id: sheet.data?.replies?.[0]?.addSheet?.properties?.sheetId,
...addHeadersResponse.data
}
}})

View File

@@ -0,0 +1,40 @@
import { createAction, Property } from '@activepieces/pieces-framework';
import { areSheetIdsValid, googleSheetsCommon } from '../common/common';
import { googleSheetsAuth } from '../common/common';
import { commonProps } from '../common/props';
export const deleteRowAction = createAction({
auth: googleSheetsAuth,
name: 'delete_row',
description: 'Delete a row on an existing sheet you have access to',
displayName: 'Delete Row',
props: {
...commonProps,
rowId: Property.Number({
displayName: 'Row Number',
description: 'The row number to remove',
required: true,
}),
},
async run(context) {
const { spreadsheetId, sheetId, rowId } = context.propsValue;
if (!areSheetIdsValid(spreadsheetId,sheetId)) {
throw new Error('Please select a spreadsheet and sheet first.');
}
// Subtract 1 from the row_id to convert it to 0-indexed
const adjustedRowIndex = rowId - 1;
const response = await googleSheetsCommon.deleteRow(
spreadsheetId as string,
sheetId as number,
adjustedRowIndex,
context.auth,
);
return {
success: true,
body: response,
};
},
});

View File

@@ -0,0 +1,85 @@
import { createAction, Property } from '@activepieces/pieces-framework';
import {
httpClient,
HttpMethod,
AuthenticationType,
} from '@activepieces/pieces-common';
import { googleSheetsAuth } from '../common/common';
import { commonProps } from '../common/props';
import { areSheetIdsValid, getAccessToken } from '../common/common';
export const exportSheetAction = createAction({
name: 'export_sheet',
displayName: 'Export Sheet',
description: 'Export a Google Sheets tab to CSV or TSV format.',
auth: googleSheetsAuth,
props: {
...commonProps,
format: Property.StaticDropdown({
displayName: 'Export Format',
description: 'The format to export the sheet to.',
required: true,
defaultValue: 'csv',
options: {
disabled: false,
options: [
{ label: 'Comma Separated Values (.csv)', value: 'csv' },
{ label: 'Tab Separated Values (.tsv)', value: 'tsv' },
],
},
}),
returnAsText: Property.Checkbox({
displayName: 'Return as Text',
description: 'Return the exported data as text instead of a file.',
required: false,
defaultValue: false,
}),
},
async run({ propsValue, auth, files }) {
const { spreadsheetId, sheetId, format, returnAsText } = propsValue;
if (!areSheetIdsValid(spreadsheetId, sheetId)) {
throw new Error('Please select a spreadsheet and sheet first.');
}
const spreadsheet_id = spreadsheetId as string;
const sheet_id = sheetId as number;
const exportUrl = `https://docs.google.com/spreadsheets/d/${spreadsheet_id}/export?format=${format}&id=${spreadsheet_id}&gid=${sheet_id}`;
try {
const response = await httpClient.sendRequest({
method: HttpMethod.GET,
url: exportUrl,
authentication: {
type: AuthenticationType.BEARER_TOKEN,
token: await getAccessToken(auth),
},
responseType: 'arraybuffer',
});
if (returnAsText) {
const textData = Buffer.from(response.body).toString('utf-8');
return {
text: textData,
format,
};
} else {
const filename = `exported_sheet.${format}`;
const file = await files.write({
fileName: filename,
data: Buffer.from(response.body),
});
return {
file,
filename,
format,
};
}
} catch (error) {
throw new Error(`Failed to export sheet: ${error}`);
}
},
});

View File

@@ -0,0 +1,42 @@
import { Property, createAction } from '@activepieces/pieces-framework';
import { areSheetIdsValid, googleSheetsCommon } from '../common/common';
import { googleSheetsAuth } from '../common/common';
import { commonProps } from '../common/props';
export const findRowByNumAction = createAction({
auth: googleSheetsAuth,
name: 'find_row_by_num',
description: 'Get a row in a Google Sheet by row number',
displayName: 'Get Row',
props: {
...commonProps,
rowNumber: Property.Number({
displayName: 'Row Number',
description: 'The row number to get from the sheet',
required: true,
}),
headerRow: Property.Number({
displayName: 'Header Row',
description: 'Which row contains the headers?',
required: true,
defaultValue: 1,
}),
},
async run(context) {
const {spreadsheetId,sheetId,rowNumber,headerRow} = context.propsValue;
if (!areSheetIdsValid(spreadsheetId,sheetId)) {
throw new Error('Please select a spreadsheet and sheet first.');
}
const row = await googleSheetsCommon.getGoogleSheetRows({
auth: context.auth,
sheetId: sheetId as number,
spreadsheetId: spreadsheetId as string,
rowIndex_s: rowNumber,
rowIndex_e: rowNumber,
headerRow: headerRow,
});
return row[0];
},
});

View File

@@ -0,0 +1,142 @@
import {
createAction,
Property,
} from '@activepieces/pieces-framework';
import {
areSheetIdsValid,
googleSheetsCommon,
labelToColumn,
mapRowsToHeaderNames
} from '../common/common';
import { googleSheetsAuth } from '../common/common';
import { z } from 'zod';
import { propsValidation } from '@activepieces/pieces-common';
import { columnNameProp, commonProps } from '../common/props';
export const findRowsAction = createAction({
auth: googleSheetsAuth,
name: 'find_rows',
description:
'Find or get rows in a Google Sheet by column name and search value',
displayName: 'Find Rows',
props: {
...commonProps,
columnName: columnNameProp(),
searchValue: Property.ShortText({
displayName: 'Search Value',
description:
'The value to search for in the specified column. If left empty, all rows will be returned.',
required: false,
}),
matchCase: Property.Checkbox({
displayName: 'Exact match',
description:
'Whether to choose the rows with exact match or choose the rows that contain the search value',
required: true,
defaultValue: false,
}),
startingRow: Property.Number({
displayName: 'Starting Row',
description: 'The row number to start searching from',
required: false,
}),
numberOfRows: Property.Number({
displayName: 'Number of Rows',
description:
'The number of rows to return ( the default is 1 if not specified )',
required: false,
defaultValue: 1,
}),
headerRow: Property.Number({
displayName: 'Header Row',
description: 'Which row contains the headers?',
required: true,
defaultValue: 1,
}),
useHeaderNames: Property.Checkbox({
displayName: 'Use header names for keys',
description: 'Map A/B/C… to the actual column headers (row specified above).',
required: false,
defaultValue: false,
}),
},
async run({ propsValue, auth }) {
await propsValidation.validateZod(propsValue, {
startingRow: z.number().min(1).optional(),
numberOfRows: z.number().min(1).optional(),
});
const spreadsheetId = propsValue.spreadsheetId;
const sheetId = propsValue.sheetId;
const startingRow = propsValue.startingRow ?? 1;
const numberOfRowsToReturn = propsValue.numberOfRows ?? 1;
const headerRow = propsValue.headerRow;
const useHeaderNames = propsValue.useHeaderNames as boolean;
if (!areSheetIdsValid(spreadsheetId,sheetId)) {
throw new Error('Please select a spreadsheet and sheet first.');
}
const rows = await googleSheetsCommon.getGoogleSheetRows({
spreadsheetId: spreadsheetId as string,
auth: auth,
sheetId: sheetId as number,
rowIndex_s: startingRow,
rowIndex_e: undefined,
headerRow: headerRow,
});
const values = rows.map((row) => {
return row.values;
});
const matchingRows: any[] = [];
const columnName = propsValue.columnName ? propsValue.columnName : 'A';
const columnNumber:number = labelToColumn(columnName);
const searchValue = propsValue.searchValue ?? '';
let matchedRowCount = 0;
for (let i = 0; i < values.length; i++) {
const row:Record<string,any> = values[i];
if (matchedRowCount === numberOfRowsToReturn) break;
if (searchValue === '') {
matchingRows.push(rows[i]);
matchedRowCount += 1;
continue;
}
const keys = Object.keys(row);
if (keys.length <= columnNumber) continue;
const entry_value = row[keys[columnNumber]];
if (entry_value === undefined) {
continue;
}
if (propsValue.matchCase) {
if (entry_value === searchValue) {
matchedRowCount += 1;
matchingRows.push(rows[i]);
}
} else {
if (entry_value.toLowerCase().includes(searchValue.toLowerCase())) {
matchedRowCount += 1;
matchingRows.push(rows[i]);
}
}
}
const finalRows = await mapRowsToHeaderNames(
matchingRows,
useHeaderNames,
spreadsheetId as string,
sheetId as number,
headerRow,
auth,
);
return finalRows;
},
});

View File

@@ -0,0 +1,83 @@
import { createAction, Property } from '@activepieces/pieces-framework';
import { httpClient, HttpMethod, AuthenticationType, HttpRequest } from '@activepieces/pieces-common';
import { googleSheetsAuth } from '../common/common';
import { includeTeamDrivesProp } from '../common/props';
import { getAccessToken } from '../common/common';
export const findSpreadsheets = createAction({
name: 'find_spreadsheets',
displayName: 'Find Spreadsheet(s)',
description: 'Find spreadsheet(s) by name.',
auth: googleSheetsAuth,
props: {
includeTeamDrives: includeTeamDrivesProp(),
spreadsheet_name: Property.ShortText({
displayName: 'Spreadsheet Name',
description: 'The name of the spreadsheet(s) to find.',
required: true,
}),
exact_match: Property.Checkbox({
displayName: 'Exact Match',
description: 'If true, only return spreadsheets that exactly match the name. If false, return spreadsheets that contain the name.',
required: false,
defaultValue: false,
}),
},
async run({ propsValue, auth }) {
const searchValue = propsValue.spreadsheet_name;
const queries = [
"mimeType='application/vnd.google-apps.spreadsheet'",
'trashed=false',
];
if (propsValue.exact_match) {
queries.push(`name = '${searchValue}'`);
} else {
queries.push(`name contains '${searchValue}'`);
}
const files = [];
let pageToken = null;
do
{
const request :HttpRequest = {
method:HttpMethod.GET,
url: 'https://www.googleapis.com/drive/v3/files',
queryParams:{
q: queries.join(' and '),
includeItemsFromAllDrives: propsValue.includeTeamDrives ? 'true' : 'false',
supportsAllDrives: 'true',
fields: 'files(id,name,webViewLink,createdTime,modifiedTime),nextPageToken',
},
authentication: {
type: AuthenticationType.BEARER_TOKEN,
token: await getAccessToken(auth),
},
}
if (pageToken) {
if (request.queryParams !== undefined) {
request.queryParams['pageToken'] = pageToken;
}
}
try {
const response = await httpClient.sendRequest<{
files: { id: string; name: string }[];
nextPageToken: string;
}>(request);
files.push(...response.body.files);
pageToken = response.body.nextPageToken;
} catch (e) {
throw new Error(`Failed to get folders\nError:${e}`);
}
}while(pageToken);
return {
found: files.length > 0,
spreadsheets:files,
};
},
});

View File

@@ -0,0 +1,52 @@
import { googleSheetsAuth } from '../common/common';
import { createAction, Property } from '@activepieces/pieces-framework';
import { google } from 'googleapis';
import { OAuth2Client } from 'googleapis-common';
import { includeTeamDrivesProp, spreadsheetIdProp } from '../common/props';
import { createGoogleClient } from '../common/common';
export const findWorksheetAction = createAction({
auth: googleSheetsAuth,
name: 'find-worksheet',
displayName: 'Find Worksheet(s)',
description: 'Finds a worksheet(s) by title.',
props: {
includeTeamDrives: includeTeamDrivesProp(),
spreadsheetId:spreadsheetIdProp('Spreadsheet',''),
title: Property.ShortText({
displayName: 'Title',
required: true,
}),
exact_match: Property.Checkbox({
displayName: 'Exact Match',
description: 'If true, only return worksheets that exactly match the name. If false, return worksheets that contain the name.',
required: false,
defaultValue: false,
}),
},
async run(context) {
const spreadsheetId = context.propsValue.spreadsheetId;
const title = context.propsValue.title;
const exactMatch = context.propsValue.exact_match ?? false;
const authClient = await createGoogleClient(context.auth);
const sheets = google.sheets({ version: 'v4', auth: authClient });
const response = await sheets.spreadsheets.get({
spreadsheetId,
});
const sheetsData = response.data.sheets ?? [];
const matchedSheets = sheetsData.filter((sheet) => {
const sheetTitle = sheet.properties?.title ?? "";
return exactMatch ? sheetTitle === title : sheetTitle.includes(title);
});
return {
found: matchedSheets.length > 0,
worksheets: matchedSheets ,
};
},
});

View File

@@ -0,0 +1,46 @@
import { createAction, Property } from "@activepieces/pieces-framework";
import { areSheetIdsValid, googleSheetsAuth, googleSheetsCommon, mapRowsToHeaderNames } from "../common/common";
import { commonProps } from "../common/props";
export const getManyRowsAction = createAction({
name: 'get-many-rows',
auth: googleSheetsAuth,
displayName: 'Get Many Rows',
description: 'Get all values from the selected sheet.',
props: {
...commonProps,
first_row_headers: Property.Checkbox({
displayName: 'Does the first row contain headers?',
required: true,
defaultValue: false,
}),
},
async run(context) {
const {first_row_headers,sheetId,spreadsheetId} = context.propsValue;
if (!areSheetIdsValid(spreadsheetId, sheetId)) {
throw new Error('Please select a spreadsheet and sheet first.');
}
const rows = await googleSheetsCommon.getGoogleSheetRows({
auth:context.auth,
sheetId: sheetId as number,
spreadsheetId: spreadsheetId as string,
rowIndex_s:undefined,
rowIndex_e:undefined,
headerRow: 1,
});
const useHeaderNames = first_row_headers;
const result = await mapRowsToHeaderNames(
rows,
useHeaderNames,
spreadsheetId as string,
sheetId as number,
1,
context.auth
)
return result
}
})

View File

@@ -0,0 +1,207 @@
import {
PiecePropValueSchema,
Property,
Store,
StoreScope,
createAction,
} from '@activepieces/pieces-framework';
import { googleSheetsAuth } from '../common/common';
import {
areSheetIdsValid,
GoogleSheetsAuthValue,
googleSheetsCommon,
mapRowsToHeaderNames,
} from '../common/common';
import { isNil } from '@activepieces/shared';
import { HttpError } from '@activepieces/pieces-common';
import { z } from 'zod';
import { propsValidation } from '@activepieces/pieces-common';
import { getWorkSheetGridSize } from '../triggers/helpers';
import { commonProps } from '../common/props';
async function getRows(
store: Store,
auth: GoogleSheetsAuthValue,
spreadsheetId: string,
sheetId: number,
memKey: string,
groupSize: number,
startRow: number,
headerRow: number,
useHeaderNames: boolean,
testing: boolean
) {
const sheetGridRange = await getWorkSheetGridSize(auth,spreadsheetId,sheetId);
const existingGridRowCount = sheetGridRange.rowCount ??0;
const memVal = await store.get(memKey, StoreScope.FLOW);
let startingRow;
if (isNil(memVal) || memVal === '') startingRow = startRow || 1;
else {
startingRow = parseInt(memVal as string);
if (isNaN(startingRow)) {
throw Error(
'The value stored in memory key : ' +
memKey +
' is ' +
memVal +
' and it is not a number'
);
}
}
if (startingRow < 1)
throw Error('Starting row : ' + startingRow + ' is less than 1' + memVal);
if(startingRow > existingGridRowCount-1){
return [];
}
const endRow = Math.min(startingRow + groupSize,existingGridRowCount);
if (testing == false) await store.put(memKey, endRow, StoreScope.FLOW);
const row = await googleSheetsCommon.getGoogleSheetRows({
auth,
sheetId: sheetId,
spreadsheetId: spreadsheetId,
rowIndex_s: startingRow,
rowIndex_e: endRow - 1,
headerRow: headerRow,
});
if (row.length == 0) {
const allRows = await googleSheetsCommon.getGoogleSheetRows({
spreadsheetId: spreadsheetId,
auth,
sheetId: sheetId,
rowIndex_s: undefined,
rowIndex_e: undefined,
headerRow: headerRow,
});
const lastRow = allRows.length + 1;
if (testing == false) await store.put(memKey, lastRow, StoreScope.FLOW);
}
const finalRows = await mapRowsToHeaderNames(
row,
useHeaderNames,
spreadsheetId,
sheetId,
headerRow,
auth,
);
return finalRows;
}
const notes = `
**Notes:**
- Memory key is used to remember where last row was processed and will be used in the following runs.
- Republishing the flow **keeps** the memory key value, If you want to start over **change** the memory key.
`
export const getRowsAction = createAction({
auth: googleSheetsAuth,
name: 'get_next_rows',
description: 'Get next group of rows from a Google Sheet',
displayName: 'Get next row(s)',
props: {
...commonProps,
startRow: Property.Number({
displayName: 'Start Row',
description: 'Which row to start from?',
required: true,
defaultValue: 1,
}),
headerRow: Property.Number({
displayName: 'Header Row',
description: 'Which row contains the headers?',
required: true,
defaultValue: 1,
}),
useHeaderNames: Property.Checkbox({
displayName: 'Use header names for keys',
description: 'Map A/B/C… to the actual column headers (row specified above).',
required: false,
defaultValue: false,
}),
markdown: Property.MarkDown({
value: notes
}),
memKey: Property.ShortText({
displayName: 'Memory Key',
description: 'The key used to store the current row number in memory',
required: true,
defaultValue: 'row_number',
}),
groupSize: Property.Number({
displayName: 'Group Size',
description: 'The number of rows to get',
required: true,
defaultValue: 1,
}),
},
async run({ store, auth, propsValue }) {
const { startRow, groupSize, memKey, headerRow, spreadsheetId, sheetId, useHeaderNames} = propsValue;
if (!areSheetIdsValid(spreadsheetId, sheetId)) {
throw new Error('Please select a spreadsheet and sheet first.');
}
await propsValidation.validateZod(propsValue, {
startRow: z.number().min(1),
groupSize: z.number().min(1),
});
try {
return await getRows(
store,
auth,
spreadsheetId as string,
sheetId as number,
memKey,
groupSize,
startRow,
headerRow,
useHeaderNames as boolean,
false
);
} catch (error) {
if (error instanceof HttpError) {
const errorBody = error.response.body as any;
throw new Error(errorBody['error']['message']);
}
throw error;
}
},
async test({ store, auth, propsValue }) {
const { startRow, groupSize, memKey, headerRow, spreadsheetId, sheetId, useHeaderNames} = propsValue;
if (!areSheetIdsValid(spreadsheetId, sheetId)) {
throw new Error('Please select a spreadsheet and sheet first.');
}
try {
return await getRows(
store,
auth,
spreadsheetId as string,
sheetId as number,
memKey,
groupSize,
startRow,
headerRow,
useHeaderNames as boolean,
true
);
} catch (error) {
if (error instanceof HttpError) {
const errorBody = error.response.body as any;
throw new Error(errorBody['error']['message']);
}
throw error;
}
},
});

View File

@@ -0,0 +1,514 @@
import { googleSheetsAuth } from '../common/common';
import {
createAction,
DropdownOption,
DynamicPropsValue,
OAuth2PropertyValue,
Property,
} from '@activepieces/pieces-framework';
import { Dimension, googleSheetsCommon, objectToArray, ValueInputOption,columnToLabel, areSheetIdsValid, GoogleSheetsAuthValue, createGoogleClient } from '../common/common';
import { getWorkSheetName, getWorkSheetGridSize } from '../triggers/helpers';
import { google, sheets_v4 } from 'googleapis';
import { MarkdownVariant } from '@activepieces/shared';
import {parse} from 'csv-parse/sync';
import { commonProps } from '../common/props';
type RowValueType = Record<string, any>
export const insertMultipleRowsAction = createAction({
auth: googleSheetsAuth,
name: 'google-sheets-insert-multiple-rows',
displayName: 'Insert Multiple Rows',
description: 'Add one or more new rows in a specific spreadsheet.',
props: {
...commonProps,
input_type: Property.StaticDropdown({
displayName: 'Rows Input Format',
description: 'Select the format of the input values to be inserted into the sheet.',
required: true,
defaultValue: 'column_names',
options: {
disabled: false,
options: [
{
value: 'csv',
label: 'CSV',
},
{
value: 'json',
label: 'JSON',
},
{
value: 'column_names',
label: 'Column Names',
},
],
},
}),
values: Property.DynamicProperties({
auth: googleSheetsAuth,
displayName: 'Values',
description: 'The values to insert.',
required: true,
refreshers: ['sheetId', 'spreadsheetId', 'input_type', 'headerRow'],
props: async ({ auth, sheetId, spreadsheetId, input_type, headerRow }) => {
const sheet_id = Number(sheetId);
const spreadsheet_id = spreadsheetId as unknown as string;
const valuesInputType = input_type as unknown as string;
if (
!auth ||
(spreadsheet_id ?? '').toString().length === 0 ||
(sheet_id ?? '').toString().length === 0 ||
!['csv', 'json', 'column_names'].includes(valuesInputType)
) {
return {};
}
const fields: DynamicPropsValue = {};
switch (valuesInputType) {
case 'csv':
fields['markdown'] = Property.MarkDown({
value: `Ensure the first row contains column headers that match the sheet's column names.`,
variant: MarkdownVariant.INFO,
});
fields['values'] = Property.LongText({
displayName: 'CSV',
required: true,
});
break;
case 'json':
fields['markdown'] = Property.MarkDown({
value: `Provide values in JSON format. Ensure the column names match the sheet's header.`,
variant: MarkdownVariant.INFO,
});
fields['values'] = Property.Json({
displayName: 'JSON',
required: true,
defaultValue: [
{
column1: 'value1',
column2: 'value2',
},
{
column1: 'value3',
column2: 'value4',
},
],
});
break;
case 'column_names': {
const headers = await googleSheetsCommon.getGoogleSheetRows({
spreadsheetId: spreadsheet_id,
auth: auth,
sheetId: sheet_id,
rowIndex_s: 1,
rowIndex_e: 1,
headerRow: (headerRow as unknown as number) || 1,
});
const firstRow = headers[0].values ?? {};
//check for empty headers
if (Object.keys(firstRow).length === 0) {
fields['markdown'] = Property.MarkDown({
value: `We couldn't find any headers in the selected spreadsheet or worksheet. Please add headers to the sheet and refresh the page to reflect the columns.`,
variant: MarkdownVariant.INFO,
});
} else {
const columns: {
[key: string]: any;
} = {};
for (const key in firstRow) {
columns[key] = Property.ShortText({
displayName: firstRow[key],
description: firstRow[key],
required: false,
defaultValue: '',
});
}
fields['values'] = Property.Array({
displayName: 'Values',
required: false,
properties: columns,
});
}
}
}
return fields;
},
}),
overwrite: Property.Checkbox({
displayName: 'Overwrite Existing Data?',
description:
'Enable this option to replace all existing data in the sheet with new data from your input. This will clear any extra rows beyond the updated range.',
required: false,
defaultValue: false,
}),
check_for_duplicate: Property.Checkbox({
displayName: 'Avoid Duplicates?',
description:
'Enable this option to check for duplicate values before inserting data into the sheet. Only unique rows will be added based on the selected column.',
required: false,
defaultValue: false,
}),
check_for_duplicate_column: Property.DynamicProperties({
auth: googleSheetsAuth,
displayName: 'Duplicate Value Column',
description: 'The column to check for duplicate values.',
refreshers: ['spreadsheetId', 'sheetId', 'check_for_duplicate', 'headerRow'],
required: false,
props: async ({ auth, spreadsheetId, sheetId, check_for_duplicate, headerRow }) => {
const sheet_id = Number(sheetId);
const spreadsheet_id = spreadsheetId as unknown as string;
const checkForExisting = check_for_duplicate as unknown as boolean;
if (
!auth ||
(spreadsheet_id ?? '').toString().length === 0 ||
(sheet_id ?? '').toString().length === 0
) {
return {};
}
const fields: DynamicPropsValue = {};
if (checkForExisting) {
const headers = await googleSheetsCommon.getGoogleSheetRows({
spreadsheetId: spreadsheet_id,
auth: auth as GoogleSheetsAuthValue,
sheetId: sheet_id,
rowIndex_s: 1,
rowIndex_e: 1,
headerRow: (headerRow as unknown as number) || 1,
});
const firstRow = headers[0].values ?? {};
//check for empty headers
if (Object.keys(firstRow).length === 0) {
fields['markdown'] = Property.MarkDown({
value: `No headers were found in the selected spreadsheet or worksheet. Please ensure that headers are added to the sheet and refresh the page to display the available columns.`,
variant: MarkdownVariant.INFO,
});
} else {
const headers: DropdownOption<string>[] = [];
for (const key in firstRow) {
headers.push({ label: firstRow[key].toString(), value: key.toString() });
}
fields['column_name'] = Property.StaticDropdown({
displayName: 'Column to Check for Duplicates',
description:
'Select the column to use for detecting duplicate values. Only rows with unique values in this column will be added to the sheet.',
required: true,
options: {
disabled: false,
options: headers,
},
});
}
}
return fields;
},
}),
as_string: Property.Checkbox({
displayName: 'As String',
description:
'Inserted values that are dates and formulas will be entered as strings and have no effect',
required: false,
}),
headerRow: Property.Number({
displayName: 'Header Row',
description: 'Which row contains the headers?',
required: true,
defaultValue: 1,
}),
},
async run(context) {
const {
spreadsheetId:inputSpreadsheetId,
sheetId:inputSheetId,
input_type: valuesInputType,
overwrite: overwriteValues,
check_for_duplicate: checkForDuplicateValues,
values: { values: rowValuesInput },
as_string: asString,
headerRow,
} = context.propsValue;
if (!areSheetIdsValid(inputSpreadsheetId, inputSheetId)) {
throw new Error('Please select a spreadsheet and sheet first.');
}
const sheetId = Number(inputSheetId);
const spreadsheetId = inputSpreadsheetId as string;
const duplicateColumn = context.propsValue.check_for_duplicate_column?.['column_name'];
const sheetName = await getWorkSheetName(context.auth, spreadsheetId, sheetId);
const rowHeaders = await googleSheetsCommon.getGoogleSheetRows({
spreadsheetId: spreadsheetId,
auth: context.auth,
sheetId: sheetId,
rowIndex_s: 1,
rowIndex_e: 1,
headerRow: headerRow,
});
const sheetHeaders = rowHeaders[0]?.values ?? {};
const authClient = await createGoogleClient(context.auth);
const sheets = google.sheets({ version: 'v4', auth: authClient });
const formattedValues = await formatInputRows(sheets,spreadsheetId, sheetName,valuesInputType, rowValuesInput, sheetHeaders);
const valueInputOption = asString ? ValueInputOption.RAW : ValueInputOption.USER_ENTERED;
if (overwriteValues) {
const sheetGridRange = await getWorkSheetGridSize(context.auth, spreadsheetId, sheetId);
const existingGridRowCount = sheetGridRange.rowCount ?? 0;
return handleOverwrite(sheets, spreadsheetId, sheetName, formattedValues, existingGridRowCount, valueInputOption);
}
if (checkForDuplicateValues) {
const existingSheetValues = await googleSheetsCommon.getGoogleSheetRows({
spreadsheetId: spreadsheetId,
auth: context.auth,
sheetId: sheetId,
rowIndex_s: 1,
rowIndex_e: undefined,
headerRow: headerRow,
});
return handleDuplicates(
sheets,
spreadsheetId,
sheetName,
formattedValues,
existingSheetValues,
duplicateColumn,
valueInputOption
);
}
return normalInsert(sheets, spreadsheetId, sheetName, formattedValues, valueInputOption);
},
});
async function handleOverwrite(
sheets: sheets_v4.Sheets,
spreadSheetId: string,
sheetName: string,
formattedValues: any[],
existingGridRowCount: number,
valueInputOption: ValueInputOption
) {
const existingRowCount = existingGridRowCount;
const inputRowCount = formattedValues.length;
const updateResponse = await sheets.spreadsheets.values.batchUpdate({
spreadsheetId: spreadSheetId,
requestBody: {
data: [{
range: `${sheetName}!A2:ZZZ${inputRowCount + 1}`,
majorDimension: Dimension.ROWS,
values: formattedValues.map(row => objectToArray(row)),
}],
valueInputOption
},
});
// Determine if clearing rows is necessary and within grid size
const clearStartRow = inputRowCount + 2; // Start clearing after the last input row
const clearEndRow = Math.max(clearStartRow, existingRowCount);
if (clearStartRow <= existingGridRowCount) {
const boundedClearEndRow = Math.min(clearEndRow, existingGridRowCount);
const clearRowsResponse = await sheets.spreadsheets.values.batchClear({
spreadsheetId: spreadSheetId,
requestBody: {
ranges: [`${sheetName}!A${clearStartRow}:ZZZ${boundedClearEndRow}`],
},
});
return {
...updateResponse.data,
...clearRowsResponse.data,
};
}
return updateResponse.data;
}
async function handleDuplicates(
sheets: sheets_v4.Sheets,
spreadSheetId: string,
sheetName: string,
formattedInputRows: any[],
existingSheetValues: any[],
duplicateColumn: string,
valueInputOption: ValueInputOption
) {
const uniqueValues = formattedInputRows.filter(
(inputRow) => !existingSheetValues.some(
(existingRow) => {
const existingValue = existingRow?.values?.[duplicateColumn];
const inputValue = inputRow?.[duplicateColumn];
return existingValue != null && inputValue != null &&
String(existingValue).toLowerCase().trim() === String(inputValue).toLowerCase().trim();
}
)
);
const response = await sheets.spreadsheets.values.append({
range: sheetName + '!A:A',
spreadsheetId: spreadSheetId,
valueInputOption,
requestBody: {
values: uniqueValues.map((row) => objectToArray(row)),
majorDimension: Dimension.ROWS,
},
});
return response.data;
}
async function normalInsert(
sheets: sheets_v4.Sheets,
spreadSheetId: string,
sheetName: string,
formattedValues: any[],
valueInputOption: ValueInputOption
) {
const response = await sheets.spreadsheets.values.append({
range: sheetName + '!A:A',
spreadsheetId: spreadSheetId,
valueInputOption,
requestBody: {
values: formattedValues.map(row => objectToArray(row)),
majorDimension: Dimension.ROWS,
},
});
return response.data;
}
async function formatInputRows(
sheets: sheets_v4.Sheets,
spreadSheetId: string,
sheetName: string,
valuesInputType: string,
rowValuesInput: any,
sheetHeaders: RowValueType
): Promise<RowValueType[]> {
let formattedInputRows: any[] = [];
switch (valuesInputType) {
case 'csv':
formattedInputRows = convertCsvToRawValues(rowValuesInput as string, ',', sheetHeaders);
break;
case 'json':
formattedInputRows = await convertJsonToRawValues(sheets,spreadSheetId, sheetName, rowValuesInput as string, sheetHeaders);
break;
case 'column_names':
formattedInputRows = rowValuesInput as RowValueType[];
break;
}
return formattedInputRows;
}
async function convertJsonToRawValues(
sheets: sheets_v4.Sheets,
spreadSheetId: string,
sheetName: string,
json: string | Record<string, any>[],
labelHeaders: RowValueType
): Promise<RowValueType[]> {
let data: RowValueType[];
// If the input is a JSON string
if (typeof json === 'string') {
try {
data = JSON.parse(json);
} catch (error) {
throw new Error('Invalid JSON format for row values');
}
} else {
// If the input is already an array of objects, use it directly
data = json;
}
// Ensure the input is an array of objects
if (!Array.isArray(data) || typeof data[0] !== 'object') {
throw new Error('Input must be an array of objects or a valid JSON string representing it.');
}
// Collect all possible headers from the data
const allHeaders = new Set<string>();
data.forEach((row) => {
Object.keys(row).forEach((key) => allHeaders.add(key));
});
// Identify headers not present in labelHeaders
const additionalHeaders = Array.from(allHeaders).filter(
(header) => !Object.values(labelHeaders).includes(header)
);
//add missing headers to labelHeaders
additionalHeaders.forEach((header) => {
labelHeaders[columnToLabel(Object.keys(labelHeaders).length)] = header;
});
// update sheets with new headers
if (additionalHeaders.length > 0) {
await sheets.spreadsheets.values.update({
range: `${sheetName}!A1:ZZZ1`,
spreadsheetId: spreadSheetId,
valueInputOption: ValueInputOption.USER_ENTERED,
requestBody: {
majorDimension:Dimension.ROWS,
values: [objectToArray(labelHeaders)]
}
});
}
return data.map((row: RowValueType) => {
return Object.entries(labelHeaders).reduce((acc, [labelColumn, csvHeader]) => {
acc[labelColumn] = row[csvHeader] ?? "";
return acc;
}, {} as RowValueType);
})
}
function convertCsvToRawValues(
csvText: string,
delimiter: string,
labelHeaders: RowValueType,
) {
// Split CSV into rows
const rows:Record<string,any>[] = parse(csvText,{delimiter: delimiter, columns: true});
const result = rows.map((row)=>{
// Normalize record keys to lowercase
const normalizedRow = Object.keys(row).reduce((acc, key) => {
acc[key.toLowerCase().trim()] = row[key];
return acc;
},{} as Record<string,any>);
const transformedRow :Record<string,any>= {};
for(const key in labelHeaders){
// Match labels to normalized keys
const normalizedKey = labelHeaders[key].toLowerCase();
transformedRow[key] = normalizedRow[normalizedKey] || '';
}
return transformedRow;
})
return result;
}

View File

@@ -0,0 +1,107 @@
import { createAction, Property } from '@activepieces/pieces-framework';
import {
areSheetIdsValid,
Dimension,
getAccessToken,
GoogleSheetsAuthValue,
googleSheetsCommon,
objectToArray,
stringifyArray,
ValueInputOption,
} from '../common/common';
import { googleSheetsAuth } from '../common/common';
import { isNil } from '@activepieces/shared';
import { AuthenticationType, httpClient, HttpMethod, HttpRequest } from '@activepieces/pieces-common';
import { commonProps, rowValuesProp } from '../common/props';
export const insertRowAction = createAction({
auth: googleSheetsAuth,
name: 'insert_row',
description: 'Append a row of values to an existing sheet',
displayName: 'Insert Row',
props: {
...commonProps,
as_string: Property.Checkbox({
displayName: 'As String',
description: 'Inserted values that are dates and formulas will be entered strings and have no effect',
required: false,
}),
first_row_headers: Property.Checkbox({
displayName: 'Does the first row contain headers?',
description: 'If the first row is headers',
required: true,
defaultValue: false,
}),
values: rowValuesProp(),
},
async run({ propsValue, auth }) {
const { values, spreadsheetId:inputSpreadsheetId, sheetId:inputSheetId, as_string, first_row_headers } = propsValue;
if (!areSheetIdsValid(inputSpreadsheetId, inputSheetId)) {
throw new Error('Please select a spreadsheet and sheet first.');
}
const sheetId = Number(inputSheetId);
const spreadsheetId = inputSpreadsheetId as string;
const sheetName = await googleSheetsCommon.findSheetName(
auth,
spreadsheetId,
sheetId
);
const formattedValues = first_row_headers
? objectToArray(values).map(val => isNil(val) ? '' : val)
: values.values;
const res = await appendGoogleSheetValues({
auth,
majorDimension: Dimension.COLUMNS,
range: sheetName,
spreadSheetId: spreadsheetId,
valueInputOption: as_string ? ValueInputOption.RAW : ValueInputOption.USER_ENTERED,
values: stringifyArray(formattedValues),
});
const updatedRowNumber = extractRowNumber(res.body.updates.updatedRange);
return { ...res.body, row: updatedRowNumber };
},
});
function extractRowNumber(updatedRange: string): number {
const rowRange = updatedRange.split('!')[1];
return parseInt(rowRange.split(':')[0].substring(1), 10);
}
async function appendGoogleSheetValues(params: AppendGoogleSheetValuesParams) {
const { auth, majorDimension, range, spreadSheetId, valueInputOption, values } = params;
const accessToken = await getAccessToken(auth);
const request: HttpRequest = {
method: HttpMethod.POST,
url: `https://sheets.googleapis.com/v4/spreadsheets/${spreadSheetId}/values/${encodeURIComponent(`${range}!A:A`)}:append`,
body: {
majorDimension,
range: `${range}!A:A`,
values: values.map(val => ({ values: val })),
},
authentication: {
type: AuthenticationType.BEARER_TOKEN,
token: accessToken,
},
queryParams: {
valueInputOption,
},
};
return httpClient.sendRequest(request);
}
type AppendGoogleSheetValuesParams = {
values: string[];
spreadSheetId: string;
range: string;
valueInputOption: ValueInputOption;
majorDimension: Dimension;
auth: GoogleSheetsAuthValue;
};

View File

@@ -0,0 +1,164 @@
import { googleSheetsAuth } from '../common/common';
import {
createAction,
DynamicPropsValue,
OAuth2PropertyValue,
Property,
} from '@activepieces/pieces-framework';
import { areSheetIdsValid, createGoogleClient, Dimension, GoogleSheetsAuthValue, googleSheetsCommon, objectToArray, ValueInputOption } from '../common/common';
import { getAccessTokenOrThrow } from '@activepieces/pieces-common';
import { isNil, isString, MarkdownVariant } from '@activepieces/shared';
import { getWorkSheetName } from '../triggers/helpers';
import { google, sheets_v4 } from 'googleapis';
import { OAuth2Client } from 'googleapis-common';
import { commonProps } from '../common/props';
export const updateMultipleRowsAction = createAction({
auth: googleSheetsAuth,
name: 'update-multiple-rows',
displayName: 'Update Multiple Rows',
description: 'Updates multiple rows in a specific spreadsheet.',
props: {
...commonProps,
values: Property.DynamicProperties({
auth: googleSheetsAuth,
displayName: 'Values',
description: 'The values to update.',
required: true,
refreshers: ['sheetId', 'spreadsheetId', 'headerRow'],
props: async ({ auth, spreadsheetId, sheetId, headerRow }) => {
const sheet_Id = Number(sheetId);
const spreadsheet_Id = spreadsheetId as unknown as string;
const authentication = auth;
if (
!auth ||
(spreadsheet_Id ?? '').toString().length === 0 ||
(sheet_Id ?? '').toString().length === 0
) {
return {};
}
const fields: DynamicPropsValue = {};
const headers = await googleSheetsCommon.getGoogleSheetRows({
spreadsheetId: spreadsheet_Id,
auth: auth as GoogleSheetsAuthValue,
sheetId: sheet_Id,
rowIndex_s: 1,
rowIndex_e: 1,
headerRow: (headerRow as unknown as number) || 1,
});
const firstRow = headers[0].values ?? {};
//check for empty headers
if (Object.keys(firstRow).length === 0) {
fields['markdown'] = Property.MarkDown({
value: `We couldn't find any headers in the selected spreadsheet or worksheet. Please add headers to the sheet and refresh the page to reflect the columns.`,
variant: MarkdownVariant.INFO,
});
} else {
const columns: {
[key: string]: any;
} = {
rowId: Property.Number({
displayName: 'Row Id',
description: 'The row id to update',
required: true,
}),
};
for (const key in firstRow) {
columns[key] = Property.ShortText({
displayName: firstRow[key].toString(),
description: firstRow[key].toString(),
required: false,
defaultValue: '',
});
}
fields['values'] = Property.Array({
displayName: 'Values',
required: false,
properties: columns,
});
}
return fields;
},
}),
as_string: Property.Checkbox({
displayName: 'As String',
description:
'Inserted values that are dates and formulas will be entered as strings and have no effect',
required: false,
}),
headerRow: Property.Number({
displayName: 'Header Row',
description: 'Which row contains the headers?',
required: true,
defaultValue: 1,
}),
},
async run(context) {
const {
spreadsheetId:inputSpreadsheetId,
sheetId:inputSheetId,
values: { values: rowValuesInput },
as_string: asString,
headerRow,
} = context.propsValue;
if (!areSheetIdsValid(inputSpreadsheetId, inputSheetId)) {
throw new Error('Please select a spreadsheet and sheet first.');
}
const sheetId = Number(inputSheetId);
const spreadsheetId = inputSpreadsheetId as string;
const sheetName = await getWorkSheetName(context.auth, spreadsheetId, sheetId);
const valueInputOption = asString ? ValueInputOption.RAW : ValueInputOption.USER_ENTERED;
const authClient = await createGoogleClient(context.auth);
const sheets = google.sheets({ version: 'v4', auth: authClient });
const values: sheets_v4.Schema$ValueRange[] = [];
for (const row of rowValuesInput) {
const { rowId, ...rowValues } = row;
if (rowId === undefined || rowId === null) {
continue;
}
const formattedValues = objectToArray(rowValues).map((value: string | null | undefined) => {
if (value === '' || value === null || value === undefined) {
return null;
}
if (isString(value)) {
return value;
}
return JSON.stringify(value, null, 2);
});
if (formattedValues.length === 0) {
continue;
}
values.push({
range: `${sheetName}!A${rowId}:ZZZ${rowId}`,
majorDimension: Dimension.ROWS,
values: [formattedValues],
});
}
const response = await sheets.spreadsheets.values.batchUpdate({
spreadsheetId: spreadsheetId,
requestBody: {
valueInputOption: valueInputOption,
data: values,
},
});
return response.data;
},
});

View File

@@ -0,0 +1,98 @@
import { createAction, Property } from '@activepieces/pieces-framework';
import { areSheetIdsValid, createGoogleClient, Dimension, objectToArray, ValueInputOption } from '../common/common';
import { googleSheetsAuth } from '../common/common';
import { getWorkSheetName } from '../triggers/helpers';
import { google } from 'googleapis';
import { isString } from '@activepieces/shared';
import { commonProps, rowValuesProp } from '../common/props';
export const updateRowAction = createAction({
auth: googleSheetsAuth,
name: 'update_row',
description: 'Overwrite values in an existing row',
displayName: 'Update Row',
props: {
...commonProps,
row_id: Property.Number({
displayName: 'Row Number',
description: 'The row number to update',
required: true,
}),
first_row_headers: Property.Checkbox({
displayName: 'Does the first row contain headers?',
description: 'If the first row is headers',
required: true,
defaultValue: false,
}),
values: rowValuesProp(),
},
async run(context) {
const inputSpreadsheetId = context.propsValue.spreadsheetId;
const inputSheetId = context.propsValue.sheetId;
const rowId = context.propsValue.row_id;
const isFirstRowHeaders = context.propsValue.first_row_headers;
const rowValuesInput = context.propsValue.values;
if (!areSheetIdsValid(inputSpreadsheetId, inputSheetId)) {
throw new Error('Please select a spreadsheet and sheet first.');
}
const sheetId = Number(inputSheetId);
const spreadsheetId = inputSpreadsheetId as string;
const authClient = await createGoogleClient(context.auth);
const sheets = google.sheets({ version: 'v4', auth: authClient });
const sheetName = await getWorkSheetName(
context.auth,
spreadsheetId,
sheetId
);
// replace empty string with null to skip the cell value
const formattedValues = (
isFirstRowHeaders
? objectToArray(rowValuesInput)
: rowValuesInput['values']
).map((value: string | null | undefined) => {
if (value === '' || value === null || value === undefined) {
return null;
}
if (isString(value)) {
return value;
}
return JSON.stringify(value, null, 2);
});
if (formattedValues.length > 0) {
const response = await sheets.spreadsheets.values.update({
range: `${sheetName}!A${rowId}:ZZZ${rowId}`,
spreadsheetId: spreadsheetId,
valueInputOption: ValueInputOption.USER_ENTERED,
requestBody: {
values: [formattedValues],
majorDimension: Dimension.ROWS,
},
});
//Split the updatedRange string to extract the row number
const updatedRangeParts = response.data.updatedRange?.split(
'!'
);
const updatedRowRange = updatedRangeParts?.[1];
const updatedRowNumber = parseInt(
updatedRowRange?.split(':')[0].substring(1) ?? '0',
10
);
return { updates: { ...response.data }, row: updatedRowNumber };
} else {
throw Error(
'Values passed are empty or not array ' +
JSON.stringify(formattedValues)
);
}
},
});

View File

@@ -0,0 +1,383 @@
import { AppConnectionValueForAuthProperty, OAuth2PropertyValue, OAuth2Props, PieceAuth, PiecePropValueSchema, Property, ShortTextProperty, StaticPropsValue } from '@activepieces/pieces-framework';
import {
httpClient,
HttpMethod,
AuthenticationType,
HttpRequest,
} from '@activepieces/pieces-common';
import { AppConnectionType, isNil, isString } from '@activepieces/shared';
import { google } from 'googleapis';
import { OAuth2Client } from 'googleapis-common';
import { mapRowsToColumnLabels } from '../triggers/helpers';
export type GoogleSheetsAuthValue = AppConnectionValueForAuthProperty<typeof googleSheetsAuth>
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<string[] | undefined> {
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<any[]> {
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<string, any> = {};
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<OAuth2Client> {
if(auth.type === AppConnectionType.CUSTOM_AUTH)
{
const serviceAccount = JSON.parse(auth.props.serviceAccount);
return new google.auth.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<string> => {
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',
]
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. <br> <br> 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. <br> <br> **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,
};
}
})];

View File

@@ -0,0 +1,259 @@
import { DropdownOption, Property } from '@activepieces/pieces-framework';
import { google, drive_v3 } from 'googleapis';
import { columnToLabel, createGoogleClient, getHeaderRow, googleSheetsAuth, GoogleSheetsAuthValue, googleSheetsCommon } from './common';
import { isNil } from '@activepieces/shared';
export const includeTeamDrivesProp = () =>
Property.Checkbox({
displayName: 'Include Team Drive Sheets ?',
description: 'Determines if sheets from Team Drives should be included in the results.',
defaultValue: false,
required: false,
});
export const spreadsheetIdProp = (displayName: string, description: string, required = true) =>
Property.Dropdown({
displayName,
description,
auth: googleSheetsAuth,
required,
refreshers: ['includeTeamDrives'],
options: async ({ auth, includeTeamDrives }, { searchValue }) => {
if (!auth) {
return {
disabled: true,
options: [],
placeholder: 'Please authenticate first',
};
}
const authValue = auth;
const authClient = await createGoogleClient(authValue);
const drive = google.drive({ version: 'v3', auth: authClient });
const q = ["mimeType='application/vnd.google-apps.spreadsheet'", 'trashed = false'];
if (searchValue) {
q.push(`name contains '${searchValue}'`);
}
let nextPageToken;
const options: DropdownOption<string>[] = [];
do {
const response: any = await drive.files.list({
q: q.join(' and '),
pageToken: nextPageToken,
orderBy: 'createdTime desc',
fields: 'nextPageToken, files(id, name)',
supportsAllDrives: true,
includeItemsFromAllDrives: includeTeamDrives ? true : false,
});
const fileList: drive_v3.Schema$FileList = response.data;
if (fileList.files) {
for (const file of fileList.files) {
options.push({
label: file.name!,
value: file.id!,
});
}
}
nextPageToken = response.data.nextPageToken;
} while (nextPageToken);
return {
disabled: false,
options,
};
},
});
export const sheetIdProp = (displayName: string, description: string, required = true) =>
Property.Dropdown({
displayName,
description,
auth: googleSheetsAuth,
required,
refreshers: ['spreadsheetId'],
options: async ({ auth, spreadsheetId }) => {
if (!auth || (spreadsheetId ?? '').toString().length === 0) {
return {
disabled: true,
options: [],
placeholder: 'Please select a spreadsheet first.',
};
}
const authValue = auth as GoogleSheetsAuthValue;
const authClient = await createGoogleClient(authValue);
const sheets = google.sheets({ version: 'v4', auth: authClient });
const response = await sheets.spreadsheets.get({
spreadsheetId: spreadsheetId as unknown as string,
});
const sheetsData = response.data.sheets ?? [];
const options: DropdownOption<number>[] = [];
for (const sheet of sheetsData) {
const title = sheet.properties?.title;
const sheetId = sheet.properties?.sheetId;
if(isNil(title) || isNil(sheetId)){
continue;
}
options.push({
label: title,
value: sheetId,
});
}
return {
disabled: false,
options,
};
},
});
export const commonProps = {
includeTeamDrives: includeTeamDrivesProp(),
spreadsheetId: spreadsheetIdProp('Spreadsheet', 'The ID of the spreadsheet to use.'),
sheetId: sheetIdProp('Sheet', 'The ID of the sheet to use.'),
};
export const rowValuesProp = () =>
Property.DynamicProperties({
displayName: 'Values',
description: 'The values to insert',
required: true,
auth: googleSheetsAuth,
refreshers: ['sheetId', 'spreadsheetId', 'first_row_headers'],
props: async ({ auth, spreadsheetId, sheetId, first_row_headers }) => {
if (
!auth ||
(spreadsheetId ?? '').toString().length === 0 ||
(sheetId ?? '').toString().length === 0
) {
return {};
}
const sheet_id = Number(sheetId);
const authValue = auth as GoogleSheetsAuthValue;
const headers = await googleSheetsCommon.getHeaderRow({
spreadsheetId: spreadsheetId as unknown as string,
auth: authValue,
sheetId: sheet_id,
});
if (!first_row_headers) {
return {
values: Property.Array({
displayName: 'Values',
required: true,
}),
};
}
const firstRow = headers ?? [];
const properties: {
[key: string]: any;
} = {};
for (let i = 0; i < firstRow.length; i++) {
const label = columnToLabel(i);
properties[label] = Property.ShortText({
displayName: firstRow[i].toString(),
description: firstRow[i].toString(),
required: false,
defaultValue: '',
});
}
return properties;
},
});
export const columnNameProp = () =>
Property.Dropdown<string,true,typeof googleSheetsAuth>({
description: 'Column Name',
displayName: 'The name of the column to search in',
required: true,
auth: googleSheetsAuth,
refreshers: ['sheetId', 'spreadsheetId'],
options: async ({ auth, spreadsheetId, sheetId }) => {
const spreadsheet_id = spreadsheetId as string;
const sheet_id = Number(sheetId) as number;
if (
!auth ||
(spreadsheet_id ?? '').toString().length === 0 ||
(sheet_id ?? '').toString().length === 0
) {
return {
disabled: true,
options: [],
placeholder: 'Please select a sheet first',
};
}
const sheetName = await googleSheetsCommon.findSheetName(
auth,
spreadsheet_id,
sheet_id,
);
if (!sheetName) {
throw Error('Sheet not found in spreadsheet');
}
const headers = await getHeaderRow({
spreadsheetId: spreadsheet_id,
auth,
sheetId: sheet_id,
});
const ret = [];
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
if (isNil(headers)) {
return {
options: [],
disabled: false,
};
}
if (headers.length === 0) {
const columnSize = headers.length;
for (let i = 0; i < columnSize; i++) {
ret.push({
label: alphabet[i].toUpperCase(),
value: alphabet[i],
});
}
} else {
let index = 0;
for (let i = 0; i < headers.length; i++) {
let value = 'A';
if (index >= alphabet.length) {
// if the index is greater than the length of the alphabet, we need to add another letter
const firstLetter = alphabet[Math.floor(index / alphabet.length) - 1];
const secondLetter = alphabet[index % alphabet.length];
value = firstLetter + secondLetter;
} else {
value = alphabet[index];
}
ret.push({
label: headers[i].toString(),
value: value,
});
index++;
}
}
return {
options: ret,
disabled: false,
};
},
});

View File

@@ -0,0 +1,155 @@
import { google } from 'googleapis';
import { nanoid } from 'nanoid';
import dayjs from 'dayjs';
import crypto from 'crypto';
import { columnToLabel, createGoogleClient, GoogleSheetsAuthValue } from '../common/common';
import { isNil } from '@activepieces/shared';
export async function getWorkSheetName(
auth: GoogleSheetsAuthValue,
spreadSheetId: string,
sheetId: number,
) {
const authClient = await createGoogleClient(auth);
const sheets = google.sheets({ version: 'v4', auth: authClient });
const res = await sheets.spreadsheets.get({ spreadsheetId: spreadSheetId });
const sheetName = res.data.sheets?.find((f) => f.properties?.sheetId == sheetId)?.properties
?.title;
if (!sheetName) {
throw Error(`Sheet with ID ${sheetId} not found in spreadsheet ${spreadSheetId}`);
}
return sheetName;
}
export async function getWorkSheetGridSize(
auth: GoogleSheetsAuthValue,
spreadSheetId: string,
sheetId: number,
) {
const authClient = await createGoogleClient(auth);
const sheets = google.sheets({ version: 'v4', auth: authClient });
const res = await sheets.spreadsheets.get({ spreadsheetId: spreadSheetId, includeGridData: true, fields: 'sheets.properties(sheetId,title,sheetType,gridProperties)' });
const sheetRange = res.data.sheets?.find((f) => f.properties?.sheetId == sheetId)?.properties?.gridProperties;
if (!sheetRange) {
throw Error(`Unable to get grid size for sheet ${sheetId} in spreadsheet ${spreadSheetId}`);
}
return sheetRange
}
export async function getWorkSheetValues(
auth: GoogleSheetsAuthValue,
spreadsheetId: string,
range?: string,
) {
const authClient = await createGoogleClient(auth);
const sheets = google.sheets({ version: 'v4', auth: authClient });
const res = await sheets.spreadsheets.values.get({
spreadsheetId: spreadsheetId,
range: range,
});
return res.data.values ?? [];
}
export async function createFileNotification(
auth: GoogleSheetsAuthValue,
fileId: string,
url: string,
includeTeamDrives?: boolean,
) {
const authClient = await createGoogleClient(auth);
const drive = google.drive({ version: 'v3', auth: authClient });
// create unique UUID for channel
const channelId = nanoid();
return await drive.files.watch({
fileId: fileId,
supportsAllDrives: includeTeamDrives,
requestBody: {
id: channelId,
expiration: (dayjs().add(6, 'day').unix() * 1000).toString(),
type: 'web_hook',
address: url,
},
});
}
export async function deleteFileNotification(
auth: GoogleSheetsAuthValue,
channelId: string,
resourceId: string,
) {
const authClient = await createGoogleClient(auth);
const drive = google.drive({ version: 'v3', auth: authClient });
return await drive.channels.stop({
requestBody: {
id: channelId,
resourceId: resourceId,
},
});
}
export function isSyncMessage(headers: Record<string, string>) {
return headers['x-goog-resource-state'] === 'sync';
}
export function isChangeContentMessage(headers: Record<string, string>) {
// https://developers.google.com/drive/api/guides/push#respond-to-notifications
return (
headers['x-goog-resource-state'] === 'update' &&
['content', 'properties', 'content,properties'].includes(headers['x-goog-changed'])
);
}
export function hashObject(obj: Record<string, unknown>): string {
const hash = crypto.createHash('sha256');
hash.update(JSON.stringify(obj));
return hash.digest('hex');
}
// returns an array of row number and cells values mapped to column labels
export function mapRowsToColumnLabels(rowValues: any[][], oldRowCount: number, headerCount: number) {
const result = [];
for (let i = 0; i < rowValues.length; i++) {
const values: Record<string, string> = {};
for (let j = 0; j < Math.max(headerCount, rowValues[i].length); j++) {
const columnLabel = columnToLabel(j);
if (isNil(rowValues[i][j])) {
values[columnLabel] = "";
} else if (typeof rowValues[i][j] === "string") {
values[columnLabel] = rowValues[i][j];
}
else if ('toString' in rowValues[i][j]) {
values[columnLabel] = rowValues[i][j].toString();
}
else {
values[columnLabel] = `${rowValues[i][j]}`;
}
}
result.push({
row: oldRowCount + i + 1,
values,
});
}
return result;
}
export interface WebhookInformation {
kind?: string | null;
id?: string | null;
resourceId?: string | null;
resourceUri?: string | null;
expiration?: string | null;
}

View File

@@ -0,0 +1,299 @@
import { isNil } from '@activepieces/shared';
import { googleSheetsAuth } from '../common/common';
import { areSheetIdsValid, columnToLabel, GoogleSheetsAuthValue, labelToColumn } from '../common/common';
import {
createFileNotification,
deleteFileNotification,
getWorkSheetName,
getWorkSheetValues,
hashObject,
isChangeContentMessage,
isSyncMessage,
mapRowsToColumnLabels,
WebhookInformation,
} from './helpers';
import {
createTrigger,
TriggerStrategy,
DEDUPE_KEY_PROPERTY,
WebhookRenewStrategy,
Property,
DropdownOption,
} from '@activepieces/pieces-framework';
import crypto from 'crypto';
import { commonProps } from '../common/props';
const ALL_COLUMNS = 'all_columns';
export const newOrUpdatedRowTrigger = createTrigger({
auth: googleSheetsAuth,
name: 'google-sheets-new-or-updated-row',
displayName: 'New or Updated Row',
description: 'Triggers when a new row is added or modified in a spreadsheet.',
props: {
info: Property.MarkDown({
value:
'Please note that there might be a delay of up to 3 minutes for the trigger to be fired, due to a delay from Google.',
}),
...commonProps,
trigger_column: Property.Dropdown({
auth: googleSheetsAuth,
displayName: 'Trigger Column',
description: `Trigger on changes to cells in this column only. \nSelect **Any Column** if you want the flow to trigger on changes to any cell within the row.`,
required: false,
refreshers: ['spreadsheetId', 'sheetId'],
defaultValue: ALL_COLUMNS,
options: async ({ auth, spreadsheetId, sheetId }) => {
if (!auth || !spreadsheetId || isNil(sheetId)) {
return {
disabled: true,
options: [],
placeholder: `Please select sheet first`,
};
}
const spreadsheet_id = spreadsheetId as string;
const sheet_id = sheetId as number;
const sheetName = await getWorkSheetName(auth, spreadsheet_id, sheet_id);
const firstRowValues = await getWorkSheetValues(
auth,
spreadsheet_id,
`${sheetName}!1:1`,
);
const headers = firstRowValues[0] ?? [];
const headerCount = headers.length;
const labeledRowValues = mapRowsToColumnLabels(firstRowValues, 0, headerCount);
const labledHeaders = labeledRowValues.length > 0 ? labeledRowValues[0].values : {};
const options = Object.entries(labledHeaders).reduce((accumlator:DropdownOption<string>[],[key,value]) => {
accumlator.push({ label: value as string, value: key });
return accumlator;
}, [{ label: 'Any Column', value: ALL_COLUMNS }]);
return {
disabled: options.length === 0,
options,
};
},
}),
},
renewConfiguration: {
strategy: WebhookRenewStrategy.CRON,
cronExpression: '0 */12 * * *',
},
type: TriggerStrategy.WEBHOOK,
async onEnable(context) {
const inputSpreadsheetId = context.propsValue.spreadsheetId;
const inputSheetId = context.propsValue.sheetId;
const triggerColumn = context.propsValue.trigger_column ?? ALL_COLUMNS;
if (!areSheetIdsValid(inputSpreadsheetId, inputSheetId)) {
throw new Error('Please select a spreadsheet and sheet first.');
}
const sheetId = Number(inputSheetId);
const spreadsheetId = inputSpreadsheetId as string;
const sheetName = await getWorkSheetName(context.auth, spreadsheetId, sheetId);
const sheetValues = await getWorkSheetValues(context.auth, spreadsheetId, sheetName);
const rowHashes = [];
// create initial row level hashes and used it to check updated row
for (const row of sheetValues) {
let targetValue;
if (triggerColumn === ALL_COLUMNS) {
targetValue = row;
} else {
const currentTriggerColumnValue = row[labelToColumn(triggerColumn)];
targetValue =
currentTriggerColumnValue !== undefined && currentTriggerColumnValue !== '' // if column value is empty
? [currentTriggerColumnValue]
: [];
}
const rowHash = crypto.createHash('md5').update(JSON.stringify(targetValue)).digest('hex');
rowHashes.push(rowHash);
}
// store compressed values
await context.store.put(`${sheetId}`, rowHashes);
// create file watch notification
const fileNotificationRes = await createFileNotification(
context.auth,
spreadsheetId,
context.webhookUrl,
context.propsValue.includeTeamDrives,
);
await context.store.put<WebhookInformation>(
'google-sheets-new-or-updated-row',
fileNotificationRes.data,
);
},
async onDisable(context) {
const webhook = await context.store.get<WebhookInformation>('google-sheets-new-or-updated-row');
if (webhook != null && webhook.id != null && webhook.resourceId != null) {
try
{
await deleteFileNotification(context.auth, webhook.id, webhook.resourceId);
}
catch(err){
console.debug("deleteFileNotification failed :",JSON.stringify(err));
}
}
},
async run(context) {
if (isSyncMessage(context.payload.headers)) {
return [];
}
if (!isChangeContentMessage(context.payload.headers)) {
return [];
}
const inputSpreadsheetId = context.propsValue.spreadsheetId;
const inputSheetId = context.propsValue.sheetId;
const triggerColumn = context.propsValue.trigger_column ?? ALL_COLUMNS;
if (!areSheetIdsValid(inputSpreadsheetId, inputSheetId)) {
throw new Error('Please select a spreadsheet and sheet first.');
}
const sheetId = Number(inputSheetId);
const spreadsheetId = inputSpreadsheetId as string;
const sheetName = await getWorkSheetName(context.auth, spreadsheetId, sheetId);
const oldValuesHashes = (await context.store.get(`${sheetId}`)) as any[];
/* Fetch rows values with all columns as this will be used on returning updated/new row data
*/
const currentValues = await getWorkSheetValues(context.auth, spreadsheetId, sheetName);
const headers = currentValues[0] ?? [];
const headerCount = headers.length;
// const rowCount = Math.max(oldValuesHashes.length, currentValues.length);
const changedValues = [];
const newRowHashes = [];
for (let row = 0; row < currentValues.length; row++) {
const currentRowValue = currentValues[row];
/**
* This variable store value based on trigger column.
* If trigger column is all_columns then store entry row as target value, else store only column value.
*/
let targetValue;
if (triggerColumn === ALL_COLUMNS) {
targetValue = currentRowValue;
} else {
const currentTriggerColumnValue = currentRowValue[labelToColumn(triggerColumn)];
targetValue =
currentTriggerColumnValue !== undefined && currentTriggerColumnValue !== ''
? [currentTriggerColumnValue]
: [];
}
// create hash for new row values
const currentRowHash = crypto
.createHash('md5')
.update(JSON.stringify(targetValue))
.digest('hex');
newRowHashes.push(currentRowHash);
// If row is empty then skip
if (currentRowValue === undefined || currentRowValue.length === 0) {
continue;
}
const oldRowHash =
!isNil(oldValuesHashes) && row < oldValuesHashes.length ? oldValuesHashes[row] : undefined;
if (oldRowHash === undefined || oldRowHash != currentRowHash) {
const formattedValues: any = {};
for (let column = 0; column < headerCount; column++) {
formattedValues[columnToLabel(column)] = currentValues[row][column] ?? '';
}
changedValues.push({
row: row + 1,
values: formattedValues,
});
}
}
// update the row hashes
await context.store.put(`${sheetId}`, newRowHashes);
return changedValues.map((row) => {
return {
...row,
[DEDUPE_KEY_PROPERTY]: hashObject(row),
};
});
},
async test(context) {
const inputSpreadsheetId = context.propsValue.spreadsheetId;
const inputSheetId = context.propsValue.sheetId;
if (!areSheetIdsValid(inputSpreadsheetId, inputSheetId)) {
throw new Error('Please select a spreadsheet and sheet first.');
}
const sheetId = Number(inputSheetId);
const spreadsheetId = inputSpreadsheetId as string;
const sheetName = await getWorkSheetName(context.auth, spreadsheetId, sheetId);
const currentSheetValues = await getWorkSheetValues(context.auth, spreadsheetId, sheetName);
const headers = currentSheetValues[0] ?? [];
const headerCount = headers.length;
const transformedRowValues = mapRowsToColumnLabels(currentSheetValues, 0, headerCount)
.slice(-5)
.reverse();
return transformedRowValues;
},
async onRenew(context) {
// get current channel ID & resource ID
const webhook = await context.store.get<WebhookInformation>(`google-sheets-new-or-updated-row`);
if (webhook != null && webhook.id != null && webhook.resourceId != null) {
// delete current channel
await deleteFileNotification(context.auth, webhook.id, webhook.resourceId);
const fileNotificationRes = await createFileNotification(
context.auth,
context.propsValue.spreadsheetId!,
context.webhookUrl,
context.propsValue.includeTeamDrives,
);
// store channel response
await context.store.put<WebhookInformation>(
'google-sheets-new-or-updated-row',
fileNotificationRes.data,
);
}
},
sampleData: {},
});

View File

@@ -0,0 +1,182 @@
import {
DEDUPE_KEY_PROPERTY,
Property,
TriggerStrategy,
WebhookRenewStrategy,
createTrigger,
} from '@activepieces/pieces-framework';
import {
createFileNotification,
deleteFileNotification,
getWorkSheetName,
getWorkSheetValues,
hashObject,
isChangeContentMessage,
isSyncMessage,
mapRowsToColumnLabels,
WebhookInformation,
} from './helpers';
import { googleSheetsAuth } from '../common/common';
import { commonProps } from '../common/props';
import { areSheetIdsValid, } from '../common/common';
export const newRowAddedTrigger = createTrigger({
auth: googleSheetsAuth,
name: 'googlesheets_new_row_added',
displayName: 'New Row Added',
description: 'Triggers when a new row is added to bottom of a spreadsheet.',
props: {
info: Property.MarkDown({
value:
'Please note that there might be a delay of up to 3 minutes for the trigger to be fired, due to a delay from Google.',
}),
...commonProps,
},
renewConfiguration: {
strategy: WebhookRenewStrategy.CRON,
cronExpression: '0 */12 * * *',
},
type: TriggerStrategy.WEBHOOK,
async onEnable(context) {
const { spreadsheetId:inputSpreadsheetId, sheetId:inputSheetId } = context.propsValue;
if (!areSheetIdsValid(inputSpreadsheetId, inputSheetId)) {
throw new Error('Please select a spreadsheet and sheet first.');
}
const sheetId = Number(inputSheetId);
const spreadsheetId = inputSpreadsheetId as string;
const sheetName = await getWorkSheetName(context.auth, spreadsheetId, sheetId);
const currentSheetValues = await getWorkSheetValues(context.auth, spreadsheetId, sheetName);
await context.store.put(`${sheetId}`, currentSheetValues.length);
const fileNotificationRes = await createFileNotification(
context.auth,
spreadsheetId,
context.webhookUrl,
context.propsValue.includeTeamDrives,
);
await context.store.put<WebhookInformation>(
'googlesheets_new_row_added',
fileNotificationRes.data,
);
},
async onDisable(context) {
const webhook = await context.store.get<WebhookInformation>(`googlesheets_new_row_added`);
if (webhook != null && webhook.id != null && webhook.resourceId != null) {
try
{
await deleteFileNotification(context.auth, webhook.id, webhook.resourceId);
}
catch(err){
console.debug("deleteFileNotification failed :",JSON.stringify(err));
}
}
},
async run(context) {
if (isSyncMessage(context.payload.headers)) {
return [];
}
if (!isChangeContentMessage(context.payload.headers)) {
return [];
}
const { spreadsheetId:inputSpreadsheetId, sheetId:inputSheetId } = context.propsValue;
if (!areSheetIdsValid(inputSpreadsheetId, inputSheetId)) {
throw new Error('Please select a spreadsheet and sheet first.');
}
const sheetId = Number(inputSheetId);
const spreadsheetId = inputSpreadsheetId as string;
const oldRowCount = (await context.store.get(`${sheetId}`)) as number;
const sheetName = await getWorkSheetName(context.auth, spreadsheetId, sheetId);
const currentRowValues = await getWorkSheetValues(context.auth, spreadsheetId, sheetName);
const currentRowCount = currentRowValues.length;
const headers = currentRowValues[0] ?? [];
const headerCount = headers.length;
if (oldRowCount >= currentRowCount) {
if (oldRowCount > currentRowCount) {
await context.store.put(`${sheetId}`, currentRowCount);
}
return [];
}
// create A1 notation range for new rows
const range = `${sheetName}!${oldRowCount + 1}:${currentRowCount}`;
const newRowValues = await getWorkSheetValues(
context.auth,
spreadsheetId,
range,
);
await context.store.put(`${sheetId}`, currentRowCount);
const transformedRowValues = mapRowsToColumnLabels(newRowValues, oldRowCount,headerCount);
return transformedRowValues.map((row) => {
return {
...row,
[DEDUPE_KEY_PROPERTY]: hashObject(row),
};
});
},
async onRenew(context) {
// get current channel ID & resource ID
const webhook = await context.store.get<WebhookInformation>(`googlesheets_new_row_added`);
const { spreadsheetId:inputSpreadsheetId, sheetId:inputSheetId } = context.propsValue;
if (!areSheetIdsValid(inputSpreadsheetId, inputSheetId)) {
throw new Error('Please select a spreadsheet and sheet first.');
}
const spreadsheetId = inputSpreadsheetId as string;
if (webhook != null && webhook.id != null && webhook.resourceId != null) {
await deleteFileNotification(context.auth, webhook.id, webhook.resourceId);
const fileNotificationRes = await createFileNotification(
context.auth,
spreadsheetId,
context.webhookUrl,
context.propsValue.includeTeamDrives,
);
await context.store.put<WebhookInformation>(
'googlesheets_new_row_added',
fileNotificationRes.data,
);
}
},
async test(context) {
const { spreadsheetId:inputSpreadsheetId, sheetId:inputSheetId } = context.propsValue;
if (!areSheetIdsValid(inputSpreadsheetId, inputSheetId)) {
throw new Error('Please select a spreadsheet and sheet first.');
}
const sheetId = Number(inputSheetId);
const spreadsheetId = inputSpreadsheetId as string;
const sheetName = await getWorkSheetName(context.auth, spreadsheetId, sheetId);
const currentSheetValues = await getWorkSheetValues(context.auth, spreadsheetId, sheetName);
const headers = currentSheetValues[0] ?? [];
const headerCount = headers.length;
const transformedRowValues = mapRowsToColumnLabels(currentSheetValues, 0,headerCount)
.slice(-5)
.reverse();
return transformedRowValues;
},
sampleData: {},
});

View File

@@ -0,0 +1,83 @@
import { AppConnectionValueForAuthProperty, createTrigger,PiecePropValueSchema,TriggerStrategy } from '@activepieces/pieces-framework';
import { googleSheetsAuth } from '../common/common';
import { DedupeStrategy, Polling, pollingHelper } from '@activepieces/pieces-common';
import dayjs from 'dayjs';
import { google, drive_v3 } from 'googleapis';
import { includeTeamDrivesProp } from '../common/props';
import { createGoogleClient, GoogleSheetsAuthValue } from '../common/common';
type Props = {
includeTeamDrives?: boolean;
};
const polling: Polling<AppConnectionValueForAuthProperty<typeof googleSheetsAuth>, Props> = {
strategy: DedupeStrategy.TIMEBASED,
async items({ auth, propsValue, lastFetchEpochMS }) {
const authValue = auth as GoogleSheetsAuthValue;
const q = ["mimeType='application/vnd.google-apps.spreadsheet'",'trashed = false'];
if (lastFetchEpochMS) {
q.push(`createdTime > '${dayjs(lastFetchEpochMS).toISOString()}'`);
}
const authClient = await createGoogleClient(authValue);
const drive = google.drive({ version: 'v3', auth: authClient });
let nextPageToken;
const items = [];
do {
const response: any = await drive.files.list({
q: q.join(' and '),
fields: '*',
orderBy: 'createdTime desc',
supportsAllDrives: true,
includeItemsFromAllDrives: propsValue.includeTeamDrives,
pageToken: nextPageToken,
});
const fileList: drive_v3.Schema$FileList = response.data;
if (fileList.files) {
items.push(...fileList.files);
}
if (lastFetchEpochMS === 0) break;
nextPageToken = response.data.nextPageToken;
} while (nextPageToken);
return items.map((item) => ({
epochMilliSeconds: dayjs(item.createdTime).valueOf(),
data: item,
}));
},
};
export const newSpreadsheetTrigger = createTrigger({
auth: googleSheetsAuth,
name: 'new-spreadsheet',
displayName: 'New Spreadsheet',
description: 'Triggers when a new spreadsheet is created.',
type: TriggerStrategy.POLLING,
props: {
includeTeamDrives: includeTeamDrivesProp()
},
async onEnable(context) {
await pollingHelper.onEnable(polling, {
auth: context.auth,
store: context.store,
propsValue: context.propsValue,
});
},
async onDisable(context) {
await pollingHelper.onDisable(polling, {
auth: context.auth,
store: context.store,
propsValue: context.propsValue,
});
},
async test(context) {
return await pollingHelper.test(polling, context);
},
async run(context) {
return await pollingHelper.poll(polling, context);
},
sampleData:{
kind: 'drive#file',
mimeType: 'application/vnd.google-apps.spreadsheet',
webViewLink:
'https://docs.google.com/document/d/1_9xjsrYFgHVvgqYwAJ8KcsDcNU/edit?usp=drivesdk',
id: '1_9xjsrYFgHVvgqYwAJ8KcsDcN3AzPelsux',
name: 'Test Document',
},
});

View File

@@ -0,0 +1,97 @@
import { googleSheetsAuth } from '../common/common';
import { createTrigger, TriggerStrategy } from '@activepieces/pieces-framework';
import { google } from 'googleapis';
import { OAuth2Client } from 'googleapis-common';
import { isNil } from '@activepieces/shared';
import { includeTeamDrivesProp, spreadsheetIdProp } from '../common/props';
import { createGoogleClient } from '../common/common';
export const newWorksheetTrigger = createTrigger({
auth: googleSheetsAuth,
name: 'new-worksheet',
displayName: 'New Worksheet',
description: 'Triggers when a worksheet is created in a spreadsheet.',
type: TriggerStrategy.POLLING,
props: {
includeTeamDrives: includeTeamDrivesProp(),
spreadsheetId: spreadsheetIdProp('Spreadsheet', '',true),
},
async onEnable(context) {
const ids: number[] = [];
const authClient = await createGoogleClient(context.auth);
const sheets = google.sheets({ version: 'v4', auth: authClient });
const response = await sheets.spreadsheets.get({
spreadsheetId: context.propsValue.spreadsheetId as string,
});
if (response.data.sheets) {
for (const sheet of response.data.sheets) {
const sheetId = sheet.properties?.sheetId;
if (sheetId) {
ids.push(sheetId);
}
}
}
await context.store.put('worksheets', JSON.stringify(ids));
},
async onDisable(context) {
await context.store.delete('worksheets');
},
async test(context) {
const worksheets = [];
const authClient = await createGoogleClient(context.auth);
const sheets = google.sheets({ version: 'v4', auth: authClient });
const response = await sheets.spreadsheets.get({
spreadsheetId: context.propsValue.spreadsheetId as string,
});
if (response.data.sheets) {
for (const sheet of response.data.sheets) {
worksheets.push(sheet);
}
}
return worksheets;
},
async run(context) {
const existingIds = (await context.store.get<string>('worksheets')) ?? '[]';
const parsedExistingIds = JSON.parse(existingIds) as number[];
const authClient = await createGoogleClient(context.auth);
const sheets = google.sheets({ version: 'v4', auth: authClient });
const response = await sheets.spreadsheets.get({
spreadsheetId: context.propsValue.spreadsheetId as string,
});
if (isNil(response.data.sheets) || response.data.sheets.length === 0) {
return [];
}
// Filter valid worksheetss
const newWorksheets = response.data.sheets.filter((sheet) => {
const sheetId = sheet.properties?.sheetId ?? undefined;
return sheetId !== undefined && !parsedExistingIds.includes(sheetId);
});
const newIds = newWorksheets
.map((sheet) => sheet.properties?.sheetId ?? undefined)
.filter((id): id is number => id !== undefined);
if (newIds.length === 0) {
return [];
}
// Store new IDs
await context.store.put('worksheets', JSON.stringify([...newIds, ...parsedExistingIds]));
return newWorksheets;
},
sampleData: {
properties: {
sheetId: 2077270595,
title: 'Sheet5',
index: 1,
sheetType: 'GRID',
gridProperties: {
rowCount: 1000,
columnCount: 26,
},
},
},
});