The "Composer actually works" milestone per the design at
docs/design-submission-generator-v2-2026-05-26.md §12 Slice B. Builds on
Slice A's substrate (submission_bases, submission_sections, base_id on
drafts); no new migrations needed.
Backend additions:
- internal/services/submission_md.go (~240 LoC): Markdown → OOXML
walker. Per the head's Slice B brief, scope is paragraphs +
bold/italic + blank-line spacing. Placeholders pass through
unchanged for the v1 substitution pass. CRLF normalisation; nested
formatting (***bold-italic***); two delimiter forms (* and _);
XML-escaping for &/</>; explicit empty-paragraph emit so blank
lines round-trip. 12 unit tests.
- internal/services/submission_compose.go (~470 LoC): SubmissionComposer
service. Pipeline: ConvertDotmToDocx pre-pass → extract
word/document.xml → render each included section's content_md_<lang>
→ splice via {{#section:KEY}}/{{/section:KEY}} anchor pairs in
the body → strip anchors for excluded sections → append unanchored
sections before <w:sectPr> → repack zip → run v1 placeholder pass.
RE2-friendly anchor scanner walks markers in body-order and matches
open/close pairs with a stack (handles unbalanced anchors
defensively). 6 unit tests covering anchor-mode splice,
append-mode-no-anchors, excluded-section drop, placeholder
resolution, lang column pick, order_index ASC.
- internal/services/submission_section_service.go: SectionPatch +
Update method. Six optional fields (content_md_de/en, included,
label_de/en, order_index). Sentinel ErrSubmissionSectionNotFound on
RLS-filtered miss.
- internal/handlers/submission_sections.go (NEW, ~150 LoC):
PATCH /api/submission-drafts/{draft_id}/sections/{section_id}.
Owner-scoped via SubmissionDraftService.Get; section-belongs-to-draft
cross-check. 404 on both missing-draft and section-belongs-elsewhere
paths.
- internal/handlers/files.go: fetchComposerBaseBytes + composerBaseSlugMap
reuse the existing Gitea proxy cache for base .docx bytes. hlc-letterhead
→ existing firmSkeletonSubmissionSlug, neutral → existing
skeletonSubmissionSlug.
- internal/handlers/submission_drafts.go: exportSubmissionDraft helper
branches on draft.BaseID. When set AND base + bytes + sections all
resolve → Composer pipeline. Else v1 fallback render path stays.
Audit metadata jsonb gains "composer": true + "base_id" flag when
composer was used.
Wiring:
- handlers.Services gains SubmissionComposer.
- dbServices.submissionComposer wired from svc.SubmissionComposer.
- main.go instantiates NewSubmissionComposer with the existing
SubmissionRenderer (so the {{rule.X}} alias contract stays preserved
inside section content).
Frontend additions (~400 LoC):
- client/submission-draft.ts: paintSectionList rewritten to render a
contentEditable per included section with a per-section B/I
toolbar. Per-section autosave debounced 500ms; mousedown handlers on
toolbar buttons preserve editor focus mid-command. domToMarkdown
walks the contentEditable's DOM tree back to Markdown source-of-
truth (b/strong → **…**, i/em → *…*, div/p → paragraph break, br
→ newline). Updated state.view.sections in-place on PATCH success
without re-painting (avoids focus-stealing on every keystroke);
re-paints only on structural changes (included toggle, label edits,
order changes).
- client/submission-draft.ts: onSectionToggleIncluded hides/shows a
section via PATCH. flushSectionAutosave on blur force-flushes
pending edits so leaving an editor doesn't strand unsynced changes.
- styles/global.css: editor surface (contentEditable area with focus
ring + placeholder), toolbar buttons (B/I 1.8rem squares),
per-section "Hide"/"Include" toggle in the head row.
- Updated i18n hint copy: "Inhalt pro Abschnitt — Autosave nach
500ms. Letztes Layout in Word."
Templates regenerated on Gitea:
- _skeleton.docx → composer-mode body (anchors only): blob SHA
ac0cdeaf49f7cd417ec143e2319ffbb02ec65644.
- _firm-skeleton.docx → composer-mode body (anchors only, preserves
sectPr → firm header/footer rIds): blob SHA
f1e9a9fb9a29ca01bf7bee709a45c5dda2a8e317.
- Both uploaded as mAi via --netrc-file ~/.netrc-mai.
- gen-skeleton-submission-template script gains an -anchors flag
(default true) so future regens emit composer-ready bodies. The
_firm-skeleton.docx regen was done via a one-off /tmp helper since
the gen-hl-skeleton-template script requires the proprietary .dotm
source which lives in HL/mWorkRepo; extending that script to accept
an existing .docx as input is a follow-up cleanup.
Build hygiene: go build/vet/test -short ./internal/... ./cmd/... all
clean; bun run build clean (2900 i18n keys, data-i18n scan clean).
NO behavior change for pre-Composer drafts (base_id NULL → v1
fallback render path stays compiled in). NO migrations needed in this
slice — sections were already in the schema from Slice A; only
content_md_de/en UPDATEs happen via the new PATCH endpoint.
Hard rules per Q2/Q10 ratification still honoured:
- No building_block_id lineage (Slice C territory; Q2).
- Caption/letterhead/signature are regular prose sections, seeded from
base spec; lawyer can edit/hide freely (Q10).
- {{rule.X}} aliases preserved (renderer pass unchanged).
NOT in scope per Slice B brief:
- Headings 1–3, lists, blockquote (Slice D's MD walker extension).
- Building blocks library (Slice C).
- Reorder / add-custom-section (Slice F).
- Auto-upgrade of pre-Composer drafts (Slice C — explicitly NOT in
this slice per head's brief msg #2393).
t-paliad-313 Slice B
357 lines
13 KiB
Go
357 lines
13 KiB
Go
// Universal-skeleton submission template generator (t-paliad-259).
|
|
//
|
|
// One-shot authoring tool that emits a minimal but Word-compatible
|
|
// .docx file exercising every placeholder SubmissionVarsService
|
|
// resolves — without baking in any submission_code-specific prose.
|
|
//
|
|
// Drop the output into m/mWorkRepo at
|
|
//
|
|
// 6 - material/Templates/Word/Paliad/HLC/_skeleton.docx
|
|
//
|
|
// so paliad's submission generator picks it up via the fallback chain
|
|
// slotted between the per-submission_code template and the bare
|
|
// universal HL Patents Style .dotm. Any submission_code that has no
|
|
// per-firm template still gets a draft populated with variables
|
|
// instead of the macro-only letterhead.
|
|
//
|
|
// Why a separate file from de.inf.lg.erwidg.docx: that one is a
|
|
// Klageerwiderung skeleton (DE LG, "I. Anträge / II. Sachverhalt /
|
|
// III. Rechtsausführungen"). For a UPC SoC, an EPO opposition, a DPMA
|
|
// appeal, that body structure is wrong. The universal skeleton drops
|
|
// the structure and leaves a single neutral body block the lawyer
|
|
// replaces — every variable still resolves regardless of code.
|
|
//
|
|
// Run:
|
|
//
|
|
// go run ./scripts/gen-skeleton-submission-template -out /tmp/_skeleton.docx
|
|
//
|
|
// Output is byte-reproducible (zip mtimes pinned to a fixed UTC
|
|
// timestamp).
|
|
package main
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// anchorsOnly switches the body emitter from the legacy variable-bag
|
|
// banner template to the Composer Slice B anchor-only body. Toggled
|
|
// via the -anchors flag; default true so the Slice B regen produces
|
|
// the composer-ready file.
|
|
var anchorsOnly = true
|
|
|
|
func main() {
|
|
out := flag.String("out", "_skeleton.docx", "output .docx path")
|
|
anchors := flag.Bool("anchors", true, "emit Composer-mode body with section anchors only (t-paliad-313 Slice B); false = legacy variable-bag banner body")
|
|
flag.Parse()
|
|
anchorsOnly = *anchors
|
|
|
|
docx, err := buildDocx()
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "gen-skeleton-submission-template:", err)
|
|
os.Exit(1)
|
|
}
|
|
if err := os.WriteFile(*out, docx, 0o644); err != nil {
|
|
fmt.Fprintln(os.Stderr, "gen-skeleton-submission-template: write:", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("wrote %s (%d bytes)\n", *out, len(docx))
|
|
}
|
|
|
|
var fixedTime = time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
|
|
|
|
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>`
|
|
|
|
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 code-agnostic Schriftsatz skeleton: firm letterhead +
|
|
// case caption + parties + submission heading + a single neutral body
|
|
// block. Mirrors the variable bag from SubmissionVarsService (firm.* /
|
|
// today.* / user.* / project.* / parties.* / rule.*) without baking in
|
|
// DE-LG-Klageerwiderung-specific structure. A lawyer customising this
|
|
// template for a UPC SoC, EPO opposition, or DPMA appeal replaces the
|
|
// [Schriftsatztext] block and renames the party labels — every
|
|
// placeholder still resolves regardless of the submission_code chosen.
|
|
//
|
|
// The {{deadline.*}} placeholders are deliberately NOT rendered by the
|
|
// default skeleton (t-paliad-287). The deadline is internal context for
|
|
// the lawyer, not text that belongs in a court-bound submission. The
|
|
// keys stay resolvable in the bag so a custom template can still
|
|
// reference them where it actually wants them.
|
|
//
|
|
// Every placeholder occupies its own <w:r> run so the renderer's pass-1
|
|
// (format-preserving, single-run) substitution catches it. The
|
|
// DEMO/SKELETON banner makes it obvious this is a starter template and
|
|
// not approved firm content.
|
|
func buildDocumentXML() string {
|
|
if anchorsOnly {
|
|
return buildAnchoredDocumentXML()
|
|
}
|
|
return buildLegacyDocumentXML()
|
|
}
|
|
|
|
// buildAnchoredDocumentXML emits the Composer-mode body: just section
|
|
// anchors. The composer pipeline (services/submission_compose.go)
|
|
// replaces each {{#section:KEY}}...{{/section:KEY}} paragraph pair
|
|
// with the rendered section content from submission_sections.
|
|
// Pre-Composer drafts continue to use the legacy body (run with
|
|
// -anchors=false).
|
|
//
|
|
// Order matches the default section spec in mig 146:
|
|
// letterhead, caption, introduction, requests, facts,
|
|
// legal_argument, evidence, exhibits, closing, signature.
|
|
func buildAnchoredDocumentXML() 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>`)
|
|
|
|
anchorPair := func(key string) {
|
|
plain(&b, "{{#section:"+key+"}}")
|
|
plain(&b, "{{/section:"+key+"}}")
|
|
}
|
|
for _, key := range []string{
|
|
"letterhead", "caption", "introduction", "requests",
|
|
"facts", "legal_argument", "evidence", "exhibits",
|
|
"closing", "signature",
|
|
} {
|
|
anchorPair(key)
|
|
}
|
|
|
|
b.WriteString(`</w:body></w:document>`)
|
|
return b.String()
|
|
}
|
|
|
|
func buildLegacyDocumentXML() 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>`)
|
|
|
|
skeletonBanner(&b)
|
|
|
|
heading1(&b, "{{firm.name}}")
|
|
plain(&b, "Bearbeiter: {{user.display_name}}")
|
|
plain(&b, "E-Mail: {{user.email}} · Büro: {{user.office}}")
|
|
plain(&b, "Datum: {{today.long_de}} ({{today.iso}})")
|
|
plainOptional(&b, "{{firm.signature_block}}")
|
|
|
|
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 Sache")
|
|
plain(&b, "{{parties.claimant.name}}")
|
|
plain(&b, "vertreten durch {{parties.claimant.representative}}")
|
|
bold(&b, "— Klägerin / Patentinhaberin / Anmelderin —")
|
|
plain(&b, "")
|
|
plain(&b, "gegen")
|
|
plain(&b, "")
|
|
plain(&b, "{{parties.defendant.name}}")
|
|
plain(&b, "vertreten durch {{parties.defendant.representative}}")
|
|
bold(&b, "— Beklagte / Einsprechende / Beschwerdegegnerin —")
|
|
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}}")
|
|
|
|
// t-paliad-287 — the dedicated Frist block was removed in 2026-05.
|
|
// {{deadline.*}} placeholders stay resolvable in the variable bag
|
|
// (lawyer can still drop them into a custom paragraph) but the
|
|
// default skeleton no longer renders them in the submission body:
|
|
// the deadline is internal/admin context and has no place in a
|
|
// document going out to court.
|
|
|
|
heading2(&b, "Schriftsatztext")
|
|
plain(&b, "[Hier folgt der eigentliche Schriftsatztext. Diese Skelett-Vorlage enthält keine vorgefertigte Struktur — bitte gemäß Schriftsatz-Typ ({{rule.name}}) ergänzen.]")
|
|
plain(&b, "")
|
|
plain(&b, "[Body of the submission goes here. This skeleton template carries no pre-baked structure — fill in according to submission type ({{rule.name_en}}).]")
|
|
|
|
heading2(&b, "Schlussformel")
|
|
plain(&b, "{{today.long_de}}")
|
|
plain(&b, "")
|
|
plain(&b, "{{user.display_name}}")
|
|
plain(&b, "{{firm.name}}")
|
|
|
|
// Locale-aware verification block — exercises every EN/DE alias the
|
|
// variable bag carries (today.long_en, deadline.due_date_long_en,
|
|
// project.our_side_en, project.proceeding.name_en, rule.name_en) and
|
|
// the bare {{today}} alias. A lawyer customising the template can
|
|
// delete this block; the renderer round-trips it cleanly today.
|
|
heading2(&b, "Locale-aware variants (SKELETON)")
|
|
plain(&b, "EN long date: {{today.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()
|
|
}
|
|
|
|
func skeletonBanner(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">SKELETON — universelle Vorlage (Schriftsatz-Typ-unabhängig, nicht freigegeben)</w:t></w:r></w:p>`)
|
|
}
|
|
|
|
func heading1(b *strings.Builder, text string) { paragraph(b, "Heading1", text, false) }
|
|
|
|
func heading2(b *strings.Builder, text string) { paragraph(b, "Heading2", text, false) }
|
|
|
|
func plain(b *strings.Builder, text string) { paragraph(b, "", text, false) }
|
|
|
|
func plainOptional(b *strings.Builder, text string) { paragraph(b, "", text, true) }
|
|
|
|
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>`)
|
|
}
|
|
|
|
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>`)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|