Skip to content

Commit 89c58cc

Browse files
committed
fix(confluence): preserve panel/callout macro semantics through sync
Confluence's rendered view HTML wraps Info/Note/Warning/Tip and custom Panel macros in divs whose class/color convey meaning that the shared htmlToPlainText tag-stripper discards along with the tags — a red "do not use" warning panel becomes indistinguishable from a plain paragraph once flattened, so RAG has no signal that a bullet under it is an exclusion rule rather than a normal one. Adds preserveConfluenceCallouts, a Confluence-specific pre-pass that rewrites each detected panel into a single bracketed label (e.g. "[WARNING] Do NOT use this form for: GitLab") before the generic plain-text conversion runs, so the callout semantic survives both the tag strip and htmlToPlainText's trailing whitespace collapse. Bumps the connector's content-representation marker so already-synced pages get one automatic re-hydration under the new extraction, rather than silently keeping their stale flattened content until their next edit.
1 parent 84e7aad commit 89c58cc

2 files changed

Lines changed: 142 additions & 5 deletions

File tree

apps/sim/connectors/confluence/confluence.test.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5-
import { escapeCql, isCurrentContent } from '@/connectors/confluence/confluence'
5+
import {
6+
escapeCql,
7+
isCurrentContent,
8+
preserveConfluenceCallouts,
9+
} from '@/connectors/confluence/confluence'
10+
import { htmlToPlainText } from '@/connectors/utils'
611

712
describe('escapeCql', () => {
813
it.concurrent('returns plain strings unchanged', () => {
@@ -48,3 +53,85 @@ describe('isCurrentContent', () => {
4853
expect(isCurrentContent({ id: '1', status: 'deleted' })).toBe(false)
4954
})
5055
})
56+
57+
describe('preserveConfluenceCallouts', () => {
58+
it.concurrent('handles empty content', () => {
59+
expect(preserveConfluenceCallouts('')).toBe('')
60+
})
61+
62+
it.concurrent('leaves content with no macros unchanged', () => {
63+
const html = '<p>Just a normal paragraph.</p>'
64+
expect(preserveConfluenceCallouts(html)).toContain('Just a normal paragraph.')
65+
})
66+
67+
it.concurrent('labels a built-in warning macro and keeps its body', () => {
68+
const html =
69+
'<div class="confluence-information-macro confluence-information-macro-warning">' +
70+
'<span class="aui-icon aui-icon-small aui-iconfont-warning confluence-information-macro-icon"></span>' +
71+
'<div class="confluence-information-macro-body"><p>Do NOT use this form for GitLab access.</p></div>' +
72+
'</div>'
73+
const result = preserveConfluenceCallouts(html)
74+
expect(result).toContain('[WARNING]')
75+
expect(result).toContain('Do NOT use this form for GitLab access.')
76+
})
77+
78+
it.concurrent('labels a built-in info macro', () => {
79+
const html =
80+
'<div class="confluence-information-macro confluence-information-macro-information">' +
81+
'<div class="confluence-information-macro-body"><p>Heads up.</p></div>' +
82+
'</div>'
83+
expect(preserveConfluenceCallouts(html)).toContain('[INFO] Heads up.')
84+
})
85+
86+
it.concurrent('labels a built-in note macro', () => {
87+
const html =
88+
'<div class="confluence-information-macro confluence-information-macro-note">' +
89+
'<div class="confluence-information-macro-body"><p>See also.</p></div>' +
90+
'</div>'
91+
expect(preserveConfluenceCallouts(html)).toContain('[NOTE] See also.')
92+
})
93+
94+
it.concurrent('labels a built-in tip macro', () => {
95+
const html =
96+
'<div class="confluence-information-macro confluence-information-macro-tip">' +
97+
'<div class="confluence-information-macro-body"><p>Pro tip.</p></div>' +
98+
'</div>'
99+
expect(preserveConfluenceCallouts(html)).toContain('[TIP] Pro tip.')
100+
})
101+
102+
it.concurrent('labels a generic custom-colored Panel macro using its header title', () => {
103+
const html =
104+
'<div class="panel" style="border-width: 1px;">' +
105+
'<div class="panelHeader" style="background-color: #ffebe6;"><b>Do NOT use this form for:</b></div>' +
106+
'<div class="panelContent"><p>GitLab access requests go to the private channel instead.</p></div>' +
107+
'</div>'
108+
const result = preserveConfluenceCallouts(html)
109+
expect(result).toContain('[CALLOUT: Do NOT use this form for:]')
110+
expect(result).toContain('GitLab access requests go to the private channel instead.')
111+
})
112+
113+
it.concurrent('falls back to a bare CALLOUT label when a Panel macro has no header text', () => {
114+
const html =
115+
'<div class="panel"><div class="panelContent"><p>Untitled panel body.</p></div></div>'
116+
const result = preserveConfluenceCallouts(html)
117+
expect(result).toContain('[CALLOUT]')
118+
expect(result).toContain('Untitled panel body.')
119+
})
120+
121+
it.concurrent(
122+
'keeps the exclusion marker attached to its content through htmlToPlainText, even across surrounding whitespace collapse',
123+
() => {
124+
const html =
125+
'<p>Intro paragraph.</p>\n\n' +
126+
'<div class="confluence-information-macro confluence-information-macro-warning">' +
127+
'<div class="confluence-information-macro-body"><p>Do NOT use this form for:</p>' +
128+
'<ul><li>GitLab</li></ul></div>' +
129+
'</div>\n\n' +
130+
'<p>Trailing paragraph.</p>'
131+
const plainText = htmlToPlainText(preserveConfluenceCallouts(html))
132+
expect(plainText).toContain('[WARNING] Do NOT use this form for:GitLab')
133+
expect(plainText).toContain('Intro paragraph.')
134+
expect(plainText).toContain('Trailing paragraph.')
135+
}
136+
)
137+
})

apps/sim/connectors/confluence/confluence.ts

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { toError } from '@sim/utils/errors'
3+
import * as cheerio from 'cheerio'
34
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
45
import { confluenceConnectorMeta } from '@/connectors/confluence/meta'
56
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
@@ -8,6 +9,53 @@ import { getConfluenceCloudId, normalizeConfluenceDomainHost } from '@/tools/con
89

910
const logger = createLogger('ConfluenceConnector')
1011

12+
/** Label prefixes for Confluence's built-in Info/Note/Warning/Tip macros, by their rendered CSS suffix. */
13+
const CALLOUT_LABELS: Record<string, string> = {
14+
information: '[INFO]',
15+
note: '[NOTE]',
16+
warning: '[WARNING]',
17+
tip: '[TIP]',
18+
error: '[ERROR]',
19+
}
20+
21+
/**
22+
* Confluence's rendered `view` HTML wraps Info/Note/Warning/Tip macros in
23+
* `confluence-information-macro confluence-information-macro-{type}` divs, and
24+
* the customizable Panel macro in `.panel` > `.panelHeader` + `.panelContent`
25+
* divs. `htmlToPlainText`'s blind tag-stripping discards the divs' classes along
26+
* with the tags, so a red "do not use" warning panel becomes indistinguishable
27+
* from a plain paragraph once flattened — and its trailing whitespace collapse
28+
* would erase any newline-based separation too. Each detected panel is rewritten
29+
* into a single bracketed label plus its own text so the callout semantic
30+
* survives both the tag strip and the whitespace collapse.
31+
*/
32+
export function preserveConfluenceCallouts(html: string): string {
33+
if (!html) return html
34+
35+
const $ = cheerio.load(html)
36+
37+
$('div.confluence-information-macro').each((_, el) => {
38+
const $el = $(el)
39+
const type = ($el.attr('class') ?? '')
40+
.match(/confluence-information-macro-(\w+)/)?.[1]
41+
?.toLowerCase()
42+
const label = (type && CALLOUT_LABELS[type]) || CALLOUT_LABELS.information
43+
const body =
44+
$el.find('.confluence-information-macro-body').first().text().trim() || $el.text().trim()
45+
$el.replaceWith($('<p></p>').text(`${label} ${body}`))
46+
})
47+
48+
$('div.panel').each((_, el) => {
49+
const $el = $(el)
50+
const headerText = $el.find('.panelHeader').first().text().trim()
51+
const bodyText = $el.find('.panelContent').first().text().trim() || $el.text().trim()
52+
const label = headerText ? `[CALLOUT: ${headerText}]` : '[CALLOUT]'
53+
$el.replaceWith($('<p></p>').text(`${label} ${bodyText}`))
54+
})
55+
56+
return $.html()
57+
}
58+
1159
/**
1260
* Escapes a value for use inside CQL double-quoted strings.
1361
*/
@@ -108,10 +156,12 @@ async function fetchLabelsForPages(
108156
* invalidates every previously-synced Confluence document so a one-time
109157
* re-hydration picks up content newly reachable by the current extraction
110158
* (e.g. the switch from `storage` to rendered `view`, which expands Include
111-
* Page / Excerpt macros). Without it, already-indexed pages whose version is
112-
* unchanged classify as `unchanged` and keep their stale (empty) content.
159+
* Page / Excerpt macros; or `preserveConfluenceCallouts`, which stops
160+
* flattening panel/info/note/warning/tip macros into indistinguishable plain
161+
* text). Without it, already-indexed pages whose version is unchanged
162+
* classify as `unchanged` and keep their stale (pre-fix) content.
113163
*/
114-
const CONTENT_REPRESENTATION = 'view'
164+
const CONTENT_REPRESENTATION = 'view-callouts'
115165

116166
/**
117167
* Produces a canonical metadata stub with a deterministic contentHash that
@@ -288,7 +338,7 @@ export const confluenceConnector: ConnectorConfig = {
288338
const body = page.body as Record<string, unknown> | undefined
289339
const view = body?.view as Record<string, unknown> | undefined
290340
const rawContent = (view?.value as string) || ''
291-
const plainText = htmlToPlainText(rawContent)
341+
const plainText = htmlToPlainText(preserveConfluenceCallouts(rawContent))
292342

293343
const labelMap = await fetchLabelsForPages(cloudId, accessToken, [String(page.id)])
294344
const labels = labelMap.get(String(page.id)) ?? []

0 commit comments

Comments
 (0)