Compare commits

..

1 Commits

Author SHA1 Message Date
mAi
677849784c feat(submissions): Composer Slice D — rich prose (headings, lists, blockquote, hyperlinks) (m/paliad#141)
Some checks failed
Paliad CI gate / deploy (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Extends the Composer's MD → OOXML walker per the design at
docs/design-submission-generator-v2-2026-05-26.md §12 Slice D from
Slice B's paragraphs + B/I baseline to the full rich-prose feature set:
headings 1-3, bullet + numbered lists, blockquote, inline hyperlinks.

MD walker (internal/services/submission_md.go, +320 / -75 LoC):
- RenderMarkdownToOOXMLWithStyles is the new Slice-D entry point;
  RenderMarkdownToOOXML stays as a thin back-compat wrapper.
- splitMarkdownBlocks classifies every line into one of:
  paragraph, heading_1/2/3, list_bullet, list_numbered, blockquote.
  CommonMark-style 3-space indent tolerance; "N. " and "N) " for
  numbered. Blank-line spacing semantics preserved from Slice B.
- renderBlockParagraph applies stylemap[blk.styleKey] (with
  fall-back to stylemap["paragraph"]). List blocks emit visible
  "• " / "N. " prefix runs so the structure surfaces even if Word
  isn't configured with auto-list-numbering — lawyer can apply a
  real Word list style post-export. Numbered-list ordinals reset
  on every non-list block (so "1. A\nplain\n1. C" renders 1./1.,
  not 1./2.).
- parseInlineRuns adds `[label](url)` recognition. Each link gets
  routed through the optional HyperlinkAllocator; the walker emits
  `<w:hyperlink r:id="{rId}">…runs…</w:hyperlink>` with the
  "Hyperlink" character style on each child run. Nil allocator
  falls back to plain-text label (URL drops, label survives).

Composer (internal/services/submission_compose.go, +130 / -10 LoC):
- composerLinkAllocator hands the walker fresh rIds (rIdComposer1,
  rIdComposer2, …) outside the base's existing namespace; same URL
  shared across multiple sections dedupes to one rId.
- patchDocumentXMLRels appends matching <Relationship Type="…/hyperlink"
  Target="URL" TargetMode="External"/> entries to
  word/_rels/document.xml.rels. Idempotent on rIds already present;
  synthesizes a fresh rels part when missing (defensive for stripped
  bases). Returns the patched parts slice (caller must overwrite
  because append may grow the backing array — fixed in this slice).
- Compose now passes the full stylemap (paragraph + heading_1/2/3 +
  list_bullet + list_numbered + blockquote) into the walker, not
  just the paragraph-style entry.

Frontend (frontend/src/client/submission-draft.ts):
- Toolbar adds H1/H2/H3 buttons (formatBlock h1/h2/h3), bullet
  list, numbered list, blockquote, and a link button that prompts
  for a URL + wraps the selection via execCommand("createLink").
- domToMarkdown serializer extends to <h1>/<h2>/<h3>, <ul>/<ol>
  with per-item ordinal counter for numbered lists, <blockquote>,
  and <a href="…"> → `[label](url)`. Nested <li> handling sits in
  the ul/ol branch.

Tests (internal/services/submission_md_test.go, internal/services/
submission_compose_test.go):
- TestRenderMarkdownToOOXML_Heading1 / _Heading2And3 — stylemap
  applied.
- _BulletList / _NumberedList / _NumberedListResetsOnNonList —
  prefixes + ordinal counter.
- _Blockquote — stylemap applied.
- _Hyperlink — allocator called, w:hyperlink rId wired, Hyperlink
  character style on label runs.
- _HyperlinkNilAllocatorFallsBackToPlain — label survives, no
  hyperlink tag emitted.
- TestDetectBlockMarker — 13 marker / non-marker cases.
- TestComposer_HeadingsAndLists — end-to-end through Compose with
  a multi-construct draft; verifies stylemap presence + content +
  ordinal prefixes.
- TestComposer_HyperlinkWiresRels — body has the right
  <w:hyperlink r:id="rIdComposer{N}">, document.xml.rels has the
  matching <Relationship> rows with External target mode.
- TestComposer_HyperlinkDedupesByURL — two `[label](url)` references
  to the same URL share one rId; second allocation gets no new
  Relationship row.

Build hygiene: go build/vet/test -short clean (all packages); bun run
build clean (2906 i18n keys).

NOT in scope (Slice D's brief was rich-prose + toolbar):
- Numbering.xml audit on bases — current approach emits visible
  "• " / "N. " prefix runs without depending on numbering.xml. A
  future slice can swap to `<w:numPr>` if firm-style auto-numbering
  becomes a hard requirement.
- DOM-from-Markdown on initial editor paint — the editor still uses
  textContent=md, so toolbar-applied formatting reverts to literal
  Markdown text after autosave + repaint. Acceptable trade-off for
  Slice D; a future polish could parse MD into the DOM on paint.
- Tables, images, footnotes (still design §13 out of scope).

Hard rules honoured:
- NO new migrations (Slice D is pure code).
- NO behavior change for pre-Composer drafts (gate on draft.BaseID
  unchanged).
- {{rule.X}} aliases preserved (placeholders pass through the walker
  verbatim, get substituted by the v1 SubmissionRenderer pass).
- Q2 ratification preserved (no building_block_id lineage).
- Q9 ratification preserved (4-tier BB visibility from Slice C).

t-paliad-316 Slice D
2026-05-26 20:15:28 +02:00
17 changed files with 857 additions and 371 deletions

View File

@@ -5,7 +5,7 @@ import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// /admin/procedural-events/{id}/edit — Slice 11b (t-paliad-192). Form for the full
// /admin/rules/{id}/edit — Slice 11b (t-paliad-192). Form for the full
// 37-column rule row plus a side panel with the preview widget and the
// audit-log timeline. Lifecycle action bar at the bottom adapts to the
// rule's current state (draft/published/archived). Every write goes
@@ -26,12 +26,12 @@ export function renderAdminRulesEdit(): string {
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.procedural_events.edit.title">Regel bearbeiten &mdash; Paliad</title>
<title data-i18n="admin.rules.edit.title">Regel bearbeiten &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/procedural-events" />
<BottomNav currentPath="/admin/procedural-events" />
<Sidebar currentPath="/admin/rules" />
<BottomNav currentPath="/admin/rules" />
<main>
<section className="tool-page">
@@ -39,7 +39,7 @@ export function renderAdminRulesEdit(): string {
<div className="tool-header admin-rules-edit-header">
<div>
<p className="admin-rules-breadcrumb">
<a href="/admin/procedural-events" data-i18n="admin.procedural_events.edit.breadcrumb">&larr; Regeln verwalten</a>
<a href="/admin/rules" data-i18n="admin.rules.edit.breadcrumb">&larr; Regeln verwalten</a>
</p>
<h1 id="rules-edit-heading" data-i18n="admin.rules.edit.heading.loading">Regel laden...</h1>
<div className="admin-rules-edit-meta">
@@ -71,7 +71,7 @@ export function renderAdminRulesEdit(): string {
</div>
<div className="admin-rules-edit-row">
<div className="form-field">
<label htmlFor="f-submission-code" data-i18n="admin.procedural_events.edit.field.code">Submission Code / Einreichung-Kennung</label>
<label htmlFor="f-submission-code" data-i18n="admin.rules.edit.field.submission_code">Submission Code / Einreichung-Kennung</label>
<input type="text" id="f-submission-code" className="admin-rules-input" readonly placeholder="z. B. upc.inf.cfi.soc" />
</div>
<div className="form-field">
@@ -103,7 +103,7 @@ export function renderAdminRulesEdit(): string {
</div>
<div className="admin-rules-edit-row">
<div className="form-field">
<label htmlFor="f-parent" data-i18n="admin.procedural_events.edit.field.parent">Parent-Regel (UUID)</label>
<label htmlFor="f-parent" data-i18n="admin.rules.edit.field.parent">Parent-Regel (UUID)</label>
<input type="text" id="f-parent" className="admin-rules-input" placeholder="UUID oder leer" />
</div>
<div className="form-field">
@@ -184,7 +184,7 @@ export function renderAdminRulesEdit(): string {
<input type="text" id="f-primary-party" className="admin-rules-input" />
</div>
<div className="form-field">
<label htmlFor="f-event-type" data-i18n="admin.procedural_events.edit.field.event_kind">Event-Typ (frei)</label>
<label htmlFor="f-event-type" data-i18n="admin.rules.edit.field.event_type">Event-Typ (frei)</label>
<input type="text" id="f-event-type" className="admin-rules-input" />
</div>
</div>

View File

@@ -5,7 +5,7 @@ import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// /admin/procedural-events — Slice 11b (t-paliad-192). Filterable rule table + an
// /admin/rules — Slice 11b (t-paliad-192). Filterable rule table + an
// Orphans tab that surfaces the Slice 10 fuzzy-match staging rows so an
// admin can hand-bind each legacy deadline to one of the candidate
// rule_ids. Both surfaces share the same page shell to keep navigation
@@ -21,25 +21,25 @@ export function renderAdminRulesList(): string {
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.procedural_events.list.title">Regeln verwalten &mdash; Paliad</title>
<title data-i18n="admin.rules.list.title">Regeln verwalten &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/procedural-events" />
<BottomNav currentPath="/admin/procedural-events" />
<Sidebar currentPath="/admin/rules" />
<BottomNav currentPath="/admin/rules" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div>
<h1 data-i18n="admin.procedural_events.list.heading">Regeln verwalten</h1>
<h1 data-i18n="admin.rules.list.heading">Regeln verwalten</h1>
<p className="tool-subtitle" data-i18n="admin.rules.list.subtitle">
Fristen-Regeln anlegen, bearbeiten und freigeben. Lifecycle: draft &rarr; published &rarr; archived.
</p>
</div>
<div className="admin-rules-header-actions">
<button type="button" id="rules-new-btn" className="btn-primary" data-i18n="admin.procedural_events.list.new">
<button type="button" id="rules-new-btn" className="btn-primary" data-i18n="admin.rules.list.new">
+ Neue Regel
</button>
</div>
@@ -101,7 +101,7 @@ export function renderAdminRulesList(): string {
<table className="entity-table admin-rules-table">
<thead>
<tr>
<th data-i18n="admin.procedural_events.col.code">Submission Code</th>
<th data-i18n="admin.rules.col.submission_code">Submission Code</th>
<th data-i18n="admin.rules.col.legal_citation">Rechtsgrundlage</th>
<th data-i18n="admin.rules.col.name">Name</th>
<th data-i18n="admin.rules.col.proceeding">Verfahrenstyp</th>

View File

@@ -95,7 +95,7 @@ export function renderAdmin(): string {
<h2 data-i18n="admin.card.approval_policies.title">Genehmigungspflichten</h2>
<p data-i18n="admin.card.approval_policies.desc">4-Augen-Pr&uuml;fung pro Projekt und Partner Unit konfigurieren.</p>
</a>
<a href="/admin/procedural-events" className="card card-link">
<a href="/admin/rules" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_TABLE }} />
<h2 data-i18n="admin.card.rules.title">Regeln verwalten</h2>
<p data-i18n="admin.card.rules.desc">Fristen-Regeln anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.</p>

View File

@@ -1,7 +1,7 @@
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
// admin-rules-edit.ts — /admin/procedural-events/{id}/edit. Loads a single rule
// admin-rules-edit.ts — /admin/rules/{id}/edit. Loads a single rule
// row, drives every form field, the preview widget, the audit-log
// timeline and the lifecycle action bar. Every write is gated behind
// a reason modal — the ≥10-char rule is enforced client-side per
@@ -106,7 +106,7 @@ function fmtDateTime(iso: string): string {
}
function parseRuleIDFromPath(): string {
// /admin/procedural-events/{uuid}/edit
// /admin/rules/{uuid}/edit
const m = /^\/admin\/rules\/([^\/]+)\/edit\/?$/.exec(window.location.pathname);
return m ? decodeURIComponent(m[1]) : "";
}
@@ -179,7 +179,7 @@ function fillProceedingSelect(selectId: string, list: ProceedingType[]) {
}
async function loadRule(): Promise<void> {
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}`);
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}`);
if (!resp.ok) {
if (resp.status === 404) {
showFeedback(t("admin.rules.edit.error.not_found") || "Regel nicht gefunden.", true);
@@ -198,7 +198,7 @@ async function loadAudit(reset: boolean = true): Promise<void> {
auditEntries = [];
auditOffset = 0;
}
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/audit?offset=${auditOffset}&limit=${AUDIT_PAGE}`);
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/audit?offset=${auditOffset}&limit=${AUDIT_PAGE}`);
if (!resp.ok) return;
const body = await resp.json();
const rows = Array.isArray(body) ? body as AuditEntry[] : [];
@@ -508,7 +508,7 @@ async function doSaveDraft(reason: string) {
return;
}
payload.reason = reason;
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}`, {
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -530,7 +530,7 @@ async function doSaveDraft(reason: string) {
async function doLifecycle(op: "publish" | "archive" | "restore", reason: string) {
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/${op}`, {
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/${op}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason }),
@@ -552,7 +552,7 @@ async function doLifecycle(op: "publish" | "archive" | "restore", reason: string
async function doClone(reason: string) {
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/clone-as-draft`, {
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/clone-as-draft`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason }),
@@ -565,7 +565,7 @@ async function doClone(reason: string) {
return;
}
const newRule = await resp.json() as Rule;
window.location.href = `/admin/procedural-events/${encodeURIComponent(newRule.id)}/edit`;
window.location.href = `/admin/rules/${encodeURIComponent(newRule.id)}/edit`;
}
// --------------------------------------------------------------------
@@ -591,7 +591,7 @@ async function runPreview() {
if (flagsRaw) qs.set("flags", flagsRaw);
out.innerHTML = `<p class="admin-rules-loading">${esc(t("admin.rules.edit.preview.running") || "Berechne...")}</p>`;
out.style.display = "";
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/preview?${qs.toString()}`);
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/preview?${qs.toString()}`);
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
out.innerHTML = `<p class="admin-rules-hint admin-rules-hint-error">${esc(body.error || (t("admin.rules.edit.preview.error") || "Preview fehlgeschlagen."))}</p>`;

View File

@@ -1,10 +1,10 @@
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
// admin-rules-list.ts — /admin/procedural-events. Drives the rule table (filterable
// admin-rules-list.ts — /admin/rules. Drives the rule table (filterable
// by proceeding type, trigger event, lifecycle state, free-text query)
// plus the Orphans tab (Slice 10 backfill staging rows). Row click on
// a rule routes to /admin/procedural-events/{id}/edit; orphan cards have their own
// a rule routes to /admin/rules/{id}/edit; orphan cards have their own
// "Pick" affordance with an inline reason prompt that posts to
// /admin/api/orphans/{id}/resolve.
@@ -145,7 +145,7 @@ function buildFilterURL(): string {
if (activeLifecycle) qs.set("lifecycle_state", activeLifecycle);
if (activeQuery) qs.set("q", activeQuery);
qs.set("limit", "500");
return "/admin/api/procedural-events?" + qs.toString();
return "/admin/api/rules?" + qs.toString();
}
async function loadProceedings(): Promise<void> {
@@ -248,7 +248,7 @@ function renderRulesTable() {
if (target && (target.closest("a") || target.closest("button"))) return;
const id = row.dataset.rowId;
if (!id) return;
window.location.href = `/admin/procedural-events/${encodeURIComponent(id)}/edit`;
window.location.href = `/admin/rules/${encodeURIComponent(id)}/edit`;
});
});
}
@@ -392,7 +392,7 @@ async function submitReasonModal(ev: Event) {
submit.disabled = false;
return;
}
const resp = await fetch("/admin/api/procedural-events", {
const resp = await fetch("/admin/api/rules", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -416,7 +416,7 @@ async function submitReasonModal(ev: Event) {
return;
}
const created = await resp.json();
window.location.href = `/admin/procedural-events/${encodeURIComponent(created.id)}/edit`;
window.location.href = `/admin/rules/${encodeURIComponent(created.id)}/edit`;
return;
}

View File

@@ -2905,11 +2905,10 @@ const translations: Record<Lang, Record<string, string>> = {
// t-paliad-192 Slice 11b — Admin rule-editor UI.
// t-paliad-262 Slice A — "Regel" relabelled as "Verfahrensschritt".
// t-paliad-305 Slice B.6 (2026-05-26) — canonical URL moved to
// `/admin/procedural-events` (301 redirects from /admin/rules*).
// The i18n keys `admin.rules.*` are kept as the corpus until a
// follow-up slice migrates each reference; canonical
// `admin.procedural_events.*` aliases live after the EN block.
// The admin URL `/admin/rules` and i18n key prefix `admin.rules.*` stay
// (URL change is Slice B.6); the visible labels rename. Canonical
// `admin.procedural_events.*` aliases live after the EN block — they
// pin the contract for when .tsx files rebind in Slice B (B.5).
"nav.admin.rules": "Verfahrensschritte verwalten",
"admin.card.rules.title": "Verfahrensschritte verwalten",
"admin.card.rules.desc": "Verfahrensschritte anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.",

View File

@@ -1358,12 +1358,21 @@ function renderSectionRow(sec: SubmissionSectionJSON, lang: string, isActive: bo
li.appendChild(head);
// Toolbar — shared B/I affordance per section. Slice D extends with
// headings, lists, quote.
// Toolbar — Slice D rich-prose affordances: B/I + H1/H2/H3 +
// bullet/numbered list + blockquote + hyperlink. Plus the Slice C
// building-block button. execCommand drives bold/italic/headings/
// lists/blockquote; hyperlink uses createLink with a prompt.
const toolbar = document.createElement("div");
toolbar.className = "submission-draft-section-toolbar";
toolbar.appendChild(makeToolbarButton("B", isEN() ? "Bold" : "Fett", "bold"));
toolbar.appendChild(makeToolbarButton("I", isEN() ? "Italic" : "Kursiv", "italic"));
toolbar.appendChild(makeHeadingButton("H1", isEN() ? "Heading 1" : "Überschrift 1", 1));
toolbar.appendChild(makeHeadingButton("H2", isEN() ? "Heading 2" : "Überschrift 2", 2));
toolbar.appendChild(makeHeadingButton("H3", isEN() ? "Heading 3" : "Überschrift 3", 3));
toolbar.appendChild(makeToolbarButton("•", isEN() ? "Bullet list" : "Aufzählung", "insertUnorderedList"));
toolbar.appendChild(makeToolbarButton("1.", isEN() ? "Numbered list" : "Nummerierte Liste", "insertOrderedList"));
toolbar.appendChild(makeToolbarButton("”", isEN() ? "Blockquote" : "Zitat", "formatBlock", "blockquote"));
toolbar.appendChild(makeLinkButton());
// t-paliad-315 Slice C — building-block insert button. Opens a
// picker modal filtered to this section's section_key. Paste is
// plain-text per Q2 (no lineage stamped).
@@ -1421,7 +1430,7 @@ function renderSectionRow(sec: SubmissionSectionJSON, lang: string, isActive: bo
return li;
}
function makeToolbarButton(label: string, title: string, format: "bold" | "italic"): HTMLButtonElement {
function makeToolbarButton(label: string, title: string, format: string, value?: string): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "submission-draft-section-toolbar-btn";
@@ -1432,7 +1441,7 @@ function makeToolbarButton(label: string, title: string, format: "bold" | "itali
// selection target.
btn.addEventListener("mousedown", (ev) => {
ev.preventDefault();
document.execCommand(format, false);
document.execCommand(format, false, value);
// Trigger the input handler so autosave fires.
const editor = document.activeElement as HTMLElement | null;
if (editor && editor.classList.contains("submission-draft-section-editor")) {
@@ -1442,6 +1451,50 @@ function makeToolbarButton(label: string, title: string, format: "bold" | "itali
return btn;
}
// makeHeadingButton emits an `<h1|h2|h3>` wrapping for the active
// block via execCommand("formatBlock", "h1") etc. Toggling the same
// heading back to a paragraph is handled by clicking the same button
// again (the browser's execCommand semantics).
function makeHeadingButton(label: string, title: string, level: 1 | 2 | 3): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "submission-draft-section-toolbar-btn";
btn.textContent = label;
btn.title = title;
btn.addEventListener("mousedown", (ev) => {
ev.preventDefault();
document.execCommand("formatBlock", false, "h" + level);
const editor = document.activeElement as HTMLElement | null;
if (editor && editor.classList.contains("submission-draft-section-editor")) {
onSectionInput(editor as HTMLDivElement);
}
});
return btn;
}
// makeLinkButton prompts for a URL and wraps the current selection
// (or inserts a label-as-URL if nothing selected). The browser's
// createLink built-in wires the <a href="…"> tag into the DOM;
// domToMarkdown reads it back as `[label](url)`.
function makeLinkButton(): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "submission-draft-section-toolbar-btn";
btn.textContent = "🔗";
btn.title = isEN() ? "Insert link" : "Link einfügen";
btn.addEventListener("mousedown", (ev) => {
ev.preventDefault();
const url = prompt(isEN() ? "URL:" : "URL:");
if (!url) return;
document.execCommand("createLink", false, url);
const editor = document.activeElement as HTMLElement | null;
if (editor && editor.classList.contains("submission-draft-section-editor")) {
onSectionInput(editor as HTMLDivElement);
}
});
return btn;
}
function activeSectionEditorID(): string | null {
const active = document.activeElement as HTMLElement | null;
if (!active || !active.classList.contains("submission-draft-section-editor")) return null;
@@ -1496,10 +1549,28 @@ function serializeNode(node: Node): string {
if (node.nodeType !== Node.ELEMENT_NODE) return "";
const el = node as HTMLElement;
const tag = el.tagName.toLowerCase();
// Lists: handle the wrapper before recursing into items so we can
// emit the right per-item Markdown prefix.
if (tag === "ul" || tag === "ol") {
const items: string[] = [];
let counter = 1;
for (const child of Array.from(el.childNodes)) {
if (child.nodeType === Node.ELEMENT_NODE && (child as HTMLElement).tagName.toLowerCase() === "li") {
const liInner = serializeNode(child).replace(/\n+$/g, "");
const prefix = tag === "ol" ? `${counter}. ` : "- ";
items.push(prefix + liInner);
counter++;
}
}
return items.join("\n") + "\n\n";
}
let inner = "";
for (const child of Array.from(el.childNodes)) {
inner += serializeNode(child);
}
switch (tag) {
case "b":
case "strong":
@@ -1514,6 +1585,26 @@ function serializeNode(node: Node): string {
// execCommand and contentEditable insert <div> on Enter in some
// browsers, <p> in others. Both are paragraph boundaries.
return inner + "\n\n";
case "h1":
return "# " + inner.replace(/\n+$/g, "") + "\n\n";
case "h2":
return "## " + inner.replace(/\n+$/g, "") + "\n\n";
case "h3":
return "### " + inner.replace(/\n+$/g, "") + "\n\n";
case "blockquote":
// Each line inside the blockquote gets its own "> " prefix per
// Markdown convention.
return inner.split("\n").map(line => line === "" ? "" : "> " + line).join("\n").replace(/\n+$/g, "") + "\n\n";
case "li":
// <li> rendered standalone (no <ul>/<ol> ancestor) — emit
// bullet by default. The ul/ol branch above handles the
// ordered/unordered choice when present.
return "- " + inner.replace(/\n+$/g, "") + "\n";
case "a": {
const href = el.getAttribute("href") ?? "";
if (!href || !inner) return inner;
return `[${inner}](${href})`;
}
default:
return inner;
}

View File

@@ -204,7 +204,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
{navItem("/admin/team", ICON_USERS, "nav.admin.team", "Team-Verwaltung", currentPath)}
{navItem("/admin/partner-units", ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)}
{navItem("/admin/event-types", ICON_TABLE, "nav.admin.event_types", "Event-Typen", currentPath)}
{navItem("/admin/procedural-events", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
{navItem("/admin/rules", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
{navItem("/admin/backups", ICON_DOWNLOAD, "nav.admin.backups", "Backups", currentPath)}
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}

View File

@@ -8,7 +8,6 @@ import (
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/models"
"mgit.msbls.de/m/paliad/internal/services"
)
@@ -26,60 +25,6 @@ import (
// is mapped to 409 Conflict so the editor UI can show a clear "must
// clone first" hint.
// Slice B.5 (t-paliad-305) JSON envelope renames:
//
// - submission_code → code (procedural-event identifier)
// - event_type → event_kind (procedural-event taxonomy)
//
// Wire compatibility: every response emits BOTH the legacy and the
// canonical keys for one slice (see Deprecation HTTP header on the
// response). Input bodies accept either name on the request; the
// canonical key wins when both are present.
//
// adminRuleResponse wraps models.DeadlineRule (= litigationplanner.Rule)
// to add the canonical `code` + `event_kind` fields alongside the
// historical `submission_code` + `event_type` already on Rule's tags.
// The embedded *models.DeadlineRule carries every existing tag through
// json.Marshal unchanged; the wrapper only ADDS the two new keys.
type adminRuleResponse struct {
*models.DeadlineRule
Code *string `json:"code,omitempty"`
EventKind *string `json:"event_kind,omitempty"`
}
// wrapRuleResponse builds the dual-emit wrapper from a service result.
// Same values, two keys per concept — no semantic change.
func wrapRuleResponse(r *models.DeadlineRule) adminRuleResponse {
if r == nil {
return adminRuleResponse{}
}
return adminRuleResponse{
DeadlineRule: r,
Code: r.SubmissionCode,
EventKind: r.EventType,
}
}
// wrapRuleListResponse maps a slice of service results into the
// dual-emit wrapper. Used by the LIST endpoint.
func wrapRuleListResponse(rows []models.DeadlineRule) []adminRuleResponse {
out := make([]adminRuleResponse, len(rows))
for i := range rows {
out[i] = wrapRuleResponse(&rows[i])
}
return out
}
// adminRuleDeprecationHeaders writes the IETF "Deprecation" + "Sunset"
// HTTP headers signaling that the legacy `submission_code` /
// `event_type` JSON keys are being retired in favour of `code` /
// `event_kind`. RFC 8594 (Sunset) + draft-ietf-httpapi-deprecation-header.
// Clients should migrate within one slice cycle.
func adminRuleDeprecationHeaders(w http.ResponseWriter) {
w.Header().Set("Deprecation", `true; key="submission_code,event_type"`)
w.Header().Set("Link", `<https://mgit.msbls.de/m/paliad/issues/93>; rel="deprecation"`)
}
// GET /admin/api/rules — paginated list with filters.
func handleAdminListRules(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.ruleEditor == nil {
@@ -128,8 +73,7 @@ func handleAdminListRules(w http.ResponseWriter, r *http.Request) {
writeRuleEditorError(w, err)
return
}
adminRuleDeprecationHeaders(w)
writeJSON(w, http.StatusOK, wrapRuleListResponse(rows))
writeJSON(w, http.StatusOK, rows)
}
// GET /admin/api/rules/{id}
@@ -147,8 +91,7 @@ func handleAdminGetRule(w http.ResponseWriter, r *http.Request) {
writeRuleEditorError(w, err)
return
}
adminRuleDeprecationHeaders(w)
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
writeJSON(w, http.StatusOK, row)
}
// POST /admin/api/rules — create draft.
@@ -165,15 +108,12 @@ func handleAdminCreateRule(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
// Slice B.5 (t-paliad-305): accept both legacy + canonical JSON keys.
body.CreateRuleInput.CoalesceCanonicalKeys()
row, err := dbSvc.ruleEditor.Create(r.Context(), body.CreateRuleInput, body.Reason)
if err != nil {
writeRuleEditorError(w, err)
return
}
adminRuleDeprecationHeaders(w)
writeJSON(w, http.StatusCreated, wrapRuleResponse(row))
writeJSON(w, http.StatusCreated, row)
}
// PATCH /admin/api/rules/{id} — partial update of a draft.
@@ -194,15 +134,12 @@ func handleAdminPatchRule(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
// Slice B.5 (t-paliad-305): accept both legacy + canonical JSON keys.
body.RulePatch.CoalesceCanonicalKeys()
row, err := dbSvc.ruleEditor.UpdateDraft(r.Context(), id, body.RulePatch, body.Reason)
if err != nil {
writeRuleEditorError(w, err)
return
}
adminRuleDeprecationHeaders(w)
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
writeJSON(w, http.StatusOK, row)
}
// POST /admin/api/rules/{id}/clone-as-draft
@@ -224,8 +161,7 @@ func handleAdminCloneAsDraft(w http.ResponseWriter, r *http.Request) {
writeRuleEditorError(w, err)
return
}
adminRuleDeprecationHeaders(w)
writeJSON(w, http.StatusCreated, wrapRuleResponse(row))
writeJSON(w, http.StatusCreated, row)
}
// POST /admin/api/rules/{id}/publish
@@ -247,8 +183,7 @@ func handleAdminPublishRule(w http.ResponseWriter, r *http.Request) {
writeRuleEditorError(w, err)
return
}
adminRuleDeprecationHeaders(w)
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
writeJSON(w, http.StatusOK, row)
}
// POST /admin/api/rules/{id}/archive
@@ -270,8 +205,7 @@ func handleAdminArchiveRule(w http.ResponseWriter, r *http.Request) {
writeRuleEditorError(w, err)
return
}
adminRuleDeprecationHeaders(w)
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
writeJSON(w, http.StatusOK, row)
}
// POST /admin/api/rules/{id}/restore
@@ -293,8 +227,7 @@ func handleAdminRestoreRule(w http.ResponseWriter, r *http.Request) {
writeRuleEditorError(w, err)
return
}
adminRuleDeprecationHeaders(w)
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
writeJSON(w, http.StatusOK, row)
}
// GET /admin/api/rules/{id}/audit?offset=N&limit=M
@@ -486,66 +419,3 @@ func handleAdminResolveOrphan(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]string{"status": "resolved"})
}
// Slice B.6 (t-paliad-305) — 301 redirect helpers for the legacy
// /admin/rules* paths. New canonical paths live under
// /admin/procedural-events; the redirects keep external bookmarks,
// audit-log entries, and curl scripts working through one
// deprecation cycle.
//
// Three flavours:
//
// * redirectToProceduralEvents(newPath) — fixed redirect target
// (used by the parameter-less paths /admin/rules and
// /admin/api/rules).
// * redirectToProceduralEventEdit — page path with {id}/edit suffix.
// * redirectToProceduralEventAPI(suffix) — JSON API paths that carry
// an {id} and optional suffix (/clone-as-draft, /publish, …).
//
// All emit 301 Moved Permanently — caches and browsers learn the new
// URL once and stop hitting the legacy path. The IETF Deprecation
// header is added so machine clients see the migration signal
// alongside the redirect.
// redirectToProceduralEvents returns an http.HandlerFunc that 301s to
// the supplied destination path. Query string is preserved.
func redirectToProceduralEvents(dst string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
target := dst
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
w.Header().Set("Deprecation", `true; path="/admin/rules"`)
w.Header().Set("Link", `</admin/procedural-events>; rel="successor-version"`)
http.Redirect(w, r, target, http.StatusMovedPermanently)
}
}
// redirectToProceduralEventEdit 301s GET /admin/rules/{id}/edit →
// /admin/procedural-events/{id}/edit.
func redirectToProceduralEventEdit(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
target := "/admin/procedural-events/" + id + "/edit"
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
w.Header().Set("Deprecation", `true; path="/admin/rules/{id}/edit"`)
w.Header().Set("Link", `</admin/procedural-events/{id}/edit>; rel="successor-version"`)
http.Redirect(w, r, target, http.StatusMovedPermanently)
}
// redirectToProceduralEventAPI 301s /admin/api/rules/{id}[/suffix] →
// /admin/api/procedural-events/{id}[/suffix]. The optional suffix
// covers /clone-as-draft, /publish, /archive, /restore, /audit, /preview.
func redirectToProceduralEventAPI(suffix string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
target := "/admin/api/procedural-events/" + id + suffix
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
w.Header().Set("Deprecation", `true; path="/admin/api/rules/{id}`+suffix+`"`)
w.Header().Set("Link", `</admin/api/procedural-events/{id}`+suffix+`>; rel="successor-version"`)
http.Redirect(w, r, target, http.StatusMovedPermanently)
}
}

View File

@@ -722,43 +722,18 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
// t-paliad-089 — admin Event-Type moderation panel.
// t-paliad-191 Slice 11a — admin rule-editor API.
// t-paliad-192 Slice 11b — admin rule-editor UI pages + orphan list/resolve.
// Slice B.6 (t-paliad-305) — canonical URL paths under
// /admin/procedural-events with 301 redirects from the legacy
// /admin/rules paths so existing bookmarks and audit-log
// entries continue to resolve. New paths point at the same
// handlers; the canonical-URL name aligns with the umbrella
// term locked in Slice A.
protected.HandleFunc("GET /admin/procedural-events", adminGate(users, gateOnboarded(handleAdminRulesListPage)))
protected.HandleFunc("GET /admin/procedural-events/{id}/edit", adminGate(users, gateOnboarded(handleAdminRulesEditPage)))
protected.HandleFunc("GET /admin/api/procedural-events", adminGate(users, handleAdminListRules))
protected.HandleFunc("GET /admin/api/procedural-events/{id}", adminGate(users, handleAdminGetRule))
protected.HandleFunc("POST /admin/api/procedural-events", adminGate(users, handleAdminCreateRule))
protected.HandleFunc("PATCH /admin/api/procedural-events/{id}", adminGate(users, handleAdminPatchRule))
protected.HandleFunc("POST /admin/api/procedural-events/{id}/clone-as-draft", adminGate(users, handleAdminCloneAsDraft))
protected.HandleFunc("POST /admin/api/procedural-events/{id}/publish", adminGate(users, handleAdminPublishRule))
protected.HandleFunc("POST /admin/api/procedural-events/{id}/archive", adminGate(users, handleAdminArchiveRule))
protected.HandleFunc("POST /admin/api/procedural-events/{id}/restore", adminGate(users, handleAdminRestoreRule))
protected.HandleFunc("GET /admin/api/procedural-events/{id}/audit", adminGate(users, handleAdminGetRuleAudit))
protected.HandleFunc("GET /admin/api/procedural-events/{id}/preview", adminGate(users, handleAdminPreviewRule))
// Legacy /admin/rules paths — 301 redirect to the canonical
// /admin/procedural-events paths. One-slice deprecation window
// per design §8.2 (B.6 optional; m authorised the rename
// 2026-05-26). After the next slice that audits external
// references, these can be retired entirely.
protected.HandleFunc("GET /admin/rules", adminGate(users, redirectToProceduralEvents("/admin/procedural-events")))
protected.HandleFunc("GET /admin/rules/{id}/edit", adminGate(users, redirectToProceduralEventEdit))
protected.HandleFunc("GET /admin/api/rules", adminGate(users, redirectToProceduralEvents("/admin/api/procedural-events")))
protected.HandleFunc("GET /admin/api/rules/{id}", adminGate(users, redirectToProceduralEventAPI("")))
protected.HandleFunc("POST /admin/api/rules", adminGate(users, redirectToProceduralEvents("/admin/api/procedural-events")))
protected.HandleFunc("PATCH /admin/api/rules/{id}", adminGate(users, redirectToProceduralEventAPI("")))
protected.HandleFunc("POST /admin/api/rules/{id}/clone-as-draft", adminGate(users, redirectToProceduralEventAPI("/clone-as-draft")))
protected.HandleFunc("POST /admin/api/rules/{id}/publish", adminGate(users, redirectToProceduralEventAPI("/publish")))
protected.HandleFunc("POST /admin/api/rules/{id}/archive", adminGate(users, redirectToProceduralEventAPI("/archive")))
protected.HandleFunc("POST /admin/api/rules/{id}/restore", adminGate(users, redirectToProceduralEventAPI("/restore")))
protected.HandleFunc("GET /admin/api/rules/{id}/audit", adminGate(users, redirectToProceduralEventAPI("/audit")))
protected.HandleFunc("GET /admin/api/rules/{id}/preview", adminGate(users, redirectToProceduralEventAPI("/preview")))
protected.HandleFunc("GET /admin/rules", adminGate(users, gateOnboarded(handleAdminRulesListPage)))
protected.HandleFunc("GET /admin/rules/{id}/edit", adminGate(users, gateOnboarded(handleAdminRulesEditPage)))
protected.HandleFunc("GET /admin/api/rules", adminGate(users, handleAdminListRules))
protected.HandleFunc("GET /admin/api/rules/{id}", adminGate(users, handleAdminGetRule))
protected.HandleFunc("POST /admin/api/rules", adminGate(users, handleAdminCreateRule))
protected.HandleFunc("PATCH /admin/api/rules/{id}", adminGate(users, handleAdminPatchRule))
protected.HandleFunc("POST /admin/api/rules/{id}/clone-as-draft", adminGate(users, handleAdminCloneAsDraft))
protected.HandleFunc("POST /admin/api/rules/{id}/publish", adminGate(users, handleAdminPublishRule))
protected.HandleFunc("POST /admin/api/rules/{id}/archive", adminGate(users, handleAdminArchiveRule))
protected.HandleFunc("POST /admin/api/rules/{id}/restore", adminGate(users, handleAdminRestoreRule))
protected.HandleFunc("GET /admin/api/rules/{id}/audit", adminGate(users, handleAdminGetRuleAudit))
protected.HandleFunc("GET /admin/api/rules/{id}/preview", adminGate(users, handleAdminPreviewRule))
protected.HandleFunc("GET /admin/api/orphans", adminGate(users, handleAdminListOrphans))
protected.HandleFunc("POST /admin/api/orphans/{id}/resolve", adminGate(users, handleAdminResolveOrphan))

View File

@@ -553,51 +553,6 @@ type Party struct {
// scans, hydration, projection service) continues to compile.
type DeadlineRule = litigationplanner.Rule
// SequencingRule is the Slice B.5 (t-paliad-305) canonical name for what
// the legacy schema called a "deadline rule". Alias to DeadlineRule so
// existing call-sites compile unchanged while new code can adopt the
// procedural-event vocabulary. Same struct, same db / json tags.
type SequencingRule = DeadlineRule
// ProceduralEvent mirrors paliad.procedural_events — the "what kind of
// step is this in the proceeding" identity row. New struct introduced
// in Slice B.5 (t-paliad-305) for code that needs the procedural-event
// columns alone. Most consumers still pull the merged shape via
// SequencingRule through the paliad.deadline_rules_unified view; this
// struct unlocks per-PE reads/writes without going through the view.
type ProceduralEvent struct {
ID uuid.UUID `db:"id" json:"id"`
Code string `db:"code" json:"code"`
Name string `db:"name" json:"name"`
NameEN string `db:"name_en" json:"name_en"`
Description *string `db:"description" json:"description,omitempty"`
EventKind *string `db:"event_kind" json:"event_kind,omitempty"`
PrimaryPartyDefault *string `db:"primary_party_default" json:"primary_party_default,omitempty"`
LegalSourceID *uuid.UUID `db:"legal_source_id" json:"legal_source_id,omitempty"`
ConceptID *uuid.UUID `db:"concept_id" json:"concept_id,omitempty"`
LifecycleState string `db:"lifecycle_state" json:"lifecycle_state"`
DraftOf *uuid.UUID `db:"draft_of" json:"draft_of,omitempty"`
PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"`
IsActive bool `db:"is_active" json:"is_active"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// LegalSource mirrors paliad.legal_sources — the source-of-law citation
// rows that procedural events anchor against. pretty_de / pretty_en are
// nullable on disk; readers fall back to
// internal/services/submission_vars.go:legalSourcePretty when missing.
type LegalSource struct {
ID uuid.UUID `db:"id" json:"id"`
Citation string `db:"citation" json:"citation"`
Jurisdiction string `db:"jurisdiction" json:"jurisdiction"`
PrettyDE *string `db:"pretty_de" json:"pretty_de,omitempty"`
PrettyEN *string `db:"pretty_en" json:"pretty_en,omitempty"`
Notes *string `db:"notes" json:"notes,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// DeadlineRuleAudit is one row of paliad.deadline_rule_audit — the
// append-only audit log for every change to paliad.deadline_rules.
// Written by the AFTER-trigger (raw create / update / delete) and by

View File

@@ -10,24 +10,12 @@ import (
"mgit.msbls.de/m/paliad/internal/models"
)
// DeadlineRuleService reads paliad.deadline_rules_unified (mig 139 view
// projecting paliad.sequencing_rules + procedural_events +
// legal_sources back to the legacy column shape after mig 140 dropped
// the underlying table) + paliad.proceeding_types. Rules are static
// reference data; no visibility check needed.
// DeadlineRuleService reads paliad.deadline_rules + paliad.proceeding_types.
// Rules are static reference data; no visibility check needed.
type DeadlineRuleService struct {
db *sqlx.DB
}
// SequencingRuleService is the Slice B.5 (t-paliad-305) canonical name
// for DeadlineRuleService. Alias preserves every existing call-site
// while new code can adopt the procedural-event vocabulary.
type SequencingRuleService = DeadlineRuleService
// NewSequencingRuleService is the canonical constructor name; alias to
// NewDeadlineRuleService for now. Both return the same underlying type.
var NewSequencingRuleService = NewDeadlineRuleService
// NewDeadlineRuleService wires the service to the pool.
func NewDeadlineRuleService(db *sqlx.DB) *DeadlineRuleService {
return &DeadlineRuleService{db: db}

View File

@@ -76,13 +76,7 @@ type RulePatch struct {
NameEN *string `json:"name_en,omitempty"`
Description *string `json:"description,omitempty"`
PrimaryParty *string `json:"primary_party,omitempty"`
// EventType is the legacy JSON key; EventKind is the Slice B.5
// canonical name. Decoder accepts either — coalescePatchKeys()
// resolves the canonical to the legacy field if only EventKind
// was sent. Same uuid wire shape; emit-side wraps via
// adminRuleResponse to expose both keys for one slice.
EventType *string `json:"event_type,omitempty"`
EventKind *string `json:"event_kind,omitempty"`
DurationValue *int `json:"duration_value,omitempty"`
DurationUnit *string `json:"duration_unit,omitempty"`
Timing *string `json:"timing,omitempty"`
@@ -107,24 +101,6 @@ type RulePatch struct {
ConceptID *uuid.UUID `json:"concept_id,omitempty"`
}
// CoalesceCanonicalKeys folds the Slice B.5 (t-paliad-305) canonical
// JSON aliases into the legacy field positions so the rest of the
// service can keep using the existing field names. Canonical wins
// when both are sent.
//
// json:"event_kind" → EventType (legacy)
//
// Called by the handler immediately after json.Decode. New code can
// adopt the canonical naming; legacy callers continue to work.
func (p *RulePatch) CoalesceCanonicalKeys() {
if p == nil {
return
}
if p.EventKind != nil {
p.EventType = p.EventKind
}
}
// CreateRuleInput is the create payload — a full rule row in draft
// state. Required fields enforce schema NOT-NULL on insert (name,
// name_en, duration_value, duration_unit).
@@ -135,16 +111,9 @@ type CreateRuleInput struct {
TriggerEventID *int64 `json:"trigger_event_id,omitempty"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
ConceptID *uuid.UUID `json:"concept_id,omitempty"`
// SubmissionCode is the legacy JSON key; Code is the Slice B.5
// canonical name. Decoder accepts either — CoalesceCanonicalKeys()
// folds Code → SubmissionCode if only the canonical was sent.
SubmissionCode *string `json:"submission_code,omitempty"`
Code *string `json:"code,omitempty"`
PrimaryParty *string `json:"primary_party,omitempty"`
// EventType is the legacy JSON key; EventKind is the Slice B.5
// canonical name. Same dual-accept pattern as SubmissionCode/Code.
EventType *string `json:"event_type,omitempty"`
EventKind *string `json:"event_kind,omitempty"`
DurationValue int `json:"duration_value"`
DurationUnit string `json:"duration_unit"`
Timing *string `json:"timing,omitempty"`
@@ -166,24 +135,6 @@ type CreateRuleInput struct {
SequenceOrder int `json:"sequence_order"`
}
// CoalesceCanonicalKeys folds the Slice B.5 (t-paliad-305) canonical
// JSON aliases into the legacy field positions. Canonical wins when
// both are sent. Called by the handler immediately after json.Decode.
//
// json:"code" → SubmissionCode (legacy)
// json:"event_kind" → EventType (legacy)
func (in *CreateRuleInput) CoalesceCanonicalKeys() {
if in == nil {
return
}
if in.Code != nil {
in.SubmissionCode = in.Code
}
if in.EventKind != nil {
in.EventType = in.EventKind
}
}
// Create inserts a new rule as lifecycle_state='draft' with
// published_at=NULL. The caller's reason is set on the session BEFORE
// the INSERT so the mig 079 trigger writes an audit row with the

View File

@@ -111,8 +111,15 @@ func (c *SubmissionComposer) Compose(ctx context.Context, opts ComposeOptions) (
return nil, err
}
// Per-compose hyperlink allocator. Each unique URL gets a fresh
// rId outside the base's existing namespace. The post-pass
// (patchDocumentXMLRels) writes the matching Relationship rows
// before the zip is repacked. Slice D adds inline `[label](url)`
// hyperlink support.
linkAlloc := newComposerLinkAllocator()
// Build the rendered-section map: section_key → OOXML span.
style := opts.Base.SectionSpec.Stylemap["paragraph"]
stylemap := opts.Base.SectionSpec.Stylemap
rendered := make(map[string]string, len(sections))
keptSections := make([]SubmissionSection, 0, len(sections))
for _, sec := range sections {
@@ -123,7 +130,7 @@ func (c *SubmissionComposer) Compose(ctx context.Context, opts ComposeOptions) (
if strings.EqualFold(opts.Lang, "en") {
md = sec.ContentMDEN
}
rendered[sec.SectionKey] = RenderMarkdownToOOXML(md, style)
rendered[sec.SectionKey] = RenderMarkdownToOOXMLWithStyles(md, stylemap, linkAlloc.Alloc)
keptSections = append(keptSections, sec)
}
// Stable order — already sorted ascending by ListForDraft, but
@@ -135,6 +142,19 @@ func (c *SubmissionComposer) Compose(ctx context.Context, opts ComposeOptions) (
assembledBody := spliceSections(documentXML, rendered, keptSections, sections)
// Slice D hyperlink patch: when the walker emitted hyperlink rIds
// for inline `[label](url)` links, the base's
// word/_rels/document.xml.rels needs matching <Relationship>
// entries so Word can resolve the rIds. Mutates one zip part in
// otherParts (or appends if missing).
if linkAlloc.HasLinks() {
updatedParts, err := patchDocumentXMLRels(otherParts, linkAlloc.Pairs())
if err != nil {
return nil, err
}
otherParts = updatedParts
}
// Re-pack into a zip with the assembled document.xml. All other
// parts (styles, fonts, headers, footers, theme, settings) pass
// through bit-for-bit at their original mtime + compression.
@@ -467,3 +487,121 @@ func readZipEntry(f *zip.File) ([]byte, error) {
defer rc.Close()
return io.ReadAll(rc)
}
// ─────────────────────────────────────────────────────────────────────
// Slice D — hyperlink wiring
// ─────────────────────────────────────────────────────────────────────
// composerLinkAllocator hands out fresh rIds for inline hyperlink
// targets discovered by the MD walker. Each unique URL gets one rId
// (deduped — repeated links to the same URL share one Relationship).
// Allocations land outside the base's rId namespace by prefixing with
// "rIdComposer" so they can't collide with existing relationships.
type composerLinkAllocator struct {
next int
byURL map[string]string
order []string // URLs in allocation order
}
func newComposerLinkAllocator() *composerLinkAllocator {
return &composerLinkAllocator{byURL: map[string]string{}}
}
// Alloc returns the rId for url, allocating one on first sight.
func (a *composerLinkAllocator) Alloc(url string) string {
if rid, ok := a.byURL[url]; ok {
return rid
}
a.next++
rid := fmt.Sprintf("rIdComposer%d", a.next)
a.byURL[url] = rid
a.order = append(a.order, url)
return rid
}
// HasLinks reports whether any links were allocated during this compose.
func (a *composerLinkAllocator) HasLinks() bool {
return len(a.order) > 0
}
// Pairs returns the (rId, URL) pairs in allocation order. The
// document.xml.rels patcher consumes this to emit <Relationship>
// elements.
func (a *composerLinkAllocator) Pairs() [][2]string {
pairs := make([][2]string, 0, len(a.order))
for _, url := range a.order {
pairs = append(pairs, [2]string{a.byURL[url], url})
}
return pairs
}
// patchDocumentXMLRels mutates the word/_rels/document.xml.rels entry
// in `parts` to append the given (rId, URL) pairs as hyperlink
// relationships. If the rels part doesn't exist (some bases omit it
// when the body has no relationships), this function appends a fresh
// part with the minimal Relationships wrapper.
//
// Idempotent on (rId, URL) pairs already present (e.g. when a base
// already references the URL for some other reason).
//
// Returns the (possibly extended) parts slice — callers must overwrite
// their reference because the append in the no-rels-yet case grows the
// backing array.
func patchDocumentXMLRels(parts []baseZipPart, pairs [][2]string) ([]baseZipPart, error) {
const path = "word/_rels/document.xml.rels"
const hyperlinkType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
existingIdx := -1
for i := range parts {
if parts[i].name == path {
existingIdx = i
break
}
}
var body string
if existingIdx >= 0 {
body = string(parts[existingIdx].body)
} else {
body = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`
}
var inserts strings.Builder
for _, p := range pairs {
rid := p[0]
url := p[1]
if strings.Contains(body, `Id="`+rid+`"`) {
continue
}
inserts.WriteString(`<Relationship Id="`)
inserts.WriteString(xmlAttrEscape(rid))
inserts.WriteString(`" Type="`)
inserts.WriteString(hyperlinkType)
inserts.WriteString(`" Target="`)
inserts.WriteString(xmlAttrEscape(url))
inserts.WriteString(`" TargetMode="External"/>`)
}
if inserts.Len() == 0 {
return parts, nil
}
closeIdx := strings.LastIndex(body, "</Relationships>")
if closeIdx < 0 {
return parts, fmt.Errorf("submission compose: malformed document.xml.rels (no closing tag)")
}
patched := body[:closeIdx] + inserts.String() + body[closeIdx:]
if existingIdx >= 0 {
parts[existingIdx].body = []byte(patched)
return parts, nil
}
parts = append(parts, baseZipPart{
name: path,
method: zip.Deflate,
modTime: time.Now().Unix(),
body: []byte(patched),
})
return parts, nil
}

View File

@@ -58,27 +58,32 @@ func minimalBaseBytes(t *testing.T, body string) []byte {
// extractDocumentXML pulls word/document.xml out of a .docx zip for
// assertions.
func extractDocumentXML(t *testing.T, data []byte) string {
return extractZipEntry(t, data, "word/document.xml")
}
// extractZipEntry pulls any named entry out of a .docx zip.
func extractZipEntry(t *testing.T, data []byte, name string) string {
t.Helper()
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
t.Fatalf("open zip: %v", err)
}
for _, f := range zr.File {
if f.Name != "word/document.xml" {
if f.Name != name {
continue
}
rc, err := f.Open()
if err != nil {
t.Fatalf("open document.xml: %v", err)
t.Fatalf("open %s: %v", name, err)
}
defer rc.Close()
var buf bytes.Buffer
if _, err := buf.ReadFrom(rc); err != nil {
t.Fatalf("read document.xml: %v", err)
t.Fatalf("read %s: %v", name, err)
}
return buf.String()
}
t.Fatal("document.xml not found in zip")
t.Fatalf("%s not found in zip", name)
return ""
}
@@ -249,6 +254,120 @@ func TestComposer_LangPicksColumn(t *testing.T) {
}
}
// Slice D — rich-prose end-to-end through the composer.
func TestComposer_HeadingsAndLists(t *testing.T) {
base := composerBase()
// Extend the stylemap so the walker has named styles to apply.
base.SectionSpec.Stylemap["heading_1"] = "Heading1"
base.SectionSpec.Stylemap["list_bullet"] = "ListBullet"
base.SectionSpec.Stylemap["list_numbered"] = "ListNumber"
base.SectionSpec.Stylemap["blockquote"] = "Quote"
body := `<w:p><w:r><w:t>{{#section:body}}</w:t></w:r></w:p><w:p><w:r><w:t>{{/section:body}}</w:t></w:r></w:p>`
baseBytes := minimalBaseBytes(t, body)
composer := NewSubmissionComposer(NewSubmissionRenderer())
md := "# Heading line\n\n- bullet a\n- bullet b\n\n1. first\n2. second\n\n> quoted"
sections := []SubmissionSection{
{ID: uuid.New(), SectionKey: "body", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: md},
}
out, err := composer.Compose(context.Background(), ComposeOptions{
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
})
if err != nil {
t.Fatalf("Compose: %v", err)
}
docXML := extractDocumentXML(t, out)
for _, want := range []string{
`<w:pStyle w:val="Heading1"/>`,
`<w:pStyle w:val="ListBullet"/>`,
`<w:pStyle w:val="ListNumber"/>`,
`<w:pStyle w:val="Quote"/>`,
"Heading line",
"bullet a",
"bullet b",
`<w:t xml:space="preserve">1. </w:t>`,
`<w:t xml:space="preserve">2. </w:t>`,
"first",
"second",
"quoted",
} {
if !strings.Contains(docXML, want) {
t.Errorf("expected %q in composed body; got: %s", want, docXML)
}
}
}
func TestComposer_HyperlinkWiresRels(t *testing.T) {
base := composerBase()
body := `<w:p><w:r><w:t>{{#section:facts}}</w:t></w:r></w:p><w:p><w:r><w:t>{{/section:facts}}</w:t></w:r></w:p>`
baseBytes := minimalBaseBytes(t, body)
composer := NewSubmissionComposer(NewSubmissionRenderer())
sections := []SubmissionSection{
{ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true,
ContentMDDE: "See [BGH](https://bgh.bund.de) and [EuGH](https://curia.europa.eu)."},
}
out, err := composer.Compose(context.Background(), ComposeOptions{
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
})
if err != nil {
t.Fatalf("Compose: %v", err)
}
// Body: hyperlink elements with composer rIds.
docXML := extractDocumentXML(t, out)
if !strings.Contains(docXML, `<w:hyperlink r:id="rIdComposer1">`) ||
!strings.Contains(docXML, `<w:hyperlink r:id="rIdComposer2">`) {
t.Errorf("hyperlink rIds missing in body: %q", docXML)
}
if !strings.Contains(docXML, "BGH") || !strings.Contains(docXML, "EuGH") {
t.Errorf("hyperlink labels missing: %q", docXML)
}
// Rels: the matching <Relationship> rows must be in
// word/_rels/document.xml.rels with the URL targets + External mode.
rels := extractZipEntry(t, out, "word/_rels/document.xml.rels")
for _, want := range []string{
`Id="rIdComposer1"`,
`Id="rIdComposer2"`,
`Target="https://bgh.bund.de"`,
`Target="https://curia.europa.eu"`,
`TargetMode="External"`,
"hyperlink", // the Type URL contains "hyperlink"
} {
if !strings.Contains(rels, want) {
t.Errorf("expected %q in document.xml.rels: %s", want, rels)
}
}
}
func TestComposer_HyperlinkDedupesByURL(t *testing.T) {
base := composerBase()
body := `<w:p><w:r><w:t>{{#section:facts}}</w:t></w:r></w:p><w:p><w:r><w:t>{{/section:facts}}</w:t></w:r></w:p>`
baseBytes := minimalBaseBytes(t, body)
composer := NewSubmissionComposer(NewSubmissionRenderer())
// Same URL referenced twice — should produce one rId, two
// <w:hyperlink> elements both pointing at it.
sections := []SubmissionSection{
{ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true,
ContentMDDE: "First [BGH](https://bgh.bund.de) and again [Bundesgerichtshof](https://bgh.bund.de)."},
}
out, _ := composer.Compose(context.Background(), ComposeOptions{
Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
})
docXML := extractDocumentXML(t, out)
if strings.Count(docXML, `<w:hyperlink r:id="rIdComposer1">`) != 2 {
t.Errorf("expected 2 hyperlinks sharing rIdComposer1; got: %s", docXML)
}
if strings.Contains(docXML, `<w:hyperlink r:id="rIdComposer2">`) {
t.Errorf("dedupe failed — second rId allocated for same URL: %s", docXML)
}
}
func TestComposer_OrderIndexAscending(t *testing.T) {
base := composerBase()
// No anchors → both sections append in order_index ASC order

View File

@@ -27,79 +27,223 @@ package services
// - Otherwise → plain text run
import (
"fmt"
"strings"
)
// HyperlinkAllocator hands the walker a `rId` for each external URL
// it encounters in `[label](url)` inline links. The composer's
// post-pass uses these allocations to mutate
// `word/_rels/document.xml.rels` so the emitted `<w:hyperlink
// r:id="…">` elements resolve correctly. Pass nil to drop links to
// plain text (the label survives, the URL doesn't render).
//
// t-paliad-316 Slice D.
type HyperlinkAllocator func(url string) string
// RenderMarkdownToOOXML renders the given Markdown source into OOXML
// paragraph elements (`<w:p>…</w:p>`), suitable for splicing into a
// .docx body. Each paragraph carries `<w:pStyle w:val="<paragraphStyle>"/>`
// when paragraphStyle is non-empty.
//
// Slice B shipped paragraphs + bold/italic. Slice D extends to
// headings (h1/h2/h3), bullet/numbered lists, blockquote, and inline
// hyperlinks via the optional HyperlinkAllocator.
//
// stylemap supplies the paragraph-style names for each kind:
// stylemap["paragraph"] — default body
// stylemap["heading_1/2/3"] — heading levels
// stylemap["list_bullet"] — bullet list paragraph style
// stylemap["list_numbered"] — numbered list paragraph style
// stylemap["blockquote"] — blockquote
// Missing entries fall back to the "paragraph" style.
//
// Empty input renders one empty paragraph so the splice site is
// well-formed even when the lawyer hasn't typed anything in this
// section.
func RenderMarkdownToOOXML(md, paragraphStyle string) string {
return RenderMarkdownToOOXMLWithStyles(md, map[string]string{"paragraph": paragraphStyle}, nil)
}
// RenderMarkdownToOOXMLWithStyles is the full Slice-D-aware entry
// point. Slice B's RenderMarkdownToOOXML is a wrapper for back-compat.
func RenderMarkdownToOOXMLWithStyles(md string, stylemap map[string]string, links HyperlinkAllocator) string {
defaultStyle := stylemap["paragraph"]
if md == "" {
return emptyParagraph(paragraphStyle)
return emptyParagraph(defaultStyle)
}
paragraphs := splitMarkdownParagraphs(md)
if len(paragraphs) == 0 {
return emptyParagraph(paragraphStyle)
blocks := splitMarkdownBlocks(md)
if len(blocks) == 0 {
return emptyParagraph(defaultStyle)
}
// Numbered-list counter resets on every non-numbered block so
// "1. A\n2. B\n\n1. C" renders as 1./2./1. (the lawyer's input
// determined the ordinal, the walker just renders).
numberedCounter := 0
var b strings.Builder
for _, para := range paragraphs {
b.WriteString(renderParagraph(para, paragraphStyle))
for _, blk := range blocks {
style := stylemap[blk.styleKey]
if style == "" {
style = defaultStyle
}
if blk.styleKey == "list_numbered" {
numberedCounter++
} else {
numberedCounter = 0
}
b.WriteString(renderBlockParagraph(blk, style, links, numberedCounter))
}
return b.String()
}
// splitMarkdownParagraphs splits the source into paragraphs. A
// "paragraph" is a maximal run of non-blank lines. N consecutive blank
// lines between two paragraphs produce (N-1) empty paragraphs in the
// output so the lawyer's intentional vertical spacing survives.
// mdBlock is one rendered paragraph: a kind (paragraph / heading_*
// / list_bullet / list_numbered / blockquote) and the inline content
// text. List markers, heading hashes, blockquote `> ` etc. are
// stripped from the text before storage.
type mdBlock struct {
styleKey string // "paragraph" | "heading_1" | "heading_2" | "heading_3" | "list_bullet" | "list_numbered" | "blockquote"
text string
}
// splitMarkdownBlocks parses the source into a sequence of blocks,
// detecting heading / list / blockquote prefixes line-by-line. Blank
// lines split paragraph runs (same semantics as splitMarkdownParagraphs)
// but each line is also tagged with its block kind.
//
// CRLF line endings normalise to LF before splitting.
func splitMarkdownParagraphs(md string) []string {
// Lines that look like block markers don't merge with their neighbours
// even across blank lines — every list / heading / blockquote line is
// its own block in the output. A run of unmarked lines collapses into
// one "paragraph" block (so soft line breaks inside a paragraph still
// concatenate).
//
// CRLF normalised to LF before parsing.
func splitMarkdownBlocks(md string) []mdBlock {
normalised := strings.ReplaceAll(md, "\r\n", "\n")
lines := strings.Split(normalised, "\n")
var paragraphs []string
var current []string
var blocks []mdBlock
var pendingPara []string
blankRun := 0
flushParagraph := func() {
if len(current) > 0 {
paragraphs = append(paragraphs, strings.Join(current, "\n"))
current = nil
flushPara := func() {
if len(pendingPara) > 0 {
blocks = append(blocks, mdBlock{styleKey: "paragraph", text: strings.Join(pendingPara, "\n")})
pendingPara = nil
}
}
for _, line := range lines {
for _, raw := range lines {
line := raw
if strings.TrimSpace(line) == "" {
if len(current) > 0 {
// End of a paragraph; the blank-counting starts now.
flushParagraph()
if len(pendingPara) > 0 {
flushPara()
blankRun = 1
continue
}
// Already inside a blank run (or before the first paragraph).
blankRun++
continue
}
// Starting a new paragraph — emit (blankRun-1) empty paragraphs
// in between if the lawyer used multiple blank lines as
// vertical spacing.
for i := 1; i < blankRun; i++ {
paragraphs = append(paragraphs, "")
// Detect heading / list / blockquote markers BEFORE we accumulate
// into the paragraph buffer.
kind, payload, ok := detectBlockMarker(line)
if ok {
flushPara()
// Emit spacing paragraphs equivalent to (blankRun - 1) extra.
for i := 1; i < blankRun; i++ {
blocks = append(blocks, mdBlock{styleKey: "paragraph", text: ""})
}
blankRun = 0
blocks = append(blocks, mdBlock{styleKey: kind, text: payload})
continue
}
// Plain paragraph line.
if len(pendingPara) == 0 {
// Starting a new paragraph after a blank run — emit
// (blankRun-1) extra empty paragraphs for vertical spacing.
for i := 1; i < blankRun; i++ {
blocks = append(blocks, mdBlock{styleKey: "paragraph", text: ""})
}
}
blankRun = 0
current = append(current, line)
pendingPara = append(pendingPara, line)
}
flushParagraph()
return paragraphs
flushPara()
return blocks
}
// renderParagraph emits one `<w:p>` element for the given paragraph
// text. Inline bold/italic spans become `<w:r>` runs with the
// corresponding `<w:rPr>`.
func renderParagraph(text, paragraphStyle string) string {
// detectBlockMarker classifies a single line. Returns (styleKey,
// payload-with-marker-stripped, true) for recognised markers; false
// for plain paragraph lines.
//
// Recognised markers (Slice D):
// # Heading → heading_1
// ## Heading → heading_2
// ### Heading → heading_3
// - item / * item → list_bullet
// 1. item / 2. item ... → list_numbered (any positive integer)
// > quote → blockquote
//
// Leading whitespace inside the line is tolerated up to 3 spaces (per
// CommonMark) so the lawyer's contentEditable indentation doesn't
// hide the marker.
func detectBlockMarker(line string) (string, string, bool) {
trimmed := strings.TrimLeft(line, " ")
// Cap to 3 spaces of leading indent — beyond that, treat as a
// regular paragraph line (matches CommonMark).
if len(line)-len(trimmed) > 3 {
return "", "", false
}
if strings.HasPrefix(trimmed, "### ") {
return "heading_3", strings.TrimSpace(trimmed[4:]), true
}
if strings.HasPrefix(trimmed, "## ") {
return "heading_2", strings.TrimSpace(trimmed[3:]), true
}
if strings.HasPrefix(trimmed, "# ") {
return "heading_1", strings.TrimSpace(trimmed[2:]), true
}
if strings.HasPrefix(trimmed, "> ") {
return "blockquote", strings.TrimSpace(trimmed[2:]), true
}
if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") {
return "list_bullet", strings.TrimSpace(trimmed[2:]), true
}
// Numbered: "N. " where N is one or more digits.
if i := indexOfNumberedMarker(trimmed); i > 0 {
return "list_numbered", strings.TrimSpace(trimmed[i:]), true
}
return "", "", false
}
// indexOfNumberedMarker checks for "N. " or "N) " at the start of the
// trimmed line; returns the byte index just past the marker, or -1 if
// no marker present.
func indexOfNumberedMarker(s string) int {
i := 0
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
if i == 0 {
return -1
}
if i >= len(s) {
return -1
}
if s[i] != '.' && s[i] != ')' {
return -1
}
if i+1 >= len(s) || s[i+1] != ' ' {
return -1
}
return i + 2
}
// renderBlockParagraph emits one `<w:p>` for a block. List blocks
// keep the same paragraph style as a default paragraph (the Slice D
// design's contract — list styles come from the base's stylemap and
// Word's numbering.xml is honoured by adding a leading bullet/number
// prefix in the rendered text). This keeps the composer free of
// numbering.xml mutations.
func renderBlockParagraph(blk mdBlock, paragraphStyle string, links HyperlinkAllocator, numberedOrdinal int) string {
var b strings.Builder
b.WriteString(`<w:p>`)
if paragraphStyle != "" {
@@ -107,21 +251,124 @@ func renderParagraph(text, paragraphStyle string) string {
b.WriteString(xmlAttrEscape(paragraphStyle))
b.WriteString(`"/></w:pPr>`)
}
if text == "" {
// Empty paragraph — emit a single empty run so Word renders the
// paragraph as a blank line. Without the run, some Word
// versions collapse the paragraph entirely.
if blk.text == "" {
b.WriteString(`<w:r><w:t xml:space="preserve"></w:t></w:r>`)
b.WriteString(`</w:p>`)
return b.String()
}
for _, span := range parseInlineSpans(text) {
b.WriteString(renderRun(span))
text := blk.text
// List blocks emit a visible "• " / "N. " prefix run. The
// stylemap entry handles paragraph indentation if the base
// defines a list paragraph style; otherwise the prefix at least
// surfaces the structure in plain Word. Lawyers who want Word's
// auto-numbering reapply a list style post-export.
switch blk.styleKey {
case "list_bullet":
b.WriteString(`<w:r><w:t xml:space="preserve">• </w:t></w:r>`)
case "list_numbered":
ordinal := numberedOrdinal
if ordinal <= 0 {
ordinal = 1
}
b.WriteString(`<w:r><w:t xml:space="preserve">`)
b.WriteString(fmt.Sprintf("%d. ", ordinal))
b.WriteString(`</w:t></w:r>`)
}
for _, run := range parseInlineRuns(text, links) {
b.WriteString(run)
}
b.WriteString(`</w:p>`)
return b.String()
}
// parseInlineRuns extracts inline spans + hyperlink runs and serialises
// each to OOXML. Hyperlinks become `<w:hyperlink r:id="RID">…runs…</w:hyperlink>`
// where RID comes from the HyperlinkAllocator.
func parseInlineRuns(text string, links HyperlinkAllocator) []string {
// Phase 1: find all hyperlink spans `[label](url)` and split the
// text around them.
type segment struct {
text string
isLink bool
url string
}
var segs []segment
rest := text
for {
idx := strings.Index(rest, "[")
if idx < 0 {
if rest != "" {
segs = append(segs, segment{text: rest})
}
break
}
// Find matching closing bracket, then a "(" right after.
closeBracket := strings.Index(rest[idx:], "](")
if closeBracket < 0 {
segs = append(segs, segment{text: rest})
break
}
closeParen := strings.Index(rest[idx+closeBracket:], ")")
if closeParen < 0 {
segs = append(segs, segment{text: rest})
break
}
// idx = start of "["
// idx+closeBracket = position of "]"
// idx+closeBracket+1 = position of "("
// idx+closeBracket+closeParen = position of ")"
label := rest[idx+1 : idx+closeBracket]
url := rest[idx+closeBracket+2 : idx+closeBracket+closeParen]
if idx > 0 {
segs = append(segs, segment{text: rest[:idx]})
}
segs = append(segs, segment{text: label, isLink: true, url: url})
rest = rest[idx+closeBracket+closeParen+1:]
}
var runs []string
for _, seg := range segs {
if seg.isLink && links != nil {
rid := links(seg.url)
if rid != "" {
var hb strings.Builder
hb.WriteString(`<w:hyperlink r:id="`)
hb.WriteString(xmlAttrEscape(rid))
hb.WriteString(`">`)
for _, span := range parseInlineSpans(seg.text) {
hb.WriteString(renderRunWithLinkStyle(span))
}
hb.WriteString(`</w:hyperlink>`)
runs = append(runs, hb.String())
continue
}
}
for _, span := range parseInlineSpans(seg.text) {
runs = append(runs, renderRun(span))
}
}
return runs
}
// renderRunWithLinkStyle emits a hyperlink child run. Same B/I support
// as renderRun, but additionally tags the run with the "Hyperlink"
// character style (Word's built-in) so the link renders in the
// document's hyperlink colour + underline.
func renderRunWithLinkStyle(span inlineSpan) string {
var b strings.Builder
b.WriteString(`<w:r><w:rPr><w:rStyle w:val="Hyperlink"/>`)
if span.Bold {
b.WriteString(`<w:b/>`)
}
if span.Italic {
b.WriteString(`<w:i/>`)
}
b.WriteString(`</w:rPr><w:t xml:space="preserve">`)
b.WriteString(xmlTextEscape(span.Text))
b.WriteString(`</w:t></w:r>`)
return b.String()
}
// inlineSpan is one piece of inline content: a text payload plus
// formatting flags. Bold and italic are independent — `***both***`
// produces one span with both flags set.

View File

@@ -144,3 +144,156 @@ func TestParseInlineSpans_UnderscoreBold(t *testing.T) {
t.Errorf("expected one bold 'strong' span; got %+v", spans)
}
}
// ─────────────────────────────────────────────────────────────────────
// Slice D — rich-prose constructs
// ─────────────────────────────────────────────────────────────────────
func slicedStylemap() map[string]string {
return map[string]string{
"paragraph": "Body",
"heading_1": "H1",
"heading_2": "H2",
"heading_3": "H3",
"list_bullet": "ListBullet",
"list_numbered": "ListNumber",
"blockquote": "Quote",
}
}
func TestRenderMarkdownToOOXML_Heading1(t *testing.T) {
out := RenderMarkdownToOOXMLWithStyles("# A heading", slicedStylemap(), nil)
if !strings.Contains(out, `<w:pStyle w:val="H1"/>`) {
t.Errorf("heading_1 missing H1 style: %q", out)
}
if !strings.Contains(out, "A heading") {
t.Errorf("heading text missing: %q", out)
}
}
func TestRenderMarkdownToOOXML_Heading2And3(t *testing.T) {
out := RenderMarkdownToOOXMLWithStyles("## H2 line\n### H3 line", slicedStylemap(), nil)
if !strings.Contains(out, `<w:pStyle w:val="H2"/>`) || !strings.Contains(out, "H2 line") {
t.Errorf("h2 not rendered: %q", out)
}
if !strings.Contains(out, `<w:pStyle w:val="H3"/>`) || !strings.Contains(out, "H3 line") {
t.Errorf("h3 not rendered: %q", out)
}
}
func TestRenderMarkdownToOOXML_BulletList(t *testing.T) {
out := RenderMarkdownToOOXMLWithStyles("- first\n- second\n* third", slicedStylemap(), nil)
if !strings.Contains(out, `<w:pStyle w:val="ListBullet"/>`) {
t.Errorf("bullet stylemap not applied: %q", out)
}
if strings.Count(out, "• ") != 3 {
t.Errorf("expected 3 bullet prefixes; got %d in %q", strings.Count(out, "• "), out)
}
}
func TestRenderMarkdownToOOXML_NumberedList(t *testing.T) {
out := RenderMarkdownToOOXMLWithStyles("1. first\n2. second\n3. third", slicedStylemap(), nil)
if !strings.Contains(out, `<w:pStyle w:val="ListNumber"/>`) {
t.Errorf("numbered stylemap not applied: %q", out)
}
for _, want := range []string{"1. ", "2. ", "3. "} {
if !strings.Contains(out, want) {
t.Errorf("missing ordinal prefix %q in %q", want, out)
}
}
}
func TestRenderMarkdownToOOXML_NumberedListResetsOnNonList(t *testing.T) {
// "1. A\n2. B\nplain\n1. C" → 1. A, 2. B, plain para, 1. C
out := RenderMarkdownToOOXMLWithStyles("1. A\n2. B\nplain\n1. C", slicedStylemap(), nil)
// The plain "plain" line breaks the list, so the next numbered
// item restarts at 1.
idxA := strings.Index(out, "1. ")
if idxA < 0 {
t.Fatalf("first 1. missing: %q", out)
}
idxB := strings.Index(out, "2. ")
if idxB < 0 || idxB <= idxA {
t.Fatalf("2. not after 1.: idxA=%d idxB=%d", idxA, idxB)
}
rest := out[idxB+1:]
idxC := strings.Index(rest, "1. ")
if idxC < 0 {
t.Errorf("numbered counter didn't reset on non-list block: %q", out)
}
}
func TestRenderMarkdownToOOXML_Blockquote(t *testing.T) {
out := RenderMarkdownToOOXMLWithStyles("> the quoted text", slicedStylemap(), nil)
if !strings.Contains(out, `<w:pStyle w:val="Quote"/>`) {
t.Errorf("blockquote stylemap not applied: %q", out)
}
if !strings.Contains(out, "the quoted text") {
t.Errorf("blockquote text missing: %q", out)
}
}
func TestRenderMarkdownToOOXML_Hyperlink(t *testing.T) {
allocated := map[string]string{}
alloc := func(url string) string {
rid := "rIdComposer" + url
allocated[url] = rid
return rid
}
out := RenderMarkdownToOOXMLWithStyles("See [Bundesgerichtshof](https://bgh.bund.de) for details.", slicedStylemap(), alloc)
if _, ok := allocated["https://bgh.bund.de"]; !ok {
t.Errorf("allocator never called for URL: %q", out)
}
if !strings.Contains(out, `<w:hyperlink r:id="rIdComposerhttps://bgh.bund.de">`) {
t.Errorf("hyperlink tag missing or wrong rid: %q", out)
}
if !strings.Contains(out, "Bundesgerichtshof") {
t.Errorf("link label missing: %q", out)
}
if !strings.Contains(out, `<w:rStyle w:val="Hyperlink"/>`) {
t.Errorf("hyperlink character style missing: %q", out)
}
}
func TestRenderMarkdownToOOXML_HyperlinkNilAllocatorFallsBackToPlain(t *testing.T) {
out := RenderMarkdownToOOXMLWithStyles("See [BGH](https://bgh.bund.de) here.", slicedStylemap(), nil)
// Without an allocator, the label still renders as plain text.
if !strings.Contains(out, "BGH") {
t.Errorf("label dropped: %q", out)
}
if strings.Contains(out, "<w:hyperlink") {
t.Errorf("hyperlink emitted without allocator: %q", out)
}
}
func TestDetectBlockMarker(t *testing.T) {
cases := []struct {
in string
kind string
want string
ok bool
}{
{"# A", "heading_1", "A", true},
{"## B", "heading_2", "B", true},
{"### C", "heading_3", "C", true},
{" # indented", "heading_1", "indented", true}, // up to 3 spaces tolerated
{" # too-deep", "", "", false}, // 4 spaces → not a heading
{"- bullet", "list_bullet", "bullet", true},
{"* star", "list_bullet", "star", true},
{"1. one", "list_numbered", "one", true},
{"42. forty-two", "list_numbered", "forty-two", true},
{"1) paren", "list_numbered", "paren", true},
{"1.no-space", "", "", false}, // ordinal needs trailing space
{"> quote", "blockquote", "quote", true},
{"plain", "", "", false},
{"#nospace", "", "", false}, // heading needs space after hash
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
kind, payload, ok := detectBlockMarker(tc.in)
if ok != tc.ok || kind != tc.kind || payload != tc.want {
t.Errorf("detectBlockMarker(%q) = (%q,%q,%v); want (%q,%q,%v)", tc.in, kind, payload, ok, tc.kind, tc.want, tc.ok)
}
})
}
}