feat(settings): name-composition token-template editor (t-paliad-356 Slice 4)

Adds the /settings "Namensschemata" tab so users can customise the two wired
name artifacts (submission_draft_title, submission_docx_filename) via a
single-line {token} template, with a clickable palette, live preview, and
reset-to-default — PRD §7.

Engine (pure, pkg/nomen):
- Composition.Template() serialises a composition to "{var}" shorthand;
  ParseTemplate() is its inverse — tokens + literal separators (trailing,
  owned by the left segment) + paren Wrap. Missing-rules are NOT in the
  shorthand (PRD §7); the parser leaves every segment KindOmit. Leading /
  trailing literals are rejected (the trailing-separator model can't carry
  them) so a save never silently drops characters. Table + round-trip tests.

Paliad glue (internal/services/name_template.go):
- ParseNameTemplate overlays each segment's missing-rule from the artifact's
  system default and validates against the catalog.
- PreviewNameComposition renders against the fixed PRD sample (Bayer AG / UPC
  / Sandoz / UPC_CFI_123/2026 / today) and an empties resolver so the
  missing-rule behaviour is visible. The frontend never parses templates —
  the nomen engine stays the single source of truth.
- SettingsNameArtifacts / SettingsNameArtifact build the per-artifact cards
  (current template, system default, override flag, ordered palette, previews).

API (internal/handlers/name_compositions.go):
- GET    /api/me/name-compositions               — cards
- POST   /api/me/name-compositions/preview        — live preview + validation
- PUT    /api/me/name-compositions/{artifact_id}   — store override
- DELETE /api/me/name-compositions/{artifact_id}   — reset to system default
Storage reuses the Slice-3 service surface (UserNameCompositions /
SetUserNameCompositions) via read-modify-write; no new column, no migration.

Frontend: new tab + JS-built cards (palette insert-at-cursor, 250ms-debounced
preview, save/reset, DE/EN labels), CSS, and i18n keys (de + en).

Gates: go vet, go test ./..., bun build all clean. Browser verification of the
settings UX is deferred to post-deploy mai-tester — the shared Supabase login
wall blocks pre-merge browser login (same ceiling as t-paliad-354).
This commit is contained in:
mAi
2026-06-01 12:46:07 +02:00
parent 83d5ed27e0
commit 6e56b9d51f
11 changed files with 1207 additions and 2 deletions

View File

@@ -1550,7 +1550,17 @@ const translations: Record<Lang, Record<string, string>> = {
"einstellungen.tab.profil": "Profil",
"einstellungen.tab.benachrichtigungen": "Benachrichtigungen",
"einstellungen.tab.caldav": "CalDAV",
"einstellungen.tab.names": "Namensschemata",
"einstellungen.tab.export": "Datenexport",
"einstellungen.names.subtitle": "Legen Sie fest, wie Paliad Entwurfstitel und Dateinamen aus Projektdaten zusammensetzt. Klicken Sie auf einen Platzhalter, um ihn einzuf\u00fcgen; die Vorschau zeigt das Ergebnis sofort.",
"einstellungen.names.preview.sample": "Beispiel:",
"einstellungen.names.preview.empty": "Ohne Projektdaten:",
"einstellungen.names.reset": "Auf Standard zur\u00fccksetzen",
"einstellungen.names.saved": "Gespeichert.",
"einstellungen.names.reset_done": "Auf Standard zur\u00fcckgesetzt.",
"einstellungen.names.override_badge": "Angepasst",
"einstellungen.names.error.load": "Namensschemata konnten nicht geladen werden.",
"einstellungen.names.error.invalid": "Ung\u00fcltige Vorlage \u2014 bitte pr\u00fcfen Sie die Platzhalter.",
"einstellungen.export.subtitle": "Laden Sie Ihre pers\u00f6nlichen Paliad-Daten als Excel- + JSON- + CSV-Paket herunter. Enthalten ist alles, was Sie aktuell sehen k\u00f6nnen \u2014 Ihre Projekte, Fristen, Termine, Notizen, Genehmigungen und Einstellungen.",
"einstellungen.export.heading": "Pers\u00f6nlicher Datenexport",
"einstellungen.export.what": "Das Paket enth\u00e4lt Ihre sichtbaren Daten in drei Formaten in einem .zip:",
@@ -4884,7 +4894,17 @@ const translations: Record<Lang, Record<string, string>> = {
"einstellungen.tab.profil": "Profile",
"einstellungen.tab.benachrichtigungen": "Notifications",
"einstellungen.tab.caldav": "CalDAV",
"einstellungen.tab.names": "Naming",
"einstellungen.tab.export": "Data export",
"einstellungen.names.subtitle": "Define how Paliad composes draft titles and file names from project data. Click a placeholder to insert it; the preview updates instantly.",
"einstellungen.names.preview.sample": "Sample:",
"einstellungen.names.preview.empty": "Without project data:",
"einstellungen.names.reset": "Reset to default",
"einstellungen.names.saved": "Saved.",
"einstellungen.names.reset_done": "Reset to default.",
"einstellungen.names.override_badge": "Customised",
"einstellungen.names.error.load": "Could not load naming schemes.",
"einstellungen.names.error.invalid": "Invalid template \u2014 please check the placeholders.",
"einstellungen.export.subtitle": "Download your personal Paliad data as an Excel + JSON + CSV bundle. The package contains everything you can currently see \u2014 your projects, deadlines, appointments, notes, approvals and settings.",
"einstellungen.export.heading": "Personal data export",
"einstellungen.export.what": "The package contains your visible data in three formats in one .zip:",

View File

@@ -51,8 +51,8 @@ interface SyncLogEntry {
duration_ms?: number;
}
type TabName = "profil" | "benachrichtigungen" | "caldav" | "export";
const TABS: TabName[] = ["profil", "benachrichtigungen", "caldav", "export"];
type TabName = "profil" | "benachrichtigungen" | "caldav" | "names" | "export";
const TABS: TabName[] = ["profil", "benachrichtigungen", "caldav", "names", "export"];
const DEFAULT_TAB: TabName = "profil";
let me: Me | null = null;
@@ -115,6 +115,7 @@ function showTab(tab: TabName, pushHistory: boolean) {
if (tab === "profil") void loadProfilTab();
else if (tab === "benachrichtigungen") void loadPrefsTab();
else if (tab === "caldav") void loadCalDAVTab();
else if (tab === "names") void loadNamesTab();
else if (tab === "export") void loadExportTab();
}
}
@@ -1119,6 +1120,282 @@ function runExport(): void {
}
}
// --- Namensschemata tab (t-paliad-356 Slice 4) ------------------------------
//
// Per-artifact token-template editor. All parsing, validation and preview
// rendering happen server-side (the nomen engine is the single source of
// truth); this client only inserts {tokens} at the cursor, debounces a preview
// request, and persists via PUT/DELETE.
interface NameVar {
var: string;
label: string;
label_en: string;
}
interface NameArtifactCard {
artifact_id: string;
label: string;
label_en: string;
template: string;
system_template: string;
is_override: boolean;
palette: NameVar[];
preview_full: string;
preview_empty: string;
}
let nameCards: NameArtifactCard[] = [];
const namePreviewTimers = new Map<string, number>();
function nameVarLabel(v: NameVar): string {
return getLang() === "en" ? v.label_en : v.label;
}
function artifactLabel(c: NameArtifactCard): string {
return getLang() === "en" ? c.label_en : c.label;
}
async function loadNamesTab(): Promise<void> {
const loading = document.getElementById("names-loading");
const list = document.getElementById("names-list");
if (!list) return;
try {
const resp = await fetch("/api/me/name-compositions");
if (!resp.ok) {
if (loading) loading.textContent = t("einstellungen.names.error.load");
return;
}
const data = await resp.json();
nameCards = (data.artifacts ?? []) as NameArtifactCard[];
} catch {
if (loading) loading.textContent = t("einstellungen.names.error.load");
return;
}
if (loading) loading.style.display = "none";
list.style.display = "";
renderNameCards();
}
function renderNameCards(): void {
const list = document.getElementById("names-list");
if (!list) return;
list.innerHTML = nameCards.map(nameCardHTML).join("");
for (const card of nameCards) wireNameCard(card.artifact_id);
}
function nameCardHTML(c: NameArtifactCard): string {
const id = c.artifact_id;
const chips = c.palette
.map(
(v) =>
`<button type="button" class="names-chip" data-var="${esc(v.var)}" data-art="${esc(id)}">${esc(nameVarLabel(v))}</button>`,
)
.join("");
const badge = c.is_override
? `<span class="names-badge" id="names-badge-${esc(id)}">${esc(t("einstellungen.names.override_badge"))}</span>`
: `<span class="names-badge" id="names-badge-${esc(id)}" style="display:none"></span>`;
return `
<div class="names-artifact" data-art="${esc(id)}">
<div class="names-artifact-head">
<h2>${esc(artifactLabel(c))}</h2>
${badge}
</div>
<div class="names-palette" id="names-palette-${esc(id)}">${chips}</div>
<input type="text" class="names-template-input" id="names-input-${esc(id)}"
value="${esc(c.template)}" autocomplete="off" spellcheck="false" />
<p class="form-msg form-msg-error names-error" id="names-error-${esc(id)}" style="display:none"></p>
<div class="names-preview">
<div class="names-preview-row">
<span class="names-preview-label" data-i18n="einstellungen.names.preview.sample">${esc(t("einstellungen.names.preview.sample"))}</span>
<code class="names-preview-value" id="names-full-${esc(id)}">${esc(c.preview_full)}</code>
</div>
<div class="names-preview-row">
<span class="names-preview-label" data-i18n="einstellungen.names.preview.empty">${esc(t("einstellungen.names.preview.empty"))}</span>
<code class="names-preview-value" id="names-empty-${esc(id)}">${esc(c.preview_empty)}</code>
</div>
</div>
<p class="form-msg names-saved" id="names-saved-${esc(id)}"></p>
<div class="form-actions">
<button type="button" class="btn-secondary" id="names-reset-${esc(id)}" data-i18n="einstellungen.names.reset">${esc(t("einstellungen.names.reset"))}</button>
<button type="button" class="btn-primary btn-cta-lime" id="names-save-${esc(id)}" data-i18n="einstellungen.save">${esc(t("einstellungen.save"))}</button>
</div>
</div>`;
}
function wireNameCard(id: string): void {
const input = document.getElementById(`names-input-${id}`) as HTMLInputElement | null;
if (!input) return;
input.addEventListener("input", () => scheduleNamePreview(id));
document.querySelectorAll<HTMLButtonElement>(`.names-chip[data-art="${cssEscapeAttr(id)}"]`).forEach((chip) => {
chip.addEventListener("click", () => insertNameToken(id, chip.getAttribute("data-var") ?? ""));
});
document.getElementById(`names-reset-${id}`)?.addEventListener("click", () => resetNameComposition(id));
document.getElementById(`names-save-${id}`)?.addEventListener("click", () => saveNameComposition(id));
}
// Artifact ids are [a-z_] only, but keep the attribute-selector value safe.
function cssEscapeAttr(s: string): string {
return s.replace(/["\\]/g, "\\$&");
}
function insertNameToken(id: string, varName: string): void {
const input = document.getElementById(`names-input-${id}`) as HTMLInputElement | null;
if (!input || !varName) return;
const token = `{${varName}}`;
const start = input.selectionStart ?? input.value.length;
const end = input.selectionEnd ?? input.value.length;
input.value = input.value.slice(0, start) + token + input.value.slice(end);
const caret = start + token.length;
input.focus();
input.setSelectionRange(caret, caret);
scheduleNamePreview(id);
}
function scheduleNamePreview(id: string): void {
clearSavedMsg(id);
const existing = namePreviewTimers.get(id);
if (existing) window.clearTimeout(existing);
namePreviewTimers.set(id, window.setTimeout(() => void runNamePreview(id), 250));
}
async function runNamePreview(id: string): Promise<void> {
const input = document.getElementById(`names-input-${id}`) as HTMLInputElement | null;
if (!input) return;
const template = input.value;
try {
const resp = await fetch("/api/me/name-compositions/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ artifact_id: id, template }),
});
if (!resp.ok) {
setNameError(id, t("einstellungen.names.error.invalid"));
return;
}
const data = await resp.json();
if (data.ok) {
setNamePreview(id, data.preview_full, data.preview_empty);
clearNameError(id);
} else {
setNameError(id, t("einstellungen.names.error.invalid"));
}
} catch {
setNameError(id, t("einstellungen.names.error.invalid"));
}
}
function setNamePreview(id: string, full: string, empty: string): void {
const f = document.getElementById(`names-full-${id}`);
const e = document.getElementById(`names-empty-${id}`);
if (f) f.textContent = full;
if (e) e.textContent = empty;
}
function setNameError(id: string, msg: string): void {
const err = document.getElementById(`names-error-${id}`);
if (err) {
err.textContent = msg;
err.style.display = "";
}
const save = document.getElementById(`names-save-${id}`) as HTMLButtonElement | null;
if (save) save.disabled = true;
}
function clearNameError(id: string): void {
const err = document.getElementById(`names-error-${id}`);
if (err) {
err.textContent = "";
err.style.display = "none";
}
const save = document.getElementById(`names-save-${id}`) as HTMLButtonElement | null;
if (save) save.disabled = false;
}
function clearSavedMsg(id: string): void {
const saved = document.getElementById(`names-saved-${id}`);
if (saved) saved.textContent = "";
}
function applyNameCard(updated: NameArtifactCard): void {
const idx = nameCards.findIndex((c) => c.artifact_id === updated.artifact_id);
if (idx >= 0) nameCards[idx] = updated;
const input = document.getElementById(`names-input-${updated.artifact_id}`) as HTMLInputElement | null;
if (input) input.value = updated.template;
setNamePreview(updated.artifact_id, updated.preview_full, updated.preview_empty);
clearNameError(updated.artifact_id);
const badge = document.getElementById(`names-badge-${updated.artifact_id}`);
if (badge) {
if (updated.is_override) {
badge.textContent = t("einstellungen.names.override_badge");
badge.style.display = "";
} else {
badge.style.display = "none";
}
}
}
async function saveNameComposition(id: string): Promise<void> {
const input = document.getElementById(`names-input-${id}`) as HTMLInputElement | null;
if (!input) return;
try {
const resp = await fetch(`/api/me/name-compositions/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ template: input.value }),
});
if (!resp.ok) {
setNameError(id, t("einstellungen.names.error.invalid"));
return;
}
const updated = (await resp.json()) as NameArtifactCard;
applyNameCard(updated);
const saved = document.getElementById(`names-saved-${id}`);
if (saved) {
saved.textContent = t("einstellungen.names.saved");
saved.className = "form-msg form-msg-success names-saved";
}
} catch {
setNameError(id, t("einstellungen.names.error.invalid"));
}
}
async function resetNameComposition(id: string): Promise<void> {
try {
const resp = await fetch(`/api/me/name-compositions/${encodeURIComponent(id)}`, { method: "DELETE" });
if (!resp.ok) {
setNameError(id, t("einstellungen.names.error.invalid"));
return;
}
const updated = (await resp.json()) as NameArtifactCard;
applyNameCard(updated);
const saved = document.getElementById(`names-saved-${id}`);
if (saved) {
saved.textContent = t("einstellungen.names.reset_done");
saved.className = "form-msg form-msg-success names-saved";
}
} catch {
setNameError(id, t("einstellungen.names.error.invalid"));
}
}
// Re-localise palette chips + artifact headings on language change without
// rebuilding the cards (which would discard in-progress edits).
function relocaliseNameCards(): void {
for (const card of nameCards) {
const head = document.querySelector(`.names-artifact[data-art="${cssEscapeAttr(card.artifact_id)}"] h2`);
if (head) head.textContent = artifactLabel(card);
const badge = document.getElementById(`names-badge-${card.artifact_id}`);
if (badge && badge.style.display !== "none") badge.textContent = t("einstellungen.names.override_badge");
for (const v of card.palette) {
const chip = document.querySelector(
`.names-chip[data-art="${cssEscapeAttr(card.artifact_id)}"][data-var="${cssEscapeAttr(v.var)}"]`,
);
if (chip) chip.textContent = nameVarLabel(v);
}
}
}
// --- Init -------------------------------------------------------------------
document.addEventListener("DOMContentLoaded", () => {
@@ -1152,6 +1429,7 @@ document.addEventListener("DOMContentLoaded", () => {
renderCalDAVStatus();
void loadCalDAVLog();
}
if (loadedTabs.has("names")) relocaliseNameCards();
});
showTab(parseTab(), false);

View File

@@ -1762,6 +1762,15 @@ export type I18nKey =
| "einstellungen.export.what"
| "einstellungen.heading"
| "einstellungen.loading"
| "einstellungen.names.error.invalid"
| "einstellungen.names.error.load"
| "einstellungen.names.override_badge"
| "einstellungen.names.preview.empty"
| "einstellungen.names.preview.sample"
| "einstellungen.names.reset"
| "einstellungen.names.reset_done"
| "einstellungen.names.saved"
| "einstellungen.names.subtitle"
| "einstellungen.optional"
| "einstellungen.prefs.escalation.default_option"
| "einstellungen.prefs.escalation.heading"
@@ -1804,6 +1813,7 @@ export type I18nKey =
| "einstellungen.tab.benachrichtigungen"
| "einstellungen.tab.caldav"
| "einstellungen.tab.export"
| "einstellungen.tab.names"
| "einstellungen.tab.profil"
| "einstellungen.title"
| "event.description.appointment_approval_approved"

View File

@@ -40,6 +40,7 @@ export function renderSettings(): string {
<a className="entity-tab" data-tab="profil" href="?tab=profil" data-i18n="einstellungen.tab.profil">Profil</a>
<a className="entity-tab" data-tab="benachrichtigungen" href="?tab=benachrichtigungen" data-i18n="einstellungen.tab.benachrichtigungen">Benachrichtigungen</a>
<a className="entity-tab" data-tab="caldav" href="?tab=caldav" data-i18n="einstellungen.tab.caldav">CalDAV</a>
<a className="entity-tab" data-tab="names" href="?tab=names" data-i18n="einstellungen.tab.names">Namensschemata</a>
<a className="entity-tab" data-tab="export" href="?tab=export" data-i18n="einstellungen.tab.export">Datenexport</a>
</nav>
@@ -362,6 +363,23 @@ export function renderSettings(): string {
</div>
</section>
{/* --- Namensschemata tab (t-paliad-356 Slice 4) -------- */}
<section className="entity-tab-panel" id="tab-names" style="display:none">
<p className="tool-subtitle" data-i18n="einstellungen.names.subtitle">
Legen Sie fest, wie Paliad Entwurfstitel und Dateinamen aus Projektdaten zusammensetzt.
Klicken Sie auf einen Platzhalter, um ihn einzuf&uuml;gen; die Vorschau zeigt das Ergebnis sofort.
</p>
<div id="names-loading" className="entity-loading">
<p data-i18n="einstellungen.loading">L&auml;dt&hellip;</p>
</div>
{/* Per-artifact cards are built client-side from
/api/me/name-compositions so the wired-artifact list stays
server-driven (no duplicated catalog in the frontend). */}
<div id="names-list" className="names-list" style="display:none" />
</section>
{/* --- Datenexport tab (t-paliad-214 Slice 1) ----------- */}
<section className="entity-tab-panel" id="tab-export" style="display:none">
<p className="tool-subtitle" data-i18n="einstellungen.export.subtitle">

View File

@@ -11203,6 +11203,101 @@ label.caldav-toggle-label {
margin-bottom: 0.3rem;
}
/* ===== Namensschemata (name-composition settings — t-paliad-356 S4) ===== */
.names-list {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.names-artifact {
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 1rem 1.1rem;
background: var(--color-surface);
}
.names-artifact-head {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.6rem;
}
.names-artifact-head h2 {
font-size: 1rem;
font-weight: 600;
margin: 0;
}
.names-badge {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: var(--color-accent-soft-bg);
color: var(--color-accent-soft-fg);
border: 1px solid var(--color-accent-soft-border);
}
.names-palette {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 0.6rem;
}
.names-chip {
font-size: 0.82rem;
padding: 0.2rem 0.6rem;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-surface-muted);
color: var(--color-text);
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease;
}
.names-chip:hover {
background: var(--color-accent-light);
border-color: var(--color-accent);
}
.names-template-input {
width: 100%;
font-family: var(--font-mono);
font-size: 0.92rem;
padding: 0.5rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface-2);
}
.names-error {
margin: 0.4rem 0 0;
}
.names-preview {
margin-top: 0.7rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.names-preview-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.5rem;
}
.names-preview-label {
font-size: 0.82rem;
color: var(--color-text-muted);
min-width: 9rem;
}
.names-preview-value {
font-family: var(--font-mono);
font-size: 0.88rem;
padding: 0.1rem 0.4rem;
border-radius: 4px;
background: var(--color-surface-muted);
word-break: break-word;
}
.names-saved {
margin: 0.5rem 0 0;
}
/* ===== Notizen (polymorphic notes — Phase I) ===== */
.notiz-container {
display: flex;

View File

@@ -502,6 +502,15 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("PUT /api/me/dashboard-layout", handlePutDashboardLayout)
protected.HandleFunc("POST /api/me/dashboard-layout/reset", handleResetDashboardLayout)
protected.HandleFunc("GET /api/dashboard-widget-catalog", handleGetWidgetCatalog)
// t-paliad-356 Slice 4 — per-user name-composition overrides (settings UX).
// Token-template shorthand per wired artifact; parse/validate/preview run
// server-side so the nomen engine stays the single source of truth.
protected.HandleFunc("GET /api/me/name-compositions", handleGetNameCompositions)
protected.HandleFunc("POST /api/me/name-compositions/preview", handlePreviewNameComposition)
protected.HandleFunc("PUT /api/me/name-compositions/{artifact_id}", handlePutNameComposition)
protected.HandleFunc("DELETE /api/me/name-compositions/{artifact_id}", handleDeleteNameComposition)
protected.HandleFunc("GET /api/projects/{id}/ancestors", handleListProjectAncestors)
protected.HandleFunc("GET /api/projects/{id}/parties", handleListParties)
protected.HandleFunc("POST /api/projects/{id}/parties", handleCreateParty)

View File

@@ -0,0 +1,177 @@
package handlers
// HTTP handlers for per-user name-composition overrides (t-paliad-356 Slice 4,
// PRD §7). The /settings "Namensschemata" tab reads and writes a token-template
// shorthand per wired artifact; these endpoints parse + validate + render
// through the nomen engine (services), so the frontend never parses templates
// itself.
//
// GET /api/me/name-compositions → all artifact cards
// POST /api/me/name-compositions/preview → live preview + validation
// PUT /api/me/name-compositions/{artifact_id} → store an override
// DELETE /api/me/name-compositions/{artifact_id} → reset to system default
//
// Storage reuses the Slice-3 service surface
// (SubmissionDraftService.UserNameCompositions / SetUserNameCompositions): the
// PUT/DELETE handlers read the full spec, mutate one artifact key, and write it
// back. No new column, no migration.
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/paliad/internal/services"
)
// nameCompositionsService returns the wired SubmissionDraftService (the owner
// of the name_compositions read/write path) or writes a 503 and returns nil.
func nameCompositionsService(w http.ResponseWriter) *services.SubmissionDraftService {
if dbSvc.submissionDraft == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "name-composition service not configured"})
return nil
}
return dbSvc.submissionDraft
}
// GET /api/me/name-compositions — the caller's artifact cards (system default
// or active override per artifact), with palette + live previews.
func handleGetNameCompositions(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
svc := nameCompositionsService(w)
if svc == nil {
return
}
overrides, err := svc.UserNameCompositions(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"artifacts": services.SettingsNameArtifacts(overrides),
})
}
// POST /api/me/name-compositions/preview — render a candidate template against
// the fixed sample without persisting it. Returns {ok:false, error} on a parse
// or validation failure so the UI can show the error inline and disable Save.
func handlePreviewNameComposition(w http.ResponseWriter, r *http.Request) {
if _, ok := requireUser(w, r); !ok {
return
}
var in struct {
ArtifactID string `json:"artifact_id"`
Template string `json:"template"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
full, empty, err := services.PreviewNameComposition(in.ArtifactID, in.Template)
if err != nil {
// A bad template is expected user input, not a server error — return
// 200 with ok:false so the live-preview fetch path stays simple.
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"preview_full": full,
"preview_empty": empty,
})
}
// PUT /api/me/name-compositions/{artifact_id} — validate the body template and
// store it as the caller's override for that artifact. Returns the refreshed
// card.
func handlePutNameComposition(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
svc := nameCompositionsService(w)
if svc == nil {
return
}
artifactID := r.PathValue("artifact_id")
var in struct {
Template string `json:"template"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
comp, err := services.ParseNameTemplate(artifactID, in.Template)
if err != nil {
if errors.Is(err, services.ErrInvalidInput) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeServiceError(w, err)
return
}
spec, err := svc.UserNameCompositions(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
if spec == nil {
spec = services.NameCompositionSpec{}
}
spec[artifactID] = comp
if err := svc.SetUserNameCompositions(r.Context(), uid, spec); err != nil {
if errors.Is(err, services.ErrInvalidInput) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeServiceError(w, err)
return
}
view, _ := services.SettingsNameArtifact(artifactID, spec)
writeJSON(w, http.StatusOK, view)
}
// DELETE /api/me/name-compositions/{artifact_id} — drop the caller's override
// for that artifact; the artifact reverts to the system default. Returns the
// refreshed card. Deleting an absent override is a no-op (still 200).
func handleDeleteNameComposition(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
svc := nameCompositionsService(w)
if svc == nil {
return
}
artifactID := r.PathValue("artifact_id")
if _, ok := services.NameArtifact(artifactID); !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "unknown name artifact"})
return
}
spec, err := svc.UserNameCompositions(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
if _, present := spec[artifactID]; present {
delete(spec, artifactID)
if err := svc.SetUserNameCompositions(r.Context(), uid, spec); err != nil {
writeServiceError(w, err)
return
}
}
view, _ := services.SettingsNameArtifact(artifactID, spec)
writeJSON(w, http.StatusOK, view)
}

View File

@@ -0,0 +1,211 @@
package services
// Paliad-side glue for the nomen token-template shorthand (t-paliad-356 Slice 4,
// PRD §7). The settings UI edits a single-line "{var}" template per artifact;
// this file is the single authority that turns that string into a validated
// nomen.Composition and renders the live previews. The frontend never parses
// templates itself — it round-trips through these functions so the engine stays
// the one source of truth (no duplicated parser to drift out of sync).
//
// - ParseNameTemplate: shorthand -> Composition. The shorthand carries Var,
// separators and paren Wraps (nomen.ParseTemplate); MissingRules are NOT in
// the shorthand (PRD §7), so they are overlaid here from the artifact's
// system default. The result is validated against the artifact catalog.
// - PreviewNameComposition: renders a parsed template against a fixed sample
// (all project vars present) and an empties resolver (only the always-on
// date), so the user sees both the normal result and the missing-rule
// behaviour.
// - SettingsNameArtifacts: the ordered, localised view the settings page
// reads to build its per-artifact cards.
import (
"fmt"
"sort"
"time"
"mgit.msbls.de/m/paliad/pkg/nomen"
)
// settingsNameArtifactOrder fixes the order the two wired artifacts appear in
// the settings UI (title before filename). New wired artifacts append here.
var settingsNameArtifactOrder = []string{
ArtifactSubmissionDraftTitle,
ArtifactSubmissionDocxFilename,
}
// canonicalVarOrder fixes the palette chip order so it is deterministic across
// requests (catalogs are maps). Vars absent from this list sort after the known
// ones, alphabetically — a safety net for future catalog additions.
var canonicalVarOrder = []string{"date", "client", "forum", "opponent", "keyword", "case_number"}
// ParseNameTemplate compiles a token-template shorthand into a validated
// Composition for an artifact. MissingRules come from the artifact's system
// default (a var the default does not carry keeps the parser's KindOmit); the
// shorthand never sets them (PRD §7). Returns an ErrInvalidInput-wrapped error
// for an unknown artifact, a malformed template, or an unknown variable.
func ParseNameTemplate(artifactID, template string) (nomen.Composition, error) {
art, ok := NameArtifact(artifactID)
if !ok {
return nomen.Composition{}, fmt.Errorf("%w: unknown name artifact %q", ErrInvalidInput, artifactID)
}
comp, err := nomen.ParseTemplate(template)
if err != nil {
return nomen.Composition{}, fmt.Errorf("%w: %v", ErrInvalidInput, err)
}
missing := make(map[string]nomen.MissingRule, len(art.SystemDefault.Segments))
for _, seg := range art.SystemDefault.Segments {
missing[seg.Var] = seg.Missing
}
for i := range comp.Segments {
if m, ok := missing[comp.Segments[i].Var]; ok {
comp.Segments[i].Missing = m
}
}
if err := comp.Validate(art.Catalog); err != nil {
return nomen.Composition{}, fmt.Errorf("%w: %v", ErrInvalidInput, err)
}
return comp, nil
}
// nameSampleResolver is the fixed preview fixture (PRD §7): client "Bayer AG",
// forum "UPC", opponent "Sandoz", case "UPC_CFI_123/2026", render-time today.
// keyword is intentionally absent so it exercises its missing rule (the title
// omits it — matching a real project draft; the filename falls back to the
// "submission" literal). When full is false only the always-on date resolves,
// so the preview shows the missing-rule behaviour for every project-derived
// variable.
func nameSampleResolver(full bool) nomen.VarResolver {
return func(key string) (string, bool) {
if key == "date" {
return nomenDateBerlin(time.Now()), true
}
if !full {
return "", false
}
switch key {
case "client":
return "Bayer AG", true
case "forum":
return "UPC", true
case "opponent":
return "Sandoz", true
case "case_number":
return "UPC_CFI_123/2026", true
}
return "", false
}
}
// PreviewNameComposition parses a template for an artifact and renders it twice:
// full (the fixed sample with all project vars present) and empty (only the
// always-on date, so missing rules show). A parse/validation error is returned
// instead — the caller surfaces it inline and disables Save.
func PreviewNameComposition(artifactID, template string) (full, empty string, err error) {
comp, err := ParseNameTemplate(artifactID, template)
if err != nil {
return "", "", err
}
art, _ := NameArtifact(artifactID)
full = comp.Render(nameSampleResolver(true), art.Target)
empty = comp.Render(nameSampleResolver(false), art.Target)
return full, empty, nil
}
// NameVarView is one palette chip: a variable's key plus its localised labels.
type NameVarView struct {
Var string `json:"var"`
Label string `json:"label"`
LabelEN string `json:"label_en"`
}
// NameCompositionView is one artifact's settings card: its labels, the current
// template (the user override when present, else the system default), the
// system default for the "reset" affordance, whether an override is active, the
// palette, and the two rendered previews of the current template.
type NameCompositionView struct {
ArtifactID string `json:"artifact_id"`
Label string `json:"label"`
LabelEN string `json:"label_en"`
Template string `json:"template"`
SystemTemplate string `json:"system_template"`
IsOverride bool `json:"is_override"`
Palette []NameVarView `json:"palette"`
PreviewFull string `json:"preview_full"`
PreviewEmpty string `json:"preview_empty"`
}
// orderedPalette returns an artifact catalog's variables as palette chips in
// canonicalVarOrder (unknown vars alphabetical, last).
func orderedPalette(catalog nomen.VarCatalog) []NameVarView {
rank := make(map[string]int, len(canonicalVarOrder))
for i, v := range canonicalVarOrder {
rank[v] = i
}
out := make([]NameVarView, 0, len(catalog))
for key, def := range catalog {
out = append(out, NameVarView{Var: key, Label: def.Label, LabelEN: def.LabelEN})
}
sort.Slice(out, func(i, j int) bool {
ri, oki := rank[out[i].Var]
rj, okj := rank[out[j].Var]
switch {
case oki && okj:
return ri < rj
case oki != okj:
return oki // known vars before unknown
default:
return out[i].Var < out[j].Var
}
})
return out
}
// SettingsNameArtifacts builds the per-artifact views for the settings page,
// applying the caller's overrides on top of the system defaults. The overrides
// map is already SanitizeForRead'd by the loader; an artifact missing from it
// renders its system default with IsOverride=false. Order is fixed by
// settingsNameArtifactOrder.
func SettingsNameArtifacts(overrides NameCompositionSpec) []NameCompositionView {
views := make([]NameCompositionView, 0, len(settingsNameArtifactOrder))
for _, id := range settingsNameArtifactOrder {
if v, ok := SettingsNameArtifact(id, overrides); ok {
views = append(views, v)
}
}
return views
}
// SettingsNameArtifact builds one artifact's settings view, applying the
// caller's overrides on top of the system default. Returns (zero, false) for an
// unknown artifact id. Used by the per-artifact PUT/DELETE responses so the
// client refreshes only the touched card.
func SettingsNameArtifact(id string, overrides NameCompositionSpec) (NameCompositionView, bool) {
art, ok := NameArtifact(id)
if !ok {
return NameCompositionView{}, false
}
systemTemplate := art.SystemDefault.Template()
template := systemTemplate
isOverride := false
if overrides != nil {
if comp, ok := overrides[id]; ok && len(comp.Segments) > 0 {
template = comp.Template()
isOverride = true
}
}
// Previews always reflect the currently shown template; a parse error here
// would mean a stored composition we already validated is somehow
// unparseable — fall back to empty previews rather than failing the page.
full, empty, _ := PreviewNameComposition(id, template)
return NameCompositionView{
ArtifactID: id,
Label: art.Label,
LabelEN: art.LabelEN,
Template: template,
SystemTemplate: systemTemplate,
IsOverride: isOverride,
Palette: orderedPalette(art.Catalog),
PreviewFull: full,
PreviewEmpty: empty,
}, true
}

View File

@@ -0,0 +1,115 @@
package services
import (
"regexp"
"strings"
"testing"
"mgit.msbls.de/m/paliad/pkg/nomen"
)
var datePrefix = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}`)
// TestParseNameTemplate_RoundTripsSystemDefaults asserts the system-default
// compositions survive Template() -> ParseNameTemplate unchanged in
// Var/Sep/Wrap, with MissingRules re-overlaid from the default. This is the
// guard that the settings shorthand is a faithful authoring view of the seed.
func TestParseNameTemplate_RoundTripsSystemDefaults(t *testing.T) {
for _, id := range []string{ArtifactSubmissionDraftTitle, ArtifactSubmissionDocxFilename} {
art, _ := NameArtifact(id)
tmpl := art.SystemDefault.Template()
got, err := ParseNameTemplate(id, tmpl)
if err != nil {
t.Fatalf("%s: ParseNameTemplate(%q): %v", id, tmpl, err)
}
want := art.SystemDefault
if len(got.Segments) != len(want.Segments) {
t.Fatalf("%s: %d segments, want %d", id, len(got.Segments), len(want.Segments))
}
for i, seg := range got.Segments {
w := want.Segments[i]
if seg.Var != w.Var || seg.Sep != w.Sep || seg.Wrap != w.Wrap || seg.Missing != w.Missing {
t.Errorf("%s seg %d = %+v, want %+v", id, i, seg, w)
}
}
}
}
func TestParseNameTemplate_Errors(t *testing.T) {
cases := []struct {
name, artifact, template string
}{
{"unknown artifact", "nope", "{date}"},
{"unknown variable", ArtifactSubmissionDocxFilename, "{date} {client}"}, // client not in filename catalog
{"malformed", ArtifactSubmissionDraftTitle, "{date"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if _, err := ParseNameTemplate(c.artifact, c.template); err == nil {
t.Errorf("expected error, got nil")
}
})
}
}
// TestPreviewNameComposition_SystemDefaults asserts the fixed-sample previews
// match the two shipped schemes. The date is render-time today, so only its
// shape is checked; the rest is byte-exact.
func TestPreviewNameComposition_SystemDefaults(t *testing.T) {
titleTmpl, _ := NameArtifact(ArtifactSubmissionDraftTitle)
full, empty, err := PreviewNameComposition(ArtifactSubmissionDraftTitle, titleTmpl.SystemDefault.Template())
if err != nil {
t.Fatalf("title preview: %v", err)
}
if !datePrefix.MatchString(full) {
t.Errorf("title full preview %q has no leading date", full)
}
if !strings.HasSuffix(full, " Bayer AG ./. UPC ./. Sandoz") {
t.Errorf("title full preview = %q, want date + ' Bayer AG ./. UPC ./. Sandoz'", full)
}
if !datePrefix.MatchString(empty) || strings.ContainsAny(empty, " ") {
t.Errorf("title empty preview = %q, want bare date (all party segments omitted)", empty)
}
fnTmpl, _ := NameArtifact(ArtifactSubmissionDocxFilename)
full, empty, err = PreviewNameComposition(ArtifactSubmissionDocxFilename, fnTmpl.SystemDefault.Template())
if err != nil {
t.Fatalf("filename preview: %v", err)
}
if !strings.HasSuffix(full, " submission (UPC_CFI_123_2026).docx") {
// '/' in the sample case number is sanitised to '_' by the filename target.
t.Errorf("filename full preview = %q, want date + ' submission (UPC_CFI_123_2026).docx'", full)
}
if !strings.HasSuffix(empty, " submission (Az. folgt).docx") {
t.Errorf("filename empty preview = %q, want date + ' submission (Az. folgt).docx'", empty)
}
}
// TestSettingsNameArtifacts_OverrideShown asserts a stored override surfaces as
// IsOverride with its own template, while the untouched artifact stays system.
func TestSettingsNameArtifacts_OverrideShown(t *testing.T) {
override := nomen.Composition{Version: nomen.Version, Segments: []nomen.Segment{
{Var: "date", Sep: " ", Missing: nomen.Omit()},
{Var: "keyword", Missing: nomen.Literal(submissionKeywordFallback)},
}}
spec := NameCompositionSpec{ArtifactSubmissionDocxFilename: override}
views := SettingsNameArtifacts(spec)
if len(views) != 2 {
t.Fatalf("got %d views, want 2", len(views))
}
byID := map[string]NameCompositionView{}
for _, v := range views {
byID[v.ArtifactID] = v
}
if v := byID[ArtifactSubmissionDocxFilename]; !v.IsOverride || v.Template != "{date} {keyword}" {
t.Errorf("filename view = %+v, want IsOverride + template '{date} {keyword}'", v)
}
if v := byID[ArtifactSubmissionDraftTitle]; v.IsOverride {
t.Errorf("title view should be system default (no override), got IsOverride")
}
// Order is fixed: title first, filename second.
if views[0].ArtifactID != ArtifactSubmissionDraftTitle || views[1].ArtifactID != ArtifactSubmissionDocxFilename {
t.Errorf("artifact order = [%s %s], want [title filename]", views[0].ArtifactID, views[1].ArtifactID)
}
}

View File

@@ -253,3 +253,149 @@ func (t FuncTarget) Finalise(assembled string) string { return assembled + t.Suf
// PlainTarget returns an identity target (no sanitisation, no suffix) for
// human-facing names such as draft titles.
func PlainTarget(name string) RenderTarget { return FuncTarget{NameVal: name} }
// ---------------------------------------------------------------------------
// Token-template shorthand (PRD §7).
//
// A composition has an authoring shorthand: a single line of "{var}" tokens
// and literal text, e.g. "{date} {keyword} ({case_number})". This is the
// settings field the user edits. The shorthand expresses Var, the trailing
// separators (the literal runs between tokens), and a paren Wrap; it does NOT
// express MissingRules (PRD §7 keeps those at the system default, not
// user-editable in v1). ParseTemplate therefore returns every segment with
// KindOmit; the paliad consumer overlays per-var rules from the artifact's
// system default after parsing.
// ---------------------------------------------------------------------------
// Template renders a composition back to its token-template shorthand — the
// exact inverse of ParseTemplate for any composition ParseTemplate can
// produce. Each segment becomes "<wrap0>{var}<wrap1>" and the trailing
// separator is emitted between consecutive segments. The last segment's Sep is
// omitted (it never renders, and emitting it would add unrepresentable
// trailing text), matching ParseTemplate's "no trailing literal" rule.
func (c Composition) Template() string {
var b strings.Builder
for i, seg := range c.Segments {
b.WriteString(seg.Wrap[0])
b.WriteString("{")
b.WriteString(seg.Var)
b.WriteString("}")
b.WriteString(seg.Wrap[1])
if i < len(c.Segments)-1 {
b.WriteString(seg.Sep)
}
}
return b.String()
}
// ParseTemplate compiles the token-template shorthand into a Composition.
//
// - Each "{var}" becomes a segment (var is trimmed; empty or brace-nested
// names are rejected).
// - A "(" immediately before a token and a ")" immediately after it become
// that segment's Wrap; any other parentheses are literal text.
// - The literal run between two tokens (after stripping adjacent wrap parens)
// becomes the LEFT token's trailing separator.
// - Literal text before the first token or after the last token cannot be
// represented in the trailing-separator model and is rejected, so a save
// never silently drops characters. The token palette and both seed
// defaults never produce such text.
//
// Every returned segment has Missing == Omit; callers overlay per-var rules.
// A blank or token-less string yields a zero-segment composition (renders "").
func ParseTemplate(s string) (Composition, error) {
type token struct {
varName string
start, end int // start = index of '{'; end = index just past '}'
}
var toks []token
for i := 0; i < len(s); {
switch s[i] {
case '{':
rel := strings.IndexByte(s[i:], '}')
if rel < 0 {
return Composition{}, &ValidationError{Msg: "unterminated '{' in template"}
}
end := i + rel + 1
name := strings.TrimSpace(s[i+1 : i+rel])
if name == "" {
return Composition{}, &ValidationError{Msg: "empty {} token in template"}
}
if strings.ContainsRune(name, '{') {
return Composition{}, &ValidationError{Msg: "nested '{' in template"}
}
toks = append(toks, token{varName: name, start: i, end: end})
i = end
case '}':
return Composition{}, &ValidationError{Msg: "unexpected '}' in template"}
default:
i++
}
}
if len(toks) == 0 {
if strings.TrimSpace(s) == "" {
return Composition{Version: Version}, nil
}
return Composition{}, &ValidationError{Msg: "template has no {variable} tokens"}
}
// lits[k] is the literal run before token k; lits[len] is the run after the
// last token. lits[k] and the run after token k-1 are the same span.
lits := make([]string, len(toks)+1)
for k := range toks {
prevEnd := 0
if k > 0 {
prevEnd = toks[k-1].end
}
lits[k] = s[prevEnd:toks[k].start]
}
lits[len(toks)] = s[toks[len(toks)-1].end:]
// A token is wrapped iff the literal directly before it ends with '(' and
// the literal directly after it starts with ')'.
wrapped := make([]bool, len(toks))
for k := range toks {
if strings.HasSuffix(lits[k], "(") && strings.HasPrefix(lits[k+1], ")") {
wrapped[k] = true
}
}
segs := make([]Segment, len(toks))
for k := range toks {
segs[k].Var = toks[k].varName
segs[k].Missing = Omit()
if wrapped[k] {
segs[k].Wrap = [2]string{"(", ")"}
}
}
// Derive each segment's trailing separator from the literal run that
// follows it, stripping the wrap parens owned by the adjacent tokens.
for m := 1; m < len(lits); m++ {
run := lits[m]
if wrapped[m-1] { // ')' closing the previous token's wrap
run = strings.TrimPrefix(run, ")")
}
if m < len(toks) && wrapped[m] { // '(' opening the next token's wrap
run = strings.TrimSuffix(run, "(")
}
if m == len(toks) {
if run != "" {
return Composition{}, &ValidationError{Msg: "literal text after the last {variable} is not supported"}
}
continue
}
segs[m-1].Sep = run
}
// Leading literal (before the first token) has no left segment to own it.
lead := lits[0]
if wrapped[0] {
lead = strings.TrimSuffix(lead, "(")
}
if lead != "" {
return Composition{}, &ValidationError{Msg: "literal text before the first {variable} is not supported"}
}
return Composition{Version: Version, Segments: segs}, nil
}

View File

@@ -116,6 +116,132 @@ func TestSanitizeForRead(t *testing.T) {
}
}
func TestParseTemplate(t *testing.T) {
cases := []struct {
name string
in string
want []Segment // Missing is always Omit from the parser; not asserted here
}{
{
"filename shorthand with wrap",
"{date} {keyword} ({case_number})",
[]Segment{
{Var: "date", Sep: " ", Missing: Omit()},
{Var: "keyword", Sep: " ", Missing: Omit()},
{Var: "case_number", Wrap: [2]string{"(", ")"}, Missing: Omit()},
},
},
{
"title shorthand with ./. separators",
"{date} {client} ./. {forum} ./. {opponent}",
[]Segment{
{Var: "date", Sep: " ", Missing: Omit()},
{Var: "client", Sep: " ./. ", Missing: Omit()},
{Var: "forum", Sep: " ./. ", Missing: Omit()},
{Var: "opponent", Missing: Omit()},
},
},
{
"adjacent wrap, no surrounding space",
"{date}({case_number})",
[]Segment{
{Var: "date", Missing: Omit()},
{Var: "case_number", Wrap: [2]string{"(", ")"}, Missing: Omit()},
},
},
{
"non-wrap parens stay literal in the separator",
"{date} ) {keyword}",
[]Segment{
{Var: "date", Sep: " ) ", Missing: Omit()},
{Var: "keyword", Missing: Omit()},
},
},
{
"whitespace var names trimmed",
"{ date } {keyword}",
[]Segment{
{Var: "date", Sep: " ", Missing: Omit()},
{Var: "keyword", Missing: Omit()},
},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
comp, err := ParseTemplate(c.in)
if err != nil {
t.Fatalf("ParseTemplate(%q): %v", c.in, err)
}
if len(comp.Segments) != len(c.want) {
t.Fatalf("segments = %+v, want %+v", comp.Segments, c.want)
}
for i, seg := range comp.Segments {
w := c.want[i]
if seg.Var != w.Var || seg.Sep != w.Sep || seg.Wrap != w.Wrap {
t.Errorf("segment %d = %+v, want %+v", i, seg, w)
}
}
})
}
}
func TestParseTemplate_Errors(t *testing.T) {
bad := []string{
"{date", // unterminated
"date}", // stray close
"{} {keyword}", // empty token
"Entwurf {date}", // leading literal
"{date} (final)", // trailing literal
"plain text no tokens", // no tokens
}
for _, in := range bad {
t.Run(in, func(t *testing.T) {
if _, err := ParseTemplate(in); err == nil {
t.Errorf("ParseTemplate(%q) = nil error, want error", in)
}
})
}
}
func TestTemplateRoundTrip(t *testing.T) {
// Format → Parse → Format is stable, and the parsed segment shape matches
// the original (separators + wraps; Missing is overlaid by the consumer,
// not the parser, so it is normalised to Omit on the way back).
comps := map[string]Composition{
"filename": {Version: Version, Segments: []Segment{
{Var: "date", Sep: " ", Missing: Omit()},
{Var: "keyword", Sep: " ", Missing: Literal("submission")},
{Var: "case_number", Wrap: [2]string{"(", ")"}, Missing: Placeholder("Az. folgt")},
}},
"title": {Version: Version, Segments: []Segment{
{Var: "date", Sep: " ", Missing: Omit()},
{Var: "client", Sep: " ./. ", Missing: Omit()},
{Var: "forum", Sep: " ./. ", Missing: Omit()},
{Var: "opponent", Missing: Omit()},
}},
}
for name, comp := range comps {
t.Run(name, func(t *testing.T) {
tmpl := comp.Template()
parsed, err := ParseTemplate(tmpl)
if err != nil {
t.Fatalf("ParseTemplate(%q): %v", tmpl, err)
}
if got := parsed.Template(); got != tmpl {
t.Errorf("round-trip template = %q, want %q", got, tmpl)
}
if len(parsed.Segments) != len(comp.Segments) {
t.Fatalf("segment count = %d, want %d", len(parsed.Segments), len(comp.Segments))
}
for i, seg := range parsed.Segments {
if seg.Var != comp.Segments[i].Var || seg.Sep != comp.Segments[i].Sep || seg.Wrap != comp.Segments[i].Wrap {
t.Errorf("segment %d = %+v, want var/sep/wrap of %+v", i, seg, comp.Segments[i])
}
}
})
}
}
func TestValidate(t *testing.T) {
cat := VarCatalog{"date": {Key: "date"}, "client": {Key: "client"}}
ok := Composition{Version: Version, Segments: []Segment{{Var: "date"}, {Var: "client"}}}