Compare commits

..

1 Commits

Author SHA1 Message Date
mAi
4f910e31ea mAi: #110 - t-paliad-278 — 3-column date-range picker (Past/NOW/Future, closeness-to-NOW sort)
Restructures atlas's #79 horizontal row into 3 vertical columns: Past
(left), NOW (middle), Future (right). Each column sorts by closeness
to NOW (closest at top, farthest at bottom) — the picker now reads as
a spatial map of time around the current moment instead of a flat
horizontal fan.

Layout

  Vergangenheit          ⌖              Zukunft
  Letzte 7 Tage          Heute          Nächste 7 Tage
  Letzte 30 Tage         Alles          Nächste 30 Tage
  Letzte 90 Tage                        Nächste 90 Tage
  Ganze Vergangenheit                   Ganze Zukunft

Changes

- date-range-picker.ts — renderPanel builds .date-range-grid with
  three vertical .date-range-col children. Past column iterates
  PAST_HORIZONS reversed (past_1d → past_all top-to-bottom). NOW
  column hosts next_1d ("Heute") + any ("Alles") plus a ⌖ glyph
  header. Future column iterates NEXT_HORIZONS minus next_1d (which
  moved to NOW). Legacy "all" horizon still lights up the Alles chip
  for saved-Custom-View back-compat.
- global.css — replace .date-range-row/.date-range-fan/.date-range-
  center{,-btn,-glyph,-label} with .date-range-grid + .date-range-col
  + .date-range-col-heading. Chips stretch to 100% column width for a
  clean vertical stack. Panel widened from 32rem to 34rem so "Ganze
  Vergangenheit" never wraps. Mobile (max-width 540px) collapses the
  grid to a single column, preserving in-column sort.
- i18n.ts — next_1d label fixed from "Morgen"/"Tomorrow" to "Heute"/
  "Today". next_1d's bounds are [today, tomorrow) = single-day today,
  so the prior label was semantically wrong; renaming aligns the
  label with the bounds and matches m's "Heute" spec for the NOW
  column.
- axes.ts — DEFAULT_TIME_PRESETS updated to match m's spec (4 past +
  Heute + Alles + 4 future + custom). projects-detail.ts continues
  to override via timePresets for its past-only Verlauf surface.

12 horizon values in the union remain unchanged — PAST_HORIZONS /
NEXT_HORIZONS registries and parseURL still accept past_1d / past_14d
/ next_14d for back-compat with saved URLs; the default picker UI
just no longer surfaces chips for them. Surfaces that want the
finer granularity can opt back in via timePresets.

Verification

- bun test src/client/date-range-picker-pure.test.ts — 38 pass
- bun run build — i18n + branding + bundle clean
- go build ./... — clean
- go test ./internal/... — pass
2026-05-25 16:45:30 +02:00
17 changed files with 162 additions and 1260 deletions

View File

@@ -191,25 +191,37 @@ export function mountDateRangePicker(opts: MountOpts): PickerHandle {
function renderPanel(): void {
panel.replaceChildren();
// Three groups in a single row: past fan / ALLES centre / next fan.
const row = document.createElement("div");
row.className = "date-range-row";
// Three vertical columns: Past (closest→farthest top→bottom),
// NOW (Heute + Alles), Future (closest→farthest). The grid
// visualises time as space around NOW — each column's top is
// closest to the current moment, bottom is furthest away.
const grid = document.createElement("div");
grid.className = "date-range-grid";
const pastGroup = renderFan(
PAST_HORIZONS.filter((h) => presets.includes(h)),
// Past column: PAST_HORIZONS registry is outermost→innermost
// (past_all → past_1d); reverse for closeness-to-NOW ordering
// (past_1d at top, past_all at bottom).
const pastCol = renderColumn(
"past",
t("date_range.fan.past.label"),
[...PAST_HORIZONS].reverse().filter((h) => presets.includes(h)),
);
const centerGroup = renderCenter();
const nextGroup = renderFan(
NEXT_HORIZONS.filter((h) => presets.includes(h)),
"next",
const nowCol = renderNowColumn();
// Future column: NEXT_HORIZONS registry is already in closeness
// order (next_1d → next_all). next_1d moves to the NOW column as
// "Heute" (semantically just-today, single-day window), so the
// future column skips it.
const futureCol = renderColumn(
"future",
t("date_range.fan.future.label"),
NEXT_HORIZONS.filter((h) => h !== "next_1d" && presets.includes(h)),
);
if (pastGroup) row.appendChild(pastGroup);
if (centerGroup) row.appendChild(centerGroup);
if (nextGroup) row.appendChild(nextGroup);
if (pastCol) grid.appendChild(pastCol);
if (nowCol) grid.appendChild(nowCol);
if (futureCol) grid.appendChild(futureCol);
panel.appendChild(row);
panel.appendChild(grid);
// Custom-range section ("Anpassen"). Toggle button + collapsible
// date-pair editor below.
@@ -218,49 +230,57 @@ export function mountDateRangePicker(opts: MountOpts): PickerHandle {
}
}
function renderFan(horizons: readonly TimeHorizon[], side: "past" | "next"): HTMLElement | null {
function renderColumn(
side: "past" | "future",
heading: string,
horizons: readonly TimeHorizon[],
): HTMLElement | null {
if (horizons.length === 0) return null;
const group = document.createElement("div");
group.className = `date-range-fan date-range-fan--${side}`;
group.setAttribute("role", "group");
group.setAttribute("aria-label", side === "past"
? t("date_range.fan.past.label")
: t("date_range.fan.future.label"));
const col = document.createElement("div");
col.className = `date-range-col date-range-col--${side}`;
col.setAttribute("role", "group");
col.setAttribute("aria-label", heading);
const head = document.createElement("div");
head.className = "date-range-col-heading";
head.textContent = heading;
col.appendChild(head);
for (const h of horizons) {
group.appendChild(makeChip(h));
col.appendChild(makeChip(h));
}
return group;
return col;
}
function renderCenter(): HTMLElement | null {
if (!presets.includes("any")) return null;
const wrap = document.createElement("div");
wrap.className = "date-range-center";
const btn = document.createElement("button");
btn.type = "button";
btn.className = "date-range-center-btn";
if (value.horizon === "any" || value.horizon === "all") {
btn.classList.add("date-range-center-btn--active");
}
btn.setAttribute("aria-pressed", String(value.horizon === "any" || value.horizon === "all"));
btn.dataset.testid = `${opts.surface}.date-range-chip.any`;
function renderNowColumn(): HTMLElement | null {
const showHeute = presets.includes("next_1d");
const showAlles = presets.includes("any");
if (!showHeute && !showAlles) return null;
const glyph = document.createElement("span");
glyph.className = "date-range-center-glyph";
const col = document.createElement("div");
col.className = "date-range-col date-range-col--now";
col.setAttribute("role", "group");
col.setAttribute("aria-label", t("date_range.center.label"));
const glyph = document.createElement("div");
glyph.className = "date-range-col-heading date-range-col-heading--glyph";
glyph.setAttribute("aria-hidden", "true");
glyph.textContent = "⌖"; // ⌖ POSITION INDICATOR
const label = document.createElement("span");
label.className = "date-range-center-label";
label.textContent = t("date_range.center.label");
btn.appendChild(glyph);
btn.appendChild(label);
col.appendChild(glyph);
btn.addEventListener("click", () => {
commit({ horizon: "any" }, /*closeAfter*/ true);
});
wrap.appendChild(btn);
return wrap;
if (showHeute) col.appendChild(makeChip("next_1d"));
if (showAlles) {
const allesChip = makeChip("any");
// Legacy "all" horizon also lights up Alles for back-compat
// with saved Custom Views that store the bidirectional-unbounded
// value (Q26 — parser preserves it, picker surfaces it here).
if (value.horizon === "all") {
allesChip.classList.add("agenda-chip-active");
allesChip.setAttribute("aria-pressed", "true");
}
col.appendChild(allesChip);
}
return col;
}
function makeChip(h: TimeHorizon): HTMLButtonElement {

View File

@@ -73,13 +73,16 @@ export function renderAxis(axis: AxisKey, ctx: AxisCtx, opts?: RenderAxisOpts):
type TimeHorizonValue = NonNullable<BarState["time"]>["horizon"];
// Default chip set when the surface doesn't override. Matches the
// forward-leaning bias of the legacy filter-bar default (the universal
// substrate is more often used for "what's coming up" than "what just
// happened") but now covers the full symmetric fan plus past_30d for
// quick recent-history lookups.
// Default chip set when the surface doesn't override. Mirrors m's
// 3-column picker spec (t-paliad-278): symmetric 7d/30d/90d/all fan
// per side, plus Heute (next_1d) + Alles (any) in the centre column,
// plus Anpassen. Surfaces with a tighter scope (project history is
// past-only) keep overriding via `timePresets`.
const DEFAULT_TIME_PRESETS: TimeHorizonValue[] = [
"past_30d", "past_7d", "any", "next_7d", "next_30d", "next_90d", "custom",
"past_7d", "past_30d", "past_90d", "past_all",
"next_1d", "any",
"next_7d", "next_30d", "next_90d", "next_all",
"custom",
];
function renderTimeAxis(ctx: AxisCtx, presetOverride?: TimeHorizonValue[]): HTMLElement {

View File

@@ -1473,11 +1473,6 @@ const translations: Record<Lang, Record<string, string>> = {
"submissions.draft.name.placeholder": "Name dieses Entwurfs",
"submissions.draft.preview.title": "Vorschau",
"submissions.draft.preview.hint": "Read-only Vorschau — finale Bearbeitung in Word.",
// t-paliad-276 — DE/EN language toggle on the draft editor.
"submissions.draft.language": "Sprache",
"submissions.draft.language.de": "DE",
"submissions.draft.language.en": "EN",
"submissions.draft.language.fallback_notice": "Fallback: universelles Skelett (keine sprachspezifische Vorlage).",
// t-paliad-240 — global Schriftsätze drafts index page.
"submissions.index.title": "Schriftsätze — Paliad",
"submissions.index.heading": "Schriftsätze",
@@ -3049,7 +3044,7 @@ const translations: Record<Lang, Record<string, string>> = {
// /admin/audit-log to the same component.
"date_range.button.label": "Zeitraum",
"date_range.button.label.custom_range": "Von {from} bis {to}",
"date_range.horizon.next_1d": "Morgen",
"date_range.horizon.next_1d": "Heute",
"date_range.horizon.next_7d": "Nächste 7 Tage",
"date_range.horizon.next_14d": "Nächste 14 Tage",
"date_range.horizon.next_30d": "Nächste 30 Tage",
@@ -4525,11 +4520,6 @@ const translations: Record<Lang, Record<string, string>> = {
"submissions.draft.switcher.label": "Draft",
"submissions.draft.name.placeholder": "Name of this draft",
"submissions.draft.preview.title": "Preview",
// t-paliad-276 — DE/EN language toggle on the draft editor.
"submissions.draft.language": "Language",
"submissions.draft.language.de": "DE",
"submissions.draft.language.en": "EN",
"submissions.draft.language.fallback_notice": "Fallback: universal skeleton (no language-matched template).",
"submissions.draft.preview.hint": "Read-only preview — final formatting in Word.",
// t-paliad-240 — global submissions drafts index page.
"submissions.index.title": "Submissions — Paliad",
@@ -6084,7 +6074,7 @@ const translations: Record<Lang, Record<string, string>> = {
// Date-range picker (t-paliad-248). See DE block above for details.
"date_range.button.label": "Time range",
"date_range.button.label.custom_range": "From {from} to {to}",
"date_range.horizon.next_1d": "Tomorrow",
"date_range.horizon.next_1d": "Today",
"date_range.horizon.next_7d": "Next 7 days",
"date_range.horizon.next_14d": "Next 14 days",
"date_range.horizon.next_30d": "Next 30 days",

View File

@@ -20,9 +20,6 @@ interface SubmissionDraftJSON {
submission_code: string;
user_id: string;
name: string;
// t-paliad-276 — per-draft output language ("de" or "en"). Drives the
// template-variant lookup and language-aware variable resolution.
language: string;
variables: Record<string, string>;
last_exported_at?: string | null;
last_exported_sha?: string | null;
@@ -49,11 +46,6 @@ interface SubmissionDraftView {
lang: string;
has_template: boolean;
template_missing?: boolean;
// t-paliad-276 — template-tier metadata used to surface the
// "Fallback: universelles Skelett" notice when the requested draft
// language has no per-firm language-matched template.
template_tier?: string;
language_fallback?: boolean;
}
interface SubmissionDraftListResponse {
@@ -409,7 +401,7 @@ async function fetchGlobalView(draftID: string): Promise<SubmissionDraftView> {
return resp.json();
}
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null; language?: string }): Promise<SubmissionDraftView> {
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null }): Promise<SubmissionDraftView> {
const p = state.parsed;
if (!p.draftID) throw new Error("no draft id");
if (state.inFlight) {
@@ -459,8 +451,6 @@ function paint(): void {
paintNoProjectBanner();
paintSwitcher();
paintNameRow();
paintLanguageRow();
paintLanguageFallback();
paintVariables();
paintPreview();
}
@@ -572,63 +562,6 @@ function paintNameRow(): void {
if (exportBtn) exportBtn.onclick = () => onExport(exportBtn);
}
// paintLanguageRow syncs the DE/EN radio with the loaded draft's
// language. Switching the radio fires onLanguageChange which PATCHes
// the draft and lets the server return the freshly-resolved bag +
// preview HTML (so the lawyer sees the EN form names appear without a
// manual reload). t-paliad-276.
function paintLanguageRow(): void {
if (!state.view) return;
const lang = (state.view.draft.language || "de").toLowerCase();
const de = document.getElementById("submission-draft-language-de") as HTMLInputElement | null;
const en = document.getElementById("submission-draft-language-en") as HTMLInputElement | null;
if (de) {
de.checked = lang === "de";
de.onchange = () => { void onLanguageChange("de"); };
}
if (en) {
en.checked = lang === "en";
en.onchange = () => { void onLanguageChange("en"); };
}
}
// paintLanguageFallback shows / hides the "no language-matched
// template" notice. The server sets language_fallback=true when the
// resolved template tier doesn't match the draft's language
// (e.g. EN draft → DE per-code template, or no skeleton EN sibling).
function paintLanguageFallback(): void {
const el = document.getElementById("submission-draft-language-fallback");
if (!el) return;
const fallback = !!state.view?.language_fallback;
el.style.display = fallback ? "" : "none";
}
async function onLanguageChange(lang: "de" | "en"): Promise<void> {
if (!state.view) return;
if ((state.view.draft.language || "de").toLowerCase() === lang) return;
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
try {
const view = await patchDraft({ language: lang });
state.view = view;
// Repaint everything that depends on language: the DE/EN form
// values in the resolved bag, the localized rule name in the
// header, and the fallback notice.
paintHeader();
paintLanguageRow();
paintLanguageFallback();
paintVariables();
paintPreview();
setSaveStatus(isEN() ? "Saved" : "Gespeichert");
} catch (err) {
if ((err as Error).name === "AbortError") return;
console.error("submission-draft language switch:", err);
setSaveStatus(isEN() ? "Save failed" : "Speichern fehlgeschlagen", true);
// Revert the radio to the persisted value so the UI doesn't lie
// about which language is active.
paintLanguageRow();
}
}
function paintVariables(): void {
const host = document.getElementById("submission-draft-variables");
if (!host || !state.view) return;

View File

@@ -2599,10 +2599,6 @@ export type I18nKey =
| "submissions.draft.action.export"
| "submissions.draft.action.new"
| "submissions.draft.back"
| "submissions.draft.language"
| "submissions.draft.language.de"
| "submissions.draft.language.en"
| "submissions.draft.language.fallback_notice"
| "submissions.draft.loading"
| "submissions.draft.name.placeholder"
| "submissions.draft.notfound"

View File

@@ -5774,40 +5774,6 @@ dialog.modal::backdrop {
color: var(--color-danger, #c00);
}
/* t-paliad-276 — DE/EN language toggle on the draft editor. Same look
as the rest of the sidebar mini-controls; muted label + inline radios
so it doesn't compete with the editor's primary inputs. */
.submission-draft-language-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0.25rem 0 0.5rem 0;
font-size: 0.9em;
}
.submission-draft-language-label {
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.85em;
}
.submission-draft-language-option {
display: inline-flex;
align-items: center;
gap: 0.25rem;
cursor: pointer;
}
.submission-draft-language-fallback {
font-size: 0.85em;
color: var(--color-text-muted);
margin: 0 0 0.5rem 0;
padding: 0.4rem 0.6rem;
border-left: 2px solid var(--color-warning, #d4a017);
background: var(--color-warning-bg, rgba(212, 160, 23, 0.08));
}
.submission-draft-variables {
display: flex;
flex-direction: column;
@@ -17667,9 +17633,10 @@ dialog.quick-add-sheet::backdrop {
}
.date-range-panel {
/* Inherits .multi-panel positioning + border + shadow. Widen it so
the symmetric fan + the custom editor have room to breathe. */
width: 32rem;
/* Inherits .multi-panel positioning + border + shadow. Sized so the
3-column grid holds the widest chip text ("Ganze Vergangenheit")
without wrapping while staying within the viewport on tablets. */
width: 34rem;
max-width: calc(100vw - 1rem);
top: 100%;
left: 0;
@@ -17677,88 +17644,54 @@ dialog.quick-add-sheet::backdrop {
gap: 0.75rem;
}
.date-range-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: stretch;
.date-range-grid {
/* Past / NOW / Future as three equal vertical columns. Each column
is a top-aligned chip stack so closeness-to-NOW (closest at top,
farthest at bottom) reads spatially. */
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.75rem;
align-items: start;
}
.date-range-fan {
.date-range-col {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
align-content: flex-start;
flex: 1 1 12rem;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
.date-range-fan--past {
/* Past fan: outermost chip (Ganze Vergangenheit) leftmost. */
justify-content: flex-end;
.date-range-col--now {
align-items: stretch;
}
.date-range-fan--next {
/* Future fan: innermost chip (Morgen / next_1d) leftmost. */
justify-content: flex-start;
}
.date-range-center {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
padding: 0 0.25rem;
}
.date-range-center-btn {
appearance: none;
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.1rem;
background: var(--color-surface-muted);
border: 1px solid var(--color-border);
border-radius: 0.6rem;
min-width: 4.5rem;
padding: 0.55rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text);
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.date-range-center-btn:hover {
background: var(--color-overlay-subtle);
border-color: var(--color-accent-light);
}
.date-range-center-btn:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.date-range-center-btn--active {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-accent-dark);
}
.date-range-center-glyph {
font-size: 1.4rem;
line-height: 1;
}
.date-range-center-label {
.date-range-col-heading {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted, #71717a);
text-align: center;
padding-bottom: 0.15rem;
}
.date-range-col-heading--glyph {
font-size: 1.3rem;
line-height: 1;
letter-spacing: 0;
text-transform: none;
color: var(--color-text-muted, #71717a);
}
.date-range-chip {
/* .agenda-chip provides bg/border/radius/typography; this modifier
only tightens horizontal padding so more chips fit per row. */
padding: 0.3rem 0.65rem;
/* .agenda-chip provides bg/border/radius/typography; in the
3-column stack each chip fills its column so the closeness-to-NOW
ordering reads as a single vertical column rather than a ragged
row. */
padding: 0.35rem 0.65rem;
font-size: 0.8rem;
width: 100%;
text-align: center;
}
.date-range-chip--custom {
@@ -17845,17 +17778,14 @@ dialog.quick-add-sheet::backdrop {
color: var(--status-red-fg, #b91c1c);
}
/* Mobile: stack past / centre / next vertically so each fan gets
the full popover width. */
/* Mobile: stack the 3 columns vertically (one column per row),
preserving the closeness-to-NOW sort within each column. */
@media (max-width: 540px) {
.date-range-panel {
width: calc(100vw - 1rem);
}
.date-range-row {
flex-direction: column;
}
.date-range-fan--past,
.date-range-fan--next {
justify-content: flex-start;
.date-range-grid {
grid-template-columns: 1fr;
gap: 0.5rem;
}
}

View File

@@ -109,47 +109,6 @@ export function renderSubmissionDraft(): string {
</button>
</div>
{/* t-paliad-276 — output language toggle (DE/EN).
Hydrated by client/submission-draft.ts; switching
autosaves the draft and re-renders the preview. */}
<div
className="submission-draft-language-row"
id="submission-draft-language-row"
role="radiogroup"
aria-labelledby="submission-draft-language-label">
<span
id="submission-draft-language-label"
className="submission-draft-language-label"
data-i18n="submissions.draft.language">
Sprache
</span>
<label className="submission-draft-language-option">
<input
type="radio"
name="submission-draft-language"
value="de"
id="submission-draft-language-de"
/>
<span data-i18n="submissions.draft.language.de">DE</span>
</label>
<label className="submission-draft-language-option">
<input
type="radio"
name="submission-draft-language"
value="en"
id="submission-draft-language-en"
/>
<span data-i18n="submissions.draft.language.en">EN</span>
</label>
</div>
<p
className="submission-draft-language-fallback"
id="submission-draft-language-fallback"
style="display:none"
data-i18n="submissions.draft.language.fallback_notice">
Fallback: universelles Skelett (keine sprachspezifische Vorlage).
</p>
<p className="submission-draft-savestatus" id="submission-draft-savestatus" />
<div className="submission-draft-variables" id="submission-draft-variables" />

View File

@@ -1,2 +0,0 @@
ALTER TABLE paliad.submission_drafts
DROP COLUMN IF EXISTS language;

View File

@@ -1,17 +0,0 @@
-- t-paliad-276 / m/paliad#108: per-draft output language for the
-- Submissions generator.
--
-- The submission editor lets the lawyer pick DE or EN per draft so the
-- generator selects the matching template variant + resolves language-
-- aware variables ({{procedural_event.name_de}} vs _en). Default is
-- 'de' to match the primary-language convention in CLAUDE.md and to
-- keep existing rows behaving exactly as before (every legacy draft
-- was implicitly DE; the resolved bag for those drafts is unchanged
-- under language='de').
ALTER TABLE paliad.submission_drafts
ADD COLUMN IF NOT EXISTS language text NOT NULL DEFAULT 'de'
CONSTRAINT submission_drafts_language_check CHECK (language IN ('de', 'en'));
COMMENT ON COLUMN paliad.submission_drafts.language IS
't-paliad-276: output language for the generated .docx. ''de'' or ''en''. Drives template variant selection ({code}.{lang}.docx fallback chain) and language-aware variable resolution.';

View File

@@ -79,38 +79,6 @@ var fileRegistry = map[string]fileEntry{
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
},
// Firm-formatted skeleton (t-paliad-275). Carries the same 48-key
// placeholder bag as the universal _skeleton.docx, but additionally
// preserves every HL paragraph + character style from the HL Patents
// Style .dotm (HLpat-Heading-H1..H5, HLpat-Body-B0, HLpat-Header-Section,
// HLpat-Table-Recitals-*, HLpat-Signature, …) and the firm letterhead
// (header logo + firm-address footer). Slotted ahead of the universal
// skeleton in the fallback chain so any submission_code without a
// dedicated per-code template still renders as a real firm-branded
// Schriftsatz with variables substituted, rather than a plain skeleton.
// Generated via scripts/gen-hl-skeleton-template against the .dotm.
firmSkeletonSubmissionSlug: {
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_firm-skeleton.docx",
DownloadName: branding.Name + " — Firm Schriftsatz-Skelett.docx",
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
RepoOwner: "m",
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_firm-skeleton.docx",
},
// English skeleton variant (t-paliad-276). Sibling of
// `_skeleton.docx`; used when a draft's language='en' and no
// per-code EN template exists. If the file isn't authored yet in
// mWorkRepo, the Gitea fetch fails and resolveSubmissionTemplate
// falls through to the DE skeleton — visible to the user as the
// "Fallback: universelles Skelett" notice on the draft editor.
skeletonSubmissionENSlug: {
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.en.docx",
DownloadName: branding.Name + " — Submission skeleton.docx",
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
RepoOwner: "m",
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.en.docx",
},
}
// skeletonSubmissionSlug names the universal skeleton template inside
@@ -119,19 +87,6 @@ var fileRegistry = map[string]fileEntry{
// the same string the registry uses.
const skeletonSubmissionSlug = "submission/_skeleton.docx"
// firmSkeletonSubmissionSlug names the firm-formatted skeleton template
// inside the shared fileRegistry cache (t-paliad-275). Same placeholder
// surface as skeletonSubmissionSlug; carries HL paragraph + character
// styles from the source .dotm on top. Sits between the per-code
// template and the generic universal skeleton in the fallback chain so
// codes without a dedicated template still render with firm branding.
const firmSkeletonSubmissionSlug = "submission/_firm-skeleton.docx"
// skeletonSubmissionENSlug names the English skeleton variant used when
// a draft's language='en' and no per-code EN template exists
// (t-paliad-276). Same role as skeletonSubmissionSlug but in EN.
const skeletonSubmissionENSlug = "submission/_skeleton.en.docx"
// submissionTemplateRegistry maps a deadline-rule submission_code to a
// fileRegistry slug. Lookup order matches the cronus design fallback
// chain §8: per-firm `templates/{FIRM_NAME}/{code}.docx` first, then
@@ -141,32 +96,14 @@ const skeletonSubmissionENSlug = "submission/_skeleton.en.docx"
// the file itself lives in mWorkRepo and is served through the shared
// Gitea proxy cache so refreshes are visible to all consumers in one
// place.
//
// t-paliad-276: codes that ship an EN sibling
// (e.g. `de.inf.lg.erwidg.en.docx`) also register it in
// submissionTemplateENRegistry; the language-aware lookup
// (resolveSubmissionTemplate(ctx, code, lang)) prefers the language-
// suffixed slug and falls back to the unsuffixed one when no per-firm
// EN variant exists.
var submissionTemplateRegistry = map[string]string{
"de.inf.lg.erwidg": "submission/de.inf.lg.erwidg.docx",
}
// submissionTemplateENRegistry maps a submission_code to the EN
// variant slug. Empty when no EN template has been authored — the
// lookup falls through to the unsuffixed (DE-baked) template and the
// editor surfaces the "Fallback: universelles Skelett" notice when
// even the skeleton has no EN sibling.
var submissionTemplateENRegistry = map[string]string{}
// fetchSubmissionTemplateBytes returns the per-submission_code template
// bytes (and provenance SHA) when one is registered. The bool result
// distinguishes "no per-code template registered" (callers fall back to
// HL Patents Style) from an upstream fetch error.
//
// Language-suffixed variants (t-paliad-276) are served via
// fetchSubmissionTemplateBytesForLang — this base function returns the
// unsuffixed registry entry only (the legacy DE-baked template).
func fetchSubmissionTemplateBytes(ctx context.Context, submissionCode string) ([]byte, string, bool, error) {
slug, ok := submissionTemplateRegistry[submissionCode]
if !ok {
@@ -272,113 +209,6 @@ func handleFileRefresh(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"ok": "true", "message": "Cache cleared"})
}
// fetchSubmissionTemplateBytesForLang returns the per-(code, lang)
// template bytes when a language-suffixed variant is registered. Used
// only for the EN variant today; DE goes through the unsuffixed
// fetchSubmissionTemplateBytes (which is the legacy / authoritative
// DE registry). t-paliad-276.
//
// Returned bool = "variant registered AND fetched OK". A registered
// variant whose file 404s on Gitea returns (nil, "", false, nil) so
// the caller falls through to the unsuffixed template, mirroring the
// behaviour for unregistered codes.
func fetchSubmissionTemplateBytesForLang(ctx context.Context, submissionCode, lang string) ([]byte, string, bool, error) {
if lang != "en" {
// Only EN has a separate registry today. DE goes through the
// unsuffixed path which is the authoritative DE template.
return nil, "", false, nil
}
slug, ok := submissionTemplateENRegistry[submissionCode]
if !ok {
return nil, "", false, nil
}
entry, ok := fileRegistry[slug]
if !ok {
return nil, "", false, fmt.Errorf("file proxy: submission template slug %q not registered", slug)
}
ce := getCacheEntry(slug)
ce.mu.RLock()
hasData := len(ce.data) > 0
needsCheck := time.Since(ce.lastChecked) >= checkInterval
ce.mu.RUnlock()
if !hasData {
if err := fileFetch(ce, entry); err != nil {
// Treat upstream miss as "variant unavailable" so the
// resolver falls through to the DE template instead of
// surfacing a 502.
log.Printf("file proxy: EN variant fetch failed for %s (%s): %v — falling through", submissionCode, slug, err)
return nil, "", false, nil
}
} else if needsCheck {
go fileCheckAndRefresh(ce, entry)
}
ce.mu.RLock()
defer ce.mu.RUnlock()
if len(ce.data) == 0 {
return nil, "", false, nil
}
out := make([]byte, len(ce.data))
copy(out, ce.data)
_ = ctx
return out, ce.sha, true, nil
}
// fetchSubmissionSkeletonBytesForLang returns the cached skeleton
// template bytes for the requested language. EN falls back to DE when
// the EN skeleton hasn't been authored yet (t-paliad-276). Returned
// bool flags whether the bytes match the requested language — false
// means the resolver should communicate "fallback" to the UI.
func fetchSubmissionSkeletonBytesForLang(ctx context.Context, lang string) ([]byte, string, bool, error) {
if lang == "en" {
entry, ok := fileRegistry[skeletonSubmissionENSlug]
if ok {
ce := getCacheEntry(skeletonSubmissionENSlug)
ce.mu.RLock()
hasData := len(ce.data) > 0
needsCheck := time.Since(ce.lastChecked) >= checkInterval
ce.mu.RUnlock()
if !hasData {
if err := fileFetch(ce, entry); err == nil {
ce.mu.RLock()
if len(ce.data) > 0 {
out := make([]byte, len(ce.data))
copy(out, ce.data)
sha := ce.sha
ce.mu.RUnlock()
return out, sha, true, nil
}
ce.mu.RUnlock()
} else {
log.Printf("file proxy: EN skeleton fetch failed (%s): %v — falling back to DE", skeletonSubmissionENSlug, err)
}
} else {
if needsCheck {
go fileCheckAndRefresh(ce, entry)
}
ce.mu.RLock()
if len(ce.data) > 0 {
out := make([]byte, len(ce.data))
copy(out, ce.data)
sha := ce.sha
ce.mu.RUnlock()
return out, sha, true, nil
}
ce.mu.RUnlock()
}
}
}
// Fall through to the DE skeleton; bool=false flags that the
// returned bytes don't carry the requested language.
bytes, sha, err := fetchSubmissionSkeletonBytes(ctx)
if err != nil {
return nil, "", false, err
}
return bytes, sha, lang == "de", nil
}
// fetchSubmissionSkeletonBytes returns the cached universal skeleton
// template bytes plus its provenance SHA. Sits between the per-firm
// per-submission_code template (fetchSubmissionTemplateBytes) and the
@@ -389,28 +219,11 @@ func fetchSubmissionSkeletonBytesForLang(ctx context.Context, lang string) ([]by
// call warms the cache synchronously from mWorkRepo via Gitea; later
// calls return immediately while a background refresh runs.
func fetchSubmissionSkeletonBytes(ctx context.Context) ([]byte, string, error) {
return fetchSubmissionTemplateSlug(ctx, skeletonSubmissionSlug)
}
// fetchFirmSkeletonBytes returns the cached firm-formatted skeleton
// template bytes (HL paragraph/character styles + 48-key placeholder
// bag) plus its provenance SHA. Sits between the per-code template and
// the generic universal skeleton in resolveSubmissionTemplate's
// fallback chain (t-paliad-275). Same stale-while-revalidate caching
// as the other Gitea-backed template parts.
func fetchFirmSkeletonBytes(ctx context.Context) ([]byte, string, error) {
return fetchSubmissionTemplateSlug(ctx, firmSkeletonSubmissionSlug)
}
// fetchSubmissionTemplateSlug is the shared cache-aware fetcher used by
// the firm-skeleton and universal-skeleton accessors. Factored out so
// the two paths can't drift apart on caching semantics.
func fetchSubmissionTemplateSlug(ctx context.Context, slug string) ([]byte, string, error) {
entry, ok := fileRegistry[slug]
entry, ok := fileRegistry[skeletonSubmissionSlug]
if !ok {
return nil, "", fmt.Errorf("file proxy: %s not registered", slug)
return nil, "", fmt.Errorf("file proxy: %s not registered", skeletonSubmissionSlug)
}
ce := getCacheEntry(slug)
ce := getCacheEntry(skeletonSubmissionSlug)
ce.mu.RLock()
hasData := len(ce.data) > 0
@@ -428,7 +241,7 @@ func fetchSubmissionTemplateSlug(ctx context.Context, slug string) ([]byte, stri
ce.mu.RLock()
defer ce.mu.RUnlock()
if len(ce.data) == 0 {
return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", slug)
return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", skeletonSubmissionSlug)
}
out := make([]byte, len(ce.data))
copy(out, ce.data)

View File

@@ -68,17 +68,6 @@ type submissionDraftView struct {
Lang string `json:"lang"`
HasTemplate bool `json:"has_template"`
TemplateMissing bool `json:"template_missing,omitempty"`
// TemplateTier identifies which tier of resolveSubmissionTemplate
// produced the bytes — one of per_code_lang, per_code, skeleton_lang,
// skeleton, letterhead. Lets the editor distinguish a perfect
// per-firm match from a skeleton fallback. t-paliad-276.
TemplateTier string `json:"template_tier,omitempty"`
// LanguageFallback is true when the requested draft.language has no
// per-firm per-code template (e.g. EN draft falls back to the DE
// per-code template, or to the universal skeleton). UI surfaces a
// notice so the lawyer knows the rendered body lacks language-
// matched code-specific prose. t-paliad-276.
LanguageFallback bool `json:"language_fallback,omitempty"`
}
type submissionDraftJSON struct {
@@ -87,7 +76,6 @@ type submissionDraftJSON struct {
SubmissionCode string `json:"submission_code"`
UserID uuid.UUID `json:"user_id"`
Name string `json:"name"`
Language string `json:"language"`
Variables services.PlaceholderMap `json:"variables"`
LastExportedAt *time.Time `json:"last_exported_at,omitempty"`
LastExportedSHA *string `json:"last_exported_sha,omitempty"`
@@ -115,7 +103,6 @@ type submissionDraftListResponse struct {
type submissionDraftPatchInput struct {
Name *string `json:"name,omitempty"`
Variables *services.PlaceholderMap `json:"variables,omitempty"`
Language *string `json:"language,omitempty"`
}
// ─────────────────────────────────────────────────────────────────────
@@ -350,7 +337,7 @@ func handlePatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
return
}
patch := services.DraftPatch{Name: input.Name, Variables: input.Variables, Language: input.Language}
patch := services.DraftPatch{Name: input.Name, Variables: input.Variables}
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
if err != nil {
writeSubmissionDraftServiceError(w, err)
@@ -431,7 +418,7 @@ func handlePreviewSubmissionDraft(w http.ResponseWriter, r *http.Request) {
writeSubmissionDraftServiceError(w, err)
return
}
tplBytes, _, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
tplBytes, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
if err != nil {
log.Printf("submission_drafts: template fetch (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
@@ -480,7 +467,7 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
writeSubmissionDraftServiceError(w, err)
return
}
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
tplBytes, tplSHA, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
if err != nil {
log.Printf("submission_drafts: export template fetch (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
@@ -683,7 +670,6 @@ func handleGetGlobalSubmissionDraft(w http.ResponseWriter, r *http.Request) {
type globalDraftPatchInput struct {
Name *string `json:"name,omitempty"`
Variables *services.PlaceholderMap `json:"variables,omitempty"`
Language *string `json:"language,omitempty"`
// projectIDProvided is true when the JSON included the "project_id"
// key (regardless of value); needed to distinguish "no change" from
// "set to null". Set by the custom UnmarshalJSON below.
@@ -695,7 +681,6 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
type alias struct {
Name *string `json:"name,omitempty"`
Variables *services.PlaceholderMap `json:"variables,omitempty"`
Language *string `json:"language,omitempty"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
}
var a alias
@@ -704,7 +689,6 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
}
g.Name = a.Name
g.Variables = a.Variables
g.Language = a.Language
g.ProjectID = a.ProjectID
// Detect whether "project_id" was present in the JSON object.
var raw map[string]json.RawMessage
@@ -742,7 +726,7 @@ func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
return
}
patch := services.DraftPatch{Name: in.Name, Variables: in.Variables, Language: in.Language}
patch := services.DraftPatch{Name: in.Name, Variables: in.Variables}
if in.projectIDProvided {
pid := in.ProjectID // may be nil → detach
patch.ProjectID = &pid
@@ -817,7 +801,7 @@ func handleGlobalExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
writeSubmissionDraftServiceError(w, err)
return
}
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
tplBytes, tplSHA, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
if err != nil {
log.Printf("submission_drafts: export template fetch (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
@@ -902,7 +886,7 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
view.Rule.LegalSourcePretty = merged["rule.legal_source_pretty"]
}
tplBytes, _, tier, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
tplBytes, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
if err != nil {
log.Printf("submission_drafts: template fetch for view (draft=%s): %v", d.ID, err)
view.TemplateMissing = true
@@ -910,12 +894,6 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
view.PreviewHTML = `<p class="preview-error">Vorlage konnte nicht geladen werden.</p>`
return view, nil
}
view.TemplateTier = string(tier)
// LanguageFallback signals "no per-firm template in the requested
// language" — the editor surfaces a notice so the lawyer knows the
// rendered body lacks code-specific prose. The per-code DE template
// counts as a fallback when the requested language is EN.
view.LanguageFallback = languageFallback(d.Language, tier)
html, err := dbSvc.submissionDraft.RenderPreview(ctx, d, tplBytes)
if err != nil {
return nil, err
@@ -924,101 +902,41 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
return view, nil
}
// submissionTemplateTier enumerates which tier of the template
// fallback chain produced the bytes returned by resolveSubmissionTemplate.
// Used by the editor to surface "Fallback: universelles Skelett" when
// the requested (code, lang) didn't have a dedicated template.
type submissionTemplateTier string
const (
tplTierPerCodeLang submissionTemplateTier = "per_code_lang" // {firm}/{code}.{lang}.docx
tplTierPerCode submissionTemplateTier = "per_code" // {firm}/{code}.docx (unsuffixed)
tplTierSkeletonLang submissionTemplateTier = "skeleton_lang" // _skeleton.{lang}.docx
tplTierSkeleton submissionTemplateTier = "skeleton" // _skeleton.docx
tplTierLetterhead submissionTemplateTier = "letterhead" // HL Patents Style .dotm
)
// resolveSubmissionTemplate returns the .docx bytes for the given
// (submission_code, language). Merges t-paliad-275 (firm-skeleton tier)
// and t-paliad-276 (language-selector + EN skeleton tier). Lookup order:
// submission code. Lookup order matches the cronus design fallback chain
// §8 plus the t-paliad-259 universal-skeleton slot:
//
// 1. per-firm per-(code, lang) template — most specific. e.g.
// `de.inf.lg.erwidg.en.docx` for EN drafts. t-paliad-276.
// 2. per-firm per-code (unsuffixed) template — DE-baked baseline. The
// legacy registry shape from before the language selector landed.
// 3. universal language-matched skeleton — `_skeleton.en.docx` for EN
// drafts. Skipped for DE drafts (steps 4+5 already cover DE).
// 4. firm-formatted skeleton — `_firm-skeleton.docx` (t-paliad-275).
// HL paragraph + character styles + letterhead, full placeholder
// bag. DE-flavored: counts as language_fallback=true for EN drafts.
// 5. universal _skeleton.docx — plain DE skeleton, no firm styles.
// Backstop when the firm skeleton is unreachable.
// 6. universal HL Patents Style .dotm — macro-only letterhead, no
// placeholders. Last-ditch when every skeleton tier is unreachable.
// 1. per-firm per-submission_code template registered in
// submissionTemplateRegistry (e.g. de.inf.lg.erwidg.docx) — code-
// specific structure plus the full variable bag.
// 2. universal _skeleton.docx — same variable bag, no submission_code-
// specific prose. Catches every code without a dedicated template
// so the editor preview / generate flow still has variables to
// substitute instead of falling through to the bare letterhead.
// 3. universal HL Patents Style .dotm — macro-only letterhead, no
// placeholders. Final fallback when even the skeleton is unreachable
// (mWorkRepo outage etc.). Preserves the pre-t-paliad-259 behaviour
// for resilience.
//
// The returned SHA pins the audit row's template provenance. The tier
// tells the editor whether the result language-matches the request so
// it can surface a "Fallback: universelles Skelett" notice.
func resolveSubmissionTemplate(ctx context.Context, submissionCode, lang string) ([]byte, string, submissionTemplateTier, error) {
if lang != "de" && lang != "en" {
lang = "de"
}
// 1. per-(code, lang)
if data, sha, found, err := fetchSubmissionTemplateBytesForLang(ctx, submissionCode, lang); err != nil {
return nil, "", "", err
} else if found {
return data, sha, tplTierPerCodeLang, nil
}
// 2. per-code (unsuffixed)
// The returned SHA is the cache entry's commit SHA so the export audit
// row can record provenance.
func resolveSubmissionTemplate(ctx context.Context, submissionCode string) ([]byte, string, error) {
if data, sha, found, err := fetchSubmissionTemplateBytes(ctx, submissionCode); err != nil {
return nil, "", "", err
return nil, "", err
} else if found {
return data, sha, tplTierPerCode, nil
return data, sha, nil
}
// 3. language-matched skeleton — only meaningful for EN drafts; DE
// drafts fall through to the firm/universal DE skeletons below.
if lang == "en" {
if data, sha, langMatched, err := fetchSubmissionSkeletonBytesForLang(ctx, lang); err == nil && langMatched {
return data, sha, tplTierSkeletonLang, nil
}
}
// 4. firm-formatted skeleton (HL styles, DE prose). For DE drafts
// this is a first-class match; for EN drafts it counts as a
// language fallback (handled by languageFallback()).
if data, sha, err := fetchFirmSkeletonBytes(ctx); err == nil {
return data, sha, tplTierSkeleton, nil
} else {
log.Printf("submission_drafts: firm-skeleton fetch failed for code=%s lang=%s, falling back to universal skeleton: %v", submissionCode, lang, err)
}
// 5. universal plain DE skeleton.
if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil {
return data, sha, tplTierSkeleton, nil
return data, sha, nil
} else {
log.Printf("submission_drafts: skeleton fetch failed for code=%s lang=%s, falling back to HL Patents Style: %v", submissionCode, lang, err)
log.Printf("submission_drafts: skeleton fetch failed for code=%s, falling back to HL Patents Style: %v", submissionCode, err)
}
// 6. HL Patents Style letterhead (no placeholders, last-ditch).
bytes, err := fetchHLPatentsStyleBytes(ctx)
if err != nil {
return nil, "", "", err
return nil, "", err
}
sha := hlPatentsStyleSHA()
return bytes, sha, tplTierLetterhead, nil
}
// languageFallback reports whether the resolved template tier failed
// to match the requested draft language. For an EN draft, anything
// other than per_code_lang or skeleton_lang is a fallback (per_code is
// the legacy DE-baked template, skeleton is the DE skeleton). For a DE
// draft, only `letterhead` counts as a fallback — the DE skeleton and
// per-code template are both first-class DE outputs. t-paliad-276.
func languageFallback(lang string, tier submissionTemplateTier) bool {
if tier == tplTierLetterhead {
return true
}
if strings.EqualFold(lang, "en") {
return tier != tplTierPerCodeLang && tier != tplTierSkeletonLang
}
return false
return bytes, sha, nil
}
// hlPatentsStyleSHA reads the current cache SHA for the universal
@@ -1040,17 +958,12 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
if vars == nil {
vars = services.PlaceholderMap{}
}
lang := d.Language
if lang == "" {
lang = "de"
}
return submissionDraftJSON{
ID: d.ID,
ProjectID: d.ProjectID,
SubmissionCode: d.SubmissionCode,
UserID: d.UserID,
Name: d.Name,
Language: lang,
Variables: vars,
LastExportedAt: d.LastExportedAt,
LastExportedSHA: d.LastExportedSHA,

View File

@@ -1,43 +0,0 @@
package handlers
// Regression tests for the template-tier → language-fallback mapping
// (t-paliad-276). The editor surfaces a "Fallback: universelles
// Skelett" notice when the requested draft language has no per-firm
// language-matched template — these tests pin which tier counts as a
// fallback for each language so the UI signal stays stable.
import "testing"
func TestLanguageFallback(t *testing.T) {
t.Parallel()
cases := []struct {
name string
lang string
tier submissionTemplateTier
want bool
}{
// DE drafts: every non-letterhead tier is a first-class match.
{"de_per_code_lang", "de", tplTierPerCodeLang, false},
{"de_per_code", "de", tplTierPerCode, false},
{"de_skeleton_lang", "de", tplTierSkeletonLang, false},
{"de_skeleton", "de", tplTierSkeleton, false},
{"de_letterhead", "de", tplTierLetterhead, true},
// EN drafts: per_code (DE-baked) and skeleton (DE-baked) both
// surface the fallback notice so the lawyer knows the rendered
// body lacks EN prose.
{"en_per_code_lang", "en", tplTierPerCodeLang, false},
{"en_per_code", "en", tplTierPerCode, true},
{"en_skeleton_lang", "en", tplTierSkeletonLang, false},
{"en_skeleton", "en", tplTierSkeleton, true},
{"en_letterhead", "en", tplTierLetterhead, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
if got := languageFallback(c.lang, c.tier); got != c.want {
t.Errorf("languageFallback(%q, %q) = %v, want %v", c.lang, c.tier, got, c.want)
}
})
}
}

View File

@@ -304,23 +304,14 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), submissionRenderTimeout)
defer cancel()
// One-shot /generate has no draft row to pull `language` from —
// accept `?language=de|en` as an explicit override (t-paliad-276)
// and otherwise fall back to the user's UI language.
user, _ := dbSvc.users.GetByID(ctx, uid)
lang := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("language")))
if lang != "de" && lang != "en" {
lang = userLang(user)
}
tplBytes, _, _, err := resolveSubmissionTemplate(ctx, submissionCode, lang)
tplBytes, _, err := resolveSubmissionTemplate(ctx, submissionCode)
if err != nil {
log.Printf("submissions: template fetch (project=%s code=%s): %v", projectID, submissionCode, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
return
}
docx, resolved, err := dbSvc.submissionDraft.RenderProjectSubmission(ctx, uid, projectID, submissionCode, lang, tplBytes)
docx, resolved, err := dbSvc.submissionDraft.RenderProjectSubmission(ctx, uid, projectID, submissionCode, tplBytes)
if err != nil {
if errors.Is(err, services.ErrSubmissionRuleNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{

View File

@@ -1,79 +0,0 @@
package services
// Regression tests for the per-draft language column (t-paliad-276).
// The draft's `language` value drives both the placeholder-bag
// language pick (`procedural_event.name` → name_de vs name_en) and the
// template-variant lookup (`{code}.{lang}.docx` fallback chain). These
// tests pin the pure-function pieces — Build wiring needs DB fixtures
// and lives in the handler-layer smoke path.
import (
"strings"
"testing"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/models"
)
func TestNormalizeDraftLanguage(t *testing.T) {
t.Parallel()
cases := []struct {
in string
want string
}{
{"de", "de"},
{"DE", "de"},
{" de ", "de"},
{"en", "en"},
{"EN", "en"},
{" en ", "en"},
{"fr", "de"}, // unknown collapses to de (the CHECK-allowed default)
{"", "de"},
{"english", "de"}, // strict — only the canonical two-letter code is accepted
}
for _, c := range cases {
if got := normalizeDraftLanguage(c.in); got != c.want {
t.Errorf("normalizeDraftLanguage(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// The placeholder bag picks the language-matched value for the
// canonical (procedural_event.name) and legacy (rule.name) keys based
// on the lang argument. This pins the wiring used by Build when a
// draft's language overrides the user's UI lang (t-paliad-276).
func TestAddRuleVars_LanguageSelectsMatchedName(t *testing.T) {
t.Parallel()
code := "de.inf.lg.erwidg"
rule := &models.DeadlineRule{
ID: uuid.New(),
SubmissionCode: &code,
Name: "Klageerwiderung",
NameEN: "Statement of Defence",
}
for _, lang := range []string{"de", "en"} {
bag := PlaceholderMap{}
addRuleVars(bag, rule, lang)
want := rule.Name
if strings.EqualFold(lang, "en") {
want = rule.NameEN
}
if got := bag["procedural_event.name"]; got != want {
t.Errorf("lang=%s: procedural_event.name = %q, want %q", lang, got, want)
}
if got := bag["rule.name"]; got != want {
t.Errorf("lang=%s: rule.name = %q, want %q (legacy alias must mirror canonical)", lang, got, want)
}
// The explicit *_de / *_en keys never change — both are always
// emitted so a template can pin one regardless of the draft's
// language. Regression guard against accidentally
// language-gating the explicit variants.
if bag["procedural_event.name_de"] != rule.Name {
t.Errorf("lang=%s: procedural_event.name_de = %q, want %q", lang, bag["procedural_event.name_de"], rule.Name)
}
if bag["procedural_event.name_en"] != rule.NameEN {
t.Errorf("lang=%s: procedural_event.name_en = %q, want %q", lang, bag["procedural_event.name_en"], rule.NameEN)
}
}
}

View File

@@ -47,11 +47,6 @@ type SubmissionDraft struct {
SubmissionCode string `db:"submission_code" json:"submission_code"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
// Language is the output language for the generated .docx — 'de' or
// 'en'. Drives the template-variant lookup ({code}.{lang}.docx
// fallback chain) and language-aware variable resolution
// ({{procedural_event.name}} → name_de or name_en). t-paliad-276.
Language string `db:"language" json:"language"`
VariablesRaw []byte `db:"variables" json:"-"`
LastExportedAt *time.Time `db:"last_exported_at" json:"last_exported_at,omitempty"`
LastExportedSHA *string `db:"last_exported_sha" json:"last_exported_sha,omitempty"`
@@ -99,9 +94,6 @@ type DraftPatch struct {
Name *string
Variables *PlaceholderMap
ProjectID **uuid.UUID
// Language sets the output language. Valid values: "de", "en".
// Anything else returns ErrInvalidInput. t-paliad-276.
Language *string
}
// ErrSubmissionDraftNotFound is the sentinel for "no draft with that id
@@ -114,7 +106,7 @@ var ErrSubmissionDraftNameTaken = errors.New("submission draft: name already tak
// draftColumns is the canonical select list — kept in one place so
// every fetch stays in sync.
const draftColumns = `id, project_id, submission_code, user_id, name, language,
const draftColumns = `id, project_id, submission_code, user_id, name,
variables, last_exported_at, last_exported_sha,
created_at, updated_at`
@@ -165,7 +157,7 @@ type DraftWithProject struct {
func (s *SubmissionDraftService) ListAllForUser(ctx context.Context, userID uuid.UUID) ([]DraftWithProject, error) {
var rows []DraftWithProject
err := s.db.SelectContext(ctx, &rows,
`SELECT d.id, d.project_id, d.submission_code, d.user_id, d.name, d.language,
`SELECT d.id, d.project_id, d.submission_code, d.user_id, d.name,
d.variables, d.last_exported_at, d.last_exported_sha,
d.created_at, d.updated_at,
p.title AS project_title,
@@ -271,18 +263,13 @@ func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, p
if err != nil {
return nil, err
}
// Seed the new draft's output language from the user's UI lang so
// the editor opens in the language the lawyer is already working in.
// Anything other than "en" normalizes to "de" — matches the DB CHECK
// constraint and the project's primary-language default.
draftLang := normalizeDraftLanguage(lang)
var d SubmissionDraft
err = s.db.GetContext(ctx, &d,
`INSERT INTO paliad.submission_drafts
(project_id, submission_code, user_id, name, language)
VALUES ($1, $2, $3, $4, $5)
(project_id, submission_code, user_id, name)
VALUES ($1, $2, $3, $4)
RETURNING `+draftColumns,
projectID, submissionCode, userID, name, draftLang)
projectID, submissionCode, userID, name)
if err != nil {
return nil, fmt.Errorf("create submission draft: %w", err)
}
@@ -407,16 +394,6 @@ func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uui
idx++
}
if patch.Language != nil {
newLang := strings.ToLower(strings.TrimSpace(*patch.Language))
if newLang != "de" && newLang != "en" {
return nil, ErrInvalidInput
}
setParts = append(setParts, fmt.Sprintf("language = $%d", idx))
args = append(args, newLang)
idx++
}
if len(setParts) == 0 {
return existing, nil
}
@@ -499,10 +476,6 @@ func (s *SubmissionDraftService) BuildRenderBag(ctx context.Context, draft *Subm
UserID: draft.UserID,
ProjectID: draft.ProjectID,
SubmissionCode: draft.SubmissionCode,
// The draft's language overrides the user's UI lang — the lawyer
// can author an EN draft in a DE-UI session and vice versa
// (t-paliad-276). Empty / unknown falls back to "de".
Lang: normalizeDraftLanguage(draft.Language),
})
if err != nil {
return nil, nil, err
@@ -557,13 +530,12 @@ func (s *SubmissionDraftService) Export(ctx context.Context, draft *SubmissionDr
// ProjectService.GetByID — callers get ErrNotFound on no-access.
// ErrSubmissionRuleNotFound surfaces when no published rule matches the
// requested submission_code.
func (s *SubmissionDraftService) RenderProjectSubmission(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string, templateBytes []byte) ([]byte, *SubmissionVarsResult, error) {
func (s *SubmissionDraftService) RenderProjectSubmission(ctx context.Context, userID, projectID uuid.UUID, submissionCode string, templateBytes []byte) ([]byte, *SubmissionVarsResult, error) {
pid := projectID
resolved, err := s.vars.Build(ctx, SubmissionVarsContext{
UserID: userID,
ProjectID: &pid,
SubmissionCode: submissionCode,
Lang: normalizeDraftLanguage(lang),
})
if err != nil {
return nil, nil, err
@@ -590,18 +562,6 @@ func (d *SubmissionDraft) decodeVariables() error {
return nil
}
// normalizeDraftLanguage maps any input to one of the two allowed
// language values for paliad.submission_drafts.language. Anything other
// than "en" (case-insensitive) collapses to "de" — matches the DB CHECK
// constraint, the project's primary-language default, and the seed
// behaviour for existing rows that came in before the column existed.
func normalizeDraftLanguage(lang string) string {
if strings.EqualFold(strings.TrimSpace(lang), "en") {
return "en"
}
return "de"
}
// Compile-time guard: ensure the *models.User reference in the import
// graph doesn't get optimised away by linters. The service doesn't
// dereference User directly — that happens in SubmissionVarsService —

View File

@@ -76,13 +76,6 @@ type SubmissionVarsContext struct {
UserID uuid.UUID
ProjectID *uuid.UUID
SubmissionCode string
// Lang pins the output language for this Build, overriding the
// caller's UI preference (user.Lang). When empty, Build falls back
// to user.Lang so existing callers (the format-only Slice 1 path)
// keep working unchanged. The draft editor passes the per-draft
// `language` column (t-paliad-276) so DE/EN can be picked
// independently of the UI session.
Lang string
}
// SubmissionVarsResult bundles the placeholder map with the lookup
@@ -132,15 +125,7 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
return nil, err
}
// Per-call Lang override (t-paliad-276) wins over the user's UI
// language so the draft editor can render an EN .docx from a DE-UI
// session and vice versa. Falls back to the user pref when the
// caller didn't specify, preserving the format-only Slice 1
// behaviour.
lang := strings.ToLower(strings.TrimSpace(in.Lang))
if lang != "de" && lang != "en" {
lang = user.Lang
}
lang := user.Lang
if lang == "" {
lang = "de"
}

View File

@@ -1,450 +0,0 @@
// HL-firm skeleton submission template generator (t-paliad-275).
//
// Reads HLC's "HL Patents Style" .dotm letterhead, strips its VBA
// macros and template-only artifacts, then emits a clean .docx that:
//
// 1. Preserves every HL paragraph + character style (HLpat-Heading-H1,
// HLpat-Body-B0, HLpat-Signature, HLpat-Table-Recitals-*, …) by
// keeping word/styles.xml, word/theme/*, word/numbering.xml,
// word/fontTable.xml, settings.xml, footnotes/endnotes from the
// source .dotm untouched.
// 2. Preserves the firm letterhead (logo header + firm-address footer)
// by keeping word/header[12].xml + word/footer[12].xml and the
// sectPr that references them.
// 3. Replaces word/document.xml with a Schriftsatz-shaped body that
// exercises every SubmissionVarsService placeholder (firm.*,
// today.*, user.*, project.*, parties.*, procedural_event.*, rule.*,
// deadline.*) — applying HL paragraph/character styles to each
// section so the rendered output reads as a real HL submission with
// variables substituted.
//
// Drop the output into HL/mWorkRepo at
//
// 6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx
//
// so paliad's submission generator picks it up via the fallback chain.
// Lookup order after this CL: per-firm per-code → _firm-skeleton.docx
// (THIS file — HL formatting + placeholders) → universal _skeleton.docx
// (generic skeleton from t-paliad-259) → bare HL Patents Style .dotm
// (no placeholders). See internal/handlers/submission_drafts.go
// resolveSubmissionTemplate.
//
// Why this is firm-specific: the .dotm carries HL-licensed fonts,
// HL-branded logo media, and HLpat-prefixed style IDs. The output lives
// under the firm-namespaced directory in mWorkRepo so a future firm gets
// its own equivalent file generated against its own .dotm.
//
// Run:
//
// go run ./scripts/gen-hl-skeleton-template \
// -in /tmp/hl-patents-style.dotm \
// -out /tmp/_firm-skeleton.docx
//
// Output is byte-stable across runs for a given input (zip mtimes
// pinned).
package main
import (
"archive/zip"
"bytes"
"flag"
"fmt"
"io"
"os"
"strings"
"time"
)
func main() {
in := flag.String("in", "", "path to source HL Patents Style .dotm (required)")
out := flag.String("out", "_firm-skeleton.docx", "output .docx path")
flag.Parse()
if *in == "" {
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template: -in is required (path to HL Patents Style .dotm)")
os.Exit(2)
}
srcBytes, err := os.ReadFile(*in)
if err != nil {
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template: read source:", err)
os.Exit(1)
}
docx, err := buildDocx(srcBytes)
if err != nil {
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template:", err)
os.Exit(1)
}
if err := os.WriteFile(*out, docx, 0o644); err != nil {
fmt.Fprintln(os.Stderr, "gen-hl-skeleton-template: write:", err)
os.Exit(1)
}
fmt.Printf("wrote %s (%d bytes)\n", *out, len(docx))
}
// fixedTime pins every zip entry's mtime so successive runs over the
// same .dotm produce byte-stable output. Useful for diffing the
// generated file in PR review.
var fixedTime = time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
// dropPaths lists zip entries removed during the .dotm → .docx
// conversion. VBA macros + their keymap binding + the template-only
// glossary parts and ribbon customizations are all dead weight (and
// some actively trigger Word's macro-security warning) — none of them
// add anything to a placeholder-rich Schriftsatz starter.
var dropPaths = map[string]bool{
"word/vbaProject.bin": true,
"word/vbaData.xml": true,
"word/customizations.xml": true,
"userCustomization/customUI.xml": true,
"customUI/customUI14.xml": true,
"word/glossary/document.xml": true,
"word/glossary/_rels/document.xml.rels": true,
"word/glossary/fontTable.xml": true,
"word/glossary/numbering.xml": true,
"word/glossary/settings.xml": true,
"word/glossary/styles.xml": true,
"word/glossary/webSettings.xml": true,
}
// rIdsToDrop names the document-rel ids whose targets are stripped
// from the package (vbaProject, customizations.xml, glossary). They
// must vanish from word/_rels/document.xml.rels so Word doesn't choke
// on a dangling reference.
var rIdsToDrop = map[string]bool{
"rId1": true, // vbaProject.bin
"rId2": true, // customizations.xml (keymap to VBA)
"rId21": true, // glossary/document.xml
}
func buildDocx(src []byte) ([]byte, error) {
zr, err := zip.NewReader(bytes.NewReader(src), int64(len(src)))
if err != nil {
return nil, fmt.Errorf("open source zip: %w", err)
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for _, f := range zr.File {
name := f.Name
if dropPaths[name] {
continue
}
body, err := readZipEntry(f)
if err != nil {
return nil, fmt.Errorf("read %s: %w", name, err)
}
switch name {
case "[Content_Types].xml":
body = []byte(patchContentTypes(string(body)))
case "_rels/.rels":
body = []byte(patchRootRels(string(body)))
case "word/_rels/document.xml.rels":
body = []byte(patchDocumentRels(string(body)))
case "word/document.xml":
body = []byte(buildDocumentXML())
}
hdr := &zip.FileHeader{
Name: name,
Method: zip.Deflate,
Modified: fixedTime,
}
w, err := zw.CreateHeader(hdr)
if err != nil {
return nil, fmt.Errorf("create %s: %w", name, err)
}
if _, err := w.Write(body); err != nil {
return nil, fmt.Errorf("write %s: %w", name, err)
}
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("finalise zip: %w", err)
}
return buf.Bytes(), nil
}
func readZipEntry(f *zip.File) ([]byte, error) {
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(rc)
}
// patchContentTypes rewrites the macroEnabledTemplate part type to the
// regular wordprocessingml.document type (a .dotm carries the macro
// part type even on the body part), and removes Default/Override
// entries that target now-deleted parts (vba binary, customizations,
// glossary).
func patchContentTypes(in string) string {
out := in
out = strings.ReplaceAll(out,
`<Override PartName="/word/document.xml" ContentType="application/vnd.ms-word.template.macroEnabledTemplate.main+xml"/>`,
`<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>`)
removals := []string{
`<Default Extension="bin" ContentType="application/vnd.ms-office.vbaProject"/>`,
`<Override PartName="/word/vbaData.xml" ContentType="application/vnd.ms-word.vbaData+xml"/>`,
`<Override PartName="/word/customizations.xml" ContentType="application/vnd.ms-word.keyMapCustomizations+xml"/>`,
`<Override PartName="/word/glossary/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml"/>`,
`<Override PartName="/word/glossary/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/>`,
`<Override PartName="/word/glossary/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>`,
`<Override PartName="/word/glossary/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>`,
`<Override PartName="/word/glossary/webSettings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml"/>`,
`<Override PartName="/word/glossary/fontTable.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml"/>`,
}
for _, r := range removals {
out = strings.ReplaceAll(out, r, "")
}
return out
}
// patchRootRels drops the userCustomization (ribbon mini-tab) and the
// customUI14 extensibility relationships — both reference VBA-backed
// UI we don't ship.
func patchRootRels(in string) string {
out := in
out = stripRelByPrefix(out, `<Relationship Id="rId2" Type="http://schemas.microsoft.com/office/2006/relationships/ui/userCustomization"`)
out = stripRelByPrefix(out, `<Relationship Id="Rf8f70ab1afd0469a" Type="http://schemas.microsoft.com/office/2007/relationships/ui/extensibility"`)
return out
}
// patchDocumentRels drops the document-level rels whose targets we
// stripped (vbaProject, customizations.xml, glossaryDocument).
func patchDocumentRels(in string) string {
out := in
for rid := range rIdsToDrop {
needle := `<Relationship Id="` + rid + `" `
out = stripRelByPrefix(out, needle)
}
return out
}
// stripRelByPrefix removes the full <Relationship .../> element whose
// open tag starts with the given prefix. Tolerates either a regular
// closing tag (</Relationship>) or the more common self-closing form.
func stripRelByPrefix(s, prefix string) string {
for {
start := strings.Index(s, prefix)
if start < 0 {
return s
}
// Find end of this element (next "/>"). The .dotm always uses the
// self-closing form for Relationship elements.
end := strings.Index(s[start:], "/>")
if end < 0 {
return s
}
s = s[:start] + s[start+end+2:]
}
}
// buildDocumentXML emits a Schriftsatz skeleton that exercises every
// SubmissionVarsService placeholder (the canonical 48-key v1 contract
// + the procedural_event.* canonical names + their rule.* legacy
// aliases). The structure mirrors a real DE/UPC submission — title
// block → court → rubrum → patent reference → submission title →
// legal grounds → Sachverhalt/Anträge/Rechtsausführungen/Beweis →
// signature → locale-variant verification footer.
//
// Each placeholder lives in its own <w:r> run so the renderer's pass-1
// (format-preserving single-run replace) catches every key. HL
// paragraph styles (HLpat-Heading-H1, HLpat-Header-Section, etc.) are
// applied via pStyle, character styles via rStyle.
//
// The sectPr at the bottom is copied verbatim from the source .dotm
// so the firm header/footer references (rId16=header1, rId17=footer1,
// rId18=header2 first-page, rId19=footer2 first-page) keep resolving
// after we replace the body. pgSz/pgMar/cols/docGrid match the .dotm
// exactly — a lawyer printing this gets the same A4 layout the .dotm
// produces.
func buildDocumentXML() string {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">`)
b.WriteString(`<w:body>`)
skeletonBanner(&b)
heading(&b, "HLpat-Heading-H1", "{{firm.name}}")
body0(&b, "Bearbeiter: {{user.display_name}}")
body0(&b, "E-Mail: {{user.email}} · Büro: {{user.office}}")
body0(&b, "Datum: {{today.long_de}} ({{today.iso}})")
body0(&b, "{{firm.signature_block}}")
headerSection(&b, "{{project.court}}")
body0(&b, "Aktenzeichen: {{project.case_number}}")
body0(&b, "Verfahrensart: {{project.proceeding.name}} ({{project.proceeding.code}})")
body0(&b, "Instanz: {{project.instance_level}}")
headerSubsection(&b, "In der Sache")
recitalsParty(&b, "{{parties.claimant.name}}")
recitalsPartyDetails(&b, "vertreten durch {{parties.claimant.representative}}")
recitalsRoles(&b, "— Klägerin / Patentinhaberin / Anmelderin —")
recitalsSequencer(&b, "gegen")
recitalsParty(&b, "{{parties.defendant.name}}")
recitalsPartyDetails(&b, "vertreten durch {{parties.defendant.representative}}")
recitalsRoles(&b, "— Beklagte / Einsprechende / Beschwerdegegnerin —")
recitalsSequencer(&b, "sowie")
recitalsParty(&b, "{{parties.other.name}}")
recitalsPartyDetails(&b, "vertreten durch {{parties.other.representative}}")
recitalsRoles(&b, "— Weitere Beteiligte —")
headerSubsection(&b, "Betreff")
body0(&b, "Streitpatent: {{project.patent_number}} (UPC-Schreibweise: {{project.patent_number_upc}})")
body0(&b, "Anmeldung: {{project.filing_date}} · Erteilung: {{project.grant_date}}")
body0(&b, "Projekttitel: {{project.title}}")
body0(&b, "Unsere Seite: {{project.our_side_de}} ({{project.our_side}})")
body0(&b, "Mandant: {{project.client_number}} · Matter: {{project.matter_number}}")
body0(&b, "Internes Aktenzeichen: {{project.reference}}")
heading(&b, "HLpat-Heading-H1", "{{procedural_event.name}}")
body0(&b, "(Schriftsatz-Code: {{procedural_event.code}})")
body0(&b, "Rechtsgrundlage: {{procedural_event.legal_source_pretty}} ({{procedural_event.legal_source}})")
body0(&b, "Typische Partei: {{procedural_event.primary_party}} · Schriftsatz-Typ: {{procedural_event.event_kind}}")
headerSubsection(&b, "Frist")
body0(&b, "Frist-Bezeichnung: {{deadline.title}}")
body0(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
body0(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
body0(&b, "Berechnet aus: {{deadline.computed_from}} · Quelle: {{deadline.source}}")
heading(&b, "HLpat-Heading-H2", "I. Sachverhalt")
body0(&b, "[Hier folgt der Sachverhalt. Diese Vorlage ist eine Skelett-Fassung — bitte gemäß Schriftsatz-Typ ({{procedural_event.name}}) ausformulieren.]")
heading(&b, "HLpat-Heading-H2", "II. Anträge")
requestsIntro(&b, "Es wird beantragt:")
requestsLevel1(&b, "[Antrag 1 — gemäß {{procedural_event.legal_source_pretty}}]")
requestsLevel1(&b, "[Antrag 2]")
heading(&b, "HLpat-Heading-H2", "III. Rechtsausführungen")
body0(&b, "[Hier folgen die Rechtsausführungen.]")
heading(&b, "HLpat-Heading-H2", "IV. Beweis")
evidenceOffering(&b, "Beweis: [Beweismittel — z. B. Anlage K1: {{project.patent_number}}]")
heading(&b, "HLpat-Heading-H2", "Schlussformel")
signature(&b, "{{today.long_de}}")
signature(&b, "")
signature(&b, "{{user.display_name}}")
signature(&b, "{{firm.name}}")
// Locale-aware verification block — exercises every EN/DE alias the
// variable bag carries plus the rule.* legacy aliases so a lawyer
// editing the template sees that both surfaces resolve. A real
// submission deletes this section after sanity-checking the render.
heading(&b, "HLpat-Heading-H3", "Locale-Varianten & Legacy-Aliase (SKELETON)")
body1(&b, "EN long date: {{today.long_en}} · Today (bare alias): {{today}}")
body1(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
body1(&b, "Proceeding (DE): {{project.proceeding.name_de}}")
body1(&b, "Deadline EN long: {{deadline.due_date_long_en}}")
body1(&b, "Procedural event name (DE): {{procedural_event.name_de}} · (EN): {{procedural_event.name_en}}")
body1(&b, "Rule legacy aliases — name: {{rule.name}}, name_de: {{rule.name_de}}, name_en: {{rule.name_en}}")
body1(&b, "Rule legacy aliases — code: {{rule.submission_code}}, legal_source: {{rule.legal_source}}, legal_source_pretty: {{rule.legal_source_pretty}}")
body1(&b, "Rule legacy aliases — primary_party: {{rule.primary_party}}, event_type: {{rule.event_type}}")
// sectPr — copied verbatim from the source .dotm. Keeps the firm
// letterhead header (rId16=header1.xml, rId18=header2.xml first-page)
// and the firm-address footer (rId17, rId19) on every printed page.
b.WriteString(sectPrXML)
b.WriteString(`</w:body></w:document>`)
return b.String()
}
// sectPrXML matches the source .dotm's section properties exactly so
// the firm header/footer refs and A4 page geometry round-trip.
const sectPrXML = `<w:sectPr><w:headerReference w:type="default" r:id="rId16"/><w:footerReference w:type="default" r:id="rId17"/><w:headerReference w:type="first" r:id="rId18"/><w:footerReference w:type="first" r:id="rId19"/><w:pgSz w:w="11906" w:h="16838" w:code="9"/><w:pgMar w:top="567" w:right="1418" w:bottom="567" w:left="1418" w:header="284" w:footer="284" w:gutter="0"/><w:cols w:space="720"/><w:titlePg/><w:docGrid w:linePitch="286"/></w:sectPr>`
func skeletonBanner(b *strings.Builder) {
b.WriteString(`<w:p><w:pPr><w:pStyle w:val="HLpat-Heading-H1"/></w:pPr><w:r><w:rPr><w:b/><w:color w:val="C00000"/></w:rPr><w:t xml:space="preserve">SKELETON — HL Patents Style mit Platzhaltern (nicht freigegeben)</w:t></w:r></w:p>`)
}
func heading(b *strings.Builder, style, text string) { styledPara(b, style, "", text) }
func headerSection(b *strings.Builder, text string) { styledPara(b, "HLpat-Header-Section", "", text) }
func headerSubsection(b *strings.Builder, text string) { styledPara(b, "HLpat-Header-Subsection", "", text) }
func body0(b *strings.Builder, text string) { styledPara(b, "HLpat-Body-B0", "", text) }
func body1(b *strings.Builder, text string) { styledPara(b, "HLpat-Body-B1", "", text) }
func recitalsParty(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-Party", "", text) }
func recitalsPartyDetails(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-PartyDetails", "", text) }
func recitalsRoles(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-PartyRoles", "", text) }
func recitalsSequencer(b *strings.Builder, text string) { styledPara(b, "HLpat-Table-Recitals-Sequencers", "", text) }
func requestsIntro(b *strings.Builder, text string) { styledPara(b, "HLpat-Requests-Intro", "", text) }
func requestsLevel1(b *strings.Builder, text string) { styledPara(b, "HLpat-Requests-Level1", "", text) }
func evidenceOffering(b *strings.Builder, text string) { styledPara(b, "HLpat-EvidenceOffering", "", text) }
func signature(b *strings.Builder, text string) { styledPara(b, "HLpat-Signature", "", text) }
// styledPara writes one paragraph with the given pStyle (paragraph
// style id) and optional rStyle (character style applied to every run).
// Empty style ids drop the corresponding wrapper. Placeholders inside
// `text` are split into their own runs so the renderer's pass-1
// single-run replace catches each one independently.
func styledPara(b *strings.Builder, pStyle, rStyle, text string) {
b.WriteString(`<w:p>`)
if pStyle != "" {
b.WriteString(`<w:pPr><w:pStyle w:val="`)
b.WriteString(pStyle)
b.WriteString(`"/></w:pPr>`)
}
for _, seg := range splitOnPlaceholders(text) {
b.WriteString(`<w:r>`)
if rStyle != "" {
b.WriteString(`<w:rPr><w:rStyle w:val="`)
b.WriteString(rStyle)
b.WriteString(`"/></w:rPr>`)
}
b.WriteString(`<w:t xml:space="preserve">`)
b.WriteString(xmlEscape(seg))
b.WriteString(`</w:t></w:r>`)
}
b.WriteString(`</w:p>`)
}
func splitOnPlaceholders(s string) []string {
if s == "" {
return []string{""}
}
var out []string
for {
open := strings.Index(s, "{{")
if open < 0 {
out = append(out, s)
return out
}
close := strings.Index(s[open:], "}}")
if close < 0 {
out = append(out, s)
return out
}
end := open + close + 2
if open > 0 {
out = append(out, s[:open])
}
out = append(out, s[open:end])
s = s[end:]
if s == "" {
return out
}
}
}
func xmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
s = strings.ReplaceAll(s, "'", "&apos;")
return s
}