diff --git a/packages/nodes-base/credentials/GoogleDocsOAuth2Api.credentials.ts b/packages/nodes-base/credentials/GoogleDocsOAuth2Api.credentials.ts new file mode 100644 index 0000000000..08db9a0b2c --- /dev/null +++ b/packages/nodes-base/credentials/GoogleDocsOAuth2Api.credentials.ts @@ -0,0 +1,27 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +const scopes = [ + 'https://www.googleapis.com/auth/documents', + 'https://www.googleapis.com/auth/drive', + 'https://www.googleapis.com/auth/drive.file', +]; + +export class GoogleDocsOAuth2Api implements ICredentialType { + name = 'googleDocsOAuth2Api'; + extends = [ + 'googleOAuth2Api', + ]; + displayName = 'Google Docs OAuth2 API'; + documentationUrl = 'google'; + properties = [ + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: scopes.join(' '), + }, + ]; +} diff --git a/packages/nodes-base/nodes/Google/Docs/DocumentDescription.ts b/packages/nodes-base/nodes/Google/Docs/DocumentDescription.ts new file mode 100644 index 0000000000..519d7c5dac --- /dev/null +++ b/packages/nodes-base/nodes/Google/Docs/DocumentDescription.ts @@ -0,0 +1,1212 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const documentOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'document', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + }, + { + name: 'Get', + value: 'get', + }, + { + name: 'Update', + value: 'update', + }, + ], + default: 'create', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const documentFields = [ + /* -------------------------------------------------------------------------- */ + /* document: create */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Drive', + name: 'driveId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getDrives', + }, + default: 'myDrive', + required: true, + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'document', + ], + }, + }, + }, + { + displayName: 'Folder', + name: 'folderId', + type: 'options', + typeOptions: { + loadOptionsDependsOn: [ + 'driveId', + ], + loadOptionsMethod: 'getFolders', + }, + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'document', + ], + }, + }, + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'document', + ], + }, + }, + }, + + /* -------------------------------------------------------------------------- */ + /* document: get */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Doc ID or URL', + name: 'documentURL', + type: 'string', + required: true, + displayOptions: { + show: { + operation: [ + 'get', + ], + resource: [ + 'document', + ], + }, + }, + default: '', + description: 'The ID in the document URL (or just paste the whole URL).', + }, + { + displayName: 'Simple', + name: 'simple', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'get', + ], + resource: [ + 'document', + ], + }, + }, + default: true, + description: 'When set to true the document text content will be used else the raw data.', + }, + + /* -------------------------------------------------------------------------- */ + /* document: update */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Doc ID or URL', + name: 'documentURL', + type: 'string', + required: true, + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'document', + ], + }, + }, + default: '', + description: 'The ID in the document URL (or just paste the whole URL).', + }, + { + displayName: 'Simple', + name: 'simple', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'document', + ], + }, + }, + default: true, + description: 'When set to true a simplified version of the response will be used else the raw data.', + }, + { + displayName: 'Actions', + name: 'actionsUi', + description: 'Actions applied to update the document.', + type: 'fixedCollection', + placeholder: 'Add Action', + typeOptions: { + multipleValues: true, + }, + default: { + actionFields: [ + { + object: 'text', + action: 'insert', + locationChoice: 'endOfSegmentLocation', + index: 0, + text: '', + }, + ], + }, + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'document', + ], + }, + }, + options: [ + { + name: 'actionFields', + displayName: 'Action Fields', + values: [ + // Object field + { + displayName: 'Object', + name: 'object', + type: 'options', + options: [ + { + name: 'Footer', + value: 'footer', + }, + { + name: 'Header', + value: 'header', + }, + { + name: 'Named Range', + value: 'namedRange', + }, + { + name: 'Page Break', + value: 'pageBreak', + }, + { + name: 'Paragraph Bullets', + value: 'paragraphBullets', + }, + { + name: 'Positioned Object', + value: 'positionedObject', + }, + { + name: 'Table', + value: 'table', + }, + { + name: 'Table Column', + value: 'tableColumn', + }, + { + name: 'Table Row', + value: 'tableRow', + }, + { + name: 'Text', + value: 'text', + }, + ], + description: 'The update object.', + default: 'text', + }, + // Action fields (depend on the Object field) + { + displayName: 'Action', + name: 'action', + type: 'options', + options: [ + { + name: 'Find and replace text', + value: 'replaceAll', + }, + { + name: 'Insert', + value: 'insert', + }, + ], + displayOptions: { + show: { + object: [ + 'text', + ], + }, + }, + description: 'The update action.', + default: '', + }, + { + displayName: 'Action', + name: 'action', + type: 'options', + options: [ + { + name: 'Create', + value: 'create', + }, + { + name: 'Delete', + value: 'delete', + }, + ], + displayOptions: { + show: { + object: [ + 'footer', + 'header', + 'namedRange', + 'paragraphBullets', + ], + }, + }, + description: 'The update action.', + default: '', + }, + { + displayName: 'Action', + name: 'action', + type: 'options', + options: [ + { + name: 'Delete', + value: 'delete', + }, + { + name: 'Insert', + value: 'insert', + }, + ], + displayOptions: { + show: { + object: [ + 'tableColumn', + 'tableRow', + ], + }, + }, + description: 'The update action.', + default: '', + }, + { + displayName: 'Action', + name: 'action', + type: 'options', + options: [ + { + name: 'Insert', + value: 'insert', + }, + ], + displayOptions: { + show: { + object: [ + 'pageBreak', + 'table', + ], + }, + }, + description: 'The update action.', + default: '', + }, + { + displayName: 'Action', + name: 'action', + type: 'options', + options: [ + { + name: 'Delete', + value: 'delete', + }, + ], + displayOptions: { + show: { + object: [ + 'positionedObject', + ], + }, + }, + description: 'The update action.', + default: '', + }, + // Shared Segment inputs for Create action (moved up for display purposes) + { + displayName: 'Insert Segment', + name: 'insertSegment', + type: 'options', + options: [ + { + name: 'Header', + value: 'header', + }, + { + name: 'Body', + value: 'body', + }, + { + name: 'Footer', + value: 'footer', + }, + ], + description: 'The location where to create the object.', + default: 'body', + displayOptions: { + show: { + object: [ + 'footer', + 'header', + 'paragraphBullets', + 'namedRange', + ], + action: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Segment ID', + name: 'segmentId', + type: 'string', + description: 'The ID of the header, footer or footnote. The Document → Get operation lists all segment IDs (make sure you disable the simple toggle).', + default: '', + displayOptions: { + show: { + object: [ + 'footer', + 'header', + 'paragraphBullets', + 'namedRange', + ], + action: [ + 'create', + ], + }, + hide: { + insertSegment: [ + 'body', + ], + }, + }, + }, + // Inputs fields + // create footer + // create header + { + displayName: 'Index', + name: 'index', + type: 'number', + description: 'The zero-based index, relative to the beginning of the specified segment.', + default: 0, + displayOptions: { + show: { + object: [ + 'footer', + 'header', + ], + action: [ + 'create', + ], + }, + }, + }, + // create named range + { + displayName: 'Name', + name: 'name', + type: 'string', + description: 'The name of the Named Range. Names do not need to be unique.', + default: '', + displayOptions: { + show: { + object: [ + 'namedRange', + ], + action: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Start Index', + name: 'startIndex', + type: 'number', + description: 'The zero-based start index of this range.', + default: 0, + displayOptions: { + show: { + object: [ + 'namedRange', + ], + action: [ + 'create', + ], + }, + }, + }, + { + displayName: 'End Index', + name: 'endIndex', + type: 'number', + description: 'The zero-based end index of this range.', + default: 0, + displayOptions: { + show: { + object: [ + 'namedRange', + ], + action: [ + 'create', + ], + }, + }, + }, + // create bullets + { + displayName: 'Style', + name: 'bulletPreset', + type: 'options', + options: [ + { + name: 'Bullet List', + value: 'BULLET_DISC_CIRCLE_SQUARE', + description: 'A bulleted list with a DISC, CIRCLE and SQUARE bullet glyph for the first 3 list nesting levels.', + }, + { + name: 'Checkbox List', + value: 'BULLET_CHECKBOX', + description: 'A bulleted list with CHECKBOX bullet glyphs for all list nesting levels.', + }, + { + name: 'Numbered List', + value: 'NUMBERED_DECIMAL_NESTED', + description: 'A numbered list with DECIMAL numeric glyphs separated by periods, where each nesting level uses the previous nesting level\'s glyph as a prefix. For example: 1., 1.1., 2., 2.2 .', + }, + ], + description: 'The Preset pattern of bullet glyphs for list.', + default: 'BULLET_DISC_CIRCLE_SQUARE', + displayOptions: { + show: { + object: [ + 'paragraphBullets', + ], + action: [ + 'create', + ], + }, + }, + }, + // delete footer + { + displayName: 'Footer ID', + name: 'footerId', + type: 'string', + description: 'The ID of the footer to delete. To retrieve it, use the get document where you can find under footers attribute.', + default: '', + displayOptions: { + show: { + object: [ + 'footer', + ], + action: [ + 'delete', + ], + }, + }, + }, + // delete header + { + displayName: 'Header ID', + name: 'headerId', + type: 'string', + description: 'The ID of the header to delete. To retrieve it, use the get document where you can find under headers attribute.', + default: '', + displayOptions: { + show: { + object: [ + 'header', + ], + action: [ + 'delete', + ], + }, + }, + }, + // delete named range + { + displayName: 'Specify range by', + name: 'namedRangeReference', + type: 'options', + options: [ + { + name: 'ID', + value: 'namedRangeId', + }, + { + name: 'Name', + value: 'name', + }, + ], + description: 'The value determines which range or ranges to delete.', + default: 'namedRangeId', + displayOptions: { + show: { + object: [ + 'namedRange', + ], + action: [ + 'delete', + ], + }, + }, + }, + { + displayName: 'ID', + name: 'value', + type: 'string', + description: 'The ID of the range.', + default: '', + displayOptions: { + show: { + object: [ + 'namedRange', + ], + action: [ + 'delete', + ], + namedRangeReference: [ + 'namedRangeId', + ], + }, + }, + }, + { + displayName: 'Name', + name: 'value', + type: 'string', + description: 'The name of the range.', + default: '', + displayOptions: { + show: { + object: [ + 'namedRange', + ], + action: [ + 'delete', + ], + namedRangeReference: [ + 'name', + ], + }, + }, + }, + // delete bullets (shared inputs added below) + // delete positioned object + { + displayName: 'Object ID', + name: 'objectId', + type: 'string', + description: 'The ID of the positioned object to delete (An object that is tied to a paragraph and positioned relative to its beginning), See the Google documentation.', + default: '', + displayOptions: { + show: { + object: [ + 'positionedObject', + ], + action: [ + 'delete', + ], + }, + }, + }, + // insert table column/row (shared inputs added below) + // delete table column/row (shared inputs added below) + // Shared Segment inputs for Insert action (moved up for display purposes) + { + displayName: 'Insert Segment', + name: 'insertSegment', + type: 'options', + options: [ + { + name: 'Header', + value: 'header', + }, + { + name: 'Body', + value: 'body', + }, + { + name: 'Footer', + value: 'footer', + }, + ], + description: 'The location where to create the object.', + default: 'body', + displayOptions: { + show: { + object: [ + 'pageBreak', + 'table', + 'tableColumn', + 'tableRow', + 'text', + ], + action: [ + 'insert', + ], + }, + }, + }, + { + displayName: 'Segment ID', + name: 'segmentId', + type: 'string', + description: 'The ID of the header, footer or footnote. The Document → Get operation lists all segment IDs (make sure you disable the simple toggle).', + default: '', + displayOptions: { + show: { + object: [ + 'pageBreak', + 'table', + 'tableColumn', + 'tableRow', + 'text', + ], + action: [ + 'insert', + ], + }, + hide: { + insertSegment: [ + 'body', + ], + }, + }, + }, + // insert page break + { + displayName: 'Insert Location', + name: 'locationChoice', + type: 'options', + options: [ + { + name: 'At end of specific position', + value: 'endOfSegmentLocation', + description: 'Inserts the text at the end of a header, footer, footnote, or document body.', + }, + { + name: 'At index', + value: 'location', + }, + ], + description: 'The location where the text will be inserted.', + default: 'endOfSegmentLocation', + displayOptions: { + show: { + object: [ + 'pageBreak', + ], + action: [ + 'insert', + ], + }, + }, + }, + { + displayName: 'Index', + name: 'index', + type: 'number', + description: 'The zero-based index, relative to the beginning of the specified segment.', + displayOptions: { + show: { + locationChoice: [ + 'location', + ], + object: [ + 'pageBreak', + ], + action: [ + 'insert', + ], + }, + }, + typeOptions: { + minValue: 1, + }, + default: 1, + }, + // insert table + { + displayName: 'Insert Location', + name: 'locationChoice', + type: 'options', + options: [ + { + name: 'At end of specific position', + value: 'endOfSegmentLocation', + description: 'Inserts the text at the end of a header, footer, footnote, or document body.', + }, + { + name: 'At index', + value: 'location', + }, + ], + description: 'The location where the text will be inserted.', + default: 'endOfSegmentLocation', + displayOptions: { + show: { + object: [ + 'table', + ], + action: [ + 'insert', + ], + }, + }, + }, + { + displayName: 'Index', + name: 'index', + type: 'number', + description: 'The zero-based index, relative to the beginning of the specified segment (use index + 1 to refer to a table).', + displayOptions: { + show: { + locationChoice: [ + 'location', + ], + object: [ + 'table', + ], + action: [ + 'insert', + ], + }, + }, + default: 1, + typeOptions: { + minValue: 1, + }, + }, + { + displayName: 'Rows', + name: 'rows', + type: 'number', + description: 'The number of rows in the table.', + default: 0, + displayOptions: { + show: { + object: [ + 'table', + ], + action: [ + 'insert', + ], + }, + }, + }, + { + displayName: 'Columns', + name: 'columns', + type: 'number', + description: 'The number of columns in the table.', + default: 0, + displayOptions: { + show: { + object: [ + 'table', + ], + action: [ + 'insert', + ], + }, + }, + }, + // insert text + { + displayName: 'Insert Location', + name: 'locationChoice', + type: 'options', + options: [ + { + name: 'At end of specific position', + value: 'endOfSegmentLocation', + description: 'Inserts the text at the end of a header, footer, footnote, or document body.', + }, + { + name: 'At index', + value: 'location', + }, + ], + description: 'The location where the text will be inserted.', + default: 'endOfSegmentLocation', + displayOptions: { + show: { + object: [ + 'text', + ], + action: [ + 'insert', + ], + }, + }, + }, + { + displayName: 'Index', + name: 'index', + type: 'number', + typeOptions: { + minValue: 1, + }, + description: 'The zero-based index, relative to the beginning of the specified segment.', + displayOptions: { + show: { + locationChoice: [ + 'location', + ], + object: [ + 'text', + ], + action: [ + 'insert', + ], + }, + }, + default: 1, + }, + { + displayName: 'Text', + name: 'text', + type: 'string', + description: 'The text to insert in the document.', + default: '', + displayOptions: { + show: { + object: [ + 'text', + ], + action: [ + 'insert', + ], + }, + }, + }, + // replace all text + { + displayName: 'Old Text', + name: 'text', + type: 'string', + description: 'The text to search for in the document.', + default: '', + displayOptions: { + show: { + object: [ + 'text', + ], + action: [ + 'replaceAll', + ], + }, + }, + }, + { + displayName: 'New Text', + name: 'replaceText', + type: 'string', + description: 'The text that will replace the matched text.', + default: '', + displayOptions: { + show: { + object: [ + 'text', + ], + action: [ + 'replaceAll', + ], + }, + }, + }, + { + displayName: 'Match Case', + name: 'matchCase', + type: 'boolean', + description: 'Indicates whether the search should respect case sensitivity.', + default: false, + displayOptions: { + show: { + object: [ + 'text', + ], + action: [ + 'replaceAll', + ], + }, + }, + }, + // Shared Segment inputs for Delete action + { + displayName: 'Insert Segment', + name: 'insertSegment', + type: 'options', + options: [ + { + name: 'Header', + value: 'header', + }, + { + name: 'Body', + value: 'body', + }, + { + name: 'Footer', + value: 'footer', + }, + ], + description: 'The location where to create the object.', + default: 'body', + displayOptions: { + show: { + object: [ + 'paragraphBullets', + 'tableColumn', + 'tableRow', + ], + action: [ + 'delete', + ], + }, + }, + }, + { + displayName: 'Segment ID', + name: 'segmentId', + type: 'string', + description: 'The ID of the header, footer or footnote. The Document → Get operation lists all segment IDs (make sure you disable the simple toggle).', + default: '', + displayOptions: { + show: { + object: [ + 'paragraphBullets', + 'tableColumn', + 'tableRow', + ], + action: [ + 'delete', + ], + }, + hide: { + insertSegment: [ + 'body', + ], + }, + }, + }, + // Shared inputs for paragraph bullets + { + displayName: 'Start Index', + name: 'startIndex', + type: 'number', + description: 'The zero-based start index of this range.', + default: 0, + displayOptions: { + show: { + object: [ + 'paragraphBullets', + ], + }, + }, + }, + { + displayName: 'End Index', + name: 'endIndex', + type: 'number', + description: 'The zero-based end index of this range.', + default: 0, + displayOptions: { + show: { + object: [ + 'paragraphBullets', + ], + }, + }, + }, + // Shared inputs for table column/row + { + displayName: 'Insert Position', + name: 'insertPosition', + type: 'options', + options: [ + { + name: 'Before content at index', + value: false, + }, + { + name: 'After content at index', + value: true, + }, + ], + default: true, + displayOptions: { + show: { + object: [ + 'tableColumn', + 'tableRow', + ], + action: [ + 'insert', + ], + }, + }, + }, + { + displayName: 'Index', + name: 'index', + type: 'number', + description: 'The zero-based index, relative to the beginning of the specified segment (use index + 1 to refer to a table).', + default: 1, + typeOptions: { + minValue: 1, + }, + displayOptions: { + show: { + object: [ + 'tableColumn', + 'tableRow', + ], + }, + }, + }, + { + displayName: 'Row Index', + name: 'rowIndex', + type: 'number', + description: 'The zero-based row index.', + default: 0, + displayOptions: { + show: { + object: [ + 'tableColumn', + 'tableRow', + ], + }, + }, + }, + { + displayName: 'Column Index', + name: 'columnIndex', + type: 'number', + description: 'The zero-based column index.', + default: 0, + displayOptions: { + show: { + object: [ + 'tableColumn', + 'tableRow', + ], + }, + }, + }, + ], + }, + ], + }, + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'fixedCollection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'document', + ], + }, + }, + options: [ + { + displayName: 'Write Control Object', + name: 'writeControlObject', + values: [ + { + displayName: 'Revision mode', + name: 'control', + type: 'options', + options: [ + { + name: 'Target', + value: 'targetRevisionId', + description: 'Apply changes to the latest revision. Otherwise changes will not be processed.', + }, + { + name: 'Required', + value: 'requiredRevisionId', + description: 'Apply changes to the provided revision while incorporating other collaborators\' changes. This mode is used for the recent revision, Otherwise changes will not be processed.', + }, + ], + default: 'requiredRevisionId', + description: 'Determines how the changes are applied to the revision.', + }, + { + displayName: 'Revision ID', + name: 'value', + type: 'string', + default: '', + }, + ], + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Google/Docs/GenericFunctions.ts b/packages/nodes-base/nodes/Google/Docs/GenericFunctions.ts new file mode 100644 index 0000000000..c232921f51 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Docs/GenericFunctions.ts @@ -0,0 +1,142 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, + NodeApiError, + NodeOperationError, +} from 'n8n-workflow'; + +import * as moment from 'moment-timezone'; + +import * as jwt from 'jsonwebtoken'; + +export async function googleApiRequest( + this: IExecuteFunctions | ILoadOptionsFunctions, + method: string, + endpoint: string, + body: IDataObject = {}, + qs?: IDataObject, + uri?: string, +) { + const authenticationMethod = this.getNodeParameter('authentication', 0, 'serviceAccount') as string; + + const options: OptionsWithUri = { + headers: { + 'Content-Type': 'application/json', + }, + method, + body, + qs, + uri: uri || `https://docs.googleapis.com/v1${endpoint}`, + json: true, + }; + + if (!Object.keys(body).length) { + delete options.body; + } + try { + + if (authenticationMethod === 'serviceAccount') { + const credentials = this.getCredentials('googleApi'); + + if (credentials === undefined) { + throw new NodeOperationError(this.getNode(), 'No credentials got returned!'); + } + + const { access_token } = await getAccessToken.call(this, credentials as IDataObject); + + options.headers!.Authorization = `Bearer ${access_token}`; + return await this.helpers.request!(options); + } else { + //@ts-ignore + return await this.helpers.requestOAuth2.call(this, 'googleDocsOAuth2Api', options); + } + } catch (error) { + throw new NodeApiError(this.getNode(), error); + } +} + +export async function googleApiRequestAllItems(this: IExecuteFunctions | ILoadOptionsFunctions, propertyName: string, method: string, endpoint: string, body: IDataObject = {}, qs?: IDataObject, uri?: string): Promise { // tslint:disable-line:no-any + + const returnData: IDataObject[] = []; + + let responseData; + const query: IDataObject = { ...qs }; + query.maxResults = 100; + query.pageSize = 100; + + do { + responseData = await googleApiRequest.call(this, method, endpoint, body, query, uri); + query.pageToken = responseData['nextPageToken']; + returnData.push.apply(returnData, responseData[propertyName]); + } while ( + responseData['nextPageToken'] !== undefined && + responseData['nextPageToken'] !== '' + ); + + return returnData; +} + +function getAccessToken(this: IExecuteFunctions | ILoadOptionsFunctions, credentials: IDataObject): Promise { + //https://developers.google.com/identity/protocols/oauth2/service-account#httprest + + const scopes = [ + 'https://www.googleapis.com/auth/documents', + 'https://www.googleapis.com/auth/drive', + 'https://www.googleapis.com/auth/drive.file', + ]; + + const now = moment().unix(); + + const signature = jwt.sign( + { + 'iss': credentials.email as string, + 'sub': credentials.delegatedEmail || credentials.email as string, + 'scope': scopes.join(' '), + 'aud': `https://oauth2.googleapis.com/token`, + 'iat': now, + 'exp': now + 3600, + }, + credentials.privateKey as string, + { + algorithm: 'RS256', + header: { + 'kid': credentials.privateKey as string, + 'typ': 'JWT', + 'alg': 'RS256', + }, + }, + ); + + const options: OptionsWithUri = { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + method: 'POST', + form: { + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: signature, + }, + uri: 'https://oauth2.googleapis.com/token', + json: true, + }; + + return this.helpers.request!(options); +} + +export const hasKeys = (obj = {}) => Object.keys(obj).length > 0; +export const extractID = (url: string) => { + const regex = new RegExp('https://docs.google.com/document/d/([a-zA-Z0-9-_]+)/'); + const results = regex.exec(url); + return results ? results[1] : undefined; +}; +export const upperFirst = (str: string) => { + return str[0].toUpperCase() + str.substr(1); +}; diff --git a/packages/nodes-base/nodes/Google/Docs/GoogleDocs.node.json b/packages/nodes-base/nodes/Google/Docs/GoogleDocs.node.json new file mode 100644 index 0000000000..6d36a6a944 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Docs/GoogleDocs.node.json @@ -0,0 +1,20 @@ +{ + "node": "n8n-nodes-base.googleDocs", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Miscellaneous" + ], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/google" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.googleDocs/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/Google/Docs/GoogleDocs.node.ts b/packages/nodes-base/nodes/Google/Docs/GoogleDocs.node.ts new file mode 100644 index 0000000000..2a94eb1097 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Docs/GoogleDocs.node.ts @@ -0,0 +1,461 @@ +import { + IExecuteFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeDescription, + NodeApiError, +} from 'n8n-workflow'; + +import { + extractID, + googleApiRequest, + googleApiRequestAllItems, + hasKeys, + upperFirst, +} from './GenericFunctions'; + +import { + documentFields, + documentOperations, +} from './DocumentDescription'; + +import { + IUpdateBody, + IUpdateFields, +} from './interfaces'; + +export class GoogleDocs implements INodeType { + description: INodeTypeDescription = { + displayName: 'Google Docs', + name: 'googleDocs', + icon: 'file:googleDocs.svg', + group: ['input'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Google Docs API.', + defaults: { + name: 'Google Docs', + color: '#1a73e8', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'googleApi', + required: true, + displayOptions: { + show: { + authentication: [ + 'serviceAccount', + ], + }, + }, + }, + { + name: 'googleDocsOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, + ], + properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Service Account', + value: 'serviceAccount', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'serviceAccount', + }, + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Document', + value: 'document', + }, + ], + default: 'document', + description: 'The resource to operate on.', + }, + ...documentOperations, + ...documentFields, + ], + }; + methods = { + loadOptions: { + // Get all the drives to display them to user so that he can + // select them easily + async getDrives(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = [ + { + name: 'My Drive', + value: 'myDrive', + }, + { + name: 'Shared with me', + value: 'sharedWithMe', + }, + ]; + let drives; + try { + drives = await googleApiRequestAllItems.call(this, 'drives', 'GET', '', {}, {}, 'https://www.googleapis.com/drive/v3/drives'); + } catch (error) { + throw new NodeApiError(this.getNode(), error, { message: 'Error in loading Drives' }); + } + + for (const drive of drives) { + returnData.push({ + name: drive.name as string, + value: drive.id as string, + }); + } + return returnData; + }, + async getFolders(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = [ + { + name: '/', + value: 'default', + }, + ]; + const driveId = this.getNodeParameter('driveId'); + + const qs = { + q: `mimeType = \'application/vnd.google-apps.folder\' ${driveId === 'sharedWithMe' ? 'and sharedWithMe = true' : ' and \'root\' in parents'}`, + ...(driveId && driveId !== 'myDrive' && driveId !== 'sharedWithMe') ? { driveId } : {}, + }; + let folders; + + try { + folders = await googleApiRequestAllItems.call(this, 'files', 'GET', '', {}, qs, 'https://www.googleapis.com/drive/v3/files'); + } catch (error) { + throw new NodeApiError(this.getNode(), error, { message: 'Error in loading Folders' }); + } + + for (const folder of folders) { + returnData.push({ + name: folder.name as string, + value: folder.id as string, + }); + } + return returnData; + }, + }, + }; + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + const length = items.length; + + let responseData; + + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + + for (let i = 0; i < length; i++) { + + try { + + if (resource === 'document') { + + if (operation === 'create') { + + // https://developers.google.com/docs/api/reference/rest/v1/documents/create + + const folderId = this.getNodeParameter('folderId', i) as string; + + const body: IDataObject = { + name: this.getNodeParameter('title', i) as string, + mimeType: 'application/vnd.google-apps.document', + ...(folderId && folderId !== 'default') ? { parents: [folderId] } : {}, + }; + + responseData = await googleApiRequest.call(this, 'POST', '', body, {}, 'https://www.googleapis.com/drive/v3/files'); + + } else if (operation === 'get') { + + // https://developers.google.com/docs/api/reference/rest/v1/documents/get + + const documentURL = this.getNodeParameter('documentURL', i) as string; + const simple = this.getNodeParameter('simple', i) as boolean; + let documentId = extractID(documentURL); + + if (!documentId) { + documentId = documentURL; + } + responseData = await googleApiRequest.call(this, 'GET', `/documents/${documentId}`); + if (simple) { + + const content = (responseData.body.content as IDataObject[]) + .reduce((arr: string[], contentItem) => { + if (contentItem && contentItem.paragraph) { + const texts = ((contentItem.paragraph as IDataObject).elements as IDataObject[]) + .map(element => { + if (element && element.textRun) { + return (element.textRun as IDataObject).content as string; + } + }) as string[]; + arr = [...arr, ...texts]; + } + return arr; + }, []) + .join(''); + + responseData = { + documentId, + content, + }; + + } + + } else if (operation === 'update') { + + // https://developers.google.com/docs/api/reference/rest/v1/documents/batchUpdate + + const documentURL = this.getNodeParameter('documentURL', i) as string; + let documentId = extractID(documentURL); + const simple = this.getNodeParameter('simple', i) as boolean; + const actionsUi = this.getNodeParameter('actionsUi', i) as { + actionFields: IDataObject[] + }; + const { writeControlObject } = this.getNodeParameter('updateFields', i) as IUpdateFields; + + if (!documentId) { + documentId = documentURL; + } + + const body = { + requests: [], + } as IUpdateBody; + + if (hasKeys(writeControlObject)) { + const { control, value } = writeControlObject; + body.writeControl = { + [control]: value, + }; + } + + if (actionsUi) { + + let requestBody: IDataObject; + actionsUi.actionFields.forEach(actionField => { + const { action, object } = actionField; + if (object === 'positionedObject') { + if (action === 'delete') { + requestBody = { + objectId: actionField.objectId, + }; + } + + } else if (object === 'pageBreak') { + + if (action === 'insert') { + const { insertSegment, segmentId, locationChoice, index } = actionField; + requestBody = { + [locationChoice as string]: { + segmentId: (insertSegment !== 'body') ? segmentId : '', + ...(locationChoice === 'location') ? { index } : {}, + }, + }; + } + + } else if (object === 'table') { + + if (action === 'insert') { + const { rows, columns, insertSegment, locationChoice, segmentId, index } = actionField; + requestBody = { + rows, + columns, + [locationChoice as string]: { + segmentId: (insertSegment !== 'body') ? segmentId : '', + ...(locationChoice === 'location') ? { index } : {}, + }, + }; + } + + } else if (object === 'footer') { + + if (action === 'create') { + const { insertSegment, locationChoice, segmentId, index } = actionField; + requestBody = { + type: 'DEFAULT', + sectionBreakLocation: { + segmentId: (insertSegment !== 'body') ? segmentId : '', + ...(locationChoice === 'location') ? { index } : {}, + }, + }; + } else if (action === 'delete') { + requestBody = { + footerId: actionField.footerId, + }; + } + + } else if (object === 'header') { + + if (action === 'create') { + const { insertSegment, locationChoice, segmentId, index } = actionField; + requestBody = { + type: 'DEFAULT', + sectionBreakLocation: { + segmentId: (insertSegment !== 'body') ? segmentId : '', + ...(locationChoice === 'location') ? { index } : {}, + }, + }; + } else if (action === 'delete') { + requestBody = { + headerId: actionField.headerId, + }; + } + + } else if (object === 'tableColumn') { + + if (action === 'insert') { + const { insertPosition, rowIndex, columnIndex, insertSegment, segmentId, index } = actionField; + requestBody = { + insertRight: insertPosition, + tableCellLocation: { + rowIndex, + columnIndex, + tableStartLocation: { segmentId: (insertSegment !== 'body') ? segmentId : '', index, }, + }, + }; + } else if (action === 'delete') { + const { rowIndex, columnIndex, insertSegment, segmentId, index } = actionField; + requestBody = { + tableCellLocation: { + rowIndex, + columnIndex, + tableStartLocation: { segmentId: (insertSegment !== 'body') ? segmentId : '', index, }, + }, + }; + } + + } else if (object === 'tableRow') { + + if (action === 'insert') { + const { insertPosition, rowIndex, columnIndex, insertSegment, segmentId, index } = actionField; + requestBody = { + insertBelow: insertPosition, + tableCellLocation: { + rowIndex, + columnIndex, + tableStartLocation: { segmentId: (insertSegment !== 'body') ? segmentId : '', index, }, + }, + }; + } else if (action === 'delete') { + const { rowIndex, columnIndex, insertSegment, segmentId, index } = actionField; + requestBody = { + tableCellLocation: { + rowIndex, + columnIndex, + tableStartLocation: { segmentId: (insertSegment !== 'body') ? segmentId : '', index, }, + }, + }; + } + + } else if (object === 'text') { + + if (action === 'insert') { + const { text, locationChoice, insertSegment, segmentId, index } = actionField; + requestBody = { + text, + [locationChoice as string]: { + segmentId: (insertSegment !== 'body') ? segmentId : '', + ...(locationChoice === 'location') ? { index } : {}, + }, + }; + } else if (action === 'replaceAll') { + const { text, replaceText, matchCase } = actionField; + requestBody = { + replaceText, + containsText: { text, matchCase }, + }; + } + + } else if (object === 'paragraphBullets') { + if (action === 'create') { + const { bulletPreset, startIndex, insertSegment, segmentId, endIndex } = actionField; + requestBody = { + bulletPreset, + range: { segmentId: (insertSegment !== 'body') ? segmentId : '', startIndex, endIndex }, + }; + } else if (action === 'delete') { + const { startIndex, insertSegment, segmentId, endIndex } = actionField; + requestBody = { + range: { segmentId: (insertSegment !== 'body') ? segmentId : '', startIndex, endIndex }, + }; + } + } else if (object === 'namedRange') { + if (action === 'create') { + const { name, insertSegment, segmentId, startIndex, endIndex } = actionField; + requestBody = { + name, + range: { segmentId: (insertSegment !== 'body') ? segmentId : '', startIndex, endIndex }, + }; + } else if (action === 'delete') { + const { namedRangeReference, value } = actionField; + requestBody = { + [namedRangeReference as string]: value, + }; + } + } + + body.requests.push({ + [`${action}${upperFirst(object as string)}`]: requestBody, + }); + + }); + } + + responseData = await googleApiRequest.call(this, 'POST', `/documents/${documentId}:batchUpdate`, body); + + if (simple === true) { + if (Object.keys(responseData.replies[0]).length !== 0) { + const key = Object.keys(responseData.replies[0])[0]; + responseData = responseData.replies[0][key]; + } else { + responseData = {}; + } + } + responseData.documentId = documentId; + } + } + + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.message }); + continue; + } + throw error; + } + + Array.isArray(responseData) + ? returnData.push(...responseData) + : returnData.push(responseData); + } + + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/Google/Docs/googleDocs.svg b/packages/nodes-base/nodes/Google/Docs/googleDocs.svg new file mode 100644 index 0000000000..c35f74f7cc --- /dev/null +++ b/packages/nodes-base/nodes/Google/Docs/googleDocs.svg @@ -0,0 +1 @@ + diff --git a/packages/nodes-base/nodes/Google/Docs/interfaces.d.ts b/packages/nodes-base/nodes/Google/Docs/interfaces.d.ts new file mode 100644 index 0000000000..e1a3616e00 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Docs/interfaces.d.ts @@ -0,0 +1,13 @@ +import { IDataObject } from 'n8n-workflow'; + +export interface IUpdateBody extends IDataObject { + requests: IDataObject[]; + writeControl?: { [key: string]: string }; +} + +export interface IUpdateFields { + writeControlObject: { + control: string, + value: string, + }; +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 34d8f43b62..5b1fbf7fe7 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -100,6 +100,7 @@ "dist/credentials/GoogleBooksOAuth2Api.credentials.js", "dist/credentials/GoogleCalendarOAuth2Api.credentials.js", "dist/credentials/GoogleContactsOAuth2Api.credentials.js", + "dist/credentials/GoogleDocsOAuth2Api.credentials.js", "dist/credentials/GoogleCloudNaturalLanguageOAuth2Api.credentials.js", "dist/credentials/GoogleDriveOAuth2Api.credentials.js", "dist/credentials/GoogleFirebaseCloudFirestoreOAuth2Api.credentials.js", @@ -381,6 +382,7 @@ "dist/nodes/Google/Calendar/GoogleCalendar.node.js", "dist/nodes/Google/CloudNaturalLanguage/GoogleCloudNaturalLanguage.node.js", "dist/nodes/Google/Contacts/GoogleContacts.node.js", + "dist/nodes/Google/Docs/GoogleDocs.node.js", "dist/nodes/Google/Drive/GoogleDrive.node.js", "dist/nodes/Google/Firebase/CloudFirestore/CloudFirestore.node.js", "dist/nodes/Google/Firebase/RealtimeDatabase/RealtimeDatabase.node.js",