feat(Confluence Node): Add shared space and page selectors with list search (no-changelog) (#36293)

This commit is contained in:
Stephen Wright
2026-08-14 10:18:30 +00:00
committed by GitHub
parent 9b10652a28
commit a59d842714
5 changed files with 624 additions and 0 deletions
@@ -2,10 +2,13 @@ import type { IExecuteFunctions, INodeType, INodeTypeDescription } from 'n8n-wor
import { confluenceNodeDescription } from './actions/description';
import { router } from './actions/router';
import { listSearch } from './methods';
export class Confluence implements INodeType {
description: INodeTypeDescription = confluenceNodeDescription;
methods = { listSearch };
async execute(this: IExecuteFunctions) {
return await router.call(this);
}
@@ -0,0 +1,149 @@
import type {
IDataObject,
IExecuteFunctions,
ILoadOptionsFunctions,
INodeProperties,
} from 'n8n-workflow';
import { CONFLUENCE_CREDENTIAL_NAME, confluenceApiRequest } from '../transport';
/**
* Shared page-selection fields: operations spread `spaceRLC`/`pageRLC` and add
* their own displayOptions. An empty space leaves page lookups site-wide.
*/
export const pageRLC: INodeProperties = {
displayName: 'Page',
name: 'page',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
description: 'The page to operate on',
typeOptions: {
loadOptionsDependsOn: ['space.value'],
},
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'getPages',
searchable: true,
},
},
{
displayName: 'By URL',
name: 'url',
type: 'string',
placeholder: 'e.g. https://your-site.atlassian.net/wiki/spaces/DOCS/pages/123456/My+Page',
validation: [
{
type: 'regex',
properties: {
regex: '.*/pages/(?:edit-v2/)?[0-9]+.*',
errorMessage: 'The URL must contain /pages/<numeric page ID>',
},
},
],
extractValue: {
type: 'regex',
regex: '/pages/(?:edit-v2/)?([0-9]+)',
},
},
{
displayName: 'By ID',
name: 'id',
type: 'string',
placeholder: 'e.g. 123456',
validation: [
{
type: 'regex',
properties: {
regex: '^[0-9]+$',
errorMessage: 'The page ID must be numeric',
},
},
],
},
{
displayName: 'By Title',
name: 'title',
type: 'string',
placeholder: 'e.g. Project plan',
},
],
};
export type ConfluenceBodyFormat = 'storage' | 'atlas_doc_format' | 'plainText';
export const spaceRLC: INodeProperties = {
displayName: 'Space',
name: 'space',
type: 'resourceLocator',
default: { mode: 'list', value: '' },
description: 'The Confluence space',
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchSpaces',
searchable: true,
},
},
{
displayName: 'By ID',
name: 'id',
type: 'string',
placeholder: 'e.g. 98432',
validation: [
{
type: 'regex',
properties: {
regex: '^[0-9]+$',
errorMessage: 'The space ID must be numeric',
},
},
],
},
],
};
const spaceKeyCache = new Map<string, string>();
export function clearSpaceKeyCache(): void {
spaceKeyCache.clear();
}
export async function resolveSpaceKey(
this: IExecuteFunctions | ILoadOptionsFunctions,
spaceId: string,
): Promise<string | undefined> {
// Space IDs are only unique per site, so the cache is keyed per credential
const rawCredentialId = this.getNode().credentials?.[CONFLUENCE_CREDENTIAL_NAME]?.id;
const credentialId = typeof rawCredentialId === 'string' ? rawCredentialId : '';
const cacheKey = `${credentialId}:${spaceId}`;
const cached = spaceKeyCache.get(cacheKey);
if (cached !== undefined) return cached;
const space = await confluenceApiRequest.call(
this,
'GET',
`/wiki/api/v2/spaces/${encodeURIComponent(spaceId)}`,
);
if (typeof space.key !== 'string' || space.key === '') return undefined;
spaceKeyCache.set(cacheKey, space.key);
return space.key;
}
export function extractNextCursor(response: IDataObject): string | undefined {
const next = (response._links as IDataObject | undefined)?.next;
if (typeof next !== 'string' || next === '') return undefined;
try {
return new URL(next, 'https://api.atlassian.com').searchParams.get('cursor') ?? undefined;
} catch {
return undefined;
}
}
@@ -0,0 +1 @@
export * as listSearch from './listSearch';
@@ -0,0 +1,162 @@
import type {
IDataObject,
ILoadOptionsFunctions,
INodeListSearchItems,
INodeListSearchResult,
} from 'n8n-workflow';
import { extractNextCursor, resolveSpaceKey } from '../actions/common';
import { confluenceApiRequest } from '../transport';
interface SearchPage {
entries: IDataObject[];
base: string;
next?: string;
}
const SEARCH_PAGE_SIZE = 50;
const MAX_FILTERED_SEARCH_PAGES = 10;
const EMPTY_PAGE: SearchPage = { entries: [], base: '' };
export async function searchSpaces(
this: ILoadOptionsFunctions,
filter?: string,
paginationToken?: string,
): Promise<INodeListSearchResult> {
const filterLower = (filter ?? '').trim().toLowerCase();
const results: INodeListSearchItems[] = [];
let cursor = paginationToken;
// No server-side text filter on the v2 spaces list; fetch ahead so matches
// beyond the first page stay discoverable
for (let fetched = 0; fetched < MAX_FILTERED_SEARCH_PAGES; fetched++) {
const qs: IDataObject = { limit: SEARCH_PAGE_SIZE, sort: 'name', status: 'current' };
if (cursor !== undefined) qs.cursor = cursor;
const response = await confluenceApiRequest.call(this, 'GET', '/wiki/api/v2/spaces', {}, qs);
const entries = Array.isArray(response.results) ? (response.results as IDataObject[]) : [];
for (const space of entries) {
if (typeof space.id !== 'string' && typeof space.id !== 'number') continue;
if (typeof space.name !== 'string') continue;
if (filterLower !== '' && !space.name.toLowerCase().includes(filterLower)) continue;
const key = typeof space.key === 'string' && space.key !== '' ? ` (${space.key})` : '';
results.push({ name: `${space.name}${key}`, value: String(space.id) });
}
cursor = extractNextCursor(response);
if (cursor === undefined || filterLower === '' || results.length > 0) break;
}
return { results, paginationToken: cursor };
}
async function fetchSearchPage(
this: ILoadOptionsFunctions,
cql: string,
start?: number,
): Promise<SearchPage> {
const qs: IDataObject = { cql, limit: SEARCH_PAGE_SIZE };
if (start !== undefined) qs.start = start;
const response = await confluenceApiRequest.call(this, 'GET', '/wiki/rest/api/search', {}, qs);
const links = response._links as IDataObject | undefined;
return {
entries: Array.isArray(response.results) ? (response.results as IDataObject[]) : [],
base: typeof links?.base === 'string' ? links.base : '',
next: typeof links?.next === 'string' && links.next !== '' ? links.next : undefined,
};
}
function toPageItems(
entries: IDataObject[],
base: string,
withSpaceLabel: boolean,
): INodeListSearchItems[] {
const results: INodeListSearchItems[] = [];
const seenIds = new Set<string>();
for (const entry of entries) {
const content = entry.content as IDataObject | undefined;
if (content === undefined) continue;
if (typeof content.id !== 'string' && typeof content.id !== 'number') continue;
const id = String(content.id);
if (seenIds.has(id)) continue;
seenIds.add(id);
const title = typeof content.title === 'string' && content.title !== '' ? content.title : id;
// The space name disambiguates same-titled pages; redundant once scoped to one space
const container = entry.resultGlobalContainer as IDataObject | undefined;
const space =
withSpaceLabel && typeof container?.title === 'string' && container.title !== ''
? ` (${container.title})`
: '';
const webui = (content._links as IDataObject | undefined)?.webui;
const url = base !== '' && typeof webui === 'string' ? `${base}${webui}` : undefined;
results.push({ name: `${title}${space}`, value: id, url });
}
return results;
}
function nextStartToken(
next: string | undefined,
start: number,
count: number,
): string | undefined {
if (next === undefined) return undefined;
let parsed: string | null = null;
try {
parsed = new URL(next, 'https://api.atlassian.com').searchParams.get('start');
} catch {
parsed = null;
}
// A page can come back empty while next is still set; never repeat the same offset
return parsed ?? String(start + Math.max(count, 1));
}
function getScopedSpaceId(this: ILoadOptionsFunctions): string {
try {
const raw = this.getCurrentNodeParameter('space', { extractValue: true });
return typeof raw === 'string' || typeof raw === 'number' ? String(raw).trim() : '';
} catch {
return '';
}
}
export async function getPages(
this: ILoadOptionsFunctions,
filter?: string,
paginationToken?: string,
): Promise<INodeListSearchResult> {
const start = paginationToken === undefined ? 0 : Number(paginationToken);
const spaceId = getScopedSpaceId.call(this);
let spaceClause = '';
if (spaceId !== '') {
// CQL's space field matches by key, so the selected space ID is resolved first
const spaceKey = await resolveSpaceKey.call(this, spaceId);
if (spaceKey !== undefined) spaceClause = ` AND space = "${spaceKey}"`;
}
const escaped = (filter ?? '').replace(/(["\\])/g, '\\$1');
const cql =
escaped === ''
? `type=page${spaceClause} ORDER BY lastmodified DESC`
: `type=page${spaceClause} AND title ~ "${escaped}*" ORDER BY lastmodified DESC`;
// Exact-title pages can be buried behind newer prefix matches, so page one
// fetches them separately; toPageItems drops the overlap
const exact =
escaped !== '' && paginationToken === undefined
? await fetchSearchPage.call(this, `type=page${spaceClause} AND title = "${escaped}"`)
: EMPTY_PAGE;
const page = await fetchSearchPage.call(this, cql, start);
return {
results: toPageItems(
[...exact.entries, ...page.entries],
page.base || exact.base,
spaceId === '',
),
paginationToken: nextStartToken(page.next, start, page.entries.length),
};
}
@@ -0,0 +1,309 @@
import type { ILoadOptionsFunctions } from 'n8n-workflow';
import { mockDeep } from 'vitest-mock-extended';
import { clearSpaceKeyCache } from '../../actions/common';
import { getPages, searchSpaces } from '../../methods/listSearch';
import { confluenceApiRequest } from '../../transport';
vi.mock('../../transport', () => ({
CONFLUENCE_CREDENTIAL_NAME: 'confluenceCloudOAuth2Api',
confluenceApiRequest: vi.fn(),
}));
const apiRequest = vi.mocked(confluenceApiRequest);
describe('Confluence listSearch.getPages', () => {
let ctx: ILoadOptionsFunctions;
beforeEach(() => {
vi.clearAllMocks();
clearSpaceKeyCache();
ctx = mockDeep<ILoadOptionsFunctions>();
vi.mocked(ctx.getNode).mockReturnValue({
id: 'test-node',
name: 'Test Confluence Node',
type: 'n8n-nodes-base.confluence',
typeVersion: 1,
position: [0, 0],
parameters: {},
credentials: { confluenceCloudOAuth2Api: { id: 'cred-1', name: 'account' } },
});
});
it('lists recently modified pages when no filter is given', async () => {
apiRequest.mockResolvedValueOnce({
_links: { base: 'https://example.atlassian.net/wiki' },
results: [
{
content: { id: 123, title: 'Doc', _links: { webui: '/spaces/D/pages/123' } },
resultGlobalContainer: { title: 'Docs Space' },
},
{ title: 'entry without content is skipped' },
{ content: { id: 456 } },
],
});
const result = await getPages.call(ctx);
expect(apiRequest).toHaveBeenCalledWith(
'GET',
'/wiki/rest/api/search',
{},
{ cql: 'type=page ORDER BY lastmodified DESC', limit: 50, start: 0 },
);
expect(result).toEqual({
results: [
{
name: 'Doc (Docs Space)',
value: '123',
url: 'https://example.atlassian.net/wiki/spaces/D/pages/123',
},
// Title falls back to the ID, no space suffix, no URL without webui link
{ name: '456', value: '456', url: undefined },
],
paginationToken: undefined,
});
});
it('scopes the search to the selected space and drops the space label', async () => {
vi.mocked(ctx.getCurrentNodeParameter).mockReturnValue('999');
apiRequest.mockImplementation(async (_method, endpoint, _body, qs) => {
if (endpoint === '/wiki/api/v2/spaces/999') return { id: 999, key: 'DOCS' };
if (endpoint === '/wiki/rest/api/search') {
const cql = (qs as { cql: string }).cql;
if (cql === 'type=page AND space = "DOCS" AND title = "plan"') return { results: [] };
expect(cql).toBe(
'type=page AND space = "DOCS" AND title ~ "plan*" ORDER BY lastmodified DESC',
);
return {
results: [
{
content: { id: 123, title: 'Project Plan' },
resultGlobalContainer: { title: 'Docs Space' },
},
],
};
}
throw new Error(`unexpected endpoint ${endpoint}`);
});
const result = await getPages.call(ctx, 'plan');
expect(ctx.getCurrentNodeParameter).toHaveBeenCalledWith('space', { extractValue: true });
expect(result.results).toEqual([{ name: 'Project Plan', value: '123', url: undefined }]);
await getPages.call(ctx, 'plan');
const spaceLookups = apiRequest.mock.calls.filter(
([, endpoint]) => endpoint === '/wiki/api/v2/spaces/999',
);
expect(spaceLookups).toHaveLength(1);
});
it('does not reuse cached space keys across credentials', async () => {
apiRequest.mockImplementation(async (_method, endpoint) => {
if (endpoint === '/wiki/api/v2/spaces/999') return { id: 999, key: 'DOCS' };
if (endpoint === '/wiki/rest/api/search') return { results: [] };
throw new Error(`unexpected endpoint ${endpoint}`);
});
const createCtx = (credentialId: string) => {
const scopedCtx = mockDeep<ILoadOptionsFunctions>();
vi.mocked(scopedCtx.getCurrentNodeParameter).mockReturnValue('999');
scopedCtx.getNode.mockReturnValue({
id: 'test-node',
name: 'Test Confluence Node',
type: 'n8n-nodes-base.confluence',
typeVersion: 1,
position: [0, 0],
parameters: {},
credentials: { confluenceCloudOAuth2Api: { id: credentialId, name: 'account' } },
});
return scopedCtx;
};
await getPages.call(createCtx('cred-1'));
await getPages.call(createCtx('cred-1'));
await getPages.call(createCtx('cred-2'));
const spaceLookups = apiRequest.mock.calls.filter(
([, endpoint]) => endpoint === '/wiki/api/v2/spaces/999',
);
expect(spaceLookups).toHaveLength(2);
});
it('advances the offset even when a page comes back empty with a next link', async () => {
apiRequest.mockResolvedValueOnce({
results: [],
_links: { next: '/rest/api/search?cql=type%3Dpage' },
});
const result = await getPages.call(ctx, undefined, '50');
expect(result.paginationToken).toBe('51');
});
it('escapes quotes and backslashes in the CQL title filter', async () => {
apiRequest.mockResolvedValue({ results: [] });
await getPages.call(ctx, 'He said "hi" \\ back');
expect(apiRequest).toHaveBeenCalledWith(
'GET',
'/wiki/rest/api/search',
{},
expect.objectContaining({
cql: 'type=page AND title = "He said \\"hi\\" \\\\ back"',
}),
);
expect(apiRequest).toHaveBeenCalledWith(
'GET',
'/wiki/rest/api/search',
{},
expect.objectContaining({
cql: 'type=page AND title ~ "He said \\"hi\\" \\\\ back*" ORDER BY lastmodified DESC',
}),
);
});
it('puts the exact-title match ahead of prefix matches on the first page', async () => {
apiRequest.mockImplementation(async (_method, endpoint, _body, qs) => {
if (endpoint !== '/wiki/rest/api/search') throw new Error(`unexpected endpoint ${endpoint}`);
const cql = (qs as { cql: string }).cql;
if (cql === 'type=page AND title = "Notes"') {
return { results: [{ content: { id: 1, title: 'Notes' } }] };
}
expect(cql).toBe('type=page AND title ~ "Notes*" ORDER BY lastmodified DESC');
return {
results: [
{ content: { id: 2, title: 'Notes 2026' } },
{ content: { id: 1, title: 'Notes' } },
],
};
});
const result = await getPages.call(ctx, 'Notes');
expect(result.results.map((item) => item.value)).toEqual(['1', '2']);
});
it('skips the exact-title query on later pages', async () => {
apiRequest.mockResolvedValue({ results: [] });
await getPages.call(ctx, 'Notes', '50');
expect(apiRequest).toHaveBeenCalledTimes(1);
expect(apiRequest).toHaveBeenCalledWith(
'GET',
'/wiki/rest/api/search',
{},
expect.objectContaining({ start: 50 }),
);
});
it('resumes from the pagination token and returns the next one while more pages exist', async () => {
apiRequest.mockResolvedValueOnce({
_links: { next: '/rest/api/search?cql=…&start=52' },
results: [{ content: { id: 1 } }, { content: { id: 2 } }],
});
const result = await getPages.call(ctx, undefined, '50');
expect(apiRequest).toHaveBeenCalledWith(
'GET',
'/wiki/rest/api/search',
{},
expect.objectContaining({ start: 50 }),
);
expect(result.paginationToken).toBe('52');
});
});
describe('Confluence listSearch.searchSpaces', () => {
let ctx: ILoadOptionsFunctions;
beforeEach(() => {
vi.clearAllMocks();
ctx = mockDeep<ILoadOptionsFunctions>();
});
it('lists current spaces sorted by name, labeled with their key', async () => {
apiRequest.mockResolvedValueOnce({
results: [
{ id: 1, name: 'Docs', key: 'DOCS' },
{ name: 'entry without id is skipped' },
{ id: 2, name: 'Engineering' },
],
});
const result = await searchSpaces.call(ctx);
expect(apiRequest).toHaveBeenCalledWith(
'GET',
'/wiki/api/v2/spaces',
{},
{ limit: 50, sort: 'name', status: 'current' },
);
expect(result).toEqual({
results: [
{ name: 'Docs (DOCS)', value: '1' },
{ name: 'Engineering', value: '2' },
],
paginationToken: undefined,
});
});
it('filters the typed text client-side, case-insensitively', async () => {
apiRequest.mockResolvedValueOnce({
results: [
{ id: 1, name: 'Docs', key: 'DOCS' },
{ id: 2, name: 'Engineering' },
],
});
const result = await searchSpaces.call(ctx, 'doc');
expect(result.results).toEqual([{ name: 'Docs (DOCS)', value: '1' }]);
});
it('keeps fetching pages while a typed filter has no match yet', async () => {
apiRequest
.mockResolvedValueOnce({
results: [{ id: 1, name: 'Docs', key: 'DOCS' }],
_links: { next: '/wiki/api/v2/spaces?cursor=c2' },
})
.mockResolvedValueOnce({
results: [{ id: 3, name: 'Sales' }],
});
const result = await searchSpaces.call(ctx, 'sales');
expect(apiRequest).toHaveBeenCalledTimes(2);
expect(apiRequest).toHaveBeenNthCalledWith(
2,
'GET',
'/wiki/api/v2/spaces',
{},
expect.objectContaining({ cursor: 'c2' }),
);
expect(result).toEqual({
results: [{ name: 'Sales', value: '3' }],
paginationToken: undefined,
});
});
it('resumes from the pagination cursor and returns the next one', async () => {
apiRequest.mockResolvedValueOnce({
results: [{ id: 3, name: 'Sales' }],
_links: { next: '/wiki/api/v2/spaces?cursor=xyz%3D%3D' },
});
const result = await searchSpaces.call(ctx, undefined, 'abc==');
expect(apiRequest).toHaveBeenCalledWith(
'GET',
'/wiki/api/v2/spaces',
{},
expect.objectContaining({ cursor: 'abc==' }),
);
expect(result.paginationToken).toBe('xyz==');
});
});