AXLR / Catalog / administration

mail_merge

Mail merge: CSV text with a header row plus a text template with {{field}} and {{field|default}} placeholders gives one rendered document per row, with the missing fields of each row (no such column, or empty cell), an optional file name per document from a file-name template, and duplicate-name warnings.

Install

/tools install mail_merge
/tools install mail_merge@1.0.0

In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · mail_merge 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool mail_merge; each run is approved as a local_exec.

Arguments

One JSON object on stdin, validated against the schema before the program starts.

nametyperequireddefaultmeaning
csvstringyesCSV text, first row the header
templatestringyesText with {{field}} or {{field|default}}
filename_templatestringnoe.g. letter_{{id}}.txt; unsafe characters become _
delimiterstringno","length ≥ 1; length ≤ 1
missingstringno"keep"What to put where a field is missing. one of "keep", "empty", "error"
rowsarray of integerno1-based data rows to render; default all
Input schema (JSON)
{
  "type": "object",
  "required": [
    "csv",
    "template"
  ],
  "additionalProperties": false,
  "properties": {
    "csv": {
      "type": "string",
      "description": "CSV text, first row the header"
    },
    "template": {
      "type": "string",
      "description": "Text with {{field}} or {{field|default}}"
    },
    "filename_template": {
      "type": "string",
      "description": "e.g. letter_{{id}}.txt; unsafe characters become _"
    },
    "delimiter": {
      "type": "string",
      "minLength": 1,
      "maxLength": 1,
      "default": ","
    },
    "missing": {
      "type": "string",
      "enum": [
        "keep",
        "empty",
        "error"
      ],
      "default": "keep",
      "description": "What to put where a field is missing"
    },
    "rows": {
      "type": "array",
      "items": {
        "type": "integer",
        "minimum": 1
      },
      "description": "1-based data rows to render; default all"
    }
  }
}

Example

Input

{
  "csv": "name,city\nAna,Madrid\nLi,\n",
  "template": "Dear {{name}}, see you in {{city|our office}}."
}

Output (stdout)

{
  "rendered": 2,
  "documents": [
    {
      "row": 1,
      "text": "Dear Ana, see you in Madrid.",
      "missing": []
    },
    {
      "row": 2,
      "text": "Dear Li, see you in our office.",
      "missing": [],
      "defaults_used": [
        "city"
      ]
    }
  ]
}

Files

The command the install card registers is python3 mail_merge.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.

mail_merge.py · sha256 f0524c10aae56190c62f094aadca53a73fe790d544af5e2828a492d4af264027
#!/usr/bin/env python3
"""Mail merge: CSV rows (given inline) through a text template with {{field}} and
{{field|default}} placeholders, one rendered document per row, with the missing fields of
each row reported and an optional file name per document.

Input (JSON on stdin):
  csv                CSV text with a header row
  template           text with {{field}} or {{field|default}} (spaces inside the braces allowed)
  filename_template  optional, same syntax, e.g. "letter_{{id}}.txt"
  delimiter          optional single character, default ","
  missing            "keep" (default: leave {{field}} in the text) | "empty" | "error" (exit 2)
  rows               optional list of 1-based data row numbers to render (default all)
A field is missing in a row when its column does not exist or the cell is empty; a default
after | is used in both cases.
"""
import csv
import io
import json
import re
import sys

PLACEHOLDER = re.compile(r"\{\{\s*([^{}|\s][^{}|]*?)\s*(?:\|([^{}]*))?\}\}")
UNSAFE = re.compile(r'[/\\:*?"<>|\x00-\x1f\x7f]')
MAX_ROWS = 10000


def fail(message):
    print(json.dumps({"error": message}, ensure_ascii=False))
    sys.exit(2)


def render(template, row, header, mode):
    """Returns (text, missing fields, defaulted fields)."""
    missing, defaulted = [], []

    def substitute(match):
        field, default = match.group(1), match.group(2)
        value = row.get(field, "") if field in header else ""
        if value != "":
            return value
        if default is not None:
            defaulted.append(field)
            return default
        missing.append({"field": field, "reason": "no such column" if field not in header else "empty cell"})
        return match.group(0) if mode == "keep" else ""

    return PLACEHOLDER.sub(substitute, template), missing, defaulted


def main():
    try:
        request = json.load(sys.stdin)
    except ValueError as error:
        fail(f"stdin is not JSON: {error}")
    if not isinstance(request, dict):
        fail("input must be a JSON object")
    extra = sorted(set(request) - {"csv", "template", "filename_template", "delimiter", "missing", "rows"})
    if extra:
        fail(f"unknown field(s): {', '.join(extra)}")
    text = request.get("csv")
    if not isinstance(text, str) or not text.strip():
        fail("csv must be non-empty CSV text with a header row")
    if len(text) > 5_000_000:
        fail("csv must be at most 5,000,000 characters")
    template = request.get("template")
    if not isinstance(template, str) or not template:
        fail("template must be a non-empty string")
    name_template = request.get("filename_template")
    if name_template is not None and (not isinstance(name_template, str) or not name_template.strip()):
        fail("filename_template must be a non-empty string")
    delimiter = request.get("delimiter", ",")
    if not isinstance(delimiter, str) or len(delimiter) != 1 or delimiter in "\"\r\n":
        fail("delimiter must be one character (not a quote or newline)")
    mode = request.get("missing", "keep")
    if mode not in ("keep", "empty", "error"):
        fail("missing must be keep, empty or error")

    text = text.lstrip("")
    try:
        records = list(csv.reader(io.StringIO(text, newline=""), delimiter=delimiter, strict=True))
    except csv.Error as error:
        fail(f"csv could not be read: {error}")
    records = [r for r in records if any(cell.strip() for cell in r)]
    if not records:
        fail("csv has no header row")
    header = [h.strip() for h in records[0]]
    if any(h == "" for h in header):
        fail("csv header has an empty column name")
    duplicates = sorted({h for h in header if header.count(h) > 1})
    if duplicates:
        fail(f"csv header repeats column name(s): {', '.join(duplicates)}")
    data = records[1:]
    if len(data) > MAX_ROWS:
        fail(f"csv has more than {MAX_ROWS} data rows")
    wanted = request.get("rows")
    if wanted is not None:
        if not isinstance(wanted, list) or not all(isinstance(n, int) and not isinstance(n, bool) for n in wanted):
            fail("rows must be a list of 1-based row numbers")
        bad = [n for n in wanted if not 1 <= n <= len(data)]
        if bad:
            fail(f"rows out of range 1..{len(data)}: {bad}")
    selected = wanted if wanted is not None else list(range(1, len(data) + 1))

    placeholders = []
    for source in (template, name_template or ""):
        for m in PLACEHOLDER.finditer(source):
            if m.group(1) not in placeholders:
                placeholders.append(m.group(1))
    documents, problems, names = [], [], {}
    warnings = []
    for number in selected:
        cells = data[number - 1]
        if len(cells) != len(header):
            warnings.append(f"row {number} has {len(cells)} cells for {len(header)} columns")
        row = {h: (cells[i] if i < len(cells) else "") for i, h in enumerate(header)}
        body, missing, defaulted = render(template, row, header, mode)
        doc = {"row": number, "text": body, "missing": missing}
        if defaulted:
            doc["defaults_used"] = sorted(set(defaulted))
        if name_template is not None:
            raw, name_missing, _ = render(name_template, row, header, "empty")
            name = UNSAFE.sub("_", raw).strip(" .") or f"document_{number}"
            doc["filename"] = name
            names.setdefault(name, []).append(number)
            if name_missing:
                doc["filename_missing"] = name_missing
        if missing:
            problems.append({"row": number, "fields": [m["field"] for m in missing]})
        documents.append(doc)
    if mode == "error" and problems:
        first = problems[0]
        fail(f"{len(problems)} row(s) have missing fields; first: row {first['row']} lacks {', '.join(first['fields'])}")
    clashes = {n: rows for n, rows in names.items() if len(rows) > 1}
    if clashes:
        warnings.append("duplicate file names: " + "; ".join(f"{n} (rows {', '.join(map(str, r))})" for n, r in clashes.items()))
    unknown = [p for p in placeholders if p not in header]
    unused = [h for h in header if h not in placeholders]
    result = {
        "columns": header, "placeholders": placeholders,
        "placeholders_without_column": unknown, "columns_not_used": unused,
        "rows_total": len(data), "rendered": len(documents),
        "rows_with_missing": problems,
        "documents": documents,
        "warnings": warnings,
    }
    print(json.dumps(result, ensure_ascii=False))


if __name__ == "__main__":
    main()

Where it is served

The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:

curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/mail_merge/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/mail_merge/1.0.0/mail_merge.py