Compare commits
11 Commits
mai/pasteu
...
mai/knuth/
| Author | SHA1 | Date | |
|---|---|---|---|
| d86cac0b53 | |||
| 7c3c84454d | |||
| 61210943d9 | |||
| 74783e7a89 | |||
| 062afb6cc5 | |||
| 47b869dddf | |||
| c4c4fa267f | |||
| d555d5f679 | |||
| 875d0c149a | |||
| 92d0340d74 | |||
| f8c6206afe |
@@ -229,19 +229,10 @@ func main() {
|
||||
// Nil-safe: empty firm row falls back to the factory layout.
|
||||
svcBundle.DashboardLayout.SetFirmDefaultService(svcBundle.FirmDashboardDefault)
|
||||
|
||||
// t-paliad-215 Slice 1 — submission generator. Three services
|
||||
// stitched together by handlers/submissions.go: registry pulls
|
||||
// templates from Gitea (reuses GITEA_TOKEN env), vars builds
|
||||
// the placeholder map from project + parties + rule, renderer
|
||||
// merges {{placeholder}} tokens into the .docx.
|
||||
svcBundle.SubmissionRegistry = services.NewTemplateRegistry(giteaToken, branding.Name)
|
||||
svcBundle.SubmissionVars = services.NewSubmissionVarsService(
|
||||
pool,
|
||||
svcBundle.Project,
|
||||
svcBundle.Party,
|
||||
svcBundle.Users,
|
||||
)
|
||||
svcBundle.SubmissionRenderer = services.NewSubmissionRenderer()
|
||||
// t-paliad-230 — submission generator (format-only). No
|
||||
// service wiring needed: handlers/submissions.go reuses the
|
||||
// existing files.go HL Patents Style cache and calls
|
||||
// services.ConvertDotmToDocx (stateless function).
|
||||
|
||||
// Paliadin backend selection.
|
||||
//
|
||||
|
||||
226
frontend/src/client/dashboard-grid.test.ts
Normal file
226
frontend/src/client/dashboard-grid.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
GRID_COLUMNS,
|
||||
clampH,
|
||||
clampW,
|
||||
placeWidgets,
|
||||
type WidgetPlacementInput,
|
||||
} from "./dashboard-grid";
|
||||
|
||||
// Regression suite for m/paliad#70 (t-paliad-228): the post-#69 edit
|
||||
// mode produced overlapping widgets when a 2-col widget sat next to a
|
||||
// 1-col widget on the same row, when a drag swapped widgets of
|
||||
// different widths, and when a resize grew a widget into a sibling. The
|
||||
// fix moved the placement math into ./dashboard-grid + made it
|
||||
// collision-aware. These tests pin the no-overlap invariant.
|
||||
|
||||
function spec(
|
||||
key: string,
|
||||
x: number | undefined,
|
||||
y: number | undefined,
|
||||
w: number,
|
||||
h = 1,
|
||||
visible = true,
|
||||
): WidgetPlacementInput {
|
||||
return { key, visible, x, y, w, h };
|
||||
}
|
||||
|
||||
// hasOverlap returns true if any placed pair shares a cell. O(n²) is
|
||||
// fine — layouts cap at 32 widgets and the tests stay tiny.
|
||||
function hasOverlap(rects: Map<string, { x: number; y: number; w: number; h: number }>): string | null {
|
||||
const list = Array.from(rects.entries());
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const [ka, a] = list[i];
|
||||
for (let j = i + 1; j < list.length; j++) {
|
||||
const [kb, b] = list[j];
|
||||
const xOverlap = a.x < b.x + b.w && b.x < a.x + a.w;
|
||||
const yOverlap = a.y < b.y + b.h && b.y < a.y + a.h;
|
||||
if (xOverlap && yOverlap) return `${ka} ↔ ${kb} at (${a.x},${a.y},${a.w}x${a.h}) vs (${b.x},${b.y},${b.w}x${b.h})`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("placeWidgets — basic auto-flow", () => {
|
||||
test("places two 6-wide widgets side by side on row 0", () => {
|
||||
const out = placeWidgets([
|
||||
spec("a", undefined, undefined, 6),
|
||||
spec("b", undefined, undefined, 6),
|
||||
]);
|
||||
expect(out.get("a")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
|
||||
expect(out.get("b")).toEqual({ x: 6, y: 0, w: 6, h: 1 });
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("wraps when row doesn't fit", () => {
|
||||
const out = placeWidgets([
|
||||
spec("a", undefined, undefined, 8),
|
||||
spec("b", undefined, undefined, 8),
|
||||
]);
|
||||
expect(out.get("a")!.y).toBe(0);
|
||||
expect(out.get("b")!.y).toBeGreaterThan(0);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("hidden widgets are skipped and reserve no cells", () => {
|
||||
const out = placeWidgets([
|
||||
spec("hidden", 0, 0, 12, 1, false),
|
||||
spec("visible", undefined, undefined, 6),
|
||||
]);
|
||||
expect(out.has("hidden")).toBe(false);
|
||||
expect(out.get("visible")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeWidgets — explicit positions, no collision", () => {
|
||||
test("trusts non-colliding explicit positions exactly", () => {
|
||||
const out = placeWidgets([
|
||||
spec("a", 0, 0, 6),
|
||||
spec("b", 6, 0, 6),
|
||||
spec("c", 0, 1, 12),
|
||||
]);
|
||||
expect(out.get("a")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
|
||||
expect(out.get("b")).toEqual({ x: 6, y: 0, w: 6, h: 1 });
|
||||
expect(out.get("c")).toEqual({ x: 0, y: 1, w: 12, h: 1 });
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeWidgets — mixed-width collision (m/paliad#70 regression)", () => {
|
||||
test("1-col + 2-col on same row do not overlap when both explicit", () => {
|
||||
// Half-width left + half-width right is the canonical 'two widgets per
|
||||
// row' layout; pre-fix this was fine but the next regression below
|
||||
// exercises the actual bug.
|
||||
const out = placeWidgets([
|
||||
spec("left", 0, 0, 6),
|
||||
spec("right", 6, 0, 6),
|
||||
]);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("4-col + 8-col both claiming (0,0) end up non-overlapping", () => {
|
||||
// Simulates a post-#69 layout where a 4-wide widget sits at (0, 0)
|
||||
// and an 8-wide widget got accidentally placed at (0, 0) too (e.g.
|
||||
// a buggy reset path or a stale spec from before #70). Placer must
|
||||
// honour the first one's position and fit the second somewhere
|
||||
// free — landing it on the same row at x=4 is acceptable (better
|
||||
// density) as long as nothing overlaps.
|
||||
const out = placeWidgets([
|
||||
spec("first", 0, 0, 4),
|
||||
spec("colliding", 0, 0, 8),
|
||||
]);
|
||||
expect(out.get("first")).toEqual({ x: 0, y: 0, w: 4, h: 1 });
|
||||
expect(out.get("colliding")!.w).toBe(8);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("drag-drop swap of 12-wide onto 6-wide does not overlap", () => {
|
||||
// Setup before swap:
|
||||
// A at (0, 0, w=12) — full width row 0
|
||||
// B at (0, 1, w=6) — half row 1 left
|
||||
// C at (6, 1, w=6) — half row 1 right
|
||||
// User drags A onto B. reorderViaDnd swaps (x, y):
|
||||
// A.x=0, A.y=1
|
||||
// B.x=0, B.y=0
|
||||
// Result must not overlap C.
|
||||
const out = placeWidgets([
|
||||
spec("a", 0, 1, 12),
|
||||
spec("b", 0, 0, 6),
|
||||
spec("c", 6, 1, 6),
|
||||
]);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("auto-flow widget steps past explicit blocker on same row", () => {
|
||||
// Explicit widget at (6, 0, w=6); auto-flow widget would pack into
|
||||
// (0, 0, w=6) which is fine — but the next auto-flow widget at w=6
|
||||
// would want (6, 0) which is taken. Placer must wrap it.
|
||||
const out = placeWidgets([
|
||||
spec("flow-a", undefined, undefined, 6),
|
||||
spec("anchored", 6, 0, 6),
|
||||
spec("flow-b", undefined, undefined, 6),
|
||||
]);
|
||||
expect(out.get("flow-a")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
|
||||
expect(out.get("anchored")).toEqual({ x: 6, y: 0, w: 6, h: 1 });
|
||||
expect(out.get("flow-b")!.y).toBeGreaterThan(0);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeWidgets — resize-grow shifts siblings", () => {
|
||||
test("growing a 6-wide to 12-wide bumps the sibling on the same row", () => {
|
||||
// Pre-resize state:
|
||||
// A at (0, 0, w=6)
|
||||
// B at (6, 0, w=6)
|
||||
// User resizes A to w=12. resizeWidget() updates A.w but leaves B
|
||||
// at (6, 0). Placer must shift B down.
|
||||
const out = placeWidgets([
|
||||
spec("a", 0, 0, 12),
|
||||
spec("b", 6, 0, 6),
|
||||
]);
|
||||
expect(out.get("a")).toEqual({ x: 0, y: 0, w: 12, h: 1 });
|
||||
expect(out.get("b")!.y).toBeGreaterThan(0);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("growing widget pushes only the first colliding sibling", () => {
|
||||
// A grows to 12-wide; B and C on row 0 are both colliding. Both must
|
||||
// move; their relative order on row 0 is preserved (B at x=0, C at
|
||||
// x=6) on row 1.
|
||||
const out = placeWidgets([
|
||||
spec("a", 0, 0, 12),
|
||||
spec("b", 0, 0, 4),
|
||||
spec("c", 4, 0, 4),
|
||||
]);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
expect(out.get("a")!.y).toBe(0);
|
||||
expect(out.get("b")!.y).toBeGreaterThan(0);
|
||||
expect(out.get("c")!.y).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeWidgets — explicit position overflow clamp", () => {
|
||||
test("x+w > GRID_COLUMNS is clamped not rejected", () => {
|
||||
// A 12-wide widget with x=6 would extend past col 11. Placer must
|
||||
// clamp x to 0 (or wherever fits) so the widget renders inside the
|
||||
// grid.
|
||||
const out = placeWidgets([
|
||||
spec("wide", 6, 0, 12),
|
||||
]);
|
||||
const r = out.get("wide")!;
|
||||
expect(r.x + r.w).toBeLessThanOrEqual(GRID_COLUMNS);
|
||||
expect(r.w).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeWidgets — vertical (multi-row) widgets", () => {
|
||||
test("a 2-row-tall widget reserves both rows", () => {
|
||||
const out = placeWidgets([
|
||||
spec("tall", 0, 0, 6, 2),
|
||||
spec("collides-on-row-1", 0, 1, 6, 1),
|
||||
]);
|
||||
expect(out.get("tall")).toEqual({ x: 0, y: 0, w: 6, h: 2 });
|
||||
// The colliding widget must move because tall covers cols 0..5
|
||||
// on both row 0 and row 1. The placer may shift it to the right
|
||||
// half of row 1 (cols 6..11) or to a later row — either is fine
|
||||
// as long as nothing overlaps.
|
||||
const other = out.get("collides-on-row-1")!;
|
||||
expect(other.x >= 6 || other.y >= 2).toBe(true);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clamp helpers", () => {
|
||||
test("clampW respects min/max bounds", () => {
|
||||
expect(clampW(2, { min_w: 4, max_w: 12 })).toBe(4);
|
||||
expect(clampW(20, { min_w: 4, max_w: 12 })).toBe(12);
|
||||
expect(clampW(0, { default_w: 6 })).toBe(6);
|
||||
expect(clampW(NaN, { default_w: 8 })).toBe(8);
|
||||
});
|
||||
|
||||
test("clampH respects min/max bounds and MAX_ROW_SPAN", () => {
|
||||
expect(clampH(0, { default_h: 2 })).toBe(2);
|
||||
expect(clampH(99, undefined)).toBe(5); // MAX_ROW_SPAN
|
||||
expect(clampH(1, { min_h: 3 })).toBe(3);
|
||||
});
|
||||
});
|
||||
216
frontend/src/client/dashboard-grid.ts
Normal file
216
frontend/src/client/dashboard-grid.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
// dashboard-grid — pure layout math for the dashboard widget grid.
|
||||
//
|
||||
// Lives outside dashboard.ts so the placement logic is importable from
|
||||
// tests without dragging in the DOM-side rendering code. The grid is a
|
||||
// 12-column CSS Grid matching internal/services/dashboard_layout_spec.go;
|
||||
// rows grow vertically as widgets are placed.
|
||||
//
|
||||
// The core invariant is no-overlap: after placeWidgets() returns, every
|
||||
// pair of widgets occupies disjoint cells. Pre-overhaul callers wrote
|
||||
// computePlacements() to trust explicit (x, y) without checking — that
|
||||
// produced visual overlap whenever a drag or resize landed a widget on
|
||||
// cells another widget already covered (m/paliad#70). The collision-
|
||||
// aware placer below shifts colliding widgets to the next free row so
|
||||
// the rendered grid never overlaps regardless of the input spec.
|
||||
|
||||
export const GRID_COLUMNS = 12;
|
||||
export const MAX_ROW_SPAN = 5;
|
||||
|
||||
// Hard cap on the row-scan depth in findFreeSlot. The widget cap on a
|
||||
// single layout is 32 (LayoutWidgetCap on the Go side); each row holds
|
||||
// at least one widget, so 256 rows is an order-of-magnitude buffer
|
||||
// against runaway loops on pathological inputs.
|
||||
const MAX_SCAN_ROWS = 256;
|
||||
|
||||
export interface PlacedRect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
// WidgetSizeBound captures the per-widget min/max/default clamps the
|
||||
// catalog publishes. Optional fields keep callers from having to
|
||||
// synthesize zeroes when the catalog entry is missing.
|
||||
export interface WidgetSizeBound {
|
||||
default_w?: number;
|
||||
default_h?: number;
|
||||
min_w?: number;
|
||||
max_w?: number;
|
||||
min_h?: number;
|
||||
max_h?: number;
|
||||
}
|
||||
|
||||
// WidgetPlacementInput is the per-widget data the placer consumes. The
|
||||
// catalog bound is optional — when missing, defaults fall back to a
|
||||
// full-width 1-row widget.
|
||||
export interface WidgetPlacementInput {
|
||||
key: string;
|
||||
visible: boolean;
|
||||
x?: number;
|
||||
y?: number;
|
||||
w?: number;
|
||||
h?: number;
|
||||
bound?: WidgetSizeBound;
|
||||
}
|
||||
|
||||
export function clampW(w: number, bound: WidgetSizeBound | undefined): number {
|
||||
let v = Math.round(w);
|
||||
if (!Number.isFinite(v) || v <= 0) v = bound?.default_w ?? GRID_COLUMNS;
|
||||
v = Math.max(1, Math.min(GRID_COLUMNS, v));
|
||||
if (bound?.min_w && v < bound.min_w) v = bound.min_w;
|
||||
if (bound?.max_w && v > bound.max_w) v = bound.max_w;
|
||||
return v;
|
||||
}
|
||||
|
||||
export function clampH(h: number, bound: WidgetSizeBound | undefined): number {
|
||||
let v = Math.round(h);
|
||||
if (!Number.isFinite(v) || v <= 0) v = bound?.default_h ?? 1;
|
||||
v = Math.max(1, Math.min(MAX_ROW_SPAN, v));
|
||||
if (bound?.min_h && v < bound.min_h) v = bound.min_h;
|
||||
if (bound?.max_h && v > bound.max_h) v = bound.max_h;
|
||||
return v;
|
||||
}
|
||||
|
||||
// Occupancy bitmap: one row → Uint8Array of GRID_COLUMNS bits. Rows are
|
||||
// created lazily so the map only stores rows the layout actually
|
||||
// reaches. Cell value 1 = occupied.
|
||||
class Occupancy {
|
||||
private rows = new Map<number, Uint8Array>();
|
||||
|
||||
row(y: number): Uint8Array {
|
||||
let r = this.rows.get(y);
|
||||
if (!r) {
|
||||
r = new Uint8Array(GRID_COLUMNS);
|
||||
this.rows.set(y, r);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
free(x: number, y: number, w: number, h: number): boolean {
|
||||
if (x < 0 || y < 0 || x + w > GRID_COLUMNS) return false;
|
||||
for (let yy = y; yy < y + h; yy++) {
|
||||
const row = this.row(yy);
|
||||
for (let xx = x; xx < x + w; xx++) {
|
||||
if (row[xx]) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
mark(x: number, y: number, w: number, h: number): void {
|
||||
for (let yy = y; yy < y + h; yy++) {
|
||||
const row = this.row(yy);
|
||||
for (let xx = x; xx < x + w; xx++) row[xx] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// findFreeSlot scans for the first (x, y) where a w×h block fits without
|
||||
// collision, starting at row startY. At each row preferX is tried first
|
||||
// — that keeps a widget close to its requested column when only the row
|
||||
// is blocked. Falls back to left-to-right scan within the row, then to
|
||||
// the next row. Caller guarantees w ≤ GRID_COLUMNS.
|
||||
function findFreeSlot(
|
||||
occ: Occupancy,
|
||||
startY: number,
|
||||
w: number,
|
||||
h: number,
|
||||
preferX: number,
|
||||
): { x: number; y: number } {
|
||||
for (let y = startY; y < startY + MAX_SCAN_ROWS; y++) {
|
||||
if (preferX >= 0 && preferX + w <= GRID_COLUMNS && occ.free(preferX, y, w, h)) {
|
||||
return { x: preferX, y };
|
||||
}
|
||||
for (let x = 0; x + w <= GRID_COLUMNS; x++) {
|
||||
if (x === preferX) continue;
|
||||
if (occ.free(x, y, w, h)) return { x, y };
|
||||
}
|
||||
}
|
||||
// Pathological fallback — caller's widget cap (32) makes this
|
||||
// unreachable in practice. Snap to the bottom-left so the widget at
|
||||
// least renders somewhere visible instead of vanishing.
|
||||
return { x: 0, y: startY + MAX_SCAN_ROWS };
|
||||
}
|
||||
|
||||
// placeWidgets assigns no-overlap grid coordinates to every visible
|
||||
// widget. Hidden widgets are skipped and contribute no placement.
|
||||
//
|
||||
// Algorithm: iterate widgets in input order. For each visible widget:
|
||||
// 1. Clamp w/h against catalog bounds.
|
||||
// 2. If the spec carries explicit x and y, try that slot. On a
|
||||
// collision, search downward starting at the requested y for the
|
||||
// first free w×h block (preferring the requested x).
|
||||
// 3. If only x is explicit, search from y=0 at that x.
|
||||
// 4. Otherwise auto-flow: pack left-to-right under a running cursor;
|
||||
// when the row doesn't fit or is blocked by an explicitly-placed
|
||||
// widget, wrap to the next free row.
|
||||
//
|
||||
// The mixed-spec case (some widgets explicit, others auto-flow) is the
|
||||
// real-world layout — placing the explicit widgets first would change
|
||||
// the visual order, so we keep input order and let auto-flow widgets
|
||||
// step around any explicit blockers via the same collision search.
|
||||
export function placeWidgets(
|
||||
widgets: WidgetPlacementInput[],
|
||||
): Map<string, PlacedRect> {
|
||||
const out = new Map<string, PlacedRect>();
|
||||
const occ = new Occupancy();
|
||||
|
||||
// Auto-flow cursor — advances as we place flowed widgets. cursorY
|
||||
// tracks the row currently being filled; rowMaxH is the tallest
|
||||
// widget in that row so wrapping advances past it (not just past the
|
||||
// new widget's height — that would let taller previous neighbours
|
||||
// overlap into the wrap row).
|
||||
let cursorX = 0;
|
||||
let cursorY = 0;
|
||||
let rowMaxH = 0;
|
||||
|
||||
for (const w of widgets) {
|
||||
if (!w.visible) continue;
|
||||
const dw = clampW(w.w ?? w.bound?.default_w ?? GRID_COLUMNS, w.bound);
|
||||
const dh = clampH(w.h ?? w.bound?.default_h ?? 1, w.bound);
|
||||
|
||||
const hasX = typeof w.x === "number";
|
||||
const hasY = typeof w.y === "number";
|
||||
let placed: { x: number; y: number };
|
||||
|
||||
if (hasX && hasY) {
|
||||
// Clamp x so the widget never overflows the right edge — drag/
|
||||
// resize gestures can produce x+w > GRID_COLUMNS otherwise.
|
||||
const prefX = Math.max(0, Math.min(GRID_COLUMNS - dw, w.x as number));
|
||||
const prefY = Math.max(0, w.y as number);
|
||||
if (occ.free(prefX, prefY, dw, dh)) {
|
||||
placed = { x: prefX, y: prefY };
|
||||
} else {
|
||||
placed = findFreeSlot(occ, prefY, dw, dh, prefX);
|
||||
}
|
||||
} else if (hasX) {
|
||||
const prefX = Math.max(0, Math.min(GRID_COLUMNS - dw, w.x as number));
|
||||
placed = findFreeSlot(occ, 0, dw, dh, prefX);
|
||||
} else {
|
||||
// Auto-flow. Wrap the cursor when the widget wouldn't fit in the
|
||||
// remaining columns of the current row, then ask findFreeSlot to
|
||||
// honour the cursor's preferred (x, y) — that lets it step past
|
||||
// any explicit widget that already claimed cells under the
|
||||
// cursor.
|
||||
if (cursorX + dw > GRID_COLUMNS) {
|
||||
cursorY += rowMaxH || 1;
|
||||
cursorX = 0;
|
||||
rowMaxH = 0;
|
||||
}
|
||||
placed = findFreeSlot(occ, cursorY, dw, dh, cursorX);
|
||||
if (placed.y > cursorY) {
|
||||
// Wrap was forced by a collision deeper than the current row.
|
||||
cursorY = placed.y;
|
||||
rowMaxH = 0;
|
||||
}
|
||||
cursorX = placed.x + dw;
|
||||
if (dh > rowMaxH) rowMaxH = dh;
|
||||
}
|
||||
|
||||
occ.mark(placed.x, placed.y, dw, dh);
|
||||
out.set(w.key, { x: placed.x, y: placed.y, w: dw, h: dh });
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -2,6 +2,16 @@ import { initI18n, onLangChange, t, tDyn, getLang, translateEvent } from "./i18n
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { renderAgendaTimeline, type AgendaItem } from "./agenda-render";
|
||||
import { openModal } from "./components/modal";
|
||||
import {
|
||||
GRID_COLUMNS,
|
||||
MAX_ROW_SPAN,
|
||||
placeWidgets,
|
||||
clampW as gridClampW,
|
||||
clampH as gridClampH,
|
||||
type PlacedRect,
|
||||
type WidgetPlacementInput,
|
||||
type WidgetSizeBound,
|
||||
} from "./dashboard-grid";
|
||||
|
||||
interface DashboardUser {
|
||||
id: string;
|
||||
@@ -156,10 +166,9 @@ interface WidgetCatalogEntry {
|
||||
settings?: WidgetSettingsSchema | null;
|
||||
}
|
||||
|
||||
// Grid constants — must match internal/services/dashboard_layout_spec.go
|
||||
const GRID_COLUMNS = 12;
|
||||
const MAX_ROW_SPAN = 5;
|
||||
|
||||
// Grid constants — must match internal/services/dashboard_layout_spec.go.
|
||||
// Re-exported from ./dashboard-grid so the placement math is shared with
|
||||
// the unit tests; the names below keep the local imports tidy.
|
||||
declare global {
|
||||
interface Window {
|
||||
__PALIAD_DASHBOARD__?: DashboardData | null;
|
||||
@@ -1937,74 +1946,43 @@ function applyLayout(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// PlacedRect is the resolved grid position for a widget — non-zero w/h,
|
||||
// concrete x/y (0-indexed) derived from spec values plus auto-flow
|
||||
// fill-in for missing y values.
|
||||
interface PlacedRect { x: number; y: number; w: number; h: number; }
|
||||
|
||||
// computePlacements assigns explicit grid coordinates to every visible
|
||||
// widget. Spec values win when present; missing values fall back to:
|
||||
// - w: catalog default_w, else GRID_COLUMNS
|
||||
// - h: catalog default_h, else 1
|
||||
// - x: 0 when also missing y; else as given
|
||||
// - y: auto-flow — packs left-to-right under the running cursor,
|
||||
// wrapping when the row doesn't fit.
|
||||
//
|
||||
// Auto-flow keeps pre-overhaul layouts (no positions on the wire)
|
||||
// rendering as a tidy single column without the visual mess the old
|
||||
// applyLayout produced. Hidden widgets are skipped — they contribute
|
||||
// no placement and don't reserve row space.
|
||||
// computePlacements is the local adapter — it walks the layout's widgets,
|
||||
// resolves each widget's catalog bound, and hands the spec to the pure
|
||||
// placeWidgets() in ./dashboard-grid. The pure placer carries the no-
|
||||
// overlap invariant: if two widgets request colliding cells (drag-drop
|
||||
// swap with mismatched widths, resize-grow into a sibling, etc.) the
|
||||
// later one is shifted down to the next free row. See m/paliad#70.
|
||||
function computePlacements(widgets: DashboardWidgetRef[]): Map<string, PlacedRect> {
|
||||
const out = new Map<string, PlacedRect>();
|
||||
// Track the tallest widget on the row currently being filled, so
|
||||
// wrapping advances cursorY past the bottom of the row (not just by
|
||||
// the new widget's height — that would let taller previous neighbours
|
||||
// overlap). Mirrors the Go-side packer in FactoryDefaultLayout.
|
||||
let cursorX = 0, cursorY = 0, rowMaxH = 0;
|
||||
for (const w of widgets) {
|
||||
if (!w.visible) continue;
|
||||
const def = lookupCatalog(w.key);
|
||||
const dw = clampW(w.w ?? def?.default_w ?? GRID_COLUMNS, def);
|
||||
const dh = clampH(w.h ?? def?.default_h ?? 1, def);
|
||||
let x = typeof w.x === "number" ? w.x : -1;
|
||||
let y = typeof w.y === "number" ? w.y : -1;
|
||||
if (x < 0) {
|
||||
if (cursorX + dw > GRID_COLUMNS) {
|
||||
cursorY += rowMaxH;
|
||||
cursorX = 0;
|
||||
rowMaxH = 0;
|
||||
}
|
||||
x = cursorX;
|
||||
y = cursorY;
|
||||
cursorX += dw;
|
||||
if (dh > rowMaxH) rowMaxH = dh;
|
||||
} else {
|
||||
// Explicit x/y from the spec — trust it. Don't move the cursor
|
||||
// because explicit positions can land anywhere; auto-flow widgets
|
||||
// are positioned independently.
|
||||
if (y < 0) y = cursorY;
|
||||
}
|
||||
out.set(w.key, { x, y, w: dw, h: dh });
|
||||
}
|
||||
return out;
|
||||
const inputs: WidgetPlacementInput[] = widgets.map((w) => ({
|
||||
key: w.key,
|
||||
visible: w.visible,
|
||||
x: w.x,
|
||||
y: w.y,
|
||||
w: w.w,
|
||||
h: w.h,
|
||||
bound: toBound(lookupCatalog(w.key)),
|
||||
}));
|
||||
return placeWidgets(inputs);
|
||||
}
|
||||
|
||||
function clampW(w: number, def: WidgetCatalogEntry | undefined): number {
|
||||
let v = Math.round(w);
|
||||
if (!Number.isFinite(v) || v <= 0) v = def?.default_w ?? GRID_COLUMNS;
|
||||
v = Math.max(1, Math.min(GRID_COLUMNS, v));
|
||||
if (def?.min_w && v < def.min_w) v = def.min_w;
|
||||
if (def?.max_w && v > def.max_w) v = def.max_w;
|
||||
return v;
|
||||
return gridClampW(w, toBound(def));
|
||||
}
|
||||
|
||||
function clampH(h: number, def: WidgetCatalogEntry | undefined): number {
|
||||
let v = Math.round(h);
|
||||
if (!Number.isFinite(v) || v <= 0) v = def?.default_h ?? 1;
|
||||
v = Math.max(1, Math.min(MAX_ROW_SPAN, v));
|
||||
if (def?.min_h && v < def.min_h) v = def.min_h;
|
||||
if (def?.max_h && v > def.max_h) v = def.max_h;
|
||||
return v;
|
||||
return gridClampH(h, toBound(def));
|
||||
}
|
||||
|
||||
function toBound(def: WidgetCatalogEntry | undefined): WidgetSizeBound | undefined {
|
||||
if (!def) return undefined;
|
||||
return {
|
||||
default_w: def.default_w,
|
||||
default_h: def.default_h,
|
||||
min_w: def.min_w,
|
||||
max_w: def.max_w,
|
||||
min_h: def.min_h,
|
||||
max_h: def.max_h,
|
||||
};
|
||||
}
|
||||
|
||||
// filterByHorizonDays drops items whose key date is more than `days`
|
||||
|
||||
@@ -159,7 +159,7 @@ async function onGenerateClick(btn: HTMLButtonElement): Promise<void> {
|
||||
|
||||
try {
|
||||
const url = `/api/projects/${projectID}/submissions/${encodeURIComponent(code)}/generate`;
|
||||
const resp = await fetch(url, { method: "GET" });
|
||||
const resp = await fetch(url, { method: "POST" });
|
||||
if (!resp.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
|
||||
@@ -112,23 +112,23 @@ export function renderDashboard(): string {
|
||||
{/* Traffic-light deadline summary (4+1: Überfällig conditional + 4 universal — t-paliad-110) */}
|
||||
<CollapsibleSection id="summary" widgetKey="deadline-summary" headingI18n="dashboard.summary.heading" headingDe="Fristen auf einen Blick">
|
||||
<div className="dashboard-summary-grid">
|
||||
<a href="/deadlines?status=overdue" className="dashboard-card dashboard-card-red" id="dashboard-card-overdue">
|
||||
<a href="/events?type=deadline&status=overdue" className="dashboard-card dashboard-card-red" id="dashboard-card-overdue">
|
||||
<div className="dashboard-card-count" id="dashboard-count-overdue">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.overdue">Überfällig</div>
|
||||
</a>
|
||||
<a href="/deadlines?status=today" className="dashboard-card dashboard-card-today" id="dashboard-card-today">
|
||||
<a href="/events?type=deadline&status=today" className="dashboard-card dashboard-card-today" id="dashboard-card-today">
|
||||
<div className="dashboard-card-count" id="dashboard-count-today">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.today">Heute</div>
|
||||
</a>
|
||||
<a href="/deadlines?status=this_week" className="dashboard-card dashboard-card-amber" id="dashboard-card-thisweek">
|
||||
<a href="/events?type=deadline&status=this_week" className="dashboard-card dashboard-card-amber" id="dashboard-card-thisweek">
|
||||
<div className="dashboard-card-count" id="dashboard-count-this-week">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.this_week">Diese Woche</div>
|
||||
</a>
|
||||
<a href="/deadlines?status=next_week" className="dashboard-card dashboard-card-green" id="dashboard-card-nextweek">
|
||||
<a href="/events?type=deadline&status=next_week" className="dashboard-card dashboard-card-green" id="dashboard-card-nextweek">
|
||||
<div className="dashboard-card-count" id="dashboard-count-next-week">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.next_week">Nächste Woche</div>
|
||||
</a>
|
||||
<a href="/deadlines?status=later" className="dashboard-card dashboard-card-later" id="dashboard-card-later">
|
||||
<a href="/events?type=deadline&status=later" className="dashboard-card dashboard-card-later" id="dashboard-card-later">
|
||||
<div className="dashboard-card-count" id="dashboard-count-later">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.later">Später</div>
|
||||
</a>
|
||||
|
||||
@@ -30,6 +30,78 @@ type Entry struct {
|
||||
// Entries lists everything shipped so far, newest first. Append new rows
|
||||
// at the top.
|
||||
var Entries = []Entry{
|
||||
{
|
||||
Date: "2026-05-21",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Konfigurierbares Dashboard",
|
||||
TitleEN: "Configurable dashboard",
|
||||
BodyDE: "Das Dashboard lässt sich jetzt frei zusammenstellen: Widgets per Drag-and-drop verschieben, in der Größe ändern und einzeln konfigurieren. Der Katalog umfasst Fristen-Ampel, Termine, Agenda, Inbox-Übersicht, angepinnte Projekte und Schnellaktionen. Admins können eine kanzleiweite Standardanordnung festlegen, von der jeder Nutzer startet und sie nach Wunsch anpasst.",
|
||||
BodyEN: "The dashboard can now be assembled freely: drag-and-drop widgets, resize them and configure each one individually. The catalog covers the deadline traffic-light, appointments, agenda, inbox summary, pinned projects and quick actions. Admins can set a firm-wide default layout that every user starts from and then tweaks to taste.",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-20",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Eigene Einreichungs-Checklisten",
|
||||
TitleEN: "User-authored checklists",
|
||||
BodyDE: "Eigene Checklisten lassen sich per Wizard anlegen und gezielt mit einzelnen Kolleg:innen, einem Büro, einer Partnereinheit oder einem Projekt teilen. Admins können besonders gute Vorlagen kanzleiweit unter „Geteilte Vorlagen\" freigeben. Wird eine Vorlage später überarbeitet, erscheint an laufenden Instanzen ein Hinweis-Badge auf die neuere Version.",
|
||||
BodyEN: "Build your own filing checklists through a wizard and share them explicitly with individual colleagues, an office, a partner unit or a project. Admins can promote the best templates firm-wide under „Shared templates\". When a template is later revised, running instances surface a notice badge pointing at the newer version.",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-20",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Genehmigungen: Änderungen vorschlagen",
|
||||
TitleEN: "Approvals: suggest changes",
|
||||
BodyDE: "Im Inbox gibt es eine dritte Aktion neben „Genehmigen\" und „Ablehnen\": „Änderungen vorschlagen\". Ein Modal zeigt den ursprünglichen Wert, der Gegenvorschlag wandert mit einem Kommentar zurück an die Antragsteller:in. Der gesamte Austausch erscheint im Verlauf des Eintrags.",
|
||||
BodyEN: "Inbox now offers a third action alongside „Approve\" and „Reject\": „Suggest changes\". A modal shows the original value, the counter-proposal travels back to the requester together with a comment. The full exchange shows up in the entry's Verlauf.",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-20",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Mandant:innen-Rolle und automatische Projekt-Codes",
|
||||
TitleEN: "Client role and auto-derived project codes",
|
||||
BodyDE: "Mandant:innen lassen sich jetzt als eigene Rolle in das Team eines Projekts aufnehmen — separat von HLC-Mitgliedern und mit eigenem Sichtbarkeitsumfang. Außerdem leitet Paliad pro Projekt einen kompakten Code aus dem Baum ab (etwa /9999-1-EP123-CFI) und zeigt ihn als zweites Badge im Header und in jedem Projekt-Picker.",
|
||||
BodyEN: "Clients can now be added to a project's team as their own role — separate from HLC members and with their own visibility scope. In addition, Paliad derives a compact code per project from the ancestor tree (e.g. /9999-1-EP123-CFI) and shows it as a second badge in the header and in every project picker.",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-19",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Datenexport — Excel, CSV, JSON",
|
||||
TitleEN: "Data export — Excel, CSV, JSON",
|
||||
BodyDE: "Unter Einstellungen → Datenexport lassen sich alle sichtbaren Projekte, Fristen, Termine, Notizen und Checklisten als Excel-, CSV- oder JSON-Datei herunterladen. Auf jeder Projekt-Seite gibt es zusätzlich einen „Daten exportieren\"-Button, der nur den jeweiligen Teilbaum mitnimmt.",
|
||||
BodyEN: "Settings → Data export lets you download every project, deadline, appointment, note and checklist you can see as an Excel, CSV or JSON file. Each project page additionally offers a „Daten exportieren\" button that exports just that subtree.",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-15",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Eigene Sichten — Liste, Karten, Kalender, Timeline",
|
||||
TitleEN: "Custom views — list, cards, calendar, timeline",
|
||||
BodyDE: "Eigene Filter über Fristen, Termine und Projekte lassen sich speichern und als Liste, Karten, Kalender oder Timeline rendern. Jede Sicht erhält einen permanenten Link, lässt sich als SVG, PNG, CSV, JSON oder iCal exportieren und erscheint in der Seitenleiste unter „Meine Sichten\".",
|
||||
BodyEN: "Custom filters over deadlines, appointments and projects can be saved and rendered as list, cards, calendar or timeline. Each view gets a permalink, can be exported as SVG, PNG, CSV, JSON or iCal and shows up in the sidebar under „Meine Sichten\".",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-07",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Projekte-Seite mit Baum, Pinnungen und Karten-Ansicht",
|
||||
TitleEN: "Projects page with tree, pins and cards view",
|
||||
BodyDE: "Die Projekte-Seite öffnet jetzt mit einem zusammenklappbaren Baum, Volltextsuche und Chips für Mandant, Ort und Status. Häufig genutzte Projekte lassen sich oben anpinnen; die alternative Karten-Ansicht erlaubt frei per Drag-and-drop sortierbare Layouts pro Nutzer.",
|
||||
BodyEN: "The Projects page now opens with a collapsible tree, full-text search and chips for client, location and status. Frequently used projects can be pinned to the top; the alternative cards view supports per-user drag-and-drop layouts.",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-06",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Vier-Augen-Prinzip für Fristen und Termine",
|
||||
TitleEN: "Four-eyes principle for deadlines and appointments",
|
||||
BodyDE: "Pro Projekt lässt sich festlegen, dass Anlegen, Ändern, Abhaken und Löschen von Fristen oder Terminen durch eine zweite Person freigegeben werden müssen. Anfragen erscheinen im Inbox, am Eintrag selbst und mit „PENDING\"-Vermerk im CalDAV-Kalender. Admins pflegen die Regeln zentral unter /admin/approval-policies.",
|
||||
BodyEN: "Per project you can require that creating, editing, completing or deleting a deadline or appointment must be cleared by a second person. Requests show up in the inbox, on the entry itself and as a „PENDING\" marker in the CalDAV calendar. Admins maintain the rules centrally under /admin/approval-policies.",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-05",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Fristenrechner v3 — Entscheidungsbaum, Begriffe, DE/EPA/DPMA",
|
||||
TitleEN: "Deadline calculator v3 — decision tree, concepts, DE/EPA/DPMA",
|
||||
BodyDE: "Der Fristenrechner wurde grundlegend überarbeitet: ein Entscheidungsbaum führt durch Verfahren und Fristart, eine neue Begriffsebene fasst Wiedereinsetzung, Säumnis, Schriftsatznachreichung und Weiterbehandlung als wiederverwendbare Konzepte zusammen. Der Regelbestand wurde um deutsche Verfahren (PatG, BPatG, BGH), EPA- und DPMA-Strecken erweitert, mit aktuellen Werten und Querverweisen.",
|
||||
BodyEN: "The deadline calculator has been overhauled from the ground up: a decision tree walks you through proceeding and deadline type, and a new concept layer treats Wiedereinsetzung, default, post-filing and further processing as reusable cross-cutting building blocks. The rule corpus has been extended with German proceedings (PatG, BPatG, BGH), EPO and DPMA tracks, with current values and cross-references.",
|
||||
},
|
||||
{
|
||||
Date: "2026-04-30",
|
||||
Tag: TagFeature,
|
||||
|
||||
@@ -11,8 +11,15 @@ import "net/http"
|
||||
// to the canonical /events?type=deadline (t-paliad-115). Detail page
|
||||
// /deadlines/{id} stays type-specific. Drop this redirect once we're
|
||||
// confident no caches / bookmarks / external links still hit the old URL.
|
||||
//
|
||||
// Preserves the incoming query string so filter params (e.g. status=this_week
|
||||
// from the dashboard summary cards) survive the redirect.
|
||||
func handleDeadlinesListRedirect(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/events?type=deadline", http.StatusMovedPermanently)
|
||||
target := "/events?type=deadline"
|
||||
if r.URL.RawQuery != "" {
|
||||
target += "&" + r.URL.RawQuery
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
||||
}
|
||||
|
||||
func handleDeadlinesNewPage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -117,6 +118,45 @@ func handleFileRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"ok": "true", "message": "Cache cleared"})
|
||||
}
|
||||
|
||||
// fetchHLPatentsStyleBytes returns the cached HL Patents Style .dotm
|
||||
// bytes. Shared accessor used by both the /files/{slug} download path
|
||||
// (Word auto-update channel) and the submission generator
|
||||
// (handlers/submissions.go) so a refresh through one path is visible to
|
||||
// the other. First call warms the cache from Gitea synchronously;
|
||||
// subsequent calls are sub-millisecond. A stale-but-present cache is
|
||||
// returned immediately while a background refresh runs.
|
||||
func fetchHLPatentsStyleBytes(ctx context.Context) ([]byte, error) {
|
||||
entry, ok := fileRegistry[hlPatentsStyleSlug]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("file proxy: %s not registered", hlPatentsStyleSlug)
|
||||
}
|
||||
ce := getCacheEntry(hlPatentsStyleSlug)
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
} else if needsCheck {
|
||||
go fileCheckAndRefresh(ce, entry)
|
||||
}
|
||||
|
||||
ce.mu.RLock()
|
||||
defer ce.mu.RUnlock()
|
||||
if len(ce.data) == 0 {
|
||||
return nil, fmt.Errorf("file proxy: %s cache empty after fetch", hlPatentsStyleSlug)
|
||||
}
|
||||
out := make([]byte, len(ce.data))
|
||||
copy(out, ce.data)
|
||||
_ = ctx // ctx reserved for future timeout pass-through; fileFetch
|
||||
// uses the package httpClient timeout today.
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fileFetch downloads the file synchronously (first request).
|
||||
func fileFetch(ce *cacheEntry, entry fileEntry) error {
|
||||
sha, _ := giteaLatestSHA(entry)
|
||||
|
||||
@@ -98,15 +98,6 @@ type Services struct {
|
||||
Projection *services.ProjectionService
|
||||
Export *services.ExportService
|
||||
|
||||
// Submission generator (t-paliad-215) — Klageerwiderung &
|
||||
// friends. Three coordinated services: registry fetches templates
|
||||
// from Gitea; vars builds the placeholder map from project +
|
||||
// parties + rule; renderer merges the .docx. Wired together in
|
||||
// cmd/server/main.go; nil here when DATABASE_URL is unset.
|
||||
SubmissionRegistry *services.TemplateRegistry
|
||||
SubmissionVars *services.SubmissionVarsService
|
||||
SubmissionRenderer *services.SubmissionRenderer
|
||||
|
||||
// Paliadin is wired when DATABASE_URL is set. The concrete backend
|
||||
// is picked in cmd/server/main.go based on PALIADIN_REMOTE_HOST
|
||||
// (remote → mRiver via SSH) or local tmux availability. Stays nil
|
||||
@@ -123,14 +114,6 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
paliadinSvc = svc.Paliadin
|
||||
}
|
||||
|
||||
// Submission generator singletons (t-paliad-215). All three or
|
||||
// none — the handler short-circuits with 503 when any is nil.
|
||||
if svc != nil {
|
||||
submissionRegistry = svc.SubmissionRegistry
|
||||
submissionVars = svc.SubmissionVars
|
||||
submissionRenderer = svc.SubmissionRenderer
|
||||
}
|
||||
|
||||
if svc != nil {
|
||||
dbSvc = &dbServices{
|
||||
projects: svc.Project,
|
||||
@@ -323,11 +306,13 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("POST /api/projects/{id}/timeline/milestone", handleCreateProjectTimelineMilestone)
|
||||
protected.HandleFunc("POST /api/projects/{id}/timeline/anchor", handleProjectTimelineAnchor)
|
||||
protected.HandleFunc("POST /api/projects/{id}/timeline/skip", handleProjectTimelineSkip)
|
||||
// t-paliad-215 Slice 1 — submission generator. /submissions lists
|
||||
// the project's filing-type rules with template-availability flags;
|
||||
// /submissions/{code}/generate streams the rendered .docx.
|
||||
// t-paliad-230 — submission generator (format-only). /submissions
|
||||
// lists the project's published filing rules; /generate fetches the
|
||||
// universal HL Patents Style .dotm, strips the macro project, and
|
||||
// streams a clean .docx attachment. POST because each generation
|
||||
// writes an audit row.
|
||||
protected.HandleFunc("GET /api/projects/{id}/submissions", handleListProjectSubmissions)
|
||||
protected.HandleFunc("GET /api/projects/{id}/submissions/{code}/generate", handleGenerateProjectSubmission)
|
||||
protected.HandleFunc("POST /api/projects/{id}/submissions/{code}/generate", handleGenerateProjectSubmission)
|
||||
// /counterclaim creates a CCR sub-project linked via the new
|
||||
// paliad.projects.counterclaim_of FK (t-paliad-174 Slice 3).
|
||||
protected.HandleFunc("POST /api/projects/{id}/counterclaim", handleCreateProjectCounterclaim)
|
||||
|
||||
@@ -57,3 +57,29 @@ func TestStandaloneCalendarHandlers_RedirectToEventsKalender(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// /deadlines list redirect must forward the incoming query string so legacy
|
||||
// dashboard cards and external bookmarks like /deadlines?status=this_week
|
||||
// land at /events?type=deadline&status=this_week instead of losing the
|
||||
// filter. Regression for m's 2026-05-21 14:20 report.
|
||||
func TestDeadlinesListRedirect_PreservesQueryString(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"/deadlines", "/events?type=deadline"},
|
||||
{"/deadlines?status=this_week", "/events?type=deadline&status=this_week"},
|
||||
{"/deadlines?status=overdue&project_id=abc", "/events?type=deadline&status=overdue&project_id=abc"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
handleDeadlinesListRedirect(w, req)
|
||||
if w.Code != http.StatusMovedPermanently {
|
||||
t.Fatalf("%s: status = %d, want 301", tc.path, w.Code)
|
||||
}
|
||||
if got := w.Header().Get("Location"); got != tc.want {
|
||||
t.Fatalf("%s: Location = %q, want %q", tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
package handlers
|
||||
|
||||
// Submission generator HTTP layer (t-paliad-215 Slice 1).
|
||||
// Submission generator HTTP layer (t-paliad-230 — format-only scope
|
||||
// reduction of t-paliad-215).
|
||||
//
|
||||
// Endpoints:
|
||||
//
|
||||
// GET /api/projects/{id}/submissions
|
||||
// Lists the project's proceeding-relevant submission codes
|
||||
// and reports template availability for each. Powers the
|
||||
// SubmissionsPanel on the project detail page.
|
||||
// Lists the project's proceeding-relevant filing rules.
|
||||
// has_template is unconditionally true: every project gets
|
||||
// offered the universal HL Patents Style template.
|
||||
//
|
||||
// GET /api/projects/{id}/submissions/{code}/generate
|
||||
// Renders the .docx and streams it as an attachment download.
|
||||
// Writes one paliad.system_audit_log row and one
|
||||
// paliad.project_events row per generation. No server-side
|
||||
// binary persistence (design §3, m's Q3 pick).
|
||||
// POST /api/projects/{id}/submissions/{code}/generate
|
||||
// Fetches the cached HL Patents Style .dotm (same proxy used
|
||||
// by /files/hl-patents-style.dotm), converts it to a clean
|
||||
// .docx via services.ConvertDotmToDocx, writes one
|
||||
// paliad.system_audit_log row, and streams the result as an
|
||||
// attachment download.
|
||||
//
|
||||
// No variable substitution, no per-submission templates, no
|
||||
// project_events/documents writes. Those layers are deferred to a
|
||||
// future "merge engine" slice; today's generator hands the lawyer a
|
||||
// clean .docx of the firm style and lets them edit and save under
|
||||
// their own filename.
|
||||
//
|
||||
// Visibility: every endpoint runs through ProjectService.GetByID
|
||||
// (paliad.can_see_project gate). Unauthorised callers get 404, never
|
||||
// 403 — same convention as the rest of the project surfaces (avoids
|
||||
// project-existence enumeration).
|
||||
// (paliad.can_see_project gate). Unauthorised callers get 404 — same
|
||||
// convention as the rest of the project surfaces (no project-existence
|
||||
// enumeration).
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -33,29 +41,26 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/branding"
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// submissionRenderer + registry + vars are package-level singletons
|
||||
// wired by Register() once at boot. Stateless rendering + thread-safe
|
||||
// caches inside the registry mean no per-request construction.
|
||||
var (
|
||||
submissionRenderer *services.SubmissionRenderer
|
||||
submissionRegistry *services.TemplateRegistry
|
||||
submissionVars *services.SubmissionVarsService
|
||||
)
|
||||
|
||||
// submissionRenderTimeout caps a single generate request. Template
|
||||
// fetch (cache-miss) + rendering of a typical pleading takes well
|
||||
// under a second; the timeout exists to surface "Gitea is unreachable"
|
||||
// quickly rather than letting the browser spin.
|
||||
// submissionRenderTimeout caps a single generate request. .dotm fetch
|
||||
// is from the in-process cache (sub-millisecond) and the convert step
|
||||
// is a single zip round-trip; the timeout exists so a cold cache miss
|
||||
// against Gitea surfaces quickly rather than letting the browser spin.
|
||||
const submissionRenderTimeout = 30 * time.Second
|
||||
|
||||
// docxMime is the .docx Content-Type per the OOXML spec.
|
||||
const docxMime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
|
||||
// submissionListEntry is one row in the SubmissionsPanel.
|
||||
// hlPatentsStyleSlug names the universal style template inside the
|
||||
// fileRegistry in files.go. Both surfaces (the /files download for
|
||||
// Word's auto-update channel and this generator) share the same
|
||||
// cache entry so a refresh through one path is visible to the other.
|
||||
const hlPatentsStyleSlug = "hl-patents-style.dotm"
|
||||
|
||||
// submissionListEntry is one row in the Schriftsätze panel.
|
||||
type submissionListEntry struct {
|
||||
SubmissionCode string `json:"submission_code"`
|
||||
Name string `json:"name"`
|
||||
@@ -73,8 +78,10 @@ type submissionListResponse struct {
|
||||
Entries []submissionListEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// handleListProjectSubmissions returns the filing-type rules for the
|
||||
// project's proceeding, annotated with template availability.
|
||||
// handleListProjectSubmissions returns the published filing rules for
|
||||
// the project's proceeding_type. has_template is true for every row —
|
||||
// Slice 1 (t-paliad-230) ships one universal template, so the only
|
||||
// "no template" case is a project that has no proceeding_type bound.
|
||||
func handleListProjectSubmissions(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
@@ -83,9 +90,6 @@ func handleListProjectSubmissions(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireSubmissionsWired(w) {
|
||||
return
|
||||
}
|
||||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
||||
@@ -123,8 +127,6 @@ func handleListProjectSubmissions(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
if rule.EventType == nil || *rule.EventType != "filing" {
|
||||
// Hearings + decisions don't generate submissions. The
|
||||
// "Schriftsätze" panel only lists filings.
|
||||
continue
|
||||
}
|
||||
if rule.LifecycleState != "published" {
|
||||
@@ -134,7 +136,7 @@ func handleListProjectSubmissions(w http.ResponseWriter, r *http.Request) {
|
||||
SubmissionCode: *rule.SubmissionCode,
|
||||
Name: rule.Name,
|
||||
NameEN: rule.NameEN,
|
||||
HasTemplate: submissionRegistry.HasTemplate(ctx, *rule.SubmissionCode),
|
||||
HasTemplate: true,
|
||||
}
|
||||
if rule.EventType != nil {
|
||||
entry.EventType = *rule.EventType
|
||||
@@ -151,9 +153,10 @@ func handleListProjectSubmissions(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// handleGenerateProjectSubmission renders the .docx and streams it
|
||||
// back to the browser. Audits the generation; never persists the
|
||||
// rendered bytes server-side.
|
||||
// handleGenerateProjectSubmission fetches the universal HL Patents
|
||||
// Style .dotm, converts it to a clean .docx, writes one audit row, and
|
||||
// streams the result. No variable substitution; the bytes that go down
|
||||
// the wire are the firm style template with macros stripped.
|
||||
func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
@@ -162,9 +165,6 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireSubmissionsWired(w) {
|
||||
return
|
||||
}
|
||||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
||||
@@ -179,209 +179,162 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), submissionRenderTimeout)
|
||||
defer cancel()
|
||||
|
||||
varsResult, err := submissionVars.Build(ctx, services.SubmissionVarsContext{
|
||||
UserID: uid,
|
||||
ProjectID: projectID,
|
||||
SubmissionCode: submissionCode,
|
||||
})
|
||||
project, err := dbSvc.projects.GetByID(ctx, uid, projectID)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrSubmissionRuleNotFound) {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
rule, err := loadPublishedRuleByCode(ctx, submissionCode)
|
||||
if err != nil {
|
||||
if errors.Is(err, errRuleNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{
|
||||
"error": fmt.Sprintf("no published rule for submission_code %q", submissionCode),
|
||||
})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
log.Printf("submissions: load rule %q: %v", submissionCode, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "rule lookup failed"})
|
||||
return
|
||||
}
|
||||
|
||||
tmpl, err := submissionRegistry.Resolve(ctx, submissionCode)
|
||||
dotm, err := fetchHLPatentsStyleBytes(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrNoTemplate) {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "no template available for this submission",
|
||||
"hint": "ask an admin to upload a .docx template under templates/_base/ in mWorkRepo",
|
||||
})
|
||||
return
|
||||
}
|
||||
log.Printf("submissions: template resolve for %s: %v", submissionCode, err)
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "template repository unreachable",
|
||||
log.Printf("submissions: fetch HL Patents Style .dotm: %v", err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{
|
||||
"error": "template upstream unreachable",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
missing := services.DefaultMissingMarker(varsResult.Lang)
|
||||
rendered, err := submissionRenderer.Render(tmpl.Bytes, varsResult.Placeholders, missing)
|
||||
docx, err := services.ConvertDotmToDocx(dotm)
|
||||
if err != nil {
|
||||
log.Printf("submissions: render %s for project %s: %v", submissionCode, projectID, err)
|
||||
log.Printf("submissions: convert dotm for project %s code %s: %v", projectID, submissionCode, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "render failed",
|
||||
"error": "convert failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
filename := submissionFileName(varsResult, projectID)
|
||||
user, err := dbSvc.users.GetByID(ctx, uid)
|
||||
if err != nil {
|
||||
log.Printf("submissions: load user %s: %v", uid, err)
|
||||
}
|
||||
lang := "de"
|
||||
if user != nil && user.Lang != "" {
|
||||
lang = user.Lang
|
||||
}
|
||||
|
||||
// Audit + Verlauf writes. Best-effort with a background context so
|
||||
// the user still receives the download even if the audit insert
|
||||
// races a slow DB.
|
||||
filename := submissionFileName(rule, project, lang)
|
||||
|
||||
// Audit write is best-effort with a background context so the
|
||||
// download still succeeds if the DB races. Audit failure here only
|
||||
// affects the system_audit_log feed — never the user's response.
|
||||
bgCtx, cancelBG := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancelBG()
|
||||
if err := writeSubmissionAuditRow(bgCtx, varsResult, tmpl, submissionCode); err != nil {
|
||||
if err := writeSubmissionAuditRow(bgCtx, user, project.ID, submissionCode, rule.Name, filename); err != nil {
|
||||
log.Printf("submissions: audit insert failed (project=%s code=%s): %v", projectID, submissionCode, err)
|
||||
}
|
||||
if err := writeSubmissionProjectEvent(bgCtx, varsResult, tmpl, submissionCode); err != nil {
|
||||
log.Printf("submissions: project_events insert failed (project=%s code=%s): %v", projectID, submissionCode, err)
|
||||
}
|
||||
if err := writeSubmissionDocumentRow(bgCtx, varsResult, tmpl, submissionCode); err != nil {
|
||||
log.Printf("submissions: documents insert failed (project=%s code=%s): %v", projectID, submissionCode, err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", docxMime)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(rendered)))
|
||||
w.Header().Set("X-Paliad-Template-Sha", tmpl.SHA)
|
||||
w.Header().Set("X-Paliad-Template-Tier", tmpl.FirmTier)
|
||||
if _, err := w.Write(rendered); err != nil {
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(docx)))
|
||||
if _, err := w.Write(docx); err != nil {
|
||||
log.Printf("submissions: response write failed (project=%s code=%s): %v", projectID, submissionCode, err)
|
||||
}
|
||||
}
|
||||
|
||||
// requireSubmissionsWired returns false (and writes 503) when the
|
||||
// generator wasn't constructed at boot. Happens in DATABASE_URL-less
|
||||
// deployments — knowledge-platform-only stacks don't ship the
|
||||
// submission engine.
|
||||
func requireSubmissionsWired(w http.ResponseWriter) bool {
|
||||
if submissionRenderer == nil || submissionRegistry == nil || submissionVars == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "submission generator not configured",
|
||||
})
|
||||
return false
|
||||
// errRuleNotFound is the sentinel for "no published rule with that
|
||||
// submission_code" — distinguished from a generic DB error so the
|
||||
// handler returns 404 instead of 500.
|
||||
var errRuleNotFound = errors.New("submission rule not found")
|
||||
|
||||
// loadPublishedRuleByCode fetches the rule the user requested. Only
|
||||
// published+active rows resolve; drafts and archived rules never feed
|
||||
// a real submission.
|
||||
func loadPublishedRuleByCode(ctx context.Context, submissionCode string) (*models.DeadlineRule, error) {
|
||||
if submissionCode == "" {
|
||||
return nil, errRuleNotFound
|
||||
}
|
||||
return true
|
||||
var rule models.DeadlineRule
|
||||
err := dbSvc.projects.DB().GetContext(ctx, &rule,
|
||||
`SELECT id, proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
description, primary_party, event_type, duration_value, duration_unit,
|
||||
timing, rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
||||
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt,
|
||||
concept_id, legal_source, is_spawn, spawn_label, is_active,
|
||||
created_at, updated_at, lifecycle_state
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = $1
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true
|
||||
ORDER BY sequence_order
|
||||
LIMIT 1`, submissionCode)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
return nil, errRuleNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &rule, nil
|
||||
}
|
||||
|
||||
// submissionFileName builds the user-facing filename per design §7:
|
||||
//
|
||||
// {rule.name}-{project.case_number}-{YYYY-MM-DD}.docx
|
||||
//
|
||||
// Slashes and backslashes in case_number sanitise to underscores so
|
||||
// the file saves cleanly across Windows + macOS + Linux. Missing
|
||||
// case_number falls back to an 8-hex-char stable id from the project
|
||||
// UUID so the file still has a deterministic handle.
|
||||
func submissionFileName(vars *services.SubmissionVarsResult, projectID uuid.UUID) string {
|
||||
// submissionFileName produces the user-facing download name per
|
||||
// design §7: {rule.name}-{project.case_number}-{YYYY-MM-DD}.docx.
|
||||
// Empty case_number drops the segment entirely (no fallback hash —
|
||||
// the lawyer can rename if the project lacks an Aktenzeichen).
|
||||
// Umlauts in the rule name are ASCII-folded by SanitiseSubmissionFileName
|
||||
// so the file lands cleanly on legacy SMB shares.
|
||||
func submissionFileName(rule *models.DeadlineRule, project *models.Project, lang string) string {
|
||||
day := time.Now()
|
||||
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
|
||||
day = day.In(loc)
|
||||
}
|
||||
ruleName := strings.TrimSpace(vars.Rule.Name)
|
||||
if strings.EqualFold(vars.Lang, "en") {
|
||||
ruleName = strings.TrimSpace(vars.Rule.NameEN)
|
||||
ruleName := strings.TrimSpace(rule.Name)
|
||||
if strings.EqualFold(lang, "en") && strings.TrimSpace(rule.NameEN) != "" {
|
||||
ruleName = strings.TrimSpace(rule.NameEN)
|
||||
}
|
||||
if ruleName == "" {
|
||||
ruleName = "submission"
|
||||
}
|
||||
parts := []string{services.SanitiseSubmissionFileName(ruleName)}
|
||||
caseNo := ""
|
||||
if vars.Project != nil && vars.Project.CaseNumber != nil {
|
||||
caseNo = strings.TrimSpace(*vars.Project.CaseNumber)
|
||||
if project != nil && project.CaseNumber != nil {
|
||||
caseNo = strings.TrimSpace(*project.CaseNumber)
|
||||
}
|
||||
if caseNo == "" {
|
||||
caseNo = projectID.String()[:8]
|
||||
if caseNo != "" {
|
||||
parts = append(parts, services.SanitiseSubmissionFileName(caseNo))
|
||||
}
|
||||
caseNo = strings.ReplaceAll(caseNo, "/", "_")
|
||||
caseNo = strings.ReplaceAll(caseNo, `\`, "_")
|
||||
return fmt.Sprintf("%s-%s-%s.docx", ruleName, caseNo, day.Format("2006-01-02"))
|
||||
parts = append(parts, day.Format("2006-01-02"))
|
||||
return strings.Join(parts, "-") + ".docx"
|
||||
}
|
||||
|
||||
// writeSubmissionAuditRow files the org-wide audit entry. Reuses the
|
||||
// system_audit_log convention (event_type='submission.generated')
|
||||
// established in t-paliad-214's mig 102.
|
||||
func writeSubmissionAuditRow(ctx context.Context, vars *services.SubmissionVarsResult, tmpl *services.ResolvedTemplate, code string) error {
|
||||
// writeSubmissionAuditRow files one row in paliad.system_audit_log per
|
||||
// generation. event_type='submission.generated', scope='project',
|
||||
// scope_root=project_id. Metadata is intentionally small per Slice 1:
|
||||
// {submission_code, rule_name, filename} — enough for a reviewer to
|
||||
// reconstruct which template was offered to which project without
|
||||
// over-baking the audit shape.
|
||||
func writeSubmissionAuditRow(ctx context.Context, user *models.User, projectID uuid.UUID, submissionCode, ruleName, filename string) error {
|
||||
meta := map[string]any{
|
||||
"submission_code": code,
|
||||
"template_path": tmpl.Path,
|
||||
"template_sha": tmpl.SHA,
|
||||
"template_tier": tmpl.FirmTier,
|
||||
"project_id": vars.Project.ID.String(),
|
||||
"rule_id": vars.Rule.ID.String(),
|
||||
"firm": branding.Name,
|
||||
"submission_code": submissionCode,
|
||||
"rule_name": ruleName,
|
||||
"filename": filename,
|
||||
}
|
||||
body, _ := json.Marshal(meta)
|
||||
var (
|
||||
actorID any
|
||||
actorEmail string
|
||||
)
|
||||
if user != nil {
|
||||
actorID = user.ID
|
||||
actorEmail = user.Email
|
||||
}
|
||||
_, err := dbSvc.projects.DB().ExecContext(ctx,
|
||||
`INSERT INTO paliad.system_audit_log
|
||||
(event_type, actor_id, actor_email, scope, scope_root, metadata)
|
||||
VALUES ('submission.generated', $1, $2, 'project', $3, $4::jsonb)`,
|
||||
vars.User.ID, vars.User.Email, vars.Project.ID.String(), string(body),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// writeSubmissionProjectEvent surfaces the generation in the project
|
||||
// Verlauf / SmartTimeline. event_type stays free-text (no CHECK on
|
||||
// paliad.project_events.event_type per Slice 2 of SmartTimeline) so we
|
||||
// don't need a migration to introduce 'submission_generated'.
|
||||
func writeSubmissionProjectEvent(ctx context.Context, vars *services.SubmissionVarsResult, tmpl *services.ResolvedTemplate, code string) error {
|
||||
ruleName := strings.TrimSpace(vars.Rule.Name)
|
||||
if strings.EqualFold(vars.Lang, "en") {
|
||||
ruleName = strings.TrimSpace(vars.Rule.NameEN)
|
||||
}
|
||||
title := fmt.Sprintf("%s generiert", ruleName)
|
||||
if strings.EqualFold(vars.Lang, "en") {
|
||||
title = fmt.Sprintf("%s generated", ruleName)
|
||||
}
|
||||
meta := map[string]any{
|
||||
"submission_code": code,
|
||||
"template_path": tmpl.Path,
|
||||
"template_sha": tmpl.SHA,
|
||||
"template_tier": tmpl.FirmTier,
|
||||
"rule_id": vars.Rule.ID.String(),
|
||||
}
|
||||
body, _ := json.Marshal(meta)
|
||||
now := time.Now().UTC()
|
||||
_, err := dbSvc.projects.DB().ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_events
|
||||
(id, project_id, event_type, title, description, event_date,
|
||||
created_by, metadata, created_at, updated_at)
|
||||
VALUES ($1, $2, 'submission_generated', $3, NULL, $4, $5, $6::jsonb, $4, $4)`,
|
||||
uuid.New(), vars.Project.ID, title, now, vars.User.ID, string(body),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// writeSubmissionDocumentRow files the audit-only paliad.documents
|
||||
// row. file_path stays NULL — the bytes are regenerable from inputs
|
||||
// (m's Q3 pick: no server-side binary). doc_type='generated_submission'
|
||||
// is the additive marker; no CHECK constraint exists on doc_type, so
|
||||
// this requires no migration.
|
||||
func writeSubmissionDocumentRow(ctx context.Context, vars *services.SubmissionVarsResult, tmpl *services.ResolvedTemplate, code string) error {
|
||||
ruleName := strings.TrimSpace(vars.Rule.Name)
|
||||
if strings.EqualFold(vars.Lang, "en") {
|
||||
ruleName = strings.TrimSpace(vars.Rule.NameEN)
|
||||
}
|
||||
day := time.Now()
|
||||
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
|
||||
day = day.In(loc)
|
||||
}
|
||||
title := fmt.Sprintf("%s (generiert %s)", ruleName, day.Format("2006-01-02"))
|
||||
if strings.EqualFold(vars.Lang, "en") {
|
||||
title = fmt.Sprintf("%s (generated %s)", ruleName, day.Format("2006-01-02"))
|
||||
}
|
||||
provenance := map[string]any{
|
||||
"submission_code": code,
|
||||
"template_path": tmpl.Path,
|
||||
"template_sha": tmpl.SHA,
|
||||
"template_tier": tmpl.FirmTier,
|
||||
"firm": branding.Name,
|
||||
"rule_id": vars.Rule.ID.String(),
|
||||
}
|
||||
body, _ := json.Marshal(provenance)
|
||||
_, err := dbSvc.projects.DB().ExecContext(ctx,
|
||||
`INSERT INTO paliad.documents
|
||||
(id, project_id, title, doc_type, file_path, file_size, mime_type,
|
||||
ai_extracted, uploaded_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, 'generated_submission', NULL, NULL, $4, $5::jsonb, $6, now(), now())`,
|
||||
uuid.New(), vars.Project.ID, title, docxMime, string(body), vars.User.ID,
|
||||
actorID, actorEmail, projectID.String(), string(body),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -59,10 +59,16 @@ type projectChainRow struct {
|
||||
ProceedingCode *string `db:"proceeding_code"`
|
||||
}
|
||||
|
||||
// BuildProjectCode walks the ancestor chain via the existing
|
||||
// paliad.projects.path ltree and returns the assembled code. One DB
|
||||
// round-trip per call; suitable for per-row use in single-project
|
||||
// projection paths.
|
||||
// BuildProjectCode walks the ancestor chain via paliad.projects.path
|
||||
// and returns the assembled code. One DB round-trip per call; suitable
|
||||
// for per-row use in single-project projection paths.
|
||||
//
|
||||
// paliad.projects.path is stored as TEXT (dot-separated UUIDs), not as
|
||||
// the ltree extension type — see export_service.go comment "ltree as
|
||||
// text" and can_see_project's string_to_array decomposition. Ancestor
|
||||
// walks use the same string_to_array(path, '.')::uuid[] pattern as the
|
||||
// canonical visibility predicate; ltree operators (@>, nlevel) would
|
||||
// raise "operator does not exist: text @> text" at runtime.
|
||||
//
|
||||
// For list endpoints with many rows, the call still scales fine for
|
||||
// firm-scale datasets (order-of-100s); if profiling later flags it as
|
||||
@@ -72,10 +78,12 @@ func BuildProjectCode(ctx context.Context, db sqlx.QueryerContext, projectID uui
|
||||
SELECT p.id, p.type, p.title, p.reference, p.opponent_code,
|
||||
p.patent_number, p.proceeding_type_id,
|
||||
pt.code AS proceeding_code
|
||||
FROM paliad.projects p
|
||||
FROM paliad.projects target
|
||||
JOIN paliad.projects p
|
||||
ON p.id = ANY(string_to_array(target.path, '.')::uuid[])
|
||||
LEFT JOIN paliad.proceeding_types pt ON pt.id = p.proceeding_type_id
|
||||
WHERE p.path @> (SELECT path FROM paliad.projects WHERE id = $1)
|
||||
ORDER BY nlevel(p.path)
|
||||
WHERE target.id = $1
|
||||
ORDER BY array_position(string_to_array(target.path, '.')::uuid[], p.id)
|
||||
`
|
||||
rows := []projectChainRow{}
|
||||
if err := sqlx.SelectContext(ctx, db, &rows, query, projectID); err != nil {
|
||||
@@ -102,8 +110,13 @@ func PopulateProjectCodes(ctx context.Context, db sqlx.QueryerContext, targets [
|
||||
ids[i] = t.ID.String()
|
||||
}
|
||||
|
||||
// One CTE-based query: for each target id, fetch the full ancestor
|
||||
// chain joined to proceeding_types, ordered so we can group in Go.
|
||||
// One query: for each target id, fetch the full ancestor chain
|
||||
// joined to proceeding_types, ordered so we can group in Go.
|
||||
//
|
||||
// Ancestor walk uses string_to_array(path, '.')::uuid[] — same shape
|
||||
// as can_see_project. paliad.projects.path is TEXT, so ltree
|
||||
// operators (@>, nlevel) would fail with "operator does not exist:
|
||||
// text @> text". See BuildProjectCode doc comment for context.
|
||||
const query = `
|
||||
WITH targets AS (
|
||||
SELECT id, path
|
||||
@@ -114,9 +127,10 @@ func PopulateProjectCodes(ctx context.Context, db sqlx.QueryerContext, targets [
|
||||
p.id, p.type, p.title, p.reference, p.opponent_code,
|
||||
p.patent_number, p.proceeding_type_id,
|
||||
pt.code AS proceeding_code,
|
||||
nlevel(p.path) AS chain_level
|
||||
array_position(string_to_array(t.path, '.')::uuid[], p.id) AS chain_level
|
||||
FROM targets t
|
||||
JOIN paliad.projects p ON p.path @> t.path
|
||||
JOIN paliad.projects p
|
||||
ON p.id = ANY(string_to_array(t.path, '.')::uuid[])
|
||||
LEFT JOIN paliad.proceeding_types pt ON pt.id = p.proceeding_type_id
|
||||
ORDER BY t.id, chain_level
|
||||
`
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
package services
|
||||
|
||||
// Submission template renderer — in-house engine for the submission
|
||||
// generator (t-paliad-215, design doc
|
||||
// docs/design-submission-generator-2026-05-19.md §6).
|
||||
// Submission .dotm → .docx converter (t-paliad-230, "format-only" scope
|
||||
// reduction of the original t-paliad-215 submission generator).
|
||||
//
|
||||
// Design choice — why not lukasjarosch/go-docx:
|
||||
// The library's "nested placeholder" guard treats sibling placeholders
|
||||
// inside the same <w:t> run (e.g. "{{a}} ./. {{b}}") as nested and
|
||||
// refuses to replace either. Patent submissions routinely have multiple
|
||||
// placeholders per paragraph (party blocks especially), so the library
|
||||
// is a non-starter without a custom fork. The in-house renderer below
|
||||
// is ~150 LoC and handles both the single-run common case and the
|
||||
// cross-run case (where Word may split a placeholder across runs after
|
||||
// editing).
|
||||
// Word .dotm (macro-enabled template), .docm (macro-enabled document),
|
||||
// .dotx (template, no macros), and .docx (document, no macros) are all
|
||||
// OOXML zip containers. The macro-bearing variants carry an extra set
|
||||
// of parts:
|
||||
//
|
||||
// Placeholder grammar: {{[A-Za-z][A-Za-z0-9_.]*}} with optional
|
||||
// whitespace inside braces ({{ project.case_number }} ≡
|
||||
// {{project.case_number}}).
|
||||
// word/vbaProject.bin — the VBA project binary
|
||||
// word/_rels/vbaProject.bin.rels — auxiliary relationships
|
||||
// word/vbaData.xml — VBA support data
|
||||
// word/customizations.xml — keyMapCustomizations
|
||||
//
|
||||
// Missing-value behaviour: when a placeholder has no binding in the
|
||||
// PlaceholderMap, the renderer emits a marker token so the lawyer sees
|
||||
// the gap in Word rather than failing the request. See §6.3 of the
|
||||
// design doc.
|
||||
// plus a Content-Types override for each of those, a Default extension
|
||||
// declaring all .bin files as vbaProject, and a different "main" content
|
||||
// type for word/document.xml itself.
|
||||
//
|
||||
// ConvertDotmToDocx walks the zip, drops the macro parts, rewrites
|
||||
// [Content_Types].xml and word/_rels/document.xml.rels to remove every
|
||||
// reference to them, and switches the main document content type to
|
||||
// the plain .docx form. Every other part — styles, fonts, theme,
|
||||
// settings, document body, header/footer/numbering, glossary, custom
|
||||
// XML — passes through bit-for-bit at the original compression method
|
||||
// and modification time.
|
||||
//
|
||||
// No variable substitution. Today's slice hands the lawyer the firm
|
||||
// style template as a clean .docx so they can edit and save under
|
||||
// their own filename. The merge-engine slice is deferred.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
@@ -32,110 +38,132 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PlaceholderMap is the variable bag built by SubmissionVarsService.
|
||||
// Keys are dotted paths without braces (e.g. "project.case_number").
|
||||
// Values are the substituted text — already locale-aware, pretty-
|
||||
// printed, and sanitised by the caller.
|
||||
type PlaceholderMap map[string]string
|
||||
// The four OOXML "main" content types we may see on word/document.xml.
|
||||
// Anything other than docxMainContentType gets rewritten so the output
|
||||
// reads as a plain document.
|
||||
const (
|
||||
dotmMainContentType = "application/vnd.ms-word.template.macroEnabledTemplate.main+xml"
|
||||
docmMainContentType = "application/vnd.ms-word.document.macroEnabled.main+xml"
|
||||
dotxMainContentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml"
|
||||
docxMainContentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"
|
||||
)
|
||||
|
||||
// MissingPlaceholderFn translates an unbound placeholder key into the
|
||||
// in-document marker token. The default in DefaultMissingMarker is
|
||||
// "[KEIN WERT: <key>]" / "[NO VALUE: <key>]" depending on lang.
|
||||
type MissingPlaceholderFn func(key string) string
|
||||
|
||||
// DefaultMissingMarker returns the standard missing-value marker for
|
||||
// the given UI language.
|
||||
func DefaultMissingMarker(lang string) MissingPlaceholderFn {
|
||||
prefix := "KEIN WERT"
|
||||
if strings.EqualFold(lang, "en") {
|
||||
prefix = "NO VALUE"
|
||||
}
|
||||
return func(key string) string {
|
||||
return "[" + prefix + ": " + key + "]"
|
||||
}
|
||||
// Macro-related parts dropped wholesale from the output zip.
|
||||
var macroParts = map[string]bool{
|
||||
"word/vbaProject.bin": true,
|
||||
"word/_rels/vbaProject.bin.rels": true,
|
||||
"word/vbaData.xml": true,
|
||||
"word/customizations.xml": true,
|
||||
}
|
||||
|
||||
// placeholderRegex matches a single placeholder. The capture group
|
||||
// extracts the key name without braces or surrounding whitespace.
|
||||
//
|
||||
// Restricted to [A-Za-z][A-Za-z0-9_.]* so that stray "{{" sequences in
|
||||
// legal prose (extremely rare in DE/EN court briefs but possible)
|
||||
// don't get mistaken for placeholders. A genuine placeholder always
|
||||
// starts with an ASCII letter.
|
||||
var placeholderRegex = regexp.MustCompile(`\{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\}`)
|
||||
const (
|
||||
contentTypesPath = "[Content_Types].xml"
|
||||
documentRelsPath = "word/_rels/document.xml.rels"
|
||||
)
|
||||
|
||||
// SubmissionRenderer renders a .docx template into a .docx output by
|
||||
// substituting {{placeholder}} tokens with values from a PlaceholderMap.
|
||||
// Stateless; safe for concurrent use.
|
||||
type SubmissionRenderer struct{}
|
||||
// vbaDefaultExtensionRegex matches the `<Default Extension="bin"
|
||||
// ContentType=".../vbaProject"/>` row in [Content_Types].xml. After
|
||||
// vbaProject.bin is dropped, the Default is dead weight (and Word will
|
||||
// flag the file as macro-bearing if it survives).
|
||||
var vbaDefaultExtensionRegex = regexp.MustCompile(
|
||||
`\s*<Default\b[^>]*\bExtension\s*=\s*"bin"[^>]*\bContentType\s*=\s*"application/vnd\.ms-office\.vbaProject"[^>]*/>`,
|
||||
)
|
||||
|
||||
// NewSubmissionRenderer constructs the renderer.
|
||||
func NewSubmissionRenderer() *SubmissionRenderer {
|
||||
return &SubmissionRenderer{}
|
||||
}
|
||||
// macroOverridePartRegex matches any <Override PartName="…"/> element
|
||||
// whose PartName is one of the dropped macro parts. The /word/
|
||||
// prefix is the OOXML convention for the absolute part path in
|
||||
// [Content_Types].xml — file paths in the zip itself omit the leading
|
||||
// slash.
|
||||
var macroOverridePartRegex = regexp.MustCompile(
|
||||
`\s*<Override\b[^>]*\bPartName\s*=\s*"/word/(?:vbaProject\.bin|vbaData\.xml|customizations\.xml)"[^>]*/>`,
|
||||
)
|
||||
|
||||
// Render reads the .docx template at templateBytes, substitutes every
|
||||
// placeholder from vars (or emits the missing-marker token), and writes
|
||||
// the result to the returned byte slice. Unknown placeholders never
|
||||
// fail the render — the lawyer sees the marker in Word and fixes it.
|
||||
func (r *SubmissionRenderer) Render(templateBytes []byte, vars PlaceholderMap, missing MissingPlaceholderFn) ([]byte, error) {
|
||||
if missing == nil {
|
||||
missing = DefaultMissingMarker("de")
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(templateBytes), int64(len(templateBytes)))
|
||||
// macroRelTypeRegex matches the two macro-related relationship Types
|
||||
// in word/_rels/document.xml.rels: vbaProject (binds to vbaProject.bin)
|
||||
// and keyMapCustomizations (binds to customizations.xml). After both
|
||||
// targets are dropped, leaving the relationships in would make Word
|
||||
// flag the file as corrupt.
|
||||
var macroRelTypeRegex = regexp.MustCompile(
|
||||
`\s*<Relationship\b[^>]*\bType\s*=\s*"http://schemas\.microsoft\.com/office/2006/relationships/(?:vbaProject|keyMapCustomizations)"[^>]*/>`,
|
||||
)
|
||||
|
||||
// ConvertDotmToDocx rewrites a .dotm (or .docm, or .dotx) zip into a
|
||||
// clean .docx zip. Idempotent on a zip that is already a plain .docx.
|
||||
// Returns an error if the input is not a valid zip.
|
||||
func ConvertDotmToDocx(dotmBytes []byte) ([]byte, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(dotmBytes), int64(len(dotmBytes)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission template: open zip: %w", err)
|
||||
return nil, fmt.Errorf("dotm→docx: open zip: %w", err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
zw := zip.NewWriter(&out)
|
||||
defer zw.Close()
|
||||
|
||||
for _, entry := range zr.File {
|
||||
body, err := readZipEntry(entry)
|
||||
if macroParts[entry.Name] {
|
||||
continue
|
||||
}
|
||||
|
||||
body, err := readZipFile(entry)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission template: read %s: %w", entry.Name, err)
|
||||
return nil, fmt.Errorf("dotm→docx: read %s: %w", entry.Name, err)
|
||||
}
|
||||
if isWordXMLEntry(entry.Name) {
|
||||
body = substituteInDocumentXML(body, vars, missing)
|
||||
|
||||
switch entry.Name {
|
||||
case contentTypesPath:
|
||||
body = rewriteContentTypes(body)
|
||||
case documentRelsPath:
|
||||
body = rewriteDocumentRels(body)
|
||||
}
|
||||
|
||||
w, err := zw.CreateHeader(&zip.FileHeader{
|
||||
Name: entry.Name,
|
||||
Method: entry.Method,
|
||||
Modified: entry.Modified,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission template: write header %s: %w", entry.Name, err)
|
||||
return nil, fmt.Errorf("dotm→docx: write header %s: %w", entry.Name, err)
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
return nil, fmt.Errorf("submission template: write %s: %w", entry.Name, err)
|
||||
return nil, fmt.Errorf("dotm→docx: write body %s: %w", entry.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("submission template: finalise zip: %w", err)
|
||||
return nil, fmt.Errorf("dotm→docx: finalise zip: %w", err)
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// isWordXMLEntry returns true for the .docx parts that contain
|
||||
// substitutable text. We touch document.xml plus header*.xml and
|
||||
// footer*.xml (templates may put firm letterhead in a header) but
|
||||
// skip styles, theme, settings, comments, footnotes — none of which
|
||||
// should carry merge placeholders in a well-formed template.
|
||||
func isWordXMLEntry(name string) bool {
|
||||
switch {
|
||||
case name == "word/document.xml":
|
||||
return true
|
||||
case strings.HasPrefix(name, "word/header") && strings.HasSuffix(name, ".xml"):
|
||||
return true
|
||||
case strings.HasPrefix(name, "word/footer") && strings.HasSuffix(name, ".xml"):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
// rewriteContentTypes demotes any of the three non-docx "main" content
|
||||
// types to plain docx, drops the bin Default-Extension entry, and
|
||||
// drops every Override that targeted a dropped macro part.
|
||||
//
|
||||
// String-level substitution rather than encoding/xml: round-tripping
|
||||
// through Go's XML marshaller would re-emit the document with
|
||||
// canonical namespace declarations on every child, which Word reads
|
||||
// but which makes the binary diff unnecessarily large. Direct
|
||||
// substitution preserves the file's original shape.
|
||||
func rewriteContentTypes(body []byte) []byte {
|
||||
body = bytes.ReplaceAll(body, []byte(dotmMainContentType), []byte(docxMainContentType))
|
||||
body = bytes.ReplaceAll(body, []byte(docmMainContentType), []byte(docxMainContentType))
|
||||
body = bytes.ReplaceAll(body, []byte(dotxMainContentType), []byte(docxMainContentType))
|
||||
body = vbaDefaultExtensionRegex.ReplaceAll(body, nil)
|
||||
body = macroOverridePartRegex.ReplaceAll(body, nil)
|
||||
return body
|
||||
}
|
||||
|
||||
// readZipEntry slurps a zip entry's bytes.
|
||||
func readZipEntry(f *zip.File) ([]byte, error) {
|
||||
// rewriteDocumentRels drops the two macro-related relationships from
|
||||
// word/_rels/document.xml.rels (vbaProject + keyMapCustomizations) so
|
||||
// the manifest no longer points at parts the zip no longer carries.
|
||||
// Every other relationship — styles, settings, numbering, theme,
|
||||
// headers/footers, customXml — passes through untouched.
|
||||
func rewriteDocumentRels(body []byte) []byte {
|
||||
return macroRelTypeRegex.ReplaceAll(body, nil)
|
||||
}
|
||||
|
||||
// readZipFile slurps a zip entry's bytes.
|
||||
func readZipFile(f *zip.File) ([]byte, error) {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -144,172 +172,33 @@ func readZipEntry(f *zip.File) ([]byte, error) {
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
|
||||
// substituteInDocumentXML walks document XML and replaces every
|
||||
// {{placeholder}} occurrence inside <w:t> text nodes. Handles both
|
||||
// single-run placeholders (the common case for freshly authored
|
||||
// templates) and cross-run placeholders (where Word's autocorrect or
|
||||
// manual editing has split a placeholder across runs).
|
||||
//
|
||||
// Two-pass strategy:
|
||||
//
|
||||
// 1. Pass 1: replace placeholders that fit entirely within one
|
||||
// <w:t>…</w:t>. This is the 99% case and preserves all run-level
|
||||
// formatting (bold, italic, font runs).
|
||||
// 2. Pass 2: for paragraphs that still contain orphan "{{" or "}}"
|
||||
// markers after pass 1, merge the text of every <w:t> inside the
|
||||
// paragraph, run the replacement on the merged text, and rewrite
|
||||
// the paragraph's runs as a single <w:r><w:t>…</w:t></w:r> using
|
||||
// the formatting properties of the first run. Loses intra-paragraph
|
||||
// formatting on the affected paragraph — but only on paragraphs
|
||||
// where Word genuinely fragmented a placeholder.
|
||||
func substituteInDocumentXML(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn) []byte {
|
||||
replaced := substituteInTextNodes(body, vars, missing)
|
||||
if !needsCrossRunMerge(replaced) {
|
||||
return replaced
|
||||
}
|
||||
return substituteAcrossRuns(replaced, vars, missing)
|
||||
}
|
||||
|
||||
// wTextNodeRegex matches one <w:t …>contents</w:t> element, capturing
|
||||
// the contents. Attributes on <w:t> (xml:space="preserve") are preserved
|
||||
// because the entire match is rewritten.
|
||||
var wTextNodeRegex = regexp.MustCompile(`<w:t(\s[^>]*)?>([^<]*)</w:t>`)
|
||||
|
||||
// substituteInTextNodes runs the placeholder replacement inside each
|
||||
// <w:t> text node independently. Format-preserving for single-run
|
||||
// placeholders.
|
||||
func substituteInTextNodes(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn) []byte {
|
||||
return wTextNodeRegex.ReplaceAllFunc(body, func(match []byte) []byte {
|
||||
sub := wTextNodeRegex.FindSubmatch(match)
|
||||
attrs := string(sub[1])
|
||||
contents := xmlDecode(string(sub[2]))
|
||||
replaced := replacePlaceholders(contents, vars, missing)
|
||||
if replaced == contents {
|
||||
return match
|
||||
// SanitiseSubmissionFileName cleans a string for use inside a download
|
||||
// filename — strips path separators and quote characters that would
|
||||
// break Content-Disposition or confuse browsers across OSes. ASCII-folds
|
||||
// the small set of German umlaut letters that show up in submission
|
||||
// names today (Klageerwiderung, Berufungsbegründung, …) so the file
|
||||
// lands cleanly on legacy SMB shares whose layer is still cp1252.
|
||||
// Other Unicode is preserved so non-DE/EN names still produce a
|
||||
// recognisable file.
|
||||
func SanitiseSubmissionFileName(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
s = umlautFolder.Replace(s)
|
||||
s = strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '/', '\\':
|
||||
return '_'
|
||||
case '"', '\'':
|
||||
return -1
|
||||
}
|
||||
// xml:space="preserve" stays attached whenever the original
|
||||
// content had leading/trailing whitespace; ensure it's still
|
||||
// declared after replacement to avoid Word collapsing spaces.
|
||||
if !strings.Contains(attrs, "xml:space") &&
|
||||
(strings.HasPrefix(replaced, " ") || strings.HasSuffix(replaced, " ")) {
|
||||
attrs += ` xml:space="preserve"`
|
||||
}
|
||||
return []byte(`<w:t` + attrs + `>` + xmlEncode(replaced) + `</w:t>`)
|
||||
})
|
||||
}
|
||||
|
||||
// needsCrossRunMerge returns true when the body still contains an
|
||||
// unmatched "{{" or "}}" after pass 1 — a sign that Word fragmented
|
||||
// the placeholder across runs and pass 1 couldn't touch it.
|
||||
func needsCrossRunMerge(body []byte) bool {
|
||||
// Cheap heuristic: count "{{" vs "}}" inside <w:t> nodes. If we have
|
||||
// either marker present in the text-node space, pass 2 will handle
|
||||
// it. (Inside attributes or other XML, the markers don't matter.)
|
||||
for _, m := range wTextNodeRegex.FindAllSubmatch(body, -1) {
|
||||
t := string(m[2])
|
||||
if strings.Contains(t, "{{") || strings.Contains(t, "}}") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// wParagraphRegex matches one <w:p>…</w:p> paragraph block. Greedy
|
||||
// inner-content match is safe here because <w:p> elements do not nest
|
||||
// in WordprocessingML — a paragraph is the leaf container for text.
|
||||
var wParagraphRegex = regexp.MustCompile(`(?s)<w:p\b[^>]*>.*?</w:p>`)
|
||||
|
||||
// wRunPropsRegex pulls the first <w:rPr>…</w:rPr> block from a
|
||||
// paragraph so we can reuse it as the formatting of the merged run.
|
||||
var wRunPropsRegex = regexp.MustCompile(`(?s)<w:rPr>.*?</w:rPr>`)
|
||||
|
||||
// wParagraphPropsRegex pulls the optional <w:pPr>…</w:pPr> that sits
|
||||
// at the top of a paragraph (alignment, spacing, etc.). Preserved.
|
||||
var wParagraphPropsRegex = regexp.MustCompile(`(?s)<w:pPr>.*?</w:pPr>`)
|
||||
|
||||
// substituteAcrossRuns is pass 2: for any paragraph that still has a
|
||||
// split placeholder, concatenate every text node, run replacement, and
|
||||
// rewrite the paragraph as a single run using the first run's
|
||||
// properties. Paragraphs without orphan markers are left untouched so
|
||||
// run-level formatting survives wherever pass 1 already resolved the
|
||||
// placeholders.
|
||||
func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn) []byte {
|
||||
return wParagraphRegex.ReplaceAllFunc(body, func(para []byte) []byte {
|
||||
textNodes := wTextNodeRegex.FindAllSubmatch(para, -1)
|
||||
if len(textNodes) == 0 {
|
||||
return para
|
||||
}
|
||||
var merged strings.Builder
|
||||
for _, m := range textNodes {
|
||||
merged.WriteString(xmlDecode(string(m[2])))
|
||||
}
|
||||
original := merged.String()
|
||||
if !strings.Contains(original, "{{") {
|
||||
// No fragmented placeholder in this paragraph; leave it
|
||||
// alone so pass 1's run-level edits survive.
|
||||
return para
|
||||
}
|
||||
replaced := replacePlaceholders(original, vars, missing)
|
||||
if replaced == original {
|
||||
return para
|
||||
}
|
||||
// Preserve paragraph properties (alignment, spacing) and the
|
||||
// first run's properties (font, bold/italic).
|
||||
pPr := wParagraphPropsRegex.Find(para)
|
||||
rPr := wRunPropsRegex.Find(para)
|
||||
var rebuilt bytes.Buffer
|
||||
rebuilt.WriteString(`<w:p>`)
|
||||
if pPr != nil {
|
||||
rebuilt.Write(pPr)
|
||||
}
|
||||
rebuilt.WriteString(`<w:r>`)
|
||||
if rPr != nil {
|
||||
rebuilt.Write(rPr)
|
||||
}
|
||||
rebuilt.WriteString(`<w:t xml:space="preserve">`)
|
||||
rebuilt.WriteString(xmlEncode(replaced))
|
||||
rebuilt.WriteString(`</w:t></w:r></w:p>`)
|
||||
return rebuilt.Bytes()
|
||||
})
|
||||
}
|
||||
|
||||
// replacePlaceholders performs the actual substitution on a plain
|
||||
// string. Unbound placeholders render the missing marker.
|
||||
func replacePlaceholders(s string, vars PlaceholderMap, missing MissingPlaceholderFn) string {
|
||||
return placeholderRegex.ReplaceAllStringFunc(s, func(match string) string {
|
||||
sub := placeholderRegex.FindStringSubmatch(match)
|
||||
if len(sub) < 2 {
|
||||
return match
|
||||
}
|
||||
key := sub[1]
|
||||
if value, ok := vars[key]; ok {
|
||||
return value
|
||||
}
|
||||
return missing(key)
|
||||
})
|
||||
}
|
||||
|
||||
// xmlDecode reverses the small set of escapes used in WordprocessingML
|
||||
// text content. We don't need a full XML parser — text nodes carry only
|
||||
// the standard five entities, and Word never emits numeric-character
|
||||
// references inside <w:t> for printable content.
|
||||
func xmlDecode(s string) string {
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, """, `"`)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
return r
|
||||
}, s)
|
||||
return s
|
||||
}
|
||||
|
||||
// xmlEncode escapes a substituted value for safe insertion back into a
|
||||
// WordprocessingML text node. & must be replaced first to avoid double
|
||||
// encoding the entity prefixes we introduce on the other characters.
|
||||
func xmlEncode(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return s
|
||||
}
|
||||
// umlautFolder turns the four DE umlaut letters (both cases) into ASCII
|
||||
// digraphs; ß → ss.
|
||||
var umlautFolder = strings.NewReplacer(
|
||||
"ä", "ae", "ö", "oe", "ü", "ue",
|
||||
"Ä", "Ae", "Ö", "Oe", "Ü", "Ue",
|
||||
"ß", "ss",
|
||||
)
|
||||
|
||||
@@ -6,392 +6,249 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// minimalDOCX builds a tiny .docx zip with one document.xml that
|
||||
// contains the given body. Just enough to exercise the renderer
|
||||
// without depending on Word's full OOXML scaffolding.
|
||||
func minimalDOCX(t *testing.T, documentBody string) []byte {
|
||||
// minimalDOTM builds a small .dotm zip whose shape mirrors the real
|
||||
// HL Patents Style template: macro-enabled main content type, Default
|
||||
// extension declaring .bin as vbaProject, Overrides for vbaData.xml +
|
||||
// customizations.xml, document.xml.rels with vbaProject +
|
||||
// keyMapCustomizations relationships, and the four macro parts on
|
||||
// disk (vbaProject.bin + auxiliary rels + vbaData.xml +
|
||||
// customizations.xml).
|
||||
//
|
||||
// In-memory so the test is self-contained (no checked-in binary).
|
||||
// Word and LibreOffice would reject this minimal file as incomplete
|
||||
// (no _rels/.rels root manifest); the tests work at the byte level
|
||||
// and assert structural properties of the converted output.
|
||||
func minimalDOTM(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
w, err := zw.Create("word/document.xml")
|
||||
if err != nil {
|
||||
t.Fatalf("create document.xml: %v", err)
|
||||
}
|
||||
if _, err := io.WriteString(w, documentBody); err != nil {
|
||||
t.Fatalf("write document.xml: %v", err)
|
||||
}
|
||||
// Drop in a stub Content-Types so the bytes look more like a real
|
||||
// .docx for any downstream sanity checks; Word doesn't care about
|
||||
// the content during our unit tests but the shape stays honest.
|
||||
w2, err := zw.Create("[Content_Types].xml")
|
||||
if err != nil {
|
||||
t.Fatalf("create content types: %v", err)
|
||||
}
|
||||
if _, err := io.WriteString(w2, `<?xml version="1.0"?><Types/>`); err != nil {
|
||||
t.Fatalf("write content types: %v", err)
|
||||
add := func(name, body string) {
|
||||
t.Helper()
|
||||
w, err := zw.CreateHeader(&zip.FileHeader{
|
||||
Name: name,
|
||||
Method: zip.Deflate,
|
||||
Modified: time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("zip header %s: %v", name, err)
|
||||
}
|
||||
if _, err := io.WriteString(w, body); err != nil {
|
||||
t.Fatalf("write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
add(contentTypesPath, `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+
|
||||
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`+
|
||||
`<Default Extension="bin" ContentType="application/vnd.ms-office.vbaProject"/>`+
|
||||
`<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>`+
|
||||
`<Default Extension="xml" ContentType="application/xml"/>`+
|
||||
`<Override PartName="/word/document.xml" ContentType="`+dotmMainContentType+`"/>`+
|
||||
`<Override PartName="/word/customizations.xml" ContentType="application/vnd.ms-word.keyMapCustomizations+xml"/>`+
|
||||
`<Override PartName="/word/vbaData.xml" ContentType="application/vnd.ms-word.vbaData+xml"/>`+
|
||||
`<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>`+
|
||||
`</Types>`)
|
||||
|
||||
add("word/document.xml",
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+
|
||||
`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`+
|
||||
`<w:body><w:p><w:r><w:t>Hello Paliad</w:t></w:r></w:p></w:body></w:document>`)
|
||||
|
||||
add(documentRelsPath,
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+
|
||||
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">`+
|
||||
`<Relationship Id="rId1" Type="http://schemas.microsoft.com/office/2006/relationships/vbaProject" Target="vbaProject.bin"/>`+
|
||||
`<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>`+
|
||||
`<Relationship Id="rId3" Type="http://schemas.microsoft.com/office/2006/relationships/keyMapCustomizations" Target="customizations.xml"/>`+
|
||||
`</Relationships>`)
|
||||
|
||||
add("word/styles.xml", `<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>`)
|
||||
add("word/vbaProject.bin", "PRETEND-VBA-BINARY-PAYLOAD")
|
||||
add("word/_rels/vbaProject.bin.rels", `<?xml version="1.0"?><Relationships/>`)
|
||||
add("word/vbaData.xml", `<?xml version="1.0"?><wne:vbaSuppData xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml"/>`)
|
||||
add("word/customizations.xml", `<?xml version="1.0"?><wne:tcg xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml"/>`)
|
||||
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("close zip: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// readDocumentXML pulls word/document.xml out of a rendered .docx.
|
||||
func readDocumentXML(t *testing.T, b []byte) string {
|
||||
func unzipEntries(t *testing.T, data []byte) map[string]string {
|
||||
t.Helper()
|
||||
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
t.Fatalf("open rendered zip: %v", err)
|
||||
t.Fatalf("open output zip: %v", err)
|
||||
}
|
||||
out := make(map[string]string, len(zr.File))
|
||||
for _, f := range zr.File {
|
||||
if f.Name != "word/document.xml" {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("open document.xml: %v", err)
|
||||
t.Fatalf("open %s: %v", f.Name, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
body, err := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("read document.xml: %v", err)
|
||||
t.Fatalf("read %s: %v", f.Name, err)
|
||||
}
|
||||
return string(body)
|
||||
out[f.Name] = string(body)
|
||||
}
|
||||
t.Fatal("rendered .docx had no word/document.xml")
|
||||
return ""
|
||||
return out
|
||||
}
|
||||
|
||||
// TestRender_SingleRunPlaceholder covers the 99% case: a placeholder
|
||||
// that sits inside a single <w:t> text node.
|
||||
func TestRender_SingleRunPlaceholder(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
|
||||
func TestConvertDotmToDocx_StripsMacroParts(t *testing.T) {
|
||||
dotm := minimalDOTM(t)
|
||||
out, err := ConvertDotmToDocx(dotm)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
t.Fatalf("ConvertDotmToDocx: %v", err)
|
||||
}
|
||||
body := readDocumentXML(t, out)
|
||||
if !strings.Contains(body, ">HLC<") {
|
||||
t.Errorf("expected HLC in body, got %q", body)
|
||||
}
|
||||
if strings.Contains(body, "{{") {
|
||||
t.Errorf("unreplaced placeholder marker in body: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRender_MultiplePlaceholdersPerRun is the case go-docx fails on
|
||||
// — sibling placeholders inside the same <w:t> run. The in-house
|
||||
// renderer must handle them.
|
||||
func TestRender_MultiplePlaceholdersPerRun(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{parties.claimant.name}}, vertreten durch {{parties.claimant.representative}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{
|
||||
"parties.claimant.name": "Acme Inc.",
|
||||
"parties.claimant.representative": "Kanzlei Müller",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
body := readDocumentXML(t, out)
|
||||
if !strings.Contains(body, "Acme Inc.") || !strings.Contains(body, "Kanzlei Müller") {
|
||||
t.Errorf("expected both party values, got %q", body)
|
||||
}
|
||||
if strings.Contains(body, "{{") {
|
||||
t.Errorf("unreplaced placeholder marker in body: %q", body)
|
||||
}
|
||||
}
|
||||
entries := unzipEntries(t, out)
|
||||
|
||||
// TestRender_MissingMarker confirms unbound placeholders render the
|
||||
// missing-value marker instead of failing the request.
|
||||
func TestRender_MissingMarker(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{project.case_number}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{}, DefaultMissingMarker("de"))
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
body := readDocumentXML(t, out)
|
||||
if !strings.Contains(body, "[KEIN WERT: project.case_number]") {
|
||||
t.Errorf("expected KEIN WERT marker, got %q", body)
|
||||
}
|
||||
outEN, err := r.Render(tmpl, PlaceholderMap{}, DefaultMissingMarker("en"))
|
||||
if err != nil {
|
||||
t.Fatalf("render en: %v", err)
|
||||
}
|
||||
bodyEN := readDocumentXML(t, outEN)
|
||||
if !strings.Contains(bodyEN, "[NO VALUE: project.case_number]") {
|
||||
t.Errorf("expected NO VALUE marker, got %q", bodyEN)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRender_CrossRunPlaceholder simulates Word fragmenting a
|
||||
// placeholder across runs (autocorrect or post-edit run-split).
|
||||
// Pass 2 must catch it.
|
||||
func TestRender_CrossRunPlaceholder(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>Hello {{</w:t></w:r><w:r><w:t>project</w:t></w:r><w:r><w:t>.case_number}}!</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{"project.case_number": "7 O 1234/26"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
body := readDocumentXML(t, out)
|
||||
if !strings.Contains(body, "7 O 1234/26") {
|
||||
t.Errorf("expected case number after cross-run merge, got %q", body)
|
||||
}
|
||||
if strings.Contains(body, "{{") {
|
||||
t.Errorf("orphan placeholder marker remained: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRender_XMLEscaping verifies special characters in placeholder
|
||||
// values are escaped so they don't corrupt the document XML.
|
||||
func TestRender_XMLEscaping(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{user.display_name}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{
|
||||
"user.display_name": `Müller & Söhne <GmbH> "Special"`,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
body := readDocumentXML(t, out)
|
||||
if !strings.Contains(body, "Müller & Söhne <GmbH> "Special"") {
|
||||
t.Errorf("expected escaped value, got %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRender_PreservesNonWordEntries leaves the rest of the .docx
|
||||
// untouched so any styles / theme / settings parts come through bit-
|
||||
// for-bit.
|
||||
func TestRender_PreservesNonWordEntries(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(out), int64(len(out)))
|
||||
if err != nil {
|
||||
t.Fatalf("open rendered: %v", err)
|
||||
}
|
||||
var sawTypes bool
|
||||
for _, f := range zr.File {
|
||||
if f.Name == "[Content_Types].xml" {
|
||||
sawTypes = true
|
||||
for _, name := range []string{
|
||||
"word/vbaProject.bin",
|
||||
"word/_rels/vbaProject.bin.rels",
|
||||
"word/vbaData.xml",
|
||||
"word/customizations.xml",
|
||||
} {
|
||||
if _, ok := entries[name]; ok {
|
||||
t.Errorf("output still contains %s", name)
|
||||
}
|
||||
}
|
||||
if !sawTypes {
|
||||
t.Error("rendered .docx lost [Content_Types].xml")
|
||||
if doc, ok := entries["word/document.xml"]; !ok {
|
||||
t.Error("output is missing word/document.xml")
|
||||
} else if !strings.Contains(doc, "Hello Paliad") {
|
||||
t.Errorf("document body lost during conversion: %q", doc)
|
||||
}
|
||||
if _, ok := entries["word/styles.xml"]; !ok {
|
||||
t.Error("output lost unrelated word/styles.xml")
|
||||
}
|
||||
|
||||
ctypes, ok := entries[contentTypesPath]
|
||||
if !ok {
|
||||
t.Fatal("output is missing [Content_Types].xml")
|
||||
}
|
||||
if strings.Contains(ctypes, "macroEnabled") {
|
||||
t.Errorf("output [Content_Types].xml still references a macro-enabled type: %q", ctypes)
|
||||
}
|
||||
if !strings.Contains(ctypes, docxMainContentType) {
|
||||
t.Errorf("output is missing plain docx main content type: %q", ctypes)
|
||||
}
|
||||
if strings.Contains(ctypes, "vbaProject") {
|
||||
t.Errorf("output [Content_Types].xml still references vbaProject: %q", ctypes)
|
||||
}
|
||||
if strings.Contains(ctypes, "vbaData") {
|
||||
t.Errorf("output [Content_Types].xml still overrides vbaData: %q", ctypes)
|
||||
}
|
||||
if strings.Contains(ctypes, "keyMapCustomizations") {
|
||||
t.Errorf("output [Content_Types].xml still overrides customizations: %q", ctypes)
|
||||
}
|
||||
if !strings.Contains(ctypes, "wordprocessingml.styles") {
|
||||
t.Errorf("output lost unrelated styles Override: %q", ctypes)
|
||||
}
|
||||
|
||||
rels, ok := entries[documentRelsPath]
|
||||
if !ok {
|
||||
t.Fatal("output is missing word/_rels/document.xml.rels")
|
||||
}
|
||||
if strings.Contains(rels, "vbaProject") {
|
||||
t.Errorf("output rels still references vbaProject: %q", rels)
|
||||
}
|
||||
if strings.Contains(rels, "keyMapCustomizations") {
|
||||
t.Errorf("output rels still references keyMapCustomizations: %q", rels)
|
||||
}
|
||||
if !strings.Contains(rels, "styles.xml") {
|
||||
t.Errorf("output rels lost unrelated styles relationship: %q", rels)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlaceholderRegex_Boundaries pins the placeholder grammar.
|
||||
func TestPlaceholderRegex_Boundaries(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
matches []string
|
||||
}{
|
||||
{"plain text", nil},
|
||||
{"{{foo}}", []string{"{{foo}}"}},
|
||||
{"{{ foo }}", []string{"{{ foo }}"}},
|
||||
{"{{foo.bar}}", []string{"{{foo.bar}}"}},
|
||||
{"{{ foo.bar_baz }}", []string{"{{ foo.bar_baz }}"}},
|
||||
{"{{1bad}}", nil}, // must start with a letter
|
||||
{"{{ foo }} and {{ bar }}", []string{"{{ foo }}", "{{ bar }}"}},
|
||||
func TestConvertDotmToDocx_IdempotentOnPlainDocx(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
add := func(name, body string) {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("create %s: %v", name, err)
|
||||
}
|
||||
if _, err := io.WriteString(w, body); err != nil {
|
||||
t.Fatalf("write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
got := placeholderRegex.FindAllString(tc.in, -1)
|
||||
if len(got) != len(tc.matches) {
|
||||
t.Fatalf("got %d matches, want %d (in=%q)", len(got), len(tc.matches), tc.in)
|
||||
add(contentTypesPath, `<?xml version="1.0"?>`+
|
||||
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`+
|
||||
`<Override PartName="/word/document.xml" ContentType="`+docxMainContentType+`"/>`+
|
||||
`</Types>`)
|
||||
add("word/document.xml", `<w:document/>`)
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
out, err := ConvertDotmToDocx(buf.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertDotmToDocx: %v", err)
|
||||
}
|
||||
|
||||
entries := unzipEntries(t, out)
|
||||
if _, ok := entries["word/vbaProject.bin"]; ok {
|
||||
t.Error("plain docx grew a vbaProject during conversion")
|
||||
}
|
||||
if ctypes := entries[contentTypesPath]; !strings.Contains(ctypes, docxMainContentType) {
|
||||
t.Errorf("plain docx lost its content type: %q", ctypes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertDotmToDocx_AcceptsDocmAndDotx(t *testing.T) {
|
||||
for _, mainType := range []string{docmMainContentType, dotxMainContentType} {
|
||||
t.Run(mainType, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
add := func(name, body string) {
|
||||
w, _ := zw.Create(name)
|
||||
_, _ = io.WriteString(w, body)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.matches[i] {
|
||||
t.Errorf("match %d: got %q, want %q", i, got[i], tc.matches[i])
|
||||
}
|
||||
add(contentTypesPath, `<?xml version="1.0"?>`+
|
||||
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`+
|
||||
`<Override PartName="/word/document.xml" ContentType="`+mainType+`"/>`+
|
||||
`</Types>`)
|
||||
add("word/document.xml", `<w:document/>`)
|
||||
zw.Close()
|
||||
out, err := ConvertDotmToDocx(buf.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertDotmToDocx: %v", err)
|
||||
}
|
||||
ctypes := unzipEntries(t, out)[contentTypesPath]
|
||||
if strings.Contains(ctypes, mainType) {
|
||||
t.Errorf("non-docx main type survived conversion: %q", ctypes)
|
||||
}
|
||||
if !strings.Contains(ctypes, docxMainContentType) {
|
||||
t.Errorf("docx main type not present: %q", ctypes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFamilyOf covers the proceeding-family extraction used by the
|
||||
// template registry's fallback chain.
|
||||
func TestFamilyOf(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"de.inf.lg.erwidg": "de.inf.lg",
|
||||
"upc.inf.cfi.soc": "upc.inf.cfi",
|
||||
"dpma.opp.dpma": "", // only three segments → no family
|
||||
"de.inf.lg": "",
|
||||
"": "",
|
||||
func TestConvertDotmToDocx_RejectsNonZip(t *testing.T) {
|
||||
_, err := ConvertDotmToDocx([]byte("not a zip file"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-zip input, got nil")
|
||||
}
|
||||
for in, want := range tests {
|
||||
}
|
||||
|
||||
func TestSanitiseSubmissionFileName(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Klageerwiderung": "Klageerwiderung",
|
||||
"Berufungsbegründung": "Berufungsbegruendung",
|
||||
"Schriftsatz/Anlage": "Schriftsatz_Anlage",
|
||||
`Statement of "Defence"`: "Statement of Defence",
|
||||
` Klage `: "Klage",
|
||||
"Größe": "Groesse",
|
||||
}
|
||||
for in, want := range cases {
|
||||
t.Run(in, func(t *testing.T) {
|
||||
got := familyOf(in)
|
||||
if got != want {
|
||||
t.Errorf("familyOf(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegalSourcePretty covers the prefix table.
|
||||
func TestLegalSourcePretty(t *testing.T) {
|
||||
tests := []struct {
|
||||
src, lang, want string
|
||||
}{
|
||||
{"DE.ZPO.276.1", "de", "§ 276 Abs. 1 ZPO"},
|
||||
{"DE.ZPO.276.1", "en", "Section 276(1) ZPO"},
|
||||
{"DE.ZPO.253", "de", "§ 253 ZPO"},
|
||||
{"DE.ZPO.253", "en", "Section 253 ZPO"},
|
||||
{"UPC.RoP.23.1", "de", "Regel 23.1 VerfO UPC"},
|
||||
{"UPC.RoP.23.1", "en", "Rule 23.1 RoP UPC"},
|
||||
{"UPC.RoP.198", "de", "Regel 198 VerfO UPC"},
|
||||
{"DE.PatG.83", "de", "§ 83 PatG"},
|
||||
{"EPC.123", "de", "Art. 123 EPÜ"},
|
||||
{"EPC.123", "en", "Art. 123 EPC"},
|
||||
// Unknown prefix → pass-through unchanged.
|
||||
{"FOO.BAR.123", "de", "FOO.BAR.123"},
|
||||
{"", "de", ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.src+"/"+tc.lang, func(t *testing.T) {
|
||||
got := legalSourcePretty(tc.src, tc.lang)
|
||||
if got != tc.want {
|
||||
t.Errorf("legalSourcePretty(%q, %q) = %q, want %q", tc.src, tc.lang, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOurSideTranslations pins the our_side enum → DE/EN prose
|
||||
// mapping used by addProjectVars. Post t-paliad-222: seven sub-role
|
||||
// values + the gender-neutral "-Seite" / "-Partei" suffix shape on
|
||||
// DE. Legacy 'court' / 'both' yield "" (the column no longer accepts
|
||||
// them after mig 112, but the function defensively handles stale
|
||||
// in-memory values from older callers).
|
||||
func TestOurSideTranslations(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, wantDE, wantEN string
|
||||
}{
|
||||
{"claimant", "Klägerseite", "Claimant"},
|
||||
{"defendant", "Beklagtenseite", "Defendant"},
|
||||
{"applicant", "Antragstellerseite", "Applicant"},
|
||||
{"appellant", "Berufungsklägerseite", "Appellant"},
|
||||
{"respondent", "Antragsgegnerseite", "Respondent"},
|
||||
{"third_party", "Drittpartei", "Third Party"},
|
||||
{"other", "sonstige Verfahrensbeteiligte", "other party"},
|
||||
{"court", "", ""},
|
||||
{"both", "", ""},
|
||||
{"", "", ""},
|
||||
{"unknown", "", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
if got := ourSideDE(tc.in); got != tc.wantDE {
|
||||
t.Errorf("ourSideDE(%q) = %q, want %q", tc.in, got, tc.wantDE)
|
||||
}
|
||||
if got := ourSideEN(tc.in); got != tc.wantEN {
|
||||
t.Errorf("ourSideEN(%q) = %q, want %q", tc.in, got, tc.wantEN)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemplateRegistry_Candidates verifies the fallback-chain order
|
||||
// matches the m-locked Q4 decision (firm → base/code → base/family →
|
||||
// skeleton).
|
||||
func TestTemplateRegistry_Candidates(t *testing.T) {
|
||||
r := NewTemplateRegistry("", "HLC")
|
||||
got := r.candidates("de.inf.lg.erwidg")
|
||||
want := []string{
|
||||
"templates/HLC/de.inf.lg.erwidg.docx",
|
||||
"templates/_base/de.inf.lg.erwidg.docx",
|
||||
"templates/_base/de.inf.lg.docx",
|
||||
"templates/_base/_skeleton.docx",
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("candidates = %v, want %v", got, want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("candidate[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemplateRegistry_Candidates_NoFamily covers submission codes
|
||||
// without a family suffix (only three dot-segments).
|
||||
func TestTemplateRegistry_Candidates_NoFamily(t *testing.T) {
|
||||
r := NewTemplateRegistry("", "HLC")
|
||||
got := r.candidates("dpma.opp.dpma")
|
||||
want := []string{
|
||||
"templates/HLC/dpma.opp.dpma.docx",
|
||||
"templates/_base/dpma.opp.dpma.docx",
|
||||
"templates/_base/_skeleton.docx",
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("candidates = %v, want %v", got, want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("candidate[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemplateRegistry_Tiers labels each candidate slot. Must stay
|
||||
// 1:1 with candidates().
|
||||
func TestTemplateRegistry_Tiers(t *testing.T) {
|
||||
r := NewTemplateRegistry("", "HLC")
|
||||
codes := []string{"de.inf.lg.erwidg", "dpma.opp.dpma"}
|
||||
for _, code := range codes {
|
||||
c := r.candidates(code)
|
||||
ts := r.tiers(code)
|
||||
if len(c) != len(ts) {
|
||||
t.Fatalf("candidate/tier mismatch for %q: %d vs %d", code, len(c), len(ts))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPatentNumberUPC covers the kind-code parenthesisation that UPC
|
||||
// briefs use (t-paliad-215 Slice 2, design §22 Q-S2-4).
|
||||
func TestPatentNumberUPC(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
// EP variants — the common case.
|
||||
{"EP 1 234 567 B1", "EP 1 234 567 (B1)"},
|
||||
{"EP 4 056 049 A1", "EP 4 056 049 (A1)"},
|
||||
// DE national number with kind code.
|
||||
{"DE 10 2020 123 456 A1", "DE 10 2020 123 456 (A1)"},
|
||||
// No kind code → pass-through unchanged.
|
||||
{"EP 1 234 567", "EP 1 234 567"},
|
||||
// Leading + trailing whitespace trimmed.
|
||||
{" EP 1 234 567 B1 ", "EP 1 234 567 (B1)"},
|
||||
// Empty input.
|
||||
{"", ""},
|
||||
// Slash-separated forms (WO publication numbers) don't match
|
||||
// the kind-code shape → pass through.
|
||||
{"WO/2023/123456", "WO/2023/123456"},
|
||||
// Two-digit kind code (e.g. B12) doesn't match the single-digit
|
||||
// pattern; pass through. This is intentional — real EP kind
|
||||
// codes are single-letter + single-digit.
|
||||
{"EP 1 234 567 B12", "EP 1 234 567 B12"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
got := patentNumberUPC(tc.in)
|
||||
if got != tc.want {
|
||||
t.Errorf("patentNumberUPC(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
if got := SanitiseSubmissionFileName(in); got != want {
|
||||
t.Errorf("SanitiseSubmissionFileName(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,442 +0,0 @@
|
||||
package services
|
||||
|
||||
// Submission template registry — Gitea-backed .docx template loader for
|
||||
// the submission generator (t-paliad-215, design doc
|
||||
// docs/design-submission-generator-2026-05-19.md §5).
|
||||
//
|
||||
// Layout in mWorkRepo:
|
||||
//
|
||||
// templates/{FIRM_NAME}/{submission_code}.docx firm-specific override
|
||||
// templates/_base/{submission_code}.docx cross-firm baseline
|
||||
// templates/_base/{family}.docx proceeding-family fallback
|
||||
// templates/_base/_skeleton.docx ultra-generic fallback
|
||||
//
|
||||
// Lookup is first-match-wins down the chain; this is the m-locked Q4
|
||||
// decision. Templates fetched via Gitea's raw URL endpoint, cached
|
||||
// in-process with a 5-minute SHA refresh check — identical pattern to
|
||||
// the HL Patents Style proxy in internal/handlers/files.go (which the
|
||||
// design doc §1 verified is in production and works).
|
||||
//
|
||||
// Slice 1 ships one template at templates/_base/de.inf.lg.erwidg.docx
|
||||
// (committed to HL/mWorkRepo at SHA 7f97b7f9, the bootstrap demo
|
||||
// authored by the engine for end-to-end testing — HLC ships the
|
||||
// polished version per §14 follow-up).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
templatesGiteaBaseURL = "https://mgit.msbls.de"
|
||||
templatesGiteaRepoOwn = "HL"
|
||||
templatesGiteaRepoName = "mWorkRepo"
|
||||
templatesGiteaBranch = "main"
|
||||
templatesCheckInterval = 5 * time.Minute
|
||||
templatesSkeleton = "_skeleton"
|
||||
)
|
||||
|
||||
// ErrNoTemplate is returned when no template resolves anywhere in the
|
||||
// fallback chain (firm/code → base/code → base/family → skeleton).
|
||||
// Caller maps to 503 + a clear UI hint.
|
||||
var ErrNoTemplate = errors.New("submission template: no template resolved in fallback chain")
|
||||
|
||||
// ErrTemplateUpstream wraps Gitea-side failures (network, 5xx).
|
||||
// Distinct from ErrNoTemplate so the handler can render different UI:
|
||||
// "no template configured" vs "template repo unreachable".
|
||||
var ErrTemplateUpstream = errors.New("submission template: upstream Gitea unreachable")
|
||||
|
||||
// ResolvedTemplate is the result of a fallback-chain lookup: the
|
||||
// template bytes plus the metadata the audit row + UI need.
|
||||
type ResolvedTemplate struct {
|
||||
// Path is the Gitea-relative path that resolved (e.g.
|
||||
// "templates/HLC/de.inf.lg.erwidg.docx"). Persisted in the
|
||||
// system_audit_log row so an admin can trace which template was
|
||||
// used for a given generation.
|
||||
Path string
|
||||
|
||||
// SHA is the commit SHA the template was fetched at. Pinning this
|
||||
// lets audit consumers reproduce the exact bytes that went into
|
||||
// the lawyer's download.
|
||||
SHA string
|
||||
|
||||
// FirmTier reports which level of the fallback chain fired:
|
||||
// "firm", "base_code", "base_family", or "skeleton". Useful for
|
||||
// the variable-contract sidebar (Slice 3) and for ops monitoring
|
||||
// of how often each firm is actually overriding.
|
||||
FirmTier string
|
||||
|
||||
// Bytes is the .docx content; only populated for callers that
|
||||
// need to render (i.e. SubmissionRenderer.Render). Resolve()
|
||||
// returns it populated; Probe() leaves it nil.
|
||||
Bytes []byte
|
||||
}
|
||||
|
||||
// templateCacheEntry mirrors the per-file cache shape used by
|
||||
// internal/handlers/files.go. Each cached entry tracks its bytes, the
|
||||
// commit SHA, the last upstream check, and a checking flag so two
|
||||
// concurrent refresh goroutines don't double-fetch.
|
||||
type templateCacheEntry struct {
|
||||
mu sync.RWMutex
|
||||
data []byte
|
||||
sha string
|
||||
lastChecked time.Time
|
||||
checking bool
|
||||
missing bool // true when Gitea returned 404 — short-circuits subsequent lookups
|
||||
}
|
||||
|
||||
// TemplateRegistry resolves submission templates from Gitea using the
|
||||
// fallback chain. Process-wide cache; single-replica deployment (per
|
||||
// docs/design-submission-generator-2026-05-19.md §1) makes in-process
|
||||
// caching sufficient — a future multi-replica rollout would swap this
|
||||
// for a shared cache. Same trade-off the HL Patents Style proxy makes.
|
||||
type TemplateRegistry struct {
|
||||
cache map[string]*templateCacheEntry
|
||||
cacheMu sync.Mutex
|
||||
giteaToken string
|
||||
httpClient *http.Client
|
||||
firmName string
|
||||
}
|
||||
|
||||
// NewTemplateRegistry constructs the registry. firmName is read once
|
||||
// at process start from internal/branding.Name so a runtime FIRM_NAME
|
||||
// rebrand cuts in on the next deploy, not mid-request.
|
||||
func NewTemplateRegistry(giteaToken, firmName string) *TemplateRegistry {
|
||||
return &TemplateRegistry{
|
||||
cache: make(map[string]*templateCacheEntry),
|
||||
giteaToken: giteaToken,
|
||||
firmName: firmName,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// HasTemplate reports whether any template resolves for the given
|
||||
// submission code, without fetching the bytes. Used by the
|
||||
// SubmissionsPanel to decide which "Generate" buttons to enable.
|
||||
//
|
||||
// Cheap path: walks the same fallback chain as Resolve, but stops at
|
||||
// the SHA-probe step (Gitea's contents endpoint, single round-trip per
|
||||
// candidate). The probe results land in the same cache as Resolve so a
|
||||
// subsequent Resolve call reuses the SHA.
|
||||
func (r *TemplateRegistry) HasTemplate(ctx context.Context, submissionCode string) bool {
|
||||
for _, candidate := range r.candidates(submissionCode) {
|
||||
if r.probe(ctx, candidate) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Resolve walks the fallback chain and returns the first template that
|
||||
// fetches successfully, with bytes loaded. Returns ErrNoTemplate when
|
||||
// no candidate (including the ultra-generic skeleton) resolves.
|
||||
func (r *TemplateRegistry) Resolve(ctx context.Context, submissionCode string) (*ResolvedTemplate, error) {
|
||||
candidates := r.candidates(submissionCode)
|
||||
tiers := r.tiers(submissionCode)
|
||||
if len(candidates) != len(tiers) {
|
||||
return nil, fmt.Errorf("template registry: candidate/tier mismatch (%d vs %d)", len(candidates), len(tiers))
|
||||
}
|
||||
for i, candidate := range candidates {
|
||||
entry := r.cacheGet(candidate)
|
||||
entry.mu.RLock()
|
||||
hasData := !entry.missing && len(entry.data) > 0
|
||||
needsCheck := time.Since(entry.lastChecked) >= templatesCheckInterval
|
||||
isMissing := entry.missing
|
||||
entry.mu.RUnlock()
|
||||
|
||||
if isMissing && !needsCheck {
|
||||
continue
|
||||
}
|
||||
if !hasData {
|
||||
if err := r.fetchInto(ctx, candidate, entry); err != nil {
|
||||
if errors.Is(err, errTemplate404) {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %v", ErrTemplateUpstream, err)
|
||||
}
|
||||
} else if needsCheck {
|
||||
go r.refresh(context.Background(), candidate, entry)
|
||||
}
|
||||
|
||||
entry.mu.RLock()
|
||||
out := &ResolvedTemplate{
|
||||
Path: candidate,
|
||||
SHA: entry.sha,
|
||||
FirmTier: tiers[i],
|
||||
Bytes: append([]byte(nil), entry.data...),
|
||||
}
|
||||
entry.mu.RUnlock()
|
||||
return out, nil
|
||||
}
|
||||
return nil, ErrNoTemplate
|
||||
}
|
||||
|
||||
// candidates returns the ordered Gitea-relative paths the registry
|
||||
// walks for the given submission code. The order is the m-locked Q4
|
||||
// decision: firm → base/code → base/family → skeleton.
|
||||
func (r *TemplateRegistry) candidates(submissionCode string) []string {
|
||||
family := familyOf(submissionCode)
|
||||
out := []string{
|
||||
fmt.Sprintf("templates/%s/%s.docx", r.firmName, submissionCode),
|
||||
fmt.Sprintf("templates/_base/%s.docx", submissionCode),
|
||||
}
|
||||
if family != "" && family != submissionCode {
|
||||
out = append(out, fmt.Sprintf("templates/_base/%s.docx", family))
|
||||
}
|
||||
out = append(out, fmt.Sprintf("templates/_base/%s.docx", templatesSkeleton))
|
||||
return out
|
||||
}
|
||||
|
||||
// tiers labels each candidate with its fallback tier. Order is locked
|
||||
// to candidates(); both functions evolve together.
|
||||
func (r *TemplateRegistry) tiers(submissionCode string) []string {
|
||||
family := familyOf(submissionCode)
|
||||
out := []string{"firm", "base_code"}
|
||||
if family != "" && family != submissionCode {
|
||||
out = append(out, "base_family")
|
||||
}
|
||||
out = append(out, "skeleton")
|
||||
return out
|
||||
}
|
||||
|
||||
// familyOf extracts the proceeding-family prefix from a submission
|
||||
// code. The convention (docs/design-proceeding-code-taxonomy-2026-05-18.md)
|
||||
// is jurisdiction.substantive.forum.submission, so the family is the
|
||||
// first three dot-segments.
|
||||
//
|
||||
// de.inf.lg.erwidg → de.inf.lg
|
||||
// upc.inf.cfi.soc → upc.inf.cfi
|
||||
// dpma.opp.dpma → "" (only three segments — no submission suffix)
|
||||
//
|
||||
// Returns "" when the code doesn't carry a submission segment (no
|
||||
// family-level fallback is meaningful).
|
||||
func familyOf(submissionCode string) string {
|
||||
parts := strings.Split(submissionCode, ".")
|
||||
if len(parts) < 4 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(parts[:3], ".")
|
||||
}
|
||||
|
||||
// cacheGet returns the cache entry for a Gitea path, creating an empty
|
||||
// entry on first lookup.
|
||||
func (r *TemplateRegistry) cacheGet(path string) *templateCacheEntry {
|
||||
r.cacheMu.Lock()
|
||||
defer r.cacheMu.Unlock()
|
||||
entry, ok := r.cache[path]
|
||||
if !ok {
|
||||
entry = &templateCacheEntry{}
|
||||
r.cache[path] = entry
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
// errTemplate404 is an internal sentinel: candidate doesn't exist in
|
||||
// Gitea, walk the chain. Distinguished from network/5xx errors so the
|
||||
// registry doesn't wrap every fallback miss as ErrTemplateUpstream.
|
||||
var errTemplate404 = errors.New("template not found in gitea")
|
||||
|
||||
// fetchInto downloads a candidate and populates the cache entry. On
|
||||
// 404 it marks the entry missing so subsequent lookups short-circuit
|
||||
// without hitting the network.
|
||||
func (r *TemplateRegistry) fetchInto(ctx context.Context, path string, entry *templateCacheEntry) error {
|
||||
sha, err := r.giteaSHA(ctx, path)
|
||||
if err != nil {
|
||||
if errors.Is(err, errTemplate404) {
|
||||
entry.mu.Lock()
|
||||
entry.missing = true
|
||||
entry.lastChecked = time.Now()
|
||||
entry.mu.Unlock()
|
||||
}
|
||||
return err
|
||||
}
|
||||
data, err := r.giteaDownload(ctx, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry.mu.Lock()
|
||||
entry.data = data
|
||||
entry.sha = sha
|
||||
entry.lastChecked = time.Now()
|
||||
entry.missing = false
|
||||
entry.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// refresh runs in the background after a stale-but-present cache hit.
|
||||
// SHA-checks the candidate; re-downloads on change. Mirrors the same
|
||||
// goroutine pattern as internal/handlers/files.go.
|
||||
func (r *TemplateRegistry) refresh(ctx context.Context, path string, entry *templateCacheEntry) {
|
||||
entry.mu.Lock()
|
||||
if entry.checking {
|
||||
entry.mu.Unlock()
|
||||
return
|
||||
}
|
||||
entry.checking = true
|
||||
entry.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
entry.mu.Lock()
|
||||
entry.checking = false
|
||||
entry.mu.Unlock()
|
||||
}()
|
||||
|
||||
latestSHA, err := r.giteaSHA(ctx, path)
|
||||
if err != nil {
|
||||
log.Printf("submission template: SHA check for %s failed: %v", path, err)
|
||||
entry.mu.Lock()
|
||||
entry.lastChecked = time.Now()
|
||||
entry.mu.Unlock()
|
||||
return
|
||||
}
|
||||
entry.mu.RLock()
|
||||
unchanged := latestSHA == entry.sha && entry.sha != ""
|
||||
entry.mu.RUnlock()
|
||||
if unchanged {
|
||||
entry.mu.Lock()
|
||||
entry.lastChecked = time.Now()
|
||||
entry.mu.Unlock()
|
||||
return
|
||||
}
|
||||
data, err := r.giteaDownload(ctx, path)
|
||||
if err != nil {
|
||||
log.Printf("submission template: download %s failed: %v", path, err)
|
||||
entry.mu.Lock()
|
||||
entry.lastChecked = time.Now()
|
||||
entry.mu.Unlock()
|
||||
return
|
||||
}
|
||||
entry.mu.Lock()
|
||||
entry.data = data
|
||||
entry.sha = latestSHA
|
||||
entry.lastChecked = time.Now()
|
||||
entry.mu.Unlock()
|
||||
log.Printf("submission template: updated %s (SHA: %.8s)", path, latestSHA)
|
||||
}
|
||||
|
||||
// probe is the cheap existence-check used by HasTemplate. Reuses the
|
||||
// cache but only fetches the SHA (not the bytes), so the
|
||||
// SubmissionsPanel's per-row HasTemplate calls don't pull a megabyte
|
||||
// of .docx data the user might never download.
|
||||
func (r *TemplateRegistry) probe(ctx context.Context, path string) bool {
|
||||
entry := r.cacheGet(path)
|
||||
entry.mu.RLock()
|
||||
hasData := !entry.missing && len(entry.data) > 0
|
||||
hasSHA := !entry.missing && entry.sha != ""
|
||||
isMissing := entry.missing
|
||||
needsCheck := time.Since(entry.lastChecked) >= templatesCheckInterval
|
||||
entry.mu.RUnlock()
|
||||
if isMissing && !needsCheck {
|
||||
return false
|
||||
}
|
||||
if hasData || hasSHA {
|
||||
return true
|
||||
}
|
||||
sha, err := r.giteaSHA(ctx, path)
|
||||
if err != nil {
|
||||
if errors.Is(err, errTemplate404) {
|
||||
entry.mu.Lock()
|
||||
entry.missing = true
|
||||
entry.lastChecked = time.Now()
|
||||
entry.mu.Unlock()
|
||||
}
|
||||
return false
|
||||
}
|
||||
entry.mu.Lock()
|
||||
entry.sha = sha
|
||||
entry.lastChecked = time.Now()
|
||||
entry.missing = false
|
||||
entry.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
// giteaSHA returns the SHA of the latest commit that touched the
|
||||
// template path. Returns errTemplate404 when Gitea responds with 404 —
|
||||
// the registry distinguishes "no such template" from "Gitea is down".
|
||||
func (r *TemplateRegistry) giteaSHA(ctx context.Context, path string) (string, error) {
|
||||
apiURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/commits?path=%s&limit=1&sha=%s",
|
||||
templatesGiteaBaseURL,
|
||||
templatesGiteaRepoOwn,
|
||||
templatesGiteaRepoName,
|
||||
url.QueryEscape(path),
|
||||
templatesGiteaBranch,
|
||||
)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if r.giteaToken != "" {
|
||||
req.Header.Set("Authorization", "token "+r.giteaToken)
|
||||
}
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return "", errTemplate404
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("gitea sha lookup returned %d", resp.StatusCode)
|
||||
}
|
||||
var commits []struct {
|
||||
SHA string `json:"sha"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&commits); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(commits) == 0 {
|
||||
return "", errTemplate404
|
||||
}
|
||||
return commits[0].SHA, nil
|
||||
}
|
||||
|
||||
// giteaDownload fetches the raw template bytes.
|
||||
func (r *TemplateRegistry) giteaDownload(ctx context.Context, path string) ([]byte, error) {
|
||||
rawURL := fmt.Sprintf("%s/%s/%s/raw/branch/%s/%s",
|
||||
templatesGiteaBaseURL,
|
||||
templatesGiteaRepoOwn,
|
||||
templatesGiteaRepoName,
|
||||
templatesGiteaBranch,
|
||||
path,
|
||||
)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.giteaToken != "" {
|
||||
req.Header.Set("Authorization", "token "+r.giteaToken)
|
||||
}
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, errTemplate404
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("gitea raw returned %d", resp.StatusCode)
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// ClearCache drops every cached entry. Exposed for an admin-side
|
||||
// "refresh templates" affordance — paliad's existing /api/files/refresh
|
||||
// has the same shape for the HL Patents Style proxy.
|
||||
func (r *TemplateRegistry) ClearCache() {
|
||||
r.cacheMu.Lock()
|
||||
defer r.cacheMu.Unlock()
|
||||
for k := range r.cache {
|
||||
r.cache[k] = &templateCacheEntry{}
|
||||
}
|
||||
}
|
||||
@@ -1,559 +0,0 @@
|
||||
package services
|
||||
|
||||
// Submission variable bag — builds the PlaceholderMap that
|
||||
// SubmissionRenderer fills into a template (t-paliad-215, design doc
|
||||
// docs/design-submission-generator-2026-05-19.md §6.2).
|
||||
//
|
||||
// Variables span six namespaces:
|
||||
//
|
||||
// firm.* process-wide (branding.Name)
|
||||
// user.* caller's user row
|
||||
// today.* server time in Europe/Berlin, locale-aware
|
||||
// project.* paliad.projects + joined proceeding type
|
||||
// parties.* paliad.parties grouped by role
|
||||
// rule.* paliad.deadline_rules row keyed by submission_code
|
||||
// deadline.* next open paliad.deadlines row for (project, rule), if any
|
||||
//
|
||||
// Locale handling: every long-form date string is computed in both DE
|
||||
// and EN; the renderer picks based on the user's lang preference. The
|
||||
// rule pretty-printer (legalSourcePretty) also has DE/EN variants.
|
||||
//
|
||||
// Visibility: caller passes userID; ProjectService.GetByID enforces
|
||||
// paliad.can_see_project — unauthorised callers get the standard
|
||||
// ErrNotFound before any variable construction runs.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/branding"
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// SubmissionVarsService assembles the placeholder map.
|
||||
type SubmissionVarsService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
parties *PartyService
|
||||
users *UserService
|
||||
}
|
||||
|
||||
// NewSubmissionVarsService wires the service.
|
||||
func NewSubmissionVarsService(db *sqlx.DB, projects *ProjectService, parties *PartyService, users *UserService) *SubmissionVarsService {
|
||||
return &SubmissionVarsService{
|
||||
db: db,
|
||||
projects: projects,
|
||||
parties: parties,
|
||||
users: users,
|
||||
}
|
||||
}
|
||||
|
||||
// SubmissionVarsContext is the input bundle that produces a render.
|
||||
type SubmissionVarsContext struct {
|
||||
UserID uuid.UUID
|
||||
ProjectID uuid.UUID
|
||||
SubmissionCode string
|
||||
}
|
||||
|
||||
// SubmissionVarsResult bundles the placeholder map with the lookup
|
||||
// values the handler needs for the audit row + file naming
|
||||
// (rule.Name, project.case_number, etc.).
|
||||
type SubmissionVarsResult struct {
|
||||
Placeholders PlaceholderMap
|
||||
|
||||
// Resolved entities for audit + naming.
|
||||
User *models.User
|
||||
Project *models.Project
|
||||
Rule *models.DeadlineRule
|
||||
ProceedingType *models.ProceedingType
|
||||
Parties []models.Party
|
||||
NextDeadline *models.Deadline
|
||||
|
||||
// Lang is the user's UI language used to pick locale-aware values
|
||||
// during the build. Returned so the renderer can use the matching
|
||||
// missing-marker function.
|
||||
Lang string
|
||||
}
|
||||
|
||||
// ErrSubmissionRuleNotFound is returned when no published deadline_rule
|
||||
// matches the requested submission_code. Maps to 404 in the handler.
|
||||
var ErrSubmissionRuleNotFound = errors.New("submission generator: no rule found for submission_code")
|
||||
|
||||
// Build resolves every entity and assembles the placeholder map.
|
||||
func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsContext) (*SubmissionVarsResult, error) {
|
||||
if s.projects == nil || s.users == nil {
|
||||
return nil, fmt.Errorf("submission vars: required services not wired")
|
||||
}
|
||||
|
||||
user, err := s.users.GetByID(ctx, in.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, ErrNotVisible
|
||||
}
|
||||
|
||||
// Visibility gate — GetByID returns ErrNotFound when the user
|
||||
// can't see the project, which is exactly the 404 the handler
|
||||
// wants to propagate.
|
||||
project, err := s.projects.GetByID(ctx, in.UserID, in.ProjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rule, err := s.loadPublishedRule(ctx, in.SubmissionCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pt, err := s.loadProceedingType(ctx, project.ProceedingTypeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parties, err := s.parties.ListForProject(ctx, in.UserID, in.ProjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
next, err := s.nextOpenDeadline(ctx, in.ProjectID, rule.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lang := user.Lang
|
||||
if lang == "" {
|
||||
lang = "de"
|
||||
}
|
||||
bag := PlaceholderMap{}
|
||||
addFirmVars(bag)
|
||||
addTodayVars(bag, time.Now())
|
||||
addUserVars(bag, user)
|
||||
addProjectVars(bag, project, pt, lang)
|
||||
addPartyVars(bag, parties)
|
||||
addRuleVars(bag, rule, lang)
|
||||
addDeadlineVars(bag, next, project, lang)
|
||||
|
||||
return &SubmissionVarsResult{
|
||||
Placeholders: bag,
|
||||
User: user,
|
||||
Project: project,
|
||||
Rule: rule,
|
||||
ProceedingType: pt,
|
||||
Parties: parties,
|
||||
NextDeadline: next,
|
||||
Lang: lang,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// loadPublishedRule fetches the deadline_rule that owns the given
|
||||
// submission_code. Restricts to lifecycle_state='published' so drafts
|
||||
// never end up shaping a real submission.
|
||||
func (s *SubmissionVarsService) loadPublishedRule(ctx context.Context, submissionCode string) (*models.DeadlineRule, error) {
|
||||
if submissionCode == "" {
|
||||
return nil, ErrSubmissionRuleNotFound
|
||||
}
|
||||
var rule models.DeadlineRule
|
||||
err := s.db.GetContext(ctx, &rule,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = $1
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true
|
||||
ORDER BY sequence_order
|
||||
LIMIT 1`, submissionCode)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrSubmissionRuleNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load rule by submission_code %q: %w", submissionCode, err)
|
||||
}
|
||||
return &rule, nil
|
||||
}
|
||||
|
||||
// loadProceedingType fetches the proceeding type row for the project's
|
||||
// proceeding_type_id. Tolerates a nil id (returns nil, nil) so projects
|
||||
// without a bound proceeding still render a meaningful template — the
|
||||
// {{project.proceeding.*}} placeholders just resolve to the missing
|
||||
// marker.
|
||||
func (s *SubmissionVarsService) loadProceedingType(ctx context.Context, id *int) (*models.ProceedingType, error) {
|
||||
if id == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var pt models.ProceedingType
|
||||
err := s.db.GetContext(ctx, &pt,
|
||||
`SELECT `+proceedingTypeColumns+`
|
||||
FROM paliad.proceeding_types
|
||||
WHERE id = $1`, *id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load proceeding type %d: %w", *id, err)
|
||||
}
|
||||
return &pt, nil
|
||||
}
|
||||
|
||||
// nextOpenDeadline finds the earliest pending paliad.deadlines row on
|
||||
// the given project that maps to the chosen rule. Returns (nil, nil)
|
||||
// when no matching deadline exists — common when the lawyer is drafting
|
||||
// the submission before the system has computed its deadline row.
|
||||
func (s *SubmissionVarsService) nextOpenDeadline(ctx context.Context, projectID, ruleID uuid.UUID) (*models.Deadline, error) {
|
||||
var d models.Deadline
|
||||
err := s.db.GetContext(ctx, &d,
|
||||
`SELECT id, project_id, title, description, due_date, original_due_date,
|
||||
warning_date, source, rule_id, rule_code, status, completed_at,
|
||||
caldav_uid, caldav_etag, notes, created_by, created_at, updated_at,
|
||||
approval_status, pending_request_id, approved_by, approved_at
|
||||
FROM paliad.deadlines
|
||||
WHERE project_id = $1
|
||||
AND rule_id = $2
|
||||
AND status = 'pending'
|
||||
ORDER BY due_date ASC
|
||||
LIMIT 1`, projectID, ruleID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load next deadline (project=%s rule=%s): %w", projectID, ruleID, err)
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// addFirmVars populates the firm.* namespace.
|
||||
func addFirmVars(bag PlaceholderMap) {
|
||||
bag["firm.name"] = branding.Name
|
||||
// firm.signature_block is reserved for Phase 2; emit empty so
|
||||
// templates that already reference it don't render the missing
|
||||
// marker (less noisy for the lawyer).
|
||||
bag["firm.signature_block"] = ""
|
||||
}
|
||||
|
||||
// addTodayVars populates today.* in both DE and EN long forms. ISO
|
||||
// short form is the default {{today}}.
|
||||
func addTodayVars(bag PlaceholderMap, now time.Time) {
|
||||
loc, _ := time.LoadLocation("Europe/Berlin")
|
||||
if loc != nil {
|
||||
now = now.In(loc)
|
||||
}
|
||||
bag["today"] = now.Format("2006-01-02")
|
||||
bag["today.iso"] = now.Format("2006-01-02")
|
||||
bag["today.long_de"] = formatLongDateDE(now)
|
||||
bag["today.long_en"] = formatLongDateEN(now)
|
||||
}
|
||||
|
||||
// addUserVars populates user.*.
|
||||
func addUserVars(bag PlaceholderMap, u *models.User) {
|
||||
bag["user.display_name"] = u.DisplayName
|
||||
bag["user.email"] = u.Email
|
||||
bag["user.office"] = u.Office
|
||||
}
|
||||
|
||||
// addProjectVars populates project.* — title / case_number / court /
|
||||
// patent_number / dates / our_side / proceeding metadata.
|
||||
func addProjectVars(bag PlaceholderMap, p *models.Project, pt *models.ProceedingType, lang string) {
|
||||
bag["project.title"] = p.Title
|
||||
bag["project.reference"] = derefString(p.Reference)
|
||||
// project.code is the auto-derived (or override) dotted project
|
||||
// code computed by services.BuildProjectCode. Populated upstream
|
||||
// by the service projection; templates that want the explicit
|
||||
// override should read project.reference instead.
|
||||
bag["project.code"] = p.Code
|
||||
bag["project.case_number"] = derefString(p.CaseNumber)
|
||||
bag["project.court"] = derefString(p.Court)
|
||||
bag["project.patent_number"] = derefString(p.PatentNumber)
|
||||
// project.patent_number_upc is the UPC-brief convention — kind code
|
||||
// parenthesised ("EP 1 234 567 (B1)") instead of the DE form
|
||||
// ("EP 1 234 567 B1"). Pure-function rewrite; pass-through when no
|
||||
// kind code is present so the lawyer's draft never sees a worse
|
||||
// number than the source value.
|
||||
bag["project.patent_number_upc"] = patentNumberUPC(derefString(p.PatentNumber))
|
||||
bag["project.filing_date"] = formatDatePtr(p.FilingDate, "2006-01-02")
|
||||
bag["project.grant_date"] = formatDatePtr(p.GrantDate, "2006-01-02")
|
||||
bag["project.our_side"] = derefString(p.OurSide)
|
||||
bag["project.our_side_de"] = ourSideDE(derefString(p.OurSide))
|
||||
bag["project.our_side_en"] = ourSideEN(derefString(p.OurSide))
|
||||
bag["project.instance_level"] = derefString(p.InstanceLevel)
|
||||
bag["project.client_number"] = derefString(p.ClientNumber)
|
||||
bag["project.matter_number"] = derefString(p.MatterNumber)
|
||||
if pt != nil {
|
||||
bag["project.proceeding.code"] = pt.Code
|
||||
if strings.EqualFold(lang, "en") {
|
||||
bag["project.proceeding.name"] = pt.NameEN
|
||||
} else {
|
||||
bag["project.proceeding.name"] = pt.Name
|
||||
}
|
||||
bag["project.proceeding.name_de"] = pt.Name
|
||||
bag["project.proceeding.name_en"] = pt.NameEN
|
||||
}
|
||||
}
|
||||
|
||||
// addPartyVars populates parties.* using the first row of each role.
|
||||
// Multi-claimant / multi-defendant suits use the first row in Slice 1
|
||||
// per design §13.6; expanded grouping is Phase 2.
|
||||
func addPartyVars(bag PlaceholderMap, parties []models.Party) {
|
||||
var claimant, defendant, other *models.Party
|
||||
for i := range parties {
|
||||
role := strings.ToLower(strings.TrimSpace(derefString(parties[i].Role)))
|
||||
switch role {
|
||||
case "claimant", "kläger", "klaeger":
|
||||
if claimant == nil {
|
||||
claimant = &parties[i]
|
||||
}
|
||||
case "defendant", "beklagter", "beklagte":
|
||||
if defendant == nil {
|
||||
defendant = &parties[i]
|
||||
}
|
||||
default:
|
||||
if other == nil {
|
||||
other = &parties[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if claimant != nil {
|
||||
bag["parties.claimant.name"] = claimant.Name
|
||||
bag["parties.claimant.representative"] = derefString(claimant.Representative)
|
||||
}
|
||||
if defendant != nil {
|
||||
bag["parties.defendant.name"] = defendant.Name
|
||||
bag["parties.defendant.representative"] = derefString(defendant.Representative)
|
||||
}
|
||||
if other != nil {
|
||||
bag["parties.other.name"] = other.Name
|
||||
bag["parties.other.representative"] = derefString(other.Representative)
|
||||
}
|
||||
}
|
||||
|
||||
// addRuleVars populates rule.* — submission_code, name(_en),
|
||||
// legal_source (+ pretty form), primary_party, event_type.
|
||||
func addRuleVars(bag PlaceholderMap, r *models.DeadlineRule, lang string) {
|
||||
bag["rule.submission_code"] = derefString(r.SubmissionCode)
|
||||
if strings.EqualFold(lang, "en") {
|
||||
bag["rule.name"] = r.NameEN
|
||||
} else {
|
||||
bag["rule.name"] = r.Name
|
||||
}
|
||||
bag["rule.name_de"] = r.Name
|
||||
bag["rule.name_en"] = r.NameEN
|
||||
bag["rule.legal_source"] = derefString(r.LegalSource)
|
||||
bag["rule.legal_source_pretty"] = legalSourcePretty(derefString(r.LegalSource), lang)
|
||||
bag["rule.primary_party"] = derefString(r.PrimaryParty)
|
||||
bag["rule.event_type"] = derefString(r.EventType)
|
||||
}
|
||||
|
||||
// addDeadlineVars populates deadline.* from the next pending row. When
|
||||
// no row exists the values fall through to the missing marker — the
|
||||
// lawyer sees [KEIN WERT: deadline.due_date] in Word and knows to fix.
|
||||
func addDeadlineVars(bag PlaceholderMap, d *models.Deadline, p *models.Project, lang string) {
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
bag["deadline.due_date"] = d.DueDate.Format("2006-01-02")
|
||||
bag["deadline.due_date_long_de"] = formatLongDateDE(d.DueDate)
|
||||
bag["deadline.due_date_long_en"] = formatLongDateEN(d.DueDate)
|
||||
if d.OriginalDueDate != nil {
|
||||
bag["deadline.original_due_date"] = d.OriginalDueDate.Format("2006-01-02")
|
||||
}
|
||||
// computed_from carries the human-readable anchor description
|
||||
// (e.g. "Klagezustellung am 14.05.2026 + 6 Wochen"). Notes is
|
||||
// the closest existing field — the calculator stores anchor
|
||||
// metadata there. If empty we leave the placeholder unresolved.
|
||||
if d.Notes != nil && strings.TrimSpace(*d.Notes) != "" {
|
||||
bag["deadline.computed_from"] = strings.TrimSpace(*d.Notes)
|
||||
}
|
||||
bag["deadline.title"] = d.Title
|
||||
bag["deadline.source"] = d.Source
|
||||
_ = p // reserved for future shape decisions where the deadline
|
||||
// var depends on project context.
|
||||
_ = lang
|
||||
}
|
||||
|
||||
// derefString returns *s or "" when s is nil.
|
||||
func derefString(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// formatDatePtr formats a *time.Time, returning "" for nil.
|
||||
func formatDatePtr(t *time.Time, layout string) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.Format(layout)
|
||||
}
|
||||
|
||||
// ourSideDE returns the German legal-prose form of an our_side value.
|
||||
//
|
||||
// t-paliad-222: unified on the gender-neutral "-Seite" / "-Partei"
|
||||
// suffix shape to match the form labels and to avoid implying the
|
||||
// firm represents a single (female) natural person — a B2B patent
|
||||
// practice almost always represents companies. The seven sub-roles
|
||||
// map onto the post-mig-110 schema; legacy 'court' / 'both' no
|
||||
// longer exist in the column.
|
||||
func ourSideDE(side string) string {
|
||||
switch strings.ToLower(side) {
|
||||
case "claimant":
|
||||
return "Klägerseite"
|
||||
case "defendant":
|
||||
return "Beklagtenseite"
|
||||
case "applicant":
|
||||
return "Antragstellerseite"
|
||||
case "appellant":
|
||||
return "Berufungsklägerseite"
|
||||
case "respondent":
|
||||
return "Antragsgegnerseite"
|
||||
case "third_party":
|
||||
return "Drittpartei"
|
||||
case "other":
|
||||
return "sonstige Verfahrensbeteiligte"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ourSideEN returns the English legal-prose form of an our_side value.
|
||||
func ourSideEN(side string) string {
|
||||
switch strings.ToLower(side) {
|
||||
case "claimant":
|
||||
return "Claimant"
|
||||
case "defendant":
|
||||
return "Defendant"
|
||||
case "applicant":
|
||||
return "Applicant"
|
||||
case "appellant":
|
||||
return "Appellant"
|
||||
case "respondent":
|
||||
return "Respondent"
|
||||
case "third_party":
|
||||
return "Third Party"
|
||||
case "other":
|
||||
return "other party"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// formatLongDateDE renders a date in the German long form
|
||||
// ("19. Mai 2026"). Pure function for unit testing.
|
||||
func formatLongDateDE(t time.Time) string {
|
||||
months := []string{
|
||||
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
||||
}
|
||||
idx := int(t.Month()) - 1
|
||||
if idx < 0 || idx >= len(months) {
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
return fmt.Sprintf("%d. %s %d", t.Day(), months[idx], t.Year())
|
||||
}
|
||||
|
||||
// formatLongDateEN renders a date in the English long form
|
||||
// ("19 May 2026").
|
||||
func formatLongDateEN(t time.Time) string {
|
||||
return t.Format("2 January 2006")
|
||||
}
|
||||
|
||||
// legalSourcePretty rewrites the shorthand stored on deadline_rules
|
||||
// (DE.ZPO.276.1, UPC.RoP.23.1, …) into the form a lawyer would type
|
||||
// in a brief ("§ 276 Abs. 1 ZPO", "Rule 23.1 RoP UPC"). Unknown
|
||||
// prefixes pass through unchanged — preferring the raw shorthand over
|
||||
// an incorrect prettification.
|
||||
//
|
||||
// Lang controls the language of connective words (Abs / Section,
|
||||
// Regel / Rule, …). The pretty table covers the prefixes used by the
|
||||
// 254 published rules in the corpus today; new prefixes default to
|
||||
// pass-through and a follow-up CL extends the table.
|
||||
func legalSourcePretty(src, lang string) string {
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(src, ".")
|
||||
en := strings.EqualFold(lang, "en")
|
||||
|
||||
switch {
|
||||
case len(parts) == 4 && parts[0] == "DE" && parts[1] == "ZPO":
|
||||
if en {
|
||||
return fmt.Sprintf("Section %s(%s) ZPO", parts[2], parts[3])
|
||||
}
|
||||
return fmt.Sprintf("§ %s Abs. %s ZPO", parts[2], parts[3])
|
||||
case len(parts) == 3 && parts[0] == "DE" && parts[1] == "ZPO":
|
||||
if en {
|
||||
return fmt.Sprintf("Section %s ZPO", parts[2])
|
||||
}
|
||||
return fmt.Sprintf("§ %s ZPO", parts[2])
|
||||
case len(parts) == 4 && parts[0] == "UPC" && parts[1] == "RoP":
|
||||
if en {
|
||||
return fmt.Sprintf("Rule %s.%s RoP UPC", parts[2], parts[3])
|
||||
}
|
||||
return fmt.Sprintf("Regel %s.%s VerfO UPC", parts[2], parts[3])
|
||||
case len(parts) == 3 && parts[0] == "UPC" && parts[1] == "RoP":
|
||||
if en {
|
||||
return fmt.Sprintf("Rule %s RoP UPC", parts[2])
|
||||
}
|
||||
return fmt.Sprintf("Regel %s VerfO UPC", parts[2])
|
||||
case len(parts) >= 3 && parts[0] == "DE" && parts[1] == "PatG":
|
||||
if en {
|
||||
return fmt.Sprintf("Section %s PatG", parts[2])
|
||||
}
|
||||
return fmt.Sprintf("§ %s PatG", parts[2])
|
||||
case len(parts) == 2 && parts[0] == "EPC":
|
||||
if en {
|
||||
return fmt.Sprintf("Art. %s EPC", parts[1])
|
||||
}
|
||||
return fmt.Sprintf("Art. %s EPÜ", parts[1])
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
// patentNumberKindCodeRegex matches a trailing kind code on a patent
|
||||
// number: a whitespace-separated single uppercase letter followed by
|
||||
// a single digit (B1, A1, A2, B2, B9, C1, T2, U1, …). Capturing
|
||||
// groups split the base from the kind code so the formatter can
|
||||
// parenthesise the kind without touching the rest of the number.
|
||||
var patentNumberKindCodeRegex = regexp.MustCompile(`^(.*?)\s+([A-Z]\d)$`)
|
||||
|
||||
// patentNumberUPC reformats a patent number from the DE convention
|
||||
// ("EP 1 234 567 B1") to the UPC-brief convention
|
||||
// ("EP 1 234 567 (B1)"). The kind code is parenthesised; everything
|
||||
// else is preserved verbatim. Numbers without a recognised trailing
|
||||
// kind code pass through unchanged so a lawyer's draft never sees a
|
||||
// number worse than the source value.
|
||||
//
|
||||
// Recognised inputs:
|
||||
//
|
||||
// "EP 1 234 567 B1" → "EP 1 234 567 (B1)"
|
||||
// "EP 4 056 049 A1" → "EP 4 056 049 (A1)"
|
||||
// "DE 10 2020 123 456 A1" → "DE 10 2020 123 456 (A1)"
|
||||
// " EP 1 234 567 B1 " → "EP 1 234 567 (B1)" (trimmed)
|
||||
//
|
||||
// Pass-through:
|
||||
//
|
||||
// "EP 1 234 567" → "EP 1 234 567"
|
||||
// "WO/2023/123456" → "WO/2023/123456" (no kind code shape)
|
||||
// "" → ""
|
||||
//
|
||||
// Pure function; unit-tested in submission_vars_test.go.
|
||||
func patentNumberUPC(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if m := patentNumberKindCodeRegex.FindStringSubmatch(s); m != nil {
|
||||
base := strings.TrimSpace(m[1])
|
||||
kind := m[2]
|
||||
if base == "" {
|
||||
return s
|
||||
}
|
||||
return base + " (" + kind + ")"
|
||||
}
|
||||
return s
|
||||
}
|
||||
Reference in New Issue
Block a user