mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
9fbd9fbc65
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
228 lines
7.3 KiB
TypeScript
228 lines
7.3 KiB
TypeScript
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: '' };
|
|
|
|
/**
|
|
* Shared list search over the v2 cursor-paginated lists that have no
|
|
* server-side text filter (spaces, labels): the typed text is matched
|
|
* client-side against `name`, fetching ahead so matches beyond the first
|
|
* page stay discoverable.
|
|
*/
|
|
async function searchByName(
|
|
this: ILoadOptionsFunctions,
|
|
endpoint: string,
|
|
baseQs: IDataObject,
|
|
toDisplayName: (name: string, entry: IDataObject) => string,
|
|
filter?: string,
|
|
paginationToken?: string,
|
|
): Promise<INodeListSearchResult> {
|
|
const filterLower = (filter ?? '').trim().toLowerCase();
|
|
const results: INodeListSearchItems[] = [];
|
|
let cursor = paginationToken;
|
|
|
|
for (let fetched = 0; fetched < MAX_FILTERED_SEARCH_PAGES; fetched++) {
|
|
const qs: IDataObject = { ...baseQs, limit: SEARCH_PAGE_SIZE };
|
|
if (cursor !== undefined) qs.cursor = cursor;
|
|
|
|
const response = await confluenceApiRequest.call(this, 'GET', endpoint, {}, qs);
|
|
const entries = Array.isArray(response.results) ? (response.results as IDataObject[]) : [];
|
|
|
|
let lastName: string | undefined;
|
|
let exactFound = false;
|
|
for (const entry of entries) {
|
|
if (typeof entry.name !== 'string') continue;
|
|
lastName = entry.name.toLowerCase();
|
|
if (typeof entry.id !== 'string' && typeof entry.id !== 'number') continue;
|
|
if (filterLower !== '' && !lastName.includes(filterLower)) continue;
|
|
if (lastName === filterLower) exactFound = true;
|
|
results.push({ name: toDisplayName(entry.name, entry), value: String(entry.id) });
|
|
}
|
|
|
|
cursor = extractNextCursor(response);
|
|
if (cursor === undefined || filterLower === '' || exactFound) break;
|
|
// The list is name-sorted: don't stop on partial matches while an exact match may still lie ahead
|
|
const exactMayLieAhead = lastName !== undefined && lastName < filterLower;
|
|
if (results.length > 0 && !exactMayLieAhead) break;
|
|
}
|
|
|
|
return { results, paginationToken: cursor };
|
|
}
|
|
|
|
export async function searchSpaces(
|
|
this: ILoadOptionsFunctions,
|
|
filter?: string,
|
|
paginationToken?: string,
|
|
): Promise<INodeListSearchResult> {
|
|
return await searchByName.call(
|
|
this,
|
|
'/wiki/api/v2/spaces',
|
|
{ sort: 'name', status: 'current' },
|
|
(name, space) => {
|
|
const key = typeof space.key === 'string' && space.key !== '' ? ` (${space.key})` : '';
|
|
return `${name}${key}`;
|
|
},
|
|
filter,
|
|
paginationToken,
|
|
);
|
|
}
|
|
|
|
export async function getLabels(
|
|
this: ILoadOptionsFunctions,
|
|
filter?: string,
|
|
paginationToken?: string,
|
|
): Promise<INodeListSearchResult> {
|
|
return await searchByName.call(
|
|
this,
|
|
'/wiki/api/v2/labels',
|
|
{ sort: 'name' },
|
|
(name, label) => {
|
|
// Non-global labels (my/team/system) share names with global ones; the prefix disambiguates
|
|
const prefix =
|
|
typeof label.prefix === 'string' && label.prefix !== '' && label.prefix !== 'global'
|
|
? ` (${label.prefix})`
|
|
: '';
|
|
return `${name}${prefix}`;
|
|
},
|
|
filter,
|
|
paginationToken,
|
|
);
|
|
}
|
|
|
|
export async function searchSpacesWithAll(
|
|
this: ILoadOptionsFunctions,
|
|
filter?: string,
|
|
paginationToken?: string,
|
|
): Promise<INodeListSearchResult> {
|
|
const search = await searchSpaces.call(this, filter, paginationToken);
|
|
if (paginationToken === undefined && (filter ?? '').trim() === '') {
|
|
search.results.unshift({ name: 'All Spaces', value: '' });
|
|
}
|
|
return search;
|
|
}
|
|
|
|
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),
|
|
};
|
|
}
|