m's 2026-05-21 scope reduction of the t-paliad-215 submission generator:
ship a demo that hands the lawyer the firm style template as a clean
.docx. No variable-merge engine, no per-submission template registry,
no fallback chain — the merge slice is deferred to a future task.
Replaces the previous engine (template registry + variable bag +
{{placeholder}} renderer + dual project_events/documents writes) with:
* services.ConvertDotmToDocx — single-function .dotm/.docm/.dotx → .docx
format converter that strips word/vbaProject.bin, word/vbaData.xml,
word/customizations.xml, and word/_rels/vbaProject.bin.rels, rewrites
[Content_Types].xml (demotes the macro/template main type to plain
docx, drops the .bin Default Extension and the macro Overrides), and
rewrites word/_rels/document.xml.rels to drop the vbaProject +
keyMapCustomizations relationships. Idempotent on a plain .docx.
archive/zip + regex stdlib only — no new third-party dependencies.
* handlers/submissions.go — POST /api/projects/{id}/submissions/{code}
/generate fetches the cached HL Patents Style .dotm (via a new
fetchHLPatentsStyleBytes accessor on files.go that shares the same
cache as /files/{slug}), converts, writes one paliad.system_audit_log
row (event_type='submission.generated', metadata={submission_code,
rule_name, filename}), and streams the .docx as an attachment. GET
/api/projects/{id}/submissions still lists filing rules but
has_template is unconditionally true (one universal template).
* Filename per design §7: {rule.name}-{project.case_number}-{YYYY-MM-DD}
.docx, with Umlauts ASCII-folded and slashes → underscores.
Drops services/submission_templates.go, services/submission_vars.go,
and the wiring in cmd/server/main.go + handlers/handlers.go that bound
them together. Frontend client switched to POST.
Verified the converter against the real HL Patents Style.dotm (361 KB
input → 243 KB output, 46 parts in output zip):
unzip -tq /tmp/hl-patents-style.converted.docx → No errors
python3 -c "import zipfile, xml.etree.ElementTree as ET; \
z=zipfile.ZipFile('/tmp/hl-patents-style.converted.docx'); \
[ET.fromstring(z.read(p)) for p in z.namelist() if p.endswith('.xml')]"
uv run --with python-docx python3 -c "import docx; \
d=docx.Document('/tmp/hl-patents-style.converted.docx'); \
print(len(d.paragraphs), 'paragraphs', len(d.styles), 'styles')"
→ 236 paragraphs, 168 styles, 1 section
All assertions passed: every Override in [Content_Types].xml resolves
to a real part, every internal Target in document.xml.rels resolves,
zero macro-related residue, and the document body + styles + theme
survive untouched.
go test -run TestBootSmoke ./cmd/server/... clean (route additions
register without conflict on the Go ServeMux).
256 lines
8.9 KiB
Go
256 lines
8.9 KiB
Go
package services
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"io"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// minimalDOTM builds a small .dotm zip whose shape mirrors the real
|
|
// HL Patents Style template: macro-enabled main content type, Default
|
|
// extension declaring .bin as vbaProject, Overrides for vbaData.xml +
|
|
// customizations.xml, document.xml.rels with vbaProject +
|
|
// keyMapCustomizations relationships, and the four macro parts on
|
|
// disk (vbaProject.bin + auxiliary rels + vbaData.xml +
|
|
// customizations.xml).
|
|
//
|
|
// In-memory so the test is self-contained (no checked-in binary).
|
|
// Word and LibreOffice would reject this minimal file as incomplete
|
|
// (no _rels/.rels root manifest); the tests work at the byte level
|
|
// and assert structural properties of the converted output.
|
|
func minimalDOTM(t *testing.T) []byte {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
zw := zip.NewWriter(&buf)
|
|
add := func(name, body string) {
|
|
t.Helper()
|
|
w, err := zw.CreateHeader(&zip.FileHeader{
|
|
Name: name,
|
|
Method: zip.Deflate,
|
|
Modified: time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("zip header %s: %v", name, err)
|
|
}
|
|
if _, err := io.WriteString(w, body); err != nil {
|
|
t.Fatalf("write %s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
add(contentTypesPath, `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+
|
|
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`+
|
|
`<Default Extension="bin" ContentType="application/vnd.ms-office.vbaProject"/>`+
|
|
`<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>`+
|
|
`<Default Extension="xml" ContentType="application/xml"/>`+
|
|
`<Override PartName="/word/document.xml" ContentType="`+dotmMainContentType+`"/>`+
|
|
`<Override PartName="/word/customizations.xml" ContentType="application/vnd.ms-word.keyMapCustomizations+xml"/>`+
|
|
`<Override PartName="/word/vbaData.xml" ContentType="application/vnd.ms-word.vbaData+xml"/>`+
|
|
`<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>`+
|
|
`</Types>`)
|
|
|
|
add("word/document.xml",
|
|
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+
|
|
`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`+
|
|
`<w:body><w:p><w:r><w:t>Hello Paliad</w:t></w:r></w:p></w:body></w:document>`)
|
|
|
|
add(documentRelsPath,
|
|
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+
|
|
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">`+
|
|
`<Relationship Id="rId1" Type="http://schemas.microsoft.com/office/2006/relationships/vbaProject" Target="vbaProject.bin"/>`+
|
|
`<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>`+
|
|
`<Relationship Id="rId3" Type="http://schemas.microsoft.com/office/2006/relationships/keyMapCustomizations" Target="customizations.xml"/>`+
|
|
`</Relationships>`)
|
|
|
|
add("word/styles.xml", `<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>`)
|
|
add("word/vbaProject.bin", "PRETEND-VBA-BINARY-PAYLOAD")
|
|
add("word/_rels/vbaProject.bin.rels", `<?xml version="1.0"?><Relationships/>`)
|
|
add("word/vbaData.xml", `<?xml version="1.0"?><wne:vbaSuppData xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml"/>`)
|
|
add("word/customizations.xml", `<?xml version="1.0"?><wne:tcg xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml"/>`)
|
|
|
|
if err := zw.Close(); err != nil {
|
|
t.Fatalf("close zip: %v", err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func unzipEntries(t *testing.T, data []byte) map[string]string {
|
|
t.Helper()
|
|
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
|
if err != nil {
|
|
t.Fatalf("open output zip: %v", err)
|
|
}
|
|
out := make(map[string]string, len(zr.File))
|
|
for _, f := range zr.File {
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
t.Fatalf("open %s: %v", f.Name, err)
|
|
}
|
|
body, err := io.ReadAll(rc)
|
|
rc.Close()
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", f.Name, err)
|
|
}
|
|
out[f.Name] = string(body)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestConvertDotmToDocx_StripsMacroParts(t *testing.T) {
|
|
dotm := minimalDOTM(t)
|
|
out, err := ConvertDotmToDocx(dotm)
|
|
if err != nil {
|
|
t.Fatalf("ConvertDotmToDocx: %v", err)
|
|
}
|
|
|
|
entries := unzipEntries(t, out)
|
|
|
|
for _, name := range []string{
|
|
"word/vbaProject.bin",
|
|
"word/_rels/vbaProject.bin.rels",
|
|
"word/vbaData.xml",
|
|
"word/customizations.xml",
|
|
} {
|
|
if _, ok := entries[name]; ok {
|
|
t.Errorf("output still contains %s", name)
|
|
}
|
|
}
|
|
if doc, ok := entries["word/document.xml"]; !ok {
|
|
t.Error("output is missing word/document.xml")
|
|
} else if !strings.Contains(doc, "Hello Paliad") {
|
|
t.Errorf("document body lost during conversion: %q", doc)
|
|
}
|
|
if _, ok := entries["word/styles.xml"]; !ok {
|
|
t.Error("output lost unrelated word/styles.xml")
|
|
}
|
|
|
|
ctypes, ok := entries[contentTypesPath]
|
|
if !ok {
|
|
t.Fatal("output is missing [Content_Types].xml")
|
|
}
|
|
if strings.Contains(ctypes, "macroEnabled") {
|
|
t.Errorf("output [Content_Types].xml still references a macro-enabled type: %q", ctypes)
|
|
}
|
|
if !strings.Contains(ctypes, docxMainContentType) {
|
|
t.Errorf("output is missing plain docx main content type: %q", ctypes)
|
|
}
|
|
if strings.Contains(ctypes, "vbaProject") {
|
|
t.Errorf("output [Content_Types].xml still references vbaProject: %q", ctypes)
|
|
}
|
|
if strings.Contains(ctypes, "vbaData") {
|
|
t.Errorf("output [Content_Types].xml still overrides vbaData: %q", ctypes)
|
|
}
|
|
if strings.Contains(ctypes, "keyMapCustomizations") {
|
|
t.Errorf("output [Content_Types].xml still overrides customizations: %q", ctypes)
|
|
}
|
|
if !strings.Contains(ctypes, "wordprocessingml.styles") {
|
|
t.Errorf("output lost unrelated styles Override: %q", ctypes)
|
|
}
|
|
|
|
rels, ok := entries[documentRelsPath]
|
|
if !ok {
|
|
t.Fatal("output is missing word/_rels/document.xml.rels")
|
|
}
|
|
if strings.Contains(rels, "vbaProject") {
|
|
t.Errorf("output rels still references vbaProject: %q", rels)
|
|
}
|
|
if strings.Contains(rels, "keyMapCustomizations") {
|
|
t.Errorf("output rels still references keyMapCustomizations: %q", rels)
|
|
}
|
|
if !strings.Contains(rels, "styles.xml") {
|
|
t.Errorf("output rels lost unrelated styles relationship: %q", rels)
|
|
}
|
|
}
|
|
|
|
func TestConvertDotmToDocx_IdempotentOnPlainDocx(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
zw := zip.NewWriter(&buf)
|
|
add := func(name, body string) {
|
|
w, err := zw.Create(name)
|
|
if err != nil {
|
|
t.Fatalf("create %s: %v", name, err)
|
|
}
|
|
if _, err := io.WriteString(w, body); err != nil {
|
|
t.Fatalf("write %s: %v", name, err)
|
|
}
|
|
}
|
|
add(contentTypesPath, `<?xml version="1.0"?>`+
|
|
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`+
|
|
`<Override PartName="/word/document.xml" ContentType="`+docxMainContentType+`"/>`+
|
|
`</Types>`)
|
|
add("word/document.xml", `<w:document/>`)
|
|
if err := zw.Close(); err != nil {
|
|
t.Fatalf("close: %v", err)
|
|
}
|
|
|
|
out, err := ConvertDotmToDocx(buf.Bytes())
|
|
if err != nil {
|
|
t.Fatalf("ConvertDotmToDocx: %v", err)
|
|
}
|
|
|
|
entries := unzipEntries(t, out)
|
|
if _, ok := entries["word/vbaProject.bin"]; ok {
|
|
t.Error("plain docx grew a vbaProject during conversion")
|
|
}
|
|
if ctypes := entries[contentTypesPath]; !strings.Contains(ctypes, docxMainContentType) {
|
|
t.Errorf("plain docx lost its content type: %q", ctypes)
|
|
}
|
|
}
|
|
|
|
func TestConvertDotmToDocx_AcceptsDocmAndDotx(t *testing.T) {
|
|
for _, mainType := range []string{docmMainContentType, dotxMainContentType} {
|
|
t.Run(mainType, func(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
zw := zip.NewWriter(&buf)
|
|
add := func(name, body string) {
|
|
w, _ := zw.Create(name)
|
|
_, _ = io.WriteString(w, body)
|
|
}
|
|
add(contentTypesPath, `<?xml version="1.0"?>`+
|
|
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`+
|
|
`<Override PartName="/word/document.xml" ContentType="`+mainType+`"/>`+
|
|
`</Types>`)
|
|
add("word/document.xml", `<w:document/>`)
|
|
zw.Close()
|
|
out, err := ConvertDotmToDocx(buf.Bytes())
|
|
if err != nil {
|
|
t.Fatalf("ConvertDotmToDocx: %v", err)
|
|
}
|
|
ctypes := unzipEntries(t, out)[contentTypesPath]
|
|
if strings.Contains(ctypes, mainType) {
|
|
t.Errorf("non-docx main type survived conversion: %q", ctypes)
|
|
}
|
|
if !strings.Contains(ctypes, docxMainContentType) {
|
|
t.Errorf("docx main type not present: %q", ctypes)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConvertDotmToDocx_RejectsNonZip(t *testing.T) {
|
|
_, err := ConvertDotmToDocx([]byte("not a zip file"))
|
|
if err == nil {
|
|
t.Fatal("expected error for non-zip input, got nil")
|
|
}
|
|
}
|
|
|
|
func TestSanitiseSubmissionFileName(t *testing.T) {
|
|
cases := map[string]string{
|
|
"Klageerwiderung": "Klageerwiderung",
|
|
"Berufungsbegründung": "Berufungsbegruendung",
|
|
"Schriftsatz/Anlage": "Schriftsatz_Anlage",
|
|
`Statement of "Defence"`: "Statement of Defence",
|
|
` Klage `: "Klage",
|
|
"Größe": "Groesse",
|
|
}
|
|
for in, want := range cases {
|
|
t.Run(in, func(t *testing.T) {
|
|
if got := SanitiseSubmissionFileName(in); got != want {
|
|
t.Errorf("SanitiseSubmissionFileName(%q) = %q, want %q", in, got, want)
|
|
}
|
|
})
|
|
}
|
|
}
|