169 lines
3.9 KiB
Go
Raw Normal View History

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
}