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
This commit is contained in:
@@ -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, """)
|
||||
.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<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
|
||||
// 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<string, VariableLabel>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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 = `
|
||||
<div class="submission-bb-picker-row-head">
|
||||
<strong>${escapeHTML(title)}</strong>
|
||||
<span class="submission-bb-picker-vis submission-bb-picker-vis--${escapeHTML(b.visibility)}">${escapeHTML(b.visibility)}</span>
|
||||
<strong>${escapeHtml(title)}</strong>
|
||||
<span class="submission-bb-picker-vis submission-bb-picker-vis--${escapeHtml(b.visibility)}">${escapeHtml(b.visibility)}</span>
|
||||
</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>`;
|
||||
${desc ? `<div class="submission-bb-picker-row-desc">${escapeHtml(desc)}</div>` : ""}
|
||||
<pre class="submission-bb-picker-row-preview">${escapeHtml(preview)}${preview.length === 200 ? "…" : ""}</pre>`;
|
||||
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, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
async function patchSection(sectionID: string, payload: Record<string, unknown>): Promise<void> {
|
||||
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;
|
||||
|
||||
43
frontend/src/lib/docforge-editor/catalogue.ts
Normal file
43
frontend/src/lib/docforge-editor/catalogue.ts
Normal 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;
|
||||
}
|
||||
26
frontend/src/lib/docforge-editor/dom.test.ts
Normal file
26
frontend/src/lib/docforge-editor/dom.test.ts
Normal 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(
|
||||
"<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");
|
||||
});
|
||||
32
frontend/src/lib/docforge-editor/dom.ts
Normal file
32
frontend/src/lib/docforge-editor/dom.ts
Normal 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, "&")
|
||||
.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");
|
||||
}
|
||||
48
internal/handlers/docforge_variables.go
Normal file
48
internal/handlers/docforge_variables.go
Normal 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})
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
55
internal/services/submission_vars_catalogue_test.go
Normal file
55
internal/services/submission_vars_catalogue_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
60
pkg/docforge/vars_test.go
Normal file
60
pkg/docforge/vars_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user