Compare commits

...

2 Commits

Author SHA1 Message Date
mAi
63a9bedf7e Merge: t-paliad-349 docforge slice 5 — editor pkg + variable catalogue SSOT (m/paliad#157)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-29 15:51:38 +02:00
mAi
b8709b903d feat(docforge): slice 5 — docforge-editor pkg + variable catalogue SSOT (t-paliad-349)
Establish the shared frontend editor package and make the Go resolvers the
single source of truth for variable labels.

Go — catalogue SSOT:
  - VariableResolver gains Keys() []VariableKey; ResolverSet gains
    Catalogue(). The 7 submission resolvers implement Keys() with the
    bilingual labels ported from the TS VARIABLE_LABELS table (incl. the
    legacy rule.* aliases). Keys() is entity-independent, so
    SubmissionVariableCatalogue() builds a metadata-only ResolverSet.
  - GET /api/docforge/variables serves the catalogue (auth-gated, static).
  - Tests: docforge ResolverSet (BuildBag merge + Catalogue order) and the
    submission catalogue integrity (no dupes, labels present, spot-checks).

Frontend — frontend/src/lib/docforge-editor/ (new shared package):
  - dom.ts: escapeHtml + cssEscape (pure), with bun tests. Dedupes the two
    identical escapeHtml/escapeHTML copies + the cssEscape copy that lived
    in the submission editor.
  - catalogue.ts: fetchVariableCatalogue() + labelMap() — the client for
    the Go catalogue.
  - submission-draft.ts now imports escapeHtml/cssEscape from the lib and
    fetches the catalogue on boot into state.varLabels (labelFor reads it,
    falling back to the raw key if the fetch fails — graceful degrade). The
    hardcoded VARIABLE_LABELS table is removed; VARIABLE_GROUPS stays
    (presentation: which keys to show + how to section them, legitimately
    frontend).

Scope note: the DOM-coupled editor plumbing (wireDraftVars/focus
preservation/autosave debounce) is extracted in slice 6 alongside its first
reuse — the authoring page — rather than speculatively now (extract with the
consumer; same principle as slices 2-3). Slice 5 lands the pure utilities +
the catalogue, which the slice-6 authoring palette consumes.

Verification: go build/vet/test green (Go files gofmt-clean; handlers.go
pre-existing drift, added region clean); bun run build.ts clean;
bun test 274/274 (incl. 5 new docforge-editor tests).

m/paliad#157
2026-05-29 15:50:42 +02:00
10 changed files with 446 additions and 102 deletions

View File

@@ -1,5 +1,7 @@
import { initI18n, t } from "./i18n"; import { initI18n, t } from "./i18n";
import { initSidebar } from "./sidebar"; import { initSidebar } from "./sidebar";
import { escapeHtml, cssEscape } from "../lib/docforge-editor/dom";
import { fetchVariableCatalogue, labelMap } from "../lib/docforge-editor/catalogue";
// t-paliad-238 Slice A — client bundle for the dedicated // t-paliad-238 Slice A — client bundle for the dedicated
// Submissions/Schriftsätze editor at // Submissions/Schriftsätze editor at
@@ -153,19 +155,16 @@ function isEN(): boolean {
return document.documentElement.lang === "en"; return document.documentElement.lang === "en";
} }
function escapeHtml(s: string): string { // escapeHtml + cssEscape now come from ../lib/docforge-editor/dom (the
return s // shared editor utilities); the local copies were removed in slice 5.
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
// Variable contract — DE/EN labels per dotted-path placeholder. // Variable contract — DE/EN labels per dotted-path placeholder.
// Mirrors the same shape the email-template variables sidebar uses; // Labels come from the Go-side catalogue (GET /api/docforge/variables),
// keeps the lawyer's mental model anchored on the same vocabulary. // fetched once on boot into state.varLabels. The frontend keeps only the
// presentation grouping (VARIABLE_GROUPS) — which keys to show and how to
// section them — not the label data itself, so labels can't drift from the
// resolvers that produce the values (t-paliad-349 slice 5).
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
interface VariableLabel { interface VariableLabel {
@@ -186,71 +185,6 @@ interface VariableGroup {
collapsedByDefault?: boolean; collapsedByDefault?: boolean;
} }
const VARIABLE_LABELS: Record<string, VariableLabel> = {
"firm.name": { de: "Kanzlei", en: "Firm" },
"firm.signature_block": { de: "Signatur-Block", en: "Signature block" },
"today": { de: "Heute", en: "Today" },
"today.iso": { de: "Heute (ISO)", en: "Today (ISO)" },
"today.long_de": { de: "Heute (DE lang)", en: "Today (DE long)" },
"today.long_en": { de: "Heute (EN lang)", en: "Today (EN long)" },
"user.display_name": { de: "Bearbeiter", en: "Author" },
"user.email": { de: "E-Mail", en: "Email" },
"user.office": { de: "Büro", en: "Office" },
"project.title": { de: "Projekttitel", en: "Project title" },
"project.reference": { de: "Aktenzeichen (intern)", en: "Internal reference" },
"project.case_number": { de: "Aktenzeichen (Gericht)", en: "Court case number" },
"project.court": { de: "Gericht", en: "Court" },
"project.patent_number": { de: "Patentnummer", en: "Patent number" },
"project.patent_number_upc": { de: "Patentnummer (UPC-Format)", en: "Patent number (UPC format)" },
"project.filing_date": { de: "Anmeldedatum", en: "Filing date" },
"project.grant_date": { de: "Erteilungsdatum", en: "Grant date" },
"project.our_side": { de: "Unsere Seite", en: "Our side" },
"project.our_side_de": { de: "Unsere Seite (DE)", en: "Our side (DE)" },
"project.our_side_en": { de: "Unsere Seite (EN)", en: "Our side (EN)" },
"project.instance_level": { de: "Instanz", en: "Instance" },
"project.client_number": { de: "Mandantennummer", en: "Client number" },
"project.matter_number": { de: "Matter-Nummer", en: "Matter number" },
"project.proceeding.code": { de: "Verfahrenstyp (Code)", en: "Proceeding type (code)" },
"project.proceeding.name": { de: "Verfahrenstyp", en: "Proceeding type" },
"project.proceeding.name_de": { de: "Verfahrenstyp (DE)", en: "Proceeding type (DE)" },
"project.proceeding.name_en": { de: "Verfahrenstyp (EN)", en: "Proceeding type (EN)" },
"parties.claimant.name": { de: "Klägerin", en: "Claimant" },
"parties.claimant.representative": { de: "Klägerin-Vertreter", en: "Claimant representative" },
"parties.defendant.name": { de: "Beklagte", en: "Defendant" },
"parties.defendant.representative":{ de: "Beklagten-Vertreter", en: "Defendant representative" },
"parties.other.name": { de: "Weitere Partei", en: "Other party" },
"parties.other.representative": { de: "Weitere-Partei-Vertreter", en: "Other party representative" },
// Procedural-event namespace (t-paliad-262 Slice A, design doc
// docs/design-procedural-events-model-2026-05-25.md). The canonical
// placeholder names are below; the `rule.*` aliases that follow are
// @deprecated but kept forever per m's Q7 lock — existing Word
// templates and saved drafts authored with the old names keep
// merging identically.
"procedural_event.code": { de: "Code (Verfahrensschritt)", en: "Code (procedural event)" },
"procedural_event.name": { de: "Verfahrensschritt", en: "Procedural event" },
"procedural_event.name_de": { de: "Verfahrensschritt (DE)", en: "Procedural event (DE)" },
"procedural_event.name_en": { de: "Verfahrensschritt (EN)", en: "Procedural event (EN)" },
"procedural_event.legal_source": { de: "Rechtsgrundlage (Code)", en: "Legal source (code)" },
"procedural_event.legal_source_pretty":{ de: "Rechtsgrundlage", en: "Legal source" },
"procedural_event.primary_party": { de: "Partei (typisch)", en: "Primary party" },
"procedural_event.event_kind": { de: "Art des Verfahrensschritts", en: "Procedural-event kind" },
// Legacy aliases — @deprecated, kept forever (m/paliad#93 Q7).
"rule.submission_code": { de: "Schriftsatz-Code (legacy)", en: "Submission code (legacy)" },
"rule.name": { de: "Schriftsatz (legacy)", en: "Submission (legacy)" },
"rule.name_de": { de: "Schriftsatz (DE, legacy)", en: "Submission (DE, legacy)" },
"rule.name_en": { de: "Schriftsatz (EN, legacy)", en: "Submission (EN, legacy)" },
"rule.legal_source": { de: "Rechtsgrundlage (Code, legacy)", en: "Legal source (code, legacy)" },
"rule.legal_source_pretty": { de: "Rechtsgrundlage (legacy)", en: "Legal source (legacy)" },
"rule.primary_party": { de: "Partei (typisch, legacy)", en: "Primary party (legacy)" },
"rule.event_type": { de: "Schriftsatz-Typ (legacy)", en: "Event type (legacy)" },
"deadline.due_date": { de: "Frist (ISO)", en: "Deadline (ISO)" },
"deadline.due_date_long_de": { de: "Frist (DE lang)", en: "Deadline (DE long)" },
"deadline.due_date_long_en": { de: "Frist (EN lang)", en: "Deadline (EN long)" },
"deadline.original_due_date": { de: "Ursprüngliche Frist", en: "Original deadline" },
"deadline.computed_from": { de: "Frist berechnet aus", en: "Deadline computed from" },
"deadline.title": { de: "Frist-Titel", en: "Deadline title" },
"deadline.source": { de: "Frist-Quelle", en: "Deadline source" },
};
// t-paliad-287 — variable groups restructured into four lawyer-facing // t-paliad-287 — variable groups restructured into four lawyer-facing
// sections: Mandant/Verfahren up top (the case identity), then Parteien // sections: Mandant/Verfahren up top (the case identity), then Parteien
@@ -341,7 +275,7 @@ const VARIABLE_GROUPS: VariableGroup[] = [
]; ];
function labelFor(key: string): string { function labelFor(key: string): string {
const entry = VARIABLE_LABELS[key]; const entry = state.varLabels[key];
if (!entry) return key; if (!entry) return key;
return isEN() ? entry.en : entry.de; return isEN() ? entry.en : entry.de;
} }
@@ -373,6 +307,12 @@ interface State {
// completes) keeps the picker hidden permanently for this load. // completes) keeps the picker hidden permanently for this load.
bases: SubmissionBaseRow[]; bases: SubmissionBaseRow[];
basesLoaded: boolean; basesLoaded: boolean;
// t-paliad-349 slice 5 — variable labels fetched once on boot from the
// Go catalogue (GET /api/docforge/variables), the single source of
// truth. Empty until the fetch lands; labelFor falls back to the raw
// key, so a failed fetch degrades gracefully rather than breaking the
// form.
varLabels: Record<string, VariableLabel>;
} }
type PartySide = "claimant" | "defendant" | "other"; type PartySide = "claimant" | "defendant" | "other";
@@ -401,6 +341,7 @@ const state: State = {
addPartyBusy: false, addPartyBusy: false,
bases: [], bases: [],
basesLoaded: false, basesLoaded: false,
varLabels: {},
}; };
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
@@ -426,6 +367,16 @@ async function boot(): Promise<void> {
state.basesLoaded = true; state.basesLoaded = true;
}); });
// t-paliad-349 slice 5 — load the variable-label catalogue (Go SSOT)
// before the first paint so the sidebar form labels render. Awaited
// because labelFor needs it at paint time; a failure leaves varLabels
// empty and labelFor falls back to the raw key (degraded but usable).
try {
state.varLabels = labelMap(await fetchVariableCatalogue());
} catch (err) {
console.warn("submission-draft: variable catalogue fetch failed", err);
}
try { try {
if (parsed.mode === "global") { if (parsed.mode === "global") {
// Global path: we have a draft_id, fetch by id alone. Drafts // Global path: we have a draft_id, fetch by id alone. Drafts
@@ -1985,11 +1936,11 @@ function paintPickerList(host: HTMLElement, blocks: BuildingBlockPickJSON[], sec
const preview = ((lang === "en" ? b.content_md_en : b.content_md_de) || "").slice(0, 200); const preview = ((lang === "en" ? b.content_md_en : b.content_md_de) || "").slice(0, 200);
row.innerHTML = ` row.innerHTML = `
<div class="submission-bb-picker-row-head"> <div class="submission-bb-picker-row-head">
<strong>${escapeHTML(title)}</strong> <strong>${escapeHtml(title)}</strong>
<span class="submission-bb-picker-vis submission-bb-picker-vis--${escapeHTML(b.visibility)}">${escapeHTML(b.visibility)}</span> <span class="submission-bb-picker-vis submission-bb-picker-vis--${escapeHtml(b.visibility)}">${escapeHtml(b.visibility)}</span>
</div> </div>
${desc ? `<div class="submission-bb-picker-row-desc">${escapeHTML(desc)}</div>` : ""} ${desc ? `<div class="submission-bb-picker-row-desc">${escapeHtml(desc)}</div>` : ""}
<pre class="submission-bb-picker-row-preview">${escapeHTML(preview)}${preview.length === 200 ? "…" : ""}</pre>`; <pre class="submission-bb-picker-row-preview">${escapeHtml(preview)}${preview.length === 200 ? "…" : ""}</pre>`;
row.addEventListener("click", () => { row.addEventListener("click", () => {
void insertBlockIntoSection(b.id, sec.id, overlay); void insertBlockIntoSection(b.id, sec.id, overlay);
}); });
@@ -2019,15 +1970,6 @@ async function insertBlockIntoSection(blockID: string, sectionID: string, overla
} }
} }
function escapeHTML(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
async function patchSection(sectionID: string, payload: Record<string, unknown>): Promise<void> { async function patchSection(sectionID: string, payload: Record<string, unknown>): Promise<void> {
try { try {
const draftID = state.view?.draft.id; const draftID = state.view?.draft.id;
@@ -2104,17 +2046,6 @@ function findVarInput(key: string): HTMLInputElement | null {
); );
} }
function cssEscape(s: string): string {
// CSS.escape covers our placeholder keys ([A-Za-z][A-Za-z0-9_.]*) but
// older browsers may lack it; defensive fallback escapes characters
// CSS treats as special. Placeholder keys never carry whitespace or
// quotes so escaping is straightforward.
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
return CSS.escape(s);
}
return s.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
}
function onDraftVarClick(key: string, ev: Event): void { function onDraftVarClick(key: string, ev: Event): void {
const input = findVarInput(key); const input = findVarInput(key);
if (!input) return; if (!input) return;

View File

@@ -0,0 +1,43 @@
// docforge-editor — the variable catalogue client.
//
// The catalogue (key + bilingual label + namespace group) is served by the
// Go backend at GET /api/docforge/variables, built from the resolvers'
// Keys() as the single source of truth. A consumer fetches it once and uses
// labelMap() to label its sidebar form + authoring palette, instead of
// hard-coding a parallel label table that can drift from the resolvers.
export interface VariableEntry {
key: string;
label_de: string;
label_en: string;
group: string;
}
interface VariablesResponse {
variables: VariableEntry[];
}
// fetchVariableCatalogue loads the catalogue from the backend. Throws on a
// non-2xx response so the caller can decide how to degrade.
export async function fetchVariableCatalogue(): Promise<VariableEntry[]> {
const res = await fetch("/api/docforge/variables", {
headers: { Accept: "application/json" },
});
if (!res.ok) {
throw new Error(`docforge variables: HTTP ${res.status}`);
}
const body = (await res.json()) as VariablesResponse;
return body.variables ?? [];
}
// labelMap turns a catalogue into a key → {de, en} lookup for a label
// function. Keys absent from the map fall back to the raw key at the call
// site, so a failed fetch degrades to dotted-key labels rather than a
// broken form.
export function labelMap(catalogue: VariableEntry[]): Record<string, { de: string; en: string }> {
const out: Record<string, { de: string; en: string }> = {};
for (const e of catalogue) {
out[e.key] = { de: e.label_de, en: e.label_en };
}
return out;
}

View File

@@ -0,0 +1,26 @@
import { test, expect } from "bun:test";
import { escapeHtml, cssEscape } from "./dom";
test("escapeHtml escapes the five HTML-significant characters", () => {
expect(escapeHtml(`<a href="x" title='y'>& z</a>`)).toBe(
"&lt;a href=&quot;x&quot; title=&#39;y&#39;&gt;&amp; z&lt;/a&gt;",
);
});
test("escapeHtml is a no-op on plain text", () => {
expect(escapeHtml("Aktenzeichen 4c O 12/23")).toBe("Aktenzeichen 4c O 12/23");
});
test("escapeHtml escapes & first to avoid double-encoding", () => {
expect(escapeHtml("&lt;")).toBe("&amp;lt;");
});
test("cssEscape backslash-escapes the dots in a placeholder key", () => {
// Both CSS.escape and the regex fallback escape '.' the same way, so the
// result is stable across environments (bun has no CSS global → fallback).
expect(cssEscape("project.case_number")).toBe("project\\.case_number");
});
test("cssEscape leaves identifier-safe characters untouched", () => {
expect(cssEscape("today")).toBe("today");
});

View File

@@ -0,0 +1,32 @@
// docforge-editor — shared, framework-agnostic editor utilities.
//
// Slice 5 of the docforge train (t-paliad-349 / m/paliad#157) begins
// extracting the generic editor plumbing out of the submission-specific
// client bundle so a second consumer (and the slice-6 authoring page) can
// reuse it. This module holds the pure DOM-string helpers — no DOM
// mutation, no editor state — so they unit-test cleanly under bun.
// escapeHtml escapes the five HTML-significant characters for safe
// insertion into element text or an attribute value. Matches the
// server-side emitTextWithDraftVars/htmlEscape contract so preview markup
// round-trips identically.
export function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// cssEscape escapes a string for use inside a CSS attribute selector
// (e.g. `[data-var="${cssEscape(key)}"]`). Prefers the native CSS.escape
// and falls back to escaping CSS-special characters for older runtimes.
// Placeholder keys ([A-Za-z][A-Za-z0-9_.]*) never carry whitespace or
// quotes, so the fallback is straightforward.
export function cssEscape(s: string): string {
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
return CSS.escape(s);
}
return s.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
}

View File

@@ -0,0 +1,48 @@
package handlers
// docforge variable catalogue handler (t-paliad-349 slice 5).
//
// Endpoint: GET /api/docforge/variables → the full variable catalogue
// (key + bilingual label + namespace group) the sidebar form and the
// authoring palette render. The catalogue is the Go-side single source of
// truth, built from the submission resolvers' Keys(); it replaces the
// duplicated TS VARIABLE_LABELS table so labels can't drift between the
// resolver that produces a value and the form that labels it.
//
// Static — no DB call, no per-user state. Auth-gated only (anonymous 401);
// the catalogue is the same for every authenticated user.
import (
"net/http"
"mgit.msbls.de/m/paliad/internal/services"
)
type docforgeVariablesResponse struct {
Variables []variableEntry `json:"variables"`
}
type variableEntry struct {
Key string `json:"key"`
LabelDE string `json:"label_de"`
LabelEN string `json:"label_en"`
Group string `json:"group"`
}
// handleDocforgeVariables backs GET /api/docforge/variables.
func handleDocforgeVariables(w http.ResponseWriter, r *http.Request) {
if _, ok := requireUser(w, r); !ok {
return
}
cat := services.SubmissionVariableCatalogue()
out := make([]variableEntry, 0, len(cat))
for _, e := range cat {
out = append(out, variableEntry{
Key: e.Key,
LabelDE: e.LabelDE,
LabelEN: e.LabelEN,
Group: e.Group,
})
}
writeJSON(w, http.StatusOK, docforgeVariablesResponse{Variables: out})
}

View File

@@ -455,6 +455,9 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
// the sidebar picker. Wide-open SELECT (any authenticated user); // the sidebar picker. Wide-open SELECT (any authenticated user);
// admin mutations are not exposed yet (Slice C). // admin mutations are not exposed yet (Slice C).
protected.HandleFunc("GET /api/submission-bases", handleListSubmissionBases) protected.HandleFunc("GET /api/submission-bases", handleListSubmissionBases)
// t-paliad-349 (m/paliad#157) docforge slice 5 — the variable
// catalogue (Go-side SSOT) the sidebar form + authoring palette read.
protected.HandleFunc("GET /api/docforge/variables", handleDocforgeVariables)
// t-paliad-313 (m/paliad#141) Composer Slice B — per-section PATCH // t-paliad-313 (m/paliad#141) Composer Slice B — per-section PATCH
// for inline editor autosave. URL keyed on draft_id + section_id; // for inline editor autosave. URL keyed on draft_id + section_id;
// owner-scoped via SubmissionDraftService.Get. // owner-scoped via SubmissionDraftService.Get.

View File

@@ -0,0 +1,55 @@
package services
import "testing"
// The variable catalogue is the single source of truth for the sidebar
// form + authoring palette labels (t-paliad-349 slice 5). These checks
// pin its integrity so a resolver Keys() edit can't silently ship a
// malformed entry or a duplicate key.
func TestSubmissionVariableCatalogue(t *testing.T) {
cat := SubmissionVariableCatalogue()
if len(cat) == 0 {
t.Fatal("catalogue is empty")
}
seen := map[string]bool{}
for _, e := range cat {
if e.Key == "" || e.LabelDE == "" || e.LabelEN == "" || e.Group == "" {
t.Errorf("incomplete catalogue entry: %+v", e)
}
if seen[e.Key] {
t.Errorf("duplicate catalogue key: %q", e.Key)
}
seen[e.Key] = true
}
// Spot-check one key per namespace resolves with the expected label.
want := map[string]struct{ group, de string }{
"firm.name": {"firm", "Kanzlei"},
"today.long_de": {"today", "Heute (DE lang)"},
"user.display_name": {"user", "Bearbeiter"},
"project.case_number": {"project", "Aktenzeichen (Gericht)"},
"parties.claimant.name": {"parties", "Klägerin"},
"procedural_event.legal_source_pretty": {"procedural_event", "Rechtsgrundlage"},
"deadline.due_date": {"deadline", "Frist (ISO)"},
}
byKey := map[string]struct{ group, de string }{}
for _, e := range cat {
byKey[e.Key] = struct{ group, de string }{e.Group, e.LabelDE}
}
for k, exp := range want {
got, ok := byKey[k]
if !ok {
t.Errorf("catalogue missing expected key %q", k)
continue
}
if got.group != exp.group || got.de != exp.de {
t.Errorf("catalogue[%q] = {%q, %q}; want {%q, %q}", k, got.group, got.de, exp.group, exp.de)
}
}
// The legacy rule.* aliases must be present for labelFor coverage.
if !seen["rule.name"] || !seen["rule.legal_source_pretty"] {
t.Error("legacy rule.* aliases missing from catalogue")
}
}

View File

@@ -30,23 +30,67 @@ var (
_ docforge.VariableResolver = deadlineResolver{} _ docforge.VariableResolver = deadlineResolver{}
) )
// vk is a terse constructor for a catalogue entry in the given group.
func vk(group, key, de, en string) docforge.VariableKey {
return docforge.VariableKey{Key: key, LabelDE: de, LabelEN: en, Group: group}
}
// SubmissionVariableCatalogue returns the full variable catalogue for the
// submission resolvers — every (key, DE/EN label, namespace) the sidebar
// form and the authoring palette can offer. Built from the resolvers'
// Keys() with no entity state, so it needs no DB call. This is the single
// source of truth for variable labels, replacing the duplicated TS
// VARIABLE_LABELS table (t-paliad-349 slice 5).
func SubmissionVariableCatalogue() []docforge.VariableKey {
return docforge.NewResolverSet(
firmResolver{},
todayResolver{},
userResolver{},
proceduralEventResolver{},
projectResolver{},
partiesResolver{},
deadlineResolver{},
).Catalogue()
}
// firmResolver populates firm.* from process-wide branding. // firmResolver populates firm.* from process-wide branding.
type firmResolver struct{} type firmResolver struct{}
func (firmResolver) Namespace() string { return "firm" } func (firmResolver) Namespace() string { return "firm" }
func (firmResolver) Populate(bag PlaceholderMap) { addFirmVars(bag) } func (firmResolver) Populate(bag PlaceholderMap) { addFirmVars(bag) }
func (firmResolver) Keys() []docforge.VariableKey {
return []docforge.VariableKey{
vk("firm", "firm.name", "Kanzlei", "Firm"),
vk("firm", "firm.signature_block", "Signatur-Block", "Signature block"),
}
}
// todayResolver populates today.* from the build-time clock. // todayResolver populates today.* from the build-time clock.
type todayResolver struct{ now time.Time } type todayResolver struct{ now time.Time }
func (todayResolver) Namespace() string { return "today" } func (todayResolver) Namespace() string { return "today" }
func (r todayResolver) Populate(bag PlaceholderMap) { addTodayVars(bag, r.now) } func (r todayResolver) Populate(bag PlaceholderMap) { addTodayVars(bag, r.now) }
func (todayResolver) Keys() []docforge.VariableKey {
return []docforge.VariableKey{
vk("today", "today", "Heute", "Today"),
vk("today", "today.iso", "Heute (ISO)", "Today (ISO)"),
vk("today", "today.long_de", "Heute (DE lang)", "Today (DE long)"),
vk("today", "today.long_en", "Heute (EN lang)", "Today (EN long)"),
}
}
// userResolver populates user.* from the caller's row. // userResolver populates user.* from the caller's row.
type userResolver struct{ user *models.User } type userResolver struct{ user *models.User }
func (userResolver) Namespace() string { return "user" } func (userResolver) Namespace() string { return "user" }
func (r userResolver) Populate(bag PlaceholderMap) { addUserVars(bag, r.user) } func (r userResolver) Populate(bag PlaceholderMap) { addUserVars(bag, r.user) }
func (userResolver) Keys() []docforge.VariableKey {
return []docforge.VariableKey{
vk("user", "user.display_name", "Bearbeiter", "Author"),
vk("user", "user.email", "E-Mail", "Email"),
vk("user", "user.office", "Büro", "Office"),
}
}
// proceduralEventResolver populates procedural_event.* and the legacy // proceduralEventResolver populates procedural_event.* and the legacy
// rule.* alias from the published deadline_rule. // rule.* alias from the published deadline_rule.
@@ -57,6 +101,27 @@ type proceduralEventResolver struct {
func (proceduralEventResolver) Namespace() string { return "procedural_event" } func (proceduralEventResolver) Namespace() string { return "procedural_event" }
func (r proceduralEventResolver) Populate(bag PlaceholderMap) { addRuleVars(bag, r.rule, r.lang) } func (r proceduralEventResolver) Populate(bag PlaceholderMap) { addRuleVars(bag, r.rule, r.lang) }
func (proceduralEventResolver) Keys() []docforge.VariableKey {
return []docforge.VariableKey{
vk("procedural_event", "procedural_event.code", "Code (Verfahrensschritt)", "Code (procedural event)"),
vk("procedural_event", "procedural_event.name", "Verfahrensschritt", "Procedural event"),
vk("procedural_event", "procedural_event.name_de", "Verfahrensschritt (DE)", "Procedural event (DE)"),
vk("procedural_event", "procedural_event.name_en", "Verfahrensschritt (EN)", "Procedural event (EN)"),
vk("procedural_event", "procedural_event.legal_source", "Rechtsgrundlage (Code)", "Legal source (code)"),
vk("procedural_event", "procedural_event.legal_source_pretty", "Rechtsgrundlage", "Legal source"),
vk("procedural_event", "procedural_event.primary_party", "Partei (typisch)", "Primary party"),
vk("procedural_event", "procedural_event.event_kind", "Art des Verfahrensschritts", "Procedural-event kind"),
// Legacy rule.* aliases — @deprecated, kept forever (m/paliad#93 Q7).
vk("procedural_event", "rule.submission_code", "Schriftsatz-Code (legacy)", "Submission code (legacy)"),
vk("procedural_event", "rule.name", "Schriftsatz (legacy)", "Submission (legacy)"),
vk("procedural_event", "rule.name_de", "Schriftsatz (DE, legacy)", "Submission (DE, legacy)"),
vk("procedural_event", "rule.name_en", "Schriftsatz (EN, legacy)", "Submission (EN, legacy)"),
vk("procedural_event", "rule.legal_source", "Rechtsgrundlage (Code, legacy)", "Legal source (code, legacy)"),
vk("procedural_event", "rule.legal_source_pretty", "Rechtsgrundlage (legacy)", "Legal source (legacy)"),
vk("procedural_event", "rule.primary_party", "Partei (typisch, legacy)", "Primary party (legacy)"),
vk("procedural_event", "rule.event_type", "Schriftsatz-Typ (legacy)", "Event type (legacy)"),
}
}
// projectResolver populates project.* from the project + its proceeding type. // projectResolver populates project.* from the project + its proceeding type.
type projectResolver struct { type projectResolver struct {
@@ -67,6 +132,28 @@ type projectResolver struct {
func (projectResolver) Namespace() string { return "project" } func (projectResolver) Namespace() string { return "project" }
func (r projectResolver) Populate(bag PlaceholderMap) { addProjectVars(bag, r.project, r.pt, r.lang) } func (r projectResolver) Populate(bag PlaceholderMap) { addProjectVars(bag, r.project, r.pt, r.lang) }
func (projectResolver) Keys() []docforge.VariableKey {
return []docforge.VariableKey{
vk("project", "project.title", "Projekttitel", "Project title"),
vk("project", "project.reference", "Aktenzeichen (intern)", "Internal reference"),
vk("project", "project.case_number", "Aktenzeichen (Gericht)", "Court case number"),
vk("project", "project.court", "Gericht", "Court"),
vk("project", "project.patent_number", "Patentnummer", "Patent number"),
vk("project", "project.patent_number_upc", "Patentnummer (UPC-Format)", "Patent number (UPC format)"),
vk("project", "project.filing_date", "Anmeldedatum", "Filing date"),
vk("project", "project.grant_date", "Erteilungsdatum", "Grant date"),
vk("project", "project.our_side", "Unsere Seite", "Our side"),
vk("project", "project.our_side_de", "Unsere Seite (DE)", "Our side (DE)"),
vk("project", "project.our_side_en", "Unsere Seite (EN)", "Our side (EN)"),
vk("project", "project.instance_level", "Instanz", "Instance"),
vk("project", "project.client_number", "Mandantennummer", "Client number"),
vk("project", "project.matter_number", "Matter-Nummer", "Matter number"),
vk("project", "project.proceeding.code", "Verfahrenstyp (Code)", "Proceeding type (code)"),
vk("project", "project.proceeding.name", "Verfahrenstyp", "Proceeding type"),
vk("project", "project.proceeding.name_de", "Verfahrenstyp (DE)", "Proceeding type (DE)"),
vk("project", "project.proceeding.name_en", "Verfahrenstyp (EN)", "Proceeding type (EN)"),
}
}
// partiesResolver populates parties.* from the (already filtered) party list. // partiesResolver populates parties.* from the (already filtered) party list.
type partiesResolver struct{ parties []models.Party } type partiesResolver struct{ parties []models.Party }
@@ -74,6 +161,21 @@ type partiesResolver struct{ parties []models.Party }
func (partiesResolver) Namespace() string { return "parties" } func (partiesResolver) Namespace() string { return "parties" }
func (r partiesResolver) Populate(bag PlaceholderMap) { addPartyVars(bag, r.parties) } func (r partiesResolver) Populate(bag PlaceholderMap) { addPartyVars(bag, r.parties) }
// Keys returns the flat, user-facing party forms (the power-user override
// rows the sidebar shows). The indexed (parties.claimant.0.name) and
// joined (parties.claimants) forms Populate also emits are not catalogue
// entries — they're resolved into the bag but not offered in the palette.
func (partiesResolver) Keys() []docforge.VariableKey {
return []docforge.VariableKey{
vk("parties", "parties.claimant.name", "Klägerin", "Claimant"),
vk("parties", "parties.claimant.representative", "Klägerin-Vertreter", "Claimant representative"),
vk("parties", "parties.defendant.name", "Beklagte", "Defendant"),
vk("parties", "parties.defendant.representative", "Beklagten-Vertreter", "Defendant representative"),
vk("parties", "parties.other.name", "Weitere Partei", "Other party"),
vk("parties", "parties.other.representative", "Weitere-Partei-Vertreter", "Other party representative"),
}
}
// deadlineResolver populates deadline.* from the next pending deadline. // deadlineResolver populates deadline.* from the next pending deadline.
type deadlineResolver struct { type deadlineResolver struct {
deadline *models.Deadline deadline *models.Deadline
@@ -85,3 +187,14 @@ func (deadlineResolver) Namespace() string { return "deadline" }
func (r deadlineResolver) Populate(bag PlaceholderMap) { func (r deadlineResolver) Populate(bag PlaceholderMap) {
addDeadlineVars(bag, r.deadline, r.project, r.lang) addDeadlineVars(bag, r.deadline, r.project, r.lang)
} }
func (deadlineResolver) Keys() []docforge.VariableKey {
return []docforge.VariableKey{
vk("deadline", "deadline.due_date", "Frist (ISO)", "Deadline (ISO)"),
vk("deadline", "deadline.due_date_long_de", "Frist (DE lang)", "Deadline (DE long)"),
vk("deadline", "deadline.due_date_long_en", "Frist (EN lang)", "Deadline (EN long)"),
vk("deadline", "deadline.original_due_date", "Ursprüngliche Frist", "Original deadline"),
vk("deadline", "deadline.computed_from", "Frist berechnet aus", "Deadline computed from"),
vk("deadline", "deadline.title", "Frist-Titel", "Deadline title"),
vk("deadline", "deadline.source", "Frist-Quelle", "Deadline source"),
}
}

View File

@@ -18,14 +18,34 @@ package docforge
// engine. // engine.
type VariableResolver interface { type VariableResolver interface {
// Namespace returns the dotted prefix this resolver owns, e.g. // Namespace returns the dotted prefix this resolver owns, e.g.
// "project". Informational — used for diagnostics and (later) the // "project". Informational — used for diagnostics and as the default
// authoring variable palette's grouping. // group for this resolver's catalogue entries.
Namespace() string Namespace() string
// Populate writes this resolver's keys into bag. Resolvers own // Populate writes this resolver's keys into bag. Resolvers own
// disjoint namespaces, so population order across resolvers does not // disjoint namespaces, so population order across resolvers does not
// affect the final bag. // affect the final bag.
Populate(bag PlaceholderMap) Populate(bag PlaceholderMap)
// Keys returns the user-facing catalogue entries for this resolver —
// the variables an authoring palette can offer and a sidebar form can
// render, each with its bilingual label. This is the curated, static
// surface (e.g. the flat parties.claimant.name form), not the full
// possibly-dynamic key set Populate emits (e.g. the indexed
// parties.claimant.0.name). Go owns these labels so the frontend form
// and the authoring palette read one source of truth instead of a
// duplicated TS table.
Keys() []VariableKey
}
// VariableKey is one catalogue entry: the placeholder key plus its
// bilingual label and a group (the owning namespace by default). The
// frontend maps groups onto its own lawyer-facing presentation sections.
type VariableKey struct {
Key string `json:"key"`
LabelDE string `json:"label_de"`
LabelEN string `json:"label_en"`
Group string `json:"group"`
} }
// ResolverSet composes an ordered list of VariableResolvers into a single // ResolverSet composes an ordered list of VariableResolvers into a single
@@ -54,3 +74,16 @@ func (s *ResolverSet) BuildBag() PlaceholderMap {
} }
return bag return bag
} }
// Catalogue concatenates every resolver's Keys() in resolver order — the
// full set of user-facing variables for a palette or form, with bilingual
// labels. It does not require any per-call entity state, so a consumer can
// build a metadata-only ResolverSet (resolvers constructed with nil
// entities) purely to serve the catalogue.
func (s *ResolverSet) Catalogue() []VariableKey {
var out []VariableKey
for _, r := range s.resolvers {
out = append(out, r.Keys()...)
}
return out
}

60
pkg/docforge/vars_test.go Normal file
View File

@@ -0,0 +1,60 @@
package docforge
import "testing"
// fakeResolver is a test double: it owns a namespace, populates a fixed
// set of key/value pairs, and advertises a fixed catalogue.
type fakeResolver struct {
ns string
values map[string]string
catalog []VariableKey
}
func (f fakeResolver) Namespace() string { return f.ns }
func (f fakeResolver) Keys() []VariableKey { return f.catalog }
func (f fakeResolver) Populate(bag PlaceholderMap) {
for k, v := range f.values {
bag[k] = v
}
}
func TestResolverSet_BuildBagMergesDisjointNamespaces(t *testing.T) {
set := NewResolverSet(
fakeResolver{ns: "a", values: map[string]string{"a.x": "1", "a.y": "2"}},
fakeResolver{ns: "b", values: map[string]string{"b.z": "3"}},
)
bag := set.BuildBag()
if len(bag) != 3 {
t.Fatalf("bag size = %d; want 3", len(bag))
}
for k, want := range map[string]string{"a.x": "1", "a.y": "2", "b.z": "3"} {
if bag[k] != want {
t.Errorf("bag[%q] = %q; want %q", k, bag[k], want)
}
}
}
func TestResolverSet_AddAndCatalogueOrder(t *testing.T) {
set := NewResolverSet(
fakeResolver{ns: "a", catalog: []VariableKey{{Key: "a.x", Group: "a"}}},
)
set.Add(fakeResolver{ns: "b", catalog: []VariableKey{
{Key: "b.y", Group: "b"},
{Key: "b.z", Group: "b"},
}})
cat := set.Catalogue()
gotOrder := make([]string, len(cat))
for i, e := range cat {
gotOrder[i] = e.Key
}
want := []string{"a.x", "b.y", "b.z"} // resolver order, then Keys() order
if len(gotOrder) != len(want) {
t.Fatalf("catalogue len = %d; want %d", len(gotOrder), len(want))
}
for i := range want {
if gotOrder[i] != want[i] {
t.Errorf("catalogue[%d] = %q; want %q", i, gotOrder[i], want[i])
}
}
}