Skip to content

Commit 2ac7faa

Browse files
authored
fix(knowledge): stop connector sync failures on download-restricted Drive files and macro-only Confluence pages (#7739)
* fix(knowledge): stop connector sync failures on download-restricted Drive files and macro-only Confluence pages * fix(knowledge): drop raw error message from hydration failure logs
1 parent ecf2ae2 commit 2ac7faa

10 files changed

Lines changed: 456 additions & 33 deletions

File tree

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

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import {
1010
buildLastModifiedClause,
1111
confluenceConnector,
1212
confluenceStorageToPlainText,
13+
DYNAMIC_CONTENT_SKIP_REASON,
1314
escapeCql,
15+
extractConfluenceStorageText,
1416
isCurrentContent,
1517
preserveConfluenceCallouts,
1618
readIncludedLabels,
@@ -770,6 +772,77 @@ describe('confluenceStorageToPlainText', () => {
770772
expect(confluenceStorageToPlainText(storage)).toBe('Public body')
771773
})
772774

775+
it('keeps text authored inside legacy section and column layouts', () => {
776+
const storage =
777+
'<ac:structured-macro ac:name="section"><ac:rich-text-body>' +
778+
'<ac:structured-macro ac:name="column"><ac:parameter ac:name="width">50%</ac:parameter>' +
779+
'<ac:rich-text-body><h1>Linux Patching</h1><p>Run the playbook.</p></ac:rich-text-body>' +
780+
'</ac:structured-macro>' +
781+
'<ac:structured-macro ac:name="column"><ac:rich-text-body><p>Second column</p></ac:rich-text-body>' +
782+
'</ac:structured-macro></ac:rich-text-body></ac:structured-macro>'
783+
784+
expect(confluenceStorageToPlainText(storage)).toBe(
785+
'Linux Patching Run the playbook. Second column'
786+
)
787+
})
788+
789+
it('keeps page properties tables, table-macro bodies, and status labels', () => {
790+
const storage =
791+
'<ac:structured-macro ac:name="details"><ac:rich-text-body>' +
792+
'<table><tbody><tr><th>Owner</th><td>Platform team</td></tr></tbody></table>' +
793+
'</ac:rich-text-body></ac:structured-macro>' +
794+
'<ac:structured-macro ac:name="table-filter"><ac:parameter ac:name="column">Name</ac:parameter>' +
795+
'<ac:rich-text-body><table><tbody><tr><td>Filtered row</td></tr></tbody></table></ac:rich-text-body>' +
796+
'</ac:structured-macro>' +
797+
'<p>State: <ac:structured-macro ac:name="status"><ac:parameter ac:name="colour">Green</ac:parameter>' +
798+
'<ac:parameter ac:name="title">Approved</ac:parameter></ac:structured-macro></p>'
799+
800+
expect(confluenceStorageToPlainText(storage)).toBe(
801+
'Owner Platform team Filtered row State: Approved'
802+
)
803+
})
804+
805+
it('keeps new-editor panel and decision text while dropping app extensions', () => {
806+
const storage =
807+
'<ac:adf-extension><ac:adf-node type="panel">' +
808+
'<ac:adf-attribute key="panel-type">custom</ac:adf-attribute>' +
809+
'<ac:adf-content><p>Rotate the key quarterly.</p></ac:adf-content>' +
810+
'</ac:adf-node><ac:adf-fallback><p>Rotate the key quarterly.</p></ac:adf-fallback></ac:adf-extension>' +
811+
'<ac:adf-extension><ac:adf-node type="decision-list">' +
812+
'<ac:adf-attribute key="local-id">abc</ac:adf-attribute>' +
813+
'<ac:adf-node type="decision-item"><ac:adf-attribute key="state">DECIDED</ac:adf-attribute>' +
814+
'<ac:adf-content>Use Vault</ac:adf-content></ac:adf-node></ac:adf-node></ac:adf-extension>' +
815+
'<ac:adf-extension><ac:adf-node type="extension">' +
816+
'<ac:adf-attribute key="parameters">remote</ac:adf-attribute></ac:adf-node>' +
817+
'<ac:adf-fallback><p>Rendered by an app</p></ac:adf-fallback></ac:adf-extension>'
818+
819+
expect(confluenceStorageToPlainText(storage)).toBe(
820+
'[CALLOUT] Rotate the key quarterly. Use Vault'
821+
)
822+
})
823+
824+
it('drops template placeholders and task bookkeeping but keeps task text intact', () => {
825+
const storage =
826+
'<p><ac:placeholder>Type your summary here</ac:placeholder></p>' +
827+
'<ac:task-list><ac:task><ac:task-id>1</ac:task-id><ac:task-uuid>u</ac:task-uuid>' +
828+
'<ac:task-status>incomplete</ac:task-status><ac:task-body>Ship it</ac:task-body></ac:task></ac:task-list>' +
829+
'<p>Un<ac:inline-comment-marker ac:ref="r">believ</ac:inline-comment-marker>able</p>'
830+
831+
expect(confluenceStorageToPlainText(storage)).toBe('Ship it Unbelievable')
832+
})
833+
834+
it('reports whether dynamic content was removed', () => {
835+
expect(extractConfluenceStorageText('<p>Local</p>')).toEqual({
836+
text: 'Local',
837+
droppedDynamicContent: false,
838+
})
839+
expect(
840+
extractConfluenceStorageText(
841+
'<ac:structured-macro ac:name="children"><ac:parameter ac:name="depth">1</ac:parameter></ac:structured-macro>'
842+
)
843+
).toEqual({ text: '', droppedDynamicContent: true })
844+
})
845+
773846
it.each(['expand', 'excerpt', 'noformat'])(
774847
'retains the authored content of the %s macro',
775848
(name) => {
@@ -902,7 +975,36 @@ describe('Confluence permission-scoped content', () => {
902975
cloudId: 'cloud-1',
903976
mirrorsSourceAcls: true,
904977
})
905-
).resolves.toMatchObject({ content: '', skippedExistingDisposition: 'replace' })
978+
).resolves.toMatchObject({
979+
content: '',
980+
skippedReason: DYNAMIC_CONTENT_SKIP_REASON,
981+
skippedExistingDisposition: 'replace',
982+
})
983+
})
984+
985+
it('names dynamic-only hub pages distinctly from genuinely empty ones', async () => {
986+
vi.mocked(fetch).mockResolvedValueOnce(
987+
new Response(
988+
JSON.stringify({
989+
id: 'hub',
990+
version: { number: 2 },
991+
body: {
992+
storage: {
993+
value:
994+
'<ac:structured-macro ac:name="children" /><ac:structured-macro ac:name="jira">' +
995+
'<ac:parameter ac:name="jql">project = X</ac:parameter></ac:structured-macro>',
996+
},
997+
},
998+
})
999+
)
1000+
)
1001+
const document = await confluenceConnector.getDocument('token', config, 'hub', {
1002+
cloudId: 'cloud-1',
1003+
perMemberListing: true,
1004+
memberId: 'member-1',
1005+
})
1006+
expect(document?.skippedReason).toBe(DYNAMIC_CONTENT_SKIP_REASON)
1007+
expect(document?.skippedRetryPolicy).toBe('source-change')
9061008
})
9071009

9081010
it('keeps skipped pages retryable when no usable source version is available', async () => {
@@ -1015,7 +1117,7 @@ describe('Confluence permission-scoped content', () => {
10151117
)
10161118
const expectedHash =
10171119
'mirrorsSourceAcls' in mode || 'perMemberListing' in mode
1018-
? 'confluence:storage-local-body-v1:shared-page:1'
1120+
? 'confluence:storage-local-body-v2:shared-page:1'
10191121
: 'confluence:view-callouts:shared-page:1'
10201122

10211123
expect(v2.documents[0].contentHash).toBe(expectedHash)

apps/sim/connectors/confluence/confluence.ts

Lines changed: 95 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ const INLINE_FORMATTING_TAGS = new Set([
9393
'var',
9494
'samp',
9595
'time',
96+
'ac:inline-comment-marker',
9697
])
9798

9899
/**
@@ -204,33 +205,98 @@ export function preserveConfluenceCallouts(html: string): string {
204205
}
205206

206207
const STORAGE_MACRO_SELECTOR = 'ac\\:structured-macro, ac\\:macro'
208+
const ADF_NODE_SELECTOR = 'ac\\:adf-node'
209+
/** Callout macros whose body is prefixed with a semantic label, as on the view path. */
210+
const LOCAL_CALLOUT_MACROS = new Set(['info', 'note', 'warning', 'tip', 'panel'])
211+
/**
212+
* Macros whose text is authored on the page itself: callouts, expand/excerpt/code
213+
* bodies, legacy `section`/`column` layouts (which wrap the entire body of pages
214+
* built in the old editor), Page Properties (`details`), table-wrapping macros,
215+
* and `status` lozenges. Everything else either resolves another resource
216+
* (include, jira, children, page tree, label reports) or is an app macro, and
217+
* may render differently for each reader.
218+
*/
207219
const LOCAL_STORAGE_MACROS = new Set([
208-
'info',
209-
'note',
210-
'warning',
211-
'tip',
212-
'panel',
220+
...LOCAL_CALLOUT_MACROS,
213221
'expand',
214222
'excerpt',
215223
'code',
216224
'noformat',
225+
'section',
226+
'column',
227+
'details',
228+
'toc-zone',
229+
'chart',
230+
'table-filter',
231+
'table-chart',
232+
'table-pivot',
233+
'table-transformer',
234+
'table-excerpt',
235+
'table-plus',
236+
'status',
217237
])
238+
/** New-editor nodes stored as ADF whose content is authored on the page. */
239+
const LOCAL_ADF_NODE_TYPES = new Set(['panel', 'decision-list', 'decision-item'])
240+
/** ADF nodes rendered by a Forge or Connect app; their output is resolved elsewhere. */
241+
const APP_ADF_NODE_TYPES = new Set(['extension', 'bodiedExtension', 'inlineExtension'])
242+
/** Storage-format bookkeeping that is never page prose. */
243+
const STORAGE_NOISE_SELECTOR = [
244+
'ac\\:parameter',
245+
'ac\\:default-parameter',
246+
'ac\\:adf-attribute',
247+
'ac\\:adf-fallback',
248+
'ac\\:placeholder',
249+
'ac\\:task-id',
250+
'ac\\:task-uuid',
251+
'ac\\:task-status',
252+
'script',
253+
'style',
254+
].join(', ')
255+
256+
/** Recorded when a scoped page holds nothing but content resolved from elsewhere. */
257+
export const DYNAMIC_CONTENT_SKIP_REASON =
258+
'Page only contains dynamic content (child lists, includes, or app macros) that Search cannot index'
259+
260+
export interface ConfluenceStorageText {
261+
text: string
262+
/** True when at least one non-local macro or app node was removed. */
263+
droppedDynamicContent: boolean
264+
}
218265

219266
/**
220267
* Search authorizes the containing page, not content expanded from another
221268
* resource. Read authored storage text and known local macro bodies only;
222269
* inclusion and third-party macros may render differently for each reader.
223270
*/
224-
export function confluenceStorageToPlainText(storage: string): string {
271+
export function extractConfluenceStorageText(storage: string): ConfluenceStorageText {
225272
const $ = cheerio.load(
226273
storage,
227274
{ xml: { xmlMode: false, recognizeCDATA: true, recognizeSelfClosing: true } },
228275
false
229276
)
230-
$('ac\\:adf-extension').remove()
277+
let droppedDynamicContent = false
278+
279+
for (const element of $(ADF_NODE_SELECTOR).toArray().reverse()) {
280+
const node = $(element)
281+
const type = node.attr('type') ?? ''
282+
if (!LOCAL_ADF_NODE_TYPES.has(type)) {
283+
if (APP_ADF_NODE_TYPES.has(type)) droppedDynamicContent = true
284+
node.remove()
285+
continue
286+
}
287+
const panelType = node.children('ac\\:adf-attribute[key="panel-type"]').text().trim()
288+
node.children('ac\\:adf-attribute, ac\\:adf-fallback').remove()
289+
const body = extractBlockJoinedText($, node)
290+
const label =
291+
type === 'panel'
292+
? (CALLOUT_LABELS[panelType === 'info' ? 'information' : panelType] ?? '[CALLOUT]')
293+
: ''
294+
node.replaceWith($('<p></p>').text([label, body].filter(Boolean).join(' ')))
295+
}
231296

232297
$(STORAGE_MACRO_SELECTOR).each((_, element) => {
233298
if (!LOCAL_STORAGE_MACROS.has($(element).attr('ac:name') ?? '')) {
299+
droppedDynamicContent = true
234300
$(element).remove()
235301
}
236302
})
@@ -248,13 +314,23 @@ export function confluenceStorageToPlainText(storage: string): string {
248314
? title
249315
? `[CALLOUT: ${title}]`
250316
: '[CALLOUT]'
251-
: CALLOUT_LABELS[name === 'info' ? 'information' : name]
317+
: LOCAL_CALLOUT_MACROS.has(name)
318+
? CALLOUT_LABELS[name === 'info' ? 'information' : name]
319+
: ''
252320
const text = [label, name === 'panel' ? '' : title, body].filter(Boolean).join(' ')
253321
macro.replaceWith($('<p></p>').text(text))
254322
}
255323

256-
$('ac\\:parameter, ac\\:default-parameter, script, style').remove()
257-
return extractBlockJoinedText($, $.root()).replace(/\s+/g, ' ').trim()
324+
$(STORAGE_NOISE_SELECTOR).remove()
325+
return {
326+
text: extractBlockJoinedText($, $.root()).replace(/\s+/g, ' ').trim(),
327+
droppedDynamicContent,
328+
}
329+
}
330+
331+
/** Plain text of a storage-format body; see {@link extractConfluenceStorageText}. */
332+
export function confluenceStorageToPlainText(storage: string): string {
333+
return extractConfluenceStorageText(storage).text
258334
}
259335

260336
function usesPermissionScopedContent(syncContext?: Record<string, unknown>): boolean {
@@ -313,7 +389,7 @@ export function readIncludedLabels(page: Record<string, unknown>): string[] {
313389
* ordinary knowledge bases retain their existing rendered representation.
314390
*/
315391
const CONTENT_REPRESENTATION = 'view-callouts'
316-
const SCOPED_CONTENT_REPRESENTATION = 'storage-local-body-v1'
392+
const SCOPED_CONTENT_REPRESENTATION = 'storage-local-body-v2'
317393

318394
/**
319395
* Produces a canonical metadata stub with a deterministic contentHash that
@@ -690,9 +766,8 @@ export const confluenceConnector: ConnectorConfig = {
690766
throw new Error(`Confluence content is missing its ${bodyFormat} body`)
691767
}
692768
const rawContent = representation.value
693-
const plainText = scopedContent
694-
? confluenceStorageToPlainText(rawContent)
695-
: htmlToPlainText(preserveConfluenceCallouts(rawContent))
769+
const scoped = scopedContent ? extractConfluenceStorageText(rawContent) : null
770+
const plainText = scoped ? scoped.text : htmlToPlainText(preserveConfluenceCallouts(rawContent))
696771

697772
const links = page._links as Record<string, unknown> | undefined
698773
const stub = pageToStub(
@@ -707,7 +782,12 @@ export const confluenceConnector: ConnectorConfig = {
707782

708783
if (!plainText.trim()) {
709784
return {
710-
...markSkipped(stub, 'Document contains no extractable text'),
785+
...markSkipped(
786+
stub,
787+
scoped?.droppedDynamicContent
788+
? DYNAMIC_CONTENT_SKIP_REASON
789+
: 'Document contains no extractable text'
790+
),
711791
skippedExistingDisposition: 'replace',
712792
}
713793
}

apps/sim/connectors/google-drive/google-drive-errors.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,18 @@ const PERMISSION_REASONS = new Set([
2020
'insufficientFilePermissions',
2121
'teamDriveMembershipRequired',
2222
])
23-
const POLICY_REASONS = new Set(['domainPolicy', 'download_restricted_for_revision'])
23+
/**
24+
* Owner- or admin-imposed restrictions on an otherwise readable file. The
25+
* credential is valid, so these are not authorization failures; `cannotExportFile`
26+
* and `cannotDownloadFile` are what Drive returns when the owner disabled
27+
* download, print, and copy for viewers.
28+
*/
29+
const POLICY_REASONS = new Set([
30+
'domainPolicy',
31+
'download_restricted_for_revision',
32+
'cannotDownloadFile',
33+
'cannotExportFile',
34+
])
2435
const UNSUPPORTED_EXPORT_REASONS = new Set(['fileNotDownloadable', 'fileNotExportable'])
2536
const QUOTA_REASONS = new Set(['dailyLimitExceeded', 'quotaExceeded'])
2637
const RATE_LIMIT_REASONS = new Set([

0 commit comments

Comments
 (0)