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
713 lines
25 KiB
Go
713 lines
25 KiB
Go
package services
|
|
|
|
// Submission variable bag — builds the PlaceholderMap that
|
|
// SubmissionRenderer fills into a template (t-paliad-215, design doc
|
|
// docs/design-submission-generator-2026-05-19.md §6.2).
|
|
//
|
|
// Variables span six namespaces:
|
|
//
|
|
// firm.* process-wide (branding.Name)
|
|
// user.* caller's user row
|
|
// today.* server time in Europe/Berlin, locale-aware
|
|
// project.* paliad.projects + joined proceeding type
|
|
// parties.* paliad.parties grouped by role
|
|
// procedural_event.* paliad.deadline_rules row keyed by submission_code
|
|
// — the "what kind of step in the proceeding"
|
|
// identity (Schriftsatz, Anhörung, Entscheidung,
|
|
// …). See docs/design-procedural-events-model-
|
|
// 2026-05-25.md (t-paliad-262 Slice A).
|
|
// rule.* legacy alias for procedural_event.*; emitted
|
|
// unconditionally for backward compatibility
|
|
// with Word templates and saved drafts authored
|
|
// before the rename. @deprecated — new templates
|
|
// should use the procedural_event.* form.
|
|
// deadline.* next open paliad.deadlines row for
|
|
// (project, procedural_event), if any
|
|
//
|
|
// Locale handling: every long-form date string is computed in both DE
|
|
// and EN; the renderer picks based on the user's lang preference. The
|
|
// procedural-event pretty-printer (legalSourcePretty) also has DE/EN
|
|
// variants.
|
|
//
|
|
// Visibility: caller passes userID; ProjectService.GetByID enforces
|
|
// paliad.can_see_project — unauthorised callers get the standard
|
|
// ErrNotFound before any variable construction runs.
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"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.
|
|
type SubmissionVarsService struct {
|
|
db *sqlx.DB
|
|
projects *ProjectService
|
|
parties *PartyService
|
|
users *UserService
|
|
}
|
|
|
|
// NewSubmissionVarsService wires the service.
|
|
func NewSubmissionVarsService(db *sqlx.DB, projects *ProjectService, parties *PartyService, users *UserService) *SubmissionVarsService {
|
|
return &SubmissionVarsService{
|
|
db: db,
|
|
projects: projects,
|
|
parties: parties,
|
|
users: users,
|
|
}
|
|
}
|
|
|
|
// SubmissionVarsContext is the input bundle that produces a render.
|
|
//
|
|
// ProjectID is optional since t-paliad-243 — a global Schriftsatz draft
|
|
// started from /submissions/new without picking a project carries
|
|
// nil here and the project / parties / deadline lookups are skipped.
|
|
//
|
|
// SelectedParties is the t-paliad-277 multi-party selection: an empty
|
|
// or nil slice means "include every party on the project" (the
|
|
// backward-compat default that every legacy draft renders with); a
|
|
// non-empty slice restricts the variable bag to the listed parties so
|
|
// the submission only mentions the chosen subset.
|
|
type SubmissionVarsContext struct {
|
|
UserID uuid.UUID
|
|
ProjectID *uuid.UUID
|
|
SubmissionCode string
|
|
SelectedParties []uuid.UUID
|
|
// Lang pins the output language for this Build, overriding the
|
|
// caller's UI preference (user.Lang). When empty, Build falls back
|
|
// to user.Lang so existing callers (the format-only Slice 1 path)
|
|
// keep working unchanged. The draft editor passes the per-draft
|
|
// `language` column (t-paliad-276) so DE/EN can be picked
|
|
// independently of the UI session.
|
|
Lang string
|
|
}
|
|
|
|
// SubmissionVarsResult bundles the placeholder map with the lookup
|
|
// values the handler needs for the audit row + file naming
|
|
// (rule.Name, project.case_number, etc.).
|
|
type SubmissionVarsResult struct {
|
|
Placeholders PlaceholderMap
|
|
|
|
// Resolved entities for audit + naming.
|
|
User *models.User
|
|
Project *models.Project
|
|
Rule *models.DeadlineRule
|
|
ProceedingType *models.ProceedingType
|
|
Parties []models.Party
|
|
NextDeadline *models.Deadline
|
|
|
|
// Lang is the user's UI language used to pick locale-aware values
|
|
// during the build. Returned so the renderer can use the matching
|
|
// missing-marker function.
|
|
Lang string
|
|
}
|
|
|
|
// ErrSubmissionRuleNotFound is returned when no published deadline_rule
|
|
// matches the requested submission_code. Maps to 404 in the handler.
|
|
var ErrSubmissionRuleNotFound = errors.New("submission generator: no rule found for submission_code")
|
|
|
|
// Build resolves every entity and assembles the placeholder map. A nil
|
|
// ProjectID skips project / parties / deadline lookups — the resolved
|
|
// bag carries only firm.*, today.*, user.* and rule.* in that case;
|
|
// every other placeholder falls through to the lawyer's overrides via
|
|
// SubmissionDraftService.BuildRenderBag.
|
|
func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsContext) (*SubmissionVarsResult, error) {
|
|
if s.projects == nil || s.users == nil {
|
|
return nil, fmt.Errorf("submission vars: required services not wired")
|
|
}
|
|
|
|
user, err := s.users.GetByID(ctx, in.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if user == nil {
|
|
return nil, ErrNotVisible
|
|
}
|
|
|
|
rule, err := s.loadPublishedRule(ctx, in.SubmissionCode)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Per-call Lang override (t-paliad-276) wins over the user's UI
|
|
// language so the draft editor can render an EN .docx from a DE-UI
|
|
// session and vice versa. Falls back to the user pref when the
|
|
// caller didn't specify, preserving the format-only Slice 1
|
|
// behaviour.
|
|
lang := strings.ToLower(strings.TrimSpace(in.Lang))
|
|
if lang != "de" && lang != "en" {
|
|
lang = user.Lang
|
|
}
|
|
if lang == "" {
|
|
lang = "de"
|
|
}
|
|
// 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{
|
|
User: user,
|
|
Rule: rule,
|
|
Lang: lang,
|
|
}
|
|
|
|
if in.ProjectID == nil {
|
|
// Project-less draft (t-paliad-243): no project / parties /
|
|
// 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
|
|
}
|
|
|
|
// Visibility gate — GetByID returns ErrNotFound when the user
|
|
// can't see the project, which is exactly the 404 the handler
|
|
// wants to propagate.
|
|
project, err := s.projects.GetByID(ctx, in.UserID, *in.ProjectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
pt, err := s.loadProceedingType(ctx, project.ProceedingTypeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
parties, err := s.parties.ListForProject(ctx, in.UserID, *in.ProjectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
next, err := s.nextOpenDeadline(ctx, *in.ProjectID, rule.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// filterPartiesBySelection returns the subset of parties whose IDs
|
|
// appear in selected. An empty or nil `selected` slice is the
|
|
// backward-compat default — every party flows through unchanged. A
|
|
// non-empty slice preserves the input ordering of `parties` (which is
|
|
// stable by name from PartyService.ListForProject) so the bag's
|
|
// "first claimant / first defendant / first other" picks remain
|
|
// deterministic for a given project state.
|
|
func filterPartiesBySelection(parties []models.Party, selected []uuid.UUID) []models.Party {
|
|
if len(selected) == 0 {
|
|
return parties
|
|
}
|
|
allowed := make(map[uuid.UUID]struct{}, len(selected))
|
|
for _, id := range selected {
|
|
allowed[id] = struct{}{}
|
|
}
|
|
out := make([]models.Party, 0, len(parties))
|
|
for _, p := range parties {
|
|
if _, ok := allowed[p.ID]; ok {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// loadPublishedRule fetches the published procedural-event template
|
|
// (paliad.deadline_rules row) keyed by submission_code. Restricts to
|
|
// lifecycle_state='published' so drafts never end up shaping a real
|
|
// submission. Function name retained for Slice A (prose-only); Slice
|
|
// B renames it to loadPublishedProceduralEvent when the Go type is
|
|
// renamed (t-paliad-262 §6).
|
|
func (s *SubmissionVarsService) loadPublishedRule(ctx context.Context, submissionCode string) (*models.DeadlineRule, error) {
|
|
if submissionCode == "" {
|
|
return nil, ErrSubmissionRuleNotFound
|
|
}
|
|
var rule models.DeadlineRule
|
|
err := s.db.GetContext(ctx, &rule,
|
|
`SELECT `+ruleColumns+`
|
|
FROM paliad.deadline_rules_unified
|
|
WHERE submission_code = $1
|
|
AND lifecycle_state = 'published'
|
|
AND is_active = true
|
|
ORDER BY sequence_order
|
|
LIMIT 1`, submissionCode)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrSubmissionRuleNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load rule by submission_code %q: %w", submissionCode, err)
|
|
}
|
|
return &rule, nil
|
|
}
|
|
|
|
// loadProceedingType fetches the proceeding type row for the project's
|
|
// proceeding_type_id. Tolerates a nil id (returns nil, nil) so projects
|
|
// without a bound proceeding still render a meaningful template — the
|
|
// {{project.proceeding.*}} placeholders just resolve to the missing
|
|
// marker.
|
|
func (s *SubmissionVarsService) loadProceedingType(ctx context.Context, id *int) (*models.ProceedingType, error) {
|
|
if id == nil {
|
|
return nil, nil
|
|
}
|
|
var pt models.ProceedingType
|
|
err := s.db.GetContext(ctx, &pt,
|
|
`SELECT `+proceedingTypeColumns+`
|
|
FROM paliad.proceeding_types
|
|
WHERE id = $1`, *id)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load proceeding type %d: %w", *id, err)
|
|
}
|
|
return &pt, nil
|
|
}
|
|
|
|
// nextOpenDeadline finds the earliest pending paliad.deadlines row on
|
|
// the given project that maps to the chosen rule. Returns (nil, nil)
|
|
// when no matching deadline exists — common when the lawyer is drafting
|
|
// the submission before the system has computed its deadline row.
|
|
func (s *SubmissionVarsService) nextOpenDeadline(ctx context.Context, projectID, ruleID uuid.UUID) (*models.Deadline, error) {
|
|
var d models.Deadline
|
|
err := s.db.GetContext(ctx, &d,
|
|
`SELECT id, project_id, title, description, due_date, original_due_date,
|
|
warning_date, source, sequencing_rule_id, rule_code, status, completed_at,
|
|
caldav_uid, caldav_etag, notes, created_by, created_at, updated_at,
|
|
approval_status, pending_request_id, approved_by, approved_at
|
|
FROM paliad.deadlines
|
|
WHERE project_id = $1
|
|
AND sequencing_rule_id = $2
|
|
AND status = 'pending'
|
|
ORDER BY due_date ASC
|
|
LIMIT 1`, projectID, ruleID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load next deadline (project=%s rule=%s): %w", projectID, ruleID, err)
|
|
}
|
|
return &d, nil
|
|
}
|
|
|
|
// addFirmVars populates the firm.* namespace.
|
|
func addFirmVars(bag PlaceholderMap) {
|
|
bag["firm.name"] = branding.Name
|
|
// firm.signature_block is reserved for Phase 2; emit empty so
|
|
// templates that already reference it don't render the missing
|
|
// marker (less noisy for the lawyer).
|
|
bag["firm.signature_block"] = ""
|
|
}
|
|
|
|
// addTodayVars populates today.* in both DE and EN long forms. ISO
|
|
// short form is the default {{today}}.
|
|
func addTodayVars(bag PlaceholderMap, now time.Time) {
|
|
loc, _ := time.LoadLocation("Europe/Berlin")
|
|
if loc != nil {
|
|
now = now.In(loc)
|
|
}
|
|
bag["today"] = now.Format("2006-01-02")
|
|
bag["today.iso"] = now.Format("2006-01-02")
|
|
bag["today.long_de"] = formatLongDateDE(now)
|
|
bag["today.long_en"] = formatLongDateEN(now)
|
|
}
|
|
|
|
// addUserVars populates user.*.
|
|
func addUserVars(bag PlaceholderMap, u *models.User) {
|
|
bag["user.display_name"] = u.DisplayName
|
|
bag["user.email"] = u.Email
|
|
bag["user.office"] = u.Office
|
|
}
|
|
|
|
// addProjectVars populates project.* — title / case_number / court /
|
|
// patent_number / dates / our_side / proceeding metadata.
|
|
func addProjectVars(bag PlaceholderMap, p *models.Project, pt *models.ProceedingType, lang string) {
|
|
bag["project.title"] = p.Title
|
|
bag["project.reference"] = derefString(p.Reference)
|
|
bag["project.case_number"] = derefString(p.CaseNumber)
|
|
bag["project.court"] = derefString(p.Court)
|
|
bag["project.patent_number"] = derefString(p.PatentNumber)
|
|
// project.patent_number_upc is the UPC-brief convention — kind code
|
|
// parenthesised ("EP 1 234 567 (B1)") instead of the DE form
|
|
// ("EP 1 234 567 B1"). Pure-function rewrite; pass-through when no
|
|
// kind code is present so the lawyer's draft never sees a worse
|
|
// number than the source value.
|
|
bag["project.patent_number_upc"] = patentNumberUPC(derefString(p.PatentNumber))
|
|
bag["project.filing_date"] = formatDatePtr(p.FilingDate, "2006-01-02")
|
|
bag["project.grant_date"] = formatDatePtr(p.GrantDate, "2006-01-02")
|
|
bag["project.our_side"] = derefString(p.OurSide)
|
|
bag["project.our_side_de"] = ourSideDE(derefString(p.OurSide))
|
|
bag["project.our_side_en"] = ourSideEN(derefString(p.OurSide))
|
|
bag["project.instance_level"] = derefString(p.InstanceLevel)
|
|
bag["project.client_number"] = derefString(p.ClientNumber)
|
|
bag["project.matter_number"] = derefString(p.MatterNumber)
|
|
if pt != nil {
|
|
bag["project.proceeding.code"] = pt.Code
|
|
if strings.EqualFold(lang, "en") {
|
|
bag["project.proceeding.name"] = pt.NameEN
|
|
} else {
|
|
bag["project.proceeding.name"] = pt.Name
|
|
}
|
|
bag["project.proceeding.name_de"] = pt.Name
|
|
bag["project.proceeding.name_en"] = pt.NameEN
|
|
}
|
|
}
|
|
|
|
// addPartyVars populates the parties.* namespace from the (already
|
|
// filtered) list of parties.
|
|
//
|
|
// Three forms coexist per role (claimant / defendant / other) so
|
|
// templates authored against any of them keep merging correctly:
|
|
//
|
|
// - Comma-joined list (t-paliad-277, primary form for multi-party
|
|
// suits):
|
|
//
|
|
// {{parties.claimants}} — all claimants' names
|
|
// {{parties.claimants.representatives}}
|
|
// {{parties.defendants}} / .representatives
|
|
// {{parties.others}} / .representatives
|
|
//
|
|
// - Indexed access (templates that need the primary individually):
|
|
//
|
|
// {{parties.claimant.0.name}} / .representative
|
|
// {{parties.defendant.0.name}} / .representative
|
|
// {{parties.other.0.name}} / .representative
|
|
//
|
|
// - Flat legacy (kept forever per the issue's backward-compat
|
|
// contract; resolves to the FIRST selected party of each role):
|
|
//
|
|
// {{parties.claimant.name}} / .representative
|
|
// {{parties.defendant.name}} / .representative
|
|
// {{parties.other.name}} / .representative
|
|
//
|
|
// Role bucketing matches the prior shape: German strings ("Kläger",
|
|
// "Beklagte") and their English equivalents fold into claimant /
|
|
// defendant; everything else (Streithelfer, Patentinhaberin, …) flows
|
|
// into "other".
|
|
func addPartyVars(bag PlaceholderMap, parties []models.Party) {
|
|
var claimants, defendants, others []models.Party
|
|
for i := range parties {
|
|
role := strings.ToLower(strings.TrimSpace(derefString(parties[i].Role)))
|
|
switch role {
|
|
case "claimant", "kläger", "klaeger", "klägerin", "klaegerin":
|
|
claimants = append(claimants, parties[i])
|
|
case "defendant", "beklagter", "beklagte":
|
|
defendants = append(defendants, parties[i])
|
|
default:
|
|
others = append(others, parties[i])
|
|
}
|
|
}
|
|
|
|
emitPartyGroup(bag, "claimant", "claimants", claimants)
|
|
emitPartyGroup(bag, "defendant", "defendants", defendants)
|
|
emitPartyGroup(bag, "other", "others", others)
|
|
}
|
|
|
|
// emitPartyGroup writes the three forms (joined list, indexed access,
|
|
// flat legacy first-of-role) for a single role bucket. `singular` is
|
|
// the legacy/indexed prefix (claimant / defendant / other); `plural`
|
|
// is the joined-list prefix (claimants / defendants / others).
|
|
func emitPartyGroup(bag PlaceholderMap, singular, plural string, group []models.Party) {
|
|
names := make([]string, 0, len(group))
|
|
reps := make([]string, 0, len(group))
|
|
for _, p := range group {
|
|
names = append(names, p.Name)
|
|
reps = append(reps, derefString(p.Representative))
|
|
}
|
|
|
|
bag["parties."+plural] = strings.Join(names, ", ")
|
|
bag["parties."+plural+".representatives"] = joinNonEmpty(reps, ", ")
|
|
|
|
for i, p := range group {
|
|
idx := fmt.Sprintf("parties.%s.%d", singular, i)
|
|
bag[idx+".name"] = p.Name
|
|
bag[idx+".representative"] = derefString(p.Representative)
|
|
}
|
|
|
|
if len(group) > 0 {
|
|
first := group[0]
|
|
bag["parties."+singular+".name"] = first.Name
|
|
bag["parties."+singular+".representative"] = derefString(first.Representative)
|
|
}
|
|
}
|
|
|
|
// joinNonEmpty joins a slice with sep but skips empty entries so a
|
|
// list of representatives where one party has no representative reads
|
|
// as "A, B" instead of "A, , B".
|
|
func joinNonEmpty(parts []string, sep string) string {
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
if strings.TrimSpace(p) == "" {
|
|
continue
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return strings.Join(out, sep)
|
|
}
|
|
|
|
// addRuleVars populates the procedural-event variable namespace —
|
|
// code, name(_en), legal_source (+ pretty form), primary_party, kind.
|
|
//
|
|
// Two key prefixes are emitted for every value:
|
|
//
|
|
// - procedural_event.* — canonical name (t-paliad-262 Slice A,
|
|
// design docs/design-procedural-events-model-2026-05-25.md).
|
|
// - rule.* — legacy alias kept forever (m's call,
|
|
// issue m/paliad#93 Q7); existing Word templates and saved
|
|
// submission_drafts authored before the rename keep working.
|
|
//
|
|
// `procedural_event.event_kind` is the canonical key for the
|
|
// procedural-event kind (filing|reply|hearing|decision|order). The
|
|
// legacy `rule.event_type` alias holds the same string. The column
|
|
// itself stays named `event_type` on `paliad.deadline_rules` — Slice
|
|
// A is prose-only; the column-level rename to `event_kind` is Slice B.
|
|
//
|
|
// Function name stays `addRuleVars` to avoid coupling Slice A to the
|
|
// Go-type rename which is Slice B (B.5 sub-slice).
|
|
func addRuleVars(bag PlaceholderMap, r *models.DeadlineRule, lang string) {
|
|
code := derefString(r.SubmissionCode)
|
|
var localizedName string
|
|
if strings.EqualFold(lang, "en") {
|
|
localizedName = r.NameEN
|
|
} else {
|
|
localizedName = r.Name
|
|
}
|
|
legalSource := derefString(r.LegalSource)
|
|
legalSourcePrettyVal := legalSourcePretty(legalSource, lang)
|
|
primaryParty := derefString(r.PrimaryParty)
|
|
eventKind := derefString(r.EventType)
|
|
|
|
bag["procedural_event.code"] = code
|
|
bag["procedural_event.name"] = localizedName
|
|
bag["procedural_event.name_de"] = r.Name
|
|
bag["procedural_event.name_en"] = r.NameEN
|
|
bag["procedural_event.legal_source"] = legalSource
|
|
bag["procedural_event.legal_source_pretty"] = legalSourcePrettyVal
|
|
bag["procedural_event.primary_party"] = primaryParty
|
|
bag["procedural_event.event_kind"] = eventKind
|
|
|
|
bag["rule.submission_code"] = code
|
|
bag["rule.name"] = localizedName
|
|
bag["rule.name_de"] = r.Name
|
|
bag["rule.name_en"] = r.NameEN
|
|
bag["rule.legal_source"] = legalSource
|
|
bag["rule.legal_source_pretty"] = legalSourcePrettyVal
|
|
bag["rule.primary_party"] = primaryParty
|
|
bag["rule.event_type"] = eventKind
|
|
}
|
|
|
|
// addDeadlineVars populates deadline.* from the next pending row. When
|
|
// no row exists the values fall through to the missing marker — the
|
|
// lawyer sees [KEIN WERT: deadline.due_date] in Word and knows to fix.
|
|
func addDeadlineVars(bag PlaceholderMap, d *models.Deadline, p *models.Project, lang string) {
|
|
if d == nil {
|
|
return
|
|
}
|
|
bag["deadline.due_date"] = d.DueDate.Format("2006-01-02")
|
|
bag["deadline.due_date_long_de"] = formatLongDateDE(d.DueDate)
|
|
bag["deadline.due_date_long_en"] = formatLongDateEN(d.DueDate)
|
|
if d.OriginalDueDate != nil {
|
|
bag["deadline.original_due_date"] = d.OriginalDueDate.Format("2006-01-02")
|
|
}
|
|
// computed_from carries the human-readable anchor description
|
|
// (e.g. "Klagezustellung am 14.05.2026 + 6 Wochen"). Notes is
|
|
// the closest existing field — the calculator stores anchor
|
|
// metadata there. If empty we leave the placeholder unresolved.
|
|
if d.Notes != nil && strings.TrimSpace(*d.Notes) != "" {
|
|
bag["deadline.computed_from"] = strings.TrimSpace(*d.Notes)
|
|
}
|
|
bag["deadline.title"] = d.Title
|
|
bag["deadline.source"] = d.Source
|
|
_ = p // reserved for future shape decisions where the deadline
|
|
// var depends on project context.
|
|
_ = lang
|
|
}
|
|
|
|
// derefString returns *s or "" when s is nil.
|
|
func derefString(s *string) string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
return *s
|
|
}
|
|
|
|
// formatDatePtr formats a *time.Time, returning "" for nil.
|
|
func formatDatePtr(t *time.Time, layout string) string {
|
|
if t == nil {
|
|
return ""
|
|
}
|
|
return t.Format(layout)
|
|
}
|
|
|
|
// ourSideDE returns the German legal-prose form of an our_side value.
|
|
func ourSideDE(side string) string {
|
|
switch strings.ToLower(side) {
|
|
case "claimant":
|
|
return "Klägerin"
|
|
case "defendant":
|
|
return "Beklagte"
|
|
case "court":
|
|
return "Gericht"
|
|
case "both":
|
|
return "Klägerin und Beklagte"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ourSideEN returns the English legal-prose form of an our_side value.
|
|
func ourSideEN(side string) string {
|
|
switch strings.ToLower(side) {
|
|
case "claimant":
|
|
return "Claimant"
|
|
case "defendant":
|
|
return "Defendant"
|
|
case "court":
|
|
return "Court"
|
|
case "both":
|
|
return "Claimant and Defendant"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// formatLongDateDE renders a date in the German long form
|
|
// ("19. Mai 2026"). Pure function for unit testing.
|
|
func formatLongDateDE(t time.Time) string {
|
|
months := []string{
|
|
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
|
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
|
}
|
|
idx := int(t.Month()) - 1
|
|
if idx < 0 || idx >= len(months) {
|
|
return t.Format("2006-01-02")
|
|
}
|
|
return fmt.Sprintf("%d. %s %d", t.Day(), months[idx], t.Year())
|
|
}
|
|
|
|
// formatLongDateEN renders a date in the English long form
|
|
// ("19 May 2026").
|
|
func formatLongDateEN(t time.Time) string {
|
|
return t.Format("2 January 2006")
|
|
}
|
|
|
|
// legalSourcePretty rewrites the shorthand stored on deadline_rules
|
|
// (DE.ZPO.276.1, UPC.RoP.23.1, …) into the form a lawyer would type
|
|
// in a brief ("§ 276 Abs. 1 ZPO", "Rule 23.1 RoP UPC"). Unknown
|
|
// prefixes pass through unchanged — preferring the raw shorthand over
|
|
// an incorrect prettification.
|
|
//
|
|
// Lang controls the language of connective words (Abs / Section,
|
|
// Regel / Rule, …). The pretty table covers the prefixes used by the
|
|
// 254 published rules in the corpus today; new prefixes default to
|
|
// pass-through and a follow-up CL extends the table.
|
|
func legalSourcePretty(src, lang string) string {
|
|
src = strings.TrimSpace(src)
|
|
if src == "" {
|
|
return ""
|
|
}
|
|
parts := strings.Split(src, ".")
|
|
en := strings.EqualFold(lang, "en")
|
|
|
|
switch {
|
|
case len(parts) == 4 && parts[0] == "DE" && parts[1] == "ZPO":
|
|
if en {
|
|
return fmt.Sprintf("Section %s(%s) ZPO", parts[2], parts[3])
|
|
}
|
|
return fmt.Sprintf("§ %s Abs. %s ZPO", parts[2], parts[3])
|
|
case len(parts) == 3 && parts[0] == "DE" && parts[1] == "ZPO":
|
|
if en {
|
|
return fmt.Sprintf("Section %s ZPO", parts[2])
|
|
}
|
|
return fmt.Sprintf("§ %s ZPO", parts[2])
|
|
case len(parts) == 4 && parts[0] == "UPC" && parts[1] == "RoP":
|
|
if en {
|
|
return fmt.Sprintf("Rule %s.%s RoP UPC", parts[2], parts[3])
|
|
}
|
|
return fmt.Sprintf("Regel %s.%s VerfO UPC", parts[2], parts[3])
|
|
case len(parts) == 3 && parts[0] == "UPC" && parts[1] == "RoP":
|
|
if en {
|
|
return fmt.Sprintf("Rule %s RoP UPC", parts[2])
|
|
}
|
|
return fmt.Sprintf("Regel %s VerfO UPC", parts[2])
|
|
case len(parts) >= 3 && parts[0] == "DE" && parts[1] == "PatG":
|
|
if en {
|
|
return fmt.Sprintf("Section %s PatG", parts[2])
|
|
}
|
|
return fmt.Sprintf("§ %s PatG", parts[2])
|
|
case len(parts) == 2 && parts[0] == "EPC":
|
|
if en {
|
|
return fmt.Sprintf("Art. %s EPC", parts[1])
|
|
}
|
|
return fmt.Sprintf("Art. %s EPÜ", parts[1])
|
|
}
|
|
return src
|
|
}
|
|
|
|
// patentNumberKindCodeRegex matches a trailing kind code on a patent
|
|
// number: a whitespace-separated single uppercase letter followed by
|
|
// a single digit (B1, A1, A2, B2, B9, C1, T2, U1, …). Capturing
|
|
// groups split the base from the kind code so the formatter can
|
|
// parenthesise the kind without touching the rest of the number.
|
|
var patentNumberKindCodeRegex = regexp.MustCompile(`^(.*?)\s+([A-Z]\d)$`)
|
|
|
|
// patentNumberUPC reformats a patent number from the DE convention
|
|
// ("EP 1 234 567 B1") to the UPC-brief convention
|
|
// ("EP 1 234 567 (B1)"). The kind code is parenthesised; everything
|
|
// else is preserved verbatim. Numbers without a recognised trailing
|
|
// kind code pass through unchanged so a lawyer's draft never sees a
|
|
// number worse than the source value.
|
|
//
|
|
// Recognised inputs:
|
|
//
|
|
// "EP 1 234 567 B1" → "EP 1 234 567 (B1)"
|
|
// "EP 4 056 049 A1" → "EP 4 056 049 (A1)"
|
|
// "DE 10 2020 123 456 A1" → "DE 10 2020 123 456 (A1)"
|
|
// " EP 1 234 567 B1 " → "EP 1 234 567 (B1)" (trimmed)
|
|
//
|
|
// Pass-through:
|
|
//
|
|
// "EP 1 234 567" → "EP 1 234 567"
|
|
// "WO/2023/123456" → "WO/2023/123456" (no kind code shape)
|
|
// "" → ""
|
|
//
|
|
// Pure function; unit-tested in submission_vars_test.go.
|
|
func patentNumberUPC(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
if m := patentNumberKindCodeRegex.FindStringSubmatch(s); m != nil {
|
|
base := strings.TrimSpace(m[1])
|
|
kind := m[2]
|
|
if base == "" {
|
|
return s
|
|
}
|
|
return base + " (" + kind + ")"
|
|
}
|
|
return s
|
|
}
|