package excelparse import ( "bytes" "context" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" "time" "github.com/xuri/excelize/v2" ) var parsers []FormatParser func init() { parsers = append(parsers, &NativeProductParser{}) parsers = append(parsers, &MiaozhangParser{}) } // RegisterParser adds a format parser at runtime (e.g. for plugins). func RegisterParser(p FormatParser) { parsers = append(parsers, p) } // xlsToolPath returns the path to the xls2json.js script. // Checks XLS_TOOL_PATH env var first, then falls back to a path // relative to the binary's directory so it works both locally and in Docker. func xlsToolPath() (string, error) { if p := os.Getenv("XLS_TOOL_PATH"); p != "" { return p, nil } // Docker layout: /app/tools/xls2json.js candidates := []string{ "/app/tools/xls2json.js", } // Local dev: look relative to the process working directory if cwd, err := os.Getwd(); err == nil { candidates = append(candidates, filepath.Join(cwd, "deploy/tools/xls2json.js"), filepath.Join(cwd, "../deploy/tools/xls2json.js"), ) } for _, p := range candidates { if _, err := os.Stat(p); err == nil { return p, nil } } return "", fmt.Errorf("xls2json.js not found; set XLS_TOOL_PATH env var or deploy to /app/tools/") } // ParseFile reads an Excel file, auto-detects the competitor format, and returns parsed products. func ParseFile(filePath string) (*ParseResult, error) { ext := strings.ToLower(filepath.Ext(filePath)) var headers []string var rows [][]string var err error switch ext { case ".xlsx": headers, rows, err = readXLSX(filePath) case ".xls": headers, rows, err = readXLS(filePath) default: return nil, ErrUnsupportedExt } if err != nil { return nil, fmt.Errorf("read excel: %w", err) } if len(rows) == 0 { return nil, ErrEmptyFile } for _, p := range parsers { if p.Match(headers) { result, err := p.ParseRows(headers, rows) if err != nil { return nil, fmt.Errorf("parse %s: %w", p.Name(), err) } result.ParserName = p.Name() return result, nil } } return nil, ErrUnknownFormat } func readXLSX(filePath string) ([]string, [][]string, error) { f, err := excelize.OpenFile(filePath) if err != nil { return nil, nil, err } defer f.Close() sheets := f.GetSheetList() if len(sheets) == 0 { return nil, nil, ErrEmptyFile } allRows, err := f.GetRows(sheets[0]) if err != nil { return nil, nil, err } if len(allRows) < 2 { return nil, nil, ErrEmptyFile } return allRows[0], allRows[1:], nil } // readXLS delegates to the xls2json.js Node.js script via subprocess. // This handles legacy .xls (BIFF8) files including those with embedded images, // which pure-Go libraries cannot reliably process. func readXLS(filePath string) ([]string, [][]string, error) { toolPath, err := xlsToolPath() if err != nil { return nil, nil, err } ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() var stdout, stderr bytes.Buffer cmd := exec.CommandContext(ctx, "node", toolPath, filePath) cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { msg := strings.TrimSpace(stderr.String()) if msg == "" { msg = err.Error() } return nil, nil, fmt.Errorf("xls2json: %s", msg) } // JSON format: [][]interface{} (first row = headers) var raw [][]interface{} if err := json.Unmarshal(stdout.Bytes(), &raw); err != nil { return nil, nil, fmt.Errorf("xls2json output parse: %w", err) } if len(raw) < 2 { return nil, nil, ErrEmptyFile } toStrings := func(row []interface{}) []string { out := make([]string, len(row)) for i, v := range row { if v == nil { out[i] = "" } else { out[i] = strings.TrimSpace(fmt.Sprintf("%v", v)) } } return out } headers := toStrings(raw[0]) rows := make([][]string, 0, len(raw)-1) for _, r := range raw[1:] { rows = append(rows, toStrings(r)) } return headers, rows, nil }