diff --git a/packages/nodes-base/nodes/Confluence/Confluence.node.ts b/packages/nodes-base/nodes/Confluence/Confluence.node.ts index e6b6d65bd4f..0db62508ab8 100644 --- a/packages/nodes-base/nodes/Confluence/Confluence.node.ts +++ b/packages/nodes-base/nodes/Confluence/Confluence.node.ts @@ -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); } diff --git a/packages/nodes-base/nodes/Confluence/actions/common.ts b/packages/nodes-base/nodes/Confluence/actions/common.ts new file mode 100644 index 00000000000..bc05a7a6249 --- /dev/null +++ b/packages/nodes-base/nodes/Confluence/actions/common.ts @@ -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/', + }, + }, + ], + 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(); + +export function clearSpaceKeyCache(): void { + spaceKeyCache.clear(); +} + +export async function resolveSpaceKey( + this: IExecuteFunctions | ILoadOptionsFunctions, + spaceId: string, +): Promise { + // 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; + } +} diff --git a/packages/nodes-base/nodes/Confluence/methods/index.ts b/packages/nodes-base/nodes/Confluence/methods/index.ts new file mode 100644 index 00000000000..c7fb720e474 --- /dev/null +++ b/packages/nodes-base/nodes/Confluence/methods/index.ts @@ -0,0 +1 @@ +export * as listSearch from './listSearch'; diff --git a/packages/nodes-base/nodes/Confluence/methods/listSearch.ts b/packages/nodes-base/nodes/Confluence/methods/listSearch.ts new file mode 100644 index 00000000000..b3937dd8b81 --- /dev/null +++ b/packages/nodes-base/nodes/Confluence/methods/listSearch.ts @@ -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 { + 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 { + 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(); + 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 { + 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), + }; +} diff --git a/packages/nodes-base/nodes/Confluence/test/methods/listSearch.test.ts b/packages/nodes-base/nodes/Confluence/test/methods/listSearch.test.ts new file mode 100644 index 00000000000..17270609750 --- /dev/null +++ b/packages/nodes-base/nodes/Confluence/test/methods/listSearch.test.ts @@ -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(); + 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(); + 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(); + }); + + 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=='); + }); +});