feat: add docfill mvp cli
This commit is contained in:
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Build and test output
|
||||||
|
/docfill
|
||||||
|
/dist/
|
||||||
|
*.test
|
||||||
|
coverage.out
|
||||||
|
|
||||||
|
# Generated example fixtures and outputs
|
||||||
|
examples/**/data.xlsx
|
||||||
|
examples/**/*.db
|
||||||
|
examples/**/template.xlsx
|
||||||
|
examples/**/template.docx
|
||||||
|
examples/**/out/
|
||||||
144
README.md
Normal file
144
README.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# docfill
|
||||||
|
|
||||||
|
`docfill` 是一个最小化本地填报工具:从 Excel 或 SQLite 读取数据,把 `{{字段名}}` 填入 Word/Excel 模板,生成输出文件。
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o docfill ./cmd/docfill
|
||||||
|
bash scripts/build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`scripts/build.sh` writes the local macOS arm64 binary to `dist/docfill-darwin-arm64`.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./docfill run --config task.json
|
||||||
|
./docfill run -c task.json --var date=2026-06-18 --var batch=早班
|
||||||
|
```
|
||||||
|
|
||||||
|
成功时 stdout 输出生成文件清单:
|
||||||
|
|
||||||
|
```text
|
||||||
|
generated 2 file(s)
|
||||||
|
- /path/to/out/report_张三.xlsx
|
||||||
|
- /path/to/out/report_李四.xlsx
|
||||||
|
```
|
||||||
|
|
||||||
|
退出码:
|
||||||
|
|
||||||
|
- `0`: 成功。
|
||||||
|
- `1`: 配置、数据、模板或输出失败。
|
||||||
|
- `2`: 命令行参数错误。
|
||||||
|
|
||||||
|
## Run Case Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -run TestCase -v ./internal/docfill
|
||||||
|
```
|
||||||
|
|
||||||
|
当前案例覆盖:
|
||||||
|
|
||||||
|
- Excel 日报数据填 Excel 模板,批量生成个人日报。
|
||||||
|
- SQLite 日报数据填 Word 模板,批量生成个人日报。
|
||||||
|
- SQLite 汇总数据填 Excel 模板,批量生成部门汇总表。
|
||||||
|
|
||||||
|
## Smoke Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Smoke test 会:
|
||||||
|
|
||||||
|
- 跑全量 Go 测试。
|
||||||
|
- 构建临时 `docfill` 二进制。
|
||||||
|
- 复制 `examples/` 到临时目录。
|
||||||
|
- 生成临时 Excel、Word、SQLite 示例数据。
|
||||||
|
- 执行两个示例任务并检查输出文件。
|
||||||
|
|
||||||
|
## Digital Employee Runbook
|
||||||
|
|
||||||
|
给数字员工或调度脚本使用时,看:
|
||||||
|
|
||||||
|
- `docs/digital-employee-runbook.md`
|
||||||
|
|
||||||
|
## Runnable Examples
|
||||||
|
|
||||||
|
示例配置在 `examples/`:
|
||||||
|
|
||||||
|
- `examples/excel-to-word/task.json`
|
||||||
|
- `examples/sqlite-to-excel/task.json`
|
||||||
|
|
||||||
|
示例里的二进制 Office/SQLite 文件由 `scripts/smoke-fixtures` 临时生成,不提交到仓库。
|
||||||
|
|
||||||
|
## Excel Data Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"vars": {
|
||||||
|
"date": "2026-06-18"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "excel",
|
||||||
|
"path": "./data.xlsx",
|
||||||
|
"sheet": "Sheet1",
|
||||||
|
"header_row": 1
|
||||||
|
},
|
||||||
|
"template": {
|
||||||
|
"path": "./template.docx"
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"path": "./out/report_{{date}}_{{name}}_{{_row}}.docx",
|
||||||
|
"overwrite": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Excel 第一行是字段名,后续每一行生成一个文件。
|
||||||
|
|
||||||
|
## SQLite Data Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"vars": {
|
||||||
|
"date": "2026-06-18"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "sqlite",
|
||||||
|
"path": "./data.db",
|
||||||
|
"query": "select name, date, done from daily_report where date = ?",
|
||||||
|
"params": ["{{date}}"]
|
||||||
|
},
|
||||||
|
"template": {
|
||||||
|
"path": "./template.xlsx"
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"path": "./out/report_{{name}}_{{_row}}.xlsx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
SQL 查询结果的列名就是模板占位符字段名。
|
||||||
|
|
||||||
|
## P0 Rules
|
||||||
|
|
||||||
|
- 配置里的相对路径按 `task.json` 所在目录解析。
|
||||||
|
- `vars` 可以提供默认变量,命令行 `--var key=value` 会覆盖同名默认变量。
|
||||||
|
- 模板填充时,每行数据字段优先于 `vars`。
|
||||||
|
- `_row` 是内置变量,表示当前数据行序号,从 `1` 开始。
|
||||||
|
- SQLite `params` 对应 SQL 里的 `?` 参数。
|
||||||
|
- Excel `header_row` 从 `1` 开始,默认是 `1`。
|
||||||
|
- 默认不覆盖已有输出文件;只有 `output.overwrite: true` 才允许覆盖。
|
||||||
|
- 输出文件路径中的字段值会清理 `/ \ : * ? " < > |`。
|
||||||
|
- 模板里缺字段或渲染后仍有 `{{字段}}`,会直接失败。
|
||||||
|
- Word 模板支持同一段落内被拆开的文本占位符,例如 `{{na` 和 `me}}` 位于相邻文本节点。
|
||||||
|
|
||||||
|
## MVP Limits
|
||||||
|
|
||||||
|
- 只支持 JSON 配置。
|
||||||
|
- 只支持 `.xlsx` 数据源、SQLite 数据源。
|
||||||
|
- 只支持 `.docx` 和 `.xlsx` 模板。
|
||||||
|
- Word 占位符拆分只做最小兼容:支持同一段落内的文本节点拆分,不做复杂表格循环或样式级模板语法。
|
||||||
|
- 不做 Web、权限、审批流、模板设计器或业务系统直连。
|
||||||
96
cmd/docfill/main.go
Normal file
96
cmd/docfill/main.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"xlsdoc/internal/docfill"
|
||||||
|
)
|
||||||
|
|
||||||
|
type varFlags map[string]string
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(args []string, stdout, stderr io.Writer) int {
|
||||||
|
if len(args) == 0 {
|
||||||
|
fmt.Fprintln(stderr, "docfill: missing command: run")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if args[0] != "run" {
|
||||||
|
fmt.Fprintf(stderr, "docfill: unknown command: %s\n", args[0])
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
result, code := runFill(args[1:], stderr)
|
||||||
|
if code != 0 {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(stdout, "generated %d file(s)\n", len(result.Files))
|
||||||
|
for _, file := range result.Files {
|
||||||
|
fmt.Fprintf(stdout, "- %s\n", file)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func runFill(args []string, stderr io.Writer) (docfill.RunResult, int) {
|
||||||
|
fs := flag.NewFlagSet("docfill run", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(stderr)
|
||||||
|
|
||||||
|
var configPath string
|
||||||
|
var vars varFlags = make(map[string]string)
|
||||||
|
fs.SetOutput(io.Discard)
|
||||||
|
fs.StringVar(&configPath, "config", "", "path to task json")
|
||||||
|
fs.StringVar(&configPath, "c", "", "path to task json")
|
||||||
|
fs.Var(vars, "var", "template variable as key=value; repeatable")
|
||||||
|
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
fmt.Fprintf(stderr, "docfill: %v\n", err)
|
||||||
|
return docfill.RunResult{}, 2
|
||||||
|
}
|
||||||
|
if fs.NArg() > 0 {
|
||||||
|
fmt.Fprintf(stderr, "docfill: unexpected argument: %s\n", fs.Arg(0))
|
||||||
|
return docfill.RunResult{}, 2
|
||||||
|
}
|
||||||
|
if vars == nil {
|
||||||
|
vars = make(map[string]string)
|
||||||
|
}
|
||||||
|
if configPath == "" {
|
||||||
|
fmt.Fprintln(stderr, "docfill: missing --config")
|
||||||
|
fs.SetOutput(stderr)
|
||||||
|
fs.Usage()
|
||||||
|
return docfill.RunResult{}, 2
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := docfill.RunWithOptions(configPath, docfill.RunOptions{Vars: vars})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "docfill: %v\n", err)
|
||||||
|
return docfill.RunResult{}, 1
|
||||||
|
}
|
||||||
|
return result, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v varFlags) String() string {
|
||||||
|
if len(v) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(v))
|
||||||
|
for key, value := range v {
|
||||||
|
parts = append(parts, key+"="+value)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v varFlags) Set(value string) error {
|
||||||
|
key, val, ok := strings.Cut(value, "=")
|
||||||
|
if !ok || strings.TrimSpace(key) == "" {
|
||||||
|
return fmt.Errorf("invalid --var %q, want key=value", value)
|
||||||
|
}
|
||||||
|
v[strings.TrimSpace(key)] = val
|
||||||
|
return nil
|
||||||
|
}
|
||||||
125
cmd/docfill/main_test.go
Normal file
125
cmd/docfill/main_test.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunCommandAcceptsShortConfigVarsAndPrintsSummary(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dataPath := filepath.Join(dir, "data.xlsx")
|
||||||
|
templatePath := filepath.Join(dir, "template.xlsx")
|
||||||
|
configPath := filepath.Join(dir, "task.json")
|
||||||
|
|
||||||
|
writeCLIWorkbook(t, dataPath, "Daily", [][]string{
|
||||||
|
{"name", "done"},
|
||||||
|
{"张三", "完成日报"},
|
||||||
|
})
|
||||||
|
writeCLIWorkbook(t, templatePath, "日报", [][]string{
|
||||||
|
{"批次", "{{batch}}"},
|
||||||
|
{"员工", "{{name}}"},
|
||||||
|
{"完成", "{{done}}"},
|
||||||
|
})
|
||||||
|
writeCLIJSON(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", "日报_{{batch}}_{{name}}.xlsx"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
exitCode := run([]string{"run", "-c", configPath, "--var", "batch=早班"}, &stdout, &stderr)
|
||||||
|
|
||||||
|
if exitCode != 0 {
|
||||||
|
t.Fatalf("exit code = %d, stderr=%q", exitCode, stderr.String())
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "generated 1 file(s)") || !strings.Contains(stdout.String(), "日报_早班_张三.xlsx") {
|
||||||
|
t.Fatalf("unexpected stdout: %q", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunCommandRejectsBadVarSyntax(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
exitCode := run([]string{"run", "-c", "task.json", "--var", "badvar"}, &stdout, &stderr)
|
||||||
|
|
||||||
|
if exitCode != 2 {
|
||||||
|
t.Fatalf("exit code = %d, want 2", exitCode)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "docfill:") || !strings.Contains(stderr.String(), "invalid --var") {
|
||||||
|
t.Fatalf("unexpected stderr: %q", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunCommandRejectsUnexpectedArgs(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
exitCode := run([]string{"run", "-c", "task.json", "extra"}, &stdout, &stderr)
|
||||||
|
|
||||||
|
if exitCode != 2 {
|
||||||
|
t.Fatalf("exit code = %d, want 2", exitCode)
|
||||||
|
}
|
||||||
|
if stdout.Len() != 0 {
|
||||||
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "unexpected argument") {
|
||||||
|
t.Fatalf("unexpected stderr: %q", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeCLIWorkbook(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: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := f.SaveAs(path); err != nil {
|
||||||
|
t.Fatalf("save workbook: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeCLIJSON(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
90
docs/digital-employee-runbook.md
Normal file
90
docs/digital-employee-runbook.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Digital Employee Runbook
|
||||||
|
|
||||||
|
This file is the minimal operating contract for calling `docfill` from a digital employee, scheduler, or shell script.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
Each run needs:
|
||||||
|
|
||||||
|
- A `task.json` config file.
|
||||||
|
- A data source: `.xlsx` or SQLite `.db`.
|
||||||
|
- A template: `.docx` or `.xlsx`.
|
||||||
|
- An output path pattern in `task.json`.
|
||||||
|
|
||||||
|
Paths inside `task.json` are resolved from the config file directory.
|
||||||
|
|
||||||
|
## Command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docfill run -c /path/to/task.json --var date=2026-06-18
|
||||||
|
```
|
||||||
|
|
||||||
|
Use repeated `--var key=value` arguments for runtime values:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docfill run -c task.json --var date=2026-06-18 --var batch=早班
|
||||||
|
```
|
||||||
|
|
||||||
|
CLI variables override `vars` in `task.json`. Row data overrides both.
|
||||||
|
|
||||||
|
## Success
|
||||||
|
|
||||||
|
Exit code `0`.
|
||||||
|
|
||||||
|
Stdout contains generated files:
|
||||||
|
|
||||||
|
```text
|
||||||
|
generated 2 file(s)
|
||||||
|
- /path/to/out/report_张三.xlsx
|
||||||
|
- /path/to/out/report_李四.xlsx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Failure
|
||||||
|
|
||||||
|
Exit code `1` means the task ran but failed.
|
||||||
|
|
||||||
|
Exit code `2` means the command line was invalid.
|
||||||
|
|
||||||
|
Errors are written to stderr with the `docfill:` prefix:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docfill: render row 1: missing template field "done"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Errors
|
||||||
|
|
||||||
|
`missing template field`
|
||||||
|
|
||||||
|
The template contains a `{{field}}` that is not present in data, `vars`, or CLI `--var`.
|
||||||
|
|
||||||
|
`output already exists`
|
||||||
|
|
||||||
|
The output file exists and `output.overwrite` is not `true`.
|
||||||
|
|
||||||
|
`missing sqlite params field`
|
||||||
|
|
||||||
|
A SQLite query parameter such as `{{date}}` was not provided.
|
||||||
|
|
||||||
|
`unresolved placeholder`
|
||||||
|
|
||||||
|
The rendered document still contains `{{field}}`. Check the source data value and the template.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The local build output is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dist/docfill-darwin-arm64
|
||||||
|
```
|
||||||
|
|
||||||
|
## Smoke Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Smoke test builds a temporary binary, generates temporary example files, runs both example configs, and checks generated outputs.
|
||||||
45
docs/superpowers/plans/2026-06-18-docfill-mvp.md
Normal file
45
docs/superpowers/plans/2026-06-18-docfill-mvp.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Docfill MVP Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build a minimal Go CLI that fills `.docx` and `.xlsx` templates from Excel or SQLite rows.
|
||||||
|
|
||||||
|
**Architecture:** A single internal package loads JSON config, reads rows from Excel or SQLite, renders placeholders into docx/xlsx files, and exposes `Run`. A tiny CLI parses `run --config`.
|
||||||
|
|
||||||
|
**Tech Stack:** Go, `excelize` for `.xlsx`, `modernc.org/sqlite` for SQLite, standard `archive/zip` for `.docx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Tests First
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `internal/docfill/docfill_test.go`
|
||||||
|
|
||||||
|
- [ ] Write tests for Excel row reading, SQLite row reading, xlsx rendering, docx rendering, and full config execution.
|
||||||
|
- [ ] Run `go test ./...` and confirm it fails because the package implementation does not exist yet.
|
||||||
|
|
||||||
|
### Task 2: Minimal Library
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `internal/docfill/docfill.go`
|
||||||
|
|
||||||
|
- [ ] Implement config parsing.
|
||||||
|
- [ ] Implement Excel and SQLite row readers.
|
||||||
|
- [ ] Implement placeholder replacement.
|
||||||
|
- [ ] Implement xlsx and docx rendering.
|
||||||
|
- [ ] Implement `Run(configPath string) error`.
|
||||||
|
|
||||||
|
### Task 3: CLI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `cmd/docfill/main.go`
|
||||||
|
|
||||||
|
- [ ] Parse `docfill run --config task.json`.
|
||||||
|
- [ ] Print clear errors to stderr.
|
||||||
|
- [ ] Exit non-zero on failure.
|
||||||
|
|
||||||
|
### Task 4: Verify
|
||||||
|
|
||||||
|
- [ ] Run `go test ./...`.
|
||||||
|
- [ ] Run `go build ./cmd/docfill`.
|
||||||
|
- [ ] Keep README usage minimal.
|
||||||
49
docs/superpowers/plans/2026-06-18-docfill-p0.5-delivery.md
Normal file
49
docs/superpowers/plans/2026-06-18-docfill-p0.5-delivery.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Docfill P0.5 Delivery Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Make the existing P0 CLI easier to hand to digital employees by adding delivery hygiene, runnable examples, a smoke script, and minimal Word split-placeholder support.
|
||||||
|
|
||||||
|
**Architecture:** Keep one `docfill run` command and the existing internal package. Add only test fixture helpers and examples around the current API; do not add a template engine, web UI, or multi-command workflow.
|
||||||
|
|
||||||
|
**Tech Stack:** Go, `excelize`, `modernc.org/sqlite`, standard shell for smoke testing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Delivery Hygiene
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.gitignore`
|
||||||
|
- Modify: `README.md`
|
||||||
|
|
||||||
|
- [ ] Ignore local OS files, compiled binaries, test binaries, and example outputs.
|
||||||
|
- [ ] Keep README focused on run/build/test/smoke usage.
|
||||||
|
|
||||||
|
### Task 2: Word Split Placeholders
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `internal/docfill/docfill.go`
|
||||||
|
- Modify: `internal/docfill/docfill_test.go`
|
||||||
|
|
||||||
|
- [ ] Add a failing test for a Word placeholder split across adjacent `<w:t>` nodes in one paragraph.
|
||||||
|
- [ ] Implement minimal rendering for split placeholders inside Word text nodes.
|
||||||
|
- [ ] Keep existing contiguous placeholder behavior passing.
|
||||||
|
|
||||||
|
### Task 3: Runnable Examples
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `examples/README.md`
|
||||||
|
- Create: `examples/excel-to-word/task.json`
|
||||||
|
- Create: `examples/sqlite-to-excel/task.json`
|
||||||
|
- Create: `scripts/smoke-fixtures/main.go`
|
||||||
|
- Create: `scripts/smoke.sh`
|
||||||
|
|
||||||
|
- [ ] Add two small configs that mirror real employee daily-report workflows.
|
||||||
|
- [ ] Generate temporary fixture files from Go during smoke tests instead of storing binary Office files in git.
|
||||||
|
- [ ] Smoke test builds the CLI, copies examples to a temp dir, generates fixtures, runs both example configs, and runs `go test -count=1 ./...`.
|
||||||
|
|
||||||
|
### Task 4: Verify
|
||||||
|
|
||||||
|
- [ ] Run `go test -count=1 ./...`.
|
||||||
|
- [ ] Run `bash scripts/smoke.sh`.
|
||||||
|
- [ ] Run `go build -o /tmp/docfill-p05 ./cmd/docfill`.
|
||||||
38
docs/superpowers/plans/2026-06-18-docfill-p0.6-freeze.md
Normal file
38
docs/superpowers/plans/2026-06-18-docfill-p0.6-freeze.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Docfill P0.6 Freeze Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Freeze the current MVP as a small deliverable that can be built, smoke-tested, and handed to a digital employee.
|
||||||
|
|
||||||
|
**Architecture:** Keep the current single-command CLI and add only delivery wrappers: a local build script, a runbook, and ignore rules for generated artifacts. No new runtime features.
|
||||||
|
|
||||||
|
**Tech Stack:** Go, shell, existing `docfill run` CLI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Build Output
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.gitignore`
|
||||||
|
- Create: `scripts/build.sh`
|
||||||
|
|
||||||
|
- [ ] Ignore `dist/`.
|
||||||
|
- [ ] Build only the current machine target into `dist/docfill-darwin-arm64`.
|
||||||
|
- [ ] Print the output path.
|
||||||
|
|
||||||
|
### Task 2: Digital Employee Runbook
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docs/digital-employee-runbook.md`
|
||||||
|
- Modify: `README.md`
|
||||||
|
|
||||||
|
- [ ] Document required inputs, command contract, stdout/stderr handling, exit codes, and common errors.
|
||||||
|
- [ ] Link the runbook from README.
|
||||||
|
|
||||||
|
### Task 3: Freeze Verification
|
||||||
|
|
||||||
|
- [ ] Run `go test -count=1 ./...`.
|
||||||
|
- [ ] Run `bash scripts/smoke.sh`.
|
||||||
|
- [ ] Run `bash scripts/build.sh`.
|
||||||
|
- [ ] Stage all project files except local ignored files.
|
||||||
|
- [ ] Commit as `feat: add docfill mvp cli`.
|
||||||
50
docs/superpowers/specs/2026-06-18-docfill-mvp-design.md
Normal file
50
docs/superpowers/specs/2026-06-18-docfill-mvp-design.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Docfill MVP Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Build the smallest local Go command-line tool that a digital employee can call to fill Word or Excel templates from Excel or SQLite data.
|
||||||
|
|
||||||
|
## MVP Scope
|
||||||
|
|
||||||
|
- Input data: `.xlsx` table or SQLite query result.
|
||||||
|
- Template files: `.docx` or `.xlsx`.
|
||||||
|
- Placeholder syntax: `{{column_name}}`.
|
||||||
|
- Output: one generated file per input row.
|
||||||
|
- Invocation: `docfill run --config task.json`.
|
||||||
|
|
||||||
|
## Explicit Non-Goals
|
||||||
|
|
||||||
|
- No web UI.
|
||||||
|
- No permission system.
|
||||||
|
- No workflow engine.
|
||||||
|
- No template designer.
|
||||||
|
- No direct business-system integration.
|
||||||
|
- No AI guessing of fields.
|
||||||
|
- No complex Word layout manipulation.
|
||||||
|
|
||||||
|
## Config Shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"type": "excel",
|
||||||
|
"path": "./data.xlsx",
|
||||||
|
"sheet": "Sheet1"
|
||||||
|
},
|
||||||
|
"template": {
|
||||||
|
"path": "./template.docx"
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"path": "./out/report_{{name}}_{{_row}}.docx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For SQLite, `data` uses `type: "sqlite"` and `query`.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Keep the implementation boring and inspectable.
|
||||||
|
- Infer template behavior from file extension.
|
||||||
|
- Treat Excel first row or SQL column names as placeholder keys.
|
||||||
|
- If a Word placeholder is split across Word XML runs, MVP may not replace it; users should type placeholders as plain contiguous text.
|
||||||
65
docs/superpowers/specs/2026-06-18-docfill-p0-cli-design.md
Normal file
65
docs/superpowers/specs/2026-06-18-docfill-p0-cli-design.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# Docfill P0 CLI Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make the MVP safe enough for a digital employee to call repeatedly from scripts.
|
||||||
|
|
||||||
|
## CLI Contract
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docfill run --config task.json --var date=2026-06-18
|
||||||
|
docfill run -c task.json --var date=2026-06-18 --var department=营销部
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit codes:
|
||||||
|
|
||||||
|
- `0`: success.
|
||||||
|
- `1`: task execution or validation failed.
|
||||||
|
- `2`: CLI usage error.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
- Success writes a short generated-file summary to stdout.
|
||||||
|
- Errors write one clear line to stderr with the `docfill:` prefix.
|
||||||
|
|
||||||
|
Reserved for later:
|
||||||
|
|
||||||
|
- `docfill validate`.
|
||||||
|
- `--json`.
|
||||||
|
|
||||||
|
## P0 Behavior
|
||||||
|
|
||||||
|
- Resolve relative paths in config from the config file directory.
|
||||||
|
- Support config vars and repeated CLI vars.
|
||||||
|
- CLI vars override config vars.
|
||||||
|
- Per-row data overrides vars during template rendering.
|
||||||
|
- Generated `_row` is always the 1-based row number.
|
||||||
|
- Support SQLite `params` for `?` query placeholders.
|
||||||
|
- Support Excel `header_row`, 1-based, default `1`.
|
||||||
|
- Do not overwrite existing output files unless `output.overwrite` is `true`.
|
||||||
|
- Sanitize placeholder values only when rendering output file paths.
|
||||||
|
- Fail if any template placeholder is missing from the current row context.
|
||||||
|
- Fail if any `{{field}}` remains after rendering.
|
||||||
|
|
||||||
|
## Config Shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"vars": {
|
||||||
|
"date": "2026-06-18"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "sqlite",
|
||||||
|
"path": "./data.db",
|
||||||
|
"query": "select name, done from daily where date = ?",
|
||||||
|
"params": ["{{date}}"]
|
||||||
|
},
|
||||||
|
"template": {
|
||||||
|
"path": "./template.docx"
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"path": "./out/report_{{date}}_{{name}}_{{_row}}.docx",
|
||||||
|
"overwrite": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
16
examples/README.md
Normal file
16
examples/README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# docfill examples
|
||||||
|
|
||||||
|
These examples are intentionally small and script-friendly.
|
||||||
|
|
||||||
|
Run all examples through the smoke script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The smoke script copies this directory to a temp folder, generates the Office and SQLite fixture files, builds `docfill`, and runs the example tasks.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
- `excel-to-word`: Excel daily rows to Word daily reports.
|
||||||
|
- `sqlite-to-excel`: SQLite summary rows to Excel department reports.
|
||||||
18
examples/excel-to-word/task.json
Normal file
18
examples/excel-to-word/task.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"vars": {
|
||||||
|
"date": "2026-06-18"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "excel",
|
||||||
|
"path": "./data.xlsx",
|
||||||
|
"sheet": "Daily",
|
||||||
|
"header_row": 1
|
||||||
|
},
|
||||||
|
"template": {
|
||||||
|
"path": "./template.docx"
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"path": "./out/daily_{{date}}_{{name}}_{{_row}}.docx",
|
||||||
|
"overwrite": true
|
||||||
|
}
|
||||||
|
}
|
||||||
18
examples/sqlite-to-excel/task.json
Normal file
18
examples/sqlite-to-excel/task.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"vars": {
|
||||||
|
"date": "2026-06-18"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "sqlite",
|
||||||
|
"path": "./data.db",
|
||||||
|
"query": "select department, report_date, total_count, abnormal_count from department_summary where report_date = ? order by department",
|
||||||
|
"params": ["{{date}}"]
|
||||||
|
},
|
||||||
|
"template": {
|
||||||
|
"path": "./template.xlsx"
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"path": "./out/summary_{{department}}_{{_row}}.xlsx",
|
||||||
|
"overwrite": true
|
||||||
|
}
|
||||||
|
}
|
||||||
29
go.mod
Normal file
29
go.mod
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
module xlsdoc
|
||||||
|
|
||||||
|
go 1.26
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/xuri/excelize/v2 v2.10.0
|
||||||
|
modernc.org/sqlite v1.40.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||||
|
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.1 // indirect
|
||||||
|
github.com/xuri/efp v0.0.1 // indirect
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||||
|
golang.org/x/crypto v0.43.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
|
golang.org/x/net v0.46.0 // indirect
|
||||||
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
|
golang.org/x/text v0.30.0 // indirect
|
||||||
|
modernc.org/libc v1.66.10 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
78
go.sum
Normal file
78
go.sum
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
|
||||||
|
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
|
||||||
|
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
|
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
|
||||||
|
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4=
|
||||||
|
github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||||
|
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||||
|
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||||
|
github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4=
|
||||||
|
github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||||
|
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||||
|
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||||
|
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||||
|
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||||
|
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||||
|
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
||||||
|
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||||
|
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
|
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||||
|
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||||
|
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||||
|
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||||
|
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||||
|
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||||
|
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
|
||||||
|
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
13
scripts/build.sh
Executable file
13
scripts/build.sh
Executable file
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
OUT_DIR="$ROOT/dist"
|
||||||
|
OUT="$OUT_DIR/docfill-darwin-arm64"
|
||||||
|
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
go build -trimpath -o "$OUT" ./cmd/docfill
|
||||||
|
|
||||||
|
echo "$OUT"
|
||||||
137
scripts/smoke-fixtures/main.go
Normal file
137
scripts/smoke-fixtures/main.go
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"database/sql"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
|
"github.com/xuri/excelize/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
base := flag.String("base", "", "examples base directory")
|
||||||
|
flag.Parse()
|
||||||
|
if *base == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "missing --base")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeExcelToWord(filepath.Join(*base, "excel-to-word")); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "write excel-to-word fixtures: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := writeSQLiteToExcel(filepath.Join(*base, "sqlite-to-excel")); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "write sqlite-to-excel fixtures: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeExcelToWord(dir string) error {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeWorkbook(filepath.Join(dir, "data.xlsx"), "Daily", [][]string{
|
||||||
|
{"name", "date", "done", "plan"},
|
||||||
|
{"张三", "2026-06-18", "完成接口数据核对", "继续处理异常数据"},
|
||||||
|
{"李四", "2026-06-18", "完成日报提交", "补充系统截图"},
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeDocx(filepath.Join(dir, "template.docx"), `<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:p><w:p><w:r><w:t>日期:{{date}}</w:t></w:r></w:p><w:p><w:r><w:t>完成:{{done}}</w:t></w:r></w:p><w:p><w:r><w:t>计划:{{plan}}</w:t></w:r></w:p></w:body></w:document>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSQLiteToExcel(dir string) error {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeSQLite(filepath.Join(dir, "data.db")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeWorkbook(filepath.Join(dir, "template.xlsx"), "Summary", [][]string{
|
||||||
|
{"部门", "{{department}}"},
|
||||||
|
{"日期", "{{report_date}}"},
|
||||||
|
{"总数", "{{total_count}}"},
|
||||||
|
{"异常数", "{{abnormal_count}}"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeWorkbook(path, sheet string, rows [][]string) error {
|
||||||
|
f := excelize.NewFile()
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
defaultSheet := f.GetSheetName(0)
|
||||||
|
if defaultSheet != sheet {
|
||||||
|
if err := f.SetSheetName(defaultSheet, sheet); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for r, row := range rows {
|
||||||
|
for c, value := range row {
|
||||||
|
cell, err := excelize.CoordinatesToCellName(c+1, r+1)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.SetCellValue(sheet, cell, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f.SaveAs(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeDocx(path, body string) error {
|
||||||
|
file, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
zw := zip.NewWriter(file)
|
||||||
|
if err := writeZipEntry(zw, "[Content_Types].xml", `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"/>`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeZipEntry(zw, "word/document.xml", body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return zw.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeZipEntry(zw *zip.Writer, name, body string) error {
|
||||||
|
w, err := zw.Create(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = w.Write([]byte(body))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSQLite(path string) error {
|
||||||
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
_, err = db.Exec(`
|
||||||
|
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),
|
||||||
|
('客服部', '2026-06-17', 68, 1);
|
||||||
|
`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
31
scripts/smoke.sh
Executable file
31
scripts/smoke.sh
Executable file
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
TMP="$(mktemp -d "${TMPDIR:-/tmp}/docfill-smoke.XXXXXX")"
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$TMP"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
go test -count=1 ./...
|
||||||
|
go build -o "$TMP/docfill" ./cmd/docfill
|
||||||
|
|
||||||
|
cp -R examples "$TMP/examples"
|
||||||
|
go run ./scripts/smoke-fixtures --base "$TMP/examples"
|
||||||
|
|
||||||
|
"$TMP/docfill" run -c "$TMP/examples/excel-to-word/task.json" --var date=2026-06-18
|
||||||
|
"$TMP/docfill" run -c "$TMP/examples/sqlite-to-excel/task.json" --var date=2026-06-18
|
||||||
|
|
||||||
|
test -f "$TMP/examples/excel-to-word/out/daily_2026-06-18_张三_1.docx"
|
||||||
|
test -f "$TMP/examples/excel-to-word/out/daily_2026-06-18_李四_2.docx"
|
||||||
|
|
||||||
|
xlsx_count="$(find "$TMP/examples/sqlite-to-excel/out" -type f -name '*.xlsx' | wc -l | tr -d ' ')"
|
||||||
|
if [[ "$xlsx_count" != "2" ]]; then
|
||||||
|
echo "expected 2 sqlite-to-excel outputs, got $xlsx_count" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "smoke ok"
|
||||||
Reference in New Issue
Block a user