diff --git a/internal/app/import_pipeline.go b/internal/app/import_pipeline.go index f1d28eed..a0d51a35 100644 --- a/internal/app/import_pipeline.go +++ b/internal/app/import_pipeline.go @@ -12,7 +12,6 @@ import ( "GoNavi-Wails/internal/connection" "GoNavi-Wails/internal/db" - "github.com/xuri/excelize/v2" ) const ( @@ -276,8 +275,10 @@ func streamImportFile(filePath string, consumer importFileConsumer) error { return streamJSONImportFile(filePath, consumer) case strings.HasSuffix(lower, ".csv"): return streamCSVImportFile(filePath, consumer) - case strings.HasSuffix(lower, ".xlsx"), strings.HasSuffix(lower, ".xls"): - return streamExcelImportFile(filePath, consumer) + case strings.HasSuffix(lower, ".xlsx"): + return streamXLSXImportFile(filePath, consumer) + case strings.HasSuffix(lower, ".xls"): + return streamLegacyExcelImportFile(filePath, consumer) default: return fmt.Errorf("Unsupported file format") } @@ -361,57 +362,6 @@ func streamCSVImportFile(filePath string, consumer importFileConsumer) error { } } -func streamExcelImportFile(filePath string, consumer importFileConsumer) error { - workbook, err := excelize.OpenFile(filePath) - if err != nil { - return fmt.Errorf("Excel Parse Error: %w", err) - } - defer workbook.Close() - - sheetName := workbook.GetSheetName(0) - if sheetName == "" { - return fmt.Errorf("Excel file has no sheets") - } - - rows, err := workbook.Rows(sheetName) - if err != nil { - return fmt.Errorf("Excel Read Error: %w", err) - } - defer rows.Close() - - if !rows.Next() { - if err := rows.Error(); err != nil { - return fmt.Errorf("Excel Read Error: %w", err) - } - return fmt.Errorf("Excel empty or missing header") - } - header, err := rows.Columns() - if err != nil { - return fmt.Errorf("Excel Read Error: %w", err) - } - columns := cloneImportColumns(header) - if !hasImportUsableColumns(columns) { - return fmt.Errorf("Excel empty or missing header") - } - if err := consumer.SetColumns(columns); err != nil { - return err - } - - for rows.Next() { - record, err := rows.Columns() - if err != nil { - return fmt.Errorf("Excel Read Error: %w", err) - } - if err := consumer.ConsumeRow(buildImportRowFromValues(columns, record)); err != nil { - return err - } - } - if err := rows.Error(); err != nil { - return fmt.Errorf("Excel Read Error: %w", err) - } - return nil -} - func buildImportInsertQuery(dbType, tableName string, columns []string, row map[string]interface{}, columnTypeMap map[string]string) (string, error) { quotedCols := make([]string, 0, len(columns)) values := make([]string, 0, len(columns)) diff --git a/internal/app/memory_reclaim.go b/internal/app/memory_reclaim.go new file mode 100644 index 00000000..21a8f3dc --- /dev/null +++ b/internal/app/memory_reclaim.go @@ -0,0 +1,79 @@ +package app + +import ( + "os" + "runtime" + "runtime/debug" + "strings" + "sync/atomic" + "time" + + "GoNavi-Wails/internal/logger" +) + +const ( + fileTransferMemoryTrimRowsThreshold int64 = 100000 + fileTransferMemoryTrimFileSizeThreshold int64 = 64 * 1024 * 1024 + fileTransferMemoryTrimMinInterval = 3 * time.Second +) + +var ( + fileTransferMemoryTrimRunning atomic.Bool + fileTransferMemoryTrimLastAt atomic.Int64 + + runFileTransferMemoryTrimAsync = func(fn func()) { + go fn() + } + fileTransferMemoryTrimFn = func() { + runtime.GC() + debug.FreeOSMemory() + } +) + +func maybeReleaseFileTransferMemory(reason string, rows int64, filePath string) { + if !shouldReleaseFileTransferMemory(rows, filePath) { + return + } + if !fileTransferMemoryTrimRunning.CompareAndSwap(false, true) { + return + } + + runFileTransferMemoryTrimAsync(func() { + defer fileTransferMemoryTrimRunning.Store(false) + + if delay := nextFileTransferMemoryTrimDelay(); delay > 0 { + time.Sleep(delay) + } + + logger.Infof("大文件导入导出任务结束,尝试回收进程内存:reason=%s rows=%d file=%s", strings.TrimSpace(reason), rows, strings.TrimSpace(filePath)) + fileTransferMemoryTrimFn() + fileTransferMemoryTrimLastAt.Store(time.Now().UnixNano()) + }) +} + +func shouldReleaseFileTransferMemory(rows int64, filePath string) bool { + if rows >= fileTransferMemoryTrimRowsThreshold { + return true + } + filePath = strings.TrimSpace(filePath) + if filePath == "" { + return false + } + info, err := os.Stat(filePath) + if err != nil { + return false + } + return info.Size() >= fileTransferMemoryTrimFileSizeThreshold +} + +func nextFileTransferMemoryTrimDelay() time.Duration { + lastUnixNano := fileTransferMemoryTrimLastAt.Load() + if lastUnixNano <= 0 { + return 0 + } + elapsed := time.Since(time.Unix(0, lastUnixNano)) + if elapsed >= fileTransferMemoryTrimMinInterval { + return 0 + } + return fileTransferMemoryTrimMinInterval - elapsed +} diff --git a/internal/app/memory_reclaim_test.go b/internal/app/memory_reclaim_test.go new file mode 100644 index 00000000..ba4917ae --- /dev/null +++ b/internal/app/memory_reclaim_test.go @@ -0,0 +1,88 @@ +package app + +import ( + "os" + "testing" +) + +func TestShouldReleaseFileTransferMemory_UsesRowsThreshold(t *testing.T) { + if !shouldReleaseFileTransferMemory(fileTransferMemoryTrimRowsThreshold, "") { + t.Fatal("达到行数阈值时应触发内存回收") + } + if shouldReleaseFileTransferMemory(fileTransferMemoryTrimRowsThreshold-1, "") { + t.Fatal("未达到行数阈值时不应仅凭行数触发内存回收") + } +} + +func TestShouldReleaseFileTransferMemory_UsesFileSizeThreshold(t *testing.T) { + f, err := os.CreateTemp("", "gonavi-memory-trim-*.bin") + if err != nil { + t.Fatalf("创建临时文件失败: %v", err) + } + defer os.Remove(f.Name()) + defer f.Close() + + if err := f.Truncate(fileTransferMemoryTrimFileSizeThreshold); err != nil { + t.Fatalf("设置临时文件大小失败: %v", err) + } + + if !shouldReleaseFileTransferMemory(0, f.Name()) { + t.Fatal("达到文件大小阈值时应触发内存回收") + } +} + +func TestMaybeReleaseFileTransferMemory_TriggersTrimForLargeJobs(t *testing.T) { + originalAsync := runFileTransferMemoryTrimAsync + originalTrim := fileTransferMemoryTrimFn + t.Cleanup(func() { + runFileTransferMemoryTrimAsync = originalAsync + fileTransferMemoryTrimFn = originalTrim + fileTransferMemoryTrimRunning.Store(false) + fileTransferMemoryTrimLastAt.Store(0) + }) + + fileTransferMemoryTrimRunning.Store(false) + fileTransferMemoryTrimLastAt.Store(0) + + triggered := 0 + runFileTransferMemoryTrimAsync = func(fn func()) { + fn() + } + fileTransferMemoryTrimFn = func() { + triggered++ + } + + maybeReleaseFileTransferMemory("test-large-job", fileTransferMemoryTrimRowsThreshold, "") + + if triggered != 1 { + t.Fatalf("大任务完成后应触发一次内存回收,got=%d", triggered) + } +} + +func TestMaybeReleaseFileTransferMemory_SkipsSmallJobs(t *testing.T) { + originalAsync := runFileTransferMemoryTrimAsync + originalTrim := fileTransferMemoryTrimFn + t.Cleanup(func() { + runFileTransferMemoryTrimAsync = originalAsync + fileTransferMemoryTrimFn = originalTrim + fileTransferMemoryTrimRunning.Store(false) + fileTransferMemoryTrimLastAt.Store(0) + }) + + fileTransferMemoryTrimRunning.Store(false) + fileTransferMemoryTrimLastAt.Store(0) + + triggered := 0 + runFileTransferMemoryTrimAsync = func(fn func()) { + fn() + } + fileTransferMemoryTrimFn = func() { + triggered++ + } + + maybeReleaseFileTransferMemory("test-small-job", 10, "") + + if triggered != 0 { + t.Fatalf("小任务不应触发内存回收,got=%d", triggered) + } +} diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index 2bb306b5..3342cde8 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -24,7 +24,6 @@ import ( "GoNavi-Wails/internal/utils" "github.com/wailsapp/wails/v2/pkg/runtime" - "github.com/xuri/excelize/v2" ) const minExportQueryTimeout = 5 * time.Minute @@ -2045,14 +2044,19 @@ func (a *App) ImportDataWithProgress(config connection.ConnectionConfig, dbName, runtime.EventsEmit(a.ctx, "import:progress", state) }) if err := streamImportFile(filePath, consumer); err != nil { + resultData := consumer.Result() + maybeReleaseFileTransferMemory("import-stream-error", int64(resultData.Total), filePath) return connection.QueryResult{Success: false, Message: err.Error()} } if err := consumer.Flush(); err != nil { + resultData := consumer.Result() + maybeReleaseFileTransferMemory("import-flush-error", int64(resultData.Total), filePath) return connection.QueryResult{Success: false, Message: err.Error()} } resultData := consumer.Result() if resultData.Total == 0 { + maybeReleaseFileTransferMemory("import-empty", 0, filePath) return connection.QueryResult{Success: true, Message: "无可导入数据"} } @@ -2064,6 +2068,7 @@ func (a *App) ImportDataWithProgress(config connection.ConnectionConfig, dbName, "errorSummary": fmt.Sprintf("Imported: %d, Failed: %d", resultData.Success, resultData.Failed), } + maybeReleaseFileTransferMemory("import-finished", int64(resultData.Total), filePath) return connection.QueryResult{Success: true, Data: result, Message: fmt.Sprintf("Imported: %d, Failed: %d", resultData.Success, resultData.Failed)} } @@ -2170,6 +2175,7 @@ func (a *App) ExportTableWithOptions(config connection.ConnectionConfig, dbName reporter.Finalizing(0) reporter.Done(0) + maybeReleaseFileTransferMemory("export-table-sql-finished", 0, filename) return connection.QueryResult{Success: true, Message: "导出完成"} } @@ -2185,9 +2191,11 @@ func (a *App) ExportTableWithOptions(config connection.ConnectionConfig, dbName rowCount, _, err := exportQueryResultToFile(f, dbInst, runConfig, query, options, reporter) if err != nil { reporter.Error(rowCount, "写入失败:"+err.Error()) + maybeReleaseFileTransferMemory("export-table-error", rowCount, filename) return connection.QueryResult{Success: false, Message: "写入失败:" + err.Error()} } reporter.Done(rowCount) + maybeReleaseFileTransferMemory("export-table-finished", rowCount, filename) return connection.QueryResult{Success: true, Message: "导出完成"} } @@ -3488,11 +3496,13 @@ func (a *App) ExportDataWithOptions(data []map[string]interface{}, columns []str if err != nil { logger.Warnf("ExportData 写入失败:file=%s err=%v", filename, err) reporter.Error(writtenRows, "写入失败:"+err.Error()) + maybeReleaseFileTransferMemory("export-data-error", writtenRows, filename) return connection.QueryResult{Success: false, Message: "写入失败:" + err.Error()} } logger.Infof("ExportData 完成:file=%s rows=%d", filename, len(data)) reporter.Done(writtenRows) + maybeReleaseFileTransferMemory("export-data-finished", writtenRows, filename) return connection.QueryResult{Success: true, Message: "导出完成"} } @@ -3550,11 +3560,13 @@ func (a *App) ExportQueryWithOptions(config connection.ConnectionConfig, dbName if err != nil { logger.Warnf("ExportQuery 查询失败:type=%s db=%s err=%v sql=%q", strings.TrimSpace(config.Type), strings.TrimSpace(dbName), err, sqlSnippet(query)) reporter.Error(rowCount, err.Error()) + maybeReleaseFileTransferMemory("export-query-error", rowCount, filename) return connection.QueryResult{Success: false, Message: err.Error()} } logger.Infof("ExportQuery 完成:file=%s rows=%d cols=%d", filename, rowCount, len(columns)) reporter.Done(rowCount) + maybeReleaseFileTransferMemory("export-query-finished", rowCount, filename) return connection.QueryResult{Success: true, Message: "导出完成"} } @@ -3997,128 +4009,6 @@ func (w *htmlExportFileWriter) Close() error { return w.writer.Flush() } -type xlsxExportFileWriter struct { - filename string - workbook *excelize.File - stream *excelize.StreamWriter - sheet string - columns []string - header []interface{} - rowBuf []interface{} - nextRow int - sheetNo int - rowCount int - maxRows int -} - -func newXLSXExportFileWriter(filename string, maxRowsPerSheet int) (*xlsxExportFileWriter, error) { - workbook := excelize.NewFile() - sheet := workbook.GetSheetName(workbook.GetActiveSheetIndex()) - stream, err := workbook.NewStreamWriter(sheet) - if err != nil { - _ = workbook.Close() - return nil, err - } - return &xlsxExportFileWriter{ - filename: filename, - workbook: workbook, - stream: stream, - sheet: sheet, - sheetNo: 1, - nextRow: 2, - maxRows: normalizeXLSXRowsPerSheet(maxRowsPerSheet), - }, nil -} - -func (w *xlsxExportFileWriter) SetColumns(columns []string) error { - w.columns = append([]string(nil), columns...) - w.rowCount = 0 - w.nextRow = 2 - w.header = make([]interface{}, len(columns)) - w.rowBuf = make([]interface{}, len(columns)) - for i, col := range columns { - w.header[i] = col - } - return w.stream.SetRow("A1", w.header) -} - -func (w *xlsxExportFileWriter) rotateSheet() error { - if err := w.stream.Flush(); err != nil { - return err - } - w.sheetNo++ - w.sheet = fmt.Sprintf("Sheet%d", w.sheetNo) - if _, err := w.workbook.NewSheet(w.sheet); err != nil { - return err - } - stream, err := w.workbook.NewStreamWriter(w.sheet) - if err != nil { - return err - } - w.stream = stream - w.rowCount = 0 - w.nextRow = 2 - return w.stream.SetRow("A1", w.header) -} - -func (w *xlsxExportFileWriter) ConsumeRow(row map[string]interface{}) error { - if w.rowCount >= w.maxRows { - if err := w.rotateSheet(); err != nil { - return err - } - } - values := w.rowBuf - for i, col := range w.columns { - val := row[col] - if val == nil { - values[i] = "NULL" - continue - } - values[i] = formatExportCellText(val) - } - cell := "A" + strconv.Itoa(w.nextRow) - w.nextRow++ - w.rowCount++ - return w.stream.SetRow(cell, values) -} - -func (w *xlsxExportFileWriter) ConsumeRowValues(values []interface{}) error { - if w.rowCount >= w.maxRows { - if err := w.rotateSheet(); err != nil { - return err - } - } - rowBuf := w.rowBuf - for i := range w.columns { - var value interface{} - if i < len(values) { - value = values[i] - } - if value == nil { - rowBuf[i] = "NULL" - continue - } - rowBuf[i] = formatExportCellText(value) - } - cell := "A" + strconv.Itoa(w.nextRow) - w.nextRow++ - w.rowCount++ - return w.stream.SetRow(cell, rowBuf) -} - -func (w *xlsxExportFileWriter) Close() error { - if err := w.stream.Flush(); err != nil { - _ = w.workbook.Close() - return err - } - saveErr := w.workbook.SaveAs(w.filename) - closeErr := w.workbook.Close() - if saveErr != nil { - return saveErr - } - return closeErr -} - type sqlInsertExportConsumer struct { w *bufio.Writer dbType string @@ -4304,11 +4194,7 @@ func newExportFileWriter(f *os.File, options ExportFileOptions) (exportFileWrite case "html": return newHTMLExportFileWriter(f), nil case "xlsx": - filename := f.Name() - if err := f.Close(); err != nil { - return nil, err - } - return newXLSXExportFileWriter(filename, options.XLSXMaxRowsPerSheet) + return newXLSXExportFileWriter(f, options.XLSXMaxRowsPerSheet) default: return nil, fmt.Errorf("unsupported format: %s", options.Format) } @@ -4756,29 +4642,23 @@ func normalizeExportJSONValue(val interface{}) interface{} { // writeRowsToXlsx 使用 excelize 写入真正的 xlsx 格式文件 func writeRowsToXlsx(filename string, data []map[string]interface{}, columns []string) error { - xlsx := excelize.NewFile() - defer xlsx.Close() - - sheet := "Sheet1" - - // 写入表头 - for i, col := range columns { - cell, _ := excelize.CoordinatesToCellName(i+1, 1) - xlsx.SetCellValue(sheet, cell, col) + file, err := os.Create(filename) + if err != nil { + return err } + defer file.Close() - // 写入数据行 - for rowIdx, rowMap := range data { - for colIdx, col := range columns { - cell, _ := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2) - val := rowMap[col] - if val == nil { - xlsx.SetCellValue(sheet, cell, "NULL") - } else { - xlsx.SetCellValue(sheet, cell, formatExportCellText(val)) - } + writer, err := newXLSXExportFileWriter(file, 0) + if err != nil { + return err + } + if err := writer.SetColumns(columns); err != nil { + return err + } + for _, rowMap := range data { + if err := writer.ConsumeRow(rowMap); err != nil { + return err } } - - return xlsx.SaveAs(filename) + return writer.Close() } diff --git a/internal/app/methods_file_export_test.go b/internal/app/methods_file_export_test.go index df668771..831f7043 100644 --- a/internal/app/methods_file_export_test.go +++ b/internal/app/methods_file_export_test.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "io" "os" "strings" @@ -44,6 +45,13 @@ type fakeValueStreamExportDB struct { valueHits int } +type fakeGeneratedValueStreamExportDB struct { + streamCols []string + rowCount int + streamHits int + valueHits int +} + func (f *fakeExportQueryDB) Connect(config connection.ConnectionConfig) error { return nil } func (f *fakeExportQueryDB) Close() error { return nil } func (f *fakeExportQueryDB) Ping() error { return nil } @@ -154,6 +162,82 @@ func (f *fakeValueStreamExportDB) StreamQueryContext(_ context.Context, query st return nil } +func (f *fakeGeneratedValueStreamExportDB) Connect(config connection.ConnectionConfig) error { + return nil +} + +func (f *fakeGeneratedValueStreamExportDB) Close() error { return nil } + +func (f *fakeGeneratedValueStreamExportDB) Ping() error { return nil } + +func (f *fakeGeneratedValueStreamExportDB) Query(query string) ([]map[string]interface{}, []string, error) { + return nil, nil, context.DeadlineExceeded +} + +func (f *fakeGeneratedValueStreamExportDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) { + return nil, nil, context.DeadlineExceeded +} + +func (f *fakeGeneratedValueStreamExportDB) Exec(query string) (int64, error) { return 0, nil } + +func (f *fakeGeneratedValueStreamExportDB) GetDatabases() ([]string, error) { return nil, nil } + +func (f *fakeGeneratedValueStreamExportDB) GetTables(dbName string) ([]string, error) { + return nil, nil +} + +func (f *fakeGeneratedValueStreamExportDB) GetCreateStatement(dbName, tableName string) (string, error) { + return "", nil +} + +func (f *fakeGeneratedValueStreamExportDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) { + return nil, nil +} + +func (f *fakeGeneratedValueStreamExportDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) { + return nil, nil +} + +func (f *fakeGeneratedValueStreamExportDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) { + return nil, nil +} + +func (f *fakeGeneratedValueStreamExportDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) { + return nil, nil +} + +func (f *fakeGeneratedValueStreamExportDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) { + return nil, nil +} + +func (f *fakeGeneratedValueStreamExportDB) StreamQuery(query string, consumer db.QueryStreamConsumer) error { + return f.StreamQueryContext(context.Background(), query, consumer) +} + +func (f *fakeGeneratedValueStreamExportDB) StreamQueryContext(_ context.Context, query string, consumer db.QueryStreamConsumer) error { + f.streamHits++ + if err := consumer.SetColumns(f.streamCols); err != nil { + return err + } + valueConsumer, ok := consumer.(db.QueryStreamValueConsumer) + if !ok { + return fmt.Errorf("value stream consumer required") + } + for i := 0; i < f.rowCount; i++ { + f.valueHits++ + if err := valueConsumer.ConsumeRowValues([]interface{}{ + i + 1, + "benchmark-user", + "plain export payload without timezone marker", + "2026-06-17 12:34:56", + "enabled", + }); err != nil { + return err + } + } + return nil +} + func TestFormatExportCellText_FloatNoScientificNotation(t *testing.T) { got := formatExportCellText(1.445663e+06) if strings.Contains(strings.ToLower(got), "e+") || strings.Contains(strings.ToLower(got), "e-") { @@ -730,6 +814,35 @@ func BenchmarkExportQueryResultToFile_XLSX_StreamValues_20000Rows(b *testing.B) } } +func BenchmarkExportQueryResultToFile_XLSX_StreamGenerated_50000Rows(b *testing.B) { + streamDB := &fakeGeneratedValueStreamExportDB{ + streamCols: []string{"id", "name", "note", "created_at", "status"}, + rowCount: 50000, + } + b.ReportAllocs() + for i := 0; i < b.N; i++ { + f, err := os.CreateTemp("", "gonavi-export-stream-generated-*.xlsx") + if err != nil { + b.Fatalf("创建临时文件失败: %v", err) + } + name := f.Name() + if _, _, err := exportQueryResultToFile( + f, + streamDB, + connection.ConnectionConfig{Type: "mysql", Timeout: 10}, + "SELECT * FROM users", + ExportFileOptions{Format: "xlsx"}, + nil, + ); err != nil { + _ = os.Remove(name) + b.Fatalf("流式生成导出失败: %v", err) + } + if err := os.Remove(name); err != nil { + b.Fatalf("删除临时文件失败: %v", err) + } + } +} + func BenchmarkDumpTableSQL_SQLBackup_StreamMap_20000Rows(b *testing.B) { rows, columns := benchmarkExportRows(20000) streamDB := &fakeStreamExportDB{ diff --git a/internal/app/methods_file_import_test.go b/internal/app/methods_file_import_test.go index b23f278b..582b5aea 100644 --- a/internal/app/methods_file_import_test.go +++ b/internal/app/methods_file_import_test.go @@ -8,6 +8,8 @@ import ( "reflect" "strings" "testing" + + "github.com/xuri/excelize/v2" ) func TestReadImportedConnectionConfigFileRejectsOversizedFiles(t *testing.T) { @@ -82,6 +84,94 @@ func TestBuildImportRowFromValuesPreservesPositionsWhenHeaderContainsBlankColumn } } +func TestBuildImportPreviewXLSXStreamSupportsInlineStrings(t *testing.T) { + path := filepath.Join(t.TempDir(), "inline.xlsx") + file, err := os.Create(path) + if err != nil { + t.Fatalf("创建 xlsx 文件失败: %v", err) + } + + writer, err := newXLSXExportFileWriter(file, 0) + if err != nil { + t.Fatalf("创建 xlsx writer 失败: %v", err) + } + if err := writer.SetColumns([]string{"id", "name"}); err != nil { + t.Fatalf("SetColumns 失败: %v", err) + } + if err := writer.ConsumeRowValues([]interface{}{1, "alice"}); err != nil { + t.Fatalf("写入第 1 行失败: %v", err) + } + if err := writer.ConsumeRowValues([]interface{}{2, "bob"}); err != nil { + t.Fatalf("写入第 2 行失败: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("关闭 xlsx writer 失败: %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("关闭 xlsx 文件失败: %v", err) + } + + preview, err := buildImportPreview(path, 5) + if err != nil { + t.Fatalf("buildImportPreview 返回错误: %v", err) + } + if !reflect.DeepEqual(preview.Columns, []string{"id", "name"}) { + t.Fatalf("unexpected columns: %#v", preview.Columns) + } + if preview.TotalRows != 2 { + t.Fatalf("expected 2 rows, got %d", preview.TotalRows) + } + if got := preview.PreviewRows[1]["name"]; got != "bob" { + t.Fatalf("expected second row name bob, got %#v", got) + } +} + +func TestBuildImportPreviewXLSXStreamSupportsSharedStrings(t *testing.T) { + path := filepath.Join(t.TempDir(), "shared.xlsx") + workbook := excelize.NewFile() + if err := workbook.SetCellValue("Sheet1", "A1", "id"); err != nil { + t.Fatalf("设置表头失败: %v", err) + } + if err := workbook.SetCellValue("Sheet1", "B1", "name"); err != nil { + t.Fatalf("设置表头失败: %v", err) + } + if err := workbook.SetCellValue("Sheet1", "A2", "1"); err != nil { + t.Fatalf("设置数据失败: %v", err) + } + if err := workbook.SetCellValue("Sheet1", "B2", "alice"); err != nil { + t.Fatalf("设置数据失败: %v", err) + } + if err := workbook.SetCellValue("Sheet1", "A3", "2"); err != nil { + t.Fatalf("设置数据失败: %v", err) + } + if err := workbook.SetCellValue("Sheet1", "B3", "bob"); err != nil { + t.Fatalf("设置数据失败: %v", err) + } + if err := workbook.SaveAs(path); err != nil { + t.Fatalf("保存 shared-string xlsx 失败: %v", err) + } + if err := workbook.Close(); err != nil { + t.Fatalf("关闭 shared-string xlsx 失败: %v", err) + } + + preview, err := buildImportPreview(path, 5) + if err != nil { + t.Fatalf("buildImportPreview 返回错误: %v", err) + } + if !reflect.DeepEqual(preview.Columns, []string{"id", "name"}) { + t.Fatalf("unexpected columns: %#v", preview.Columns) + } + if preview.TotalRows != 2 { + t.Fatalf("expected 2 rows, got %d", preview.TotalRows) + } + if got := preview.PreviewRows[0]["name"]; got != "alice" { + t.Fatalf("expected first row name alice, got %#v", got) + } + if got := preview.PreviewRows[1]["id"]; got != "2" { + t.Fatalf("expected second row id 2, got %#v", got) + } +} + type fakeImportRowWriter struct { columns []string batchCalls int diff --git a/internal/app/xlsx_import_stream.go b/internal/app/xlsx_import_stream.go new file mode 100644 index 00000000..c13219a6 --- /dev/null +++ b/internal/app/xlsx_import_stream.go @@ -0,0 +1,544 @@ +package app + +import ( + "archive/zip" + "bufio" + "encoding/binary" + "encoding/xml" + "fmt" + "io" + "os" + "path" + "strconv" + "strings" + + "github.com/xuri/excelize/v2" +) + +const ( + xlsxWorkbookXMLPath = "xl/workbook.xml" + xlsxWorkbookRelsXMLPath = "xl/_rels/workbook.xml.rels" + xlsxSharedStringsXML = "xl/sharedStrings.xml" +) + +type xlsxSharedStringStore struct { + path string + file *os.File + writer *bufio.Writer + offsets []int64 + size int64 +} + +func newXLSXSharedStringStore() (*xlsxSharedStringStore, error) { + file, err := os.CreateTemp("", "gonavi-xlsx-shared-strings-*.bin") + if err != nil { + return nil, err + } + return &xlsxSharedStringStore{ + path: file.Name(), + file: file, + writer: bufio.NewWriterSize(file, 1024*256), + }, nil +} + +func (s *xlsxSharedStringStore) Add(value string) error { + if s == nil || s.file == nil || s.writer == nil { + return fmt.Errorf("shared string store unavailable") + } + s.offsets = append(s.offsets, s.size) + if err := binary.Write(s.writer, binary.LittleEndian, uint32(len(value))); err != nil { + return err + } + written, err := s.writer.WriteString(value) + s.size += 4 + int64(written) + return err +} + +func (s *xlsxSharedStringStore) Get(index int) (string, error) { + if s == nil { + return "", nil + } + if index < 0 || index >= len(s.offsets) { + return "", fmt.Errorf("shared string index out of range: %d", index) + } + if err := s.flush(); err != nil { + return "", err + } + if _, err := s.file.Seek(s.offsets[index], io.SeekStart); err != nil { + return "", err + } + var length uint32 + if err := binary.Read(s.file, binary.LittleEndian, &length); err != nil { + return "", err + } + buf := make([]byte, int(length)) + if _, err := io.ReadFull(s.file, buf); err != nil { + return "", err + } + return string(buf), nil +} + +func (s *xlsxSharedStringStore) flush() error { + if s == nil || s.writer == nil { + return nil + } + return s.writer.Flush() +} + +func (s *xlsxSharedStringStore) Close() error { + if s == nil { + return nil + } + if s.writer != nil { + _ = s.writer.Flush() + } + var err error + if s.file != nil { + err = s.file.Close() + } + if s.path != "" { + _ = os.Remove(s.path) + } + return err +} + +func streamXLSXImportFile(filePath string, consumer importFileConsumer) error { + reader, err := zip.OpenReader(filePath) + if err != nil { + return fmt.Errorf("Excel Parse Error: %w", err) + } + defer reader.Close() + + entryByPath := make(map[string]*zip.File, len(reader.File)) + for _, entry := range reader.File { + entryByPath[entry.Name] = entry + } + + sheetPath, err := resolveXLSXFirstSheetPath(entryByPath) + if err != nil { + return fmt.Errorf("Excel Parse Error: %w", err) + } + + sharedStrings, err := loadXLSXSharedStrings(entryByPath[xlsxSharedStringsXML]) + if err != nil { + return fmt.Errorf("Excel Parse Error: %w", err) + } + if sharedStrings != nil { + defer sharedStrings.Close() + } + + sheetEntry := entryByPath[sheetPath] + if sheetEntry == nil { + return fmt.Errorf("Excel Parse Error: worksheet not found: %s", sheetPath) + } + if err := streamXLSXSheetRows(sheetEntry, sharedStrings, consumer); err != nil { + return fmt.Errorf("Excel Read Error: %w", err) + } + return nil +} + +func streamLegacyExcelImportFile(filePath string, consumer importFileConsumer) error { + workbook, err := excelize.OpenFile(filePath) + if err != nil { + return fmt.Errorf("Excel Parse Error: %w", err) + } + defer workbook.Close() + + sheetName := workbook.GetSheetName(0) + if sheetName == "" { + return fmt.Errorf("Excel file has no sheets") + } + + rows, err := workbook.Rows(sheetName) + if err != nil { + return fmt.Errorf("Excel Read Error: %w", err) + } + defer rows.Close() + + if !rows.Next() { + if err := rows.Error(); err != nil { + return fmt.Errorf("Excel Read Error: %w", err) + } + return fmt.Errorf("Excel empty or missing header") + } + header, err := rows.Columns() + if err != nil { + return fmt.Errorf("Excel Read Error: %w", err) + } + columns := cloneImportColumns(header) + if !hasImportUsableColumns(columns) { + return fmt.Errorf("Excel empty or missing header") + } + if err := consumer.SetColumns(columns); err != nil { + return err + } + + for rows.Next() { + record, err := rows.Columns() + if err != nil { + return fmt.Errorf("Excel Read Error: %w", err) + } + if err := consumer.ConsumeRow(buildImportRowFromValues(columns, record)); err != nil { + return err + } + } + if err := rows.Error(); err != nil { + return fmt.Errorf("Excel Read Error: %w", err) + } + return nil +} + +func resolveXLSXFirstSheetPath(entryByPath map[string]*zip.File) (string, error) { + workbookEntry := entryByPath[xlsxWorkbookXMLPath] + if workbookEntry == nil { + return "", fmt.Errorf("workbook.xml missing") + } + workbookReader, err := workbookEntry.Open() + if err != nil { + return "", err + } + defer workbookReader.Close() + + firstSheetRelID, err := readXLSXFirstSheetRelID(workbookReader) + if err != nil { + return "", err + } + if firstSheetRelID == "" { + return "", fmt.Errorf("workbook has no sheets") + } + + relsEntry := entryByPath[xlsxWorkbookRelsXMLPath] + if relsEntry == nil { + return "", fmt.Errorf("workbook rels missing") + } + relsReader, err := relsEntry.Open() + if err != nil { + return "", err + } + defer relsReader.Close() + + target, err := readXLSXWorkbookRelTarget(relsReader, firstSheetRelID) + if err != nil { + return "", err + } + if target == "" { + return "", fmt.Errorf("worksheet target missing for relationship %s", firstSheetRelID) + } + target = strings.TrimPrefix(strings.TrimSpace(target), "/") + if strings.HasPrefix(target, "xl/") { + return path.Clean(target), nil + } + return path.Clean(path.Join("xl", target)), nil +} + +func readXLSXFirstSheetRelID(reader io.Reader) (string, error) { + decoder := xml.NewDecoder(reader) + for { + token, err := decoder.Token() + if err != nil { + if err == io.EOF { + return "", nil + } + return "", err + } + start, ok := token.(xml.StartElement) + if !ok || start.Name.Local != "sheet" { + continue + } + for _, attr := range start.Attr { + if attr.Name.Local == "id" { + return strings.TrimSpace(attr.Value), nil + } + } + } +} + +func readXLSXWorkbookRelTarget(reader io.Reader, relID string) (string, error) { + decoder := xml.NewDecoder(reader) + for { + token, err := decoder.Token() + if err != nil { + if err == io.EOF { + return "", nil + } + return "", err + } + start, ok := token.(xml.StartElement) + if !ok || start.Name.Local != "Relationship" { + continue + } + var id string + var target string + for _, attr := range start.Attr { + switch attr.Name.Local { + case "Id": + id = strings.TrimSpace(attr.Value) + case "Target": + target = strings.TrimSpace(attr.Value) + } + } + if id == relID { + return target, nil + } + } +} + +func loadXLSXSharedStrings(entry *zip.File) (*xlsxSharedStringStore, error) { + if entry == nil { + return nil, nil + } + reader, err := entry.Open() + if err != nil { + return nil, err + } + defer reader.Close() + + store, err := newXLSXSharedStringStore() + if err != nil { + return nil, err + } + if err := parseXLSXSharedStrings(reader, store); err != nil { + _ = store.Close() + return nil, err + } + if err := store.flush(); err != nil { + _ = store.Close() + return nil, err + } + return store, nil +} + +func parseXLSXSharedStrings(reader io.Reader, store *xlsxSharedStringStore) error { + decoder := xml.NewDecoder(reader) + for { + token, err := decoder.Token() + if err != nil { + if err == io.EOF { + return nil + } + return err + } + start, ok := token.(xml.StartElement) + if !ok || start.Name.Local != "si" { + continue + } + value, err := readXLSXSharedStringItem(decoder) + if err != nil { + return err + } + if err := store.Add(value); err != nil { + return err + } + } +} + +func readXLSXSharedStringItem(decoder *xml.Decoder) (string, error) { + var builder strings.Builder + depth := 1 + for depth > 0 { + token, err := decoder.Token() + if err != nil { + return "", err + } + switch typed := token.(type) { + case xml.StartElement: + if typed.Name.Local == "si" { + depth++ + continue + } + if typed.Name.Local == "t" { + text, err := readXMLTextNode(decoder, typed.Name.Local) + if err != nil { + return "", err + } + builder.WriteString(text) + } + case xml.EndElement: + if typed.Name.Local == "si" { + depth-- + } + } + } + return builder.String(), nil +} + +func streamXLSXSheetRows(entry *zip.File, sharedStrings *xlsxSharedStringStore, consumer importFileConsumer) error { + reader, err := entry.Open() + if err != nil { + return err + } + defer reader.Close() + + decoder := xml.NewDecoder(reader) + var columns []string + for { + token, err := decoder.Token() + if err != nil { + if err == io.EOF { + break + } + return err + } + start, ok := token.(xml.StartElement) + if !ok || start.Name.Local != "row" { + continue + } + values, err := readXLSXRow(decoder, sharedStrings) + if err != nil { + return err + } + if columns == nil { + columns = cloneImportColumns(values) + if !hasImportUsableColumns(columns) { + return fmt.Errorf("Excel empty or missing header") + } + if err := consumer.SetColumns(columns); err != nil { + return err + } + continue + } + if err := consumer.ConsumeRow(buildImportRowFromValues(columns, values)); err != nil { + return err + } + } + if columns == nil { + return fmt.Errorf("Excel empty or missing header") + } + return nil +} + +func readXLSXRow(decoder *xml.Decoder, sharedStrings *xlsxSharedStringStore) ([]string, error) { + values := make([]string, 0, 16) + currentColumn := 0 + for { + token, err := decoder.Token() + if err != nil { + return nil, err + } + switch typed := token.(type) { + case xml.StartElement: + if typed.Name.Local != "c" { + continue + } + columnIndex := currentColumn + 1 + cellType := "" + for _, attr := range typed.Attr { + switch attr.Name.Local { + case "r": + if idx := xlsxCellRefColumnIndex(attr.Value); idx > 0 { + columnIndex = idx + } + case "t": + cellType = strings.TrimSpace(attr.Value) + } + } + if columnIndex <= 0 { + columnIndex = currentColumn + 1 + } + cellValue, err := readXLSXCell(decoder, cellType, sharedStrings) + if err != nil { + return nil, err + } + for len(values) < columnIndex { + values = append(values, "") + } + values[columnIndex-1] = cellValue + currentColumn = columnIndex + case xml.EndElement: + if typed.Name.Local == "row" { + return values, nil + } + } + } +} + +func readXLSXCell(decoder *xml.Decoder, cellType string, sharedStrings *xlsxSharedStringStore) (string, error) { + var rawValue strings.Builder + var inlineValue strings.Builder + for { + token, err := decoder.Token() + if err != nil { + return "", err + } + switch typed := token.(type) { + case xml.StartElement: + switch typed.Name.Local { + case "v": + text, err := readXMLTextNode(decoder, typed.Name.Local) + if err != nil { + return "", err + } + rawValue.WriteString(text) + case "t": + text, err := readXMLTextNode(decoder, typed.Name.Local) + if err != nil { + return "", err + } + inlineValue.WriteString(text) + } + case xml.EndElement: + if typed.Name.Local != "c" { + continue + } + switch cellType { + case "s": + indexText := strings.TrimSpace(rawValue.String()) + if indexText == "" { + return "", nil + } + index, err := strconv.Atoi(indexText) + if err != nil { + return "", err + } + return sharedStrings.Get(index) + case "inlineStr": + return inlineValue.String(), nil + default: + if inlineValue.Len() > 0 { + return inlineValue.String(), nil + } + return rawValue.String(), nil + } + } + } +} + +func readXMLTextNode(decoder *xml.Decoder, endLocal string) (string, error) { + var builder strings.Builder + for { + token, err := decoder.Token() + if err != nil { + return "", err + } + switch typed := token.(type) { + case xml.CharData: + builder.Write([]byte(typed)) + case xml.EndElement: + if typed.Name.Local == endLocal { + return builder.String(), nil + } + } + } +} + +func xlsxCellRefColumnIndex(ref string) int { + ref = strings.TrimSpace(ref) + if ref == "" { + return 0 + } + value := 0 + for i := 0; i < len(ref); i++ { + ch := ref[i] + switch { + case ch >= 'A' && ch <= 'Z': + value = value*26 + int(ch-'A'+1) + case ch >= 'a' && ch <= 'z': + value = value*26 + int(ch-'a'+1) + default: + if value > 0 { + return value + } + } + } + return value +} diff --git a/internal/app/xlsx_stream_writer.go b/internal/app/xlsx_stream_writer.go new file mode 100644 index 00000000..ea50be6b --- /dev/null +++ b/internal/app/xlsx_stream_writer.go @@ -0,0 +1,443 @@ +package app + +import ( + "archive/zip" + "bufio" + "encoding/xml" + "fmt" + "io" + "os" + "strconv" + "strings" + "sync" + + "github.com/xuri/excelize/v2" +) + +const ( + xlsxMainNamespace = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" + xlsxRelationshipsNamespace = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + xlsxPackageRelationshipsNS = "http://schemas.openxmlformats.org/package/2006/relationships" + xlsxWorksheetType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" + xlsxStylesType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" + xlsxThemeType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" + xlsxWorkbookContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml" + xlsxWorksheetContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml" + xlsxStylesContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml" + xlsxThemeContentType = "application/vnd.openxmlformats-officedocument.theme+xml" + xlsxCoreContentType = "application/vnd.openxmlformats-package.core-properties+xml" + xlsxAppContentType = "application/vnd.openxmlformats-officedocument.extended-properties+xml" +) + +var ( + xlsxTemplatePartsOnce sync.Once + xlsxTemplateParts map[string][]byte + xlsxTemplatePartsErr error +) + +type xlsxExportTempSheet struct { + name string + path string + file *os.File + writer *bufio.Writer + closed bool +} + +type xlsxExportFileWriter struct { + file *os.File + columns []string + columnRefs []string + rowBuf []string + sheets []*xlsxExportTempSheet + current *xlsxExportTempSheet + nextRow int + sheetNo int + rowCount int + maxRows int +} + +func newXLSXExportFileWriter(f *os.File, maxRowsPerSheet int) (*xlsxExportFileWriter, error) { + if f == nil { + return nil, fmt.Errorf("file required") + } + return &xlsxExportFileWriter{ + file: f, + maxRows: normalizeXLSXRowsPerSheet(maxRowsPerSheet), + }, nil +} + +func (w *xlsxExportFileWriter) SetColumns(columns []string) error { + w.columns = append([]string(nil), columns...) + w.columnRefs = make([]string, len(columns)) + w.rowBuf = make([]string, len(columns)) + for i := range columns { + ref, err := excelize.ColumnNumberToName(i + 1) + if err != nil { + return err + } + w.columnRefs[i] = ref + } + return w.rotateSheet() +} + +func (w *xlsxExportFileWriter) rotateSheet() error { + if err := w.closeCurrentSheet(); err != nil { + return err + } + + tmpFile, err := os.CreateTemp("", "gonavi-export-sheet-*.xml") + if err != nil { + return err + } + + sheet := &xlsxExportTempSheet{ + name: fmt.Sprintf("Sheet%d", w.sheetNo+1), + path: tmpFile.Name(), + file: tmpFile, + writer: bufio.NewWriterSize(tmpFile, 1024*256), + } + if err := writeXLSXSheetHeader(sheet.writer); err != nil { + _ = tmpFile.Close() + _ = os.Remove(tmpFile.Name()) + return err + } + + w.sheetNo++ + w.current = sheet + w.sheets = append(w.sheets, sheet) + w.rowCount = 0 + w.nextRow = 2 + return w.writeStringRow(1, w.columns) +} + +func (w *xlsxExportFileWriter) ConsumeRow(row map[string]interface{}) error { + if w.rowCount >= w.maxRows { + if err := w.rotateSheet(); err != nil { + return err + } + } + + values := w.rowBuf + for i, col := range w.columns { + val := row[col] + if val == nil { + values[i] = "NULL" + continue + } + values[i] = formatExportCellText(val) + } + if err := w.writeStringRow(w.nextRow, values); err != nil { + return err + } + w.nextRow++ + w.rowCount++ + return nil +} + +func (w *xlsxExportFileWriter) ConsumeRowValues(values []interface{}) error { + if w.rowCount >= w.maxRows { + if err := w.rotateSheet(); err != nil { + return err + } + } + + record := w.rowBuf + for i := range w.columns { + var value interface{} + if i < len(values) { + value = values[i] + } + if value == nil { + record[i] = "NULL" + continue + } + record[i] = formatExportCellText(value) + } + if err := w.writeStringRow(w.nextRow, record); err != nil { + return err + } + w.nextRow++ + w.rowCount++ + return nil +} + +func (w *xlsxExportFileWriter) Close() error { + if err := w.closeCurrentSheet(); err != nil { + w.cleanupTempSheets() + return err + } + if len(w.sheets) == 0 { + w.cleanupTempSheets() + return nil + } + + if err := w.file.Truncate(0); err != nil { + w.cleanupTempSheets() + return err + } + if _, err := w.file.Seek(0, 0); err != nil { + w.cleanupTempSheets() + return err + } + + zw := zip.NewWriter(w.file) + defer w.cleanupTempSheets() + + if err := writeXLSXZipFile(zw, w.sheets); err != nil { + _ = zw.Close() + return err + } + return zw.Close() +} + +func (w *xlsxExportFileWriter) closeCurrentSheet() error { + if w.current == nil || w.current.closed { + return nil + } + if _, err := w.current.writer.WriteString(``); err != nil { + return err + } + if err := w.current.writer.Flush(); err != nil { + return err + } + if err := w.current.file.Close(); err != nil { + return err + } + w.current.closed = true + w.current = nil + return nil +} + +func (w *xlsxExportFileWriter) cleanupTempSheets() { + for _, sheet := range w.sheets { + if sheet == nil { + continue + } + if !sheet.closed && sheet.file != nil { + _ = sheet.writer.Flush() + _ = sheet.file.Close() + } + if sheet.path != "" { + _ = os.Remove(sheet.path) + } + } +} + +func (w *xlsxExportFileWriter) writeStringRow(rowNumber int, values []string) error { + if w.current == nil { + return fmt.Errorf("xlsx sheet not initialized") + } + + if _, err := w.current.writer.WriteString(``); err != nil { + return err + } + rowRef := strconv.Itoa(rowNumber) + for i, value := range values { + if err := writeXLSXInlineStringCell(w.current.writer, w.columnRefs[i], rowRef, value); err != nil { + return err + } + } + _, err := w.current.writer.WriteString(``) + return err +} + +func writeXLSXZipFile(zw *zip.Writer, sheets []*xlsxExportTempSheet) error { + parts, err := loadXLSXTemplateParts() + if err != nil { + return err + } + + if err := writeZipBytes(zw, "[Content_Types].xml", buildXLSXContentTypes(len(sheets))); err != nil { + return err + } + if err := writeZipBytes(zw, "_rels/.rels", parts["_rels/.rels"]); err != nil { + return err + } + if err := writeZipBytes(zw, "docProps/app.xml", parts["docProps/app.xml"]); err != nil { + return err + } + if err := writeZipBytes(zw, "docProps/core.xml", parts["docProps/core.xml"]); err != nil { + return err + } + if err := writeZipBytes(zw, "xl/workbook.xml", buildXLSXWorkbookXML(sheets)); err != nil { + return err + } + if err := writeZipBytes(zw, "xl/_rels/workbook.xml.rels", buildXLSXWorkbookRels(sheets)); err != nil { + return err + } + if err := writeZipBytes(zw, "xl/styles.xml", parts["xl/styles.xml"]); err != nil { + return err + } + if err := writeZipBytes(zw, "xl/theme/theme1.xml", parts["xl/theme/theme1.xml"]); err != nil { + return err + } + for idx, sheet := range sheets { + if err := writeZipFileFromPath(zw, fmt.Sprintf("xl/worksheets/sheet%d.xml", idx+1), sheet.path); err != nil { + return err + } + } + return nil +} + +func loadXLSXTemplateParts() (map[string][]byte, error) { + xlsxTemplatePartsOnce.Do(func() { + workbook := excelize.NewFile() + defer workbook.Close() + + required := []string{ + "_rels/.rels", + "docProps/app.xml", + "docProps/core.xml", + "xl/styles.xml", + "xl/theme/theme1.xml", + } + xlsxTemplateParts = make(map[string][]byte, len(required)) + for _, path := range required { + value, ok := workbook.Pkg.Load(path) + if !ok { + xlsxTemplatePartsErr = fmt.Errorf("xlsx template part missing: %s", path) + return + } + xlsxTemplateParts[path] = append([]byte(nil), value.([]byte)...) + } + }) + return xlsxTemplateParts, xlsxTemplatePartsErr +} + +func writeZipBytes(zw *zip.Writer, name string, content []byte) error { + writer, err := zw.Create(name) + if err != nil { + return err + } + _, err = writer.Write(content) + return err +} + +func writeZipFileFromPath(zw *zip.Writer, name string, path string) error { + writer, err := zw.Create(name) + if err != nil { + return err + } + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + _, err = io.Copy(writer, file) + return err +} + +func writeXLSXSheetHeader(writer *bufio.Writer) error { + _, err := writer.WriteString(`` + + `` + + `` + + `` + + ``) + return err +} + +func writeXLSXInlineStringCell(writer *bufio.Writer, columnRef string, rowRef string, value string) error { + if _, err := writer.WriteString(``); err != nil { + return err + } + if err := xml.EscapeText(writer, []byte(value)); err != nil { + return err + } + _, err := writer.WriteString(``) + return err +} + +func buildXLSXContentTypes(sheetCount int) []byte { + var builder strings.Builder + builder.Grow(1024 + sheetCount*128) + builder.WriteString(``) + builder.WriteString(``) + builder.WriteString(``) + builder.WriteString(``) + builder.WriteString(``) + builder.WriteString(``) + builder.WriteString(``) + builder.WriteString(``) + builder.WriteString(``) + for i := 1; i <= sheetCount; i++ { + builder.WriteString(``) + } + builder.WriteString(``) + return []byte(builder.String()) +} + +func buildXLSXWorkbookXML(sheets []*xlsxExportTempSheet) []byte { + var builder strings.Builder + builder.Grow(512 + len(sheets)*96) + builder.WriteString(``) + builder.WriteString(``) + builder.WriteString(``) + builder.WriteString(``) + for i, sheet := range sheets { + builder.WriteString(``) + } + builder.WriteString(``) + builder.WriteString(``) + return []byte(builder.String()) +} + +func buildXLSXWorkbookRels(sheets []*xlsxExportTempSheet) []byte { + var builder strings.Builder + builder.Grow(512 + len(sheets)*160) + builder.WriteString(``) + builder.WriteString(``) + for i := range sheets { + builder.WriteString(``) + } + builder.WriteString(``) + builder.WriteString(``) + builder.WriteString(``) + return []byte(builder.String()) +}