|
1 | 1 | package tools |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "bytes" |
4 | 5 | "context" |
5 | 6 | "database/sql" |
6 | 7 | "encoding/json" |
@@ -36,15 +37,58 @@ var dataAnalysisTool = BaseTool{ |
36 | 37 | "the single table \"" + DataAnalysisTableName + "\"; write SQL against that table name and never put " + |
37 | 38 | "the document ID inside the SQL. For Excel files with multiple sheets, non-empty sheets are loaded into " + |
38 | 39 | "the same table and the source sheet name is exposed as a '__sheet_name' column so you can " + |
39 | | - "filter/aggregate per sheet. If the user's question requires data statistics, convert the " + |
40 | | - "question into SQL and execute it.", |
| 40 | + "filter/aggregate per sheet. Only the .xlsx container is readable: a legacy binary .xls must be " + |
| 41 | + "re-saved as .xlsx (or exported as .csv) before it can be analysed. If the user's question requires " + |
| 42 | + "data statistics, convert the question into SQL and execute it.", |
41 | 43 | schema: utils.GenerateSchema[DataAnalysisInput](), |
42 | 44 | } |
43 | 45 |
|
44 | 46 | // excelSheetNameColumn is the name of the synthetic column that identifies |
45 | 47 | // which Excel sheet a row came from when multiple sheets are unioned together. |
46 | 48 | const excelSheetNameColumn = "__sheet_name" |
47 | 49 |
|
| 50 | +// legacyXLSMagic is the OLE2/CFB compound-document signature that binary BIFF |
| 51 | +// .xls workbooks start with ("D0 CF 11 E0 A1 B1 1A E1"). |
| 52 | +// |
| 53 | +// DuckDB's excel extension only reads the ZIP-based OOXML container |
| 54 | +// (.xlsx/.xlsm), so a genuine .xls has to be rejected up front: handed to |
| 55 | +// read_xlsx it fails with the opaque "No xl/workbook.xml found in xlsx file", |
| 56 | +// which says nothing about the real cause or what to do about it. |
| 57 | +var legacyXLSMagic = []byte{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1} |
| 58 | + |
| 59 | +// hasLegacyXLSContainer reports whether path holds an OLE2 compound document, |
| 60 | +// i.e. a binary BIFF .xls workbook. |
| 61 | +// |
| 62 | +// The sniff is by content, not by extension: a .xls that is really a renamed |
| 63 | +// .xlsx is a ZIP archive and returns false, so it keeps loading through the |
| 64 | +// normal read_xlsx path. Unreadable or truncated files also return false and |
| 65 | +// are left to DuckDB to report. |
| 66 | +func hasLegacyXLSContainer(path string) bool { |
| 67 | + f, err := os.Open(path) |
| 68 | + if err != nil { |
| 69 | + return false |
| 70 | + } |
| 71 | + defer func() { _ = f.Close() }() |
| 72 | + |
| 73 | + head := make([]byte, len(legacyXLSMagic)) |
| 74 | + if _, err := io.ReadFull(f, head); err != nil { |
| 75 | + return false |
| 76 | + } |
| 77 | + return bytes.Equal(head, legacyXLSMagic) |
| 78 | +} |
| 79 | + |
| 80 | +// errLegacyBinaryXLS is the actionable failure returned for a binary .xls |
| 81 | +// workbook. The data analysis tool cannot read it, and silently reporting an |
| 82 | +// empty table (or leaking DuckDB's xlsx-internals error) leaves the model |
| 83 | +// guessing; naming the conversion to perform is what the caller can act on. |
| 84 | +func errLegacyBinaryXLS() error { |
| 85 | + return fmt.Errorf( |
| 86 | + "legacy binary .xls workbooks are not supported by data analysis: " + |
| 87 | + "only the ZIP-based .xlsx format can be read; " + |
| 88 | + "re-save the workbook as .xlsx (or export the sheet as .csv) and upload it again", |
| 89 | + ) |
| 90 | +} |
| 91 | + |
48 | 92 | // sqlSingleQuoteEscape escapes single quotes in a string so it can be safely |
49 | 93 | // embedded inside a single-quoted SQL literal. |
50 | 94 | func sqlSingleQuoteEscape(s string) string { |
@@ -547,9 +591,23 @@ func (t *DataAnalysisTool) LoadFromCSV(ctx context.Context, filename string, tab |
547 | 591 | // |
548 | 592 | // Note: requires the DuckDB 'excel' extension (for read_xlsx) and the |
549 | 593 | // 'spatial' extension (for st_read_meta used to enumerate sheets). |
| 594 | +// |
| 595 | +// Binary BIFF .xls workbooks are rejected with an actionable error before |
| 596 | +// DuckDB is touched: the excel extension only reads the ZIP-based OOXML |
| 597 | +// container, so a real .xls would otherwise surface as "No xl/workbook.xml |
| 598 | +// found in xlsx file". |
550 | 599 | func (t *DataAnalysisTool) LoadFromExcel(ctx context.Context, filename string, tableName string) (*TableSchema, error) { |
551 | 600 | logger.Infof(ctx, "[Tool][DataAnalysis] Loading Excel file '%s' into table '%s' for session %s", filename, tableName, t.sessionID) |
552 | 601 |
|
| 602 | + if hasLegacyXLSContainer(filename) { |
| 603 | + logger.Warnf(ctx, |
| 604 | + "[Tool][DataAnalysis] Rejecting legacy binary .xls workbook '%s' (session=%s): "+ |
| 605 | + "DuckDB can only read the ZIP-based .xlsx container", |
| 606 | + filename, t.sessionID, |
| 607 | + ) |
| 608 | + return nil, errLegacyBinaryXLS() |
| 609 | + } |
| 610 | + |
553 | 611 | if !t.loadedTables[tableName] { |
554 | 612 | t.recordCreatedTable(tableName) |
555 | 613 | sheetNames, enumErr := t.listExcelSheets(ctx, filename) |
|
0 commit comments