Merge: t-paliad-356 Slice 1 — nomen name-composition engine + fold in #155/354 schemes (byte-equal refactor) + PRD
This commit is contained in:
354
docs/plans/prd-filename-generator-2026-06-01.md
Normal file
354
docs/plans/prd-filename-generator-2026-06-01.md
Normal file
@@ -0,0 +1,354 @@
|
||||
# PRD — Composable Name/Filename Generator Engine
|
||||
|
||||
**Task:** t-paliad-355 · **Author:** leibniz (inventor) · **Date:** 2026-06-01
|
||||
**Status:** DESIGN — awaiting head go/no-go on coder shift
|
||||
**Builds on:** t-paliad-352 / m/paliad#155 (draft title), t-paliad-354 (export filename, merged `94adeeb`)
|
||||
**Related:** `docs/plans/prd-docforge-2026-05-29.md` (doc-generation engine — a future naming consumer)
|
||||
|
||||
---
|
||||
|
||||
## § m's decisions (2026-06-01)
|
||||
|
||||
All eight grilling questions answered; every pick matched the inventor recommendation.
|
||||
|
||||
**Batch 1 — model:**
|
||||
- Q1 (Composition model): **Structured segments + string shorthand.** Canonical model is an ordered segment list with per-segment missing-rules; a token-template string is an optional authoring shorthand that compiles to segments.
|
||||
- Q2 (v1 precedence): **System → Firm → User → per-document.** Mirrors the existing dashboard-layout chain exactly. Project-level deferred to v1.1.
|
||||
- Q3 (Engine depth): **Reusable engine, wire 3 known consumers.** Real engine now; only draft-title, submission-.docx, and the non-project fix are wired. Other surfaces register as known artifacts but keep current code.
|
||||
- Q4 (Non-project name): **`<date> <keyword>`**, falling back to `Entwurf N` only when no type context exists.
|
||||
|
||||
**Batch 2 — concrete:**
|
||||
- Q5 (Missing-rule set): **omit + placeholder + literal**, per segment.
|
||||
- Q6 (Date semantics): **Render-time "today", Europe/Berlin, `YYYY-MM-DD`.**
|
||||
- Q7 (Settings UX): **Live-preview string field on `/settings`** + clickable `{token}` palette. Missing-rules use defaults (not user-editable in v1).
|
||||
- Q8 (Artifact scope): **2 submission artifacts (`submission_draft_title`, `submission_docx_filename`) + extensible registry.** docforge-export, data-zip, projection-slug registered as known artifacts but unwired in v1.
|
||||
|
||||
These are necessary for a coder shift, **not** sufficient — the head still gates whether/who/when to implement (inventor→coder rule).
|
||||
|
||||
---
|
||||
|
||||
## §0 Premises (verified against the live system, 2026-06-01)
|
||||
|
||||
| # | Premise | How verified |
|
||||
|---|---------|--------------|
|
||||
| P1 | Draft title = `<date> <client> ./. <forum> ./. <opponent>`, project-bound only, missing segments dropped-with-separator. | Read `internal/services/submission_autoname.go` (`AutoSubmissionTitle`). |
|
||||
| P2 | Non-project drafts fall back to `Entwurf N` / `Draft N` counter. | Read `submission_draft_service.go` `newDraftName`/`Create`. |
|
||||
| P3 | Export filename = `<date> <keyword> (<case | "Az. folgt">).docx`; keyword overridable per-draft via `composer_meta.filename_keyword`. | Read `internal/handlers/submissions.go` `submissionFileName` + `submissionFilenameKeyword`. |
|
||||
| P4 | Sanitiser `SanitiseSubmissionFileName` folds umlauts, maps `/\:*?<>|`→`_`, strips `"'`, **preserves spaces/parens**. Lives in `pkg/docforge/docx/dotm.go`, re-exported via `services.SanitiseSubmissionFileName` (`docforge_shims.go`). | Read `pkg/docforge/docx/dotm.go`. |
|
||||
| P5 | `DashboardLayoutSpec` is a production precedent for a validated jsonb spec: code `FactoryDefaultLayout` → admin `firm_dashboard_default` (db row id=1) → per-user `user_dashboard_layouts`, with `Validate()` (write) + `SanitizeForRead()` (read). | Read `dashboard_layout_spec.go`, `firm_dashboard_default_service.go`. |
|
||||
| P6 | `users.email_preferences jsonb` (per-user bag) and `projects.metadata jsonb` exist live. No dedicated `user_preferences` table — migration 017 only added the `email_preferences` column. | `information_schema.columns` query on live `paliad` schema. |
|
||||
| P7 | Draft titles are de-duplicated at create time via `uniqueDraftName` (appends a counter on collision). | Read `newDraftName`. |
|
||||
|
||||
**Doc-is-the-bug flags raised:** none. The two shipped behaviours are exactly as the task described; `projects.metadata` exists so a project-level override needs no new column when v1.1 arrives (only a documented sub-key).
|
||||
|
||||
---
|
||||
|
||||
## §1 The problem
|
||||
|
||||
Two one-off naming functions shipped in successive tasks (#155, 354). Each hardcodes: a date format, an ordered set of segments, a separator, and a missing-value policy. m wants to stop re-deriving this per feature — "we will need a filename generator more often later on" — and to expose **defaults / compositions** as a **user (and maybe project) setting**. Plus one immediate gap: non-project drafts get no date-led name.
|
||||
|
||||
The design must:
|
||||
1. Extract a **reusable composition engine** that renders a name from (template, variable-bag, render-target).
|
||||
2. Reproduce **both shipped schemes byte-for-byte** as seed defaults (no behaviour regression).
|
||||
3. Add **settings** with a clean precedence chain, built **on** the dashboard-spec pattern (P5), not beside it.
|
||||
4. Fix the **non-project** gap inside the engine, not as another special case.
|
||||
|
||||
---
|
||||
|
||||
## §2 The engine
|
||||
|
||||
A new package **`pkg/nomen`** (Latin *nomen* = "name"; firm-agnostic, sits beside `pkg/docforge`). Pure, dependency-light, table-testable. No DB, no HTTP — consumers resolve variables and hand them in, exactly as `AutoSubmissionTitle` is pure today.
|
||||
|
||||
> **FLAG (coder + m):** package name `nomen` is the inventor pick. Alternatives: `pkg/naming`, `internal/services/namegen`. Pick at implementation; nothing downstream depends on the name.
|
||||
|
||||
### 2.1 Core types (interface sketch — not final Go)
|
||||
|
||||
```go
|
||||
package nomen
|
||||
|
||||
// Segment is one piece of a composition: a variable reference, the
|
||||
// separator that precedes it, and what to do when the variable resolves
|
||||
// empty.
|
||||
type Segment struct {
|
||||
Var string // key into the variable catalog, e.g. "date", "keyword"
|
||||
Sep string // TRAILING separator: emitted AFTER this segment iff a
|
||||
// later segment also emits. The last emitted segment's
|
||||
// Sep is never used. (See Slice-1 note below.)
|
||||
Wrap [2]string // optional surrounding literals, e.g. {"(", ")"} for case-no.
|
||||
Missing MissingRule // omit | placeholder | literal
|
||||
}
|
||||
|
||||
type MissingRule struct {
|
||||
Kind MissingKind // KindOmit | KindPlaceholder | KindLiteral
|
||||
Value string // placeholder/literal text (e.g. "Az. folgt"); ignored for omit
|
||||
}
|
||||
|
||||
// Composition is the canonical, validated model.
|
||||
type Composition struct {
|
||||
Version int // schema version (start at 1)
|
||||
Segments []Segment
|
||||
}
|
||||
|
||||
// VarResolver yields a variable's value for one render. Returns ("", false)
|
||||
// when the variable is unavailable in this context (→ Missing rule applies).
|
||||
type VarResolver func(key string) (string, bool)
|
||||
|
||||
// RenderTarget post-processes the assembled string (sanitisation, suffix).
|
||||
type RenderTarget interface {
|
||||
Name() string // "title" | "filename"
|
||||
Transform(assembled string) string
|
||||
}
|
||||
|
||||
func (c Composition) Render(resolve VarResolver, target RenderTarget) string
|
||||
func (c Composition) Validate(catalog VarCatalog) error
|
||||
```
|
||||
|
||||
> **Implementation note (Slice 1, 2026-06-01 — `Sep` is trailing, not leading).**
|
||||
> This PRD originally sketched `Sep` as the separator emitted *before* a
|
||||
> segment. During Slice 1 that model proved unable to reproduce #155
|
||||
> byte-for-byte: the existing test `"no client — client segment omitted"`
|
||||
> requires `2026-05-31 UPC ./. Novartis Pharma` — the date must join the
|
||||
> *forum* with a single space when the client is absent, while the
|
||||
> forum-to-opponent join stays ` ./. `. A separator owned by the right-hand
|
||||
> segment would need two different values for the same segment depending on
|
||||
> what was omitted before it. Making the separator **trailing** (owned by
|
||||
> the left-hand segment) is the minimal faithful fix: the date's trailing
|
||||
> ` ` is used whenever any identity segment follows, and each party's
|
||||
> trailing ` ./. ` is used whenever another party follows. All shipped
|
||||
> #155/354 tests pass unchanged. Implemented in `pkg/nomen/nomen.go`; the
|
||||
> realised `RenderTarget` also splits `Transform` into `SanitiseValue`
|
||||
> (per-variable) + `Finalise` (whole-string + suffix) per §2.3.
|
||||
|
||||
### 2.2 Render algorithm (reproduces both shipped schemes)
|
||||
|
||||
For each segment, in order:
|
||||
1. `val, ok := resolve(seg.Var)`.
|
||||
2. If `!ok || strings.TrimSpace(val) == ""`, apply `seg.Missing`:
|
||||
- `KindOmit` → segment contributes nothing (and its `Sep` is suppressed).
|
||||
- `KindPlaceholder` → `val = seg.Missing.Value` (treated as present).
|
||||
- `KindLiteral` → `val = seg.Missing.Value` (same as placeholder; distinct *intent* in the model — "this is a fixed label", not "this is a stand-in for missing data" — so the settings UI can word them differently and future policy can diverge).
|
||||
3. If the segment emits, prepend `seg.Sep` **iff at least one segment already emitted** (kills the leading-separator problem the #155 code solves by hand), then wrap with `seg.Wrap`.
|
||||
4. Concatenate.
|
||||
5. `target.Transform(assembled)` runs once on the whole string.
|
||||
|
||||
**Separator suppression** is the generalisation of #155's "drop segment + its leading separator". **Placeholder** is the generalisation of 354's `(Az. folgt)`.
|
||||
|
||||
### 2.3 Render targets
|
||||
|
||||
The **same** `Composition` renders to different targets:
|
||||
|
||||
| Target | `Transform` | Used by |
|
||||
|--------|-------------|---------|
|
||||
| `TitleTarget` | identity (spaces, umlauts, ` ./. ` all valid in a human title) | `submission_draft_title` |
|
||||
| `FilenameTarget{ext: ".docx"}` | per-segment-aware: applies `services.SanitiseSubmissionFileName` to **variable values** (not the frame — preserve the spaces/parens/wrap), then appends `ext`. | `submission_docx_filename` |
|
||||
|
||||
> **Design note — where sanitisation runs.** 354 sanitises *each variable value* but keeps the assembled frame (`<date> <kw> (<case>)`) intact. To preserve that exactly, the `FilenameTarget` is **not** a dumb whole-string transform — the engine sanitises each resolved variable value *before* assembly when the target requests it, and the target only appends the extension at the end. So `RenderTarget` gains one more hook:
|
||||
|
||||
```go
|
||||
type RenderTarget interface {
|
||||
Name() string
|
||||
SanitiseValue(v string) string // per-variable; identity for TitleTarget
|
||||
Finalise(assembled string) string // whole-string; appends ".docx" for filename
|
||||
}
|
||||
```
|
||||
|
||||
This is the one subtlety that makes the engine faithful to 354. Both shipped schemes drop out of `(Composition, VarResolver, RenderTarget)` with no special-casing.
|
||||
|
||||
### 2.4 Variable catalog
|
||||
|
||||
A `VarCatalog` is an extensible registry: `key → VarDef{ Label, LabelEN, Description, Group }`. The catalog is **metadata only** (for validation + the settings palette); **values** come from the per-render `VarResolver` the consumer supplies. This keeps the engine pure — a consumer registers which keys it can resolve, the engine validates a composition only references known keys.
|
||||
|
||||
v1 catalog (the union of what the two schemes need + obvious near-neighbours):
|
||||
|
||||
| key | meaning | resolver source (submission consumer) |
|
||||
|-----|---------|----------------------------------------|
|
||||
| `date` | render-time today, Europe/Berlin, `YYYY-MM-DD` | engine-provided default resolver (see §2.5) |
|
||||
| `keyword` | document/submission type; user-overridable | `composer_meta.filename_keyword` → rule name (lang-aware) → "submission" |
|
||||
| `case_number` | project Aktenzeichen | `project.CaseNumber` |
|
||||
| `client` | root-ancestor client name | project-tree walk (existing `autoNameForProject`) |
|
||||
| `forum` | short forum label (UPC/EPA/LG/…) | `submissionForumShort(pt)` (existing) |
|
||||
| `opponent` | primary opposing party name | `submissionOpponentName(parties, ourSide)` (existing) |
|
||||
|
||||
Registered-but-deferred keys (declared so compositions can reference them, resolvers added when a consumer needs them): `proceeding`, `lang`, `client_matter`, `project_name`, `draft_counter`.
|
||||
|
||||
**Extensibility contract:** a new consumer (e.g. docforge export) builds its own `VarCatalog` subset + `VarResolver` and registers an artifact (§4). It never edits the engine.
|
||||
|
||||
### 2.5 The `date` resolver
|
||||
|
||||
The engine ships a default `date` resolver: `time.Now()` → `Europe/Berlin` → `Format("2006-01-02")`. This is the **one** variable the engine resolves itself (both shipped schemes compute it identically), so a consumer that only wants the standard date doesn't re-implement it. A consumer may override `date` in its resolver (e.g. a created-at date) — but v1 does not.
|
||||
|
||||
---
|
||||
|
||||
## §3 Settings & precedence
|
||||
|
||||
### 3.1 Precedence chain (v1)
|
||||
|
||||
Resolution order for a given artifact, **first hit wins**:
|
||||
|
||||
```
|
||||
per-document override → user override → firm default → system default
|
||||
(highest priority) (always present)
|
||||
```
|
||||
|
||||
- **System default** — code-resident, per artifact. The seed `Composition` literals (§5). Always exists; nothing can delete it.
|
||||
- **Firm default** — optional admin-set row, mirrors `firm_dashboard_default` (P5). A firm can mandate a house naming convention. Cleared → reverts to system.
|
||||
- **User override** — per-user, stored in a jsonb bag keyed by artifact id. Absent key → fall through.
|
||||
- **Per-document override** — the **already-shipped** `composer_meta.filename_keyword`, generalised to a `composer_meta.name_overrides` map of `{var → value}` (back-compat: `filename_keyword` reads as `name_overrides.keyword` for the filename artifact). This is a *variable-value* override, not a *composition* override — the user is replacing one token's value for one document, not redefining the template.
|
||||
|
||||
> **Why per-document is a value override, not a template override:** the shipped "Stichwort" editor lets a lawyer change *what the keyword is* for one draft, not *the shape of the name*. Keeping per-document as value-only avoids giving every draft its own editable template (scope creep) while preserving the shipped UX exactly.
|
||||
|
||||
### 3.2 Storage
|
||||
|
||||
| Level | Where | Shape |
|
||||
|-------|-------|-------|
|
||||
| System | Go code (`nomen` consumer package) | `Composition` literals |
|
||||
| Firm | **new** `paliad.firm_name_compositions` (id=1 singleton, mirrors `firm_dashboard_default`) | `jsonb`: `{ artifact_id: Composition }` map, validated |
|
||||
| User | **new column** `paliad.users.name_compositions jsonb NOT NULL DEFAULT '{}'` (mirrors `email_preferences`) | `{ artifact_id: Composition }` map |
|
||||
| Per-document | **existing** `submission_drafts.composer_meta` | `{ name_overrides: { var: value } }` (supersedes flat `filename_keyword`) |
|
||||
|
||||
A `NameCompositionSpec` type gets `Validate()` (write — references-known-vars, known-artifact, ≤ N segments) and `SanitizeForRead()` (read — drop segments referencing dropped vars, clamp version), exactly like `DashboardLayoutSpec`. This is the closest existing analog and the pattern is copy-shaped.
|
||||
|
||||
> **Project-level (v1.1, deferred):** when it lands, it slots between user and firm (`per-document → user → project → firm → system`) and stores under a documented `projects.metadata.name_compositions` sub-key — **no migration needed** (P6: column exists). The "project vs user, who wins?" call (Q2) is deferred with it; the v1.1 default is **user wins** (a lawyer's personal convention beats a matter's), but that's a v1.1 decision, flagged here so v1 storage doesn't preclude it.
|
||||
|
||||
---
|
||||
|
||||
## §4 Artifact registry
|
||||
|
||||
An **artifact** is a named thing that gets a name: it binds a default composition, an allowed-variable subset, and a render target.
|
||||
|
||||
```go
|
||||
type Artifact struct {
|
||||
ID string // "submission_draft_title", "submission_docx_filename"
|
||||
Label string // for the settings UI
|
||||
Catalog VarCatalog // which variables are available here
|
||||
Target RenderTarget // title vs filename
|
||||
SystemDefault Composition // the seed (§5)
|
||||
}
|
||||
```
|
||||
|
||||
v1 registry (`internal/services/namegen` — the paliad-side wiring; `pkg/nomen` stays pure):
|
||||
|
||||
| Artifact ID | Target | Wired in v1? |
|
||||
|-------------|--------|--------------|
|
||||
| `submission_draft_title` | title | **yes** |
|
||||
| `submission_docx_filename` | filename `.docx` | **yes** |
|
||||
| `docforge_export` | filename | registered, **unwired** (opts in when docforge ships) |
|
||||
| `data_zip_export` | filename `.zip` | registered, **unwired** (keeps `ExportFilename` shape) |
|
||||
| `projection_slug` | slug | registered, **unwired** |
|
||||
|
||||
Registering-but-not-wiring means: the artifact ID exists in the catalog so the settings UI *could* list it and a composition *could* be stored, but the consumer still calls its current code path until a follow-up task flips it. No dead behaviour, no speculative resolver code.
|
||||
|
||||
> **`data_zip_export` note:** `ExportFilename` (`paliad-export-project-<slug>-<short>-<ts>.zip`) is deliberately machine-shaped (UTC timestamp, uuid disambiguator) — it is **not** a legal title and should **not** inherit the legal-composition defaults. It is registered for *discoverability*, but its eventual opt-in would use a distinct catalog (slug/timestamp/uuid vars), confirming the engine generalises beyond the legal-title model without forcing that model on it.
|
||||
|
||||
---
|
||||
|
||||
## §5 Seed defaults (the two shipped schemes, as data)
|
||||
|
||||
### 5.1 `submission_draft_title` (reproduces `AutoSubmissionTitle`, #155)
|
||||
|
||||
```
|
||||
Segments:
|
||||
{ Var: "date", Sep: "", Missing: omit }
|
||||
{ Var: "client", Sep: " ", Missing: omit }
|
||||
{ Var: "forum", Sep: " ./. ", Missing: omit }
|
||||
{ Var: "opponent", Sep: " ./. ", Missing: omit }
|
||||
Target: TitleTarget
|
||||
```
|
||||
|
||||
- All-omit + separator-suppression reproduces "drop empty segment with its leading separator".
|
||||
- `date` with `Sep: ""` and the others' first-emitted-suppresses-Sep rule yields `2026-05-31 Bayer AG ./. UPC` when opponent is empty — identical to today.
|
||||
- Non-project draft: `client`/`forum`/`opponent` resolve `("", false)` → all omitted → renders bare `<date>`. **This is the non-project fix** (§6).
|
||||
|
||||
### 5.2 `submission_docx_filename` (reproduces `submissionFileName`, 354)
|
||||
|
||||
```
|
||||
Segments:
|
||||
{ Var: "date", Sep: "", Missing: omit }
|
||||
{ Var: "keyword", Sep: " ", Missing: literal("submission") }
|
||||
{ Var: "case_number", Sep: " ", Wrap: {"(", ")"},
|
||||
Missing: placeholder("Az. folgt") }
|
||||
Target: FilenameTarget{ext: ".docx"}
|
||||
```
|
||||
|
||||
- `keyword` missing → `literal("submission")` reproduces the `kw == "" → "submission"` fallback.
|
||||
- `case_number` missing → `placeholder("Az. folgt")`, wrapped in parens → `(Az. folgt)`.
|
||||
- `FilenameTarget` sanitises each value via `SanitiseSubmissionFileName`, preserves the frame, appends `.docx`. Output identical to 354.
|
||||
|
||||
**Faithfulness test (acceptance gate):** golden-file table tests assert the engine's output is byte-equal to the current `AutoSubmissionTitle` / `submissionFileName` across the existing test matrix (with/without opponent, with/without case-number, en/de, umlaut folding). The shipped funcs become thin wrappers over the engine, or are deleted once call-sites move.
|
||||
|
||||
---
|
||||
|
||||
## §6 The non-project fix
|
||||
|
||||
Currently `newDraftName` only calls `autoNameForProject` when `project != nil`; otherwise `nextDraftName` → `Entwurf N`. Under the engine:
|
||||
|
||||
- A non-project draft renders `submission_draft_title` with a resolver where `client/forum/opponent` are all `("", false)` → composition degrades to `<date>`.
|
||||
- Per Q4, the default gains a `keyword` segment so non-project drafts read **`<date> <keyword>`** where `keyword` = submission/document type if the draft has a `submission_code` that maps to a rule, else falls back.
|
||||
- **Fallback when no keyword context:** if `keyword` also resolves empty (project-less draft with no `submission_code`/rule), the title degrades to `<date> Entwurf N` — `Entwurf N` enters as the `keyword` segment's `literal` fallback **with** the existing counter, so uniqueness is preserved via `uniqueDraftName` (P7).
|
||||
|
||||
> **FLAG (coder):** confirm whether project-less drafts (t-paliad-243) carry a `submission_code`. If yes, `keyword` derives from the rule like the project path. If no, the `literal("Entwurf N")` fallback is the norm and non-project names read `<date> Entwurf N` — still satisfies "date first there" (m's ask). Resolve in implementation; both paths are handled by the same composition.
|
||||
|
||||
The non-project title is the **same** `submission_draft_title` artifact — not a separate composition. Degradation is data-driven, not a code branch. This is the payoff of the engine: the gap closes by *removing* the `project != nil` special-case, not adding another.
|
||||
|
||||
---
|
||||
|
||||
## §7 Settings UX (v1)
|
||||
|
||||
A section on the existing `/settings` page (017 surface):
|
||||
|
||||
- **Per artifact** (v1 lists the 2 wired ones): a single-line **token-template string** field, e.g. `{date} {keyword} ({case_number})`.
|
||||
- A **token palette**: clickable chips (`{date}` `{client}` `{forum}` `{opponent}` `{keyword}` `{case_number}`) insert at cursor. Chips show the localised label (DE primary / EN secondary).
|
||||
- A **live preview** rendered against a **sample project** (fixed fixture: client "Bayer AG", forum "UPC", opponent "Sandoz", case "UPC_CFI_123/2026", today's date) so the user sees the result instantly — and a second preview line with empties so they see the missing-rule behaviour.
|
||||
- **Reset to firm/system default** button (mirrors the dashboard "reset layout").
|
||||
|
||||
**String ⇄ segments:** the field is the *shorthand* (Q1). A small parser compiles `{var}` tokens + surrounding literals into `Segments` (separators = the literal runs between tokens; `(…)` around a token → `Wrap`). Missing-rules are **not** in the string (Q7) — they come from the system default for that var and are not user-editable in v1. So a user can reorder/drop/re-add tokens and change literals, but can't (yet) flip case-number from placeholder to omit. That's a deliberate v1 boundary; the structured model already supports it, the UI just doesn't expose it.
|
||||
|
||||
> Parser edge: a `{token}` the catalog doesn't know → inline validation error ("Unknown variable {foo}"), preview shows nothing, save disabled. Mirrors `DashboardLayoutSpec.Validate` rejecting unknown widget keys.
|
||||
|
||||
---
|
||||
|
||||
## §8 Slice train
|
||||
|
||||
Sliced so a **tracer bullet** ships value before the settings UI exists.
|
||||
|
||||
- **Slice 1 — Engine + faithful refactor (no behaviour change).**
|
||||
`pkg/nomen` (types, render, targets, catalog) + `internal/services/namegen` (artifact registry + the 2 seed compositions + resolvers built from existing `submission_autoname.go` helpers). Re-point `AutoSubmissionTitle` and `submissionFileName` call-sites at the engine. **Acceptance:** §5 golden-file byte-equality; all existing #155/354 tests green unchanged. *No user-visible change — this is the safety net.*
|
||||
- **Slice 2 — Non-project date-first (§6).**
|
||||
Remove the `project != nil` special-case in `newDraftName`; non-project drafts render `submission_draft_title`. **Acceptance:** project-less draft gets `<date> <keyword>` (or `<date> Entwurf N` fallback); existing project drafts unchanged. *First user-visible win, m's immediate ask.*
|
||||
- **Slice 3 — Precedence: system → user (per-document already shipped).**
|
||||
`users.name_compositions jsonb` column + `NameCompositionSpec` (`Validate`/`SanitizeForRead`) + resolution that prefers a user override over the system default. Generalise `composer_meta.filename_keyword` → `name_overrides.keyword` (back-compat read). *No UI yet — overrides settable via API/test.*
|
||||
- **Slice 4 — Settings UX (§7).**
|
||||
`/settings` token-template field + palette + live preview for the 2 wired artifacts. *User can now customise.*
|
||||
- **Slice 5 — Firm default.**
|
||||
`firm_name_compositions` singleton + admin surface, mirroring `firm_dashboard_default`. Slots into precedence below user. *Firm-wide convention.*
|
||||
|
||||
Slices 1–2 are the tracer bullet (engine proven on shipped behaviour + the gap closed). 3–5 layer settings without re-touching the engine.
|
||||
|
||||
---
|
||||
|
||||
## §9 Out of scope (this PRD)
|
||||
|
||||
- Implementation, migration SQL drafting, Go code.
|
||||
- Re-litigating #155 / 354 behaviour — they are the seed defaults, reproduced not redesigned.
|
||||
- **Project-level** compositions (v1.1; storage path reserved in §3.2, precedence call deferred).
|
||||
- Wiring `docforge_export`, `data_zip_export`, `projection_slug` — registered, not migrated (each is its own follow-up when the surface needs it).
|
||||
- Naming for non-doc-generation strings across the app.
|
||||
- User-editable **missing-rules** in the settings UI (model supports it; UI deferred past v1).
|
||||
|
||||
---
|
||||
|
||||
## §10 Open questions (historical record — resolved in § m's decisions)
|
||||
|
||||
1. Composition representation — token-string vs structured-segments vs both. → **Q1: structured + string shorthand.**
|
||||
2. v1 precedence levels. → **Q2: system → firm → user → per-document.**
|
||||
3. Generalisation depth (YAGNI vs engine-now). → **Q3: reusable engine, 3 consumers wired.**
|
||||
4. Non-project default name. → **Q4: `<date> <keyword>`.**
|
||||
5. Missing-rule policy set. → **Q5: omit + placeholder + literal.**
|
||||
6. Date semantics. → **Q6: render-time today, Europe/Berlin, `YYYY-MM-DD`.**
|
||||
7. Settings UX shape. → **Q7: live-preview string field + palette.**
|
||||
8. Artifact registry scope. → **Q8: 2 submission artifacts + extensible registry.**
|
||||
|
||||
**Remaining FLAGs for the coder (not blocking design approval):**
|
||||
- Package name `pkg/nomen` (vs `naming`/`namegen`) — implementation pick.
|
||||
- Whether project-less drafts carry a `submission_code` (decides `keyword` source in §6).
|
||||
- `name_overrides` back-compat read of the existing `filename_keyword` key — confirm the one shipped draft-keyword row migrates cleanly (live round-trip test, like t-paliad-354's).
|
||||
@@ -357,51 +357,13 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// submissionNoCaseNumberPlaceholder fills the bracketed case-number slot
|
||||
// when the project has no Aktenzeichen yet. Kept as a named const so the
|
||||
// wording is one-line changeable (m left the exact text open, t-paliad-354).
|
||||
const submissionNoCaseNumberPlaceholder = "Az. folgt"
|
||||
|
||||
// submissionFileName produces the user-facing download name
|
||||
// (t-paliad-354): "<YYYY-MM-DD> <keyword> (<case number>).docx".
|
||||
//
|
||||
// - Date first (Europe/Berlin) so the files sort chronologically.
|
||||
// - keyword is the user override when set, else the lang-aware rule
|
||||
// name, else "submission".
|
||||
// - The case number is always rendered in parentheses; when the project
|
||||
// has no Aktenzeichen it falls back to submissionNoCaseNumberPlaceholder.
|
||||
//
|
||||
// Each segment is run through SanitiseSubmissionFileName (umlaut-folds for
|
||||
// legacy SMB shares, strips the Windows-reserved set so a case number like
|
||||
// "UPC_CFI_123/2026" stays safe) while the assembled "<date> <kw> (<case>)"
|
||||
// frame keeps its spaces and brackets — the sanitiser preserves both.
|
||||
// (t-paliad-354): "<YYYY-MM-DD> <keyword> (<case number>).docx". The scheme
|
||||
// is now the submission_docx_filename artifact of the pkg/nomen engine; this
|
||||
// remains a thin wrapper so the call-sites and regression tests stay put.
|
||||
// See services.RenderSubmissionFilename (internal/services/namegen.go).
|
||||
func submissionFileName(rule *models.DeadlineRule, project *models.Project, lang, keyword string) string {
|
||||
day := time.Now()
|
||||
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
|
||||
day = day.In(loc)
|
||||
}
|
||||
kw := strings.TrimSpace(keyword)
|
||||
if kw == "" {
|
||||
kw = strings.TrimSpace(rule.Name)
|
||||
if strings.EqualFold(lang, "en") && strings.TrimSpace(rule.NameEN) != "" {
|
||||
kw = strings.TrimSpace(rule.NameEN)
|
||||
}
|
||||
}
|
||||
if kw == "" {
|
||||
kw = "submission"
|
||||
}
|
||||
caseNo := ""
|
||||
if project != nil && project.CaseNumber != nil {
|
||||
caseNo = strings.TrimSpace(*project.CaseNumber)
|
||||
}
|
||||
if caseNo == "" {
|
||||
caseNo = submissionNoCaseNumberPlaceholder
|
||||
}
|
||||
return fmt.Sprintf("%s %s (%s).docx",
|
||||
day.Format("2006-01-02"),
|
||||
services.SanitiseSubmissionFileName(kw),
|
||||
services.SanitiseSubmissionFileName(caseNo),
|
||||
)
|
||||
return services.RenderSubmissionFilename(rule, project, lang, keyword)
|
||||
}
|
||||
|
||||
// submissionFilenameKeyword pulls the user's filename keyword override
|
||||
|
||||
240
internal/services/namegen.go
Normal file
240
internal/services/namegen.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package services
|
||||
|
||||
// Paliad-side wiring for the pkg/nomen composition engine
|
||||
// (docs/plans/prd-filename-generator-2026-06-01.md, Slice 1).
|
||||
//
|
||||
// pkg/nomen stays pure; this file holds the paliad-specific pieces:
|
||||
// - the variable catalogs (which variables each artifact exposes),
|
||||
// - the seed system-default Compositions that reproduce the two shipped
|
||||
// naming schemes byte-for-byte (#155 draft title, t-paliad-354 .docx
|
||||
// filename),
|
||||
// - the per-render VarResolvers built from the existing submission_autoname
|
||||
// helpers (submissionForumShort / submissionOpponentName / derefString),
|
||||
// - and the artifact registry binding artifact -> catalog -> target ->
|
||||
// default.
|
||||
//
|
||||
// The two public entry points (AutoSubmissionTitle here-adjacent, and
|
||||
// RenderSubmissionFilename) render through the registry so the engine is the
|
||||
// single source of truth. Folding the two schemes in as DATA (compositions)
|
||||
// rather than code is the whole point: future levels (user/firm overrides,
|
||||
// non-project degradation) layer on without re-deriving the assembly logic.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/pkg/nomen"
|
||||
)
|
||||
|
||||
// Artifact identifiers. v1 wires the two submission artifacts; further
|
||||
// artifacts (docforge export, data-zip, projection slug — PRD §4) register
|
||||
// alongside their own slice, with their own catalog/resolver, when they opt
|
||||
// in. They are intentionally NOT registered here as placeholders: an
|
||||
// artifact with no resolver and no consumer would be dead code.
|
||||
const (
|
||||
ArtifactSubmissionDraftTitle = "submission_draft_title"
|
||||
ArtifactSubmissionDocxFilename = "submission_docx_filename"
|
||||
)
|
||||
|
||||
// submissionFilenamePlaceholder fills the bracketed case-number slot when the
|
||||
// project has no Aktenzeichen yet (t-paliad-354). Kept as a named const so
|
||||
// the wording stays one-line changeable (m left the exact text open).
|
||||
const submissionFilenamePlaceholder = "Az. folgt"
|
||||
|
||||
// submissionKeywordFallback is the keyword used when neither a user override
|
||||
// nor a rule name resolves (t-paliad-354).
|
||||
const submissionKeywordFallback = "submission"
|
||||
|
||||
// Artifact binds a named output to its variable catalog, render target, and
|
||||
// system-default composition. The catalog drives validation + the settings
|
||||
// palette; the default is the seed used when no override exists.
|
||||
type Artifact struct {
|
||||
ID string
|
||||
Label string
|
||||
LabelEN string
|
||||
Catalog nomen.VarCatalog
|
||||
Target nomen.RenderTarget
|
||||
SystemDefault nomen.Composition
|
||||
}
|
||||
|
||||
// nameArtifacts is the v1 registry. Lookup via NameArtifact.
|
||||
var nameArtifacts = map[string]Artifact{
|
||||
ArtifactSubmissionDraftTitle: {
|
||||
ID: ArtifactSubmissionDraftTitle,
|
||||
Label: "Entwurfstitel",
|
||||
LabelEN: "Draft title",
|
||||
Catalog: submissionTitleCatalog(),
|
||||
Target: nomen.PlainTarget("title"),
|
||||
SystemDefault: submissionDraftTitleComposition(),
|
||||
},
|
||||
ArtifactSubmissionDocxFilename: {
|
||||
ID: ArtifactSubmissionDocxFilename,
|
||||
Label: "Dateiname (.docx)",
|
||||
LabelEN: "File name (.docx)",
|
||||
Catalog: submissionFilenameCatalog(),
|
||||
Target: nomen.FuncTarget{
|
||||
NameVal: "filename",
|
||||
Sanitiser: SanitiseSubmissionFileName,
|
||||
Suffix: ".docx",
|
||||
},
|
||||
SystemDefault: submissionDocxFilenameComposition(),
|
||||
},
|
||||
}
|
||||
|
||||
// NameArtifact returns the registered artifact for id, or (zero, false).
|
||||
func NameArtifact(id string) (Artifact, bool) {
|
||||
a, ok := nameArtifacts[id]
|
||||
return a, ok
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed compositions (the two shipped schemes, as data — PRD §5).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// submissionDraftTitleComposition reproduces AutoSubmissionTitle (#155):
|
||||
//
|
||||
// <date> <client> ./. <forum> ./. <opponent>
|
||||
//
|
||||
// Trailing separators: the date joins the identity block with a space, the
|
||||
// identity segments join each other with " ./. ". Because separators are
|
||||
// owned by the left segment, dropping any identity segment (or all of them)
|
||||
// still yields the byte-exact original — e.g. client-absent renders
|
||||
// "<date> <forum> ./. <opponent>" with a single space after the date.
|
||||
func submissionDraftTitleComposition() nomen.Composition {
|
||||
return nomen.Composition{
|
||||
Version: nomen.Version,
|
||||
Segments: []nomen.Segment{
|
||||
{Var: "date", Sep: " ", Missing: nomen.Omit()},
|
||||
{Var: "client", Sep: " ./. ", Missing: nomen.Omit()},
|
||||
{Var: "forum", Sep: " ./. ", Missing: nomen.Omit()},
|
||||
{Var: "opponent", Sep: "", Missing: nomen.Omit()},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// submissionDocxFilenameComposition reproduces submissionFileName (354):
|
||||
//
|
||||
// <date> <keyword> (<case number>).docx
|
||||
//
|
||||
// keyword falls back to a fixed "submission" literal; the case number is
|
||||
// always rendered in parentheses, falling back to a placeholder when the
|
||||
// project has no Aktenzeichen. The .docx suffix and per-value sanitisation
|
||||
// come from the artifact's FuncTarget, not the composition.
|
||||
func submissionDocxFilenameComposition() nomen.Composition {
|
||||
return nomen.Composition{
|
||||
Version: nomen.Version,
|
||||
Segments: []nomen.Segment{
|
||||
{Var: "date", Sep: " ", Missing: nomen.Omit()},
|
||||
{Var: "keyword", Sep: " ", Missing: nomen.Literal(submissionKeywordFallback)},
|
||||
{Var: "case_number", Sep: "", Wrap: [2]string{"(", ")"}, Missing: nomen.Placeholder(submissionFilenamePlaceholder)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variable catalogs.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func submissionTitleCatalog() nomen.VarCatalog {
|
||||
return nomen.VarCatalog{
|
||||
"date": {Key: "date", Label: "Datum", LabelEN: "Date", Group: "common", Description: "Aktuelles Datum (Europe/Berlin), JJJJ-MM-TT"},
|
||||
"client": {Key: "client", Label: "Mandant", LabelEN: "Client", Group: "parties", Description: "Name des Mandanten (Wurzel der Akte)"},
|
||||
"forum": {Key: "forum", Label: "Forum", LabelEN: "Forum", Group: "proceeding", Description: "Kurzbezeichnung des Forums (UPC, EPA, LG, …)"},
|
||||
"opponent": {Key: "opponent", Label: "Gegner", LabelEN: "Opponent", Group: "parties", Description: "Name der Gegenseite"},
|
||||
}
|
||||
}
|
||||
|
||||
func submissionFilenameCatalog() nomen.VarCatalog {
|
||||
return nomen.VarCatalog{
|
||||
"date": {Key: "date", Label: "Datum", LabelEN: "Date", Group: "common", Description: "Aktuelles Datum (Europe/Berlin), JJJJ-MM-TT"},
|
||||
"keyword": {Key: "keyword", Label: "Stichwort", LabelEN: "Keyword", Group: "document", Description: "Dokument-/Schriftsatztyp; überschreibbar"},
|
||||
"case_number": {Key: "case_number", Label: "Aktenzeichen", LabelEN: "Case number", Group: "proceeding", Description: "Aktenzeichen des Verfahrens"},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolvers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// nomenDateBerlin formats t as the JJJJ-MM-TT date in Europe/Berlin,
|
||||
// matching both shipped schemes. A failed zone load leaves t untouched
|
||||
// (same fallback the original code used).
|
||||
func nomenDateBerlin(t time.Time) string {
|
||||
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
|
||||
t = t.In(loc)
|
||||
}
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
|
||||
// submissionTitleResolver yields the draft-title variables. now is injected
|
||||
// (tests pin a fixed instant); the three identity segments resolve from the
|
||||
// existing helpers and report absence so the composition's Omit rule drops
|
||||
// them.
|
||||
func submissionTitleResolver(now time.Time, clientName string, project *models.Project, parties []models.Party, pt *models.ProceedingType) nomen.VarResolver {
|
||||
return func(key string) (string, bool) {
|
||||
switch key {
|
||||
case "date":
|
||||
return nomenDateBerlin(now), true
|
||||
case "client":
|
||||
c := strings.TrimSpace(clientName)
|
||||
return c, c != ""
|
||||
case "forum":
|
||||
f := submissionForumShort(pt)
|
||||
return f, f != ""
|
||||
case "opponent":
|
||||
ourSide := ""
|
||||
if project != nil {
|
||||
ourSide = derefString(project.OurSide)
|
||||
}
|
||||
o := submissionOpponentName(parties, ourSide)
|
||||
return o, o != ""
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// submissionFilenameResolver yields the .docx-filename variables. The date is
|
||||
// render-time "today" (the original used time.Now()); keyword applies the
|
||||
// override -> lang-aware rule name precedence and reports absence so the
|
||||
// composition's "submission" literal kicks in; case_number reports absence so
|
||||
// the "(Az. folgt)" placeholder kicks in.
|
||||
func submissionFilenameResolver(rule *models.DeadlineRule, project *models.Project, lang, keyword string) nomen.VarResolver {
|
||||
return func(key string) (string, bool) {
|
||||
switch key {
|
||||
case "date":
|
||||
return nomenDateBerlin(time.Now()), true
|
||||
case "keyword":
|
||||
kw := strings.TrimSpace(keyword)
|
||||
if kw == "" && rule != nil {
|
||||
kw = strings.TrimSpace(rule.Name)
|
||||
if strings.EqualFold(lang, "en") && strings.TrimSpace(rule.NameEN) != "" {
|
||||
kw = strings.TrimSpace(rule.NameEN)
|
||||
}
|
||||
}
|
||||
return kw, kw != ""
|
||||
case "case_number":
|
||||
if project != nil && project.CaseNumber != nil {
|
||||
c := strings.TrimSpace(*project.CaseNumber)
|
||||
if c != "" {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// RenderSubmissionFilename produces the user-facing download name for a
|
||||
// generated submission (t-paliad-354), rendered through the nomen engine:
|
||||
// "<JJJJ-MM-TT> <keyword> (<case number>).docx". keyword is the user override
|
||||
// when set, else the lang-aware rule name, else "submission"; the case number
|
||||
// falls back to "(Az. folgt)" when the project has no Aktenzeichen. Each
|
||||
// variable value is sanitised for SMB-safe filenames while the frame (spaces,
|
||||
// parentheses, .docx) is preserved.
|
||||
func RenderSubmissionFilename(rule *models.DeadlineRule, project *models.Project, lang, keyword string) string {
|
||||
art := nameArtifacts[ArtifactSubmissionDocxFilename]
|
||||
resolve := submissionFilenameResolver(rule, project, lang, keyword)
|
||||
return art.SystemDefault.Render(resolve, art.Target)
|
||||
}
|
||||
34
internal/services/namegen_test.go
Normal file
34
internal/services/namegen_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package services
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestNameArtifactsValidate guards the seed system-default compositions
|
||||
// against their own catalogs — a typo'd variable in a seed composition (a key
|
||||
// the catalog doesn't declare) fails here rather than silently rendering
|
||||
// nothing in production.
|
||||
func TestNameArtifactsValidate(t *testing.T) {
|
||||
for id, art := range nameArtifacts {
|
||||
if art.ID != id {
|
||||
t.Errorf("artifact %q has mismatched ID %q", id, art.ID)
|
||||
}
|
||||
if art.Target == nil {
|
||||
t.Errorf("artifact %q has nil target", id)
|
||||
}
|
||||
if err := art.SystemDefault.Validate(art.Catalog); err != nil {
|
||||
t.Errorf("artifact %q system default invalid: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNameArtifactLookup covers the registry accessor.
|
||||
func TestNameArtifactLookup(t *testing.T) {
|
||||
if _, ok := NameArtifact(ArtifactSubmissionDraftTitle); !ok {
|
||||
t.Errorf("draft-title artifact not registered")
|
||||
}
|
||||
if _, ok := NameArtifact(ArtifactSubmissionDocxFilename); !ok {
|
||||
t.Errorf("docx-filename artifact not registered")
|
||||
}
|
||||
if _, ok := NameArtifact("nonexistent"); ok {
|
||||
t.Errorf("lookup of unknown artifact returned ok")
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,13 @@ package services
|
||||
// a project-less draft never reaches this path at all (it keeps the
|
||||
// "Entwurf N" counter — see SubmissionDraftService.Create).
|
||||
//
|
||||
// v1.1 customization hook: the template is hardcoded here in v1. When m
|
||||
// promotes naming to a per-user / per-firm / per-base setting (issue
|
||||
// #155 Q4), the override string lands as an extra parameter on
|
||||
// AutoSubmissionTitle (or a small template struct) and the segment
|
||||
// resolvers below stay as the value source. Nothing else needs to move.
|
||||
// v1 promotes this scheme into the pkg/nomen composition engine: the
|
||||
// template lives as the submission_draft_title artifact's system-default
|
||||
// Composition (see namegen.go, PRD §5.1) and the identity resolvers below
|
||||
// stay as the value source. AutoSubmissionTitle is now a thin wrapper that
|
||||
// renders that composition; the assembly logic (separators, missing-segment
|
||||
// rules) is the engine's. Per-user / per-firm overrides (Slices 3–5) layer
|
||||
// onto the artifact without touching this file.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -30,10 +32,6 @@ import (
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// submissionTitleSep is the separator between identity segments —
|
||||
// " ./. " is the German legal convention for "gegen" / "versus".
|
||||
const submissionTitleSep = " ./. "
|
||||
|
||||
// AutoSubmissionTitle assembles the auto-generated draft title from the
|
||||
// resolved identity pieces. Pure and table-testable — every DB hop
|
||||
// happens in the caller (SubmissionDraftService.autoNameForProject).
|
||||
@@ -44,35 +42,13 @@ const submissionTitleSep = " ./. "
|
||||
// the proceeding type both come off the draft's project node, the
|
||||
// parties hang directly off it.
|
||||
//
|
||||
// The date is always present (formatted in Europe/Berlin to match the
|
||||
// today.* render vars); the three identity segments are appended only
|
||||
// when non-empty.
|
||||
// The date is always present (formatted in Europe/Berlin); the three
|
||||
// identity segments are appended only when non-empty. Rendered through the
|
||||
// submission_draft_title artifact (namegen.go).
|
||||
func AutoSubmissionTitle(now time.Time, clientName string, project *models.Project, parties []models.Party, pt *models.ProceedingType) string {
|
||||
loc, _ := time.LoadLocation("Europe/Berlin")
|
||||
if loc != nil {
|
||||
now = now.In(loc)
|
||||
}
|
||||
date := now.Format("2006-01-02")
|
||||
|
||||
segments := make([]string, 0, 3)
|
||||
if c := strings.TrimSpace(clientName); c != "" {
|
||||
segments = append(segments, c)
|
||||
}
|
||||
if f := submissionForumShort(pt); f != "" {
|
||||
segments = append(segments, f)
|
||||
}
|
||||
ourSide := ""
|
||||
if project != nil {
|
||||
ourSide = derefString(project.OurSide)
|
||||
}
|
||||
if o := submissionOpponentName(parties, ourSide); o != "" {
|
||||
segments = append(segments, o)
|
||||
}
|
||||
|
||||
if len(segments) == 0 {
|
||||
return date
|
||||
}
|
||||
return date + " " + strings.Join(segments, submissionTitleSep)
|
||||
art := nameArtifacts[ArtifactSubmissionDraftTitle]
|
||||
resolve := submissionTitleResolver(now, clientName, project, parties, pt)
|
||||
return art.SystemDefault.Render(resolve, art.Target)
|
||||
}
|
||||
|
||||
// submissionForumShort maps a proceeding type to the short forum label
|
||||
|
||||
228
pkg/nomen/nomen.go
Normal file
228
pkg/nomen/nomen.go
Normal file
@@ -0,0 +1,228 @@
|
||||
// Package nomen renders human- and machine-facing names from a reusable
|
||||
// composition model (Latin nomen, "name"). It is the engine extracted from
|
||||
// the one-off naming functions that shipped for submission draft titles
|
||||
// (m/paliad#155) and exported .docx filenames (t-paliad-354); see
|
||||
// docs/plans/prd-filename-generator-2026-06-01.md.
|
||||
//
|
||||
// The package is pure: no DB, no HTTP, no filesystem, and no dependency on
|
||||
// the rest of paliad. A consumer supplies a Composition (the template), a
|
||||
// VarResolver (the values for this render), and a RenderTarget (the output
|
||||
// policy — a human title vs a sanitised filename). The same Composition
|
||||
// renders to different targets.
|
||||
//
|
||||
// # Separator semantics (trailing, not leading)
|
||||
//
|
||||
// Each Segment carries a Sep that is the separator emitted AFTER it, and
|
||||
// only when a later segment also emits. So the separator between two
|
||||
// consecutive emitted segments is owned by the LEFT segment. This is what
|
||||
// lets a composition stay byte-faithful when a middle segment drops out:
|
||||
// the draft-title scheme joins the date to the party trio with a space and
|
||||
// the parties to each other with " ./. ", and when the client is absent the
|
||||
// date must still join the forum with a space — which only works if the
|
||||
// space is the date's trailing separator, independent of which identity
|
||||
// segment happens to come next. A leading-separator model can't express
|
||||
// that (the same segment would need two different leading separators
|
||||
// depending on what was omitted before it).
|
||||
package nomen
|
||||
|
||||
import "strings"
|
||||
|
||||
// Version is the current Composition schema version. Stored compositions
|
||||
// (firm/user overrides, once those land) carry it so a future change can be
|
||||
// detected and migrated; the seed system defaults always use this value.
|
||||
const Version = 1
|
||||
|
||||
// MaxSegments is a sanity cap on how many segments a single composition may
|
||||
// have. The wired artifacts use 3–4; the cap exists so a stored override
|
||||
// can't smuggle an unbounded blob through Validate.
|
||||
const MaxSegments = 16
|
||||
|
||||
// MissingKind selects what a segment contributes when its variable resolves
|
||||
// empty or unavailable.
|
||||
type MissingKind int
|
||||
|
||||
const (
|
||||
// KindOmit drops the segment entirely (and suppresses its trailing
|
||||
// separator). Generalises the #155 "drop empty segment with its
|
||||
// separator" rule.
|
||||
KindOmit MissingKind = iota
|
||||
// KindPlaceholder substitutes a stand-in value for missing data, e.g.
|
||||
// "(Az. folgt)" for an as-yet-unknown case number (t-paliad-354).
|
||||
KindPlaceholder
|
||||
// KindLiteral substitutes a fixed label. Functionally identical to a
|
||||
// placeholder today, but kept distinct so the settings UI can word them
|
||||
// differently ("fixed label" vs "stand-in for missing data") and so
|
||||
// future policy can diverge.
|
||||
KindLiteral
|
||||
)
|
||||
|
||||
// MissingRule is a segment's missing-value policy. Value is ignored for
|
||||
// KindOmit.
|
||||
type MissingRule struct {
|
||||
Kind MissingKind
|
||||
Value string
|
||||
}
|
||||
|
||||
// Omit returns a drop-when-empty rule.
|
||||
func Omit() MissingRule { return MissingRule{Kind: KindOmit} }
|
||||
|
||||
// Placeholder returns a substitute-when-empty rule for missing data.
|
||||
func Placeholder(v string) MissingRule { return MissingRule{Kind: KindPlaceholder, Value: v} }
|
||||
|
||||
// Literal returns a substitute-when-empty rule for a fixed label.
|
||||
func Literal(v string) MissingRule { return MissingRule{Kind: KindLiteral, Value: v} }
|
||||
|
||||
// Segment is one piece of a composition.
|
||||
type Segment struct {
|
||||
// Var is the variable key resolved against the catalog/resolver.
|
||||
Var string
|
||||
// Sep is the trailing separator: emitted AFTER this segment iff a later
|
||||
// segment also emits. The last emitted segment's Sep is never used.
|
||||
Sep string
|
||||
// Wrap surrounds the resolved value with fixed literals, e.g.
|
||||
// {"(", ")"} for a bracketed case number. The wrap is part of the frame:
|
||||
// it is NOT passed through the target's value sanitiser.
|
||||
Wrap [2]string
|
||||
// Missing is the policy applied when Var resolves empty/unavailable.
|
||||
Missing MissingRule
|
||||
}
|
||||
|
||||
// Composition is the canonical, validated name template: an ordered list of
|
||||
// segments plus a schema version.
|
||||
type Composition struct {
|
||||
Version int
|
||||
Segments []Segment
|
||||
}
|
||||
|
||||
// VarResolver yields a variable's value for one render. It returns
|
||||
// (value, true) when the variable is available (even if the consumer wants
|
||||
// to force it empty by returning ("", true) — though the engine treats a
|
||||
// blank value as absent regardless), and ("", false) when the variable is
|
||||
// unavailable in this context, in which case the segment's MissingRule
|
||||
// applies.
|
||||
type VarResolver func(key string) (value string, ok bool)
|
||||
|
||||
// RenderTarget post-processes a render. SanitiseValue runs per resolved
|
||||
// variable value (before wrapping/assembly); Finalise runs once on the
|
||||
// fully-assembled string (e.g. to append an extension).
|
||||
type RenderTarget interface {
|
||||
Name() string
|
||||
SanitiseValue(v string) string
|
||||
Finalise(assembled string) string
|
||||
}
|
||||
|
||||
// Render assembles the name. For each segment in order it resolves the
|
||||
// value (applying the MissingRule when empty), sanitises the value via the
|
||||
// target, wraps it, and joins it to the previous emitted segment using that
|
||||
// previous segment's trailing Sep. The assembled string is passed once
|
||||
// through Finalise.
|
||||
func (c Composition) Render(resolve VarResolver, target RenderTarget) string {
|
||||
var b strings.Builder
|
||||
var pendingSep string
|
||||
emitted := false
|
||||
for _, seg := range c.Segments {
|
||||
val, ok := effectiveValue(seg, resolve)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
val = target.SanitiseValue(val)
|
||||
piece := seg.Wrap[0] + val + seg.Wrap[1]
|
||||
if emitted {
|
||||
b.WriteString(pendingSep)
|
||||
}
|
||||
b.WriteString(piece)
|
||||
pendingSep = seg.Sep
|
||||
emitted = true
|
||||
}
|
||||
return target.Finalise(b.String())
|
||||
}
|
||||
|
||||
// effectiveValue resolves a segment to its emitted value, applying the
|
||||
// MissingRule. The second return is false when the segment contributes
|
||||
// nothing (omit, or a placeholder/literal whose value is itself blank).
|
||||
// A resolved value is trimmed; a blank resolved value is treated as absent.
|
||||
func effectiveValue(seg Segment, resolve VarResolver) (string, bool) {
|
||||
val, ok := resolve(seg.Var)
|
||||
val = strings.TrimSpace(val)
|
||||
if ok && val != "" {
|
||||
return val, true
|
||||
}
|
||||
switch seg.Missing.Kind {
|
||||
case KindPlaceholder, KindLiteral:
|
||||
v := strings.TrimSpace(seg.Missing.Value)
|
||||
if v == "" {
|
||||
return "", false
|
||||
}
|
||||
return v, true
|
||||
default: // KindOmit
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// VarDef is a variable's catalog metadata: it drives write-time validation
|
||||
// and the settings palette. Values come from the per-render VarResolver, not
|
||||
// from here — the catalog is metadata only.
|
||||
type VarDef struct {
|
||||
Key string
|
||||
Label string // DE primary
|
||||
LabelEN string
|
||||
Description string
|
||||
Group string
|
||||
}
|
||||
|
||||
// VarCatalog is the set of variables available to an artifact, keyed by Var.
|
||||
type VarCatalog map[string]VarDef
|
||||
|
||||
// Validate enforces the structural invariants on a composition against the
|
||||
// catalog of an artifact. Used on writes (stored firm/user overrides). The
|
||||
// seed system defaults are validated by a unit test so a typo can't ship.
|
||||
func (c Composition) Validate(catalog VarCatalog) error {
|
||||
if c.Version != Version {
|
||||
return &ValidationError{Msg: "unsupported composition version"}
|
||||
}
|
||||
if len(c.Segments) > MaxSegments {
|
||||
return &ValidationError{Msg: "too many segments"}
|
||||
}
|
||||
for _, seg := range c.Segments {
|
||||
if strings.TrimSpace(seg.Var) == "" {
|
||||
return &ValidationError{Msg: "segment has empty variable"}
|
||||
}
|
||||
if _, ok := catalog[seg.Var]; !ok {
|
||||
return &ValidationError{Msg: "unknown variable: " + seg.Var}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidationError is returned by Composition.Validate. It is a distinct type
|
||||
// so consumers can map it to a 400 without string-matching.
|
||||
type ValidationError struct{ Msg string }
|
||||
|
||||
func (e *ValidationError) Error() string { return "nomen: " + e.Msg }
|
||||
|
||||
// FuncTarget is the general RenderTarget: an optional per-value sanitiser
|
||||
// and a fixed suffix appended on finalise. A zero FuncTarget (nil sanitiser,
|
||||
// empty suffix) is an identity target suitable for human titles.
|
||||
type FuncTarget struct {
|
||||
NameVal string
|
||||
Sanitiser func(string) string
|
||||
Suffix string
|
||||
}
|
||||
|
||||
// Name reports the target name (e.g. "title", "filename").
|
||||
func (t FuncTarget) Name() string { return t.NameVal }
|
||||
|
||||
// SanitiseValue applies the per-value sanitiser, or is identity when none.
|
||||
func (t FuncTarget) SanitiseValue(v string) string {
|
||||
if t.Sanitiser == nil {
|
||||
return v
|
||||
}
|
||||
return t.Sanitiser(v)
|
||||
}
|
||||
|
||||
// Finalise appends the target's suffix to the assembled string.
|
||||
func (t FuncTarget) Finalise(assembled string) string { return assembled + t.Suffix }
|
||||
|
||||
// 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} }
|
||||
115
pkg/nomen/nomen_test.go
Normal file
115
pkg/nomen/nomen_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package nomen
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mapResolver builds a VarResolver from a map: a present key (even empty) is
|
||||
// reported present only when its value is non-blank, matching the engine's
|
||||
// blank-is-absent contract.
|
||||
func mapResolver(m map[string]string) VarResolver {
|
||||
return func(key string) (string, bool) {
|
||||
v, ok := m[key]
|
||||
return v, ok
|
||||
}
|
||||
}
|
||||
|
||||
// upperSanitiser is a stand-in per-value transform used to prove SanitiseValue
|
||||
// runs on values but not on separators or wraps.
|
||||
func upperSanitiser(s string) string { return strings.ToUpper(s) }
|
||||
|
||||
func TestRender_TrailingSeparators(t *testing.T) {
|
||||
// date joins with " ", parties join with " ./. " — the draft-title shape.
|
||||
comp := Composition{Version: Version, Segments: []Segment{
|
||||
{Var: "date", Sep: " ", Missing: Omit()},
|
||||
{Var: "client", Sep: " ./. ", Missing: Omit()},
|
||||
{Var: "forum", Sep: " ./. ", Missing: Omit()},
|
||||
{Var: "opponent", Sep: "", Missing: Omit()},
|
||||
}}
|
||||
cases := []struct {
|
||||
name string
|
||||
vars map[string]string
|
||||
want string
|
||||
}{
|
||||
{"all present", map[string]string{"date": "2026-05-31", "client": "Bayer AG", "forum": "UPC", "opponent": "Novartis"}, "2026-05-31 Bayer AG ./. UPC ./. Novartis"},
|
||||
{"client absent — date joins forum with a space", map[string]string{"date": "2026-05-31", "forum": "UPC", "opponent": "Novartis"}, "2026-05-31 UPC ./. Novartis"},
|
||||
{"only opponent absent", map[string]string{"date": "2026-05-31", "client": "Bayer AG", "forum": "UPC"}, "2026-05-31 Bayer AG ./. UPC"},
|
||||
{"forum absent", map[string]string{"date": "2026-05-31", "client": "Bayer AG", "opponent": "Acme"}, "2026-05-31 Bayer AG ./. Acme"},
|
||||
{"date only", map[string]string{"date": "2026-05-31"}, "2026-05-31"},
|
||||
{"blank value treated as absent", map[string]string{"date": "2026-05-31", "client": " ", "forum": "UPC"}, "2026-05-31 UPC"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := comp.Render(mapResolver(c.vars), PlainTarget("title"))
|
||||
if got != c.want {
|
||||
t.Errorf("Render = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_MissingRulesAndTargets(t *testing.T) {
|
||||
// The filename shape: keyword literal fallback, case placeholder + wrap,
|
||||
// a sanitiser + suffix target.
|
||||
comp := Composition{Version: Version, Segments: []Segment{
|
||||
{Var: "date", Sep: " ", Missing: Omit()},
|
||||
{Var: "keyword", Sep: " ", Missing: Literal("submission")},
|
||||
{Var: "case_number", Sep: "", Wrap: [2]string{"(", ")"}, Missing: Placeholder("Az. folgt")},
|
||||
}}
|
||||
target := FuncTarget{NameVal: "filename", Sanitiser: upperSanitiser, Suffix: ".docx"}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
vars map[string]string
|
||||
want string
|
||||
}{
|
||||
{"all present — value sanitised, frame preserved", map[string]string{"date": "2026-05-31", "keyword": "Replik", "case_number": "x/y"}, "2026-05-31 REPLIK (X/Y).docx"},
|
||||
{"keyword empty → literal fallback (also sanitised)", map[string]string{"date": "2026-05-31", "case_number": "abc"}, "2026-05-31 SUBMISSION (ABC).docx"},
|
||||
{"case empty → placeholder, wrapped", map[string]string{"date": "2026-05-31", "keyword": "Replik"}, "2026-05-31 REPLIK (AZ. FOLGT).docx"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := comp.Render(mapResolver(c.vars), target)
|
||||
if got != c.want {
|
||||
t.Errorf("Render = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_EmptyPlaceholderOmits(t *testing.T) {
|
||||
// A placeholder/literal whose value is itself blank contributes nothing
|
||||
// (and suppresses its trailing separator).
|
||||
comp := Composition{Version: Version, Segments: []Segment{
|
||||
{Var: "a", Sep: "-", Missing: Placeholder(" ")},
|
||||
{Var: "b", Sep: "", Missing: Omit()},
|
||||
}}
|
||||
got := comp.Render(mapResolver(map[string]string{"b": "tail"}), PlainTarget("x"))
|
||||
if got != "tail" {
|
||||
t.Errorf("Render = %q, want %q", got, "tail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
cat := VarCatalog{"date": {Key: "date"}, "client": {Key: "client"}}
|
||||
ok := Composition{Version: Version, Segments: []Segment{{Var: "date"}, {Var: "client"}}}
|
||||
if err := ok.Validate(cat); err != nil {
|
||||
t.Fatalf("valid composition rejected: %v", err)
|
||||
}
|
||||
bad := []struct {
|
||||
name string
|
||||
comp Composition
|
||||
}{
|
||||
{"wrong version", Composition{Version: 0, Segments: []Segment{{Var: "date"}}}},
|
||||
{"unknown var", Composition{Version: Version, Segments: []Segment{{Var: "nope"}}}},
|
||||
{"empty var", Composition{Version: Version, Segments: []Segment{{Var: " "}}}},
|
||||
}
|
||||
for _, c := range bad {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if err := c.comp.Validate(cat); err == nil {
|
||||
t.Errorf("expected validation error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user