diff --git a/packages/nodes-base/nodes/Confluence/actions/common.ts b/packages/nodes-base/nodes/Confluence/actions/common.ts
index bc05a7a6249..f62ee396fe5 100644
--- a/packages/nodes-base/nodes/Confluence/actions/common.ts
+++ b/packages/nodes-base/nodes/Confluence/actions/common.ts
@@ -76,6 +76,30 @@ export const pageRLC: INodeProperties = {
export type ConfluenceBodyFormat = 'storage' | 'atlas_doc_format' | 'plainText';
+export const bodyFormatOption: INodeProperties = {
+ displayName: 'Body Format',
+ name: 'bodyFormat',
+ type: 'options',
+ options: [
+ {
+ name: 'Atlas Doc Format',
+ value: 'atlas_doc_format',
+ description: 'The ADF JSON representation',
+ },
+ {
+ name: 'Plain Text',
+ value: 'plainText',
+ description: 'Text extracted from the ADF body (dynamic macros carry no text)',
+ },
+ {
+ name: 'Storage',
+ value: 'storage',
+ description: 'The raw storage-format XHTML',
+ },
+ ],
+ default: 'storage',
+};
+
export const spaceRLC: INodeProperties = {
displayName: 'Space',
name: 'space',
diff --git a/packages/nodes-base/nodes/Confluence/actions/description.ts b/packages/nodes-base/nodes/Confluence/actions/description.ts
index 36d62c98eea..229cd5d95b2 100644
--- a/packages/nodes-base/nodes/Confluence/actions/description.ts
+++ b/packages/nodes-base/nodes/Confluence/actions/description.ts
@@ -2,6 +2,7 @@
import type { INodeTypeDescription } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
+import * as page from './page';
import { CONFLUENCE_CREDENTIAL_NAME } from '../transport';
export const confluenceNodeDescription: INodeTypeDescription = {
@@ -10,6 +11,7 @@ export const confluenceNodeDescription: INodeTypeDescription = {
icon: 'file:confluence.svg',
group: ['transform'],
version: 1,
+ subtitle: '={{ $parameter["operation"] + ": " + $parameter["resource"] }}',
description: 'Interact with the Confluence Cloud API',
defaults: {
name: 'Confluence',
@@ -24,5 +26,20 @@ export const confluenceNodeDescription: INodeTypeDescription = {
required: true,
},
],
- properties: [],
+ properties: [
+ {
+ displayName: 'Resource',
+ name: 'resource',
+ type: 'options',
+ noDataExpression: true,
+ options: [
+ {
+ name: 'Page',
+ value: 'page',
+ },
+ ],
+ default: 'page',
+ },
+ ...page.description,
+ ],
};
diff --git a/packages/nodes-base/nodes/Confluence/actions/page/bodyEnvelope.ts b/packages/nodes-base/nodes/Confluence/actions/page/bodyEnvelope.ts
new file mode 100644
index 00000000000..c202a33b92f
--- /dev/null
+++ b/packages/nodes-base/nodes/Confluence/actions/page/bodyEnvelope.ts
@@ -0,0 +1,148 @@
+import type { IDataObject, IExecuteFunctions, INodeProperties } from 'n8n-workflow';
+import { NodeOperationError } from 'n8n-workflow';
+
+import type { ConfluenceBodyFormat } from '../common';
+import { bodyFormatOption } from '../common';
+
+export interface ConfluenceBodyEnvelope extends IDataObject {
+ representation: 'storage' | 'atlas_doc_format';
+ value: string;
+}
+
+export function bodyProperties(operations: string[]): INodeProperties[] {
+ const show = { resource: ['page'], operation: operations };
+ return [
+ {
+ ...bodyFormatOption,
+ default: 'plainText',
+ description: 'How the page content below is interpreted',
+ displayOptions: { show },
+ // Same values as the shared selector; write-oriented descriptions
+ options: [
+ {
+ name: 'Atlas Doc Format',
+ value: 'atlas_doc_format',
+ description: 'Raw Atlassian Document Format JSON document',
+ },
+ {
+ name: 'Plain Text',
+ value: 'plainText',
+ description: 'Text is wrapped in paragraph blocks; no markup needed',
+ },
+ {
+ name: 'Storage',
+ value: 'storage',
+ description: 'Confluence storage-format XHTML, e.g.
Title
Text
',
+ },
+ ],
+ },
+ {
+ displayName: 'Body',
+ name: 'bodyPlainText',
+ type: 'string',
+ typeOptions: { rows: 4 },
+ default: '',
+ description:
+ 'Page content as plain text; each line becomes a paragraph. Blank lines and leading whitespace are removed.',
+ displayOptions: { show: { ...show, bodyFormat: ['plainText'] } },
+ },
+ {
+ displayName: 'Body (Storage HTML)',
+ name: 'bodyStorage',
+ type: 'string',
+ typeOptions: { rows: 4 },
+ default: '',
+ placeholder: 'Heading
Text
',
+ description: 'Page content in Confluence storage format',
+ displayOptions: { show: { ...show, bodyFormat: ['storage'] } },
+ },
+ {
+ displayName: 'Body (ADF JSON)',
+ name: 'bodyAdf',
+ type: 'json',
+ default: '',
+ placeholder: '{ "type": "doc", "version": 1, "content": [] }',
+ description: 'Page content as an Atlassian Document Format document',
+ displayOptions: { show: { ...show, bodyFormat: ['atlas_doc_format'] } },
+ },
+ ];
+}
+
+const HTML_ESCAPES: Record = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+};
+
+function escapeHtml(text: string): string {
+ return text.replace(/[&<>"]/g, (char) => HTML_ESCAPES[char]);
+}
+
+export function buildBodyEnvelope(
+ format: ConfluenceBodyFormat,
+ content: unknown,
+): ConfluenceBodyEnvelope {
+ switch (format) {
+ case 'plainText': {
+ if (typeof content === 'object' && content !== null) {
+ throw new Error(
+ 'Plain text body must be text, got an object. Use the ADF JSON format for document objects.',
+ );
+ }
+ const text = content === null || content === undefined ? '' : String(content);
+ const paragraphs = text
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter((line) => line !== '')
+ .map((line) => `${escapeHtml(line)}
`);
+ return { representation: 'storage', value: paragraphs.join('') };
+ }
+
+ case 'storage': {
+ if (typeof content !== 'string') {
+ throw new Error('Storage (HTML) body must be a string of Confluence storage-format markup');
+ }
+ return { representation: 'storage', value: content };
+ }
+
+ case 'atlas_doc_format': {
+ let parsed: unknown = content;
+ if (typeof content === 'string') {
+ if (content.trim() === '') {
+ throw new Error('ADF JSON body is empty. Provide an ADF document object.');
+ }
+ try {
+ parsed = JSON.parse(content);
+ } catch {
+ throw new Error('ADF JSON body is not valid JSON');
+ }
+ }
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ throw new Error('ADF JSON body must be a JSON object, e.g. { "type": "doc", ... }');
+ }
+ return { representation: 'atlas_doc_format', value: JSON.stringify(parsed) };
+ }
+
+ default:
+ throw new Error(`Unsupported body format "${format as string}"`);
+ }
+}
+
+export function readBodyEnvelope(
+ ctx: IExecuteFunctions,
+ itemIndex: number,
+): ConfluenceBodyEnvelope {
+ const format = ctx.getNodeParameter('bodyFormat', itemIndex, 'plainText') as ConfluenceBodyFormat;
+ const fieldByFormat: Record = {
+ plainText: 'bodyPlainText',
+ storage: 'bodyStorage',
+ atlas_doc_format: 'bodyAdf',
+ };
+ const content = ctx.getNodeParameter(fieldByFormat[format], itemIndex, '');
+ try {
+ return buildBodyEnvelope(format, content);
+ } catch (error) {
+ throw new NodeOperationError(ctx.getNode(), (error as Error).message, { itemIndex });
+ }
+}
diff --git a/packages/nodes-base/nodes/Confluence/actions/page/create.operation.ts b/packages/nodes-base/nodes/Confluence/actions/page/create.operation.ts
new file mode 100644
index 00000000000..4f6abd993c8
--- /dev/null
+++ b/packages/nodes-base/nodes/Confluence/actions/page/create.operation.ts
@@ -0,0 +1,149 @@
+import type {
+ IDataObject,
+ IExecuteFunctions,
+ INodeParameterResourceLocator,
+ INodeProperties,
+} from 'n8n-workflow';
+import { NodeApiError, NodeOperationError } from 'n8n-workflow';
+
+import { bodyProperties, readBodyEnvelope } from './bodyEnvelope';
+import { pageRLC, spaceRLC } from '../common';
+import { confluenceApiRequest } from '../../transport';
+
+const showOnCreate = { resource: ['page'], operation: ['create'] };
+
+export const description: INodeProperties[] = [
+ {
+ ...spaceRLC,
+ required: true,
+ description: 'The space to create the page in',
+ displayOptions: { show: showOnCreate },
+ },
+ {
+ displayName: 'Title',
+ name: 'title',
+ type: 'string',
+ default: '',
+ required: true,
+ placeholder: 'e.g. Weekly Report',
+ description: 'The title of the new page',
+ displayOptions: { show: showOnCreate },
+ },
+ ...bodyProperties(['create']),
+ {
+ ...pageRLC,
+ displayName: 'Parent Page',
+ name: 'parentPage',
+ required: false,
+ description:
+ 'The page to create the new page under. Leave empty to create under the space homepage.',
+ // By Title needs a title-to-ID resolver the create path does not have yet
+ modes: (pageRLC.modes ?? []).filter((mode) => mode.name !== 'title'),
+ displayOptions: {
+ show: showOnCreate,
+ // The API rejects root-level + parentId; hiding makes the combination unrepresentable
+ hide: { '/options.rootLevel': [true] },
+ },
+ },
+ {
+ displayName: 'Options',
+ name: 'options',
+ type: 'collection',
+ placeholder: 'Add Option',
+ default: {},
+ displayOptions: { show: showOnCreate },
+ options: [
+ {
+ displayName: 'Create as Draft',
+ name: 'createAsDraft',
+ type: 'boolean',
+ default: false,
+ description: 'Whether to create the page as a draft instead of publishing it',
+ },
+ {
+ displayName: 'Private',
+ name: 'private',
+ type: 'boolean',
+ default: false,
+ description:
+ 'Whether only the creating user can view and edit the page. The creator is the connected account, which needs permission to restrict content in the space.',
+ },
+ {
+ displayName: 'Root Level',
+ name: 'rootLevel',
+ type: 'boolean',
+ default: false,
+ description:
+ 'Whether to create the page at the space root, outside the space homepage tree. Cannot be combined with a parent page.',
+ },
+ ],
+ },
+];
+
+export async function execute(
+ this: IExecuteFunctions,
+ itemIndex: number,
+): Promise {
+ const spaceId = this.getNodeParameter('space', itemIndex, undefined, {
+ extractValue: true,
+ }) as string;
+ const options = this.getNodeParameter('options', itemIndex, {});
+
+ const rawTitle: unknown = this.getNodeParameter('title', itemIndex, '');
+ // Objects coerce to '' so validation rejects them instead of titling the page '[object Object]'
+ const title =
+ typeof rawTitle === 'string'
+ ? rawTitle.trim()
+ : rawTitle === null || rawTitle === undefined || typeof rawTitle === 'object'
+ ? ''
+ : String(rawTitle).trim();
+
+ if (!spaceId) {
+ throw new NodeOperationError(this.getNode(), 'Space is required', { itemIndex });
+ }
+ if (!title) {
+ throw new NodeOperationError(this.getNode(), 'Title is required', { itemIndex });
+ }
+
+ const body: IDataObject = {
+ spaceId,
+ status: options.createAsDraft ? 'draft' : 'current',
+ title,
+ body: readBodyEnvelope(this, itemIndex),
+ };
+
+ if (!options.rootLevel) {
+ const parentRef = this.getNodeParameter('parentPage', itemIndex, '') as
+ | INodeParameterResourceLocator
+ | string;
+ const rawParentValue =
+ typeof parentRef === 'object' && parentRef !== null ? parentRef.value : parentRef;
+ // The field is optional: an empty By URL value means "no parent", so only
+ // extract (and regex-validate) once something is set
+ if (String(rawParentValue ?? '').trim() !== '') {
+ const parentId = this.getNodeParameter('parentPage', itemIndex, '', {
+ extractValue: true,
+ }) as string;
+ if (parentId) body.parentId = parentId;
+ }
+ }
+
+ const qs: IDataObject = {};
+ if (options.private) qs.private = true;
+ if (options.rootLevel) qs['root-level'] = true;
+
+ try {
+ return await confluenceApiRequest.call(this, 'POST', '/wiki/api/v2/pages', body, qs);
+ } catch (error) {
+ // Private creation applies a content restriction under the hood, and Atlassian
+ // masks the failing permission/scope check as 404 on this endpoint
+ if (options.private && error instanceof NodeApiError && error.httpCode === '404') {
+ throw new NodeOperationError(this.getNode(), 'Could not create the page as private', {
+ itemIndex,
+ description:
+ 'Atlassian reports this as "not found", but it usually means the restriction step was refused: the connected user needs the "Add/Delete restrictions" permission in the space, and the credential\'s OAuth app must allow the content-restriction scopes. Try again without the Private option, or check the space permissions.',
+ });
+ }
+ throw error;
+ }
+}
diff --git a/packages/nodes-base/nodes/Confluence/actions/page/index.ts b/packages/nodes-base/nodes/Confluence/actions/page/index.ts
new file mode 100644
index 00000000000..5c392a13b01
--- /dev/null
+++ b/packages/nodes-base/nodes/Confluence/actions/page/index.ts
@@ -0,0 +1,29 @@
+import type { INodeProperties } from 'n8n-workflow';
+
+import * as create from './create.operation';
+
+export { create };
+
+export const description: INodeProperties[] = [
+ {
+ displayName: 'Operation',
+ name: 'operation',
+ type: 'options',
+ noDataExpression: true,
+ displayOptions: {
+ show: {
+ resource: ['page'],
+ },
+ },
+ options: [
+ {
+ name: 'Create',
+ value: 'create',
+ description: 'Create a new page in a space',
+ action: 'Create a page',
+ },
+ ],
+ default: 'create',
+ },
+ ...create.description,
+];
diff --git a/packages/nodes-base/nodes/Confluence/actions/router.ts b/packages/nodes-base/nodes/Confluence/actions/router.ts
index 4a4cf8920f5..6a3b690a91f 100644
--- a/packages/nodes-base/nodes/Confluence/actions/router.ts
+++ b/packages/nodes-base/nodes/Confluence/actions/router.ts
@@ -1,10 +1,11 @@
import type { IDataObject, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
+import * as page from './page';
+
/**
- * Compile-checked contract for operation modules. The router body (owned by ENT-125)
- * calls `..execute.call(this, i)` once per item, SharePoint v2
- * shape; the other op tickets (ENT-126/319/127/305/327/306) implement against this.
+ * Compile-checked contract for operation modules. The router calls
+ * `..execute.call(this, i)` once per item.
*/
export type ConfluenceOperation = (
this: IExecuteFunctions,
@@ -12,16 +13,41 @@ export type ConfluenceOperation = (
) => Promise;
export async function router(this: IExecuteFunctions): Promise {
- // Fallbacks: the shell ships properties: [], so these parameters don't exist yet
+ const items = this.getInputData();
const resource = this.getNodeParameter('resource', 0, '');
const operation = this.getNodeParameter('operation', 0, '');
- switch (resource) {
- // Op tickets (ENT-125/126/319/127/305/327/306) add their resource cases here
- default:
- throw new NodeOperationError(
- this.getNode(),
- `The operation "${resource}:${operation}" is not supported`,
+ const returnData: INodeExecutionData[] = [];
+
+ for (let i = 0; i < items.length; i++) {
+ try {
+ let responseData: IDataObject | IDataObject[];
+
+ switch (`${resource}:${operation}`) {
+ case 'page:create':
+ responseData = await page.create.execute.call(this, i);
+ break;
+ default:
+ throw new NodeOperationError(
+ this.getNode(),
+ `The operation "${resource}:${operation}" is not supported`,
+ );
+ }
+
+ const executionData = this.helpers.constructExecutionMetaData(
+ this.helpers.returnJsonArray(responseData),
+ { itemData: { item: i } },
);
+ returnData.push.apply(returnData, executionData);
+ } catch (error) {
+ if (this.continueOnFail()) {
+ const message = error instanceof Error ? error.message : String(error);
+ returnData.push({ json: { error: message }, pairedItem: { item: i } });
+ continue;
+ }
+ throw error;
+ }
}
+
+ return [returnData];
}
diff --git a/packages/nodes-base/nodes/Confluence/test/Confluence.node.test.ts b/packages/nodes-base/nodes/Confluence/test/Confluence.node.test.ts
index b9ca6224ed5..27e30c8277d 100644
--- a/packages/nodes-base/nodes/Confluence/test/Confluence.node.test.ts
+++ b/packages/nodes-base/nodes/Confluence/test/Confluence.node.test.ts
@@ -7,12 +7,19 @@ import { Confluence } from '../Confluence.node';
describe('Confluence Node', () => {
const node = new Confluence();
- it('should ship gated: hidden, no properties, not usable as a tool', () => {
+ it('should ship gated: hidden and not usable as a tool', () => {
expect(node.description.hidden).toBe(true);
- expect(node.description.properties).toEqual([]);
expect(node.description.usableAsTool).toBeUndefined();
});
+ it('should expose the page resource with the create operation', () => {
+ const resource = node.description.properties.find((p) => p.name === 'resource');
+ expect(resource?.options).toEqual([expect.objectContaining({ value: 'page' })]);
+
+ const operation = node.description.properties.find((p) => p.name === 'operation');
+ expect(operation?.options).toEqual([expect.objectContaining({ value: 'create' })]);
+ });
+
it('should reference the confluenceCloudOAuth2Api credential by name', () => {
expect(node.description.credentials).toEqual([
{ name: 'confluenceCloudOAuth2Api', required: true },
diff --git a/packages/nodes-base/nodes/Confluence/test/actions/page/bodyEnvelope.test.ts b/packages/nodes-base/nodes/Confluence/test/actions/page/bodyEnvelope.test.ts
new file mode 100644
index 00000000000..238c4db5111
--- /dev/null
+++ b/packages/nodes-base/nodes/Confluence/test/actions/page/bodyEnvelope.test.ts
@@ -0,0 +1,100 @@
+import { NodeOperationError } from 'n8n-workflow';
+
+import { buildBodyEnvelope, readBodyEnvelope } from '../../../actions/page/bodyEnvelope';
+import { mockExecuteCtx } from '../../shared';
+
+describe('buildBodyEnvelope', () => {
+ describe('plainText', () => {
+ it.each([
+ ['wraps a single line in a paragraph block', 'Hello world', 'Hello world
'],
+ [
+ 'wraps each non-blank line in its own paragraph',
+ 'First\nSecond\n\n \nThird',
+ 'First
Second
Third
',
+ ],
+ [
+ 'escapes markup so text cannot inject storage format',
+ '',
+ '<script>1 & 2 > "0"</script>
',
+ ],
+ ['produces an empty value for empty input', '', ''],
+ ['coerces number expression results instead of dropping them', 42, '42
'],
+ ['coerces boolean expression results instead of dropping them', true, 'true
'],
+ ])('%s', (_name, input, value) => {
+ expect(buildBodyEnvelope('plainText', input)).toEqual({ representation: 'storage', value });
+ });
+
+ it('rejects object content instead of creating an empty page', () => {
+ expect(() => buildBodyEnvelope('plainText', { some: 'object' })).toThrow(
+ 'must be text, got an object',
+ );
+ });
+ });
+
+ describe('storage', () => {
+ it('passes storage markup through verbatim', () => {
+ const html = 'Title
';
+ expect(buildBodyEnvelope('storage', html)).toEqual({
+ representation: 'storage',
+ value: html,
+ });
+ });
+
+ it('rejects non-string content', () => {
+ expect(() => buildBodyEnvelope('storage', { html: 'x
' })).toThrow(
+ 'Storage (HTML) body must be a string',
+ );
+ });
+ });
+
+ describe('atlas_doc_format', () => {
+ const doc = { type: 'doc', version: 1, content: [] };
+
+ it.each([
+ ['serializes an already-parsed document object', doc],
+ ['parses and re-serializes a JSON string', JSON.stringify(doc)],
+ ])('%s', (_name, input) => {
+ expect(buildBodyEnvelope('atlas_doc_format', input)).toEqual({
+ representation: 'atlas_doc_format',
+ value: JSON.stringify(doc),
+ });
+ });
+
+ it.each([
+ ['invalid JSON', '{not json', 'not valid JSON'],
+ ['an empty string', ' ', 'empty'],
+ ['an array document', JSON.stringify([1, 2]), 'must be a JSON object'],
+ ['a null document', 'null', 'must be a JSON object'],
+ ['a scalar document', '"text"', 'must be a JSON object'],
+ ])('rejects %s', (_name, input, message) => {
+ expect(() => buildBodyEnvelope('atlas_doc_format', input)).toThrow(message);
+ });
+ });
+});
+
+describe('readBodyEnvelope', () => {
+ it('reads the field matching the selected format', () => {
+ const ctx = mockExecuteCtx({
+ bodyFormat: 'storage',
+ bodyStorage: 'from storage field
',
+ bodyPlainText: 'from the wrong field',
+ });
+ expect(readBodyEnvelope(ctx, 0).value).toBe('from storage field
');
+ });
+
+ it('defaults to plain text', () => {
+ const ctx = mockExecuteCtx({ bodyPlainText: 'Hello' });
+ expect(readBodyEnvelope(ctx, 0)).toEqual({ representation: 'storage', value: 'Hello
' });
+ });
+
+ it('wraps envelope errors in a NodeOperationError carrying the item index', () => {
+ const ctx = mockExecuteCtx({ bodyFormat: 'atlas_doc_format', bodyAdf: '{broken' });
+ try {
+ readBodyEnvelope(ctx, 3);
+ throw new Error('expected readBodyEnvelope to throw');
+ } catch (error) {
+ expect(error).toBeInstanceOf(NodeOperationError);
+ expect((error as NodeOperationError).context.itemIndex).toBe(3);
+ }
+ });
+});
diff --git a/packages/nodes-base/nodes/Confluence/test/actions/page/create.operation.test.ts b/packages/nodes-base/nodes/Confluence/test/actions/page/create.operation.test.ts
new file mode 100644
index 00000000000..230964a8450
--- /dev/null
+++ b/packages/nodes-base/nodes/Confluence/test/actions/page/create.operation.test.ts
@@ -0,0 +1,154 @@
+import { NodeApiError, NodeOperationError } from 'n8n-workflow';
+import type { Mock } from 'vitest';
+
+import { execute } from '../../../actions/page/create.operation';
+import { confluenceApiRequest } from '../../../transport';
+import { mockExecuteCtx, testNode } from '../../shared';
+
+vi.mock('../../../transport', async (importOriginal) => ({
+ ...(await importOriginal