feat: add docfill mvp cli
This commit is contained in:
160
internal/docfill/case_test.go
Normal file
160
internal/docfill/case_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package docfill
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCaseExcelDailyDataToExcelReports(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dataPath := filepath.Join(dir, "daily-data.xlsx")
|
||||
templatePath := filepath.Join(dir, "daily-template.xlsx")
|
||||
configPath := filepath.Join(dir, "task.json")
|
||||
outputPattern := filepath.Join(dir, "out", "日报_{{date}}_{{name}}.xlsx")
|
||||
|
||||
writeWorkbook(t, dataPath, "Daily", [][]string{
|
||||
{"name", "date", "system_a_count", "system_b_amount", "summary"},
|
||||
{"张三", "2026-06-18", "12", "3500", "接口数据已核对"},
|
||||
{"李四", "2026-06-18", "8", "2100", "报表已提交"},
|
||||
})
|
||||
writeWorkbook(t, templatePath, "日报", [][]string{
|
||||
{"员工", "{{name}}"},
|
||||
{"日期", "{{date}}"},
|
||||
{"A系统数量", "{{system_a_count}}"},
|
||||
{"B系统金额", "{{system_b_amount}}"},
|
||||
{"今日摘要", "{{summary}}"},
|
||||
})
|
||||
writeJSON(t, configPath, map[string]any{
|
||||
"data": map[string]any{
|
||||
"type": "excel",
|
||||
"path": dataPath,
|
||||
"sheet": "Daily",
|
||||
},
|
||||
"template": map[string]any{
|
||||
"path": templatePath,
|
||||
},
|
||||
"output": map[string]any{
|
||||
"path": outputPattern,
|
||||
},
|
||||
})
|
||||
|
||||
if err := Run(configPath); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
|
||||
assertWorkbookCell(t, filepath.Join(dir, "out", "日报_2026-06-18_张三.xlsx"), "日报", "B1", "张三")
|
||||
assertWorkbookCell(t, filepath.Join(dir, "out", "日报_2026-06-18_张三.xlsx"), "日报", "B4", "3500")
|
||||
assertWorkbookCell(t, filepath.Join(dir, "out", "日报_2026-06-18_李四.xlsx"), "日报", "B5", "报表已提交")
|
||||
}
|
||||
|
||||
func TestCaseSQLiteDailyDataToWordReports(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "daily.db")
|
||||
templatePath := filepath.Join(dir, "daily-template.docx")
|
||||
configPath := filepath.Join(dir, "task.json")
|
||||
outputPattern := filepath.Join(dir, "out", "日报_{{date}}_{{name}}.docx")
|
||||
|
||||
seedCaseSQLite(t, dbPath, `
|
||||
create table daily_report (
|
||||
name text,
|
||||
date text,
|
||||
done text,
|
||||
plan text,
|
||||
risk text
|
||||
);
|
||||
insert into daily_report values
|
||||
('张三', '2026-06-18', '完成接口数据核对', '继续处理异常数据', '无'),
|
||||
('李四', '2026-06-18', '完成日报提交', '补充系统截图', '等待业务确认');
|
||||
`)
|
||||
writeMinimalDocx(t, templatePath, "员工:{{name}}\n日期:{{date}}\n完成:{{done}}\n计划:{{plan}}\n风险:{{risk}}")
|
||||
writeJSON(t, configPath, map[string]any{
|
||||
"data": map[string]any{
|
||||
"type": "sqlite",
|
||||
"path": dbPath,
|
||||
"query": "select name, date, done, plan, risk from daily_report order by name",
|
||||
},
|
||||
"template": map[string]any{
|
||||
"path": templatePath,
|
||||
},
|
||||
"output": map[string]any{
|
||||
"path": outputPattern,
|
||||
},
|
||||
})
|
||||
|
||||
if err := Run(configPath); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
|
||||
zhangsanDoc := readZipEntry(t, filepath.Join(dir, "out", "日报_2026-06-18_张三.docx"), "word/document.xml")
|
||||
if !strings.Contains(zhangsanDoc, "完成接口数据核对") || !strings.Contains(zhangsanDoc, "风险:无") {
|
||||
t.Fatalf("unexpected 张三 docx body: %q", zhangsanDoc)
|
||||
}
|
||||
|
||||
lisiDoc := readZipEntry(t, filepath.Join(dir, "out", "日报_2026-06-18_李四.docx"), "word/document.xml")
|
||||
if !strings.Contains(lisiDoc, "补充系统截图") || !strings.Contains(lisiDoc, "等待业务确认") {
|
||||
t.Fatalf("unexpected 李四 docx body: %q", lisiDoc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaseSQLiteSummaryDataToExcelReports(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "summary.db")
|
||||
templatePath := filepath.Join(dir, "summary-template.xlsx")
|
||||
configPath := filepath.Join(dir, "task.json")
|
||||
outputPattern := filepath.Join(dir, "out", "汇总_{{department}}_{{_row}}.xlsx")
|
||||
|
||||
seedCaseSQLite(t, dbPath, `
|
||||
create table department_summary (
|
||||
department text,
|
||||
report_date text,
|
||||
total_count integer,
|
||||
abnormal_count integer
|
||||
);
|
||||
insert into department_summary values
|
||||
('营销部', '2026-06-18', 128, 3),
|
||||
('客服部', '2026-06-18', 76, 0);
|
||||
`)
|
||||
writeWorkbook(t, templatePath, "汇总", [][]string{
|
||||
{"部门", "{{department}}"},
|
||||
{"日期", "{{report_date}}"},
|
||||
{"总数", "{{total_count}}"},
|
||||
{"异常数", "{{abnormal_count}}"},
|
||||
})
|
||||
writeJSON(t, configPath, map[string]any{
|
||||
"data": map[string]any{
|
||||
"type": "sqlite",
|
||||
"path": dbPath,
|
||||
"query": "select department, report_date, total_count, abnormal_count from department_summary order by department desc",
|
||||
},
|
||||
"template": map[string]any{
|
||||
"path": templatePath,
|
||||
},
|
||||
"output": map[string]any{
|
||||
"path": outputPattern,
|
||||
},
|
||||
})
|
||||
|
||||
if err := Run(configPath); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
|
||||
assertWorkbookCell(t, filepath.Join(dir, "out", "汇总_营销部_1.xlsx"), "汇总", "B3", "128")
|
||||
assertWorkbookCell(t, filepath.Join(dir, "out", "汇总_营销部_1.xlsx"), "汇总", "B4", "3")
|
||||
assertWorkbookCell(t, filepath.Join(dir, "out", "汇总_客服部_2.xlsx"), "汇总", "B4", "0")
|
||||
}
|
||||
|
||||
func seedCaseSQLite(t *testing.T, path, statements string) {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if _, err := db.Exec(statements); err != nil {
|
||||
t.Fatalf("seed sqlite: %v", err)
|
||||
}
|
||||
}
|
||||
740
internal/docfill/docfill.go
Normal file
740
internal/docfill/docfill.go
Normal file
@@ -0,0 +1,740 @@
|
||||
package docfill
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
type config struct {
|
||||
Vars map[string]string `json:"vars"`
|
||||
Data dataConfig `json:"data"`
|
||||
Template templateConfig `json:"template"`
|
||||
Output outputConfig `json:"output"`
|
||||
}
|
||||
|
||||
type dataConfig struct {
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
Sheet string `json:"sheet"`
|
||||
Query string `json:"query"`
|
||||
Params []string `json:"params"`
|
||||
HeaderRow int `json:"header_row"`
|
||||
}
|
||||
|
||||
type templateConfig struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type outputConfig struct {
|
||||
Path string `json:"path"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
}
|
||||
|
||||
type RunOptions struct {
|
||||
Vars map[string]string
|
||||
}
|
||||
|
||||
type RunResult struct {
|
||||
Files []string
|
||||
}
|
||||
|
||||
var (
|
||||
placeholderPattern = regexp.MustCompile(`\{\{([^{}<>]+)\}\}`)
|
||||
docxParagraphPattern = regexp.MustCompile(`(?s)<w:p\b[^>]*>.*?</w:p>`)
|
||||
docxTextNodePattern = regexp.MustCompile(`(?s)<w:t\b[^>]*>(.*?)</w:t>`)
|
||||
docxGeneratedXMLTypes = map[string]struct{}{
|
||||
".xml": {},
|
||||
}
|
||||
)
|
||||
|
||||
// Run executes one configured fill task.
|
||||
func Run(configPath string) error {
|
||||
_, err := RunWithOptions(configPath, RunOptions{})
|
||||
return err
|
||||
}
|
||||
|
||||
// RunWithOptions executes one configured fill task and returns generated files.
|
||||
func RunWithOptions(configPath string, opts RunOptions) (RunResult, error) {
|
||||
cfg, err := loadConfig(configPath)
|
||||
if err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
|
||||
vars := mergeVars(cfg.Vars, opts.Vars)
|
||||
rows, err := readRowsWithVars(cfg.Data, vars)
|
||||
if err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return RunResult{}, errors.New("data returned no rows")
|
||||
}
|
||||
|
||||
result := RunResult{Files: make([]string, 0, len(rows))}
|
||||
for i, row := range rows {
|
||||
context := renderContext(vars, row, i+1)
|
||||
if err := ensurePlaceholdersAvailable("output path", cfg.Output.Path, context); err != nil {
|
||||
return RunResult{}, fmt.Errorf("render row %d: %w", i+1, err)
|
||||
}
|
||||
|
||||
outputPath := replacePathPlaceholders(cfg.Output.Path, context)
|
||||
if hasPlaceholder(outputPath) {
|
||||
return RunResult{}, fmt.Errorf("render row %d: unresolved placeholder in output path %q", i+1, outputPath)
|
||||
}
|
||||
if !cfg.Output.Overwrite && fileExists(outputPath) {
|
||||
return RunResult{}, fmt.Errorf("render row %d: output already exists: %s", i+1, outputPath)
|
||||
}
|
||||
if err := renderTemplate(cfg.Template.Path, outputPath, context); err != nil {
|
||||
return RunResult{}, fmt.Errorf("render row %d: %w", i+1, err)
|
||||
}
|
||||
|
||||
result.Files = append(result.Files, outputPath)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func loadConfig(path string) (config, error) {
|
||||
if path == "" {
|
||||
return config{}, errors.New("config path is required")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return config{}, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
|
||||
var cfg config
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return config{}, fmt.Errorf("parse config json: %w", err)
|
||||
}
|
||||
resolveConfigPaths(&cfg, filepath.Dir(absPath(path)))
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func validateConfig(cfg config) error {
|
||||
if cfg.Data.Type == "" {
|
||||
return errors.New("data.type is required")
|
||||
}
|
||||
if cfg.Data.Path == "" {
|
||||
return errors.New("data.path is required")
|
||||
}
|
||||
if cfg.Template.Path == "" {
|
||||
return errors.New("template.path is required")
|
||||
}
|
||||
if cfg.Output.Path == "" {
|
||||
return errors.New("output.path is required")
|
||||
}
|
||||
if cfg.Data.HeaderRow < 0 {
|
||||
return errors.New("data.header_row must not be negative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readRows(cfg dataConfig) ([]map[string]string, error) {
|
||||
return readRowsWithVars(cfg, nil)
|
||||
}
|
||||
|
||||
func readRowsWithVars(cfg dataConfig, vars map[string]string) ([]map[string]string, error) {
|
||||
switch strings.ToLower(cfg.Type) {
|
||||
case "excel", "xlsx":
|
||||
return readExcelRows(cfg)
|
||||
case "sqlite":
|
||||
return readSQLiteRows(cfg, vars)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported data.type %q", cfg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func readExcelRows(cfg dataConfig) ([]map[string]string, error) {
|
||||
f, err := excelize.OpenFile(cfg.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open excel data: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
sheet := cfg.Sheet
|
||||
if sheet == "" {
|
||||
sheet = f.GetSheetName(0)
|
||||
}
|
||||
if sheet == "" {
|
||||
return nil, errors.New("excel data has no sheets")
|
||||
}
|
||||
|
||||
rawRows, err := f.GetRows(sheet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read excel sheet %q: %w", sheet, err)
|
||||
}
|
||||
if len(rawRows) == 0 {
|
||||
return nil, errors.New("excel data has no header row")
|
||||
}
|
||||
|
||||
headerRow := cfg.HeaderRow
|
||||
if headerRow == 0 {
|
||||
headerRow = 1
|
||||
}
|
||||
if headerRow > len(rawRows) {
|
||||
return nil, fmt.Errorf("excel header_row %d is outside sheet %q", headerRow, sheet)
|
||||
}
|
||||
|
||||
headers := make([]string, len(rawRows[headerRow-1]))
|
||||
for i, header := range rawRows[headerRow-1] {
|
||||
headers[i] = strings.TrimSpace(header)
|
||||
}
|
||||
|
||||
var rows []map[string]string
|
||||
for _, raw := range rawRows[headerRow:] {
|
||||
if rowIsEmpty(raw) {
|
||||
continue
|
||||
}
|
||||
row := make(map[string]string)
|
||||
for i, header := range headers {
|
||||
if header == "" {
|
||||
continue
|
||||
}
|
||||
if i < len(raw) {
|
||||
row[header] = raw[i]
|
||||
} else {
|
||||
row[header] = ""
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func readSQLiteRows(cfg dataConfig, vars map[string]string) ([]map[string]string, error) {
|
||||
if cfg.Query == "" {
|
||||
return nil, errors.New("data.query is required for sqlite")
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", cfg.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite data: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
args, err := sqliteParams(cfg.Params, vars)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := db.Query(cfg.Query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query sqlite data: %w", err)
|
||||
}
|
||||
defer result.Close()
|
||||
|
||||
columns, err := result.Columns()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read sqlite columns: %w", err)
|
||||
}
|
||||
|
||||
var rows []map[string]string
|
||||
for result.Next() {
|
||||
values := make([]any, len(columns))
|
||||
scanTargets := make([]any, len(columns))
|
||||
for i := range values {
|
||||
scanTargets[i] = &values[i]
|
||||
}
|
||||
if err := result.Scan(scanTargets...); err != nil {
|
||||
return nil, fmt.Errorf("scan sqlite row: %w", err)
|
||||
}
|
||||
|
||||
row := make(map[string]string)
|
||||
for i, column := range columns {
|
||||
row[column] = stringify(values[i])
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
if err := result.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate sqlite rows: %w", err)
|
||||
}
|
||||
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func renderTemplate(templatePath, outputPath string, row map[string]string) error {
|
||||
if err := validateTemplateFields(templatePath, row); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||
return fmt.Errorf("create output directory: %w", err)
|
||||
}
|
||||
|
||||
switch strings.ToLower(filepath.Ext(templatePath)) {
|
||||
case ".xlsx":
|
||||
return renderXLSX(templatePath, outputPath, row)
|
||||
case ".docx":
|
||||
return renderDOCX(templatePath, outputPath, row)
|
||||
default:
|
||||
return fmt.Errorf("unsupported template extension %q", filepath.Ext(templatePath))
|
||||
}
|
||||
}
|
||||
|
||||
func renderXLSX(templatePath, outputPath string, row map[string]string) error {
|
||||
f, err := excelize.OpenFile(templatePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open xlsx template: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
for _, sheet := range f.GetSheetList() {
|
||||
rows, err := f.GetRows(sheet)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read template sheet %q: %w", sheet, err)
|
||||
}
|
||||
for r, values := range rows {
|
||||
for c, value := range values {
|
||||
if !strings.Contains(value, "{{") {
|
||||
continue
|
||||
}
|
||||
cell, err := excelize.CoordinatesToCellName(c+1, r+1)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cell name: %w", err)
|
||||
}
|
||||
rendered := replacePlaceholders(value, row)
|
||||
if hasPlaceholder(rendered) {
|
||||
return fmt.Errorf("unresolved placeholder in %s!%s", sheet, cell)
|
||||
}
|
||||
if err := f.SetCellValue(sheet, cell, rendered); err != nil {
|
||||
return fmt.Errorf("set cell %s!%s: %w", sheet, cell, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := f.SaveAs(outputPath); err != nil {
|
||||
return fmt.Errorf("save xlsx output: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderDOCX(templatePath, outputPath string, row map[string]string) error {
|
||||
reader, err := zip.OpenReader(templatePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open docx template: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
output, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create docx output: %w", err)
|
||||
}
|
||||
|
||||
writer := zip.NewWriter(output)
|
||||
for _, source := range reader.File {
|
||||
if err := copyDocxPart(writer, source, row); err != nil {
|
||||
_ = writer.Close()
|
||||
_ = output.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
_ = output.Close()
|
||||
return fmt.Errorf("close docx output: %w", err)
|
||||
}
|
||||
if err := output.Close(); err != nil {
|
||||
return fmt.Errorf("close docx file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyDocxPart(writer *zip.Writer, source *zip.File, row map[string]string) error {
|
||||
header := source.FileHeader
|
||||
header.Name = source.Name
|
||||
header.Method = source.Method
|
||||
|
||||
dest, err := writer.CreateHeader(&header)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create docx part %s: %w", source.Name, err)
|
||||
}
|
||||
|
||||
if source.FileInfo().IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
src, err := source.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("open docx part %s: %w", source.Name, err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
data, err := io.ReadAll(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read docx part %s: %w", source.Name, err)
|
||||
}
|
||||
if isDocxXMLPart(source.Name) {
|
||||
rendered, err := renderDocxXML(string(data), row)
|
||||
if err != nil {
|
||||
return fmt.Errorf("render docx part %s: %w", source.Name, err)
|
||||
}
|
||||
if docxHasPlaceholder(rendered) {
|
||||
return fmt.Errorf("unresolved placeholder in docx part %s", source.Name)
|
||||
}
|
||||
data = []byte(rendered)
|
||||
}
|
||||
|
||||
if _, err := dest.Write(data); err != nil {
|
||||
return fmt.Errorf("write docx part %s: %w", source.Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func replacePlaceholders(text string, row map[string]string) string {
|
||||
return replacePlaceholdersWith(text, row, func(value string) string {
|
||||
return value
|
||||
})
|
||||
}
|
||||
|
||||
func replaceDocxPlaceholders(text string, row map[string]string) string {
|
||||
return replacePlaceholdersWith(text, row, escapeXMLText)
|
||||
}
|
||||
|
||||
func replacePathPlaceholders(text string, row map[string]string) string {
|
||||
return replacePlaceholdersWith(text, row, sanitizePathValue)
|
||||
}
|
||||
|
||||
func replacePlaceholdersWith(text string, row map[string]string, transform func(string) string) string {
|
||||
return placeholderPattern.ReplaceAllStringFunc(text, func(match string) string {
|
||||
key := placeholderKey(match)
|
||||
value, ok := row[key]
|
||||
if !ok {
|
||||
return match
|
||||
}
|
||||
return transform(value)
|
||||
})
|
||||
}
|
||||
|
||||
func rowIsEmpty(row []string) bool {
|
||||
for _, cell := range row {
|
||||
if strings.TrimSpace(cell) != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func stringify(value any) string {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case []byte:
|
||||
return string(v)
|
||||
case string:
|
||||
return v
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
|
||||
func mergeVars(configVars, optionVars map[string]string) map[string]string {
|
||||
merged := make(map[string]string)
|
||||
for key, value := range configVars {
|
||||
merged[key] = value
|
||||
}
|
||||
for key, value := range optionVars {
|
||||
merged[key] = value
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func renderContext(vars, row map[string]string, rowNumber int) map[string]string {
|
||||
context := make(map[string]string)
|
||||
for key, value := range vars {
|
||||
context[key] = value
|
||||
}
|
||||
for key, value := range row {
|
||||
context[key] = value
|
||||
}
|
||||
context["_row"] = strconv.Itoa(rowNumber)
|
||||
return context
|
||||
}
|
||||
|
||||
func sqliteParams(params []string, vars map[string]string) ([]any, error) {
|
||||
args := make([]any, 0, len(params))
|
||||
for i, param := range params {
|
||||
if err := ensurePlaceholdersAvailable(fmt.Sprintf("sqlite params[%d]", i), param, vars); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rendered := replacePlaceholders(param, vars)
|
||||
if hasPlaceholder(rendered) {
|
||||
return nil, fmt.Errorf("unresolved placeholder in sqlite params[%d]", i)
|
||||
}
|
||||
args = append(args, rendered)
|
||||
}
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func validateTemplateFields(templatePath string, row map[string]string) error {
|
||||
fields, err := templatePlaceholders(templatePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for field := range fields {
|
||||
if _, ok := row[field]; !ok {
|
||||
return fmt.Errorf("missing template field %q", field)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func templatePlaceholders(templatePath string) (map[string]struct{}, error) {
|
||||
switch strings.ToLower(filepath.Ext(templatePath)) {
|
||||
case ".xlsx":
|
||||
return xlsxPlaceholders(templatePath)
|
||||
case ".docx":
|
||||
return docxPlaceholders(templatePath)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported template extension %q", filepath.Ext(templatePath))
|
||||
}
|
||||
}
|
||||
|
||||
func xlsxPlaceholders(templatePath string) (map[string]struct{}, error) {
|
||||
f, err := excelize.OpenFile(templatePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open xlsx template: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fields := make(map[string]struct{})
|
||||
for _, sheet := range f.GetSheetList() {
|
||||
rows, err := f.GetRows(sheet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read template sheet %q: %w", sheet, err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
for _, value := range row {
|
||||
addPlaceholders(fields, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return fields, nil
|
||||
}
|
||||
|
||||
func docxPlaceholders(templatePath string) (map[string]struct{}, error) {
|
||||
reader, err := zip.OpenReader(templatePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open docx template: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
fields := make(map[string]struct{})
|
||||
for _, file := range reader.File {
|
||||
if !strings.HasSuffix(strings.ToLower(file.Name), ".xml") {
|
||||
continue
|
||||
}
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open docx part %s: %w", file.Name, err)
|
||||
}
|
||||
data, readErr := io.ReadAll(rc)
|
||||
closeErr := rc.Close()
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("read docx part %s: %w", file.Name, readErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, fmt.Errorf("close docx part %s: %w", file.Name, closeErr)
|
||||
}
|
||||
xml := string(data)
|
||||
addPlaceholders(fields, xml)
|
||||
addDocxTextPlaceholders(fields, xml)
|
||||
}
|
||||
return fields, nil
|
||||
}
|
||||
|
||||
func ensurePlaceholdersAvailable(label, text string, row map[string]string) error {
|
||||
fields := make(map[string]struct{})
|
||||
addPlaceholders(fields, text)
|
||||
for field := range fields {
|
||||
if _, ok := row[field]; !ok {
|
||||
return fmt.Errorf("missing %s field %q", label, field)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addPlaceholders(fields map[string]struct{}, text string) {
|
||||
matches := placeholderPattern.FindAllStringSubmatch(text, -1)
|
||||
for _, match := range matches {
|
||||
if len(match) < 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(match[1])
|
||||
if key != "" {
|
||||
fields[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func placeholderKey(match string) string {
|
||||
matches := placeholderPattern.FindStringSubmatch(match)
|
||||
if len(matches) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(matches[1])
|
||||
}
|
||||
|
||||
func hasPlaceholder(text string) bool {
|
||||
return placeholderPattern.MatchString(text)
|
||||
}
|
||||
|
||||
func renderDocxXML(xml string, row map[string]string) (string, error) {
|
||||
rendered := replaceDocxPlaceholders(xml, row)
|
||||
return renderDocxSplitTextPlaceholders(rendered, row)
|
||||
}
|
||||
|
||||
func renderDocxSplitTextPlaceholders(xml string, row map[string]string) (string, error) {
|
||||
matches := docxParagraphPattern.FindAllStringIndex(xml, -1)
|
||||
if len(matches) == 0 {
|
||||
return renderDocxTextGroup(xml, row)
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
cursor := 0
|
||||
for _, match := range matches {
|
||||
out.WriteString(xml[cursor:match[0]])
|
||||
rendered, err := renderDocxTextGroup(xml[match[0]:match[1]], row)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out.WriteString(rendered)
|
||||
cursor = match[1]
|
||||
}
|
||||
out.WriteString(xml[cursor:])
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
func renderDocxTextGroup(xml string, row map[string]string) (string, error) {
|
||||
textMatches := docxTextNodePattern.FindAllStringSubmatchIndex(xml, -1)
|
||||
if len(textMatches) == 0 {
|
||||
return xml, nil
|
||||
}
|
||||
|
||||
joined := joinedDocxText(xml, textMatches)
|
||||
if !hasPlaceholder(joined) {
|
||||
return xml, nil
|
||||
}
|
||||
if err := ensurePlaceholdersAvailable("docx text", joined, row); err != nil {
|
||||
return "", err
|
||||
}
|
||||
rendered := replaceDocxPlaceholders(joined, row)
|
||||
if hasPlaceholder(rendered) {
|
||||
return "", errors.New("unresolved placeholder in docx text")
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
cursor := 0
|
||||
for i, match := range textMatches {
|
||||
out.WriteString(xml[cursor:match[2]])
|
||||
if i == 0 {
|
||||
out.WriteString(rendered)
|
||||
}
|
||||
out.WriteString(xml[match[3]:match[1]])
|
||||
cursor = match[1]
|
||||
}
|
||||
out.WriteString(xml[cursor:])
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
func joinedDocxText(xml string, matches [][]int) string {
|
||||
var joined strings.Builder
|
||||
for _, match := range matches {
|
||||
joined.WriteString(xml[match[2]:match[3]])
|
||||
}
|
||||
return joined.String()
|
||||
}
|
||||
|
||||
func addDocxTextPlaceholders(fields map[string]struct{}, xml string) {
|
||||
matches := docxParagraphPattern.FindAllStringIndex(xml, -1)
|
||||
if len(matches) == 0 {
|
||||
addPlaceholders(fields, joinedDocxText(xml, docxTextNodePattern.FindAllStringSubmatchIndex(xml, -1)))
|
||||
return
|
||||
}
|
||||
for _, match := range matches {
|
||||
paragraph := xml[match[0]:match[1]]
|
||||
textMatches := docxTextNodePattern.FindAllStringSubmatchIndex(paragraph, -1)
|
||||
addPlaceholders(fields, joinedDocxText(paragraph, textMatches))
|
||||
}
|
||||
}
|
||||
|
||||
func docxHasPlaceholder(xml string) bool {
|
||||
if hasPlaceholder(xml) {
|
||||
return true
|
||||
}
|
||||
fields := make(map[string]struct{})
|
||||
addDocxTextPlaceholders(fields, xml)
|
||||
return len(fields) > 0
|
||||
}
|
||||
|
||||
func escapeXMLText(value string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
)
|
||||
return replacer.Replace(value)
|
||||
}
|
||||
|
||||
func isDocxXMLPart(name string) bool {
|
||||
_, ok := docxGeneratedXMLTypes[strings.ToLower(filepath.Ext(name))]
|
||||
return ok
|
||||
}
|
||||
|
||||
func sanitizePathValue(value string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
"/", "_",
|
||||
"\\", "_",
|
||||
":", "_",
|
||||
"*", "_",
|
||||
"?", "_",
|
||||
`"`, "_",
|
||||
"<", "_",
|
||||
">", "_",
|
||||
"|", "_",
|
||||
)
|
||||
return strings.TrimSpace(replacer.Replace(value))
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func resolveConfigPaths(cfg *config, baseDir string) {
|
||||
cfg.Data.Path = resolvePath(baseDir, cfg.Data.Path)
|
||||
cfg.Template.Path = resolvePath(baseDir, cfg.Template.Path)
|
||||
cfg.Output.Path = resolvePath(baseDir, cfg.Output.Path)
|
||||
}
|
||||
|
||||
func resolvePath(baseDir, path string) string {
|
||||
if path == "" || filepath.IsAbs(path) {
|
||||
return path
|
||||
}
|
||||
return filepath.Clean(filepath.Join(baseDir, path))
|
||||
}
|
||||
|
||||
func absPath(path string) string {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
return abs
|
||||
}
|
||||
309
internal/docfill/docfill_test.go
Normal file
309
internal/docfill/docfill_test.go
Normal file
@@ -0,0 +1,309 @@
|
||||
package docfill
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
func TestReadExcelRowsUsesFirstRowAsHeaders(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "data.xlsx")
|
||||
writeWorkbook(t, path, "Daily", [][]string{
|
||||
{"name", "date", "done"},
|
||||
{"Alice", "2026-06-18", "接口数据已整理"},
|
||||
{"Bob", "2026-06-18", "报表已提交"},
|
||||
})
|
||||
|
||||
rows, err := readRows(dataConfig{Type: "excel", Path: path, Sheet: "Daily"})
|
||||
if err != nil {
|
||||
t.Fatalf("readRows returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("expected 2 rows, got %d", len(rows))
|
||||
}
|
||||
if rows[0]["name"] != "Alice" || rows[0]["done"] != "接口数据已整理" {
|
||||
t.Fatalf("unexpected first row: %#v", rows[0])
|
||||
}
|
||||
if rows[1]["name"] != "Bob" || rows[1]["date"] != "2026-06-18" {
|
||||
t.Fatalf("unexpected second row: %#v", rows[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSQLiteRowsUsesQueryColumnNames(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "data.db")
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec(`
|
||||
create table daily (name text, done text);
|
||||
insert into daily (name, done) values ('Alice', '完成日报'), ('Bob', '完成周报');
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("seed sqlite: %v", err)
|
||||
}
|
||||
|
||||
rows, err := readRows(dataConfig{
|
||||
Type: "sqlite",
|
||||
Path: dbPath,
|
||||
Query: "select name, done from daily order by name",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("readRows returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("expected 2 rows, got %d", len(rows))
|
||||
}
|
||||
if rows[0]["name"] != "Alice" || rows[0]["done"] != "完成日报" {
|
||||
t.Fatalf("unexpected first row: %#v", rows[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderXLSXReplacesPlaceholders(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
templatePath := filepath.Join(dir, "template.xlsx")
|
||||
outputPath := filepath.Join(dir, "out.xlsx")
|
||||
writeWorkbook(t, templatePath, "Sheet1", [][]string{
|
||||
{"姓名", "内容"},
|
||||
{"{{name}}", "今天完成:{{done}}"},
|
||||
})
|
||||
|
||||
err := renderTemplate(templatePath, outputPath, map[string]string{
|
||||
"name": "Alice",
|
||||
"done": "接口数据整理",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("renderTemplate returned error: %v", err)
|
||||
}
|
||||
|
||||
f, err := excelize.OpenFile(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open output: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
name, err := f.GetCellValue("Sheet1", "A2")
|
||||
if err != nil {
|
||||
t.Fatalf("read A2: %v", err)
|
||||
}
|
||||
done, err := f.GetCellValue("Sheet1", "B2")
|
||||
if err != nil {
|
||||
t.Fatalf("read B2: %v", err)
|
||||
}
|
||||
|
||||
if name != "Alice" || done != "今天完成:接口数据整理" {
|
||||
t.Fatalf("unexpected rendered values: A2=%q B2=%q", name, done)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDOCXReplacesPlaceholdersInXMLParts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
templatePath := filepath.Join(dir, "template.docx")
|
||||
outputPath := filepath.Join(dir, "out.docx")
|
||||
writeMinimalDocx(t, templatePath, "日报:{{name}} 完成 {{done}}")
|
||||
|
||||
err := renderTemplate(templatePath, outputPath, map[string]string{
|
||||
"name": "Alice",
|
||||
"done": "接口数据整理",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("renderTemplate returned error: %v", err)
|
||||
}
|
||||
|
||||
got := readZipEntry(t, outputPath, "word/document.xml")
|
||||
want := "日报:Alice 完成 接口数据整理"
|
||||
if got != want {
|
||||
t.Fatalf("document.xml = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDOCXReplacesPlaceholderSplitAcrossTextNodes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
templatePath := filepath.Join(dir, "template.docx")
|
||||
outputPath := filepath.Join(dir, "out.docx")
|
||||
writeMinimalDocx(t, templatePath, `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>员工:{{na</w:t></w:r><w:r><w:t>me}}</w:t></w:r><w:r><w:t>,完成:{{done}}</w:t></w:r></w:p></w:body></w:document>`)
|
||||
|
||||
err := renderTemplate(templatePath, outputPath, map[string]string{
|
||||
"name": "Alice",
|
||||
"done": "接口数据整理",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("renderTemplate returned error: %v", err)
|
||||
}
|
||||
|
||||
got := readZipEntry(t, outputPath, "word/document.xml")
|
||||
if !strings.Contains(got, "员工:Alice,完成:接口数据整理") {
|
||||
t.Fatalf("document.xml = %q, want rendered split placeholder", got)
|
||||
}
|
||||
if strings.Contains(got, "{{") || strings.Contains(got, "}}") {
|
||||
t.Fatalf("document.xml still has placeholder markers: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunGeneratesOneOutputPerInputRow(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dataPath := filepath.Join(dir, "data.xlsx")
|
||||
templatePath := filepath.Join(dir, "template.xlsx")
|
||||
outputPattern := filepath.Join(dir, "out", "report_{{name}}_{{_row}}.xlsx")
|
||||
configPath := filepath.Join(dir, "task.json")
|
||||
|
||||
writeWorkbook(t, dataPath, "Daily", [][]string{
|
||||
{"name", "done"},
|
||||
{"Alice", "日报"},
|
||||
{"Bob", "周报"},
|
||||
})
|
||||
writeWorkbook(t, templatePath, "Sheet1", [][]string{
|
||||
{"{{name}}", "{{done}}"},
|
||||
})
|
||||
writeJSON(t, configPath, map[string]any{
|
||||
"data": map[string]any{
|
||||
"type": "excel",
|
||||
"path": dataPath,
|
||||
"sheet": "Daily",
|
||||
},
|
||||
"template": map[string]any{
|
||||
"path": templatePath,
|
||||
},
|
||||
"output": map[string]any{
|
||||
"path": outputPattern,
|
||||
},
|
||||
})
|
||||
|
||||
if err := Run(configPath); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
|
||||
assertWorkbookCell(t, filepath.Join(dir, "out", "report_Alice_1.xlsx"), "Sheet1", "A1", "Alice")
|
||||
assertWorkbookCell(t, filepath.Join(dir, "out", "report_Bob_2.xlsx"), "Sheet1", "B1", "周报")
|
||||
}
|
||||
|
||||
func writeWorkbook(t *testing.T, path, sheet string, rows [][]string) {
|
||||
t.Helper()
|
||||
|
||||
f := excelize.NewFile()
|
||||
defer f.Close()
|
||||
|
||||
defaultSheet := f.GetSheetName(0)
|
||||
if defaultSheet != sheet {
|
||||
if err := f.SetSheetName(defaultSheet, sheet); err != nil {
|
||||
t.Fatalf("set sheet name: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for r, row := range rows {
|
||||
for c, value := range row {
|
||||
cell, err := excelize.CoordinatesToCellName(c+1, r+1)
|
||||
if err != nil {
|
||||
t.Fatalf("cell name: %v", err)
|
||||
}
|
||||
if err := f.SetCellValue(sheet, cell, value); err != nil {
|
||||
t.Fatalf("set cell value: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := f.SaveAs(path); err != nil {
|
||||
t.Fatalf("save workbook: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeMinimalDocx(t *testing.T, path, body string) {
|
||||
t.Helper()
|
||||
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create docx: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
zw := zip.NewWriter(file)
|
||||
writeZipEntry(t, zw, "[Content_Types].xml", `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"/>`)
|
||||
writeZipEntry(t, zw, "word/document.xml", body)
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("close zip: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeZipEntry(t *testing.T, zw *zip.Writer, name, body string) {
|
||||
t.Helper()
|
||||
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("create zip entry %s: %v", name, err)
|
||||
}
|
||||
if _, err := w.Write([]byte(body)); err != nil {
|
||||
t.Fatalf("write zip entry %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func readZipEntry(t *testing.T, path, name string) string {
|
||||
t.Helper()
|
||||
|
||||
zr, err := zip.OpenReader(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open zip: %v", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
for _, file := range zr.File {
|
||||
if file.Name != name {
|
||||
continue
|
||||
}
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("open zip entry %s: %v", name, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
data, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
t.Fatalf("read zip entry %s: %v", name, err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
t.Fatalf("zip entry %s not found", name)
|
||||
return ""
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, path string, value any) {
|
||||
t.Helper()
|
||||
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal json: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
t.Fatalf("write json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertWorkbookCell(t *testing.T, path, sheet, cell, want string) {
|
||||
t.Helper()
|
||||
|
||||
f, err := excelize.OpenFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open workbook %s: %v", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
got, err := f.GetCellValue(sheet, cell)
|
||||
if err != nil {
|
||||
t.Fatalf("read cell %s: %v", cell, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("%s %s = %q, want %q", filepath.Base(path), cell, got, want)
|
||||
}
|
||||
}
|
||||
274
internal/docfill/p0_test.go
Normal file
274
internal/docfill/p0_test.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package docfill
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunResolvesConfigRelativePathsAndSanitizesOutputFileNames(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
taskDir := filepath.Join(dir, "task")
|
||||
if err := os.MkdirAll(taskDir, 0o755); err != nil {
|
||||
t.Fatalf("make task dir: %v", err)
|
||||
}
|
||||
|
||||
writeWorkbook(t, filepath.Join(taskDir, "data.xlsx"), "Daily", [][]string{
|
||||
{"name", "done"},
|
||||
{"张/三:测试", "已完成"},
|
||||
})
|
||||
writeWorkbook(t, filepath.Join(taskDir, "template.xlsx"), "日报", [][]string{
|
||||
{"{{name}}", "{{done}}"},
|
||||
})
|
||||
configPath := filepath.Join(taskDir, "task.json")
|
||||
writeJSON(t, configPath, map[string]any{
|
||||
"data": map[string]any{
|
||||
"type": "excel",
|
||||
"path": "./data.xlsx",
|
||||
"sheet": "Daily",
|
||||
},
|
||||
"template": map[string]any{
|
||||
"path": "./template.xlsx",
|
||||
},
|
||||
"output": map[string]any{
|
||||
"path": "./out/日报_{{name}}.xlsx",
|
||||
},
|
||||
})
|
||||
|
||||
result, err := RunWithOptions(configPath, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("RunWithOptions returned error: %v", err)
|
||||
}
|
||||
|
||||
wantPath := filepath.Join(taskDir, "out", "日报_张_三_测试.xlsx")
|
||||
if len(result.Files) != 1 || result.Files[0] != wantPath {
|
||||
t.Fatalf("result files = %#v, want %q", result.Files, wantPath)
|
||||
}
|
||||
assertWorkbookCell(t, wantPath, "日报", "A1", "张/三:测试")
|
||||
}
|
||||
|
||||
func TestRunUsesVarsForSQLiteParamsAndTemplateDefaults(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "daily.db")
|
||||
templatePath := filepath.Join(dir, "template.docx")
|
||||
configPath := filepath.Join(dir, "task.json")
|
||||
|
||||
seedP0SQLite(t, dbPath, `
|
||||
create table daily (name text, date text, done text);
|
||||
insert into daily values
|
||||
('张三', '2026-06-18', '完成旧日报'),
|
||||
('李四', '2026-06-19', '完成新日报');
|
||||
`)
|
||||
writeMinimalDocx(t, templatePath, "批次:{{batch}}\n日期:{{date}}\n员工:{{name}}\n内容:{{done}}")
|
||||
writeJSON(t, configPath, map[string]any{
|
||||
"vars": map[string]any{
|
||||
"date": "2026-06-18",
|
||||
"batch": "默认批次",
|
||||
},
|
||||
"data": map[string]any{
|
||||
"type": "sqlite",
|
||||
"path": dbPath,
|
||||
"query": "select name, date, done from daily where date = ?",
|
||||
"params": []any{"{{date}}"},
|
||||
},
|
||||
"template": map[string]any{
|
||||
"path": templatePath,
|
||||
},
|
||||
"output": map[string]any{
|
||||
"path": filepath.Join(dir, "out", "日报_{{batch}}_{{date}}_{{name}}.docx"),
|
||||
},
|
||||
})
|
||||
|
||||
result, err := RunWithOptions(configPath, RunOptions{
|
||||
Vars: map[string]string{
|
||||
"date": "2026-06-19",
|
||||
"batch": "命令行批次",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunWithOptions returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(result.Files) != 1 {
|
||||
t.Fatalf("expected 1 generated file, got %#v", result.Files)
|
||||
}
|
||||
got := readZipEntry(t, result.Files[0], "word/document.xml")
|
||||
if !strings.Contains(got, "批次:命令行批次") || !strings.Contains(got, "员工:李四") {
|
||||
t.Fatalf("unexpected rendered docx body: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "张三") || strings.Contains(result.Files[0], "默认批次") {
|
||||
t.Fatalf("CLI vars did not override config vars: file=%q body=%q", result.Files[0], got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSupportsExcelHeaderRow(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dataPath := filepath.Join(dir, "data.xlsx")
|
||||
templatePath := filepath.Join(dir, "template.xlsx")
|
||||
configPath := filepath.Join(dir, "task.json")
|
||||
|
||||
writeWorkbook(t, dataPath, "Daily", [][]string{
|
||||
{"日报导出数据"},
|
||||
{"name", "done"},
|
||||
{"张三", "完成日报"},
|
||||
})
|
||||
writeWorkbook(t, templatePath, "日报", [][]string{
|
||||
{"员工", "{{name}}"},
|
||||
{"完成", "{{done}}"},
|
||||
})
|
||||
writeJSON(t, configPath, map[string]any{
|
||||
"data": map[string]any{
|
||||
"type": "excel",
|
||||
"path": dataPath,
|
||||
"sheet": "Daily",
|
||||
"header_row": 2,
|
||||
},
|
||||
"template": map[string]any{
|
||||
"path": templatePath,
|
||||
},
|
||||
"output": map[string]any{
|
||||
"path": filepath.Join(dir, "out", "日报_{{name}}.xlsx"),
|
||||
},
|
||||
})
|
||||
|
||||
if err := Run(configPath); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
|
||||
assertWorkbookCell(t, filepath.Join(dir, "out", "日报_张三.xlsx"), "日报", "B1", "张三")
|
||||
}
|
||||
|
||||
func TestRunRefusesToOverwriteUnlessConfigured(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dataPath := filepath.Join(dir, "data.xlsx")
|
||||
templatePath := filepath.Join(dir, "template.xlsx")
|
||||
configPath := filepath.Join(dir, "task.json")
|
||||
outputPath := filepath.Join(dir, "out", "日报_张三.xlsx")
|
||||
|
||||
writeWorkbook(t, dataPath, "Daily", [][]string{
|
||||
{"name"},
|
||||
{"张三"},
|
||||
})
|
||||
writeWorkbook(t, templatePath, "日报", [][]string{{"{{name}}"}})
|
||||
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||
t.Fatalf("make output dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(outputPath, []byte("existing"), 0o644); err != nil {
|
||||
t.Fatalf("write existing output: %v", err)
|
||||
}
|
||||
writeJSON(t, configPath, map[string]any{
|
||||
"data": map[string]any{
|
||||
"type": "excel",
|
||||
"path": dataPath,
|
||||
"sheet": "Daily",
|
||||
},
|
||||
"template": map[string]any{
|
||||
"path": templatePath,
|
||||
},
|
||||
"output": map[string]any{
|
||||
"path": outputPath,
|
||||
},
|
||||
})
|
||||
|
||||
err := Run(configPath)
|
||||
if err == nil || !strings.Contains(err.Error(), "already exists") {
|
||||
t.Fatalf("expected already exists error, got %v", err)
|
||||
}
|
||||
|
||||
writeJSON(t, configPath, map[string]any{
|
||||
"data": map[string]any{
|
||||
"type": "excel",
|
||||
"path": dataPath,
|
||||
"sheet": "Daily",
|
||||
},
|
||||
"template": map[string]any{
|
||||
"path": templatePath,
|
||||
},
|
||||
"output": map[string]any{
|
||||
"path": outputPath,
|
||||
"overwrite": true,
|
||||
},
|
||||
})
|
||||
if err := Run(configPath); err != nil {
|
||||
t.Fatalf("Run with overwrite returned error: %v", err)
|
||||
}
|
||||
assertWorkbookCell(t, outputPath, "日报", "A1", "张三")
|
||||
}
|
||||
|
||||
func TestRunFailsWhenTemplatePlaceholderIsMissing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dataPath := filepath.Join(dir, "data.xlsx")
|
||||
templatePath := filepath.Join(dir, "template.xlsx")
|
||||
configPath := filepath.Join(dir, "task.json")
|
||||
|
||||
writeWorkbook(t, dataPath, "Daily", [][]string{
|
||||
{"name"},
|
||||
{"张三"},
|
||||
})
|
||||
writeWorkbook(t, templatePath, "日报", [][]string{{"{{name}}", "{{missing}}"}})
|
||||
writeJSON(t, configPath, map[string]any{
|
||||
"data": map[string]any{
|
||||
"type": "excel",
|
||||
"path": dataPath,
|
||||
"sheet": "Daily",
|
||||
},
|
||||
"template": map[string]any{
|
||||
"path": templatePath,
|
||||
},
|
||||
"output": map[string]any{
|
||||
"path": filepath.Join(dir, "out", "日报_{{name}}.xlsx"),
|
||||
},
|
||||
})
|
||||
|
||||
err := Run(configPath)
|
||||
if err == nil || !strings.Contains(err.Error(), "missing template field") {
|
||||
t.Fatalf("expected missing template field error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFailsWhenRenderedTemplateStillHasPlaceholder(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dataPath := filepath.Join(dir, "data.xlsx")
|
||||
templatePath := filepath.Join(dir, "template.xlsx")
|
||||
configPath := filepath.Join(dir, "task.json")
|
||||
|
||||
writeWorkbook(t, dataPath, "Daily", [][]string{
|
||||
{"name", "done"},
|
||||
{"张三", "{{still_pending}}"},
|
||||
})
|
||||
writeWorkbook(t, templatePath, "日报", [][]string{{"{{name}}", "{{done}}"}})
|
||||
writeJSON(t, configPath, map[string]any{
|
||||
"data": map[string]any{
|
||||
"type": "excel",
|
||||
"path": dataPath,
|
||||
"sheet": "Daily",
|
||||
},
|
||||
"template": map[string]any{
|
||||
"path": templatePath,
|
||||
},
|
||||
"output": map[string]any{
|
||||
"path": filepath.Join(dir, "out", "日报_{{name}}.xlsx"),
|
||||
},
|
||||
})
|
||||
|
||||
err := Run(configPath)
|
||||
if err == nil || !strings.Contains(err.Error(), "unresolved placeholder") {
|
||||
t.Fatalf("expected unresolved placeholder error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedP0SQLite(t *testing.T, path, statements string) {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if _, err := db.Exec(statements); err != nil {
|
||||
t.Fatalf("seed sqlite: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user