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",