Skip to content

Commit 78e14b2

Browse files
waleedlatif1claude
andauthored
fix(parsers): structure-preserving knowledge base parsers with before/after benchmark (#7709)
* chore(parsers): add parser quality evaluation framework Ground-truth corpus generator, real-world fetcher, bun harness over the production parseBuffer path, reference extractors and scorer, plus the plan and findings from the 2026-09-09 audit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parsers): index spreadsheet cells as display text `XlsxParser` converted sheets without `raw: false`, so the indexed text held stored values rather than what a user sees: dates as Excel serials (46085), 20% as 0.2, $1,250.00 as 1250, booleans as `true`, and ODS dates as `String(Date)` in the worker's local time zone. The Google Drive connector exports every Google Sheet through this parser while the Sheets and Excel connectors already request formatted text, so the same sheet indexed differently by path. The Files viewer had the same defect. Read with `cellDates` + `cellNF` and convert with `raw: false`, rewriting only the two cases the file's own text gets wrong inside the bounded window: dates become zone-free ISO text from the UTC fields SheetJS parsed, and General numbers print their full stored value instead of Excel's 11-char rendering (4111111111111111 -> 4.11111E+15). The shared pass handles dense and sparse sheets so the viewer reuses it without pulling `xlsx` into the client bundle. The parser-eval fixture used `0.#%`, which Excel renders as `20.%`; it now uses `0%` / `0.0%` so the spec strings match Excel. The `sheet-wide` row builder is also typed so the script type-checks. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parsers): walk office document structure instead of flattening cells DOCX now routes mammoth's HTML rendering through the shared HTML structured-text walker so tables keep their rows, lists keep their markers, and footnotes survive; the unread metadata.html field is gone. PPTX and ODT/ODP get dedicated XML walkers that render tables row by row, skip slide-number/date/header/footer placeholders, read presenter notes from the notes body placeholder only, and drop ODF annotations and tracked deletions. Legacy OLE .ppt is rejected as unsupported_type instead of scraping printable bytes from the container. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parsers): decode text by encoding, sniff bytes before routing, read legacy .doc Text parsers decoded every buffer as UTF-8 and then stripped U+FFFD, so a Latin-1 or Windows-1252 file silently lost every accented character, a UTF-8 BOM leaked into content and broke JSON.parse, and UTF-16 only worked for ASCII. `decodeTextBuffer` (BOM > strict UTF-8 with a guarded truncated-tail retry > Windows-1252) now backs txt/md/csv/json/jsonl/yaml, the .doc plain-text fallback and the connectors' text decode, and records `encoding`/`warning` in metadata. `parseBuffer` routed on the caller-supplied extension alone. `sniff.ts` now identifies the bytes (PDF, OLE2, ZIP central-directory part names, ODF mimetype, UTF-16 layout, HTML head) and reconciles them with the extension's family: a sniffed kind with its own parser overrides the route and records `detectedType`; binary/unknown bytes under a mismatched family are a typed `invalid_format` instead of mojibake or placeholder prose. Legacy OLE .doc goes through word-extractor (body, headers, footers, footnotes, endnotes; Word 6/95 magic maps to `unsupported_type`); the byte scrape that returned ZIP part names as degraded prose is deleted. Legacy .ppt is dropped from the registry, upload and connector allowlists and Chat's parseable set so it is refused up front. Chat's file reader and the internal file tool now treat `degraded` output as a parse failure. pdf.js `InvalidPDFException`/`FormatError`/`PasswordException` are mapped to typed parser errors at the single `openPdfDocument` choke point, and the zip guard's `ArchiveIntegrityError` surfaces from `parseBuffer` as a typed `invalid_format`, so neither classifies as transient and retries forever. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parsers): decode HTML by detected encoding and add before/after benchmark Wires decodeTextBuffer into the HTML parser, refreshes the degraded docblock now that legacy formats raise typed errors, and adds the large corpus harness plus the regression-gated comparer. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parsers): rebuild PDF line and paragraph structure from item geometry The PDF parser collapsed every page to a single line and concatenated items without separators, so the chunker fell back to sentence splits, words fused across Form XObject boundaries and backwards x-moves, and running headers/footers landed mid-sentence in most chunks. - Build positioned lines from pdf.js item transforms; derive separators from baseline shifts, backwards x-moves, and word-sized gaps, falling back to hasEOL when an item carries no geometry - Join lines per page with paragraph breaks from the median pitch and height changes, rejoin same-row and wrapped table cells, and dehyphenate line-end breaks unless the compound appears intact in the document - Suppress repeated header/footer furniture and page numbers across pages, keeping the first occurrence of each - Prefix short oversized lines with a heading marker - Replace the whitespace collapse with a structure-preserving normaliser and join pages with a paragraph break Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parsers): tighten benchmark-found edge cases Ambiguous archives and binary layouts stay on the SheetJS and legacy Word routes instead of being refused; line-end hyphens are removed only when the document shows the joined word; page numbers printed inside a wide margin are dropped from a page's edge lines; time-of-day cells no longer carry the 1899 epoch; table cells with several paragraphs keep a space between them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(parsers): record the before/after parser benchmark Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * style(parsers): apply biome formatting Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parsers): accept YAML document streams and JSON with comments Kubernetes manifests, Helm output and CI fixtures hold several YAML documents separated by ---; js-yaml's single-document load rejected them outright. A stream now becomes one item per document. JSON files with comments or trailing commas (tsconfig, editor settings) parse leniently after strict parsing fails, with a warning in metadata. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parsers): render elapsed, time-only and General cells as Excel does Elapsed formats (`[h]:mm`, `[mm]:ss`) are durations; `cellDates` still parses them into a Date, so the ISO rewrite fabricated `1900-01-01T06:00:00` where Excel shows `30:00`. Their SSF-rendered `w` is now kept. A time-only cell was decided by its epoch year, which breaks in a 1904 workbook where `h:mm:ss` landed on `1904-01-01T12:29:59`; the decision now comes from the format (no `y`/`d`, and every `m` run beside hours or seconds), verified for xlsx, xls, xlsb and ods in both epochs. General numbers round fractions to Excel's 15 significant digits (`=0.1+0.2` reads `0.3`) while integers stay exact. The Files viewer read its workbook without `cellDates`/`cellNF`, which left the normalizer overwriting every rendered `w`; the read now lives in `readXlsxWorkbook` with the display options, and its test builds the fixture through that read path. A tab or line break inside a cell no longer splits the row. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parsers): round float date serials to the nearest second A serial such as 45366.572916666664 parses to 13:44:59.999, and slicing the ISO string truncated it to 13:44:59 — one second early for three of twelve probed cells. The instant is rounded to the nearest second before either the date-time or time-only text is formatted, and a value that rounds up to midnight renders as a whole date. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(parser-eval): harden the comparer and pin the benchmark corpus Sample reference lines across the whole document instead of its head, add count-aware word-depletion checks so a repeated table header that vanishes is visible, score noise symmetrically, and commit the corpus build scripts with a SHA-256 manifest so the 961-file benchmark can be rebuilt. Adds a README with requirements. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parsers): close audit findings on byte sniffing, decoding and legacy formats Routing and the KB pipeline: - A non-PDF named .pdf (HTML error page, plain text) is rejected as a permanent `invalid_file` in `readEmbeddedPdfText` instead of being indexed as the "text layer" or sent to OCR to fail terminally; the document processor logs `detectedType`/`warning` at info with the filename (no document id is in scope in that module). - The HTML override now applies only to `.txt` and `.md` (a `.md` opening with `<!DOCTYPE html>` is deliberately treated as HTML); an HTML document under csv/json/jsonl/yaml is `invalid_format`. - RTF (`{\rtf` at offset 0) is a sniffed kind and is `unsupported_type` under any extension, so control words are never indexed as prose. - `%PDF-` is searched through the first KiB only under a declared `.pdf`; elsewhere it must be at offset 0 (after BOM/whitespace), so a `.txt` that mentions the magic string stays text. - NUL bytes in a declared text file keep the text route: the decoder handles UTF-16/Windows-1252 and the sanitizer strips stray NULs. Recognised containers under a text extension are still refused. - `resolveParserExtension` throws `FileParserError('unsupported_type')`, so stored `.ppt` documents dead-letter as permanent instead of burning the retry budget as transient. - Workspace-file "get content" (`internal/file/operations.ts`) treats `degraded` output as a parse failure like the other two tool paths. Decoding: - Windows-1252 uses the runtime `TextDecoder` when a module-init self-test proves the label is real (Bun 1.3.14), else a one-pass table decode into UTF-16 code units. 100 MB of C1 bytes: 379 ms / +200 MB native, 286 ms / +401 MB table (was ~4 s / +5.4 GB). - Connectors sftp, s3, databricks, google-drive and bitbucket decode through `decodeTextBuffer` (bitbucket previously skipped non-UTF-8 files; it now indexes them decoded). Connectors hash source revisions (blob sha, etag, rev), not decoded text, so there is no mass re-sync: documents indexed earlier with mojibake stay as they are until the source changes. Legacy .doc: text boxes are a sixth extracted section; word-extractor's raw `RangeError` text is replaced by the stable "This .doc file could not be read". Behaviour notes: UTF-32 input is not recognised and decodes as Windows-1252; BOM-less UTF-16 whose code units are mostly non-ASCII (CJK) does not match the NUL-layout heuristic and also falls to Windows-1252 — the warning now says the file may use another encoding. Stale `.ppt` mentions removed from the files-audit OpenAPI description (regenerated), the Box representation list, `OFFICE_REPAIR_EXTENSIONS` and two TSDoc blocks; the package.json re-sort from the previous commit is reverted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parsers): dedupe nested tables, sniff encrypted OOXML, cap XML parts Nested tables in HTML and DOCX were emitted once glued into the outer cell and again as rows of their own; the HTML walker now visits only a table's direct rows and renders a nested table inline as its cells joined with ' / '. The ODF walker does the same inside cells. An encrypted .docx/.pptx/.xlsx is an OLE container carrying EncryptedPackage and EncryptionInfo streams, so the sniff now reports it as encrypted-ooxml and every route maps it to encrypted_file instead of unsupported_type or a mojibake plaintext fallback. The presentation and ODF walkers bound each XML part at 16 MB before parsing, honor mc:AlternateContent, skip slidenum/datetime fields anywhere, clamp notes targets under ppt/notesSlides, emit picture alt text, and reject an archive with no content.xml as invalid_format. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(parsers): re-record tool-registry module baseline for the new parser modules The knowledge page reaches document-processor and therefore the file parsers; seven new parser modules plus word-extractor's dependency tree add server-graph modules beyond the allowed drift. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parsers): keep table headers, bound PDF assembly, disable heading markers Furniture suppression treated any band text repeating across pages as a running header, which deleted multi-page table headers (IRS tax tables, NIST recommendation tables, EIC tables, DFAST captions). A band group is now exempt when it runs into the body at line pitch, when its key also occurs in body positions, or when dropping it would orphan a hyphenated word; folio candidates get the same flow test so edge table cells survive, and roman numerals must parse and fit the page count. joinLines accumulated the page in one string and ran anchored regexes over it per line, which was quadratic (40k lines: 72 s, now 7 ms); the hyphen and compound scans are bounded to the line tail, assembly yields to the event loop and honours the abort signal, and the line count and word set are capped. Geometry separators no longer count against the character budget, and preview output that overflows after decoration sets the truncated flag. Heading markers are off by default: on documents dominated by table or footnote text the estimated body height turned prose into headings that the chunker then split per line. The estimator now weighs prose-like lines only and skips runs of same-height lines for when it is enabled. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parsers): keep date fields, drop file-name image alt text, fast-path plain cells Only the slide-number field is layout text; date and time fields outside a dt placeholder are content, and skipping them emptied a deck made of them. Image alternative text that is a bare file name or an auto caption is noise, so the HTML, PresentationML, and OpenDocument walkers share one filter. A table cell with no element children is read directly instead of running the block-spacing and nested-table queries. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(parsers): regenerate the benchmark from the final run Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parsers): walk slides in display order and read SmartArt and chart text The PPTX walker sorted physical slide part names, but a deck reordered in PowerPoint keeps its old part names and changes only p:sldIdLst, so it was indexed out of order. Slides now follow the presentation's id list resolved through its rels, skipping ids whose part is missing and falling back to part numbering only when nothing resolves. Graphic frames that hold SmartArt or a chart were dropped entirely; the diagram data part's dgm:pt text bodies and a modest chart summary (title, axis titles, series, categories) are now emitted, with connector text included. Every relationship target is clamped under ppt/ and read through the per-part size cap. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parsers): bound ODF whitespace expansion and accept absolute OPC targets An ODF text:s element passed its text:c count straight to String.repeat, so one tiny element could request a multi-gigabyte allocation. Each run is now capped at 100 spaces, every emitted piece is charged against a 16 MiB document budget that throws complexity_limit before later parts are inflated, and both walkers assert the assembled text against the same ceiling. The line-end trim that followed was quadratic on a long whitespace run — a document inside the budget could still hang it — so it is now a linear per-line trimEnd. OPC relationship targets may be package-absolute (/ppt/slides/slide1.xml); the PPTX resolver joined them onto the base directory and skipped those parts. A leading slash now resolves from the package root under the same ppt/ clamp. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent b8ceb2e commit 78e14b2

109 files changed

Lines changed: 16102 additions & 758 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/openapi-v2-files-audit.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -745,7 +745,7 @@
745745
"get": {
746746
"operationId": "readFileText",
747747
"summary": "Read File Text",
748-
"description": "Extract text without changing the file. Use Unzip File to unpack archives or Download File for original bytes. Unsupported types return `400`, compiling documents return `409`, and oversized files return `413`. `degraded: true` indicates incomplete or synthesized text, including some legacy `.doc` and `.ppt` results; `truncated: true` indicates a parser limit.\n\nOAuth scope: `api:read`.",
748+
"description": "Extract text without changing the file. Use Unzip File to unpack archives or Download File for original bytes. Unsupported types return `400`, compiling documents return `409`, and oversized files return `413`. `degraded: true` indicates incomplete or synthesized text, such as the legacy `.pptx` fallback; `truncated: true` indicates a parser limit.\n\nOAuth scope: `api:read`.",
749749
"x-sim-operation": "files.read_content",
750750
"x-oauth-scope": "api:read",
751751
"tags": ["Files"],

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
55
import * as XLSX from 'xlsx'
66
import {
77
readXlsxPreviewData,
8+
readXlsxWorkbook,
89
XLSX_MAX_COLUMNS,
910
XLSX_MAX_ROWS,
1011
} from '@/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data'
@@ -26,9 +27,11 @@ describe('readXlsxPreviewData', () => {
2627
const result = readXlsxPreviewData(XLSX, sheet)
2728
const options = toJson.mock.calls[0][1] as {
2829
range: { s: { r: number }; e: { r: number } }
30+
raw?: boolean
2931
}
3032

3133
expect(options.range.e.r - options.range.s.r).toBe(XLSX_MAX_ROWS)
34+
expect(options.raw).toBe(false)
3235
expect(result.headers).toEqual(['header-a', 'header-b'])
3336
expect(result.rows).toHaveLength(XLSX_MAX_ROWS)
3437
expect(result.rows.slice(0, 2)).toEqual([
@@ -71,4 +74,37 @@ describe('readXlsxPreviewData', () => {
7174
expect(result.rowTruncated).toBe(false)
7275
expect(result.columnTruncated).toBe(true)
7376
})
77+
78+
/**
79+
* Built through the viewer's own read path rather than by hand-setting `z`,
80+
* so the assertions cover the read options as well as the conversion.
81+
*/
82+
function typedWorkbook(): ArrayBuffer {
83+
const sheet = XLSX.utils.aoa_to_sheet([['Issued', 'Rate', 'Card', 'Elapsed']])
84+
sheet.A2 = { t: 'd', v: new Date(Date.UTC(2026, 2, 4)), z: 'm/d/yyyy' }
85+
sheet.B2 = { t: 'n', v: 0.2, z: '0%' }
86+
sheet.C2 = { t: 'n', v: 4111111111111111 }
87+
sheet.D2 = { t: 'n', v: 1.25, z: '[h]:mm' }
88+
sheet['!ref'] = 'A1:D2'
89+
const book = XLSX.utils.book_new()
90+
XLSX.utils.book_append_sheet(book, sheet, 'Ledger')
91+
const bytes = XLSX.write(book, { type: 'buffer', bookType: 'xlsx' }) as Buffer
92+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
93+
}
94+
95+
it('shows display text rather than stored values', () => {
96+
const workbook = readXlsxWorkbook(XLSX, typedWorkbook())
97+
98+
const result = readXlsxPreviewData(XLSX, workbook.Sheets.Ledger)
99+
100+
expect(result.rows).toEqual([['2026-03-04', '20%', '4111111111111111', '30:00']])
101+
})
102+
103+
it('reads the workbook with the display-text options', () => {
104+
const read = vi.fn(XLSX.read)
105+
106+
readXlsxWorkbook({ read, utils: XLSX.utils }, typedWorkbook())
107+
108+
expect(read.mock.calls[0][1]).toMatchObject({ type: 'array', cellDates: true, cellNF: true })
109+
})
74110
})

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
1-
import type { WorkSheet } from 'xlsx'
1+
import type { WorkBook, WorkSheet } from 'xlsx'
2+
import {
3+
normalizeSheetDisplayText,
4+
SHEET_DISPLAY_READ_OPTIONS,
5+
} from '@/lib/file-parsers/sheet-display-text'
26

37
export const XLSX_MAX_ROWS = 1_000
48
export const XLSX_MAX_COLUMNS = 200
59

610
interface XlsxModule {
7-
utils: Pick<typeof import('xlsx').utils, 'decode_range' | 'sheet_to_json'>
11+
read: typeof import('xlsx').read
12+
utils: Pick<typeof import('xlsx').utils, 'decode_range' | 'encode_cell' | 'sheet_to_json'>
13+
}
14+
15+
/**
16+
* Reads a workbook for preview with the options that make its cells carry
17+
* display text: without `cellDates` a date arrives as a bare serial and
18+
* without `cellNF` no cell has a format, so every rendered `w` would be
19+
* overwritten as a General number.
20+
*/
21+
export function readXlsxWorkbook(XLSX: XlsxModule, data: ArrayBuffer): WorkBook {
22+
return XLSX.read(new Uint8Array(data), { type: 'array', ...SHEET_DISPLAY_READ_OPTIONS })
823
}
924

1025
interface XlsxPreviewData {
@@ -18,12 +33,21 @@ export function readXlsxPreviewData(XLSX: XlsxModule, sheet: WorkSheet): XlsxPre
1833
const declaredRange = XLSX.utils.decode_range(sheet['!ref'] || 'A1')
1934
const lastPreviewRow = Math.min(declaredRange.e.r, declaredRange.s.r + XLSX_MAX_ROWS)
2035
const lastPreviewColumn = Math.min(declaredRange.e.c, declaredRange.s.c + XLSX_MAX_COLUMNS - 1)
36+
const previewRange = {
37+
s: declaredRange.s,
38+
e: { r: lastPreviewRow, c: lastPreviewColumn },
39+
}
40+
41+
/**
42+
* Shown as the text a user sees in Excel: `raw: false` emits each cell's
43+
* formatted text, so a sheet read through {@link readXlsxWorkbook} shows a
44+
* date as ISO text and `20%` rather than a serial and `0.2`.
45+
*/
46+
normalizeSheetDisplayText(sheet, previewRange, XLSX.utils)
2147
const previewRows = XLSX.utils.sheet_to_json<string[]>(sheet, {
2248
header: 1,
23-
range: {
24-
s: declaredRange.s,
25-
e: { r: lastPreviewRow, c: lastPreviewColumn },
26-
},
49+
raw: false,
50+
range: previewRange,
2751
})
2852

2953
return {

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
1010
import { useHorizontalWheelScroll } from '@/app/workspace/[workspaceId]/files/components/file-viewer/use-horizontal-wheel-scroll'
1111
import {
1212
readXlsxPreviewData,
13+
readXlsxWorkbook,
1314
XLSX_MAX_COLUMNS,
1415
XLSX_MAX_ROWS,
1516
} from '@/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data'
@@ -55,7 +56,7 @@ export const XlsxPreview = memo(function XlsxPreview({
5556
setRenderError(null)
5657
await assertOoxmlPreviewWithinLimits(data)
5758
const XLSX = await import('xlsx')
58-
const workbook = XLSX.read(new Uint8Array(data), { type: 'array' })
59+
const workbook = readXlsxWorkbook(XLSX, data)
5960
if (!cancelled) {
6061
workbookRef.current = workbook
6162
setSheetNames(workbook.SheetNames)

apps/sim/connectors/azure-devops/azure-devops.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage, toError } from '@sim/utils/errors'
3+
import { decodeTextBuffer } from '@/lib/file-parsers/utils'
34
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
45
import { azureDevopsConnectorMeta } from '@/connectors/azure-devops/meta'
56
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
@@ -1182,7 +1183,7 @@ async function getFileDocument(
11821183
return null
11831184
}
11841185

1185-
const content = buffer.toString('utf8')
1186+
const content = decodeTextBuffer(buffer).text
11861187
if (!content.trim()) return null
11871188

11881189
const title = path.split('/').filter(Boolean).pop() || path

apps/sim/connectors/bitbucket/bitbucket.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -774,7 +774,7 @@ describe('bitbucket getDocument', () => {
774774
expect(doc?.skippedReason).toMatch(/Binary/)
775775
})
776776

777-
it('surfaces non-UTF-8 source as skipped instead of indexing replacement characters', async () => {
777+
it('decodes non-UTF-8 source as Windows-1252 instead of skipping or indexing replacement characters', async () => {
778778
mockApi([
779779
[
780780
/\/src\/[a-f0-9]+\/latin1\.txt$/,
@@ -784,8 +784,9 @@ describe('bitbucket getDocument', () => {
784784

785785
const doc = await bitbucketConnector.getDocument(ACCESS_TOKEN, CONFIG, 'file:latin1.txt', {})
786786

787-
expect(doc?.skippedReason).toMatch(/Non-UTF-8/)
788-
expect(doc?.content).toBe('')
787+
expect(doc?.skippedReason).toBeUndefined()
788+
expect(doc?.content).toContain('café')
789+
expect(doc?.content).not.toContain('\uFFFD')
789790
})
790791

791792
it('returns null for a file the ref no longer carries', async () => {

apps/sim/connectors/bitbucket/bitbucket.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage, toError } from '@sim/utils/errors'
3+
import { decodeTextBuffer } from '@/lib/file-parsers/utils'
34
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
45
import { bitbucketConnectorMeta } from '@/connectors/bitbucket/meta'
56
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
@@ -81,7 +82,6 @@ const BINARY_SNIFF_BYTES = 8000
8182
*/
8283
const MAX_TREE_DEPTH = 5
8384
const BINARY_SKIP_REASON = 'Binary file was not indexed'
84-
const NON_UTF8_SKIP_REASON = 'Non-UTF-8 file was not indexed'
8585
/**
8686
* Bitbucket answers a raw read of an LFS-managed file with a 301 to Atlassian's
8787
* media services platform. The connector deliberately surfaces the file as
@@ -1240,13 +1240,7 @@ export const bitbucketConnector: ConnectorConfig = {
12401240
return markSkipped(stub, BINARY_SKIP_REASON)
12411241
}
12421242

1243-
let text: string
1244-
try {
1245-
text = new TextDecoder('utf-8', { fatal: true }).decode(buffer)
1246-
} catch {
1247-
logger.info('Skipping non-UTF-8 Bitbucket file', { path })
1248-
return markSkipped(stub, NON_UTF8_SKIP_REASON)
1249-
}
1243+
const text = decodeTextBuffer(buffer).text
12501244

12511245
const body = composeBody(stub.title, text)
12521246
if (!body.trim()) return null

apps/sim/connectors/box/box.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
33
import { sleep } from '@sim/utils/helpers'
4+
import { decodeTextBuffer } from '@/lib/file-parsers/utils'
45
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
56
import { boxConnectorMeta } from '@/connectors/box/meta'
67
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
@@ -132,7 +133,6 @@ const REPRESENTATION_EXTENSIONS = new Set([
132133
'odt',
133134
'otp',
134135
'pdf',
135-
'ppt',
136136
'pptx',
137137
'rtf',
138138
'vi',
@@ -319,7 +319,7 @@ async function fetchPlainTextContent(
319319
extension: string
320320
): Promise<string> {
321321
const buffer = await downloadWithinLimit(`${BOX_API_BASE}/files/${fileId}/content`, accessToken)
322-
const text = buffer.toString('utf8')
322+
const { text } = decodeTextBuffer(buffer)
323323
return HTML_EXTENSIONS.has(extension) ? htmlToPlainText(text) : text
324324
}
325325

@@ -347,7 +347,7 @@ async function fetchExtractedText(
347347
urlTemplate.replace('{+asset_path}', ''),
348348
accessToken
349349
)
350-
return buffer.toString('utf8')
350+
return decodeTextBuffer(buffer).text
351351
}
352352
if (state === 'error' || !infoUrl) return null
353353
if (attempt === REPRESENTATION_POLL_ATTEMPTS) break

apps/sim/connectors/databricks/databricks.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
22
import { getErrorMessage, toError } from '@sim/utils/errors'
33
import { truncate } from '@sim/utils/string'
44
import { validateDatabricksWorkspaceHost } from '@/lib/core/security/input-validation'
5+
import { decodeTextBuffer } from '@/lib/file-parsers/utils'
56
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
67
import {
78
DATABRICKS_CONTENT_TYPES,
@@ -586,7 +587,7 @@ async function exportNotebook(
586587
return { skippedReason: sizeLimitSkipReason(CONNECTOR_MAX_FILE_BYTES) }
587588
}
588589

589-
return { content: decoded.toString('utf8') }
590+
return { content: decodeTextBuffer(decoded).text }
590591
}
591592

592593
/**

apps/sim/connectors/dropbox/dropbox.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
3+
import { decodeTextBuffer } from '@/lib/file-parsers/utils'
34
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
45
import { dropboxConnectorMeta } from '@/connectors/dropbox/meta'
56
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
@@ -147,7 +148,7 @@ async function downloadFileContent(
147148
throw new ConnectorFileTooLargeError(MAX_FILE_SIZE)
148149
}
149150

150-
const text = buffer.toString('utf8')
151+
const { text } = decodeTextBuffer(buffer)
151152

152153
return isHtml ? htmlToPlainText(text) : text
153154
}

0 commit comments

Comments
 (0)