refactor(docforge): slice 3 — VariableResolver interface + ResolverSet (t-paliad-349)
Move the variable-bag contract (PlaceholderMap, MissingPlaceholderFn,
DefaultMissingMarker) up to the pkg/docforge root (placeholder.go) — it is
format-neutral, consumed by the resolver layer and any future exporter.
The {{key}} substitution grammar (placeholderRegex, PUA preview sentinels,
replacePlaceholders) stays in pkg/docforge/docx: it is the .docx renderer's
own machinery, not a root concern.
New at the root (vars.go):
- VariableResolver{Namespace() string; Populate(bag PlaceholderMap)} —
a PUSH interface, deliberately not pull Resolve(key): some namespaces
emit a data-dependent key set (parties.claimant.0.name, .1.name, … one
per party) that a fixed key-by-key pull can't enumerate.
- ResolverSet + BuildBag() — composes resolvers into one bag, replacing
the hard-coded addFooVars-then-addBarVars sequencing in Build.
paliad side (submission_vars_resolvers.go): seven resolver types wrap the
UNCHANGED addXxxVars push-builders (firm/today/user/procedural_event/
project/parties/deadline), each capturing the entity it needs. The builder
bodies are byte-for-byte untouched, so the bag is identical by
construction; SubmissionVarsService.Build now wires the applicable
resolvers and calls ResolverSet.BuildBag(). Resolvers stay in paliad
because they read paliad's domain model; a second docforge consumer plugs
its own resolvers into a ResolverSet the same way.
Keys()/Catalogue() (the static key list that will data-drive the authoring
palette + kill the hardcoded VARIABLE_GROUPS in submission-draft.ts) is
deferred to the UI slice that consumes it, sourced from the frontend's
existing labels — building it now, ahead of its consumer, would be
speculative (PRD §4 B3 principle).
Verification: go build ./... clean, go vet clean, full module test green.
Alias-parity (procedural_event ≡ rule) and party-form tests pass unchanged
= bag byte-identical.
m/paliad#157
This commit is contained in:
@@ -13,15 +13,20 @@ package services
|
|||||||
// refactored to call docforge directly through the neutral model and the
|
// refactored to call docforge directly through the neutral model and the
|
||||||
// VariableResolver interface.
|
// VariableResolver interface.
|
||||||
|
|
||||||
import "mgit.msbls.de/m/paliad/pkg/docforge/docx"
|
import (
|
||||||
|
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||||
|
"mgit.msbls.de/m/paliad/pkg/docforge/docx"
|
||||||
|
)
|
||||||
|
|
||||||
// PlaceholderMap is the variable bag (dotted-key → substituted value),
|
// PlaceholderMap is the variable bag (dotted-key → substituted value),
|
||||||
// built by SubmissionVarsService and consumed by the renderer.
|
// built by SubmissionVarsService and consumed by the renderer. The
|
||||||
type PlaceholderMap = docx.PlaceholderMap
|
// canonical type lives in the docforge root (the format-neutral
|
||||||
|
// variable-bag contract).
|
||||||
|
type PlaceholderMap = docforge.PlaceholderMap
|
||||||
|
|
||||||
// MissingPlaceholderFn translates an unbound placeholder key into the
|
// MissingPlaceholderFn translates an unbound placeholder key into the
|
||||||
// in-document marker token.
|
// in-document marker token.
|
||||||
type MissingPlaceholderFn = docx.MissingPlaceholderFn
|
type MissingPlaceholderFn = docforge.MissingPlaceholderFn
|
||||||
|
|
||||||
// SubmissionRenderer renders a .docx template by substituting
|
// SubmissionRenderer renders a .docx template by substituting
|
||||||
// {{placeholder}} tokens. Stateless; safe for concurrent use.
|
// {{placeholder}} tokens. Stateless; safe for concurrent use.
|
||||||
@@ -36,7 +41,9 @@ func NewSubmissionRenderer() *SubmissionRenderer { return docx.NewSubmissionRend
|
|||||||
|
|
||||||
// DefaultMissingMarker returns the standard missing-value marker for the
|
// DefaultMissingMarker returns the standard missing-value marker for the
|
||||||
// given UI language ("[KEIN WERT: <key>]" / "[NO VALUE: <key>]").
|
// given UI language ("[KEIN WERT: <key>]" / "[NO VALUE: <key>]").
|
||||||
func DefaultMissingMarker(lang string) MissingPlaceholderFn { return docx.DefaultMissingMarker(lang) }
|
func DefaultMissingMarker(lang string) MissingPlaceholderFn {
|
||||||
|
return docforge.DefaultMissingMarker(lang)
|
||||||
|
}
|
||||||
|
|
||||||
// RenderMarkdownToOOXML renders Markdown source into OOXML paragraph
|
// RenderMarkdownToOOXML renders Markdown source into OOXML paragraph
|
||||||
// elements using a single paragraph style.
|
// elements using a single paragraph style.
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import (
|
|||||||
|
|
||||||
"mgit.msbls.de/m/paliad/internal/branding"
|
"mgit.msbls.de/m/paliad/internal/branding"
|
||||||
"mgit.msbls.de/m/paliad/internal/models"
|
"mgit.msbls.de/m/paliad/internal/models"
|
||||||
|
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SubmissionVarsService assembles the placeholder map.
|
// SubmissionVarsService assembles the placeholder map.
|
||||||
@@ -151,17 +152,20 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
|
|||||||
if lang == "" {
|
if lang == "" {
|
||||||
lang = "de"
|
lang = "de"
|
||||||
}
|
}
|
||||||
bag := PlaceholderMap{}
|
// firm / today / user / procedural_event apply to every render,
|
||||||
addFirmVars(bag)
|
// project-bound or not. Each resolver wraps the matching addXxxVars
|
||||||
addTodayVars(bag, time.Now())
|
// builder (unchanged); ResolverSet.BuildBag runs them into one bag.
|
||||||
addUserVars(bag, user)
|
resolvers := []docforge.VariableResolver{
|
||||||
addRuleVars(bag, rule, lang)
|
firmResolver{},
|
||||||
|
todayResolver{now: time.Now()},
|
||||||
|
userResolver{user: user},
|
||||||
|
proceduralEventResolver{rule: rule, lang: lang},
|
||||||
|
}
|
||||||
|
|
||||||
out := &SubmissionVarsResult{
|
out := &SubmissionVarsResult{
|
||||||
Placeholders: bag,
|
User: user,
|
||||||
User: user,
|
Rule: rule,
|
||||||
Rule: rule,
|
Lang: lang,
|
||||||
Lang: lang,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if in.ProjectID == nil {
|
if in.ProjectID == nil {
|
||||||
@@ -169,6 +173,7 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
|
|||||||
// deadline state to resolve. The lawyer's overrides will fill
|
// deadline state to resolve. The lawyer's overrides will fill
|
||||||
// the placeholder map; missing keys render as
|
// the placeholder map; missing keys render as
|
||||||
// [KEIN WERT: …] / [NO VALUE: …] in the preview.
|
// [KEIN WERT: …] / [NO VALUE: …] in the preview.
|
||||||
|
out.Placeholders = docforge.NewResolverSet(resolvers...).BuildBag()
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,14 +200,17 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
addProjectVars(bag, project, pt, lang)
|
resolvers = append(resolvers,
|
||||||
addPartyVars(bag, filterPartiesBySelection(parties, in.SelectedParties))
|
projectResolver{project: project, pt: pt, lang: lang},
|
||||||
addDeadlineVars(bag, next, project, lang)
|
partiesResolver{parties: filterPartiesBySelection(parties, in.SelectedParties)},
|
||||||
|
deadlineResolver{deadline: next, project: project, lang: lang},
|
||||||
|
)
|
||||||
|
|
||||||
out.Project = project
|
out.Project = project
|
||||||
out.ProceedingType = pt
|
out.ProceedingType = pt
|
||||||
out.Parties = parties
|
out.Parties = parties
|
||||||
out.NextDeadline = next
|
out.NextDeadline = next
|
||||||
|
out.Placeholders = docforge.NewResolverSet(resolvers...).BuildBag()
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
87
internal/services/submission_vars_resolvers.go
Normal file
87
internal/services/submission_vars_resolvers.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
// Variable resolvers — the paliad-side implementations of
|
||||||
|
// docforge.VariableResolver (t-paliad-349 slice 3). Each wraps one of the
|
||||||
|
// addXxxVars push-builders, capturing the entity it needs, so the proven
|
||||||
|
// builder bodies stay byte-for-byte unchanged while the composition moves
|
||||||
|
// behind the docforge.ResolverSet seam. SubmissionVarsService.Build wires
|
||||||
|
// the applicable resolvers and calls ResolverSet.BuildBag().
|
||||||
|
//
|
||||||
|
// These live in paliad (not docforge) because they read paliad's domain
|
||||||
|
// model — branding, user, project, parties, deadline_rules, deadlines. A
|
||||||
|
// second docforge consumer implements its own resolvers against its own
|
||||||
|
// data and plugs them into a ResolverSet the same way.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/paliad/internal/models"
|
||||||
|
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Compile-time conformance: each resolver satisfies docforge.VariableResolver.
|
||||||
|
var (
|
||||||
|
_ docforge.VariableResolver = firmResolver{}
|
||||||
|
_ docforge.VariableResolver = todayResolver{}
|
||||||
|
_ docforge.VariableResolver = userResolver{}
|
||||||
|
_ docforge.VariableResolver = proceduralEventResolver{}
|
||||||
|
_ docforge.VariableResolver = projectResolver{}
|
||||||
|
_ docforge.VariableResolver = partiesResolver{}
|
||||||
|
_ docforge.VariableResolver = deadlineResolver{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// firmResolver populates firm.* from process-wide branding.
|
||||||
|
type firmResolver struct{}
|
||||||
|
|
||||||
|
func (firmResolver) Namespace() string { return "firm" }
|
||||||
|
func (firmResolver) Populate(bag PlaceholderMap) { addFirmVars(bag) }
|
||||||
|
|
||||||
|
// todayResolver populates today.* from the build-time clock.
|
||||||
|
type todayResolver struct{ now time.Time }
|
||||||
|
|
||||||
|
func (todayResolver) Namespace() string { return "today" }
|
||||||
|
func (r todayResolver) Populate(bag PlaceholderMap) { addTodayVars(bag, r.now) }
|
||||||
|
|
||||||
|
// userResolver populates user.* from the caller's row.
|
||||||
|
type userResolver struct{ user *models.User }
|
||||||
|
|
||||||
|
func (userResolver) Namespace() string { return "user" }
|
||||||
|
func (r userResolver) Populate(bag PlaceholderMap) { addUserVars(bag, r.user) }
|
||||||
|
|
||||||
|
// proceduralEventResolver populates procedural_event.* and the legacy
|
||||||
|
// rule.* alias from the published deadline_rule.
|
||||||
|
type proceduralEventResolver struct {
|
||||||
|
rule *models.DeadlineRule
|
||||||
|
lang string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (proceduralEventResolver) Namespace() string { return "procedural_event" }
|
||||||
|
func (r proceduralEventResolver) Populate(bag PlaceholderMap) { addRuleVars(bag, r.rule, r.lang) }
|
||||||
|
|
||||||
|
// projectResolver populates project.* from the project + its proceeding type.
|
||||||
|
type projectResolver struct {
|
||||||
|
project *models.Project
|
||||||
|
pt *models.ProceedingType
|
||||||
|
lang string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (projectResolver) Namespace() string { return "project" }
|
||||||
|
func (r projectResolver) Populate(bag PlaceholderMap) { addProjectVars(bag, r.project, r.pt, r.lang) }
|
||||||
|
|
||||||
|
// partiesResolver populates parties.* from the (already filtered) party list.
|
||||||
|
type partiesResolver struct{ parties []models.Party }
|
||||||
|
|
||||||
|
func (partiesResolver) Namespace() string { return "parties" }
|
||||||
|
func (r partiesResolver) Populate(bag PlaceholderMap) { addPartyVars(bag, r.parties) }
|
||||||
|
|
||||||
|
// deadlineResolver populates deadline.* from the next pending deadline.
|
||||||
|
type deadlineResolver struct {
|
||||||
|
deadline *models.Deadline
|
||||||
|
project *models.Project
|
||||||
|
lang string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (deadlineResolver) Namespace() string { return "deadline" }
|
||||||
|
func (r deadlineResolver) Populate(bag PlaceholderMap) {
|
||||||
|
addDeadlineVars(bag, r.deadline, r.project, r.lang)
|
||||||
|
}
|
||||||
@@ -42,6 +42,8 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Composer assembles base + sections into a final .docx.
|
// Composer assembles base + sections into a final .docx.
|
||||||
@@ -114,11 +116,11 @@ type ComposeOptions struct {
|
|||||||
// Vars is the merged placeholder bag the v1 renderer pass
|
// Vars is the merged placeholder bag the v1 renderer pass
|
||||||
// substitutes after the composer assembly. Passed straight through
|
// substitutes after the composer assembly. Passed straight through
|
||||||
// to SubmissionRenderer.Render.
|
// to SubmissionRenderer.Render.
|
||||||
Vars PlaceholderMap
|
Vars docforge.PlaceholderMap
|
||||||
|
|
||||||
// Missing translates an unbound placeholder key into the marker
|
// Missing translates an unbound placeholder key into the marker
|
||||||
// the lawyer sees in Word. Passed straight to the renderer.
|
// the lawyer sees in Word. Passed straight to the renderer.
|
||||||
Missing MissingPlaceholderFn
|
Missing docforge.MissingPlaceholderFn
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compose runs the full pipeline and returns the merged .docx bytes.
|
// Compose runs the full pipeline and returns the merged .docx bytes.
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ package docx
|
|||||||
// {{project.case_number}}).
|
// {{project.case_number}}).
|
||||||
//
|
//
|
||||||
// Missing-value behaviour: when a placeholder has no binding in the
|
// Missing-value behaviour: when a placeholder has no binding in the
|
||||||
// PlaceholderMap, the renderer emits a marker token so the lawyer sees
|
// docforge.PlaceholderMap, the renderer emits a marker token so the lawyer sees
|
||||||
// the gap in Word rather than failing the request.
|
// the gap in Word rather than failing the request.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -34,18 +34,15 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PlaceholderMap is the variable bag built by SubmissionVarsService.
|
// docforge.PlaceholderMap, docforge.MissingPlaceholderFn, and docforge.DefaultMissingMarker — the
|
||||||
// Keys are dotted paths without braces (e.g. "project.case_number").
|
// format-neutral variable-bag contract — live in the docforge root
|
||||||
// Values are the substituted text — already locale-aware, pretty-
|
// package (placeholder.go). This adapter consumes them; the {{key}}
|
||||||
// printed, and sanitised by the caller.
|
// substitution grammar below (placeholderRegex, replacePlaceholders, the
|
||||||
type PlaceholderMap map[string]string
|
// PUA preview sentinels) is the .docx renderer's own machinery.
|
||||||
|
|
||||||
// MissingPlaceholderFn translates an unbound placeholder key into the
|
|
||||||
// in-document marker token. The default in DefaultMissingMarker is
|
|
||||||
// "[KEIN WERT: <key>]" / "[NO VALUE: <key>]" depending on lang.
|
|
||||||
type MissingPlaceholderFn func(key string) string
|
|
||||||
|
|
||||||
// valueWrapperFn wraps a substituted value with a marker the HTML
|
// valueWrapperFn wraps a substituted value with a marker the HTML
|
||||||
// preview emitter can recognise — used by RenderHTML to turn each
|
// preview emitter can recognise — used by RenderHTML to turn each
|
||||||
@@ -74,18 +71,6 @@ func htmlPreviewWrapper(key, value string) string {
|
|||||||
return previewVarBegin + key + previewVarMid + value + previewVarEnd
|
return previewVarBegin + key + previewVarMid + value + previewVarEnd
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultMissingMarker returns the standard missing-value marker for
|
|
||||||
// the given UI language.
|
|
||||||
func DefaultMissingMarker(lang string) MissingPlaceholderFn {
|
|
||||||
prefix := "KEIN WERT"
|
|
||||||
if strings.EqualFold(lang, "en") {
|
|
||||||
prefix = "NO VALUE"
|
|
||||||
}
|
|
||||||
return func(key string) string {
|
|
||||||
return "[" + prefix + ": " + key + "]"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// placeholderRegex matches a single placeholder. The capture group
|
// placeholderRegex matches a single placeholder. The capture group
|
||||||
// extracts the key name without braces or surrounding whitespace.
|
// extracts the key name without braces or surrounding whitespace.
|
||||||
//
|
//
|
||||||
@@ -95,7 +80,7 @@ func DefaultMissingMarker(lang string) MissingPlaceholderFn {
|
|||||||
var placeholderRegex = regexp.MustCompile(`\{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\}`)
|
var placeholderRegex = regexp.MustCompile(`\{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\}`)
|
||||||
|
|
||||||
// SubmissionRenderer renders a .docx template into a .docx output by
|
// SubmissionRenderer renders a .docx template into a .docx output by
|
||||||
// substituting {{placeholder}} tokens with values from a PlaceholderMap.
|
// substituting {{placeholder}} tokens with values from a docforge.PlaceholderMap.
|
||||||
// Stateless; safe for concurrent use.
|
// Stateless; safe for concurrent use.
|
||||||
type SubmissionRenderer struct{}
|
type SubmissionRenderer struct{}
|
||||||
|
|
||||||
@@ -112,9 +97,9 @@ func NewSubmissionRenderer() *SubmissionRenderer {
|
|||||||
// Pre-pass: ConvertDotmToDocx is called on the input so a .dotm
|
// Pre-pass: ConvertDotmToDocx is called on the input so a .dotm
|
||||||
// template (macro-bearing) is downgraded to a plain .docx before the
|
// template (macro-bearing) is downgraded to a plain .docx before the
|
||||||
// merge step runs. Idempotent on inputs that are already plain .docx.
|
// merge step runs. Idempotent on inputs that are already plain .docx.
|
||||||
func (r *SubmissionRenderer) Render(templateBytes []byte, vars PlaceholderMap, missing MissingPlaceholderFn) ([]byte, error) {
|
func (r *SubmissionRenderer) Render(templateBytes []byte, vars docforge.PlaceholderMap, missing docforge.MissingPlaceholderFn) ([]byte, error) {
|
||||||
if missing == nil {
|
if missing == nil {
|
||||||
missing = DefaultMissingMarker("de")
|
missing = docforge.DefaultMissingMarker("de")
|
||||||
}
|
}
|
||||||
cleanBytes, err := ConvertDotmToDocx(templateBytes)
|
cleanBytes, err := ConvertDotmToDocx(templateBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -166,9 +151,9 @@ func (r *SubmissionRenderer) Render(templateBytes []byte, vars PlaceholderMap, m
|
|||||||
// Returns escaped HTML safe to inject into the page via dangerouslySet
|
// Returns escaped HTML safe to inject into the page via dangerouslySet
|
||||||
// or innerHTML. The caller is responsible for wrapping in an outer
|
// or innerHTML. The caller is responsible for wrapping in an outer
|
||||||
// container; this method emits only the body fragment.
|
// container; this method emits only the body fragment.
|
||||||
func (r *SubmissionRenderer) RenderHTML(templateBytes []byte, vars PlaceholderMap, missing MissingPlaceholderFn) (string, error) {
|
func (r *SubmissionRenderer) RenderHTML(templateBytes []byte, vars docforge.PlaceholderMap, missing docforge.MissingPlaceholderFn) (string, error) {
|
||||||
if missing == nil {
|
if missing == nil {
|
||||||
missing = DefaultMissingMarker("de")
|
missing = docforge.DefaultMissingMarker("de")
|
||||||
}
|
}
|
||||||
cleanBytes, err := ConvertDotmToDocx(templateBytes)
|
cleanBytes, err := ConvertDotmToDocx(templateBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -241,7 +226,7 @@ func readMergeZipEntry(f *zip.File) ([]byte, error) {
|
|||||||
// paragraph, run the replacement on the merged text, and rewrite
|
// paragraph, run the replacement on the merged text, and rewrite
|
||||||
// the paragraph's runs as a single <w:r><w:t>…</w:t></w:r> using
|
// the paragraph's runs as a single <w:r><w:t>…</w:t></w:r> using
|
||||||
// the formatting properties of the first run.
|
// the formatting properties of the first run.
|
||||||
func substituteInDocumentXML(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) []byte {
|
func substituteInDocumentXML(body []byte, vars docforge.PlaceholderMap, missing docforge.MissingPlaceholderFn, wrap valueWrapperFn) []byte {
|
||||||
replaced := substituteInTextNodes(body, vars, missing, wrap)
|
replaced := substituteInTextNodes(body, vars, missing, wrap)
|
||||||
if !needsCrossRunMerge(replaced) {
|
if !needsCrossRunMerge(replaced) {
|
||||||
return replaced
|
return replaced
|
||||||
@@ -256,7 +241,7 @@ var wTextNodeRegex = regexp.MustCompile(`<w:t(\s[^>]*)?>([^<]*)</w:t>`)
|
|||||||
// substituteInTextNodes runs the placeholder replacement inside each
|
// substituteInTextNodes runs the placeholder replacement inside each
|
||||||
// <w:t> text node independently. Format-preserving for single-run
|
// <w:t> text node independently. Format-preserving for single-run
|
||||||
// placeholders.
|
// placeholders.
|
||||||
func substituteInTextNodes(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) []byte {
|
func substituteInTextNodes(body []byte, vars docforge.PlaceholderMap, missing docforge.MissingPlaceholderFn, wrap valueWrapperFn) []byte {
|
||||||
return wTextNodeRegex.ReplaceAllFunc(body, func(match []byte) []byte {
|
return wTextNodeRegex.ReplaceAllFunc(body, func(match []byte) []byte {
|
||||||
sub := wTextNodeRegex.FindSubmatch(match)
|
sub := wTextNodeRegex.FindSubmatch(match)
|
||||||
attrs := string(sub[1])
|
attrs := string(sub[1])
|
||||||
@@ -297,7 +282,7 @@ var wParagraphPropsRegex = regexp.MustCompile(`(?s)<w:pPr>.*?</w:pPr>`)
|
|||||||
|
|
||||||
// substituteAcrossRuns is pass 2: concatenate every text node in a
|
// substituteAcrossRuns is pass 2: concatenate every text node in a
|
||||||
// fragmented-placeholder paragraph, run replacement, rewrite as one run.
|
// fragmented-placeholder paragraph, run replacement, rewrite as one run.
|
||||||
func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) []byte {
|
func substituteAcrossRuns(body []byte, vars docforge.PlaceholderMap, missing docforge.MissingPlaceholderFn, wrap valueWrapperFn) []byte {
|
||||||
return wParagraphRegex.ReplaceAllFunc(body, func(para []byte) []byte {
|
return wParagraphRegex.ReplaceAllFunc(body, func(para []byte) []byte {
|
||||||
textNodes := wTextNodeRegex.FindAllSubmatch(para, -1)
|
textNodes := wTextNodeRegex.FindAllSubmatch(para, -1)
|
||||||
if len(textNodes) == 0 {
|
if len(textNodes) == 0 {
|
||||||
@@ -340,7 +325,7 @@ func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlace
|
|||||||
// emit clickable spans around every substituted placeholder, including
|
// emit clickable spans around every substituted placeholder, including
|
||||||
// missing ones (clicking a missing marker jumps to the corresponding
|
// missing ones (clicking a missing marker jumps to the corresponding
|
||||||
// sidebar input).
|
// sidebar input).
|
||||||
func replacePlaceholders(s string, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) string {
|
func replacePlaceholders(s string, vars docforge.PlaceholderMap, missing docforge.MissingPlaceholderFn, wrap valueWrapperFn) string {
|
||||||
return placeholderRegex.ReplaceAllStringFunc(s, func(match string) string {
|
return placeholderRegex.ReplaceAllStringFunc(s, func(match string) string {
|
||||||
sub := placeholderRegex.FindStringSubmatch(match)
|
sub := placeholderRegex.FindStringSubmatch(match)
|
||||||
if len(sub) < 2 {
|
if len(sub) < 2 {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||||
)
|
)
|
||||||
|
|
||||||
// minimalMergeDOCX builds a tiny .docx zip with one document.xml that
|
// minimalMergeDOCX builds a tiny .docx zip with one document.xml that
|
||||||
@@ -74,7 +76,7 @@ func TestRender_SingleRunPlaceholder(t *testing.T) {
|
|||||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p></w:body></w:document>`
|
doc := `<w:document><w:body><w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p></w:body></w:document>`
|
||||||
tmpl := minimalMergeDOCX(t, doc)
|
tmpl := minimalMergeDOCX(t, doc)
|
||||||
r := NewSubmissionRenderer()
|
r := NewSubmissionRenderer()
|
||||||
out, err := r.Render(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
|
out, err := r.Render(tmpl, docforge.PlaceholderMap{"firm.name": "HLC"}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("render: %v", err)
|
t.Fatalf("render: %v", err)
|
||||||
}
|
}
|
||||||
@@ -91,7 +93,7 @@ func TestRender_MultiplePlaceholdersPerRun(t *testing.T) {
|
|||||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{parties.claimant.name}}, vertreten durch {{parties.claimant.representative}}</w:t></w:r></w:p></w:body></w:document>`
|
doc := `<w:document><w:body><w:p><w:r><w:t>{{parties.claimant.name}}, vertreten durch {{parties.claimant.representative}}</w:t></w:r></w:p></w:body></w:document>`
|
||||||
tmpl := minimalMergeDOCX(t, doc)
|
tmpl := minimalMergeDOCX(t, doc)
|
||||||
r := NewSubmissionRenderer()
|
r := NewSubmissionRenderer()
|
||||||
out, err := r.Render(tmpl, PlaceholderMap{
|
out, err := r.Render(tmpl, docforge.PlaceholderMap{
|
||||||
"parties.claimant.name": "Acme Inc.",
|
"parties.claimant.name": "Acme Inc.",
|
||||||
"parties.claimant.representative": "Kanzlei Müller",
|
"parties.claimant.representative": "Kanzlei Müller",
|
||||||
}, nil)
|
}, nil)
|
||||||
@@ -111,7 +113,7 @@ func TestRender_MissingMarker(t *testing.T) {
|
|||||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{project.case_number}}</w:t></w:r></w:p></w:body></w:document>`
|
doc := `<w:document><w:body><w:p><w:r><w:t>{{project.case_number}}</w:t></w:r></w:p></w:body></w:document>`
|
||||||
tmpl := minimalMergeDOCX(t, doc)
|
tmpl := minimalMergeDOCX(t, doc)
|
||||||
r := NewSubmissionRenderer()
|
r := NewSubmissionRenderer()
|
||||||
out, err := r.Render(tmpl, PlaceholderMap{}, DefaultMissingMarker("de"))
|
out, err := r.Render(tmpl, docforge.PlaceholderMap{}, docforge.DefaultMissingMarker("de"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("render: %v", err)
|
t.Fatalf("render: %v", err)
|
||||||
}
|
}
|
||||||
@@ -119,7 +121,7 @@ func TestRender_MissingMarker(t *testing.T) {
|
|||||||
if !strings.Contains(body, "[KEIN WERT: project.case_number]") {
|
if !strings.Contains(body, "[KEIN WERT: project.case_number]") {
|
||||||
t.Errorf("expected KEIN WERT marker, got %q", body)
|
t.Errorf("expected KEIN WERT marker, got %q", body)
|
||||||
}
|
}
|
||||||
outEN, err := r.Render(tmpl, PlaceholderMap{}, DefaultMissingMarker("en"))
|
outEN, err := r.Render(tmpl, docforge.PlaceholderMap{}, docforge.DefaultMissingMarker("en"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("render en: %v", err)
|
t.Fatalf("render en: %v", err)
|
||||||
}
|
}
|
||||||
@@ -133,7 +135,7 @@ func TestRender_CrossRunPlaceholder(t *testing.T) {
|
|||||||
doc := `<w:document><w:body><w:p><w:r><w:t>Hello {{</w:t></w:r><w:r><w:t>project</w:t></w:r><w:r><w:t>.case_number}}!</w:t></w:r></w:p></w:body></w:document>`
|
doc := `<w:document><w:body><w:p><w:r><w:t>Hello {{</w:t></w:r><w:r><w:t>project</w:t></w:r><w:r><w:t>.case_number}}!</w:t></w:r></w:p></w:body></w:document>`
|
||||||
tmpl := minimalMergeDOCX(t, doc)
|
tmpl := minimalMergeDOCX(t, doc)
|
||||||
r := NewSubmissionRenderer()
|
r := NewSubmissionRenderer()
|
||||||
out, err := r.Render(tmpl, PlaceholderMap{"project.case_number": "7 O 1234/26"}, nil)
|
out, err := r.Render(tmpl, docforge.PlaceholderMap{"project.case_number": "7 O 1234/26"}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("render: %v", err)
|
t.Fatalf("render: %v", err)
|
||||||
}
|
}
|
||||||
@@ -150,7 +152,7 @@ func TestRender_XMLEscaping(t *testing.T) {
|
|||||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{user.display_name}}</w:t></w:r></w:p></w:body></w:document>`
|
doc := `<w:document><w:body><w:p><w:r><w:t>{{user.display_name}}</w:t></w:r></w:p></w:body></w:document>`
|
||||||
tmpl := minimalMergeDOCX(t, doc)
|
tmpl := minimalMergeDOCX(t, doc)
|
||||||
r := NewSubmissionRenderer()
|
r := NewSubmissionRenderer()
|
||||||
out, err := r.Render(tmpl, PlaceholderMap{
|
out, err := r.Render(tmpl, docforge.PlaceholderMap{
|
||||||
"user.display_name": `Müller & Söhne <GmbH> "Special"`,
|
"user.display_name": `Müller & Söhne <GmbH> "Special"`,
|
||||||
}, nil)
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -203,7 +205,7 @@ func TestRenderHTML_ExtractsParagraphsAndFormatting(t *testing.T) {
|
|||||||
`</w:body></w:document>`
|
`</w:body></w:document>`
|
||||||
tmpl := minimalMergeDOCX(t, doc)
|
tmpl := minimalMergeDOCX(t, doc)
|
||||||
r := NewSubmissionRenderer()
|
r := NewSubmissionRenderer()
|
||||||
html, err := r.RenderHTML(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
|
html, err := r.RenderHTML(tmpl, docforge.PlaceholderMap{"firm.name": "HLC"}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("render html: %v", err)
|
t.Fatalf("render html: %v", err)
|
||||||
}
|
}
|
||||||
@@ -225,7 +227,7 @@ func TestRenderHTML_EscapesContent(t *testing.T) {
|
|||||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{user.display_name}}</w:t></w:r></w:p></w:body></w:document>`
|
doc := `<w:document><w:body><w:p><w:r><w:t>{{user.display_name}}</w:t></w:r></w:p></w:body></w:document>`
|
||||||
tmpl := minimalMergeDOCX(t, doc)
|
tmpl := minimalMergeDOCX(t, doc)
|
||||||
r := NewSubmissionRenderer()
|
r := NewSubmissionRenderer()
|
||||||
html, err := r.RenderHTML(tmpl, PlaceholderMap{
|
html, err := r.RenderHTML(tmpl, docforge.PlaceholderMap{
|
||||||
"user.display_name": `M&S <Inc> "X"`,
|
"user.display_name": `M&S <Inc> "X"`,
|
||||||
}, nil)
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -244,7 +246,7 @@ func TestRenderHTML_WrapsMissingMarker(t *testing.T) {
|
|||||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{project.case_number}}</w:t></w:r></w:p></w:body></w:document>`
|
doc := `<w:document><w:body><w:p><w:r><w:t>{{project.case_number}}</w:t></w:r></w:p></w:body></w:document>`
|
||||||
tmpl := minimalMergeDOCX(t, doc)
|
tmpl := minimalMergeDOCX(t, doc)
|
||||||
r := NewSubmissionRenderer()
|
r := NewSubmissionRenderer()
|
||||||
html, err := r.RenderHTML(tmpl, PlaceholderMap{}, nil)
|
html, err := r.RenderHTML(tmpl, docforge.PlaceholderMap{}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("render html: %v", err)
|
t.Fatalf("render html: %v", err)
|
||||||
}
|
}
|
||||||
@@ -262,7 +264,7 @@ func TestRenderHTML_WrapsMissingMarker(t *testing.T) {
|
|||||||
// value. There is no distinction at the renderer level between a value
|
// value. There is no distinction at the renderer level between a value
|
||||||
// that came from the resolved bag (project / parties / deadline lookups)
|
// that came from the resolved bag (project / parties / deadline lookups)
|
||||||
// and a value the lawyer typed into the sidebar — both arrive in the
|
// and a value the lawyer typed into the sidebar — both arrive in the
|
||||||
// same PlaceholderMap and both must be wrapped.
|
// same docforge.PlaceholderMap and both must be wrapped.
|
||||||
func TestRenderHTML_WrapsOverriddenValueSameAsResolved(t *testing.T) {
|
func TestRenderHTML_WrapsOverriddenValueSameAsResolved(t *testing.T) {
|
||||||
doc := `<w:document><w:body>` +
|
doc := `<w:document><w:body>` +
|
||||||
`<w:p><w:r><w:t>{{project.case_number}} / {{firm.name}}</w:t></w:r></w:p>` +
|
`<w:p><w:r><w:t>{{project.case_number}} / {{firm.name}}</w:t></w:r></w:p>` +
|
||||||
@@ -271,7 +273,7 @@ func TestRenderHTML_WrapsOverriddenValueSameAsResolved(t *testing.T) {
|
|||||||
r := NewSubmissionRenderer()
|
r := NewSubmissionRenderer()
|
||||||
// project.case_number is the typed-by-lawyer override.
|
// project.case_number is the typed-by-lawyer override.
|
||||||
// firm.name is the always-resolved value from the firm bag.
|
// firm.name is the always-resolved value from the firm bag.
|
||||||
html, err := r.RenderHTML(tmpl, PlaceholderMap{
|
html, err := r.RenderHTML(tmpl, docforge.PlaceholderMap{
|
||||||
"project.case_number": "UPC_CFI_42/2026",
|
"project.case_number": "UPC_CFI_42/2026",
|
||||||
"firm.name": "HLC",
|
"firm.name": "HLC",
|
||||||
}, nil)
|
}, nil)
|
||||||
@@ -297,7 +299,7 @@ func TestRender_DocxOutputUnchangedByPreviewWrap(t *testing.T) {
|
|||||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p></w:body></w:document>`
|
doc := `<w:document><w:body><w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p></w:body></w:document>`
|
||||||
tmpl := minimalMergeDOCX(t, doc)
|
tmpl := minimalMergeDOCX(t, doc)
|
||||||
r := NewSubmissionRenderer()
|
r := NewSubmissionRenderer()
|
||||||
out, err := r.Render(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
|
out, err := r.Render(tmpl, docforge.PlaceholderMap{"firm.name": "HLC"}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("render docx: %v", err)
|
t.Fatalf("render docx: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
33
pkg/docforge/placeholder.go
Normal file
33
pkg/docforge/placeholder.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package docforge
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// PlaceholderMap is the variable bag a ResolverSet builds and a format
|
||||||
|
// exporter fills into a template. Keys are dotted paths without braces
|
||||||
|
// (e.g. "project.case_number"); values are the substituted text — already
|
||||||
|
// locale-aware, pretty-printed, and sanitised by the resolvers that
|
||||||
|
// produced them.
|
||||||
|
//
|
||||||
|
// It is format-neutral: the .docx exporter substitutes these into OOXML,
|
||||||
|
// but a future PDF/HTML/Markdown exporter consumes the same bag. The
|
||||||
|
// {{key}} substitution grammar itself is the exporter's concern and lives
|
||||||
|
// with the adapter (pkg/docforge/docx), not here.
|
||||||
|
type PlaceholderMap map[string]string
|
||||||
|
|
||||||
|
// MissingPlaceholderFn translates an unbound placeholder key into the
|
||||||
|
// in-document marker token. DefaultMissingMarker returns the standard
|
||||||
|
// "[KEIN WERT: <key>]" / "[NO VALUE: <key>]" form.
|
||||||
|
type MissingPlaceholderFn func(key string) string
|
||||||
|
|
||||||
|
// DefaultMissingMarker returns the standard missing-value marker for the
|
||||||
|
// given UI language. Unbound placeholders render this marker inline so the
|
||||||
|
// lawyer sees the gap in the document rather than the render failing.
|
||||||
|
func DefaultMissingMarker(lang string) MissingPlaceholderFn {
|
||||||
|
prefix := "KEIN WERT"
|
||||||
|
if strings.EqualFold(lang, "en") {
|
||||||
|
prefix = "NO VALUE"
|
||||||
|
}
|
||||||
|
return func(key string) string {
|
||||||
|
return "[" + prefix + ": " + key + "]"
|
||||||
|
}
|
||||||
|
}
|
||||||
56
pkg/docforge/vars.go
Normal file
56
pkg/docforge/vars.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package docforge
|
||||||
|
|
||||||
|
// VariableResolver populates one namespace of the placeholder bag.
|
||||||
|
//
|
||||||
|
// Each resolver owns a dotted namespace (e.g. "project", "parties") and
|
||||||
|
// pushes its keys into a shared PlaceholderMap. The push model — rather
|
||||||
|
// than a pull Resolve(key) — is deliberate: some namespaces emit a
|
||||||
|
// data-dependent set of keys (a multi-party suit produces
|
||||||
|
// parties.claimant.0.name, .1.name, … one per party), which a fixed
|
||||||
|
// key-by-key pull interface can't enumerate cleanly. Populate lets each
|
||||||
|
// resolver decide its own (possibly dynamic) key set in one pass.
|
||||||
|
//
|
||||||
|
// The consuming application implements concrete resolvers against its own
|
||||||
|
// data sources (paliad resolves project/party/deadline state from its
|
||||||
|
// Postgres database); docforge owns only the interface and the
|
||||||
|
// composition machinery (ResolverSet). This is the seam a second consumer
|
||||||
|
// (e.g. upc-commentary) plugs its own resolvers into without touching the
|
||||||
|
// engine.
|
||||||
|
type VariableResolver interface {
|
||||||
|
// Namespace returns the dotted prefix this resolver owns, e.g.
|
||||||
|
// "project". Informational — used for diagnostics and (later) the
|
||||||
|
// authoring variable palette's grouping.
|
||||||
|
Namespace() string
|
||||||
|
|
||||||
|
// Populate writes this resolver's keys into bag. Resolvers own
|
||||||
|
// disjoint namespaces, so population order across resolvers does not
|
||||||
|
// affect the final bag.
|
||||||
|
Populate(bag PlaceholderMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolverSet composes an ordered list of VariableResolvers into a single
|
||||||
|
// PlaceholderMap. It is the replacement for hard-coded "call addFooVars,
|
||||||
|
// then addBarVars, …" sequencing: a consumer registers the resolvers that
|
||||||
|
// apply to a given render and calls BuildBag.
|
||||||
|
type ResolverSet struct {
|
||||||
|
resolvers []VariableResolver
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewResolverSet builds a set from the given resolvers, in order.
|
||||||
|
func NewResolverSet(resolvers ...VariableResolver) *ResolverSet {
|
||||||
|
return &ResolverSet{resolvers: resolvers}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add appends a resolver to the set.
|
||||||
|
func (s *ResolverSet) Add(r VariableResolver) { s.resolvers = append(s.resolvers, r) }
|
||||||
|
|
||||||
|
// BuildBag runs every resolver's Populate into a fresh PlaceholderMap and
|
||||||
|
// returns it. Because resolvers own disjoint namespaces, the result is
|
||||||
|
// independent of resolver order.
|
||||||
|
func (s *ResolverSet) BuildBag() PlaceholderMap {
|
||||||
|
bag := PlaceholderMap{}
|
||||||
|
for _, r := range s.resolvers {
|
||||||
|
r.Populate(bag)
|
||||||
|
}
|
||||||
|
return bag
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user