The "Generieren" button on the project Schriftsätze tab posts to
/api/projects/{id}/submissions/{code}/generate. Pre-fix that handler
called `fetchHLPatentsStyleBytes` unconditionally and streamed the
result after a format-only .dotm→.docx convert — it never touched
`submissionTemplateRegistry` (added in t-paliad-241 for the draft
editor) and never ran the SubmissionRenderer merge. m's report on
m/paliad#84 ("the document generator still has no variables in the
template") was the lawyer-facing manifestation: HL Patents Style has
no {{…}} placeholders, so the downloaded .docx had nothing to
substitute and looked like a generic firm-style fixture.
The "Bearbeiten" path (/projects/{id}/submissions/{code}/draft) was
unaffected — it uses `resolveSubmissionTemplate` + the renderer
already, which is why the editor preview shows the 48 placeholders
resolved correctly. Only the one-click /generate side missed the
wire-up.
Fix:
- `internal/services/submission_draft_service.go` — add
`RenderProjectSubmission(ctx, userID, projectID, submissionCode,
templateBytes)` that wraps `vars.Build` + `renderer.Render` for the
no-saved-draft path. Returns the merged bytes plus the resolved
SubmissionVarsResult (rule, project, user, lang) so the handler can
derive filename + audit metadata without a second DB round-trip.
- `internal/handlers/submissions.go` — rewrite
`handleGenerateProjectSubmission` to resolve the template via
`resolveSubmissionTemplate` (per-firm slug → HL Patents Style
fallback, same as the editor draft) and run the new service method.
Visibility / rule-not-found semantics route through
`SubmissionVarsService` errors so the gate behavior matches every
other project endpoint. Removed `loadPublishedRuleByCode` and
`errRuleNotFound` — both were only used by the old handler.
- `scripts/gen-demo-submission-template/main.go` + the regenerated
`de.inf.lg.erwidg.docx` on mWorkRepo (HL/mWorkRepo @ 3e3e828f) now
exercise the bare `{{today}}` alias too. The demo template covers
every one of the 48 keys SubmissionVarsService can resolve (firm 2,
today 4, user 3, project 18, parties 6, rule 8, deadline 7).
The renderer is a no-op on placeholder substitution when the
fallback HL Patents Style is fetched (it has none) — but it still
runs the .dotm→.docx pre-pass via `ConvertDotmToDocx`, so the
non-per-firm code path streams a byte-for-byte equivalent download.
Build + vet + tests clean (go test ./internal/...; bun run build).
347 lines
13 KiB
Go
347 lines
13 KiB
Go
// Demo submission template generator (t-paliad-241).
|
|
//
|
|
// One-shot authoring tool that emits a minimal but Word-compatible
|
|
// .docx file exercising every placeholder SubmissionVarsService
|
|
// resolves. Drop the output into m/mWorkRepo at
|
|
//
|
|
// 6 - material/Templates/Word/Paliad/HLC/de.inf.lg.erwidg.docx
|
|
//
|
|
// so paliad's submission-draft editor (t-paliad-238 Slice A) can fetch
|
|
// it via the per-submission_code fallback chain wired into
|
|
// handlers/files.go. The structure is a fake Klageerwiderung skeleton
|
|
// in German — fake legal prose, real placeholder tokens.
|
|
//
|
|
// Why a generator instead of authoring in Word: the per-placeholder
|
|
// docx grammar is `{{[A-Za-z][A-Za-z0-9_.]*}}` and Word's autocorrect
|
|
// happily fragments such tokens across <w:r> runs ({{ → "{", "{",
|
|
// project.case_number, "}", "}"). A programmatic emitter writes each
|
|
// placeholder as a single run so the renderer's pass-1 substitution
|
|
// (format-preserving) catches it cleanly. The merge engine handles
|
|
// cross-run cases too (pass 2) but pass 1 is the cheaper path.
|
|
//
|
|
// Run:
|
|
//
|
|
// go run ./scripts/gen-demo-submission-template -out /tmp/de.inf.lg.erwidg.docx
|
|
//
|
|
// Output is deterministic so re-generating to the same path produces a
|
|
// byte-identical file (modulo zip mtime — we pin those to a fixed UTC
|
|
// timestamp so the bytes are reproducible).
|
|
package main
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
func main() {
|
|
out := flag.String("out", "de.inf.lg.erwidg.docx", "output .docx path")
|
|
flag.Parse()
|
|
|
|
docx, err := buildDocx()
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "gen-demo-submission-template:", err)
|
|
os.Exit(1)
|
|
}
|
|
if err := os.WriteFile(*out, docx, 0o644); err != nil {
|
|
fmt.Fprintln(os.Stderr, "gen-demo-submission-template: write:", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("wrote %s (%d bytes)\n", *out, len(docx))
|
|
}
|
|
|
|
// fixedTime is the zip mtime stamp baked into every entry so the output
|
|
// is byte-reproducible.
|
|
var fixedTime = time.Date(2026, 5, 23, 0, 0, 0, 0, time.UTC)
|
|
|
|
// buildDocx assembles the four-part .docx zip Word needs to open the
|
|
// file cleanly: Content_Types, root rels, document.xml, and document
|
|
// rels. Everything else (styles, theme, fonts) is optional — Word
|
|
// supplies sane defaults when absent.
|
|
func buildDocx() ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
zw := zip.NewWriter(&buf)
|
|
|
|
add := func(name, body string) error {
|
|
hdr := &zip.FileHeader{
|
|
Name: name,
|
|
Method: zip.Deflate,
|
|
Modified: fixedTime,
|
|
}
|
|
w, err := zw.CreateHeader(hdr)
|
|
if err != nil {
|
|
return fmt.Errorf("create %s: %w", name, err)
|
|
}
|
|
if _, err := w.Write([]byte(body)); err != nil {
|
|
return fmt.Errorf("write %s: %w", name, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if err := add("[Content_Types].xml", contentTypesXML); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := add("_rels/.rels", rootRelsXML); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := add("word/_rels/document.xml.rels", documentRelsXML); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := add("word/styles.xml", stylesXML); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := add("word/document.xml", buildDocumentXML()); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := zw.Close(); err != nil {
|
|
return nil, fmt.Errorf("finalise zip: %w", err)
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
const contentTypesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
|
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
|
<Default Extension="xml" ContentType="application/xml"/>
|
|
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
|
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
|
|
</Types>`
|
|
|
|
const rootRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
|
</Relationships>`
|
|
|
|
const documentRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
|
</Relationships>`
|
|
|
|
// stylesXML provides minimal Heading1 + Heading2 paragraph styles so
|
|
// the section headings render with visual weight. Body text falls
|
|
// through to Word's Normal style.
|
|
const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
|
<w:style w:type="paragraph" w:styleId="Heading1">
|
|
<w:name w:val="heading 1"/>
|
|
<w:basedOn w:val="Normal"/>
|
|
<w:pPr><w:spacing w:before="360" w:after="120"/></w:pPr>
|
|
<w:rPr><w:b/><w:sz w:val="28"/></w:rPr>
|
|
</w:style>
|
|
<w:style w:type="paragraph" w:styleId="Heading2">
|
|
<w:name w:val="heading 2"/>
|
|
<w:basedOn w:val="Normal"/>
|
|
<w:pPr><w:spacing w:before="240" w:after="80"/></w:pPr>
|
|
<w:rPr><w:b/><w:sz w:val="24"/></w:rPr>
|
|
</w:style>
|
|
<w:style w:type="paragraph" w:default="1" w:styleId="Normal">
|
|
<w:name w:val="Normal"/>
|
|
</w:style>
|
|
</w:styles>`
|
|
|
|
// Document body — a fake Klageerwiderung skeleton with every placeholder
|
|
// SubmissionVarsService resolves embedded in natural positions. Each
|
|
// placeholder is in its own run so pass-1 substitution catches it without
|
|
// fragmentation worries. The DEMO marker in the header makes it obvious
|
|
// this is not approved firm content.
|
|
//
|
|
// Structure mirrors a real submission:
|
|
//
|
|
// 1. Firm letterhead + author block (firm.*, user.*, today.*)
|
|
// 2. Court caption (project.*, project.proceeding.*)
|
|
// 3. Parties block (parties.*)
|
|
// 4. Submission title + legal source (rule.*)
|
|
// 5. Deadline (deadline.*)
|
|
// 6. Boilerplate body + signature
|
|
//
|
|
// Order matches what a lawyer drafting a real Klageerwiderung would put
|
|
// at the top of the document, so when the lawyer customises this
|
|
// template later they don't have to restructure.
|
|
func buildDocumentXML() string {
|
|
var b strings.Builder
|
|
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
|
|
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`)
|
|
b.WriteString(`<w:body>`)
|
|
|
|
demoBanner(&b)
|
|
|
|
heading1(&b, "{{firm.name}} — Patentstreitsachen")
|
|
plain(&b, "Bearbeiter: {{user.display_name}}")
|
|
plain(&b, "E-Mail: {{user.email}} · Büro: {{user.office}}")
|
|
plain(&b, "Datum: {{today.long_de}} ({{today.iso}})")
|
|
|
|
heading1(&b, "{{project.court}}")
|
|
plain(&b, "Aktenzeichen: {{project.case_number}}")
|
|
plain(&b, "Verfahrensart: {{project.proceeding.name}} ({{project.proceeding.code}})")
|
|
plain(&b, "Instanz: {{project.instance_level}}")
|
|
|
|
heading2(&b, "In der Patentstreitsache")
|
|
plain(&b, "{{parties.claimant.name}}")
|
|
plain(&b, "vertreten durch {{parties.claimant.representative}}")
|
|
bold(&b, "— Klägerin —")
|
|
plain(&b, "")
|
|
plain(&b, "gegen")
|
|
plain(&b, "")
|
|
plain(&b, "{{parties.defendant.name}}")
|
|
plain(&b, "vertreten durch {{parties.defendant.representative}}")
|
|
bold(&b, "— Beklagte —")
|
|
plainOptional(&b, "Weitere Beteiligte: {{parties.other.name}}, vertreten durch {{parties.other.representative}}")
|
|
|
|
heading2(&b, "Betreff")
|
|
plain(&b, "Streitpatent: {{project.patent_number}} (UPC: {{project.patent_number_upc}})")
|
|
plain(&b, "Anmeldung: {{project.filing_date}} · Erteilung: {{project.grant_date}}")
|
|
plain(&b, "Projekttitel: {{project.title}}")
|
|
plain(&b, "Unsere Seite: {{project.our_side_de}} ({{project.our_side}})")
|
|
plain(&b, "Mandant: {{project.client_number}} · Matter: {{project.matter_number}}")
|
|
plain(&b, "Internes Aktenzeichen: {{project.reference}}")
|
|
|
|
heading1(&b, "{{rule.name}}")
|
|
plain(&b, "(Schriftsatz-Code: {{rule.submission_code}})")
|
|
plain(&b, "Rechtsgrundlage: {{rule.legal_source_pretty}} ({{rule.legal_source}})")
|
|
plain(&b, "Typische Partei: {{rule.primary_party}} · Schriftsatz-Typ: {{rule.event_type}}")
|
|
|
|
heading2(&b, "Frist")
|
|
plain(&b, "Diese Frist wurde berechnet aus: {{deadline.computed_from}}")
|
|
plain(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
|
|
plainOptional(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
|
|
plain(&b, "Frist-Bezeichnung: {{deadline.title}} · Quelle: {{deadline.source}}")
|
|
|
|
heading2(&b, "I. Anträge")
|
|
plain(&b, "Die Beklagte beantragt,")
|
|
plain(&b, "")
|
|
plain(&b, "1. die Klage abzuweisen;")
|
|
plain(&b, "2. der Klägerin die Kosten des Rechtsstreits aufzuerlegen.")
|
|
|
|
heading2(&b, "II. Sachverhalt")
|
|
plain(&b, "[DEMO-Platzhalter] Hier folgt der Sachvortrag der Beklagten zum Streitpatent {{project.patent_number}} und zu den von der Klägerin geltend gemachten Ansprüchen.")
|
|
|
|
heading2(&b, "III. Rechtsausführungen")
|
|
plain(&b, "[DEMO-Platzhalter] Die Beklagte tritt der Klage aus den nachfolgenden Gründen entgegen.")
|
|
|
|
heading2(&b, "Schlussformel")
|
|
plain(&b, "{{today.long_de}}")
|
|
plain(&b, "")
|
|
plain(&b, "{{user.display_name}}")
|
|
plain(&b, "{{firm.name}}")
|
|
plainOptional(&b, "{{firm.signature_block}}")
|
|
|
|
// English-locale exercise — lets the lawyer verify the EN long-form
|
|
// date and EN proceeding name resolve correctly when the user's
|
|
// preference is en. Also exercises the bare {{today}} alias
|
|
// (identical to {{today.iso}}; included so every key the variable
|
|
// bag carries appears at least once in this demo template).
|
|
heading2(&b, "Locale-aware variants (DEMO)")
|
|
plain(&b, "EN long date: {{today.long_en}} · Deadline EN: {{deadline.due_date_long_en}}")
|
|
plain(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
|
|
plain(&b, "Rule name (EN): {{rule.name_en}} · Project our side (DE): {{project.our_side_de}}")
|
|
plain(&b, "Proceeding (DE): {{project.proceeding.name_de}} · Rule name (DE): {{rule.name_de}}")
|
|
plain(&b, "Today (bare alias): {{today}}")
|
|
|
|
b.WriteString(`</w:body></w:document>`)
|
|
return b.String()
|
|
}
|
|
|
|
// demoBanner writes a clearly-marked DEMO header so the file can't be
|
|
// mistaken for approved firm content (HLC branding compliance has not
|
|
// reviewed this — it's a developer-authored placeholder fixture).
|
|
func demoBanner(b *strings.Builder) {
|
|
b.WriteString(`<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:rPr><w:b/><w:color w:val="C00000"/></w:rPr><w:t xml:space="preserve">DEMO — interne Vorlage (nicht freigegeben)</w:t></w:r></w:p>`)
|
|
}
|
|
|
|
// heading1 emits a styled "Heading 1" paragraph with placeholder runs
|
|
// emitted intact (one run per placeholder so pass-1 substitution works).
|
|
func heading1(b *strings.Builder, text string) { paragraph(b, "Heading1", text, false) }
|
|
|
|
// heading2 emits a "Heading 2" paragraph.
|
|
func heading2(b *strings.Builder, text string) { paragraph(b, "Heading2", text, false) }
|
|
|
|
// plain emits a Normal-style paragraph.
|
|
func plain(b *strings.Builder, text string) { paragraph(b, "", text, false) }
|
|
|
|
// plainOptional is a Normal paragraph rendered as italic so the lawyer
|
|
// recognises rows that contain placeholders which may be empty
|
|
// (parties.other.*, deadline.original_due_date, firm.signature_block).
|
|
// Visual cue only; the merge engine still substitutes the same way.
|
|
func plainOptional(b *strings.Builder, text string) { paragraph(b, "", text, true) }
|
|
|
|
// bold emits a Normal paragraph with bold run formatting.
|
|
func bold(b *strings.Builder, text string) {
|
|
b.WriteString(`<w:p>`)
|
|
b.WriteString(`<w:r><w:rPr><w:b/></w:rPr><w:t xml:space="preserve">`)
|
|
b.WriteString(xmlEscape(text))
|
|
b.WriteString(`</w:t></w:r></w:p>`)
|
|
}
|
|
|
|
// paragraph splits text on placeholder boundaries and emits one <w:r>
|
|
// per segment. Each placeholder occupies a dedicated run so the
|
|
// renderer's pass-1 substitution (format-preserving, single-run) hits
|
|
// the placeholder without the cross-run fallback. Italic runs are
|
|
// flagged via the italic argument.
|
|
func paragraph(b *strings.Builder, style, text string, italic bool) {
|
|
b.WriteString(`<w:p>`)
|
|
if style != "" {
|
|
b.WriteString(`<w:pPr><w:pStyle w:val="`)
|
|
b.WriteString(style)
|
|
b.WriteString(`"/></w:pPr>`)
|
|
}
|
|
for _, seg := range splitOnPlaceholders(text) {
|
|
b.WriteString(`<w:r>`)
|
|
if italic {
|
|
b.WriteString(`<w:rPr><w:i/></w:rPr>`)
|
|
}
|
|
b.WriteString(`<w:t xml:space="preserve">`)
|
|
b.WriteString(xmlEscape(seg))
|
|
b.WriteString(`</w:t></w:r>`)
|
|
}
|
|
b.WriteString(`</w:p>`)
|
|
}
|
|
|
|
// splitOnPlaceholders returns the input split into alternating text /
|
|
// placeholder segments while keeping each placeholder intact in its own
|
|
// segment. Empty input yields a single empty segment so the paragraph
|
|
// still emits a (visible) blank line.
|
|
func splitOnPlaceholders(s string) []string {
|
|
if s == "" {
|
|
return []string{""}
|
|
}
|
|
var out []string
|
|
for {
|
|
open := strings.Index(s, "{{")
|
|
if open < 0 {
|
|
out = append(out, s)
|
|
return out
|
|
}
|
|
close := strings.Index(s[open:], "}}")
|
|
if close < 0 {
|
|
out = append(out, s)
|
|
return out
|
|
}
|
|
end := open + close + 2
|
|
if open > 0 {
|
|
out = append(out, s[:open])
|
|
}
|
|
out = append(out, s[open:end])
|
|
s = s[end:]
|
|
if s == "" {
|
|
return out
|
|
}
|
|
}
|
|
}
|
|
|
|
// xmlEscape handles the five XML-significant characters for <w:t>
|
|
// content. Whitespace is preserved by the xml:space="preserve" attr we
|
|
// always emit on text runs.
|
|
func xmlEscape(s string) string {
|
|
s = strings.ReplaceAll(s, "&", "&")
|
|
s = strings.ReplaceAll(s, "<", "<")
|
|
s = strings.ReplaceAll(s, ">", ">")
|
|
s = strings.ReplaceAll(s, `"`, """)
|
|
s = strings.ReplaceAll(s, "'", "'")
|
|
return s
|
|
}
|