From ca444bd5d022c1fb93f1e7963e1ebf28afe0b5ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E4=B9=89=E4=BB=91?= Date: Thu, 18 Jun 2026 20:03:47 +0800 Subject: [PATCH] feat: add docfill mvp cli --- .gitignore | 14 + README.md | 144 ++++ cmd/docfill/main.go | 96 +++ cmd/docfill/main_test.go | 125 +++ docs/digital-employee-runbook.md | 90 +++ .../plans/2026-06-18-docfill-mvp.md | 45 ++ .../plans/2026-06-18-docfill-p0.5-delivery.md | 49 ++ .../plans/2026-06-18-docfill-p0.6-freeze.md | 38 + .../specs/2026-06-18-docfill-mvp-design.md | 50 ++ .../specs/2026-06-18-docfill-p0-cli-design.md | 65 ++ examples/README.md | 16 + examples/excel-to-word/task.json | 18 + examples/sqlite-to-excel/task.json | 18 + go.mod | 29 + go.sum | 78 ++ internal/docfill/case_test.go | 160 ++++ internal/docfill/docfill.go | 740 ++++++++++++++++++ internal/docfill/docfill_test.go | 309 ++++++++ internal/docfill/p0_test.go | 274 +++++++ scripts/build.sh | 13 + scripts/smoke-fixtures/main.go | 137 ++++ scripts/smoke.sh | 31 + 22 files changed, 2539 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cmd/docfill/main.go create mode 100644 cmd/docfill/main_test.go create mode 100644 docs/digital-employee-runbook.md create mode 100644 docs/superpowers/plans/2026-06-18-docfill-mvp.md create mode 100644 docs/superpowers/plans/2026-06-18-docfill-p0.5-delivery.md create mode 100644 docs/superpowers/plans/2026-06-18-docfill-p0.6-freeze.md create mode 100644 docs/superpowers/specs/2026-06-18-docfill-mvp-design.md create mode 100644 docs/superpowers/specs/2026-06-18-docfill-p0-cli-design.md create mode 100644 examples/README.md create mode 100644 examples/excel-to-word/task.json create mode 100644 examples/sqlite-to-excel/task.json create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/docfill/case_test.go create mode 100644 internal/docfill/docfill.go create mode 100644 internal/docfill/docfill_test.go create mode 100644 internal/docfill/p0_test.go create mode 100755 scripts/build.sh create mode 100644 scripts/smoke-fixtures/main.go create mode 100755 scripts/smoke.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..150ed21 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3458507 --- /dev/null +++ b/README.md @@ -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、权限、审批流、模板设计器或业务系统直连。 diff --git a/cmd/docfill/main.go b/cmd/docfill/main.go new file mode 100644 index 0000000..bf46060 --- /dev/null +++ b/cmd/docfill/main.go @@ -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 +} diff --git a/cmd/docfill/main_test.go b/cmd/docfill/main_test.go new file mode 100644 index 0000000..bc7ca9d --- /dev/null +++ b/cmd/docfill/main_test.go @@ -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) + } +} diff --git a/docs/digital-employee-runbook.md b/docs/digital-employee-runbook.md new file mode 100644 index 0000000..70fa061 --- /dev/null +++ b/docs/digital-employee-runbook.md @@ -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. diff --git a/docs/superpowers/plans/2026-06-18-docfill-mvp.md b/docs/superpowers/plans/2026-06-18-docfill-mvp.md new file mode 100644 index 0000000..649b5df --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-docfill-mvp.md @@ -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. diff --git a/docs/superpowers/plans/2026-06-18-docfill-p0.5-delivery.md b/docs/superpowers/plans/2026-06-18-docfill-p0.5-delivery.md new file mode 100644 index 0000000..e59d975 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-docfill-p0.5-delivery.md @@ -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 `` 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`. diff --git a/docs/superpowers/plans/2026-06-18-docfill-p0.6-freeze.md b/docs/superpowers/plans/2026-06-18-docfill-p0.6-freeze.md new file mode 100644 index 0000000..18d8888 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-docfill-p0.6-freeze.md @@ -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`. diff --git a/docs/superpowers/specs/2026-06-18-docfill-mvp-design.md b/docs/superpowers/specs/2026-06-18-docfill-mvp-design.md new file mode 100644 index 0000000..2724d0a --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-docfill-mvp-design.md @@ -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. diff --git a/docs/superpowers/specs/2026-06-18-docfill-p0-cli-design.md b/docs/superpowers/specs/2026-06-18-docfill-p0-cli-design.md new file mode 100644 index 0000000..dfad33d --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-docfill-p0-cli-design.md @@ -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 + } +} +``` diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..bb68185 --- /dev/null +++ b/examples/README.md @@ -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. diff --git a/examples/excel-to-word/task.json b/examples/excel-to-word/task.json new file mode 100644 index 0000000..c76104c --- /dev/null +++ b/examples/excel-to-word/task.json @@ -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 + } +} diff --git a/examples/sqlite-to-excel/task.json b/examples/sqlite-to-excel/task.json new file mode 100644 index 0000000..722e90a --- /dev/null +++ b/examples/sqlite-to-excel/task.json @@ -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 + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..28e1070 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..422c88c --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/docfill/case_test.go b/internal/docfill/case_test.go new file mode 100644 index 0000000..95de2a5 --- /dev/null +++ b/internal/docfill/case_test.go @@ -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) + } +} diff --git a/internal/docfill/docfill.go b/internal/docfill/docfill.go new file mode 100644 index 0000000..0098f16 --- /dev/null +++ b/internal/docfill/docfill.go @@ -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)]*>.*?`) + docxTextNodePattern = regexp.MustCompile(`(?s)]*>(.*?)`) + 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 +} diff --git a/internal/docfill/docfill_test.go b/internal/docfill/docfill_test.go new file mode 100644 index 0000000..5ae97b5 --- /dev/null +++ b/internal/docfill/docfill_test.go @@ -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, `员工:{{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") + 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", ``) + 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) + } +} diff --git a/internal/docfill/p0_test.go b/internal/docfill/p0_test.go new file mode 100644 index 0000000..b4d5bb5 --- /dev/null +++ b/internal/docfill/p0_test.go @@ -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) + } +} diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..6db8e91 --- /dev/null +++ b/scripts/build.sh @@ -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" diff --git a/scripts/smoke-fixtures/main.go b/scripts/smoke-fixtures/main.go new file mode 100644 index 0000000..c2aa8cd --- /dev/null +++ b/scripts/smoke-fixtures/main.go @@ -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"), `员工:{{name}}日期:{{date}}完成:{{done}}计划:{{plan}}`) +} + +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", ``); 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 +} diff --git a/scripts/smoke.sh b/scripts/smoke.sh new file mode 100755 index 0000000..7ac00c4 --- /dev/null +++ b/scripts/smoke.sh @@ -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"