From 2c7ac6423f89bd81745af62094a8624384179f1f Mon Sep 17 00:00:00 2001 From: mAi Date: Sat, 23 May 2026 01:30:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(submissions):=20t-paliad-241=20=E2=80=94?= =?UTF-8?q?=20demo=20Klageerwiderung=20template=20wired?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authored a per-submission-code .docx template for `de.inf.lg.erwidg` exercising every placeholder SubmissionVarsService resolves (45 keys across firm/today/user/project/parties/rule/deadline namespaces), so the Submissions draft editor has variables to substitute and the sidebar/preview feature can be demonstrated end-to-end. Pieces: - `scripts/gen-demo-submission-template/` — one-shot Go authoring tool that emits a minimal but Word-compatible .docx zip with a fake Klageerwiderung skeleton in German. Each placeholder lives in its own run so the renderer's pass-1 (format-preserving) substitution catches it without falling into the cross-run merge path. Output is byte-reproducible (fixed mtime). - `internal/handlers/files.go` — added `submissionTemplateRegistry` (submission_code → fileRegistry slug) plus `fetchSubmissionTemplateBytes` helper that reuses the Gitea proxy cache infra. Registered one entry for `de.inf.lg.erwidg`. The file itself was uploaded to mWorkRepo at `6 - material/Templates/Word/Paliad/HLC/de.inf.lg.erwidg.docx` (mWorkRepo commit 9633524). - `internal/handlers/submission_drafts.go` — `resolveSubmissionTemplate` now tries the per-code lookup first; falls back to the universal HL Patents Style for any code that doesn't have a per-firm template registered, matching the cronus design fallback chain §8. The existing HL Patents Style .dotm is untouched (still the universal fallback and still the source for the format-only /generate path). Future per-submission templates register one fileRegistry entry + one submissionTemplateRegistry row. --- internal/handlers/files.go | 71 ++++ internal/handlers/submission_drafts.go | 19 +- scripts/gen-demo-submission-template/main.go | 343 +++++++++++++++++++ 3 files changed, 424 insertions(+), 9 deletions(-) create mode 100644 scripts/gen-demo-submission-template/main.go diff --git a/internal/handlers/files.go b/internal/handlers/files.go index 62e03cb..34e2463 100644 --- a/internal/handlers/files.go +++ b/internal/handlers/files.go @@ -37,6 +37,11 @@ type fileEntry struct { // // The URL slug ("hl-patents-style.dotm") is preserved as a stable public // identifier so existing bookmarks keep working post-rebrand. +// +// Per-submission templates (slug `submission/.docx`) are server-only: +// only the submission-draft editor reaches them via fetchSubmissionTemplateBytes. +// handleFileDownload serves any slug that lands here, but the public URL +// surface for submission templates is the export endpoint, not /files. var fileRegistry = map[string]fileEntry{ "hl-patents-style.dotm": { RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/HL%20Patents%20Style.dotm", @@ -46,6 +51,72 @@ var fileRegistry = map[string]fileEntry{ RepoName: "mWorkRepo", FilePath: "6 - material/Templates/Word/HL Patents Style.dotm", }, + // Per-submission demo template (t-paliad-241). Exercises every + // placeholder SubmissionVarsService resolves so the + // /projects/{id}/submissions/{code}/draft editor has variables to + // substitute. One file per submission_code; future codes register + // the same way — slug shape "submission/.docx" so the + // namespace stays separate from the universal style template. + "submission/de.inf.lg.erwidg.docx": { + RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/de.inf.lg.erwidg.docx", + DownloadName: "Klageerwiderung — " + branding.Name + ".docx", + ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + RepoOwner: "m", + RepoName: "mWorkRepo", + FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/de.inf.lg.erwidg.docx", + }, +} + +// submissionTemplateRegistry maps a deadline-rule submission_code to a +// fileRegistry slug. Lookup order matches the cronus design fallback +// chain §8: per-firm `templates/{FIRM_NAME}/{code}.docx` first, then +// universal HL Patents Style as the global fallback. +// +// Add new entries here as the firm authors per-submission templates; +// the file itself lives in mWorkRepo and is served through the shared +// Gitea proxy cache so refreshes are visible to all consumers in one +// place. +var submissionTemplateRegistry = map[string]string{ + "de.inf.lg.erwidg": "submission/de.inf.lg.erwidg.docx", +} + +// fetchSubmissionTemplateBytes returns the per-submission_code template +// bytes (and provenance SHA) when one is registered. The bool result +// distinguishes "no per-code template registered" (callers fall back to +// HL Patents Style) from an upstream fetch error. +func fetchSubmissionTemplateBytes(ctx context.Context, submissionCode string) ([]byte, string, bool, error) { + slug, ok := submissionTemplateRegistry[submissionCode] + if !ok { + return nil, "", false, nil + } + entry, ok := fileRegistry[slug] + if !ok { + return nil, "", false, fmt.Errorf("file proxy: submission template slug %q not registered", slug) + } + ce := getCacheEntry(slug) + + ce.mu.RLock() + hasData := len(ce.data) > 0 + needsCheck := time.Since(ce.lastChecked) >= checkInterval + ce.mu.RUnlock() + + if !hasData { + if err := fileFetch(ce, entry); err != nil { + return nil, "", false, err + } + } else if needsCheck { + go fileCheckAndRefresh(ce, entry) + } + + ce.mu.RLock() + defer ce.mu.RUnlock() + if len(ce.data) == 0 { + return nil, "", false, fmt.Errorf("file proxy: %s cache empty after fetch", slug) + } + out := make([]byte, len(ce.data)) + copy(out, ce.data) + _ = ctx + return out, ce.sha, true, nil } type cacheEntry struct { diff --git a/internal/handlers/submission_drafts.go b/internal/handlers/submission_drafts.go index 6d1f628..36b7f1c 100644 --- a/internal/handlers/submission_drafts.go +++ b/internal/handlers/submission_drafts.go @@ -532,16 +532,17 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft, } // resolveSubmissionTemplate returns the .docx bytes for the given -// submission code. Slice A: universal HL Patents Style .dotm only; -// Slice B will wire the per-code fallback chain here. SHA is returned -// from the file registry's cache entry so the export audit row can -// record provenance. -// -// submissionCode is intentionally unused in Slice A — Slice B's -// TemplateRegistry resolves the per-code chain from this parameter -// without callers having to change signature. +// submission code. Lookup order matches the cronus design fallback chain +// §8: per-firm template registered in submissionTemplateRegistry first, +// then the universal HL Patents Style as the global fallback. The +// returned SHA is the cache entry's commit SHA so the export audit row +// can record provenance. func resolveSubmissionTemplate(ctx context.Context, submissionCode string) ([]byte, string, error) { - _ = submissionCode + if data, sha, found, err := fetchSubmissionTemplateBytes(ctx, submissionCode); err != nil { + return nil, "", err + } else if found { + return data, sha, nil + } bytes, err := fetchHLPatentsStyleBytes(ctx) if err != nil { return nil, "", err diff --git a/scripts/gen-demo-submission-template/main.go b/scripts/gen-demo-submission-template/main.go new file mode 100644 index 0000000..55b5f8f --- /dev/null +++ b/scripts/gen-demo-submission-template/main.go @@ -0,0 +1,343 @@ +// 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 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 = ` + + + + + +` + +const rootRelsXML = ` + + +` + +const documentRelsXML = ` + + +` + +// 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 = ` + + + + + + + + + + + + + + + + +` + +// 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(``) + b.WriteString(``) + b.WriteString(``) + + 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. + 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}}") + + b.WriteString(``) + 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(`DEMO — interne Vorlage (nicht freigegeben)`) +} + +// 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(``) + b.WriteString(``) + b.WriteString(xmlEscape(text)) + b.WriteString(``) +} + +// paragraph splits text on placeholder boundaries and emits one +// 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(``) + if style != "" { + b.WriteString(``) + } + for _, seg := range splitOnPlaceholders(text) { + b.WriteString(``) + if italic { + b.WriteString(``) + } + b.WriteString(``) + b.WriteString(xmlEscape(seg)) + b.WriteString(``) + } + b.WriteString(``) +} + +// 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 +// 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 +}