From f0da60f467e52716a55efe36f757bc7eab89109f Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 21 Mar 2025 20:56:35 +0800 Subject: [PATCH] feat: Migrate Excel plugin to built-in functionality Move Excel plugin from external plugin to built-in functionality, including: - Core Excel file operations (open, close, save) - Sheet manipulation and cell operations - Row and column iterators - Process handlers for Excel operations - Comprehensive test coverage This change internalizes Excel manipulation capabilities: - Read/write operations on cells, rows and columns - Sheet management and styling - Cell merging and formula handling - Coordinate conversion utilities --- excel/each.go | 104 ++++++++++ excel/each_test.go | 64 +++++++ excel/excel.go | 102 ++++++++++ excel/excel_test.go | 252 ++++++++++++++++++++++++ excel/process.go | 261 +++++++++++++++++++++++++ excel/process_test.go | 432 ++++++++++++++++++++++++++++++++++++++++++ excel/write.go | 75 ++++++++ main.go | 1 + 8 files changed, 1291 insertions(+) create mode 100644 excel/each.go create mode 100644 excel/each_test.go create mode 100644 excel/excel.go create mode 100644 excel/excel_test.go create mode 100644 excel/process.go create mode 100644 excel/process_test.go create mode 100644 excel/write.go diff --git a/excel/each.go b/excel/each.go new file mode 100644 index 00000000..0d4ea904 --- /dev/null +++ b/excel/each.go @@ -0,0 +1,104 @@ +package excel + +import ( + "fmt" + "sync" + "time" + + "github.com/google/uuid" + "github.com/xuri/excelize/v2" +) + +// Cols defines an iterator to a sheet +type Cols struct { + id string + *excelize.Cols + create int64 +} + +// Rows defines an iterator to a sheet +type Rows struct { + id string + *excelize.Rows + create int64 +} + +var openCols = sync.Map{} +var openRows = sync.Map{} + +// OpenRow each row of the sheet +func (excel *Excel) OpenRow(sheet string) (string, error) { + id := uuid.NewString() + rows, err := excel.Rows(sheet) + if err != nil { + return "", err + } + openRows.Store(id, &Rows{id: id, Rows: rows, create: time.Now().Unix()}) + return id, nil +} + +// NextRow next row of the sheet +func NextRow(id string) ([]string, error) { + value, ok := openRows.Load(id) + if !ok { + return nil, fmt.Errorf("rows %s not found", id) + } + + if value.(*Rows).Next() { + row, err := value.(*Rows).Columns() + // fmt.Printf("DEBUG: %#v %v %v\n", row, err, row == nil) + if err != nil { + return nil, err + } + + if row == nil { + return []string{}, nil + } + return row, nil + } + return nil, nil +} + +// CloseRow done the sheet +func CloseRow(id string) { + openRows.Delete(id) +} + +// OpenColumn each cols of the sheet +func (excel *Excel) OpenColumn(sheet string) (string, error) { + id := uuid.NewString() + cols, err := excel.Cols(sheet) + if err != nil { + return "", err + } + openCols.Store(id, &Cols{id: id, Cols: cols, create: time.Now().Unix()}) + return id, nil +} + +// NextColumn next col of the sheet +func NextColumn(id string) ([]string, error) { + value, ok := openCols.Load(id) + if !ok { + return nil, fmt.Errorf("cols %s not found", id) + } + + if value.(*Cols).Next() { + col, err := value.(*Cols).Rows() + if err != nil { + return nil, err + } + + if col == nil { + return []string{}, nil + } + + return col, nil + } + + return nil, nil +} + +// CloseColumn done the sheet +func CloseColumn(id string) { + openCols.Delete(id) +} diff --git a/excel/each_test.go b/excel/each_test.go new file mode 100644 index 00000000..5d29e86a --- /dev/null +++ b/excel/each_test.go @@ -0,0 +1,64 @@ +package excel + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestEachCols(t *testing.T) { + files := testFiles(t) + h1, err := Open(files["test-01"], false) + if err != nil { + t.Fatal(err) + } + defer Close(h1) + + xls, err := Get(h1) + if err != nil { + t.Fatal(err) + } + + id, err := xls.OpenColumn("供销存管理表格") + if err != nil { + t.Fatal(err) + } + defer CloseColumn(id) + + res := []string{} + for col, err := NextColumn(id); err == nil && col != nil; col, err = NextColumn(id) { + res = append(res, col...) + } + + assert.Contains(t, strings.Join(res, ""), "供销存管理表格产品查询") + assert.Contains(t, strings.Join(res, ""), "刘大大") +} + +func TestEachRows(t *testing.T) { + files := testFiles(t) + h1, err := Open(files["test-01"], false) + if err != nil { + t.Fatal(err) + } + defer Close(h1) + + xls, err := Get(h1) + if err != nil { + t.Fatal(err) + } + + id, err := xls.OpenRow("供销存管理表格") + if err != nil { + t.Fatal(err) + } + defer CloseRow(id) + + res := []string{} + for row, err := NextRow(id); err == nil && row != nil; row, err = NextRow(id) { + res = append(res, row...) + } + + assert.Contains(t, strings.Join(res, ""), "供销存管理表格产品查询") + assert.Contains(t, strings.Join(res, ""), "刘大大") +} diff --git a/excel/excel.go b/excel/excel.go new file mode 100644 index 00000000..e60cd646 --- /dev/null +++ b/excel/excel.go @@ -0,0 +1,102 @@ +package excel + +import ( + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/google/uuid" + "github.com/xuri/excelize/v2" + "github.com/yaoapp/yao/config" +) + +// Excel the excel file +type Excel struct { + id string + path string + create int64 + *excelize.File +} + +// openFiles the open files +var openFiles = sync.Map{} + +// Open open the excel file +func Open(path string, writable bool) (string, error) { + + excel := &Excel{path: path} + // GET DATA ROOT + root := config.Conf.DataRoot + path, err := filepath.Abs(filepath.Join(root, path)) + if err != nil { + return "", err + } + + if writable { + + // if the file not exists, create it + if _, err := os.Stat(path); os.IsNotExist(err) { + create := excelize.NewFile() + err := create.SaveAs(path) + if err != nil { + return "", err + } + create.Close() + } + + excelFile, err := excelize.OpenFile(path) + if err != nil { + return "", err + } + id := uuid.NewString() + excel.File = excelFile + excel.id = id + excel.create = time.Now().Unix() + openFiles.Store(id, excel) + return id, nil + } + + file, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("open file %s failed: %w", path, err) + } + + excelFile, err := excelize.OpenReader(file) + if err != nil { + return "", err + } + + id := uuid.NewString() + excel.File = excelFile + excel.id = id + excel.create = time.Now().Unix() + openFiles.Store(id, excel) + return id, nil +} + +// Close close the excel file +func Close(handler string) error { + excel, ok := openFiles.Load(handler) + if !ok { + return fmt.Errorf("file not found") + } + + err := excel.(*Excel).Close() + if err != nil { + return err + } + + openFiles.Delete(handler) + return nil +} + +// Get get the excel file +func Get(handler string) (*Excel, error) { + excel, ok := openFiles.Load(handler) + if !ok { + return nil, fmt.Errorf("%s not found", handler) + } + return excel.(*Excel), nil +} diff --git a/excel/excel_test.go b/excel/excel_test.go new file mode 100644 index 00000000..e9a883a5 --- /dev/null +++ b/excel/excel_test.go @@ -0,0 +1,252 @@ +package excel + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/yaoapp/yao/config" + "github.com/yaoapp/yao/test" +) + +func TestOpenClose(t *testing.T) { + files := testFiles(t) + + h1, err := Open(files["test-01"], false) + if err != nil { + t.Fatal(err) + } + + if _, ok := openFiles.Load(h1); !ok { + t.Fatal("open file failed") + } + + h2, err := Open(files["test-02"], true) + if err != nil { + t.Fatal(err) + } + if _, ok := openFiles.Load(h2); !ok { + t.Fatal("open file failed") + } + + h3, err := Open(files["test-03"], false) + if err != nil { + t.Fatal(err) + } + if _, ok := openFiles.Load(h3); !ok { + t.Fatal("open file failed") + } + + _, err = Open(files["test-04"], false) + assert.Error(t, err) + + err = Close(h1) + if err != nil { + t.Fatal(err) + } + if _, ok := openFiles.Load(h1); ok { + t.Fatal("close file failed") + } +} + +func TestGetSheetList(t *testing.T) { + + files := testFiles(t) + h1, err := Open(files["test-01"], false) + if err != nil { + t.Fatal(err) + } + defer Close(h1) + + xls, err := Get(h1) + if err != nil { + t.Fatal(err) + } + + sheets := xls.GetSheetList() + assert.Equal(t, []string{"供销存管理表格", "使用说明"}, sheets) + + _, err = Get("not found") + assert.Error(t, err) +} + +func TestOpenInvalidFile(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + // Create an invalid excel file in the data root + root := "excel" + invalidFile := filepath.Join(root, "invalid.xlsx") + + // Ensure cleanup after test + defer func() { + if err := os.Remove(filepath.Join(config.Conf.DataRoot, invalidFile)); err != nil { + t.Logf("Failed to cleanup test file: %v", err) + } + }() + + err := os.WriteFile(filepath.Join(config.Conf.DataRoot, invalidFile), []byte("invalid content"), 0644) + if err != nil { + t.Fatal(err) + } + + _, err = Open(invalidFile, false) + assert.Error(t, err, "should fail to open invalid excel file") +} + +func TestCloseErrors(t *testing.T) { + // Test closing non-existent handler + err := Close("non-existent-handler") + assert.Error(t, err, "should fail to close non-existent file") + + // Test double close + files := testFiles(t) + h1, err := Open(files["test-01"], false) + if err != nil { + t.Fatal(err) + } + + // First close + err = Close(h1) + assert.NoError(t, err) + + // Second close should fail + err = Close(h1) + assert.Error(t, err, "should fail on second close") +} + +func TestOpenWithInvalidPath(t *testing.T) { + // Test with invalid path + _, err := Open("../invalid/path/file.xlsx", false) + assert.Error(t, err, "should fail with invalid path") + + // Test with path trying to escape data root + _, err = Open("../../../../etc/file.xlsx", false) + assert.Error(t, err, "should fail with path trying to escape data root") +} + +func TestWrite(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + // Create a new writable file for testing + root := "excel" + testFile := filepath.Join(root, "write-test.xlsx") + + // Ensure cleanup after test + defer func() { + if err := os.Remove(filepath.Join(config.Conf.DataRoot, testFile)); err != nil { + t.Logf("Failed to cleanup test file: %v", err) + } + }() + + h1, err := Open(testFile, true) + if err != nil { + t.Fatal(err) + } + defer Close(h1) + + xls, err := Get(h1) + if err != nil { + t.Fatal(err) + } + + // Test WriteCell + err = xls.WriteCell("Sheet1", "A1", "Hello") + assert.NoError(t, err) + err = xls.WriteCell("Sheet1", "B1", 123) + assert.NoError(t, err) + err = xls.WriteCell("Sheet1", "C1", true) + assert.NoError(t, err) + + // Test WriteRow + row := []interface{}{"Row1", 456, false} + err = xls.WriteRow("Sheet1", "A2", row) + assert.NoError(t, err) + + // Test WriteColumn + col := []interface{}{"Col1", 789, true} + err = xls.WriteColumn("Sheet1", "D1", col) + assert.NoError(t, err) + + // Test WriteAll + data := [][]interface{}{ + {"Name", "Age", "City"}, + {"John", 30, "New York"}, + {"Alice", 25, "London"}, + } + err = xls.WriteAll("Sheet2", "A1", data) + assert.NoError(t, err) + + // Test error cases + // Invalid cell reference + err = xls.WriteCell("Sheet1", "invalid", "test") + assert.Error(t, err) + + // Save the file to verify changes + err = xls.SaveAs(filepath.Join(config.Conf.DataRoot, testFile)) + assert.NoError(t, err) + + // Verify written data + val, err := xls.GetCellValue("Sheet1", "A1") + assert.NoError(t, err) + assert.Equal(t, "Hello", val) + + val, err = xls.GetCellValue("Sheet2", "A1") + assert.NoError(t, err) + assert.Equal(t, "Name", val) +} + +func TestSetSheet(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + root := "excel" + testFile := filepath.Join(root, "sheet-test.xlsx") + + // Ensure cleanup after test + defer func() { + if err := os.Remove(filepath.Join(config.Conf.DataRoot, testFile)); err != nil { + t.Logf("Failed to cleanup test file: %v", err) + } + }() + + h1, err := Open(testFile, true) + if err != nil { + t.Fatal(err) + } + defer Close(h1) + + xls, err := Get(h1) + if err != nil { + t.Fatal(err) + } + + // Test creating new sheet + idx, err := xls.SetSheet("NewSheet") + assert.NoError(t, err) + assert.Greater(t, idx, 0) + + // Test getting existing sheet + idx2, err := xls.SetSheet("NewSheet") + assert.NoError(t, err) + assert.Equal(t, idx, idx2) + + // Verify sheet exists + sheets := xls.GetSheetList() + assert.Contains(t, sheets, "NewSheet") +} + +func testFiles(t *testing.T) map[string]string { + test.Prepare(t, config.Conf) + defer test.Clean() + + // test data root path + root := "excel" + return map[string]string{ + "test-01": filepath.Join(root, "test-01.xlsx"), + "test-02": filepath.Join(root, "test-02.xlsx"), + "test-03": filepath.Join(root, "test-03.xlsx"), + } +} diff --git a/excel/process.go b/excel/process.go new file mode 100644 index 00000000..bb616925 --- /dev/null +++ b/excel/process.go @@ -0,0 +1,261 @@ +package excel + +import ( + "github.com/xuri/excelize/v2" + "github.com/yaoapp/gou/process" + "github.com/yaoapp/kun/exception" +) + +func init() { + process.RegisterGroup("excel", map[string]process.Handler{ + "open": processOpen, + "close": processClose, + "save": processSave, + "sheets": processSheets, + + "read.cell": processReadCell, + "write.cell": processWriteCell, + + "set.style": processSetStyle, + "set.formula": processSetFormula, + "set.link": processSetLink, + "set.mergecell": processMergeCell, + "set.unmergecell": processUnmergeCell, + + "convert.columnnametonumber": processColumnNameToNumber, + "convert.columnnumbertoname": processColumnNumberToName, + "convert.cellnametocoordinates": processCellNameToCoordinates, + "convert.coordinatestocellname": processCoordinatesToCellName, + }) +} + +// processOpen process the excel.open +func processOpen(process *process.Process) interface{} { + process.ValidateArgNums(1) + file := process.ArgsString(0) + readonly := false + if len(process.Args) > 1 { + readonly = process.ArgsBool(1) + } + + handle, err := Open(file, readonly) + if err != nil { + exception.New("excel.open %s error: %s", 500, file, err.Error()).Throw() + } + return handle +} + +// processClose process the excel.close +func processClose(process *process.Process) interface{} { + process.ValidateArgNums(1) + handle := process.ArgsString(0) + err := Close(handle) + if err != nil { + exception.New("excel.close %s error: %s", 500, handle, err.Error()).Throw() + } + return nil +} + +// processSave process the excel.save +func processSave(process *process.Process) interface{} { + process.ValidateArgNums(1) + handle := process.ArgsString(0) + xls, err := Get(handle) + if err != nil { + exception.New("excel.save %s error: %s", 500, handle, err.Error()).Throw() + } + err = xls.Save() + if err != nil { + exception.New("excel.save %s error: %s", 500, handle, err.Error()).Throw() + } + return nil +} + +// processSheets process the excel.sheets +func processSheets(process *process.Process) interface{} { + process.ValidateArgNums(1) + handle := process.ArgsString(0) + xls, err := Get(handle) + if err != nil { + exception.New("excel.sheets %s error: %s", 500, handle, err.Error()).Throw() + } + return xls.GetSheetList() +} + +// processReadCell process the excel.read.cell +func processReadCell(process *process.Process) interface{} { + process.ValidateArgNums(3) + handle := process.ArgsString(0) + sheet := process.ArgsString(1) + cell := process.ArgsString(2) + + xls, err := Get(handle) + if err != nil { + exception.New("excel.read.cell %s error: %s", 500, handle, err.Error()).Throw() + } + value, err := xls.GetCellValue(sheet, cell) + if err != nil { + exception.New("excel.read.cell %s:%s:%s error: %s", 500, handle, sheet, cell, err.Error()).Throw() + } + return value +} + +// processWriteCell process the excel.write.cell +func processWriteCell(process *process.Process) interface{} { + process.ValidateArgNums(4) + handle := process.ArgsString(0) + sheet := process.ArgsString(1) + cell := process.ArgsString(2) + value := process.Args[3] + + xls, err := Get(handle) + if err != nil { + exception.New("excel.write.cell %s error: %s", 500, handle, err.Error()).Throw() + } + err = xls.SetCellValue(sheet, cell, value) + if err != nil { + exception.New("excel.write.cell %s:%s:%s error: %s", 500, handle, sheet, cell, err.Error()).Throw() + } + return nil +} + +// processSetStyle process the excel.set.style