From 0fc4524c7c93bfc74f33e498e030ad18ad00db51 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Thu, 17 Sep 2026 23:26:20 -0700 Subject: [PATCH] feat(confluence): index PowerPoint and Excel attachments Extend the Confluence attachment allowlist so .pptx and .xlsx files on synced pages and blog posts are listed and handed to the shared parser pipeline the same way PDF and Word attachments already are. Macro-enabled, template, legacy binary and OpenDocument variants stay excluded. Add listing, hydration, genuine-bytes roundtrip and renamed-to-unsupported coverage, and update the connector guides to name the new formats. --- .../content/docs/knowledgebase/connectors.mdx | 4 +- apps/docs/content/docs/search/confluence.mdx | 8 +- .../connectors/confluence/attachments.test.ts | 133 ++++++++++++++---- apps/sim/connectors/confluence/attachments.ts | 12 +- 4 files changed, 119 insertions(+), 38 deletions(-) diff --git a/apps/docs/content/docs/knowledgebase/connectors.mdx b/apps/docs/content/docs/knowledgebase/connectors.mdx index 8aade70fc02..dd74389e69e 100644 --- a/apps/docs/content/docs/knowledgebase/connectors.mdx +++ b/apps/docs/content/docs/knowledgebase/connectors.mdx @@ -78,7 +78,7 @@ Each connector has source-specific fields that control what gets synced. Example - **Notion** — sync an entire workspace, a specific database, or a single page tree - **GitHub** — specify a repository, branch, and optional file extension filter -- **Confluence** — enter your Atlassian domain and choose spaces, or **All** for all spaces accessible at each sync. Optionally filter by content type or label. PDF and Word (`.docx`, Word 97–2003 `.doc`) attachments on matching pages and blog posts are included as separate documents. +- **Confluence** — enter your Atlassian domain and choose spaces, or **All** for all spaces accessible at each sync. Optionally filter by content type or label. PDF, Word (`.docx`, Word 97–2003 `.doc`), Excel (`.xlsx`), and PowerPoint (`.pptx`) attachments on matching pages and blog posts are included as separate documents. - **Azure DevOps** — choose what to sync (wiki pages, work items, repository files, or all), with optional work item type/state filters, a custom WIQL query, and repository/branch/path filters - **Amazon S3** — point at a bucket with an optional key prefix and a customizable file extension allowlist; S3-compatible stores (Cloudflare R2, MinIO) are supported via a custom endpoint - **YouTube** — sync a channel (by `@handle` or ID) or playlist, with an optional published-after date filter and the option to exclude Shorts @@ -88,7 +88,7 @@ Each connector has source-specific fields that control what gets synced. Example Configuration is validated on save — if a repository doesn't exist or a domain is unreachable, you'll see an error immediately. -Confluence attachment indexing requires `read:attachment:confluence`. For a service account, include it when creating the scoped API token; see the [Confluence scope list](/search/confluence#using-a-service-account). Attachments are checked even when the parent page has not changed. Files over 100 MB appear as skipped; convert Word 6/95 files to `.docx` before attaching them. +Confluence attachment indexing requires `read:attachment:confluence`. For a service account, include it when creating the scoped API token; see the [Confluence scope list](/search/confluence#using-a-service-account). Attachments are checked even when the parent page has not changed. Files over 100 MB appear as skipped; convert Word 6/95 files to `.docx`, and `.xls` and `.ppt` files to `.xlsx` and `.pptx`, before attaching them. Spaces that were already connected pick up newly supported formats on their next sync. diff --git a/apps/docs/content/docs/search/confluence.mdx b/apps/docs/content/docs/search/confluence.mdx index aab0393b058..968b0b24e3f 100644 --- a/apps/docs/content/docs/search/confluence.mdx +++ b/apps/docs/content/docs/search/confluence.mdx @@ -7,7 +7,7 @@ import { Callout } from 'fumadocs-ui/components/callout' import { Step, Steps } from 'fumadocs-ui/components/steps' import { Image } from '@/components/ui/image' -Search pages, blog posts, and their PDF and Word attachments from selected Confluence Cloud spaces. A Sim organization admin enables Confluence; **each teammate connects their own account**. +Search pages, blog posts, and their PDF, Word, Excel, and PowerPoint attachments from selected Confluence Cloud spaces. A Sim organization admin enables Confluence; **each teammate connects their own account**. | Method | How it works | | --- | --- | @@ -125,9 +125,9 @@ See Atlassian's [account setup](https://support.atlassian.com/user-management/do | **Filter by Label** | Optional comma-separated labels; content can match any listed label. | | **Metadata tags** | Labels, version, and last-modified tags. | -Search manages the schedule and hides item limits. It indexes published/current content and each page's own text, including supported local callouts and code blocks. PDF, Word `.docx`, and Word 97–2003 `.doc` attachments on the selected pages and blog posts are indexed as separate documents with their parent content's permissions. Space, content-type, and label filters apply to the parent content. Attachment changes are checked on each sync, even when the parent text has not changed. +Search manages the schedule and hides item limits. It indexes published/current content and each page's own text, including supported local callouts and code blocks. PDF, Word `.docx`, Word 97–2003 `.doc`, Excel `.xlsx`, and PowerPoint `.pptx` attachments on the selected pages and blog posts are indexed as separate documents with their parent content's permissions. Space, content-type, and label filters apply to the parent content. Attachment changes are checked on each sync, even when the parent text has not changed. -Archived content, comments, other attachment formats, and expanded Include Page, Excerpt Include, or third-party macro output are excluded. Referenced pages can be indexed separately with their own permissions. Attachments over 100 MB are shown as skipped; convert older Word 6/95 files to `.docx` before attaching them. +Archived content, comments, other attachment formats, and expanded Include Page, Excerpt Include, or third-party macro output are excluded. Referenced pages can be indexed separately with their own permissions. Attachments over 100 MB are shown as skipped; convert older Word 6/95 files to `.docx`, and `.xls` and `.ppt` files to `.xlsx` and `.pptx`, before attaching them. Spaces that were already connected pick up newly supported formats on their next sync. ## Manage access and sync @@ -151,7 +151,7 @@ In **Sync history**, **Continuing** means a healthy listing needs another batch. | A new page, blog post, or label is missing | Confluence search can take time to update. Once the content appears in Confluence search with the selected label, sync again. | | A restricted page is missing | Both your account and the crawling account need access to the page and its ancestors. | | Embedded content is missing | Index the referenced page separately; remote macro output is excluded. | -| PDF or Word attachments are missing | Check `read:attachment:confluence` and access to the parent page. Existing service-account tokens may need to be replaced with one that includes this scope. Attachment access failures are reported as a partial sync. | +| Attachments are missing | Check `read:attachment:confluence` and access to the parent page. Existing service-account tokens may need to be replaced with one that includes this scope. Attachment access failures are reported as a partial sync. | | **Reconnect** or email mismatch | Authorize with the Atlassian account matching your verified Sim email and grant all requested permissions. | Open a missing page as the affected teammate, check its space and page restrictions, then sync again after correcting access. See Atlassian's [content access](https://support.atlassian.com/confluence-cloud/docs/add-or-remove-page-restrictions/) and [permission inspection](https://support.atlassian.com/confluence-cloud/docs/inspect-a-users-permissions/) guides. diff --git a/apps/sim/connectors/confluence/attachments.test.ts b/apps/sim/connectors/confluence/attachments.test.ts index f74e033e96c..dedfc6d95f4 100644 --- a/apps/sim/connectors/confluence/attachments.test.ts +++ b/apps/sim/connectors/confluence/attachments.test.ts @@ -4,12 +4,13 @@ import JSZip from 'jszip' import { PDFDocument, StandardFonts } from 'pdf-lib' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as XLSX from 'xlsx' import { DEFAULT_MAX_ERROR_BODY_BYTES, PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { parseBuffer } from '@/lib/file-parsers' import { listConfluenceAttachments } from '@/connectors/confluence/attachments' import { confluenceConnector } from '@/connectors/confluence/confluence' import type { ExternalDocument, ExternalDocumentList } from '@/connectors/types' -import { CONNECTOR_MAX_FILE_BYTES } from '@/connectors/utils' +import { CONNECTOR_MAX_FILE_BYTES, PIPELINE_PARSED_MIME_TYPES } from '@/connectors/utils' const { secureDownload } = vi.hoisted(() => ({ secureDownload: vi.fn() })) vi.mock('@/lib/knowledge/documents/secure-fetch.server', async (importOriginal) => ({ @@ -90,6 +91,75 @@ async function get(externalId = 'attachment:page:p1:att123', config = CONFIG) { return confluenceConnector.getDocument('token', config, externalId, { ...CONTEXT }) } +const ROUNDTRIP_TEXT = 'Confluence attachment text' +const OOXML_REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' +const PACKAGE_RELS_NS = 'xmlns="http://schemas.openxmlformats.org/package/2006/relationships"' +const PRESENTATION_NS = + 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"' + +async function pdfBytes(): Promise { + const document = await PDFDocument.create() + const font = await document.embedFont(StandardFonts.Helvetica) + document.addPage().drawText(ROUNDTRIP_TEXT, { font, size: 14 }) + return Buffer.from(await document.save()) +} + +async function docxBytes(): Promise { + const zip = new JSZip() + zip.file( + '[Content_Types].xml', + '' + ) + zip.file( + '_rels/.rels', + `` + ) + zip.file( + 'word/document.xml', + `${ROUNDTRIP_TEXT}` + ) + return zip.generateAsync({ type: 'nodebuffer' }) +} + +/** One slide resolved through `p:sldIdLst`, the order the presentation walker follows. */ +async function pptxBytes(): Promise { + const zip = new JSZip() + zip.file( + '[Content_Types].xml', + '' + ) + zip.file( + 'ppt/presentation.xml', + `` + ) + zip.file( + 'ppt/_rels/presentation.xml.rels', + `` + ) + zip.file( + 'ppt/slides/slide1.xml', + `${ROUNDTRIP_TEXT}` + ) + return zip.generateAsync({ type: 'nodebuffer' }) +} + +async function xlsxBytes(): Promise { + const book = XLSX.utils.book_new() + XLSX.utils.book_append_sheet( + book, + XLSX.utils.aoa_to_sheet([['Note'], [ROUNDTRIP_TEXT]]), + 'Sheet1' + ) + return XLSX.write(book, { type: 'buffer', bookType: 'xlsx' }) as Buffer +} + +const ROUNDTRIP_FIXTURES = { + pdf: pdfBytes, + docx: docxBytes, + pptx: pptxBytes, + xlsx: xlsxBytes, +} as const + beforeEach(() => { fetchMock.mockReset() secureDownload.mockReset().mockResolvedValue(new Response('binary bytes')) @@ -98,15 +168,20 @@ beforeEach(() => { afterEach(() => vi.unstubAllGlobals()) describe('Confluence attachment listing', () => { - it('lists PDF, DOC and DOCX stubs without downloading, and excludes unsupported or archived files', async () => { + it('lists PDF, Word, PowerPoint and Excel stubs without downloading, and excludes unsupported or archived files', async () => { fetchMock.mockResolvedValue( Response.json({ results: [ file(), file({ id: '2', title: 'Legacy.DOC' }), file({ id: '3', title: 'Modern.docx' }), - file({ id: '4', title: 'image.png' }), - file({ id: '5', title: 'old.pdf', status: 'archived' }), + file({ id: '4', title: 'Deck.pptx' }), + file({ id: '5', title: 'Sheet.XLSX' }), + file({ id: '6', title: 'image.png' }), + file({ id: '7', title: 'Macro.pptm' }), + file({ id: '8', title: 'Legacy.xls' }), + file({ id: '9', title: 'Old.ppt' }), + file({ id: '10', title: 'old.pdf', status: 'archived' }), ], }) ) @@ -119,7 +194,14 @@ describe('Confluence attachment listing', () => { 'attachment:page:p1:att123', 'attachment:page:p1:2', 'attachment:page:p1:3', + 'attachment:page:p1:4', + 'attachment:page:p1:5', ]) + expect(result.documents.slice(1).map((doc) => doc.mimeType)).toEqual( + ['pdf', 'doc', 'docx', 'pptx', 'xlsx'].map((extension) => + PIPELINE_PARSED_MIME_TYPES.get(extension) + ) + ) expect( result.documents.slice(1).every((doc) => doc.contentDeferred && doc.content === '') ).toBe(true) @@ -364,7 +446,7 @@ describe('Confluence attachment hydration', () => { } ) - it.each(['pdf', 'doc', 'docx'])( + it.each(['pdf', 'doc', 'docx', 'pptx', 'xlsx'])( 'hands an original %s file to the shared parser pipeline', async (extension) => { fixture(file({ title: `Guide.${extension}` })) @@ -492,37 +574,30 @@ describe('Confluence attachment hydration', () => { expect(secureDownload.mock.calls[0][1].signal).toBe(signal) }) - it.each(['pdf', 'docx'] as const)( + it.each(['pdf', 'docx', 'pptx', 'xlsx'] as const)( 'roundtrips genuine %s bytes through the public parser', async (extension) => { - let bytes: Buffer - if (extension === 'pdf') { - const document = await PDFDocument.create() - const font = await document.embedFont(StandardFonts.Helvetica) - document.addPage().drawText('Confluence attachment text', { font, size: 14 }) - bytes = Buffer.from(await document.save()) - } else { - const zip = new JSZip() - zip.file( - '[Content_Types].xml', - '' - ) - zip.file( - '_rels/.rels', - '' - ) - zip.file( - 'word/document.xml', - 'Confluence attachment text' - ) - bytes = await zip.generateAsync({ type: 'nodebuffer' }) - } + const bytes = await ROUNDTRIP_FIXTURES[extension]() fixture(file({ title: `Guide.${extension}`, fileSize: bytes.length })) secureDownload.mockResolvedValue(new Response(bytes)) const doc = await get() expect(doc?.sourceFile).toBeDefined() const parsed = await parseBuffer(doc!.sourceFile!.bytes, extension) - expect(parsed.content).toContain('Confluence attachment text') + expect(parsed.content).toContain(ROUNDTRIP_TEXT) + } + ) + + it.each(['Guide.ppt', 'Guide.xls'])( + 'replaces an attachment renamed to unsupported %s without downloading', + async (title) => { + fixture(file({ title })) + const doc = await get() + expect(doc?.skippedReason).toBe( + 'Attachment is no longer a PDF, Word, Excel or PowerPoint document' + ) + expect(doc?.skippedExistingDisposition).toBe('replace') + expect(doc?.contentDeferred).toBe(false) + expect(secureDownload).not.toHaveBeenCalled() } ) }) diff --git a/apps/sim/connectors/confluence/attachments.ts b/apps/sim/connectors/confluence/attachments.ts index 087e84faa20..c3e6467d540 100644 --- a/apps/sim/connectors/confluence/attachments.ts +++ b/apps/sim/connectors/confluence/attachments.ts @@ -26,7 +26,13 @@ const PAGE_SIZE = 50 const REQUESTS_PER_CALL = 5 const MAX_CURSOR_BYTES = 512 * 1024 const MAX_METADATA_BYTES = 2 * 1024 * 1024 -const FILE_EXTENSIONS = new Set(['pdf', 'doc', 'docx']) +/** + * Attachment formats listed for indexing: a deliberate subset of the shared + * `PIPELINE_PARSED_MIME_TYPES`, limited to the headline PDF, Word, Excel and + * PowerPoint extensions. Macro-enabled, template, legacy binary (`.xls`, `.ppt`) + * and OpenDocument variants are not listed for Confluence. + */ +const FILE_EXTENSIONS = new Set(['pdf', 'doc', 'docx', 'pptx', 'xlsx']) const boundedId = z.string().min(1).max(254) const providerCursor = z.string().min(1).max(8192) const parentSchema = z.object({ id: boundedId, type: z.enum(['page', 'blogpost']) }) @@ -406,7 +412,7 @@ function assertDownloadUrl(value: string): void { } } -/** Downloads a version-pinned original for the shared PDF/OCR and Word parsing pipeline. */ +/** Downloads a version-pinned original for the shared PDF/OCR, Word, Excel and PowerPoint parsing pipeline. */ export async function getConfluenceAttachment( input: AttachmentRequest, sourceConfig: Record, @@ -420,7 +426,7 @@ export async function getConfluenceAttachment( const mimeType = attachmentMimeType(attachment) if (!mimeType) { return { - ...markSkipped(stub, 'Attachment is no longer a PDF or Word document'), + ...markSkipped(stub, 'Attachment is no longer a PDF, Word, Excel or PowerPoint document'), skippedExistingDisposition: 'replace', } }