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:
@@ -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:",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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ügen; die Vorschau zeigt das Ergebnis sofort.
|
||||
</p>
|
||||
|
||||
<div id="names-loading" className="entity-loading">
|
||||
<p data-i18n="einstellungen.loading">Lädt…</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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
177
internal/handlers/name_compositions.go
Normal file
177
internal/handlers/name_compositions.go
Normal 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)
|
||||
}
|
||||
211
internal/services/name_template.go
Normal file
211
internal/services/name_template.go
Normal 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
|
||||
}
|
||||
115
internal/services/name_template_test.go
Normal file
115
internal/services/name_template_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"}}}
|
||||
|
||||
Reference in New Issue
Block a user