diff --git a/frontend/src/client/submission-draft.ts b/frontend/src/client/submission-draft.ts index 228f875..b76d899 100644 --- a/frontend/src/client/submission-draft.ts +++ b/frontend/src/client/submission-draft.ts @@ -1,5 +1,7 @@ import { initI18n, t } from "./i18n"; 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 // Submissions/Schriftsätze editor at @@ -153,19 +155,16 @@ function isEN(): boolean { return document.documentElement.lang === "en"; } -function escapeHtml(s: string): string { - return s - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} +// escapeHtml + cssEscape now come from ../lib/docforge-editor/dom (the +// shared editor utilities); the local copies were removed in slice 5. // ───────────────────────────────────────────────────────────────────── // Variable contract — DE/EN labels per dotted-path placeholder. -// Mirrors the same shape the email-template variables sidebar uses; -// keeps the lawyer's mental model anchored on the same vocabulary. +// Labels come from the Go-side catalogue (GET /api/docforge/variables), +// 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 { @@ -186,71 +185,6 @@ interface VariableGroup { collapsedByDefault?: boolean; } -const VARIABLE_LABELS: Record = { - "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 // sections: Mandant/Verfahren up top (the case identity), then Parteien @@ -341,7 +275,7 @@ const VARIABLE_GROUPS: VariableGroup[] = [ ]; function labelFor(key: string): string { - const entry = VARIABLE_LABELS[key]; + const entry = state.varLabels[key]; if (!entry) return key; return isEN() ? entry.en : entry.de; } @@ -373,6 +307,12 @@ interface State { // completes) keeps the picker hidden permanently for this load. bases: SubmissionBaseRow[]; 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; } type PartySide = "claimant" | "defendant" | "other"; @@ -401,6 +341,7 @@ const state: State = { addPartyBusy: false, bases: [], basesLoaded: false, + varLabels: {}, }; // ───────────────────────────────────────────────────────────────────── @@ -426,6 +367,16 @@ async function boot(): Promise { 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 { if (parsed.mode === "global") { // 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); row.innerHTML = `
- ${escapeHTML(title)} - ${escapeHTML(b.visibility)} + ${escapeHtml(title)} + ${escapeHtml(b.visibility)}
- ${desc ? `
${escapeHTML(desc)}
` : ""} -
${escapeHTML(preview)}${preview.length === 200 ? "…" : ""}
`; + ${desc ? `
${escapeHtml(desc)}
` : ""} +
${escapeHtml(preview)}${preview.length === 200 ? "…" : ""}
`; row.addEventListener("click", () => { 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, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - async function patchSection(sectionID: string, payload: Record): Promise { try { 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 { const input = findVarInput(key); if (!input) return; diff --git a/frontend/src/lib/docforge-editor/catalogue.ts b/frontend/src/lib/docforge-editor/catalogue.ts new file mode 100644 index 0000000..8b98ca5 --- /dev/null +++ b/frontend/src/lib/docforge-editor/catalogue.ts @@ -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 { + 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 { + const out: Record = {}; + for (const e of catalogue) { + out[e.key] = { de: e.label_de, en: e.label_en }; + } + return out; +} diff --git a/frontend/src/lib/docforge-editor/dom.test.ts b/frontend/src/lib/docforge-editor/dom.test.ts new file mode 100644 index 0000000..1cfb8dc --- /dev/null +++ b/frontend/src/lib/docforge-editor/dom.test.ts @@ -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(`& z`)).toBe( + "<a href="x" title='y'>& z</a>", + ); +}); + +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("<")).toBe("&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"); +}); diff --git a/frontend/src/lib/docforge-editor/dom.ts b/frontend/src/lib/docforge-editor/dom.ts new file mode 100644 index 0000000..824495f --- /dev/null +++ b/frontend/src/lib/docforge-editor/dom.ts @@ -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, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +// 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"); +} diff --git a/internal/handlers/docforge_variables.go b/internal/handlers/docforge_variables.go new file mode 100644 index 0000000..bb64ce7 --- /dev/null +++ b/internal/handlers/docforge_variables.go @@ -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}) +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index fd68873..b469028 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -455,6 +455,9 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc // the sidebar picker. Wide-open SELECT (any authenticated user); // admin mutations are not exposed yet (Slice C). 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 // for inline editor autosave. URL keyed on draft_id + section_id; // owner-scoped via SubmissionDraftService.Get. diff --git a/internal/services/submission_vars_catalogue_test.go b/internal/services/submission_vars_catalogue_test.go new file mode 100644 index 0000000..7aac769 --- /dev/null +++ b/internal/services/submission_vars_catalogue_test.go @@ -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") + } +} diff --git a/internal/services/submission_vars_resolvers.go b/internal/services/submission_vars_resolvers.go index 05e6d39..3dba11c 100644 --- a/internal/services/submission_vars_resolvers.go +++ b/internal/services/submission_vars_resolvers.go @@ -30,23 +30,67 @@ var ( _ 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. type firmResolver struct{} func (firmResolver) Namespace() string { return "firm" } 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. type todayResolver struct{ now time.Time } func (todayResolver) Namespace() string { return "today" } 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. type userResolver struct{ user *models.User } func (userResolver) Namespace() string { return "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 // rule.* alias from the published deadline_rule. @@ -57,6 +101,27 @@ type proceduralEventResolver struct { func (proceduralEventResolver) Namespace() string { return "procedural_event" } 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. type projectResolver struct { @@ -67,6 +132,28 @@ type projectResolver struct { func (projectResolver) Namespace() string { return "project" } 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. type partiesResolver struct{ parties []models.Party } @@ -74,6 +161,21 @@ type partiesResolver struct{ parties []models.Party } func (partiesResolver) Namespace() string { return "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. type deadlineResolver struct { deadline *models.Deadline @@ -85,3 +187,14 @@ func (deadlineResolver) Namespace() string { return "deadline" } func (r deadlineResolver) Populate(bag PlaceholderMap) { 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"), + } +} diff --git a/pkg/docforge/vars.go b/pkg/docforge/vars.go index 8b18179..c94c0bb 100644 --- a/pkg/docforge/vars.go +++ b/pkg/docforge/vars.go @@ -18,14 +18,34 @@ package docforge // 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. + // "project". Informational — used for diagnostics and as the default + // group for this resolver's catalogue entries. 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) + + // 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 @@ -54,3 +74,16 @@ func (s *ResolverSet) BuildBag() PlaceholderMap { } 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 +} diff --git a/pkg/docforge/vars_test.go b/pkg/docforge/vars_test.go new file mode 100644 index 0000000..a76fe7b --- /dev/null +++ b/pkg/docforge/vars_test.go @@ -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]) + } + } +}