Skip to content

Commit a59d842

Browse files
authored
feat(Confluence Node): Add shared space and page selectors with list search (no-changelog) (#36293)
1 parent 9b10652 commit a59d842

5 files changed

Lines changed: 624 additions & 0 deletions

File tree

packages/nodes-base/nodes/Confluence/Confluence.node.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ import type { IExecuteFunctions, INodeType, INodeTypeDescription } from 'n8n-wor
22

33
import { confluenceNodeDescription } from './actions/description';
44
import { router } from './actions/router';
5+
import { listSearch } from './methods';
56

67
export class Confluence implements INodeType {
78
description: INodeTypeDescription = confluenceNodeDescription;
89

10+
methods = { listSearch };
11+
912
async execute(this: IExecuteFunctions) {
1013
return await router.call(this);
1114
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import type {
2+
IDataObject,
3+
IExecuteFunctions,
4+
ILoadOptionsFunctions,
5+
INodeProperties,
6+
} from 'n8n-workflow';
7+
8+
import { CONFLUENCE_CREDENTIAL_NAME, confluenceApiRequest } from '../transport';
9+
10+
/**
11+
* Shared page-selection fields: operations spread `spaceRLC`/`pageRLC` and add
12+
* their own displayOptions. An empty space leaves page lookups site-wide.
13+
*/
14+
export const pageRLC: INodeProperties = {
15+
displayName: 'Page',
16+
name: 'page',
17+
type: 'resourceLocator',
18+
default: { mode: 'list', value: '' },
19+
required: true,
20+
description: 'The page to operate on',
21+
typeOptions: {
22+
loadOptionsDependsOn: ['space.value'],
23+
},
24+
modes: [
25+
{
26+
displayName: 'From List',
27+
name: 'list',
28+
type: 'list',
29+
typeOptions: {
30+
searchListMethod: 'getPages',
31+
searchable: true,
32+
},
33+
},
34+
{
35+
displayName: 'By URL',
36+
name: 'url',
37+
type: 'string',
38+
placeholder: 'e.g. https://your-site.atlassian.net/wiki/spaces/DOCS/pages/123456/My+Page',
39+
validation: [
40+
{
41+
type: 'regex',
42+
properties: {
43+
regex: '.*/pages/(?:edit-v2/)?[0-9]+.*',
44+
errorMessage: 'The URL must contain /pages/<numeric page ID>',
45+
},
46+
},
47+
],
48+
extractValue: {
49+
type: 'regex',
50+
regex: '/pages/(?:edit-v2/)?([0-9]+)',
51+
},
52+
},
53+
{
54+
displayName: 'By ID',
55+
name: 'id',
56+
type: 'string',
57+
placeholder: 'e.g. 123456',
58+
validation: [
59+
{
60+
type: 'regex',
61+
properties: {
62+
regex: '^[0-9]+$',
63+
errorMessage: 'The page ID must be numeric',
64+
},
65+
},
66+
],
67+
},
68+
{
69+
displayName: 'By Title',
70+
name: 'title',
71+
type: 'string',
72+
placeholder: 'e.g. Project plan',
73+
},
74+
],
75+
};
76+
77+
export type ConfluenceBodyFormat = 'storage' | 'atlas_doc_format' | 'plainText';
78+
79+
export const spaceRLC: INodeProperties = {
80+
displayName: 'Space',
81+
name: 'space',
82+
type: 'resourceLocator',
83+
default: { mode: 'list', value: '' },
84+
description: 'The Confluence space',
85+
modes: [
86+
{
87+
displayName: 'From List',
88+
name: 'list',
89+
type: 'list',
90+
typeOptions: {
91+
searchListMethod: 'searchSpaces',
92+
searchable: true,
93+
},
94+
},
95+
{
96+
displayName: 'By ID',
97+
name: 'id',
98+
type: 'string',
99+
placeholder: 'e.g. 98432',
100+
validation: [
101+
{
102+
type: 'regex',
103+
properties: {
104+
regex: '^[0-9]+$',
105+
errorMessage: 'The space ID must be numeric',
106+
},
107+
},
108+
],
109+
},
110+
],
111+
};
112+
113+
const spaceKeyCache = new Map<string, string>();
114+
115+
export function clearSpaceKeyCache(): void {
116+
spaceKeyCache.clear();
117+
}
118+
119+
export async function resolveSpaceKey(
120+
this: IExecuteFunctions | ILoadOptionsFunctions,
121+
spaceId: string,
122+
): Promise<string | undefined> {
123+
// Space IDs are only unique per site, so the cache is keyed per credential
124+
const rawCredentialId = this.getNode().credentials?.[CONFLUENCE_CREDENTIAL_NAME]?.id;
125+
const credentialId = typeof rawCredentialId === 'string' ? rawCredentialId : '';
126+
const cacheKey = `${credentialId}:${spaceId}`;
127+
128+
const cached = spaceKeyCache.get(cacheKey);
129+
if (cached !== undefined) return cached;
130+
131+
const space = await confluenceApiRequest.call(
132+
this,
133+
'GET',
134+
`/wiki/api/v2/spaces/${encodeURIComponent(spaceId)}`,
135+
);
136+
if (typeof space.key !== 'string' || space.key === '') return undefined;
137+
spaceKeyCache.set(cacheKey, space.key);
138+
return space.key;
139+
}
140+
141+
export function extractNextCursor(response: IDataObject): string | undefined {
142+
const next = (response._links as IDataObject | undefined)?.next;
143+
if (typeof next !== 'string' || next === '') return undefined;
144+
try {
145+
return new URL(next, 'https://api.atlassian.com').searchParams.get('cursor') ?? undefined;
146+
} catch {
147+
return undefined;
148+
}
149+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * as listSearch from './listSearch';
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import type {
2+
IDataObject,
3+
ILoadOptionsFunctions,
4+
INodeListSearchItems,
5+
INodeListSearchResult,
6+
} from 'n8n-workflow';
7+
8+
import { extractNextCursor, resolveSpaceKey } from '../actions/common';
9+
import { confluenceApiRequest } from '../transport';
10+
11+
interface SearchPage {
12+
entries: IDataObject[];
13+
base: string;
14+
next?: string;
15+
}
16+
17+
const SEARCH_PAGE_SIZE = 50;
18+
const MAX_FILTERED_SEARCH_PAGES = 10;
19+
const EMPTY_PAGE: SearchPage = { entries: [], base: '' };
20+
21+
export async function searchSpaces(
22+
this: ILoadOptionsFunctions,
23+
filter?: string,
24+
paginationToken?: string,
25+
): Promise<INodeListSearchResult> {
26+
const filterLower = (filter ?? '').trim().toLowerCase();
27+
const results: INodeListSearchItems[] = [];
28+
let cursor = paginationToken;
29+
30+
// No server-side text filter on the v2 spaces list; fetch ahead so matches
31+
// beyond the first page stay discoverable
32+
for (let fetched = 0; fetched < MAX_FILTERED_SEARCH_PAGES; fetched++) {
33+
const qs: IDataObject = { limit: SEARCH_PAGE_SIZE, sort: 'name', status: 'current' };
34+
if (cursor !== undefined) qs.cursor = cursor;
35+
36+
const response = await confluenceApiRequest.call(this, 'GET', '/wiki/api/v2/spaces', {}, qs);
37+
const entries = Array.isArray(response.results) ? (response.results as IDataObject[]) : [];
38+
39+
for (const space of entries) {
40+
if (typeof space.id !== 'string' && typeof space.id !== 'number') continue;
41+
if (typeof space.name !== 'string') continue;
42+
if (filterLower !== '' && !space.name.toLowerCase().includes(filterLower)) continue;
43+
const key = typeof space.key === 'string' && space.key !== '' ? ` (${space.key})` : '';
44+
results.push({ name: `${space.name}${key}`, value: String(space.id) });
45+
}
46+
47+
cursor = extractNextCursor(response);
48+
if (cursor === undefined || filterLower === '' || results.length > 0) break;
49+
}
50+
51+
return { results, paginationToken: cursor };
52+
}
53+
54+
async function fetchSearchPage(
55+
this: ILoadOptionsFunctions,
56+
cql: string,
57+
start?: number,
58+
): Promise<SearchPage> {
59+
const qs: IDataObject = { cql, limit: SEARCH_PAGE_SIZE };
60+
if (start !== undefined) qs.start = start;
61+
62+
const response = await confluenceApiRequest.call(this, 'GET', '/wiki/rest/api/search', {}, qs);
63+
const links = response._links as IDataObject | undefined;
64+
return {
65+
entries: Array.isArray(response.results) ? (response.results as IDataObject[]) : [],
66+
base: typeof links?.base === 'string' ? links.base : '',
67+
next: typeof links?.next === 'string' && links.next !== '' ? links.next : undefined,
68+
};
69+
}
70+
71+
function toPageItems(
72+
entries: IDataObject[],
73+
base: string,
74+
withSpaceLabel: boolean,
75+
): INodeListSearchItems[] {
76+
const results: INodeListSearchItems[] = [];
77+
const seenIds = new Set<string>();
78+
for (const entry of entries) {
79+
const content = entry.content as IDataObject | undefined;
80+
if (content === undefined) continue;
81+
if (typeof content.id !== 'string' && typeof content.id !== 'number') continue;
82+
const id = String(content.id);
83+
if (seenIds.has(id)) continue;
84+
seenIds.add(id);
85+
const title = typeof content.title === 'string' && content.title !== '' ? content.title : id;
86+
// The space name disambiguates same-titled pages; redundant once scoped to one space
87+
const container = entry.resultGlobalContainer as IDataObject | undefined;
88+
const space =
89+
withSpaceLabel && typeof container?.title === 'string' && container.title !== ''
90+
? ` (${container.title})`
91+
: '';
92+
const webui = (content._links as IDataObject | undefined)?.webui;
93+
const url = base !== '' && typeof webui === 'string' ? `${base}${webui}` : undefined;
94+
results.push({ name: `${title}${space}`, value: id, url });
95+
}
96+
return results;
97+
}
98+
99+
function nextStartToken(
100+
next: string | undefined,
101+
start: number,
102+
count: number,
103+
): string | undefined {
104+
if (next === undefined) return undefined;
105+
let parsed: string | null = null;
106+
try {
107+
parsed = new URL(next, 'https://api.atlassian.com').searchParams.get('start');
108+
} catch {
109+
parsed = null;
110+
}
111+
// A page can come back empty while next is still set; never repeat the same offset
112+
return parsed ?? String(start + Math.max(count, 1));
113+
}
114+
115+
function getScopedSpaceId(this: ILoadOptionsFunctions): string {
116+
try {
117+
const raw = this.getCurrentNodeParameter('space', { extractValue: true });
118+
return typeof raw === 'string' || typeof raw === 'number' ? String(raw).trim() : '';
119+
} catch {
120+
return '';
121+
}
122+
}
123+
124+
export async function getPages(
125+
this: ILoadOptionsFunctions,
126+
filter?: string,
127+
paginationToken?: string,
128+
): Promise<INodeListSearchResult> {
129+
const start = paginationToken === undefined ? 0 : Number(paginationToken);
130+
const spaceId = getScopedSpaceId.call(this);
131+
132+
let spaceClause = '';
133+
if (spaceId !== '') {
134+
// CQL's space field matches by key, so the selected space ID is resolved first
135+
const spaceKey = await resolveSpaceKey.call(this, spaceId);
136+
if (spaceKey !== undefined) spaceClause = ` AND space = "${spaceKey}"`;
137+
}
138+
139+
const escaped = (filter ?? '').replace(/(["\\])/g, '\\$1');
140+
const cql =
141+
escaped === ''
142+
? `type=page${spaceClause} ORDER BY lastmodified DESC`
143+
: `type=page${spaceClause} AND title ~ "${escaped}*" ORDER BY lastmodified DESC`;
144+
145+
// Exact-title pages can be buried behind newer prefix matches, so page one
146+
// fetches them separately; toPageItems drops the overlap
147+
const exact =
148+
escaped !== '' && paginationToken === undefined
149+
? await fetchSearchPage.call(this, `type=page${spaceClause} AND title = "${escaped}"`)
150+
: EMPTY_PAGE;
151+
152+
const page = await fetchSearchPage.call(this, cql, start);
153+
154+
return {
155+
results: toPageItems(
156+
[...exact.entries, ...page.entries],
157+
page.base || exact.base,
158+
spaceId === '',
159+
),
160+
paginationToken: nextStartToken(page.next, start, page.entries.length),
161+
};
162+
}

0 commit comments

Comments
 (0)