diff --git a/apps/sim/connectors/confluence/confluence.test.ts b/apps/sim/connectors/confluence/confluence.test.ts index 706023ccb93..25b3dfff5cb 100644 --- a/apps/sim/connectors/confluence/confluence.test.ts +++ b/apps/sim/connectors/confluence/confluence.test.ts @@ -2,7 +2,12 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { escapeCql, isCurrentContent } from '@/connectors/confluence/confluence' +import { + escapeCql, + isCurrentContent, + preserveConfluenceCallouts, +} from '@/connectors/confluence/confluence' +import { htmlToPlainText } from '@/connectors/utils' describe('escapeCql', () => { it.concurrent('returns plain strings unchanged', () => { @@ -48,3 +53,248 @@ describe('isCurrentContent', () => { expect(isCurrentContent({ id: '1', status: 'deleted' })).toBe(false) }) }) + +describe('preserveConfluenceCallouts', () => { + it.concurrent('handles empty content', () => { + expect(preserveConfluenceCallouts('')).toBe('') + }) + + it.concurrent('leaves content with no macros unchanged', () => { + const html = '
Just a normal paragraph.
' + expect(preserveConfluenceCallouts(html)).toContain('Just a normal paragraph.') + }) + + it.concurrent('labels a built-in warning macro and keeps its body', () => { + const html = + 'Do NOT use this form for GitLab access.
Heads up.
See also.
Pro tip.
GitLab access requests go to the private channel instead.
See replacement form.
See replacement form.
Untitled panel body.
Intro paragraph.
\n\n' + + 'Do NOT use this form for:
' + + 'Trailing paragraph.
' + const plainText = htmlToPlainText(preserveConfluenceCallouts(html)) + expect(plainText).toContain('[WARNING] Do NOT use this form for: GitLab') + expect(plainText).toContain('Intro paragraph.') + expect(plainText).toContain('Trailing paragraph.') + } + ) + + it.concurrent( + 'does not fuse adjacent paragraph and list-item text together (word-boundary regression)', + () => { + const html = + 'Do NOT use this form for:
' + + 'First sentence.
Second sentence.
' + + 'Cell textafter quote |
This is unbelieveable.
' + + 'Do not proceed!
Do NOT use this form.
' + + 'inner body
Do not use this.
inner body
Do NOT use this form for:
GitLab
...for:
` immediately followed by + * `` by the time its parent's body/header text is read — at which + * point it correctly reads as plain text carrying its own label. + */ +export function preserveConfluenceCallouts(html: string): string { + if (!html) return html + + const $ = cheerio.load(html) + + let progressed = true + while (progressed) { + progressed = false + const leaves = $(MACRO_SELECTOR).filter((_, el) => $(el).find(MACRO_SELECTOR).length === 0) + if (leaves.length === 0) break + + leaves.each((_, el) => { + const $el = $(el) + if ($el.hasClass('confluence-information-macro')) { + const type = ($el.attr('class') ?? '') + .match(/confluence-information-macro-(\w+)/)?.[1] + ?.toLowerCase() + const label = (type && CALLOUT_LABELS[type]) || CALLOUT_LABELS.information + const macroBody = $el.find('.confluence-information-macro-body').first() + const body = extractBlockJoinedText($, macroBody.length > 0 ? macroBody : $el) + $el.replaceWith($('
').text(`${label} ${body}`)) + } else { + const headerText = extractBlockJoinedText($, $el.find('.panelHeader').first()) + const panelContent = $el.find('.panelContent').first() + const bodyText = extractBlockJoinedText($, panelContent.length > 0 ? panelContent : $el) + const label = headerText ? `[CALLOUT: ${headerText}]` : '[CALLOUT]' + $el.replaceWith($('').text(`${label} ${bodyText}`)) + } + progressed = true + }) + } + + return $.html() +} + /** * Escapes a value for use inside CQL double-quoted strings. */ @@ -108,10 +258,12 @@ async function fetchLabelsForPages( * invalidates every previously-synced Confluence document so a one-time * re-hydration picks up content newly reachable by the current extraction * (e.g. the switch from `storage` to rendered `view`, which expands Include - * Page / Excerpt macros). Without it, already-indexed pages whose version is - * unchanged classify as `unchanged` and keep their stale (empty) content. + * Page / Excerpt macros; or `preserveConfluenceCallouts`, which stops + * flattening panel/info/note/warning/tip macros into indistinguishable plain + * text). Without it, already-indexed pages whose version is unchanged + * classify as `unchanged` and keep their stale (pre-fix) content. */ -const CONTENT_REPRESENTATION = 'view' +const CONTENT_REPRESENTATION = 'view-callouts' /** * Produces a canonical metadata stub with a deterministic contentHash that @@ -288,7 +440,7 @@ export const confluenceConnector: ConnectorConfig = { const body = page.body as Record