Compare commits

...

6 Commits

Author SHA1 Message Date
mAi
28aaafeb05 Merge: t-paliad-349 docforge slice 6c — authoring page (frontend) (m/paliad#157)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-29 16:08:46 +02:00
mAi
f9331e9bb9 Merge: t-paliad-349 docforge slice 6b — authoring HTTP endpoints (m/paliad#157) 2026-05-29 16:08:46 +02:00
mAi
e53bcf8cc2 Merge: t-paliad-349 docforge slice 6a — authoring core + TemplateStore wiring (m/paliad#157) 2026-05-29 16:08:46 +02:00
mAi
68fcbc6fbf feat(docforge): slice 6c — template authoring page (frontend) (t-paliad-349)
The WYSIWYG authoring surface at /admin/templates (admin-gated page route):
  - templates-authoring.tsx — page shell (upload form, template list,
    workspace: palette / run-addressable preview / placed slots).
  - client/templates-authoring.ts — hydrates it: lists templates, uploads a
    .docx (multipart), renders the run-span preview, builds the variable
    palette from the Go catalogue (GET /api/docforge/variables), and wires
    the select-then-pick gesture: select text within one .docforge-run, click
    a palette variable → POST the slot → re-render with the response. Reuses
    the docforge-editor lib (escapeHtml, catalogue client). Cross-run
    selections rejected with a hint (v1: single-run text slots).
  - build.ts emits dist/templates-authoring.html + bundles the client.
  - handleTemplatesAuthoringPage serves the shell; GET /admin/templates
    registered under adminGate.
  - 12 i18n keys (DE+EN) for the page; i18n-keys.ts regenerated (3079).

Verification: go build/vet/test green (13 pkgs); bun run build.ts clean
(i18n scan passes); bun test 274/274; gofmt-clean. The docx surgery + store
+ catalogue are unit/live-tested. VERIFICATION CEILING: the integrated live
flow (upload→render→select→inject→save in a browser) needs the app running
with DATABASE_URL + Supabase auth + Playwright — verified post-merge, not in
this env.

m/paliad#157
2026-05-29 16:07:43 +02:00
mAi
31e15d4b20 feat(docforge): slice 6b — template authoring HTTP endpoints (t-paliad-349)
Admin-gated authoring API over docforge.TemplateStore + the docx authoring
engine (handlers/templates.go, routes under adminGate):
  GET  /api/admin/templates            — catalog list
  POST /api/admin/templates            — multipart upload → ImportForAuthoring
                                          (validate + detect slots) → Create v1
  GET  /api/admin/templates/{id}       — authoring view (run-addressable
                                          preview + slots)
  POST /api/admin/templates/{id}/slots — InjectSlot at the selection →
                                          AddVersion (re-detect slots from the
                                          new carrier so template_slots mirror
                                          the carrier's actual {{tokens}})

docforge.ErrTemplateNotFound → 404; injection failures (bad selection/key)
→ 400 with the engine's message for the UI to surface. Upload capped at
10 MB. Slot placement creates a version per placement (immutable snapshot);
batching a session into one version on explicit save is a documented
refinement.

Verification: go build/vet clean, handlers test green, gofmt-clean. The docx
surgery + store are unit/live-tested; the integrated HTTP flow is verified
post-merge (needs DATABASE_URL + Supabase auth).

m/paliad#157
2026-05-29 16:03:07 +02:00
mAi
a111a82640 feat(docforge): slice 6a — docx authoring core + TemplateStore wiring (t-paliad-349)
The verifiable backend heart of the authoring surface, before the HTTP +
frontend layers.

pkg/docforge/docx/authoring.go:
  - ImportForAuthoring(carrier) → AuthoringView{PreviewHTML, Slots}: parses
    an uploaded .docx into a run-addressable preview (one
    <span class="docforge-run" data-run="N"> per <w:t>, document order)
    plus the {{placeholder}} slots already present.
  - InjectSlot(carrier, runIndex, selectedText, slotKey) → new carrier:
    replaces the selection inside run N with a {{slot_key}} token. Keys on
    the selected TEXT (not a byte/UTF-16 offset) so umlauts can't desync the
    client selection from the server slice; preview + injection walk runs in
    the identical paragraph→<w:t> order so data-run indices line up.
  - v1 scope: text slots in body paragraphs; out-of-run / cross-run / not-
    found selections return an error the UI turns into a hint.

6 unit tests cover run-addressable preview, slot detection, injection +
round-trip re-import, umlaut/run-targeting, and the error paths (selection
absent, out-of-range run, invalid slot key) — all passing.

Wired PgTemplateStore through the stack (main.go → handlers.Services →
dbServices) so the upcoming authoring endpoints can reach it.

Verification: go build/vet clean, full module test green (13 pkgs), new
files gofmt-clean. The HTTP endpoints + frontend authoring page land next;
their live flow needs the post-merge e2e/manual loop (DB+Supabase).

m/paliad#157
2026-05-29 16:00:27 +02:00
11 changed files with 1050 additions and 0 deletions

View File

@@ -174,6 +174,9 @@ func main() {
submissionComposerSvc := services.NewSubmissionComposer(submissionRenderer)
// t-paliad-315 Slice C — building-block library.
submissionBuildingBlockSvc := services.NewBuildingBlockService(pool, branding.Name)
// t-paliad-349 docforge slice 4/6 — uploaded-template store
// (Postgres bytea) backing the authoring surface.
templateStoreSvc := services.NewPgTemplateStore(pool)
// t-paliad-225 Slice A — user-authored checklist templates.
// Slice B adds checklist_shares grants + admin promotion.
checklistCatalogSvc := services.NewChecklistCatalogService(pool)
@@ -190,6 +193,7 @@ func main() {
SubmissionSection: submissionSectionSvc,
SubmissionComposer: submissionComposerSvc,
SubmissionBuildingBlock: submissionBuildingBlockSvc,
TemplateStore: templateStoreSvc,
Deadline: deadlineSvc,
Appointment: appointmentSvc,
CalDAV: caldavSvc,

View File

@@ -18,6 +18,7 @@ import { renderProjectsNew } from "./src/projects-new";
import { renderProjectsDetail } from "./src/projects-detail";
import { renderProjectsChart } from "./src/projects-chart";
import { renderSubmissionDraft } from "./src/submission-draft";
import { renderTemplatesAuthoring } from "./src/templates-authoring";
import { renderSubmissionsIndex } from "./src/submissions-index";
import { renderSubmissionsNew } from "./src/submissions-new";
import { renderEvents } from "./src/events";
@@ -255,6 +256,7 @@ async function build() {
join(import.meta.dir, "src/client/projects-detail.ts"),
join(import.meta.dir, "src/client/projects-chart.ts"),
join(import.meta.dir, "src/client/submission-draft.ts"),
join(import.meta.dir, "src/client/templates-authoring.ts"),
join(import.meta.dir, "src/client/submissions-index.ts"),
join(import.meta.dir, "src/client/submissions-new.ts"),
join(import.meta.dir, "src/client/events.ts"),
@@ -382,6 +384,7 @@ async function build() {
await Bun.write(join(DIST, "projects-detail.html"), renderProjectsDetail());
await Bun.write(join(DIST, "projects-chart.html"), renderProjectsChart());
await Bun.write(join(DIST, "submission-draft.html"), renderSubmissionDraft());
await Bun.write(join(DIST, "templates-authoring.html"), renderTemplatesAuthoring());
await Bun.write(join(DIST, "submissions-index.html"), renderSubmissionsIndex());
await Bun.write(join(DIST, "submissions-new.html"), renderSubmissionsNew());
// t-paliad-115 — shared EventsPage at the canonical /events URL.

View File

@@ -1697,6 +1697,19 @@ const translations: Record<Lang, Record<string, string>> = {
"submissions.draft.base.hint": "Steuert Schriftarten, Briefkopf und Abschnitts-Defaults.",
"submissions.draft.sections.title": "Abschnitte",
"submissions.draft.sections.hint": "Inhalt pro Abschnitt — Autosave nach 500 ms. Letztes Layout in Word.",
// t-paliad-349 (m/paliad#157) docforge slice 6 — template authoring page.
"templates.authoring.title": "Vorlagen — Paliad",
"templates.authoring.heading": "Vorlagen",
"templates.authoring.intro": "Lade eine Word-Vorlage hoch, markiere Stellen und setze Variablen ein.",
"templates.authoring.upload.title": "Neue Vorlage hochladen",
"templates.authoring.upload.file": "Word-Datei (.docx)",
"templates.authoring.upload.name_de": "Name (DE)",
"templates.authoring.upload.name_en": "Name (EN)",
"templates.authoring.upload.firm": "Kanzlei (optional)",
"templates.authoring.upload.submit": "Hochladen",
"templates.authoring.list.title": "Vorhandene Vorlagen",
"templates.authoring.workspace.hint": "Text markieren, dann eine Variable wählen, um einen Platzhalter zu setzen.",
"templates.authoring.slots.title": "Platzhalter",
// t-paliad-315 (m/paliad#141) Composer Slice C — building blocks admin.
"admin.building_blocks.title": "Bausteine — Paliad",
"admin.building_blocks.heading": "Bausteine",
@@ -4962,6 +4975,19 @@ const translations: Record<Lang, Record<string, string>> = {
"submissions.draft.base.hint": "Drives fonts, letterhead, and section defaults.",
"submissions.draft.sections.title": "Sections",
"submissions.draft.sections.hint": "Edit per section — autosaves after 500ms. Final layout in Word.",
// t-paliad-349 (m/paliad#157) docforge slice 6 — template authoring page.
"templates.authoring.title": "Templates — Paliad",
"templates.authoring.heading": "Templates",
"templates.authoring.intro": "Upload a Word template, highlight spots and insert variables.",
"templates.authoring.upload.title": "Upload a new template",
"templates.authoring.upload.file": "Word file (.docx)",
"templates.authoring.upload.name_de": "Name (DE)",
"templates.authoring.upload.name_en": "Name (EN)",
"templates.authoring.upload.firm": "Firm (optional)",
"templates.authoring.upload.submit": "Upload",
"templates.authoring.list.title": "Existing templates",
"templates.authoring.workspace.hint": "Highlight text, then pick a variable to place a placeholder.",
"templates.authoring.slots.title": "Placeholders",
// t-paliad-315 (m/paliad#141) Composer Slice C — building blocks admin.
"admin.building_blocks.title": "Building blocks — Paliad",
"admin.building_blocks.heading": "Building blocks",

View File

@@ -0,0 +1,314 @@
import { initI18n } from "./i18n";
import { initSidebar } from "./sidebar";
import { escapeHtml } from "../lib/docforge-editor/dom";
import { fetchVariableCatalogue, type VariableEntry } from "../lib/docforge-editor/catalogue";
// t-paliad-349 docforge slice 6 — client for the template authoring page.
//
// Flow: list templates → upload a .docx (or open one) → the carrier renders
// as run spans (<span class="docforge-run" data-run="N">) → the admin
// selects text within one run, then clicks a variable in the palette → the
// server injects {{slot}} at the selection and returns the updated view.
//
// The select-then-pick gesture keys on the run index (data-run) + the
// selected text, matching the server's text-based InjectSlot so umlauts
// can't desync the selection from the slice. Selections that span more than
// one run are rejected with a hint (v1 scope: single-run text slots).
interface TemplateMeta {
id: string;
slug?: string;
name_de: string;
name_en: string;
kind: string;
source_format: string;
firm?: string;
is_active: boolean;
version: number;
}
interface TemplateSlot {
key: string;
anchor: string;
label?: string;
order_index: number;
}
interface AuthoringView {
template: TemplateMeta;
preview_html: string;
slots: TemplateSlot[];
}
interface Selection1Run {
runIndex: number;
text: string;
}
interface State {
catalogue: VariableEntry[];
openID: string | null;
activeSlotKey: string | null;
selection: Selection1Run | null;
}
const state: State = {
catalogue: [],
openID: null,
activeSlotKey: null,
selection: null,
};
function isEN(): boolean {
return (document.documentElement.lang || "de").toLowerCase().startsWith("en");
}
function labelOf(e: VariableEntry): string {
return isEN() ? e.label_en : e.label_de;
}
async function boot(): Promise<void> {
initI18n();
initSidebar();
try {
state.catalogue = await fetchVariableCatalogue();
} catch (err) {
console.warn("templates-authoring: catalogue fetch failed", err);
}
wireUploadForm();
await loadList();
}
async function loadList(): Promise<void> {
const host = document.getElementById("docforge-template-list");
if (!host) return;
let metas: TemplateMeta[] = [];
try {
const res = await fetch("/api/admin/templates", { headers: { Accept: "application/json" } });
if (res.ok) {
const body = (await res.json()) as { templates: TemplateMeta[] };
metas = body.templates ?? [];
}
} catch (err) {
console.warn("templates-authoring: list fetch failed", err);
}
if (metas.length === 0) {
host.innerHTML = `<li class="docforge-template-empty">${escapeHtml(isEN() ? "No templates yet." : "Noch keine Vorlagen.")}</li>`;
return;
}
host.innerHTML = metas
.map((m) => {
const name = isEN() ? m.name_en : m.name_de;
const firm = m.firm ? ` · ${escapeHtml(m.firm)}` : "";
return `<li class="docforge-template-row" data-template-id="${escapeHtml(m.id)}">
<span class="docforge-template-name">${escapeHtml(name)}</span>
<span class="docforge-template-meta">v${m.version}${firm}</span>
</li>`;
})
.join("");
host.querySelectorAll<HTMLLIElement>(".docforge-template-row").forEach((li) => {
li.addEventListener("click", () => {
const id = li.dataset.templateId;
if (id) void openTemplate(id);
});
});
}
function wireUploadForm(): void {
const form = document.getElementById("docforge-upload-form") as HTMLFormElement | null;
if (!form) return;
form.addEventListener("submit", async (ev) => {
ev.preventDefault();
const status = document.getElementById("docforge-upload-status");
const data = new FormData(form);
setText(status, isEN() ? "Uploading…" : "Lädt hoch…");
try {
const res = await fetch("/api/admin/templates", { method: "POST", body: data });
if (!res.ok) {
const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
setText(status, (isEN() ? "Error: " : "Fehler: ") + (body.error ?? res.status));
return;
}
const view = (await res.json()) as AuthoringView;
setText(status, "");
form.reset();
await loadList();
openView(view);
} catch (err) {
setText(status, (isEN() ? "Error: " : "Fehler: ") + String(err));
}
});
}
async function openTemplate(id: string): Promise<void> {
try {
const res = await fetch(`/api/admin/templates/${encodeURIComponent(id)}`, {
headers: { Accept: "application/json" },
});
if (!res.ok) return;
openView((await res.json()) as AuthoringView);
} catch (err) {
console.warn("templates-authoring: open failed", err);
}
}
function openView(view: AuthoringView): void {
state.openID = view.template.id;
state.activeSlotKey = null;
state.selection = null;
const workspace = document.getElementById("docforge-workspace");
if (workspace) workspace.hidden = false;
const title = document.getElementById("docforge-workspace-title");
if (title) {
const name = isEN() ? view.template.name_en : view.template.name_de;
title.textContent = `${name} · v${view.template.version}`;
}
renderPreview(view.preview_html);
renderSlots(view.slots);
renderPalette();
setWorkspaceStatus("");
}
function renderPreview(html: string): void {
const host = document.getElementById("docforge-preview");
if (!host) return;
host.innerHTML = html;
host.addEventListener("mouseup", onPreviewSelect);
}
// onPreviewSelect captures a selection that lies entirely within one run
// span; otherwise it clears the pending selection and hints.
function onPreviewSelect(): void {
const sel = window.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) {
state.selection = null;
return;
}
const text = sel.toString();
if (text === "") {
state.selection = null;
return;
}
const anchorRun = closestRun(sel.anchorNode);
const focusRun = closestRun(sel.focusNode);
if (!anchorRun || anchorRun !== focusRun) {
state.selection = null;
setWorkspaceStatus(isEN()
? "Select within a single text span."
: "Bitte innerhalb einer Textstelle markieren.");
return;
}
const runIndex = Number(anchorRun.dataset.run);
if (Number.isNaN(runIndex)) {
state.selection = null;
return;
}
state.selection = { runIndex, text };
setWorkspaceStatus(state.activeSlotKey
? (isEN() ? `Click to bind “${text}” → ${state.activeSlotKey}` : `Variable wählen, um „${text}“ zu setzen`)
: (isEN() ? `Selected “${text}” — now pick a variable.` : `${text}" markiert — jetzt Variable wählen.`));
}
function closestRun(node: Node | null): HTMLElement | null {
let el: Node | null = node;
while (el && el !== document.body) {
if (el instanceof HTMLElement && el.classList.contains("docforge-run")) return el;
el = el.parentNode;
}
return null;
}
// renderPalette groups catalogue entries by their namespace group and wires
// each as a click-to-place control.
function renderPalette(): void {
const host = document.getElementById("docforge-palette");
if (!host) return;
if (state.catalogue.length === 0) {
host.innerHTML = `<p class="docforge-palette-empty">${escapeHtml(isEN() ? "No variables." : "Keine Variablen.")}</p>`;
return;
}
const groups = new Map<string, VariableEntry[]>();
for (const e of state.catalogue) {
const arr = groups.get(e.group) ?? [];
arr.push(e);
groups.set(e.group, arr);
}
let html = `<h3>${escapeHtml(isEN() ? "Variables" : "Variablen")}</h3>`;
for (const [group, entries] of groups) {
html += `<div class="docforge-palette-group"><h4>${escapeHtml(group)}</h4>`;
for (const e of entries) {
html += `<button type="button" class="docforge-palette-var" data-slot-key="${escapeHtml(e.key)}" title="{{${escapeHtml(e.key)}}}">${escapeHtml(labelOf(e))}</button>`;
}
html += `</div>`;
}
host.innerHTML = html;
host.querySelectorAll<HTMLButtonElement>(".docforge-palette-var").forEach((btn) => {
btn.addEventListener("click", () => onPaletteClick(btn.dataset.slotKey ?? "", btn));
});
}
function onPaletteClick(slotKey: string, btn: HTMLButtonElement): void {
state.activeSlotKey = slotKey;
const host = document.getElementById("docforge-palette");
host?.querySelectorAll(".docforge-palette-var--active").forEach((el) => el.classList.remove("docforge-palette-var--active"));
btn.classList.add("docforge-palette-var--active");
if (state.selection) {
void placeSlot(state.selection.runIndex, state.selection.text, slotKey);
} else {
setWorkspaceStatus(isEN()
? `${slotKey} selected — now highlight the text to replace.`
: `${slotKey} gewählt — jetzt den zu ersetzenden Text markieren.`);
}
}
async function placeSlot(runIndex: number, selectedText: string, slotKey: string): Promise<void> {
if (!state.openID) return;
setWorkspaceStatus(isEN() ? "Placing…" : "Setze…");
try {
const res = await fetch(`/api/admin/templates/${encodeURIComponent(state.openID)}/slots`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ run_index: runIndex, selected_text: selectedText, slot_key: slotKey }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
setWorkspaceStatus((isEN() ? "Error: " : "Fehler: ") + (body.error ?? res.status));
return;
}
openView((await res.json()) as AuthoringView);
} catch (err) {
setWorkspaceStatus((isEN() ? "Error: " : "Fehler: ") + String(err));
}
}
function renderSlots(slots: TemplateSlot[]): void {
const host = document.getElementById("docforge-slot-list");
if (!host) return;
if (slots.length === 0) {
host.innerHTML = `<li class="docforge-slot-empty">${escapeHtml(isEN() ? "No slots yet." : "Noch keine Platzhalter.")}</li>`;
return;
}
host.innerHTML = slots
.map((s) => `<li class="docforge-slot-row" data-slot="${escapeHtml(s.key)}"><code>{{${escapeHtml(s.key)}}}</code></li>`)
.join("");
}
function setWorkspaceStatus(msg: string): void {
setText(document.getElementById("docforge-workspace-status"), msg);
}
function setText(el: Element | null, msg: string): void {
if (el) el.textContent = msg;
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => void boot());
} else {
void boot();
}

View File

@@ -2887,6 +2887,18 @@ export type I18nKey =
| "team.selection.toggle_card"
| "team.subtitle"
| "team.title"
| "templates.authoring.heading"
| "templates.authoring.intro"
| "templates.authoring.list.title"
| "templates.authoring.slots.title"
| "templates.authoring.title"
| "templates.authoring.upload.file"
| "templates.authoring.upload.firm"
| "templates.authoring.upload.name_de"
| "templates.authoring.upload.name_en"
| "templates.authoring.upload.submit"
| "templates.authoring.upload.title"
| "templates.authoring.workspace.hint"
| "theme.toggle.auto"
| "theme.toggle.cycle.auto"
| "theme.toggle.cycle.dark"

View File

@@ -0,0 +1,112 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// t-paliad-349 docforge slice 6 — template authoring page at
// /admin/templates.
//
// Admin uploads a base .docx, sees it rendered as run-addressable text,
// selects a span + a variable from the palette to drop a {{slot}}, and the
// result saves as a reusable docforge template. Pure shell:
// client/templates-authoring.ts hydrates the list, upload form, preview,
// palette, and slot list after load. The palette labels come from the Go
// variable catalogue (GET /api/docforge/variables, the SSOT from slice 5).
//
// Design ref: docs/plans/prd-docforge-2026-05-29.md §2.1.
export function renderTemplatesAuthoring(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<PWAHead />
<title data-i18n="templates.authoring.title">Vorlagen &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar page-templates-authoring">
<Sidebar currentPath="/admin" />
<BottomNav currentPath="/admin" />
<main>
<section className="tool-page docforge-templates-page">
<div className="container">
<header className="docforge-templates-header">
<h1 data-i18n="templates.authoring.heading">Vorlagen</h1>
<p
className="docforge-templates-intro"
data-i18n="templates.authoring.intro">
Lade eine Word-Vorlage hoch, markiere Stellen und setze Variablen ein.
</p>
</header>
{/* Upload a new base .docx */}
<section className="docforge-upload" id="docforge-upload">
<h2 data-i18n="templates.authoring.upload.title">Neue Vorlage hochladen</h2>
<form id="docforge-upload-form" className="entity-form">
<label className="entity-form-row">
<span data-i18n="templates.authoring.upload.file">Word-Datei (.docx)</span>
<input type="file" name="file" accept=".docx,.dotx,.docm,.dotm" required />
</label>
<label className="entity-form-row">
<span data-i18n="templates.authoring.upload.name_de">Name (DE)</span>
<input type="text" name="name_de" className="entity-form-input" required />
</label>
<label className="entity-form-row">
<span data-i18n="templates.authoring.upload.name_en">Name (EN)</span>
<input type="text" name="name_en" className="entity-form-input" required />
</label>
<label className="entity-form-row">
<span data-i18n="templates.authoring.upload.firm">Kanzlei (optional)</span>
<input type="text" name="firm" className="entity-form-input" />
</label>
<button type="submit" className="btn-primary" data-i18n="templates.authoring.upload.submit">
Hochladen
</button>
<span className="docforge-upload-status" id="docforge-upload-status" />
</form>
</section>
{/* Existing templates */}
<section className="docforge-template-list-wrap">
<h2 data-i18n="templates.authoring.list.title">Vorhandene Vorlagen</h2>
<ul className="entity-table docforge-template-list" id="docforge-template-list" />
</section>
{/* Authoring workspace — hidden until a template is opened. */}
<section className="docforge-workspace" id="docforge-workspace" hidden>
<header className="docforge-workspace-header">
<h2 id="docforge-workspace-title" />
<span className="docforge-workspace-hint" data-i18n="templates.authoring.workspace.hint">
Text markieren, dann eine Variable wählen, um einen Platzhalter zu setzen.
</span>
<span className="docforge-workspace-status" id="docforge-workspace-status" />
</header>
<div className="docforge-workspace-grid">
{/* Variable palette (left) — populated from the catalogue. */}
<aside className="docforge-palette" id="docforge-palette" />
{/* Run-addressable preview (center) — selection target. */}
<div className="docforge-preview" id="docforge-preview" />
{/* Placed slots (right). */}
<aside className="docforge-slots">
<h3 data-i18n="templates.authoring.slots.title">Platzhalter</h3>
<ul className="docforge-slot-list" id="docforge-slot-list" />
</aside>
</div>
</section>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/templates-authoring.js"></script>
</body>
</html>
);
}

View File

@@ -128,6 +128,10 @@ type Services struct {
// editor. Per Q2: paste sources only, no lineage on sections.
SubmissionBuildingBlock *services.BuildingBlockService
// t-paliad-349 docforge slice 4/6 — uploaded-template store backing
// the authoring surface.
TemplateStore *services.PgTemplateStore
// t-paliad-265 / m/paliad#96 — per-event-card optional choices on
// the Verfahrensablauf timeline.
EventChoice *services.EventChoiceService
@@ -215,6 +219,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
submissionSection: svc.SubmissionSection,
submissionComposer: svc.SubmissionComposer,
submissionBuildingBlock: svc.SubmissionBuildingBlock,
templateStore: svc.TemplateStore,
eventChoice: svc.EventChoice,
scenario: svc.Scenario,
scenarioFlags: svc.ScenarioFlags,
@@ -750,6 +755,15 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /api/admin/backups/{id}", adminGate(users, handleAdminGetBackup))
protected.HandleFunc("GET /api/admin/backups/{id}/file", adminGate(users, handleAdminDownloadBackup))
// t-paliad-349 docforge slice 6 — template authoring surface
// (upload base .docx → place variable slots → save). Admin-only,
// firm-shared catalog like submission_bases.
protected.HandleFunc("GET /admin/templates", adminGate(users, gateOnboarded(handleTemplatesAuthoringPage)))
protected.HandleFunc("GET /api/admin/templates", adminGate(users, handleListTemplates))
protected.HandleFunc("POST /api/admin/templates", adminGate(users, handleUploadTemplate))
protected.HandleFunc("GET /api/admin/templates/{id}", adminGate(users, handleGetTemplateAuthoring))
protected.HandleFunc("POST /api/admin/templates/{id}/slots", adminGate(users, handlePlaceTemplateSlot))
protected.HandleFunc("GET /api/admin/users", adminGate(users, handleAdminListUsers))
protected.HandleFunc("POST /api/admin/users", adminGate(users, handleAdminCreateUser))
protected.HandleFunc("POST /api/admin/users/full", adminGate(users, handleAdminCreateFullUser))

View File

@@ -77,6 +77,9 @@ type dbServices struct {
submissionComposer *services.SubmissionComposer
submissionBuildingBlock *services.BuildingBlockService
// t-paliad-349 docforge slice 4/6 — uploaded-template store.
templateStore *services.PgTemplateStore
// t-paliad-265 — per-event-card optional choices.
eventChoice *services.EventChoiceService

View File

@@ -0,0 +1,279 @@
package handlers
// docforge template authoring handlers (t-paliad-349 slice 6).
//
// The admin-only authoring surface: upload a base .docx, see it rendered as
// run-addressable text, place {{variable}} slots into it, and save the
// result as a reusable template. Backed by docforge.TemplateStore
// (Postgres bytea carrier) + the docx authoring engine
// (ImportForAuthoring / InjectSlot).
//
// Endpoints (all under adminGate — templates are firm-shared, admin-
// authored, like submission_bases):
// GET /api/admin/templates — catalog list
// POST /api/admin/templates — multipart upload → create v1
// GET /api/admin/templates/{id} — authoring view (preview+slots)
// POST /api/admin/templates/{id}/slots — place a slot → new version
//
// Slot placement creates a new template version (immutable snapshot) per
// placement. That keeps the snapshot guarantee simple; batching a whole
// authoring session into one version on an explicit "save" is a documented
// future refinement (it trades the version-per-slot churn for a client- or
// session-held draft carrier).
//
// VERIFICATION CEILING: the live upload→render→select→inject→save flow
// needs the app running with DATABASE_URL + Supabase auth + Playwright; it
// is verified post-merge. The docx surgery (ImportForAuthoring/InjectSlot)
// and the store are unit/live-tested independently.
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"mgit.msbls.de/m/paliad/pkg/docforge"
"mgit.msbls.de/m/paliad/pkg/docforge/docx"
)
// maxTemplateUpload bounds an uploaded .docx. Templates are firm letterhead
// + chrome — tens of KB in practice; 10 MB is a generous ceiling.
const maxTemplateUpload = 10 << 20
type templateMetaJSON struct {
ID string `json:"id"`
Slug string `json:"slug,omitempty"`
NameDE string `json:"name_de"`
NameEN string `json:"name_en"`
Kind string `json:"kind"`
SourceFormat string `json:"source_format"`
Firm string `json:"firm,omitempty"`
IsActive bool `json:"is_active"`
Version int `json:"version"`
}
type templateSlotJSON struct {
Key string `json:"key"`
Anchor string `json:"anchor"`
Label string `json:"label,omitempty"`
OrderIndex int `json:"order_index"`
}
type authoringViewJSON struct {
Template templateMetaJSON `json:"template"`
PreviewHTML string `json:"preview_html"`
Slots []templateSlotJSON `json:"slots"`
}
func metaJSON(m docforge.TemplateMeta) templateMetaJSON {
return templateMetaJSON{
ID: m.ID, Slug: m.Slug, NameDE: m.NameDE, NameEN: m.NameEN,
Kind: m.Kind, SourceFormat: m.SourceFormat, Firm: m.Firm,
IsActive: m.IsActive, Version: m.Version,
}
}
func slotsJSON(slots []docforge.TemplateSlot) []templateSlotJSON {
out := make([]templateSlotJSON, 0, len(slots))
for _, s := range slots {
out = append(out, templateSlotJSON{Key: s.Key, Anchor: s.Anchor, Label: s.Label, OrderIndex: s.OrderIndex})
}
return out
}
// writeTemplateError maps docforge's not-found sentinel to 404 and falls
// back to the shared service-error mapper.
func writeTemplateError(w http.ResponseWriter, err error) {
if errors.Is(err, docforge.ErrTemplateNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "template not found"})
return
}
writeServiceError(w, err)
}
func requireTemplateStore(w http.ResponseWriter) bool {
if !requireDB(w) {
return false
}
if dbSvc.templateStore == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "template store not configured"})
return false
}
return true
}
// handleTemplatesAuthoringPage serves the authoring page shell. The client
// bundle hydrates the list, upload, preview, palette, and slots.
func handleTemplatesAuthoringPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/templates-authoring.html")
}
// handleListTemplates backs GET /api/admin/templates.
func handleListTemplates(w http.ResponseWriter, r *http.Request) {
if !requireTemplateStore(w) {
return
}
if _, ok := requireUser(w, r); !ok {
return
}
metas, err := dbSvc.templateStore.List(r.Context(), docforge.TemplateFilter{ActiveOnly: true})
if err != nil {
writeTemplateError(w, err)
return
}
out := make([]templateMetaJSON, 0, len(metas))
for _, m := range metas {
out = append(out, metaJSON(m))
}
writeJSON(w, http.StatusOK, map[string]any{"templates": out})
}
// handleUploadTemplate backs POST /api/admin/templates (multipart). Reads
// the uploaded .docx, validates it parses, detects any slots already in it,
// and creates the template at version 1.
func handleUploadTemplate(w http.ResponseWriter, r *http.Request) {
if !requireTemplateStore(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if err := r.ParseMultipartForm(maxTemplateUpload); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart form"})
return
}
file, _, err := r.FormFile("file")
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file field required"})
return
}
defer file.Close()
carrier, err := io.ReadAll(io.LimitReader(file, maxTemplateUpload))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "could not read uploaded file"})
return
}
nameDE := r.FormValue("name_de")
nameEN := r.FormValue("name_en")
if nameDE == "" || nameEN == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name_de and name_en required"})
return
}
// Validate + detect existing slots before persisting.
view, err := docx.ImportForAuthoring(carrier)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "not a parseable .docx: " + err.Error()})
return
}
tmpl, err := dbSvc.templateStore.Create(r.Context(),
docforge.TemplateMetaInput{
Slug: r.FormValue("slug"),
NameDE: nameDE,
NameEN: nameEN,
Firm: r.FormValue("firm"),
CreatedBy: uid.String(),
},
docforge.TemplateVersionInput{
CarrierBytes: carrier,
Slots: view.Slots,
CreatedBy: uid.String(),
})
if err != nil {
writeTemplateError(w, err)
return
}
writeJSON(w, http.StatusCreated, authoringViewJSON{
Template: metaJSON(tmpl.TemplateMeta),
PreviewHTML: view.PreviewHTML,
Slots: slotsJSON(tmpl.Slots),
})
}
// handleGetTemplateAuthoring backs GET /api/admin/templates/{id} — the
// authoring view: current carrier rendered run-addressable + its slots.
func handleGetTemplateAuthoring(w http.ResponseWriter, r *http.Request) {
if !requireTemplateStore(w) {
return
}
if _, ok := requireUser(w, r); !ok {
return
}
tmpl, err := dbSvc.templateStore.Get(r.Context(), r.PathValue("id"))
if err != nil {
writeTemplateError(w, err)
return
}
view, err := docx.ImportForAuthoring(tmpl.CarrierBytes)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "stored carrier failed to parse: " + err.Error()})
return
}
writeJSON(w, http.StatusOK, authoringViewJSON{
Template: metaJSON(tmpl.TemplateMeta),
PreviewHTML: view.PreviewHTML,
Slots: slotsJSON(view.Slots),
})
}
type placeSlotInput struct {
RunIndex int `json:"run_index"`
SelectedText string `json:"selected_text"`
SlotKey string `json:"slot_key"`
}
// handlePlaceTemplateSlot backs POST /api/admin/templates/{id}/slots —
// inject a slot at the selection and persist as a new version.
func handlePlaceTemplateSlot(w http.ResponseWriter, r *http.Request) {
if !requireTemplateStore(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var in placeSlotInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
id := r.PathValue("id")
tmpl, err := dbSvc.templateStore.Get(r.Context(), id)
if err != nil {
writeTemplateError(w, err)
return
}
newCarrier, err := docx.InjectSlot(tmpl.CarrierBytes, in.RunIndex, in.SelectedText, in.SlotKey)
if err != nil {
// Injection failures are client-fixable (bad selection / key).
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
// Re-detect slots from the new carrier so template_slots mirrors the
// carrier's actual {{tokens}} (single source of truth).
newView, err := docx.ImportForAuthoring(newCarrier)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": fmt.Sprintf("post-inject parse: %v", err)})
return
}
updated, err := dbSvc.templateStore.AddVersion(r.Context(), id,
docforge.TemplateVersionInput{
CarrierBytes: newCarrier,
Stylemap: tmpl.Stylemap,
Slots: newView.Slots,
CreatedBy: uid.String(),
})
if err != nil {
writeTemplateError(w, err)
return
}
writeJSON(w, http.StatusOK, authoringViewJSON{
Template: metaJSON(updated.TemplateMeta),
PreviewHTML: newView.PreviewHTML,
Slots: slotsJSON(updated.Slots),
})
}

View File

@@ -0,0 +1,172 @@
package docx
// Authoring support — the .docx side of the docforge authoring surface
// (t-paliad-349 slice 6). Two operations back the "upload a base .docx →
// place variable slots" flow:
//
// ImportForAuthoring — parse an uploaded .docx into a run-addressable
// preview (one <span data-run="N"> per <w:t>, in document order) plus
// the slots already present in the carrier.
// InjectSlot — replace a selected piece of text inside run N with a
// {{slot_key}} placeholder, returning the new carrier bytes. The
// placeholder is the sentinel that locates the slot (PRD §5 lean) and
// the same token the generation-time renderer substitutes.
//
// Both walk runs in the same order (paragraphs, then <w:t> within), so the
// data-run indices the preview emits address exactly the runs InjectSlot
// targets. Injection keys on the selected text
// (not a byte/UTF-16 offset) so umlauts in German prose can't desync the
// client's selection from the server's slice.
//
// v1 scope (PRD §2.1): text-level slots inside body paragraphs. A run is a
// <w:t> within a <w:p>; selections spanning runs or sitting in
// headers/footers/tables are out of scope and surface as an error the UI
// turns into "select within a single text span".
import (
"bytes"
"fmt"
"strconv"
"strings"
"mgit.msbls.de/m/paliad/pkg/docforge"
)
// AuthoringView is the parsed, run-addressable form of an uploaded
// template, ready for the authoring editor.
type AuthoringView struct {
// PreviewHTML is the body rendered as paragraphs of run spans:
// <p>…<span class="docforge-run" data-run="N">text</span>…</p>.
// The client attaches selection handling to the run spans; data-run
// is the index InjectSlot expects.
PreviewHTML string
// Slots are the {{placeholder}} tokens already present in the
// carrier (so re-opening a saved template shows its slots).
Slots []docforge.TemplateSlot
}
// ImportForAuthoring parses carrierBytes (any .docx/.dotm/...) into an
// AuthoringView. Runs the .dotm→.docx pre-pass so macro templates import
// cleanly.
func ImportForAuthoring(carrierBytes []byte) (*AuthoringView, error) {
clean, err := ConvertDotmToDocx(carrierBytes)
if err != nil {
return nil, fmt.Errorf("authoring import: convert: %w", err)
}
documentXML, _, err := splitBaseZip(clean)
if err != nil {
return nil, fmt.Errorf("authoring import: %w", err)
}
return &AuthoringView{
PreviewHTML: authoringPreviewHTML(documentXML),
Slots: detectSlots(documentXML),
}, nil
}
// authoringPreviewHTML renders the body as run-addressable HTML. One <p>
// per <w:p>; one <span class="docforge-run" data-run="N"> per <w:t>, with
// the decoded run text HTML-escaped. N is the global run index in
// document-then-paragraph order — the same order InjectSlot walks.
func authoringPreviewHTML(documentXML []byte) string {
var out bytes.Buffer
runIdx := 0
paras := wParagraphRegex.FindAll(documentXML, -1)
for _, para := range paras {
out.WriteString("<p>")
for _, m := range wTextNodeRegex.FindAllSubmatch(para, -1) {
text := xmlDecode(string(m[2]))
out.WriteString(`<span class="docforge-run" data-run="`)
out.WriteString(strconv.Itoa(runIdx))
out.WriteString(`">`)
out.WriteString(htmlEscape(text))
out.WriteString(`</span>`)
runIdx++
}
out.WriteString("</p>\n")
}
if out.Len() == 0 {
return "<p></p>"
}
return out.String()
}
// detectSlots returns the distinct {{placeholder}} tokens present in the
// document body, in first-appearance order.
func detectSlots(documentXML []byte) []docforge.TemplateSlot {
seen := map[string]bool{}
var slots []docforge.TemplateSlot
// Match against decoded text so a placeholder split by an entity is
// still found the same way the renderer would substitute it.
for _, m := range wTextNodeRegex.FindAllSubmatch(documentXML, -1) {
text := xmlDecode(string(m[2]))
for _, pm := range placeholderRegex.FindAllStringSubmatch(text, -1) {
key := pm[1]
if seen[key] {
continue
}
seen[key] = true
slots = append(slots, docforge.TemplateSlot{
Key: key,
Anchor: "{{" + key + "}}",
OrderIndex: len(slots),
})
}
}
return slots
}
// InjectSlot replaces the first occurrence of selectedText inside run
// runIndex with a {{slotKey}} placeholder and returns the new carrier
// bytes. Errors when the run is out of range or selectedText isn't found
// in that run (a render/selection desync, or a cross-run selection).
func InjectSlot(carrierBytes []byte, runIndex int, selectedText, slotKey string) ([]byte, error) {
if selectedText == "" {
return nil, fmt.Errorf("authoring inject: empty selection")
}
if !placeholderRegex.MatchString("{{" + slotKey + "}}") {
return nil, fmt.Errorf("authoring inject: invalid slot key %q", slotKey)
}
clean, err := ConvertDotmToDocx(carrierBytes)
if err != nil {
return nil, fmt.Errorf("authoring inject: convert: %w", err)
}
documentXML, parts, err := splitBaseZip(clean)
if err != nil {
return nil, fmt.Errorf("authoring inject: %w", err)
}
runIdx := 0
injected := false
newDoc := wParagraphRegex.ReplaceAllFunc(documentXML, func(para []byte) []byte {
return wTextNodeRegex.ReplaceAllFunc(para, func(tnode []byte) []byte {
idx := runIdx
runIdx++
if injected || idx != runIndex {
return tnode
}
sub := wTextNodeRegex.FindSubmatch(tnode)
attrs := string(sub[1])
content := xmlDecode(string(sub[2]))
before, after, found := strings.Cut(content, selectedText)
if !found {
return tnode // not found here — reported after the walk
}
newContent := before + "{{" + slotKey + "}}" + after
if !strings.Contains(attrs, "xml:space") &&
(strings.HasPrefix(newContent, " ") || strings.HasSuffix(newContent, " ")) {
attrs += ` xml:space="preserve"`
}
injected = true
return []byte(`<w:t` + attrs + `>` + xmlEncode(newContent) + `</w:t>`)
})
})
if !injected {
return nil, fmt.Errorf("authoring inject: selection %q not found in run %d", selectedText, runIndex)
}
repacked, err := repackBaseZip(parts, newDoc)
if err != nil {
return nil, fmt.Errorf("authoring inject: %w", err)
}
return repacked, nil
}

View File

@@ -0,0 +1,111 @@
package docx
import (
"strings"
"testing"
)
// docBody wraps a <w:body> inner string into a full document.xml.
func docBody(inner string) string {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">` +
`<w:body>` + inner + `</w:body></w:document>`
}
func TestImportForAuthoring_PreviewIsRunAddressable(t *testing.T) {
body := docBody(
`<w:p><w:r><w:t>Az. 4c O 12/23</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>Klägerin</w:t></w:r><w:r><w:t xml:space="preserve"> GmbH</w:t></w:r></w:p>`)
view, err := ImportForAuthoring(minimalMergeDOCX(t, body))
if err != nil {
t.Fatalf("ImportForAuthoring: %v", err)
}
// Three <w:t> → three run spans, indexed 0,1,2 in document order.
for i, want := range []string{`data-run="0"`, `data-run="1"`, `data-run="2"`} {
if !strings.Contains(view.PreviewHTML, want) {
t.Errorf("preview missing %s (run %d); html=%s", want, i, view.PreviewHTML)
}
}
if !strings.Contains(view.PreviewHTML, "Az. 4c O 12/23") {
t.Errorf("preview missing run text; html=%s", view.PreviewHTML)
}
// Two paragraphs.
if n := strings.Count(view.PreviewHTML, "<p>"); n != 2 {
t.Errorf("paragraph count = %d; want 2", n)
}
if len(view.Slots) != 0 {
t.Errorf("fresh doc should have no slots; got %v", view.Slots)
}
}
func TestImportForAuthoring_DetectsExistingSlots(t *testing.T) {
body := docBody(`<w:p><w:r><w:t>Az. {{project.case_number}} vor {{project.court}}</w:t></w:r></w:p>`)
view, err := ImportForAuthoring(minimalMergeDOCX(t, body))
if err != nil {
t.Fatalf("ImportForAuthoring: %v", err)
}
if len(view.Slots) != 2 {
t.Fatalf("slots = %d; want 2 (%v)", len(view.Slots), view.Slots)
}
if view.Slots[0].Key != "project.case_number" || view.Slots[0].Anchor != "{{project.case_number}}" {
t.Errorf("slot[0] = %+v; want project.case_number", view.Slots[0])
}
if view.Slots[1].Key != "project.court" {
t.Errorf("slot[1].Key = %q; want project.court", view.Slots[1].Key)
}
}
func TestInjectSlot_ReplacesSelectionWithPlaceholder(t *testing.T) {
body := docBody(`<w:p><w:r><w:t>Az. 4c O 12/23 vor dem LG</w:t></w:r></w:p>`)
out, err := InjectSlot(minimalMergeDOCX(t, body), 0, "4c O 12/23", "project.case_number")
if err != nil {
t.Fatalf("InjectSlot: %v", err)
}
doc := readMergeDocumentXML(t, out)
if !strings.Contains(doc, "Az. {{project.case_number}} vor dem LG") {
t.Errorf("injected doc wrong; got %s", doc)
}
// Round-trips: re-importing finds the new slot.
view, err := ImportForAuthoring(out)
if err != nil {
t.Fatalf("re-import: %v", err)
}
if len(view.Slots) != 1 || view.Slots[0].Key != "project.case_number" {
t.Errorf("re-imported slots = %v; want [project.case_number]", view.Slots)
}
}
func TestInjectSlot_TargetsTheNamedRun(t *testing.T) {
// "GmbH" appears in run 1 only; "Müller" (with umlaut) in run 0.
body := docBody(
`<w:p><w:r><w:t>Müller</w:t></w:r><w:r><w:t xml:space="preserve"> GmbH</w:t></w:r></w:p>`)
out, err := InjectSlot(minimalMergeDOCX(t, body), 0, "Müller", "parties.claimant.name")
if err != nil {
t.Fatalf("InjectSlot: %v", err)
}
doc := readMergeDocumentXML(t, out)
if !strings.Contains(doc, "{{parties.claimant.name}}") {
t.Errorf("umlaut selection not replaced; got %s", doc)
}
if !strings.Contains(doc, " GmbH") {
t.Errorf("run 1 should be untouched; got %s", doc)
}
}
func TestInjectSlot_ErrorsWhenSelectionNotInRun(t *testing.T) {
body := docBody(`<w:p><w:r><w:t>Hello</w:t></w:r></w:p>`)
if _, err := InjectSlot(minimalMergeDOCX(t, body), 0, "Goodbye", "firm.name"); err == nil {
t.Error("expected error when selection absent from run; got nil")
}
// Out-of-range run index.
if _, err := InjectSlot(minimalMergeDOCX(t, body), 9, "Hello", "firm.name"); err == nil {
t.Error("expected error for out-of-range run index; got nil")
}
}
func TestInjectSlot_RejectsInvalidSlotKey(t *testing.T) {
body := docBody(`<w:p><w:r><w:t>Hello</w:t></w:r></w:p>`)
if _, err := InjectSlot(minimalMergeDOCX(t, body), 0, "Hello", "9bad-key!"); err == nil {
t.Error("expected error for invalid slot key; got nil")
}
}