Skip to content

Commit 5c42779

Browse files
authored
fix(confluence): preserve panel/callout macro semantics through sync (#5896)
* 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. * fix(confluence): preserve word boundaries when extracting callout body text Greptile P1: cheerio's .text() concatenates every descendant text node with no separator, so pulling a macro body's text in one call fused adjacent blocks together (e.g. a paragraph ending in "for:" immediately followed by a list item "GitLab" became "for:GitLab"), corrupting the exact word boundaries RAG chunking and keyword matching depend on. extractBlockJoinedText now extracts each paragraph/list-item/heading/cell/quote individually and joins them with a single space, keeping every block's text intact and properly separated, matching how htmlToPlainText already treats the rest of the page. * fix(confluence): fix nested-block duplication in callout text extraction Greptile P1: filtering the found blocks to only top-level ones still wasn't enough — a nested block (an outer <li> containing its own nested <ul><li>, a <td> containing a <blockquote>) matched the selector once, but its .text() call recurses into and flattens its own matched descendants with no separator, reproducing the exact word-fusion bug one level deeper (and any duplicate-selection would have double-counted the same text). Replaces the block-selector approach with a recursive text-node walk: extractBlockJoinedText now visits every text node individually and joins them all with a single space, so word boundaries are preserved at every nesting depth with no double-counting, matching the pattern html-parser.ts already uses elsewhere in this codebase for the same class of problem. * fix(confluence): apply the same word-boundary-safe extraction to panel headers Greptile: panelHeader text extraction was left on the plain .text() call while panel/macro body extraction was already fixed to use extractBlockJoinedText, so a rich multi-node header (e.g. <b>Warning:</b> followed by a sibling <span>) could still fuse into "Warning:Do not use" with no space. Panel headers now go through the same recursive text-node walk as bodies, for consistency across every text extraction in this file. * fix(confluence): distinguish inline formatting from block boundaries in extraction Greptile: the recursive text-node walk unconditionally inserted a space between every text node, which fixed block-boundary fusion but broke genuinely inline-formatted text — "un<b>believe</b>able" became "un believe able" and "Hello<b>!</b>" became "Hello !", corrupting valid callout content on its way into the index. Adds an INLINE_FORMATTING_TAGS allowlist (b, strong, i, em, span, a, etc.): text flowing through those tags accumulates with no artificial separator, preserving exact source adjacency, while every other tag boundary (p, li, td, headings, br, ...) still flushes to a new segment — a block always implies a break even with no literal whitespace in the source, but an inline tag never does. Fixed one test that had encoded the old, incorrect expectation for two genuinely adjacent inline tags with no source whitespace between them, and added regression tests for mid-word inline formatting, punctuation attached to an inline tag, and a header with real source spacing. * fix(confluence): process nested panels/macros innermost-first Cursor: processing matches in document order (outermost first) read a nested, not-yet-converted panel/macro as plain body text before it ever got its own bracketed label, silently dropping the inner callout's type. Worse, an untitled outer panel's `.find('.panelHeader')` could reach past its own missing header into a nested panel's header and adopt it as its own title. Replaces the two independent .each() passes with a loop that converts only "leaf" macros (no remaining nested macro/panel inside them) and repeats until none are left. This processes innermost-first, so a nested macro is already its own bracketed <p> by the time its parent's body/header text is read, and an untitled outer panel's .find() can no longer reach a header that isn't its own, since a leaf by definition has no nested panel left to reach into. * test(confluence): add explicit regression for the exact reported <br> repro Formalizes an explicit test for the exact <br>-separated string Greptile's review cited as broken (verified manually not to reproduce, but wasn't directly asserted in the suite before this).
1 parent 84e7aad commit 5c42779

2 files changed

Lines changed: 407 additions & 5 deletions

File tree

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

Lines changed: 251 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,248 @@ 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('preserves word boundaries between a block header and its own content', () => {
114+
const html =
115+
'<div class="panel"><div class="panelHeader"><b>Warning:</b></div>' +
116+
'<div class="panelContent"><p>See replacement form.</p></div></div>'
117+
const result = preserveConfluenceCallouts(html)
118+
expect(result).toContain('[CALLOUT: Warning:] See replacement form.')
119+
})
120+
121+
it.concurrent(
122+
'keeps a rich header with a real source space intact, without adding a second one',
123+
() => {
124+
const html =
125+
'<div class="panel"><div class="panelHeader"><b>Warning:</b> <span>Do not use</span></div>' +
126+
'<div class="panelContent"><p>See replacement form.</p></div></div>'
127+
const result = preserveConfluenceCallouts(html)
128+
expect(result).toContain('[CALLOUT: Warning: Do not use]')
129+
}
130+
)
131+
132+
it.concurrent('falls back to a bare CALLOUT label when a Panel macro has no header text', () => {
133+
const html =
134+
'<div class="panel"><div class="panelContent"><p>Untitled panel body.</p></div></div>'
135+
const result = preserveConfluenceCallouts(html)
136+
expect(result).toContain('[CALLOUT]')
137+
expect(result).toContain('Untitled panel body.')
138+
})
139+
140+
it.concurrent(
141+
'keeps the exclusion marker attached to its content through htmlToPlainText, even across surrounding whitespace collapse',
142+
() => {
143+
const html =
144+
'<p>Intro paragraph.</p>\n\n' +
145+
'<div class="confluence-information-macro confluence-information-macro-warning">' +
146+
'<div class="confluence-information-macro-body"><p>Do NOT use this form for:</p>' +
147+
'<ul><li>GitLab</li></ul></div>' +
148+
'</div>\n\n' +
149+
'<p>Trailing paragraph.</p>'
150+
const plainText = htmlToPlainText(preserveConfluenceCallouts(html))
151+
expect(plainText).toContain('[WARNING] Do NOT use this form for: GitLab')
152+
expect(plainText).toContain('Intro paragraph.')
153+
expect(plainText).toContain('Trailing paragraph.')
154+
}
155+
)
156+
157+
it.concurrent(
158+
'does not fuse adjacent paragraph and list-item text together (word-boundary regression)',
159+
() => {
160+
const html =
161+
'<div class="confluence-information-macro confluence-information-macro-warning">' +
162+
'<div class="confluence-information-macro-body">' +
163+
'<p>Do NOT use this form for:</p>' +
164+
'<ul><li>GitLab</li><li>ServiceNow</li></ul>' +
165+
'</div></div>'
166+
const result = preserveConfluenceCallouts(html)
167+
expect(result).not.toContain('for:GitLab')
168+
expect(result).not.toContain('GitLabServiceNow')
169+
expect(result).toContain('Do NOT use this form for: GitLab ServiceNow')
170+
}
171+
)
172+
173+
it.concurrent(
174+
'preserves word boundaries across multiple paragraphs in a generic Panel macro',
175+
() => {
176+
const html =
177+
'<div class="panel"><div class="panelContent">' +
178+
'<p>First sentence.</p><p>Second sentence.</p>' +
179+
'</div></div>'
180+
const result = preserveConfluenceCallouts(html)
181+
expect(result).toContain('First sentence. Second sentence.')
182+
expect(result).not.toContain('sentence.Second')
183+
}
184+
)
185+
186+
it.concurrent(
187+
'does not duplicate or fuse text from a nested list inside a callout body (nesting regression)',
188+
() => {
189+
const html =
190+
'<div class="confluence-information-macro confluence-information-macro-note">' +
191+
'<div class="confluence-information-macro-body">' +
192+
'<ul><li>Outer item' +
193+
'<ul><li>Nested item A</li><li>Nested item B</li></ul>' +
194+
'</li><li>Outer item two</li></ul>' +
195+
'</div></div>'
196+
const result = preserveConfluenceCallouts(html)
197+
// Each nested <li>'s text must appear exactly once, not duplicated by the
198+
// outer <li> also being matched and its .text() recursing into it.
199+
const occurrences = (result.match(/Nested item A/g) ?? []).length
200+
expect(occurrences).toBe(1)
201+
expect(result).not.toContain('Nested item ANested item B')
202+
expect(result).toContain('Outer item Nested item A Nested item B Outer item two')
203+
}
204+
)
205+
206+
it.concurrent('does not fuse text from a blockquote nested inside a table cell', () => {
207+
const html =
208+
'<div class="panel"><div class="panelContent">' +
209+
'<table><tr><td>Cell text<blockquote><p>quoted text</p></blockquote>after quote</td></tr></table>' +
210+
'</div></div>'
211+
const result = preserveConfluenceCallouts(html)
212+
expect(result).not.toContain('quotedtext')
213+
expect(result).not.toContain('textafter')
214+
expect(result).toContain('Cell text quoted text after quote')
215+
})
216+
217+
it.concurrent(
218+
'does not inject an artificial space into inline-formatted text mid-word (inline vs. block regression)',
219+
() => {
220+
const html =
221+
'<div class="panel"><div class="panelContent">' +
222+
'<p>This is un<b>believe</b>able.</p>' +
223+
'</div></div>'
224+
const result = preserveConfluenceCallouts(html)
225+
expect(result).not.toContain('un believe able')
226+
expect(result).toContain('This is unbelieveable.')
227+
}
228+
)
229+
230+
it.concurrent('does not inject a space before punctuation carried by an inline tag', () => {
231+
const html =
232+
'<div class="confluence-information-macro confluence-information-macro-warning">' +
233+
'<div class="confluence-information-macro-body"><p>Do not proceed<b>!</b></p></div>' +
234+
'</div>'
235+
const result = preserveConfluenceCallouts(html)
236+
expect(result).not.toContain('proceed !')
237+
expect(result).toContain('[WARNING] Do not proceed!')
238+
})
239+
240+
it.concurrent('keeps natural word spacing when inline tags wrap a whole word', () => {
241+
const html =
242+
'<div class="panel"><div class="panelContent">' +
243+
'<p>Do <b>NOT</b> use this form.</p>' +
244+
'</div></div>'
245+
const result = preserveConfluenceCallouts(html)
246+
expect(result).toContain('Do NOT use this form.')
247+
})
248+
249+
it.concurrent(
250+
'labels a nested panel-in-panel with both its own and its parent label (nesting regression)',
251+
() => {
252+
const html =
253+
'<div class="panel"><div class="panelHeader"><b>Outer</b></div><div class="panelContent">' +
254+
'<div class="panel"><div class="panelHeader"><b>Inner</b></div>' +
255+
'<div class="panelContent"><p>inner body</p></div></div>' +
256+
'</div></div>'
257+
const result = preserveConfluenceCallouts(html)
258+
expect(result).toContain('[CALLOUT: Outer]')
259+
expect(result).toContain('[CALLOUT: Inner] inner body')
260+
}
261+
)
262+
263+
it.concurrent(
264+
'labels a nested info-macro inside a panel with its own type instead of dropping it',
265+
() => {
266+
const html =
267+
'<div class="panel"><div class="panelContent">' +
268+
'<div class="confluence-information-macro confluence-information-macro-warning">' +
269+
'<div class="confluence-information-macro-body"><p>Do not use this.</p></div></div>' +
270+
'</div></div>'
271+
const result = preserveConfluenceCallouts(html)
272+
expect(result).toContain('[WARNING] Do not use this.')
273+
}
274+
)
275+
276+
it.concurrent(
277+
"does not let an untitled outer panel adopt a nested panel's header as its own",
278+
() => {
279+
const html =
280+
'<div class="panel"><div class="panelContent">' +
281+
'<div class="panel"><div class="panelHeader"><b>Inner title</b></div>' +
282+
'<div class="panelContent"><p>inner body</p></div></div>' +
283+
'</div></div>'
284+
const result = preserveConfluenceCallouts(html)
285+
// The outer panel has no header of its own — it must fall back to a
286+
// bare [CALLOUT], not steal "Inner title" from the nested panel.
287+
expect(result).toContain('[CALLOUT] [CALLOUT: Inner title] inner body')
288+
}
289+
)
290+
291+
it.concurrent('does not fuse text on either side of a <br> line break', () => {
292+
const html =
293+
'<div class="confluence-information-macro confluence-information-macro-warning">' +
294+
'<div class="confluence-information-macro-body"><p>Do NOT use this form for:<br>GitLab</p></div>' +
295+
'</div>'
296+
const result = preserveConfluenceCallouts(html)
297+
expect(result).not.toContain('for:GitLab')
298+
expect(result).toContain('[WARNING] Do NOT use this form for: GitLab')
299+
})
300+
})

0 commit comments

Comments
 (0)