Merge: t-paliad-349 docforge slice 3 — VariableResolver interface + ResolverSet (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
|
||||
// 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),
|
||||
// built by SubmissionVarsService and consumed by the renderer.
|
||||
type PlaceholderMap = docx.PlaceholderMap
|
||||
// built by SubmissionVarsService and consumed by the renderer. The
|
||||
// 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
|
||||
// in-document marker token.
|
||||
type MissingPlaceholderFn = docx.MissingPlaceholderFn
|
||||
type MissingPlaceholderFn = docforge.MissingPlaceholderFn
|
||||
|
||||
// SubmissionRenderer renders a .docx template by substituting
|
||||
// {{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
|
||||
// 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
|
||||
// elements using a single paragraph style.
|
||||
|
||||
@@ -47,6 +47,7 @@ import (
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/branding"
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
)
|
||||
|
||||
// SubmissionVarsService assembles the placeholder map.
|
||||
@@ -151,17 +152,20 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
|
||||
if lang == "" {
|
||||
lang = "de"
|
||||
}
|
||||
bag := PlaceholderMap{}
|
||||
addFirmVars(bag)
|
||||
addTodayVars(bag, time.Now())
|
||||
addUserVars(bag, user)
|
||||
addRuleVars(bag, rule, lang)
|
||||
// firm / today / user / procedural_event apply to every render,
|
||||
// project-bound or not. Each resolver wraps the matching addXxxVars
|
||||
// builder (unchanged); ResolverSet.BuildBag runs them into one bag.
|
||||
resolvers := []docforge.VariableResolver{
|
||||
firmResolver{},
|
||||
todayResolver{now: time.Now()},
|
||||
userResolver{user: user},
|
||||
proceduralEventResolver{rule: rule, lang: lang},
|
||||
}
|
||||
|
||||
out := &SubmissionVarsResult{
|
||||
Placeholders: bag,
|
||||
User: user,
|
||||
Rule: rule,
|
||||
Lang: lang,
|
||||
User: user,
|
||||
Rule: rule,
|
||||
Lang: lang,
|
||||
}
|
||||
|
||||
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
|
||||
// the placeholder map; missing keys render as
|
||||
// [KEIN WERT: …] / [NO VALUE: …] in the preview.
|
||||
out.Placeholders = docforge.NewResolverSet(resolvers...).BuildBag()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -195,14 +200,17 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
|
||||
return nil, err
|
||||
}
|
||||
|
||||
addProjectVars(bag, project, pt, lang)
|
||||
addPartyVars(bag, filterPartiesBySelection(parties, in.SelectedParties))
|
||||
addDeadlineVars(bag, next, project, lang)
|
||||
resolvers = append(resolvers,
|
||||
projectResolver{project: project, pt: pt, lang: lang},
|
||||
partiesResolver{parties: filterPartiesBySelection(parties, in.SelectedParties)},
|
||||
deadlineResolver{deadline: next, project: project, lang: lang},
|
||||
)
|
||||
|
||||
out.Project = project
|
||||
out.ProceedingType = pt
|
||||
out.Parties = parties
|
||||
out.NextDeadline = next
|
||||
out.Placeholders = docforge.NewResolverSet(resolvers...).BuildBag()
|
||||
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"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
)
|
||||
|
||||
// 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
|
||||
// substitutes after the composer assembly. Passed straight through
|
||||
// to SubmissionRenderer.Render.
|
||||
Vars PlaceholderMap
|
||||
Vars docforge.PlaceholderMap
|
||||
|
||||
// Missing translates an unbound placeholder key into the marker
|
||||
// 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.
|
||||
|
||||
@@ -24,7 +24,7 @@ package docx
|
||||
// {{project.case_number}}).
|
||||
//
|
||||
// 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.
|
||||
|
||||
import (
|
||||
@@ -34,18 +34,15 @@ import (
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
)
|
||||
|
||||
// PlaceholderMap is the variable bag built by SubmissionVarsService.
|
||||
// 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 caller.
|
||||
type PlaceholderMap map[string]string
|
||||
|
||||
// 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
|
||||
// docforge.PlaceholderMap, docforge.MissingPlaceholderFn, and docforge.DefaultMissingMarker — the
|
||||
// format-neutral variable-bag contract — live in the docforge root
|
||||
// package (placeholder.go). This adapter consumes them; the {{key}}
|
||||
// substitution grammar below (placeholderRegex, replacePlaceholders, the
|
||||
// PUA preview sentinels) is the .docx renderer's own machinery.
|
||||
|
||||
// valueWrapperFn wraps a substituted value with a marker the HTML
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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*\}\}`)
|
||||
|
||||
// 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.
|
||||
type SubmissionRenderer struct{}
|
||||
|
||||
@@ -112,9 +97,9 @@ func NewSubmissionRenderer() *SubmissionRenderer {
|
||||
// Pre-pass: ConvertDotmToDocx is called on the input so a .dotm
|
||||
// template (macro-bearing) is downgraded to a plain .docx before the
|
||||
// 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 {
|
||||
missing = DefaultMissingMarker("de")
|
||||
missing = docforge.DefaultMissingMarker("de")
|
||||
}
|
||||
cleanBytes, err := ConvertDotmToDocx(templateBytes)
|
||||
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
|
||||
// or innerHTML. The caller is responsible for wrapping in an outer
|
||||
// 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 {
|
||||
missing = DefaultMissingMarker("de")
|
||||
missing = docforge.DefaultMissingMarker("de")
|
||||
}
|
||||
cleanBytes, err := ConvertDotmToDocx(templateBytes)
|
||||
if err != nil {
|
||||
@@ -241,7 +226,7 @@ func readMergeZipEntry(f *zip.File) ([]byte, error) {
|
||||
// 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 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)
|
||||
if !needsCrossRunMerge(replaced) {
|
||||
return replaced
|
||||
@@ -256,7 +241,7 @@ var wTextNodeRegex = regexp.MustCompile(`<w:t(\s[^>]*)?>([^<]*)</w:t>`)
|
||||
// substituteInTextNodes runs the placeholder replacement inside each
|
||||
// <w:t> text node independently. Format-preserving for single-run
|
||||
// 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 {
|
||||
sub := wTextNodeRegex.FindSubmatch(match)
|
||||
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
|
||||
// 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 {
|
||||
textNodes := wTextNodeRegex.FindAllSubmatch(para, -1)
|
||||
if len(textNodes) == 0 {
|
||||
@@ -340,7 +325,7 @@ func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlace
|
||||
// emit clickable spans around every substituted placeholder, including
|
||||
// missing ones (clicking a missing marker jumps to the corresponding
|
||||
// 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 {
|
||||
sub := placeholderRegex.FindStringSubmatch(match)
|
||||
if len(sub) < 2 {
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
)
|
||||
|
||||
// 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>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
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 {
|
||||
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>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{
|
||||
out, err := r.Render(tmpl, docforge.PlaceholderMap{
|
||||
"parties.claimant.name": "Acme Inc.",
|
||||
"parties.claimant.representative": "Kanzlei Müller",
|
||||
}, 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>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{}, DefaultMissingMarker("de"))
|
||||
out, err := r.Render(tmpl, docforge.PlaceholderMap{}, docforge.DefaultMissingMarker("de"))
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
@@ -119,7 +121,7 @@ func TestRender_MissingMarker(t *testing.T) {
|
||||
if !strings.Contains(body, "[KEIN WERT: project.case_number]") {
|
||||
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 {
|
||||
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>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
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 {
|
||||
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>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{
|
||||
out, err := r.Render(tmpl, docforge.PlaceholderMap{
|
||||
"user.display_name": `Müller & Söhne <GmbH> "Special"`,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
@@ -203,7 +205,7 @@ func TestRenderHTML_ExtractsParagraphsAndFormatting(t *testing.T) {
|
||||
`</w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
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 {
|
||||
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>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
html, err := r.RenderHTML(tmpl, PlaceholderMap{
|
||||
html, err := r.RenderHTML(tmpl, docforge.PlaceholderMap{
|
||||
"user.display_name": `M&S <Inc> "X"`,
|
||||
}, 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>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
html, err := r.RenderHTML(tmpl, PlaceholderMap{}, nil)
|
||||
html, err := r.RenderHTML(tmpl, docforge.PlaceholderMap{}, nil)
|
||||
if err != nil {
|
||||
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
|
||||
// that came from the resolved bag (project / parties / deadline lookups)
|
||||
// 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) {
|
||||
doc := `<w:document><w:body>` +
|
||||
`<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()
|
||||
// project.case_number is the typed-by-lawyer override.
|
||||
// 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",
|
||||
"firm.name": "HLC",
|
||||
}, 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>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
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 {
|
||||
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