Compare commits
12 Commits
mai/maxwel
...
a2da501917
| Author | SHA1 | Date | |
|---|---|---|---|
| a2da501917 | |||
| 8ea78fd376 | |||
| e189d3fe6a | |||
| 58907554fc | |||
| 9b8a865c5f | |||
| f8067c2fe5 | |||
| 78a30a7ee0 | |||
| 091804923a | |||
| 9201501941 | |||
| 05247d7bd7 | |||
| a81581878e | |||
| 8d8a882f46 |
495
docs/plans/prd-docforge-2026-05-29.md
Normal file
495
docs/plans/prd-docforge-2026-05-29.md
Normal file
@@ -0,0 +1,495 @@
|
||||
# PRD — `docforge`: a modular document-generator engine
|
||||
|
||||
**Task:** t-paliad-349 (m/paliad#157) · **Author:** leibniz (inventor) · **Date:** 2026-05-29
|
||||
**Status:** DESIGN — awaiting head's go/no-go on the coder shift.
|
||||
**Supersedes nothing.** Extends and re-homes the submission generator designed in
|
||||
`docs/design-submission-generator-2026-05-19.md`, `…-v2-2026-05-26.md`, and
|
||||
`docs/design-submission-page-2026-05-22.md`.
|
||||
|
||||
---
|
||||
|
||||
## §0 Premises
|
||||
|
||||
### 0.1 What this is
|
||||
|
||||
m wants the paliad "doc generator" pulled apart into a clean, reusable engine.
|
||||
Verbatim direction (2026-05-29):
|
||||
|
||||
> I want to be able to create and modify word documents, using variables inside
|
||||
> the documents, "editing them live" and preview the results, export in the end.
|
||||
> We should have all that modular to keep it clean. The editor is something else
|
||||
> than the importing, exporting, variable exchange, data fetching etc.
|
||||
>
|
||||
> Currently I can't upload the base document to insert variables into to create a
|
||||
> template — and then later I want to fill the template using data, modifying it
|
||||
> manually where necessary, then exporting.
|
||||
|
||||
Two distinct user surfaces fall out of that:
|
||||
|
||||
- **Authoring** — upload a base `.docx` → place variable slots into it → save as a
|
||||
reusable template. *This is the gap that does not exist today.*
|
||||
- **Generation** — pick a template → bind variables to project data → manually edit
|
||||
where needed (live editor + preview) → export `.docx`.
|
||||
|
||||
### 0.2 Today's state (audited 2026-05-29, verified against the live tree)
|
||||
|
||||
The current submission generator is ~250 KB of Go plus a 115 KB editor bundle:
|
||||
|
||||
- `internal/services/submission_vars.go` — variable resolution across **7 namespaces**
|
||||
(`firm.*`, `today.*`, `user.*`, `project.*`, `parties.*`, `procedural_event.*`
|
||||
+ `rule.*` legacy aliases, `deadline.*`). Resolution is a **push** model: each
|
||||
namespace is a hardcoded `addXxxVars(bag PlaceholderMap, …)` function mutating a
|
||||
shared `map[string]string`. There is **no interface and no registry** — adding a
|
||||
namespace means hand-editing `Build` to call a new function.
|
||||
- `internal/services/submission_merge.go` — placeholder substitution. The regex
|
||||
(line 95, verified) is `\{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\}`.
|
||||
Two-pass: single-run replace inside each `<w:t>`, then
|
||||
cross-run merge for fragmented placeholders. HTML preview wraps `(key,value)` in
|
||||
Private-Use-Area sentinels so `emitTextWithDraftVars` can reconstruct
|
||||
`<span class="draft-var" data-var="key">…</span>` for click-to-jump.
|
||||
- `internal/services/submission_md.go` — Markdown → OOXML runs. `parseInlineSpans`
|
||||
(lines 393–446) tokenises bold/italic and **preserves `{{…}}` verbatim**.
|
||||
- `internal/services/submission_compose.go` — assembles the final `.docx`: unzip base,
|
||||
render each included section's Markdown to OOXML, splice between
|
||||
`{{#section:KEY}}…{{/section:KEY}}` anchors, patch hyperlink rels, repack, then run
|
||||
the placeholder pass.
|
||||
- `internal/services/submission_{draft,section,building_block,base}_service.go` — the
|
||||
draft/section/building-block/base data model + CRUD.
|
||||
- `internal/handlers/submission_{drafts,sections,building_blocks,bases}.go` — the HTTP
|
||||
wire (the 53 KB `submission_drafts.go` is the bulk).
|
||||
- `frontend/src/client/submission-draft.ts` — the editor UI (**one `.ts` bundle; there is
|
||||
no `submission-draft.tsx`** — the brief was wrong on this point).
|
||||
|
||||
**OOXML approach (verified):** pure `archive/zip` + string manipulation of
|
||||
`word/document.xml`. **No third-party docx library** — `go.mod` has none.
|
||||
`lukasjarosch/go-docx` appears *only in a comment* (`submission_merge.go:13`)
|
||||
documenting why it was rejected (it refuses sibling placeholders in one run). The base
|
||||
stays byte-for-byte identical outside the regions we touch.
|
||||
|
||||
**Reference model:** `pkg/litigationplanner/` (t-paliad-292). The package **owns its
|
||||
types** and exposes **interfaces for stateful inputs** (`Catalog`, `HolidayCalendar`,
|
||||
`CourtRegistry`); paliad implements them against Postgres, youpc.org against an embedded
|
||||
JSON snapshot. `doc.go` is the package doc; `types_wire_test.go` locks the JSON contract.
|
||||
**docforge mirrors this packaging discipline exactly.**
|
||||
|
||||
### 0.3 Premise correction (load-bearing)
|
||||
|
||||
The brief lists **two consumers in scope: paliad + upc-commentary**. Verified against the
|
||||
live repo: **`UPCommentary/upc-kommentar` is Bun + SvelteKit + TypeScript + PLpgSQL —
|
||||
zero Go.** A SvelteKit app cannot `import` a Go `pkg/`. m's resolution (2026-05-29):
|
||||
**upc-kommentar is out of scope as a live consumer for now.** docforge is a pure Go
|
||||
package; paliad imports it in-process like `litigationplanner`. The interfaces are
|
||||
designed so an HTTP veneer (for a future TS consumer) is *addable later* without rework —
|
||||
but none is built now. See §4 D-P1 and §8.
|
||||
|
||||
### 0.4 Locked constraints (m, confirmed)
|
||||
|
||||
- One Go module: `pkg/docforge`. Same packaging model as `pkg/litigationplanner`.
|
||||
- docforge **owns no database tables** — data flows in via interfaces.
|
||||
- `.docx` first; engine designed format-pluggable for `.pdf`/`.html`/`.md` later.
|
||||
- Authoring and Generation are **distinct pages**, but share the engine + the generic
|
||||
editor plumbing.
|
||||
- Generation must support **minor manual content edits** (live editor, not just
|
||||
data-binding).
|
||||
- Editor stays per-consumer; the **generic UX plumbing** is extracted into a reusable UI
|
||||
package now.
|
||||
- The neutral model must be **lossless for our own `.docx`** (the uploaded base is an
|
||||
opaque carrier, preserved byte-for-byte outside touched regions).
|
||||
|
||||
### 0.5 Contracts that MUST survive the refactor
|
||||
|
||||
These are invariants. The migration (§6) protects each by moving it *with its file and its
|
||||
test*, unchanged:
|
||||
|
||||
1. **`placeholderRegex`** = `` `\{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\}` `` — underscores
|
||||
and dots legal in keys; whitespace inside braces trimmed; case-sensitive.
|
||||
2. **Last night's underscore fix** (commit `b78a984`): `parseInlineSpans` short-circuits
|
||||
the inline scanner on `{{` and copies the placeholder literally to `}}`, so
|
||||
`{{project.case_number}}` is never mangled to `{{project.casenumber}}`.
|
||||
3. **`data-var` contract** — `data-var="<key>"` on both `.draft-var` preview spans and
|
||||
`.submission-draft-var-input` sidebar inputs; the click-to-jump and focus-highlight are
|
||||
bijective across repaints.
|
||||
4. **Missing-value markers** — `[KEIN WERT: key]` (DE) / `[NO VALUE: key]` (EN) render
|
||||
inline, never an error.
|
||||
5. **Legacy aliases** — `procedural_event.X ≡ rule.X` resolve identically
|
||||
(`submission_vars_aliases_test.go`); party variables emit comma-joined, indexed, and
|
||||
flat-legacy forms (`submission_vars_parties_test.go`).
|
||||
6. **Section anchor syntax** — `{{#section:KEY}}…{{/section:KEY}}`, `KEY` matched against
|
||||
`[A-Za-z0-9_]+`.
|
||||
7. **No binary retention** — exported `.docx` is regenerable from inputs; only audit rows
|
||||
persist (`system_audit_log` `submission.exported` + `project_events`).
|
||||
8. **V1 fallback path** — pre-Composer drafts (`base_id IS NULL`, no section rows) render
|
||||
via the pure-placeholder path. No auto-upgrade.
|
||||
9. **`{{…}}` pass-through** — the Markdown walker emits placeholders verbatim; the merge
|
||||
pass substitutes them afterward. Order is load-bearing (substitution runs *inside*
|
||||
compose, after section splicing).
|
||||
|
||||
---
|
||||
|
||||
## §1 Goals
|
||||
|
||||
**G1.** Extract the format-neutral document machinery (Markdown→OOXML walker, OOXML
|
||||
merge/compose, placeholder engine, `.dotm`→`.docx`) into `pkg/docforge` with a clean
|
||||
public surface and zero behavior change at the extraction step.
|
||||
|
||||
**G2.** Introduce a **neutral document/template model** so importers produce it, the engine
|
||||
binds variables on it, and exporters render it out — with `.docx` as the first
|
||||
importer+exporter pair, not the universe. Lossless for our own `.docx`.
|
||||
|
||||
**G3.** Replace the hardcoded `addXxxVars` push with a **`VariableResolver` interface per
|
||||
namespace** + a `ResolverSet` that composes them, preserves aliases, and exposes the key
|
||||
catalogue (label + group) so the frontend variable form/palette becomes data-driven
|
||||
instead of hardcoded in TS.
|
||||
|
||||
**G4.** Build the **Authoring surface**: upload `.docx` → WYSIWYG render → click/select →
|
||||
insert `{{slot}}` → save template. Closes the gap m named.
|
||||
|
||||
**G5.** Refactor **Generation** onto docforge + uploaded templates, preserving the live
|
||||
editor, preview, manual-edit, and export — and every contract in §0.5.
|
||||
|
||||
**G6.** Extract the **generic editor UX** into `frontend/src/lib/docforge-editor/`,
|
||||
consumed by both the generation and authoring shells.
|
||||
|
||||
**Non-goals (this PRD):** implementation, migration SQL, code. Formats beyond `.docx`
|
||||
(interface only). Live upc-kommentar integration. Multi-user concurrent editing of one
|
||||
draft. An HTTP service veneer.
|
||||
|
||||
---
|
||||
|
||||
## §2 User journeys
|
||||
|
||||
### 2.1 Authoring (new)
|
||||
|
||||
1. m opens **`/admin/templates`** (or `/templates/new`) and uploads a base `.docx`
|
||||
(firm letterhead with caption layout, signature block, etc.).
|
||||
2. docforge's `.docx` importer parses the upload into a **carrier** (opaque OOXML kept
|
||||
intact) + a renderable preview. The page shows a **WYSIWYG-ish render** of the document.
|
||||
3. m highlights a piece of text — e.g. `Az. 4c O 12/23` — and a **variable palette**
|
||||
(sourced from the `ResolverSet.Keys()` catalogue, grouped DE/EN) lets him pick
|
||||
`project.case_number`. The selection is **replaced with a `{{project.case_number}}`
|
||||
slot**; a `template_slots` row records the slot key + its anchor position.
|
||||
4. He repeats for every variable region, saves, and the template becomes pickable in
|
||||
Generation. (Editing the template later creates a new **version** — see §4 D-A3.)
|
||||
|
||||
**Scope guard:** v1 authoring places **text-level slots in body paragraphs**. Slots in
|
||||
headers/footers/tables/text-boxes are a flagged follow-up (§7 note), because the
|
||||
click→OOXML-run mapping there is materially harder.
|
||||
|
||||
### 2.2 Generation (refactor of today)
|
||||
|
||||
1. Lawyer picks a template (uploaded template *or* a legacy Gitea base — both supported
|
||||
during transition) for a submission code, optionally project-scoped.
|
||||
2. A **draft** is created. Its template **structure is snapshotted** at create
|
||||
(§4 D-A3) so later template edits don't shift an in-flight draft.
|
||||
3. The sidebar shows the variable form (data-driven from `ResolverSet.Keys()`); the
|
||||
resolved bag is merged with the lawyer's overrides; the live preview renders with
|
||||
`data-var` click-to-jump; manual prose edits autosave (500 ms debounce).
|
||||
4. Export → docforge binds the model + carrier + resolved variables → `.docx` bytes
|
||||
stream as a download. Audit rows written. No binary retained.
|
||||
|
||||
### 2.3 upc-kommentar parallel journey (deferred — validates the abstractions)
|
||||
|
||||
Not built now, but the abstractions are sized for it: upc-kommentar authors work in
|
||||
**Markdown** (and want to import **foreign doc/docx** as input — m, 2026-05-29 Q4). When
|
||||
it becomes a consumer, it would: implement its own `VariableResolver`(s) over its Postgres
|
||||
(commentary metadata), feed Markdown through docforge's **markdown importer** into the
|
||||
neutral model, edit live in its own Svelte shell (reusing the *wire contract*, not Go
|
||||
code), and export. The Go engine is reached over an HTTP veneer added at that point. This
|
||||
journey is the litmus test for §3's seams: **a new consumer adds resolvers + a transport,
|
||||
touches no engine internals.**
|
||||
|
||||
---
|
||||
|
||||
## §3 Module shape
|
||||
|
||||
### 3.1 Package tree
|
||||
|
||||
```
|
||||
pkg/docforge/
|
||||
doc.go // package doc (litigationplanner-style)
|
||||
model.go // neutral model: Document, Block, InlineSpan, Slot
|
||||
template.go // Template, TemplateSlot, Carrier
|
||||
variables.go // VariableResolver interface, VariableKey, ResolverSet, alias registry
|
||||
bind.go // binding engine: walk model, resolve slots, apply missing-marker policy
|
||||
render.go // RenderHTML (preview w/ data-var spans) — format-neutral entry
|
||||
importer.go // Importer interface
|
||||
exporter.go // Exporter interface
|
||||
store.go // TemplateStore interface (carrier bytes + slot persistence contract)
|
||||
errors.go // sentinel errors (ErrUnknownTemplate, ErrUnboundSlot, …)
|
||||
placeholder.go // placeholderRegex + substitution primitives (THE locked grammar)
|
||||
types_wire_test.go // locks the JSON wire shape consumed by the TS editor
|
||||
docx/ // the .docx adapter — first importer + exporter
|
||||
importer.go // DocxImporter: parse .docx -> Carrier + detect/locate slots
|
||||
exporter.go // DocxExporter: (model + carrier + vars) -> .docx bytes [today's compose+merge]
|
||||
ooxml.go // archive/zip + document.xml manipulation [today's submission_merge/compose internals]
|
||||
md_to_ooxml.go // Markdown -> OOXML runs [today's submission_md walker + the b78a984 fix]
|
||||
dotm.go // ConvertDotmToDocx [today's pre-pass]
|
||||
markdown/ // markdown importer (input content; foreign-docx import is a later sibling)
|
||||
importer.go // parse Markdown -> neutral blocks
|
||||
```
|
||||
|
||||
**What lives in docforge vs paliad:**
|
||||
|
||||
| Concern | Home | Why |
|
||||
|---|---|---|
|
||||
| Neutral model, binding, preview-render | `docforge` | format-neutral core |
|
||||
| `VariableResolver` interface + `ResolverSet` | `docforge` | the seam m wants clean |
|
||||
| Placeholder grammar + substitution | `docforge` | shared invariant (§0.5.1) |
|
||||
| `.docx` importer + exporter, MD→OOXML walker | `docforge/docx` | first format adapter (ships *inside* the pkg, like litigationplanner's embedded snapshot) |
|
||||
| Markdown importer | `docforge/markdown` | input-format adapter |
|
||||
| Concrete resolvers (`project`, `parties`, `firm`, `user`, `today`, `deadline`, `procedural_event`) | **paliad** `internal/…` | they read paliad's DB/services |
|
||||
| `TemplateStore` impl (Postgres bytea) | **paliad** | docforge owns no tables |
|
||||
| Section / building-block model, submission codes | **paliad** | consumer-specific composition concepts |
|
||||
| HTTP handlers, editor UI, authoring page | **paliad** | wire + per-consumer UI |
|
||||
|
||||
### 3.2 The neutral model + the carrier (resolving "intermediate, but lossless docx")
|
||||
|
||||
```go
|
||||
// A Document is the format-neutral content model importers produce and exporters consume.
|
||||
type Document struct {
|
||||
Blocks []Block
|
||||
}
|
||||
type Block struct {
|
||||
Kind BlockKind // paragraph | heading | list_item | blockquote | section_marker
|
||||
Style string // logical style key (mapped to a base stylemap on export)
|
||||
Spans []InlineSpan // text runs (bold/italic/link) + Slots
|
||||
// …list level, section key, etc.
|
||||
}
|
||||
type InlineSpan struct {
|
||||
Text string
|
||||
Bold bool
|
||||
Italic bool
|
||||
Link string
|
||||
Slot *Slot // non-nil => this span is a variable slot, not literal text
|
||||
}
|
||||
type Slot struct {
|
||||
Key string // e.g. "project.case_number" — the placeholder grammar key
|
||||
}
|
||||
```
|
||||
|
||||
**The carrier keeps the lossless guarantee.** The uploaded `.docx` chrome
|
||||
(letterhead, styles, caption, signature) is **never round-tripped through `Document`**.
|
||||
It is held as an opaque `Carrier` (the original OOXML), and the exporter splices the
|
||||
rendered neutral content into the carrier's named anchors, then substitutes slots — exactly
|
||||
today's compose mechanism, now formalised:
|
||||
|
||||
```go
|
||||
type Carrier struct {
|
||||
Format string // "docx"
|
||||
Bytes []byte // original upload, preserved byte-for-byte outside anchor regions
|
||||
Anchors []Anchor // {{#section:KEY}}…{{/section:KEY}} positions + slot positions
|
||||
}
|
||||
```
|
||||
|
||||
So **two layers**: editable content = `Document` (neutral, format-pluggable); base chrome =
|
||||
`Carrier` (opaque, lossless). Foreign-docx *import as input content* (Q4) does parse into
|
||||
`Document` and **is inherently lossy** — flagged as a boundary (§8), distinct from the
|
||||
lossless export of *our* templates.
|
||||
|
||||
### 3.3 The variable resolver seam (G3)
|
||||
|
||||
```go
|
||||
// VariableResolver answers keys within one dotted namespace.
|
||||
type VariableResolver interface {
|
||||
Namespace() string // e.g. "project"
|
||||
Resolve(key string) (value string, ok bool)// ok=false => unknown key => missing marker
|
||||
Keys() []VariableKey // catalogue for the palette + sidebar form
|
||||
}
|
||||
type VariableKey struct {
|
||||
Key, LabelDE, LabelEN, Group string
|
||||
}
|
||||
|
||||
// ResolverSet composes namespaced resolvers, registers canonical<->legacy aliases,
|
||||
// and offers BOTH a pull path (Resolve, used during binding) and a push path
|
||||
// (BuildBag, preserving today's resolved_bag/merged_bag wire).
|
||||
type ResolverSet struct{ /* … */ }
|
||||
func (s *ResolverSet) Resolve(key string) (string, bool)
|
||||
func (s *ResolverSet) BuildBag() map[string]string // == today's PlaceholderMap
|
||||
func (s *ResolverSet) Catalogue() []VariableKey // drives the data-driven form/palette
|
||||
func (s *ResolverSet) RegisterAlias(canonical, legacy string)
|
||||
```
|
||||
|
||||
paliad's seven `addXxxVars` functions become seven resolver types implementing this
|
||||
interface. `BuildBag()` reproduces today's flat map exactly (alias parity tests pin it).
|
||||
`Catalogue()` kills the hardcoded `VARIABLE_GROUPS`/`VARIABLE_LABELS` in the TS bundle.
|
||||
**Resolver model = hybrid** (pull-capable interface, push-driven `BuildBag` default —
|
||||
inventor pick, §4 D-I1).
|
||||
|
||||
### 3.4 Wire contract (Go ↔ TS) — preserved, locked by test
|
||||
|
||||
The editor wire stays as-is; `types_wire_test.go` pins it:
|
||||
|
||||
- `GET draft` → `{ draft, resolved_bag, merged_bag, preview_html, rule, parties, sections }`
|
||||
- preview HTML carries `<span class="draft-var" data-var="<key>">…</span>` (built by
|
||||
docforge's `RenderHTML`, today's `emitTextWithDraftVars`).
|
||||
- `PATCH draft` ← `{ variables: PlaceholderMap, … }` (presence-tracked optional fields).
|
||||
- export/preview endpoints unchanged.
|
||||
- **New (authoring):** `POST /api/templates` (upload), `GET /api/templates/:id` (carrier
|
||||
preview + slots), `POST /api/templates/:id/slots` (place slot), `GET /api/docforge/variables`
|
||||
(the `Catalogue()`).
|
||||
|
||||
---
|
||||
|
||||
## §4 Decisions (m's picks, 2026-05-29)
|
||||
|
||||
### Prose-grill resolutions (core metaphor)
|
||||
|
||||
| # | Question | m's decision | Note |
|
||||
|---|---|---|---|
|
||||
| P1 | Cross-language sharing model | **Go pkg only; upc-kommentar out of scope for now, "reuse later somehow"** | Interfaces sized so an HTTP veneer is addable without rework. No service built. |
|
||||
| P2 | Intermediate model? | **Yes — but lossless for our .docx** | → carrier (opaque OOXML) + neutral Document (editable content). §3.2. |
|
||||
| P3 | Authoring slot mechanic | **(b) click-to-insert** | Upload → render → click/select → inject `{{…}}`. |
|
||||
| P4 | Input formats | **Markdown primary; foreign doc/docx import later** | Markdown importer first; foreign-docx import is lossy (§8). |
|
||||
| P5 | Editor sharing | **Build paliad's UI; extract generic UX into a UI package** | `frontend/src/lib/docforge-editor/`. |
|
||||
|
||||
### Structured decisions
|
||||
|
||||
| # | Decision | m's pick | Rationale / divergence |
|
||||
|---|---|---|---|
|
||||
| A1 | Authoring UX | **WYSIWYG inline** | Matches "insert variables into the document". Hardest part — render fidelity + click→run mapping — flagged §7. |
|
||||
| A2 | Template storage | **Postgres bytea (interface-backed)** | m leans (1); flagged Supabase Storage as viable. Resolved: behind a `TemplateStore` interface, bytea impl now, Supabase Storage a one-impl swap later. No schema churn either way. |
|
||||
| A3 | Versioning of existing drafts | **Snapshot at draft-create** | Lawyer's in-flight draft won't shift under them; matches today's section-seeding. |
|
||||
| A4 | Migration strategy | **Extract-in-place, then extend** | Lowest risk to the recent fixes — they move with their files + tests; behavior identical at each step. |
|
||||
| B1 | Package name | **`docforge`** | — |
|
||||
| B2 | Schema scope | **New generic tables** (`templates`, `template_slots`, `template_versions`) | Authoring is domain-neutral; submission_bases (Gitea/section_spec) stays for legacy bases with a converge path. |
|
||||
| B3 | UI package extraction | **Extract now** | Authoring reuses it this cycle — earns its keep, not speculative. |
|
||||
| B4 | Exporter pluggability | **Interface now, docx-only impl** | Cheap insurance; matches "pluggable for later". |
|
||||
|
||||
### Inventor picks (m delegated — "whatever works best")
|
||||
|
||||
| # | Pick | Reasoning |
|
||||
|---|---|---|
|
||||
| I1 | `VariableResolver` = pull-capable interface, push `BuildBag()` default | Preserves today's flat-map wire while enabling on-demand resolution + the `Catalogue()` that data-drives the form. |
|
||||
| I2 | `.docx` adapter ships **inside** `pkg/docforge/docx` | Mirrors litigationplanner shipping its embedded snapshot in-package; keeps the first adapter co-located with the engine it proves. |
|
||||
| I3 | Carrier-vs-Document split (§3.2) | Only way to satisfy "intermediate model" AND "lossless our .docx" simultaneously. |
|
||||
|
||||
---
|
||||
|
||||
## §5 Data model deltas (paliad-side — docforge owns none)
|
||||
|
||||
**New tables** (additive; SQL drafted by the coder, not here):
|
||||
|
||||
- **`paliad.templates`** — `id`, `slug`, `name_de/en`, `kind` (`'submission'` | generic),
|
||||
`source_format` (`'docx'`), `firm`, `is_active`, `created/updated_by`, timestamps,
|
||||
`current_version_id` FK.
|
||||
- **`paliad.template_versions`** — immutable snapshots: `id`, `template_id` FK,
|
||||
`version` int, `carrier_blob` bytea (the `.docx`; or storage ref via `TemplateStore`),
|
||||
`created_at`, `created_by`. Editing a template inserts a new version row.
|
||||
- **`paliad.template_slots`** — `id`, `template_version_id` FK, `slot_key` (the variable
|
||||
key, e.g. `project.case_number`), `anchor` (position encoding — see flag below),
|
||||
`label`, `order_index`. Versioned alongside the carrier.
|
||||
|
||||
**Snapshot semantics (A3):** a draft pins `template_version_id`. Template edits create a
|
||||
new version; existing drafts keep their pinned version. *(Flag for coder: pin
|
||||
`template_version_id` on the draft vs. copy a `template_snapshot` jsonb onto the draft —
|
||||
both satisfy A3; the version-table approach is preferred for auditability but the coder
|
||||
picks based on query ergonomics.)*
|
||||
|
||||
**Touched existing tables:**
|
||||
|
||||
- `submission_drafts` — add nullable `template_version_id` for uploaded-template drafts;
|
||||
**legacy `base_id` path preserved** (extract-in-place ⇒ no data migration of the 11
|
||||
existing drafts; §0.5.8 fallback intact).
|
||||
- `submission_bases`, `submission_sections`, `submission_building_blocks` — **unchanged**.
|
||||
They remain paliad consumer-specific concepts that map onto docforge's neutral model at
|
||||
render time. submission_bases (Gitea-backed) coexists with the new uploaded-template
|
||||
tables during transition; convergence is a later, separate task.
|
||||
|
||||
**Slot anchor encoding (flag for coder):** how a `template_slots.anchor` records *where*
|
||||
in the carrier OOXML the slot sits (run index + offset, vs. a stable sentinel token
|
||||
injected into the carrier at authoring time). The sentinel-token approach is likely
|
||||
simpler and reuses the existing cross-run substitution machinery — resolve in
|
||||
implementation chat.
|
||||
|
||||
---
|
||||
|
||||
## §6 Migration plan (protects working code + the recent fixes)
|
||||
|
||||
**Principle:** extract-in-place (A4). Each step **compiles, passes the moved tests, and
|
||||
leaves observable behavior identical.** The recent fixes travel *with their files*:
|
||||
|
||||
- The **b78a984 underscore fix** → `pkg/docforge/docx/md_to_ooxml.go` (was
|
||||
`submission_md.go` `parseInlineSpans`), `submission_md_test.go` moves alongside.
|
||||
- **`placeholderRegex`** → `pkg/docforge/placeholder.go`; its tests move.
|
||||
- **`data-var` / `emitTextWithDraftVars`** → `pkg/docforge/render.go` (`RenderHTML`);
|
||||
wire test moves and is pinned in `types_wire_test.go`.
|
||||
- **Cross-run merge, `.dotm`→`.docx`, anchor splicing** → `pkg/docforge/docx/`; tests move.
|
||||
- **Building-block + section model, submission codes, the 7 concrete resolvers** stay in
|
||||
`internal/` (consumer-specific) — now calling into docforge.
|
||||
|
||||
**Safety rails per step:** (1) `go build ./...` green; (2) the moved test files green; (3)
|
||||
a golden-export check — generate a known draft before and after the step, assert byte-equal
|
||||
`.docx`; (4) the live preview HTML for a fixture draft is string-equal (the `data-var`
|
||||
contract). No step ships until all four hold.
|
||||
|
||||
**What is explicitly NOT migrated:** the 11 pre-Composer drafts (`base_id IS NULL`) keep
|
||||
the v1 fallback render path; no auto-upgrade (§0.5.8).
|
||||
|
||||
---
|
||||
|
||||
## §7 Slice train
|
||||
|
||||
Tracer-bullet vertical slices, each independently shippable. Slices 1–3 are pure
|
||||
behavior-preserving refactors (the risky-to-working-code part, front-loaded under golden
|
||||
checks); 4–7 build the new capability; 8 sets up the future.
|
||||
|
||||
1. **Extract the docx engine** — move MD→OOXML walker, OOXML merge/compose, placeholder
|
||||
grammar, `.dotm`→`.docx` into `pkg/docforge/{placeholder.go, render.go, docx/}`.
|
||||
paliad's `submission_*` services become thin adapters. Golden-export + preview checks
|
||||
green. *Protects b78a984, the regex, the data-var contract.*
|
||||
2. **Neutral model + binding** — introduce `Document`/`Block`/`Slot`/`Carrier` + `bind.go`;
|
||||
refactor the docx exporter to consume the neutral model (sections → blocks → OOXML
|
||||
spliced into carrier). Behavior identical (golden checks).
|
||||
3. **`VariableResolver` interface** — refactor the 7 `addXxxVars` into resolver types +
|
||||
`ResolverSet`; `BuildBag()` reproduces today's map (alias-parity tests pin it);
|
||||
`Catalogue()` exposed. Frontend form switched to consume `Catalogue()` (kills hardcoded
|
||||
`VARIABLE_GROUPS`).
|
||||
4. **Template store + schema** — `templates`/`template_versions`/`template_slots` +
|
||||
Postgres-bytea `TemplateStore` impl. No UI yet. Additive migrations.
|
||||
5. **UI package extraction** — pull generic plumbing (debounced autosave, data-var wiring,
|
||||
preview/export round-trip, focus preservation, sticky collapse) into
|
||||
`frontend/src/lib/docforge-editor/`; submission editor consumes it. Refactor, behavior
|
||||
identical.
|
||||
6. **Authoring page** — upload `.docx` → docforge docx-importer → WYSIWYG render → select
|
||||
text → pick variable from `Catalogue()` palette → inject slot (writes
|
||||
`template_slots` + new `template_version`). Reuses the UI package + docforge importer.
|
||||
*(v1: body-paragraph text slots only.)*
|
||||
7. **Generation on uploaded templates** — generation page picks an uploaded template
|
||||
(`template_version_id` path) alongside legacy bases; snapshot-at-create; data-bind +
|
||||
manual edit + export via docforge. Legacy base path still works.
|
||||
8. **Markdown importer + exporter-interface finalisation** — `docforge/markdown` importer
|
||||
as input; `Exporter` interface locked (docx-only impl). Sets up future formats +
|
||||
eventual upc-kommentar reuse.
|
||||
|
||||
**Flagged follow-ups (post-train, separate tasks):** slots in headers/footers/tables;
|
||||
foreign-docx import fidelity; the HTTP veneer + a TS consumer; submission_bases →
|
||||
templates convergence; auto-upgrade of pre-Composer drafts.
|
||||
|
||||
---
|
||||
|
||||
## §8 Out of scope
|
||||
|
||||
- **Implementation, migration SQL, code.** PRD only.
|
||||
- **upc-kommentar as a live consumer** — deferred; abstractions sized for it, nothing built.
|
||||
- **An HTTP service veneer** — addable later without engine rework; not now.
|
||||
- **Formats beyond `.docx`** — `Exporter` interface defined (B4), only the docx impl built.
|
||||
- **Lossless import of *foreign* `.docx`** — our own templates export losslessly via the
|
||||
carrier; importing an arbitrary third-party Word doc as input content is best-effort and
|
||||
inherently lossy. Distinct guarantee.
|
||||
- **Multi-user concurrent editing** of one draft.
|
||||
- **Re-proposing the current `submission_*.go` shape** — the point is to extract + clean it.
|
||||
- **Slots outside body paragraphs** (headers/footers/tables/text-boxes) in authoring v1.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — open flags for the coder (resolve in implementation chat)
|
||||
|
||||
1. **Slot anchor encoding** — run-index+offset vs. injected sentinel token (§5). Lean
|
||||
sentinel.
|
||||
2. **Snapshot mechanism** — pinned `template_version_id` vs. `template_snapshot` jsonb on
|
||||
the draft (§5). Lean version-pin.
|
||||
3. **Authoring render fidelity** — reuse the existing lossy `docXMLToHTML` preview for the
|
||||
WYSIWYG surface, or invest in higher fidelity. Lean reuse for v1, accept that
|
||||
complex layouts render approximately while slots still anchor correctly.
|
||||
4. **Storage backend** — Postgres bytea now; Supabase Storage is a clean `TemplateStore`
|
||||
swap if template volume/size grows.
|
||||
280
exports/gen-deadline-list.py
Executable file
280
exports/gen-deadline-list.py
Executable file
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a markdown deadline-list export for UPC PA training (work/head delegation #2572).
|
||||
|
||||
Sorts by proceeding-type display_order then sequence_order. Sections by proceeding.
|
||||
|
||||
t-paliad-348 / yoUPC#178 update: matches the engine's `IncludeOptional=false`
|
||||
default (`pkg/litigationplanner/engine.go`). Optional rules (priority='optional')
|
||||
are SUPPRESSED by default so the manuscript shows the same "naked proceeding
|
||||
backbone" the UI now renders. Pass `--include-optional` to opt back in for an
|
||||
exhaustive catalog dump.
|
||||
|
||||
Usage:
|
||||
uv run exports/gen-deadline-list.py [--include-optional] [-o OUT]
|
||||
"""
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["psycopg2-binary"]
|
||||
# ///
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
DSN = os.environ.get(
|
||||
"PALIAD_DEADLINE_EXPORT_DSN",
|
||||
"postgres://postgres:rpsak3yf4lu1izgefx9p9xweg3qroojw@100.99.98.201:11833/postgres?sslmode=disable",
|
||||
)
|
||||
|
||||
# `priority` filter is wired at the SQL level (not post-filter in Python) so
|
||||
# the row counter in the markdown header reflects what's actually in the
|
||||
# manuscript — matching what the lawyer sees on /tools/procedures.
|
||||
SQL_TEMPLATE = """
|
||||
SELECT
|
||||
pt.code AS pt_code,
|
||||
pt.display_order,
|
||||
COALESCE(pt.name_en, pt.name) AS pt_label_en,
|
||||
pt.name AS pt_label_de,
|
||||
COALESCE(pe.name_en, pe.name) AS event_en,
|
||||
pe.name AS event_de,
|
||||
sr.duration_value,
|
||||
sr.duration_unit,
|
||||
sr.timing,
|
||||
sr.alt_duration_value,
|
||||
sr.alt_duration_unit,
|
||||
sr.combine_op,
|
||||
sr.rule_code,
|
||||
COALESCE(te.name, te.name_de) AS trigger_label,
|
||||
te.code AS trigger_code,
|
||||
sr.primary_party,
|
||||
sr.is_court_set,
|
||||
sr.is_spawn,
|
||||
sr.priority,
|
||||
sr.deadline_notes_en,
|
||||
sr.deadline_notes,
|
||||
sr.condition_expr,
|
||||
sr.sequence_order
|
||||
FROM paliad.sequencing_rules sr
|
||||
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
LEFT JOIN paliad.proceeding_types pt ON pt.id = sr.proceeding_type_id
|
||||
LEFT JOIN paliad.trigger_events te ON te.id = sr.trigger_event_id
|
||||
WHERE sr.lifecycle_state = 'published'
|
||||
AND sr.is_active = true
|
||||
AND pt.id IS NOT NULL
|
||||
{priority_filter}
|
||||
ORDER BY pt.display_order NULLS LAST, pt.code, sr.sequence_order NULLS LAST, sr.rule_code, pe.name;
|
||||
"""
|
||||
|
||||
|
||||
def format_frist(duration_value, duration_unit, timing, alt_value, alt_unit, combine_op):
|
||||
"""Format the deadline duration cleanly."""
|
||||
if duration_value is None or duration_unit is None:
|
||||
return ""
|
||||
unit_map = {
|
||||
"days": "d",
|
||||
"weeks": "w",
|
||||
"months": "M",
|
||||
"years": "y",
|
||||
"calendar_days": "CD",
|
||||
"working_days": "WD",
|
||||
}
|
||||
unit = unit_map.get(duration_unit, duration_unit)
|
||||
main = f"{duration_value} {unit}"
|
||||
if alt_value is not None and alt_unit is not None:
|
||||
alt_unit_short = unit_map.get(alt_unit, alt_unit)
|
||||
op = combine_op or "or"
|
||||
main = f"{main} {op} {alt_value} {alt_unit_short}"
|
||||
if timing == "before":
|
||||
main = f"{main} before"
|
||||
elif timing == "after":
|
||||
main = f"{main} after"
|
||||
return main
|
||||
|
||||
|
||||
def format_party(primary_party, is_court_set):
|
||||
if is_court_set:
|
||||
return "court-set"
|
||||
if primary_party == "claimant":
|
||||
return "claimant"
|
||||
if primary_party == "defendant":
|
||||
return "defendant"
|
||||
if primary_party == "both":
|
||||
return "either"
|
||||
if primary_party == "court":
|
||||
return "court"
|
||||
return primary_party or "—"
|
||||
|
||||
|
||||
def detect_r94(notes_en, notes_de):
|
||||
"""Flag R.9.4 non-extendable from notes text (heuristic — no DB field)."""
|
||||
blobs = " ".join(filter(None, [notes_en or "", notes_de or ""])).lower()
|
||||
if "r.9.4" in blobs or "r 9.4" in blobs or "r9.4" in blobs:
|
||||
return "✗"
|
||||
if "non-extendable" in blobs or "nicht verlängerbar" in blobs or "nicht verlaengerbar" in blobs:
|
||||
return "✗"
|
||||
return ""
|
||||
|
||||
|
||||
def conditional_marker(condition_expr):
|
||||
if condition_expr in (None, "", {}):
|
||||
return ""
|
||||
# condition_expr is JSONB → returns dict
|
||||
if isinstance(condition_expr, dict):
|
||||
if "flag" in condition_expr:
|
||||
return f"if `{condition_expr['flag']}`"
|
||||
if condition_expr.get("op") == "and" and "args" in condition_expr:
|
||||
flags = [a.get("flag", "?") for a in condition_expr["args"]]
|
||||
return "if " + " & ".join(f"`{f}`" for f in flags)
|
||||
if condition_expr.get("op") == "or" and "args" in condition_expr:
|
||||
flags = [a.get("flag", "?") for a in condition_expr["args"]]
|
||||
return "if " + " | ".join(f"`{f}`" for f in flags)
|
||||
return "cond"
|
||||
|
||||
|
||||
def md_escape(s):
|
||||
if s is None:
|
||||
return ""
|
||||
return str(s).replace("|", "\\|").replace("\n", " ")
|
||||
|
||||
|
||||
def render(rows, *, include_optional: bool, generated_for: str) -> str:
|
||||
by_pt = {}
|
||||
for r in rows:
|
||||
key = (r["display_order"] or 9999, r["pt_code"], r["pt_label_de"], r["pt_label_en"])
|
||||
by_pt.setdefault(key, []).append(r)
|
||||
|
||||
out = []
|
||||
today = date.today().isoformat()
|
||||
out.append(f"# UPC + DE/EP Deadline Catalog — Stand {today}")
|
||||
out.append("")
|
||||
out.append(f"Source: `paliad.sequencing_rules` (lifecycle_state=published, is_active=true).")
|
||||
out.append(f"Generated for {generated_for}. {len(rows)} rules across {len(by_pt)} proceedings.")
|
||||
if include_optional:
|
||||
out.append("")
|
||||
out.append(
|
||||
"**Mode:** `--include-optional` — every published rule, including "
|
||||
"`priority='optional'` rules suppressed by the engine's default "
|
||||
"(`IncludeOptional=false`). This is the exhaustive catalog dump."
|
||||
)
|
||||
else:
|
||||
out.append("")
|
||||
out.append(
|
||||
"**Mode:** default — matches the engine's `IncludeOptional=false` "
|
||||
"behaviour (pkg/litigationplanner/engine.go). `priority='optional'` "
|
||||
"rules are suppressed; the manuscript shows only the mandatory "
|
||||
"backbone the lawyer sees by default on /tools/procedures. "
|
||||
"Re-run with `--include-optional` for the full catalog. "
|
||||
"(t-paliad-348 / yoUPC#178)"
|
||||
)
|
||||
out.append("")
|
||||
out.append("**Spalten:**")
|
||||
out.append("- **Phase/Event** = procedural event (German primary)")
|
||||
out.append("- **Frist** = duration + timing (`d` days, `w` weeks, `M` months, `CD` calendar days, `WD` working days; `before` = relative to anchor)")
|
||||
out.append("- **Rule** = legal source (RoP / § ZPO / § PatG / Art. EPÜ)")
|
||||
out.append("- **Anchor** = trigger event the deadline runs from")
|
||||
out.append("- **Seite** = filing party (claimant / defendant / either / court-set)")
|
||||
out.append("- **Priorität** = mandatory / recommended / optional / informational (only when `--include-optional`)")
|
||||
out.append("- **R.9.4** = ✗ marked non-extendable in notes (heuristic — confirm against rule text)")
|
||||
out.append("- **Bedingung** = scenario flag(s) that must be set for the rule to fire (blank = always)")
|
||||
out.append("")
|
||||
out.append("---")
|
||||
out.append("")
|
||||
|
||||
for (order, pt_code, pt_de, pt_en) in sorted(by_pt.keys()):
|
||||
prules = by_pt[(order, pt_code, pt_de, pt_en)]
|
||||
out.append(f"## {pt_de} · `{pt_code}`")
|
||||
out.append("")
|
||||
if pt_en and pt_en != pt_de:
|
||||
out.append(f"*{pt_en}*")
|
||||
out.append("")
|
||||
if include_optional:
|
||||
out.append("| # | Phase / Event | Frist | Rule | Anchor | Seite | Priorität | R.9.4 | Bedingung |")
|
||||
out.append("|---:|---|---|---|---|---|---|:---:|---|")
|
||||
else:
|
||||
out.append("| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |")
|
||||
out.append("|---:|---|---|---|---|---|:---:|---|")
|
||||
for i, r in enumerate(prules, 1):
|
||||
event = md_escape(r["event_de"] or r["event_en"] or "")
|
||||
frist = md_escape(
|
||||
format_frist(
|
||||
r["duration_value"], r["duration_unit"], r["timing"],
|
||||
r["alt_duration_value"], r["alt_duration_unit"], r["combine_op"],
|
||||
)
|
||||
)
|
||||
rule = md_escape(r["rule_code"] or "")
|
||||
anchor = md_escape(r["trigger_label"] or "")
|
||||
party = format_party(r["primary_party"], r["is_court_set"])
|
||||
r94 = detect_r94(r["deadline_notes_en"], r["deadline_notes"])
|
||||
cond = md_escape(conditional_marker(r["condition_expr"]))
|
||||
spawn_marker = " ⤴" if r["is_spawn"] else ""
|
||||
if include_optional:
|
||||
priority = md_escape(r["priority"] or "")
|
||||
out.append(
|
||||
f"| {i} | {event}{spawn_marker} | {frist} | {rule} | {anchor} | {party} | {priority} | {r94} | {cond} |"
|
||||
)
|
||||
else:
|
||||
out.append(
|
||||
f"| {i} | {event}{spawn_marker} | {frist} | {rule} | {anchor} | {party} | {r94} | {cond} |"
|
||||
)
|
||||
out.append("")
|
||||
|
||||
out.append("---")
|
||||
out.append("")
|
||||
out.append("**Lesehilfe:**")
|
||||
out.append("- ⤴ Spawn-Marker: event opens a sub-proceeding (e.g. CCR forks revocation track)")
|
||||
out.append("- `with_ccr` = Widerklage auf Nichtigkeit gefilt | `with_amend` = Patentänderungsantrag | `with_cci` = Widerklage auf Verletzung (in rev.cfi)")
|
||||
out.append("- Catalog ist work-in-progress: 7 compound-name rules + Patentänderung-Duplikation noch in m's split-review backlog (m/paliad#149).")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--include-optional",
|
||||
action="store_true",
|
||||
help="Include priority='optional' rules. Default false matches the engine's IncludeOptional=false default.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--out",
|
||||
default="exports/upc-deadlines-2026-05-28.md",
|
||||
help="Output path (relative to repo root).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--generated-for",
|
||||
default="PA-Schulung 2026-05-28",
|
||||
help="Free-text label rendered in the markdown header.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
priority_filter = "" if args.include_optional else "AND sr.priority != 'optional'"
|
||||
sql = SQL_TEMPLATE.format(priority_filter=priority_filter)
|
||||
|
||||
conn = psycopg2.connect(DSN)
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(sql)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
md = render(rows, include_optional=args.include_optional, generated_for=args.generated_for)
|
||||
# Resolve out path relative to the repo root (= the script's grandparent).
|
||||
out_path = Path(args.out)
|
||||
if not out_path.is_absolute():
|
||||
out_path = Path(__file__).resolve().parent.parent / out_path
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(md)
|
||||
n_pt = len({(r["display_order"] or 9999, r["pt_code"]) for r in rows})
|
||||
print(
|
||||
f"WROTE {out_path} ({len(rows)} rules, {n_pt} proceedings, "
|
||||
f"include_optional={args.include_optional})"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
BIN
exports/screenshots/paliad-348-after-upc-inf-cfi.png
Normal file
BIN
exports/screenshots/paliad-348-after-upc-inf-cfi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
BIN
exports/screenshots/paliad-348-before-upc-inf-cfi.png
Normal file
BIN
exports/screenshots/paliad-348-before-upc-inf-cfi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 490 KiB |
378
exports/upc-deadlines-2026-05-28.md
Normal file
378
exports/upc-deadlines-2026-05-28.md
Normal file
@@ -0,0 +1,378 @@
|
||||
# UPC + DE/EP Deadline Catalog — Stand 2026-05-28
|
||||
|
||||
Source: `paliad.sequencing_rules` (lifecycle_state=published, is_active=true).
|
||||
Generated for PA-Schulung 2026-05-28. 178 rules across 25 proceedings.
|
||||
|
||||
**Mode:** default — matches the engine's `IncludeOptional=false` behaviour (pkg/litigationplanner/engine.go). `priority='optional'` rules are suppressed; the manuscript shows only the mandatory backbone the lawyer sees by default on /tools/procedures. Re-run with `--include-optional` for the full catalog. (t-paliad-348 / yoUPC#178)
|
||||
|
||||
**Spalten:**
|
||||
- **Phase/Event** = procedural event (German primary)
|
||||
- **Frist** = duration + timing (`d` days, `w` weeks, `M` months, `CD` calendar days, `WD` working days; `before` = relative to anchor)
|
||||
- **Rule** = legal source (RoP / § ZPO / § PatG / Art. EPÜ)
|
||||
- **Anchor** = trigger event the deadline runs from
|
||||
- **Seite** = filing party (claimant / defendant / either / court-set)
|
||||
- **Priorität** = mandatory / recommended / optional / informational (only when `--include-optional`)
|
||||
- **R.9.4** = ✗ marked non-extendable in notes (heuristic — confirm against rule text)
|
||||
- **Bedingung** = scenario flag(s) that must be set for the rule to fire (blank = always)
|
||||
|
||||
---
|
||||
|
||||
## Verletzungsverfahren · `upc.inf.cfi`
|
||||
|
||||
*Infringement Action*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Klageerhebung | 0 M after | RoP.013.1 | | claimant | | |
|
||||
| 2 | Klageerwiderung | 3 M after | RoP.023 | | defendant | | |
|
||||
| 3 | Replik | 2 M or 2 M after | RoP.029.b | | claimant | | if `with_ccr` |
|
||||
| 4 | Duplik | 1 M or 2 M after | RoP.029.c | | defendant | | if `with_ccr` |
|
||||
| 5 | Erwiderung auf Nichtigkeitswiderklage | 2 M after | RoP.029.a | | claimant | | if `with_ccr` |
|
||||
| 6 | Replik auf Erwiderung zur Nichtigkeitswiderklage | 2 M after | RoP.029.d | | defendant | | if `with_ccr` |
|
||||
| 7 | Duplik auf Replik zur Erwiderung Nichtigkeitswiderklage | 1 M after | RoP.029.e | | claimant | | if `with_ccr` |
|
||||
| 8 | Antrag auf Patentänderung | 2 M after | RoP.030.1 | | claimant | | if `with_ccr` & `with_amend` |
|
||||
| 9 | Erwiderung auf Patentänderungsantrag | 2 M after | RoP.032.1 | | defendant | | if `with_ccr` & `with_amend` |
|
||||
| 10 | Replik auf Erwiderung zum Patentänderungsantrag | 1 M after | RoP.032.3 | | claimant | | if `with_ccr` & `with_amend` |
|
||||
| 11 | Duplik auf Replik zum Patentänderungsantrag | 1 M after | RoP.032.3 | | defendant | | if `with_ccr` & `with_amend` |
|
||||
| 12 | Zwischenanhörung | 0 M after | RoP.105 | | court-set | | |
|
||||
| 13 | Mitteilung Dolmetscherkosten | 2 w before | RoP.109.4 | Oral hearing | court | | |
|
||||
| 14 | Übersetzungen einreichen | 2 w after | RoP.109.5 | | either | | |
|
||||
| 15 | Mündliche Verhandlung | 0 M after | RoP.112 | | court-set | | |
|
||||
| 16 | Entscheidung | 0 M after | RoP.118.1 | | court-set | | |
|
||||
| 17 | Duplik zur Replik auf die Erwiderung zum Patentänderungsantrag | 1 M after | RoP.032.3 | Reply to the Defence to an Application to amend the patent | defendant | | if `with_ccr` & `with_amend` |
|
||||
| 18 | Einreichung von Übersetzungen von Schriftstücken | 1 M after | RoP.007.4 | Order of the judge-rapporteur to lodge translations | either | | |
|
||||
| 19 | Antrag auf Simultanübersetzung | 1 M before | RoP.109.5 | Oral hearing | either | | |
|
||||
| 20 | Antrag auf Folgemaßnahmen aus einer rechtskräftigen Validitätsentscheidung | 2 M after | RoP.118.4 | Final decision of the central division, Court of Appeal or EPO on the validity of the patent | either | | if `with_ccr` |
|
||||
| 21 | Antrag auf Überprüfung einer verfahrensleitenden Anordnung | 15 d after | RoP.333 | Case management order (Service) | either | | |
|
||||
| 22 | Mängelbeseitigung / Einreichung schriftlicher Stellungnahme | 14 d after | RoP.019 | Preliminary Objection | either | | |
|
||||
| 23 | Mängelbeseitigung / Zahlung | 14 d after | RoP.016 | Notification by the Registry to correct deficiencies | either | | |
|
||||
| 24 | Antrag auf Verweisung an die Zentralkammer | 10 d after | RoP.323 | Information by the Court not to approve Application to use the patent's language as language of the proceedings | either | | |
|
||||
| 25 | Mitteilung über Beauftragung eines Dolmetschers auf Kosten der Partei | 2 w before | RoP.109.5 | Oral hearing | either | | |
|
||||
| 26 | Klärung von Übersetzungsfragen | 2 w after | RoP.109 | Summons to Oral Hearing | court | | |
|
||||
| 27 | Antrag auf Vertraulichkeit gegenüber der Öffentlichkeit | 14 d after | RoP.262.2 | Opponent Submission | either | | |
|
||||
| 28 | Wiedereinsetzungsantrag (UPC R.320) | 2 M after | RoP.320 | Removal of obstacle (UPC R.320) | either | | |
|
||||
|
||||
## Verletzungsverfahren (LG) · `de.inf.lg`
|
||||
|
||||
*Infringement (Regional Court)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Klageerhebung | 0 M after | § 253 ZPO | | claimant | | |
|
||||
| 2 | Anzeige der Verteidigungsbereitschaft | 2 w after | § 276 ZPO | | defendant | | |
|
||||
| 3 | Klageerwiderung | 6 w after | § 276 ZPO | | court-set | | |
|
||||
| 4 | Replik | 4 w after | § 282 ZPO | | court-set | | |
|
||||
| 5 | Duplik | 4 w after | § 282 ZPO | | court-set | | |
|
||||
| 6 | Haupttermin | 0 M after | § 279 ZPO | | court-set | | |
|
||||
| 7 | Urteil | 0 M after | § 300 ZPO | | court-set | | |
|
||||
| 8 | Berufungsfrist | 1 M after | § 517 ZPO | | either | | |
|
||||
| 9 | Berufungsbegründung | 2 M after | § 520 ZPO | | either | | |
|
||||
| 10 | Wiedereinsetzungsantrag (§ 233 ZPO) | 2 w after | § 233 ZPO | Removal of obstacle (ZPO §233) | — | | |
|
||||
| 11 | Einspruch gegen Versäumnisurteil (§ 339 ZPO) | 2 w after | § 339 ZPO | Service of default judgment | — | | |
|
||||
| 12 | Schriftsatznachreichung (§ 296a ZPO) | 3 w after | § 296a ZPO | End of oral hearing | — | | |
|
||||
|
||||
## Nichtigkeitsverfahren · `upc.rev.cfi`
|
||||
|
||||
*Revocation Action*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Nichtigkeitsklage | 0 M after | RoP.044 | | claimant | | |
|
||||
| 2 | Klageerwiderung | 2 M after | RoP.049.1 | | defendant | | |
|
||||
| 3 | Antrag auf Patentänderung | 0 M after | RoP.049.2.a | | defendant | | if `with_amend` |
|
||||
| 4 | Verletzungswiderklage | 0 M after | RoP.049.2.b | | defendant | | if `with_cci` |
|
||||
| 5 | Replik | 2 M after | RoP.051 | | claimant | | |
|
||||
| 6 | Erwiderung auf Patentänderungsantrag | 2 M after | RoP.043.3 | | claimant | | if `with_amend` |
|
||||
| 7 | Erwiderung auf Verletzungswiderklage | 2 M after | RoP.056.1 | | claimant | | if `with_cci` |
|
||||
| 8 | Duplik | 1 M after | RoP.052 | | defendant | | |
|
||||
| 9 | Replik auf Erwiderung zum Patentänderungsantrag | 1 M after | RoP.032.3 | | defendant | | if `with_amend` |
|
||||
| 10 | Replik auf Erwiderung zur Verletzungswiderklage | 1 M after | RoP.056.3 | | defendant | | if `with_cci` |
|
||||
| 11 | Duplik auf Replik zum Patentänderungsantrag | 1 M after | RoP.032.3 | | claimant | | if `with_amend` |
|
||||
| 12 | Duplik auf Replik zur Erwiderung Verletzungswiderklage | 1 M after | RoP.056.4 | | claimant | | if `with_cci` |
|
||||
| 13 | Zwischenanhörung | 0 M after | RoP.105 | | court-set | | |
|
||||
| 14 | Mündliche Verhandlung | 0 M after | RoP.112 | | court-set | | |
|
||||
| 15 | Entscheidung | 0 M after | RoP.118.3 | | court-set | | |
|
||||
| 16 | Duplik zur Replik auf die Erwiderung zur Nichtigkeitsklage | 1 M after | RoP.052 | Reply to the Defence to revocation | — | | |
|
||||
| 17 | Verletzungswiderklage | 2 M after | RoP.053 | Statement for Revocation | — | | |
|
||||
| 18 | Antrag auf Patentänderung | 2 M after | RoP.050 | Statement for Revocation | — | | |
|
||||
|
||||
## Nichtigkeitsverfahren (BPatG) · `de.null.bpatg`
|
||||
|
||||
*Nullity (Federal Patent Court)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Nichtigkeitsklage | 0 M after | § 81 PatG | | claimant | | |
|
||||
| 2 | Klageerwiderung | 2 M after | § 82 Abs. 3 PatG | | defendant | | |
|
||||
| 3 | Replik | 2 M after | § 83 PatG | | claimant | | |
|
||||
| 4 | Hinweisbeschluss | 0 M after | § 83 PatG | | court-set | | |
|
||||
| 5 | Stellungnahme zum Hinweisbeschluss | 0 M after | § 83 PatG | | either | | |
|
||||
| 6 | Duplik | 1 M after | § 83 PatG | | defendant | | |
|
||||
| 7 | Mündliche Verhandlung | 0 M after | § 80 PatG | | court-set | | |
|
||||
| 8 | Urteil | 0 M after | § 84 PatG | | court-set | | |
|
||||
| 9 | Berufungsfrist | 1 M after | § 110 PatG | | either | | |
|
||||
| 10 | Berufungsbegründung | 3 M after | § 111 PatG | | either | | |
|
||||
| 11 | Wiedereinsetzungsantrag (§ 123 PatG) | 2 M after | § 123 PatG | Removal of obstacle (PatG §123) | — | | |
|
||||
|
||||
## Einspruchsverfahren · `epa.opp.opd`
|
||||
|
||||
*Opposition Proceedings*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Veröffentlichung der Erteilung | 0 M after | Art. 97 EPÜ | | either | | |
|
||||
| 2 | Einspruchsfrist | 9 M after | Art. 99 EPÜ | | either | | |
|
||||
| 3 | Erwiderung des Patentinhabers | 4 M after | R. 79(1) EPÜ | | court-set | | |
|
||||
| 4 | Entscheidung | 0 M after | Art. 102 EPÜ | | court-set | | |
|
||||
| 5 | Beschwerdefrist | 2 M after | Art. 108 EPÜ | | either | | |
|
||||
| 6 | Beschwerdebegründung | 4 M after | Art. 108 EPÜ | | either | | |
|
||||
| 7 | Stellungnahme weiterer Beteiligter | 0 M after | R. 79 EPÜ | | either | | |
|
||||
| 8 | Eingaben vor mündl. Verhandlung | 0 M after | R. 116 EPÜ | | either | | |
|
||||
| 9 | Wiedereinsetzungsantrag (Art. 122 EPÜ) | 2 M after | Art. 122 EPÜ | Removal of obstacle (EPC Art.122) | — | | |
|
||||
|
||||
## Beschwerdeverfahren · `epa.opp.boa`
|
||||
|
||||
*Appeal Proceedings*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung der Beschwerdeentscheidung | 0 M after | R. 124 EPÜ | | either | | |
|
||||
| 2 | Beschwerdeeinlegung | 2 M after | Art. 108 EPÜ | | either | | |
|
||||
| 3 | Beschwerdebegründung | 4 M after | Art. 108 EPÜ | | either | | |
|
||||
| 4 | Beschwerdeerwiderung | 4 M after | RPBA Art. 12 | | either | | |
|
||||
| 5 | Mündliche Verhandlung | 0 M after | Art. 116 EPÜ | | court-set | | |
|
||||
| 6 | Entscheidung | 0 M after | Art. 111 EPÜ | | court-set | | |
|
||||
| 7 | Eingaben vor mündl. Verhandlung | 0 M after | R. 116 EPÜ | | either | | |
|
||||
| 8 | Antrag auf Überprüfung | 2 M after | Art. 112a EPÜ | | either | | |
|
||||
|
||||
## Einspruchsverfahren DPMA · `dpma.opp.dpma`
|
||||
|
||||
*Opposition DPMA*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Veröffentlichung der Erteilung | 0 M after | § 58 PatG | | either | | |
|
||||
| 2 | Einspruchsfrist | 9 M after | § 59 PatG | | either | | |
|
||||
| 3 | Erwiderung des Patentinhabers | 4 M after | § 59(2) PatG | | court-set | | |
|
||||
| 4 | DPMA-Entscheidung | 0 M after | § 61 PatG | | court-set | | |
|
||||
| 5 | Wiedereinsetzungsantrag (DPMA) | 2 M after | § 123 PatG | Removal of obstacle (DPMA, PatG §123) | — | | |
|
||||
|
||||
## Berufungsverfahren · `upc.apl.merits`
|
||||
|
||||
*Appeal*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Berufungseinlegung | 2 M after | RoP.224.1.a | | either | | |
|
||||
| 2 | Berufungsbegründung | 4 M after | RoP.224.2.a | | either | | |
|
||||
| 3 | Berufungserwiderung | 3 M after | RoP.235.1 | | either | | |
|
||||
| 4 | Mündliche Verhandlung | 0 M after | RoP.240 | | court-set | | |
|
||||
| 5 | Entscheidung | 0 M after | RoP.235.4 | | court-set | | |
|
||||
| 6 | Anschlussberufung | 3 M after | RoP.237 | | either | | |
|
||||
| 7 | Erwiderung Anschlussberufung | 2 M after | RoP.238.1 | | either | | |
|
||||
| 8 | Berufungsschrift gegen eine in Regel 220.1(a) und (b) genannte Entscheidung | 2 M after | RoP.224.1(a) | Decision referred to in Rule 220.1(a) and (b) | — | | |
|
||||
| 9 | Berufungsbegründung gegen eine in Regel 220.1(a) und (b) genannte Entscheidung | 4 M after | RoP.224.1(a) | Decision referred to in Rule 220.1(a) and (b) | — | | |
|
||||
| 10 | Anfechtung einer Entscheidung über die Verwerfung der Berufung als unzulässig | 1 M after | RoP.245 | Decision to reject an appeal as inadmissible | — | | |
|
||||
| 11 | Antrag auf Wiederaufnahme (schwerwiegender Verfahrensmangel) | 2 M after | RoP.247.2 | Final decision (Service) / Discovery of the fundamental defect (whichever is later) | — | | |
|
||||
| 12 | Antrag auf Wiederaufnahme (Straftat) | 2 M after | RoP.247.1 | Final decision (Service) / Court decision on criminal offence (whichever is later) | — | | |
|
||||
|
||||
## Berufungsverfahren OLG (Verletzung) · `de.inf.olg`
|
||||
|
||||
*Appeal OLG (Infringement)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung LG-Urteil | 0 M after | § 540 ZPO | | either | | |
|
||||
| 2 | Berufungsschrift | 1 M after | § 517 ZPO | | either | | |
|
||||
| 3 | Berufungsbegründung | 2 M after | § 520 ZPO | | either | | |
|
||||
| 4 | Berufungserwiderung | 1 M after | § 521 ZPO | | either | | |
|
||||
| 5 | Anschlussberufung | 0 M after | § 524 ZPO | | either | | |
|
||||
| 6 | Mündliche Verhandlung | 0 M after | § 540 ZPO | | court-set | | |
|
||||
| 7 | OLG-Urteil | 0 M after | § 540 ZPO | | court-set | | |
|
||||
|
||||
## Revisions-/NZB-Verfahren BGH (Verletzung) · `de.inf.bgh`
|
||||
|
||||
*Revision / Non-admission Appeal BGH (Infringement)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung OLG-Urteil | 0 M after | § 555 ZPO | | either | | |
|
||||
| 2 | Nichtzulassungsbeschwerde | 1 M after | § 544 ZPO | | either | | |
|
||||
| 3 | Nichtzulassungsbeschwerde-Begründung | 2 M after | § 544 ZPO | | either | | |
|
||||
| 4 | Revisionsfrist | 1 M after | § 548 ZPO | | either | | |
|
||||
| 5 | Revisionsbegründung | 2 M after | § 551 ZPO | | either | | |
|
||||
| 6 | Revisionserwiderung | 1 M after | § 554 ZPO | | either | | |
|
||||
| 7 | Mündliche Verhandlung BGH | 0 M after | § 555 ZPO | | court-set | | |
|
||||
| 8 | BGH-Urteil | 0 M after | § 555 ZPO | | court-set | | |
|
||||
|
||||
## Berufungsverfahren BGH (Nichtigkeit) · `de.null.bgh`
|
||||
|
||||
*Appeal BGH (Nullity)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung BPatG-Urteil | 0 M after | § 110 PatG | | either | | |
|
||||
| 2 | Berufungsschrift | 1 M after | § 110 PatG | | either | | |
|
||||
| 3 | Berufungsbegründung | 3 M after | § 520 Abs. 2 ZPO i.V.m. § 117 PatG | | either | | |
|
||||
| 4 | Berufungserwiderung | 2 M after | § 521 Abs. 2 ZPO i.V.m. § 117 PatG | | court-set | | |
|
||||
| 5 | Mündliche Verhandlung BGH | 0 M after | § 121 PatG | | court-set | | |
|
||||
| 6 | BGH-Urteil | 0 M after | § 122 PatG | | court-set | | |
|
||||
|
||||
## EP-Erteilungsverfahren · `epa.grant.exa`
|
||||
|
||||
*EP Grant Procedure*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Anmeldung | 0 M after | Art. 75 EPÜ | | claimant | | |
|
||||
| 2 | Recherchenbericht | 6 M after | Art. 92 EPÜ | | court-set | | |
|
||||
| 3 | Veröffentlichung (A1) | 18 M after | Art. 93 EPÜ | | court-set | | |
|
||||
| 4 | Prüfungsantrag | 6 M after | R. 70(1) EPÜ | | claimant | | |
|
||||
| 5 | Mitteilung nach R. 71(3) | 0 M after | R. 71(3) EPÜ | | court-set | | |
|
||||
| 6 | Zustimmung + Übersetzung | 4 M after | R. 71(3) EPÜ | | claimant | | |
|
||||
| 7 | Erteilung (B1) | 0 M after | Art. 97 EPÜ | | court-set | | |
|
||||
|
||||
## Beschwerdeverfahren BPatG (DPMA) · `dpma.appeal.bpatg`
|
||||
|
||||
*Appeal BPatG (against DPMA Decision)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung DPMA-Entscheidung | 0 M after | § 65 PatG | | either | | |
|
||||
| 2 | Beschwerde | 1 M after | § 73 PatG | | either | | |
|
||||
| 3 | Beschwerdebegründung | 1 M after | § 75 PatG | | court-set | | |
|
||||
| 4 | Mündliche Verhandlung BPatG | 0 M after | § 78 PatG | | court-set | | |
|
||||
| 5 | BPatG-Entscheidung | 0 M after | § 78 PatG | | court-set | | |
|
||||
|
||||
## Rechtsbeschwerdeverfahren BGH · `dpma.appeal.bgh`
|
||||
|
||||
*Legal Appeal BGH*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung BPatG-Entscheidung | 0 M after | § 100 PatG | | either | | |
|
||||
| 2 | Rechtsbeschwerde | 1 M after | § 100 PatG | | either | | |
|
||||
| 3 | Rechtsbeschwerdebegründung | 1 M after | § 102 PatG | | either | | |
|
||||
| 4 | BGH-Entscheidung | 0 M after | § 100 PatG | | court-set | | |
|
||||
|
||||
## Berufungsverfahren Anordnungen · `upc.apl.order`
|
||||
|
||||
*Order Appeal (15-day track)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Anordnung / angegriffene Entscheidung | 0 M after | RoP.220 | | court-set | | |
|
||||
| 2 | Berufung mit Zulassung | 15 d after | RoP.220.2 | | either | | |
|
||||
| 3 | Antrag auf Ermessensüberprüfung | 15 d after | RoP.220.3 | | either | | |
|
||||
| 4 | Berufungsbegründung (Orders Track) | 15 d after | RoP.224.2.b | | either | | |
|
||||
| 5 | Anschlussberufung | 15 d after | RoP.237 | | either | | |
|
||||
| 6 | Erwiderung Anschlussberufung | 15 d after | RoP.238.2 | | either | | |
|
||||
| 7 | Berufungsschrift gegen eine in Regel 220.1(c) genannte Anordnung oder eine in Regel 220.2 oder 221.3 genannte Entscheidung | 15 d after | RoP.224.1(b) | Order referred to in Rule 220.1(c) or a decision referred to in Rule 220.2 or 221.3 | — | | |
|
||||
| 8 | Berufungsbegründung gegen eine in Regel 220.1(c) genannte Anordnung oder eine in Regel 220.2 oder 221.3 genannte Entscheidung | 15 d after | RoP.224.1(b) | Order referred to in Rule 220.1(c) or a decision referred to in Rule 220.2 or 221.3 | — | | |
|
||||
|
||||
## Schadensbemessungsverfahren · `upc.dmgs.cfi`
|
||||
|
||||
*Damages Determination*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag auf Schadensbemessung | 0 M after | RoP.125 | | claimant | | |
|
||||
| 2 | Klageerwiderung | 2 M after | RoP.137.2 | | defendant | | |
|
||||
| 3 | Replik | 1 M after | RoP.139 | | claimant | | |
|
||||
| 4 | Duplik | 1 M after | RoP.139 | | defendant | | |
|
||||
|
||||
## Bucheinsichtsverfahren · `upc.disc.cfi`
|
||||
|
||||
*Lay-open Books / Discovery*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag auf Bucheinsicht | 0 M after | RoP.190 | | claimant | | |
|
||||
| 2 | Klageerwiderung | 2 M after | RoP.142.2 | | defendant | | |
|
||||
| 3 | Replik | 14 d after | RoP.142.3 | | claimant | | |
|
||||
| 4 | Duplik | 14 d after | RoP.142.3 | | defendant | | |
|
||||
|
||||
## Einstweilige Maßnahmen · `upc.pi.cfi`
|
||||
|
||||
*Provisional Measures*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag | 0 M after | RoP.206 | | claimant | | |
|
||||
| 2 | Erwiderung | 0 M after | RoP.211.2 | | court-set | | |
|
||||
| 3 | Mündliche Verhandlung | 0 M after | RoP.195 | | court-set | | |
|
||||
| 4 | Mängelbeseitigung Antrag | 14 d after | RoP.207.6.a | | claimant | | |
|
||||
| 5 | Beschluss | 0 M after | RoP.211 | | court-set | | |
|
||||
| 6 | Klage in der Hauptsache erheben | 31 d max 20 WD after | RoP.213 | | claimant | | |
|
||||
|
||||
## Schutzschrift · `upc.pl.cfi`
|
||||
|
||||
*Protective Letter*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Einreichung der Schutzschrift | 0 M after | RoP.207 | | defendant | | |
|
||||
| 2 | Erneuerung der Schutzschrift | 6 M after | RoP.207.9 | Protective Letter | — | | |
|
||||
|
||||
## Berufungsverfahren Kosten · `upc.apl.cost`
|
||||
|
||||
*Cost-Decision Appeal*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Kostenfestsetzungsbeschluss | 0 M after | RoP.221.4 | | court-set | | |
|
||||
| 2 | Antrag auf Berufungszulassung | 15 d after | RoP.221.1 | | either | | |
|
||||
| 3 | Antrag auf Berufungszulassung gegen Kostenentscheidungen | 15 d after | RoP.220.2 | Decision on fixation of costs (Rule 157) | — | | |
|
||||
|
||||
## Negative Feststellungsklage · `upc.dni.cfi`
|
||||
|
||||
*Declaration of Non-Infringement*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Klage auf negative Feststellung der Nichtverletzung | 0 M after | RoP.063 | | claimant | | |
|
||||
| 2 | Erwiderung auf die negative Feststellungsklage | 2 M after | RoP.066 | Statement for a declaration of non-infringement | — | | |
|
||||
| 3 | Replik auf die Erwiderung zur negativen Feststellungsklage | 1 M after | RoP.067 | Defence to the Statement for a declaration of non-infringement | — | | |
|
||||
| 4 | Duplik zur Replik auf die Erwiderung zur negativen Feststellungsklage | 1 M after | RoP.068 | Reply to the Defence to the Statement for a declaration of non-infringement | — | | |
|
||||
|
||||
## Überprüfung von EPA-Entscheidungen · `upc.epo.review`
|
||||
|
||||
*Review of EPO decisions*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag auf Überprüfung der EPA-Entscheidung | 0 M after | RoP.088 | | — | | |
|
||||
| 2 | Antrag auf Aufhebung einer Entscheidung des EPA, mit der ein Antrag auf einheitliche Wirkung zurückgewiesen wurde | 3 w after | RoP.097 | Decision of the EPO not to grant unitary effect | — | | |
|
||||
|
||||
## Separate Kostenentscheidung · `upc.costs.cfi`
|
||||
|
||||
*Separate Cost Decision*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag auf Kostenfestsetzung | 1 M after | RoP.151 | | claimant | | |
|
||||
|
||||
## Beweissicherung / saisie · `upc.bsv.cfi`
|
||||
|
||||
*Evidence Preservation*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag auf Beweissicherung | 0 M after | RoP.192 | | court-set | | |
|
||||
| 2 | Antrag auf Überprüfung der Beweissicherungsanordnung | 30 d after | RoP.197.3 | Execution of measures to preserve evidence | — | | |
|
||||
| 3 | Beginn des Hauptsacheverfahrens | 31 d max 20 WD after | RoP.198 | Date specified in the Court's order to preserve evidence | — | | |
|
||||
|
||||
## Widerklage auf Nichtigkeit · `upc.ccr.cfi`
|
||||
|
||||
*Counterclaim for Revocation*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Widerklage auf Nichtigkeit | 3 M after | RoP.025 | | defendant | | |
|
||||
|
||||
---
|
||||
|
||||
**Lesehilfe:**
|
||||
- ⤴ Spawn-Marker: event opens a sub-proceeding (e.g. CCR forks revocation track)
|
||||
- `with_ccr` = Widerklage auf Nichtigkeit gefilt | `with_amend` = Patentänderungsantrag | `with_cci` = Widerklage auf Verletzung (in rev.cfi)
|
||||
- Catalog ist work-in-progress: 7 compound-name rules + Patentänderung-Duplikation noch in m's split-review backlog (m/paliad#149).
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type DeadlineResponse,
|
||||
type Side,
|
||||
} from "./views/verfahrensablauf-core";
|
||||
import { filterByDetailMode, type DetailMode } from "./verfahrensablauf-detail-mode";
|
||||
import { mountAddProceedingPicker, type ProceedingTypeMeta } from "./builder-picker";
|
||||
import { overlayEventStates, renderTriplet, type ScenarioFlagCatalogEntry } from "./builder-triplet";
|
||||
import {
|
||||
@@ -550,15 +551,32 @@ async function hydrateTriplet(
|
||||
return;
|
||||
}
|
||||
const stichtag = proc.stichtag || state.active?.stichtag || todayISO();
|
||||
// t-paliad-348 — port engine semantics to the Builder triplet calc:
|
||||
// - `includeOptional` follows the proceeding's detailgrad. The
|
||||
// "selected" default suppresses optional rules server-side
|
||||
// (engine drops them); "all_options" opts in so the dimmed
|
||||
// optional cards can be rendered for the lawyer to opt into.
|
||||
// - `filterByDetailMode` then runs client-side over what the
|
||||
// engine emitted, dropping isConditional rows (rules whose
|
||||
// `trigger_event_id` anchor wasn't supplied) when the lawyer
|
||||
// is on "selected"/"mandatory_only" — those rules belong to the
|
||||
// "naked proceeding with options but not always displayed"
|
||||
// mental model and shouldn't pollute the backbone view.
|
||||
const detailgrad: DetailMode = (proc.detailgrad as DetailMode) || "selected";
|
||||
const data: DeadlineResponse | null = await calculateDeadlines({
|
||||
proceedingType: meta.code,
|
||||
triggerDate: stichtag,
|
||||
flags: scenarioFlagsToArray(proc.scenario_flags),
|
||||
includeOptional: detailgrad === "all_options",
|
||||
});
|
||||
const side: Side = (proc.primary_party as Side) || null;
|
||||
const eventsByRule = buildEventsByRule(proc.id);
|
||||
const columnsHtml = data
|
||||
? renderColumnsBody(data, { editable: false, side, showDurations: false })
|
||||
const scenarioFlagsBool = scenarioFlagsToBoolMap(proc.scenario_flags);
|
||||
const filteredData: DeadlineResponse | null = data
|
||||
? { ...data, deadlines: filterByDetailMode(data.deadlines, detailgrad, scenarioFlagsBool) }
|
||||
: null;
|
||||
const columnsHtml = filteredData
|
||||
? renderColumnsBody(filteredData, { editable: false, side, showDurations: false })
|
||||
: "";
|
||||
host.innerHTML = renderTriplet({
|
||||
proceeding: proc,
|
||||
@@ -927,6 +945,19 @@ function scenarioFlagsToArray(flags: Record<string, unknown>): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
// scenarioFlagsToBoolMap narrows the jsonb-shape scenario_flags blob
|
||||
// (`{key: true|false|null|other}`) to the strict `Record<string, boolean>`
|
||||
// shape filterByDetailMode consumes. The rule:<uuid>=true|false per-rule
|
||||
// deviation keys flow through verbatim (their truthiness IS the override
|
||||
// signal isRuleSelected reads).
|
||||
function scenarioFlagsToBoolMap(flags: Record<string, unknown>): Record<string, boolean> {
|
||||
const out: Record<string, boolean> = {};
|
||||
for (const [k, v] of Object.entries(flags)) {
|
||||
if (typeof v === "boolean") out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Actions
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -176,6 +176,20 @@ const APPEAL_TARGET_PROCEEDINGS = new Set([
|
||||
"upc.apl.unified",
|
||||
]);
|
||||
|
||||
// hasOptionalOptIn — true when any `rule:<uuid>=true` override exists in
|
||||
// the scenarioFlags map (the per-rule "Aufnehmen" deviation written by
|
||||
// the detail-mode selection-chip in onRuleSelectionToggle). When set we
|
||||
// flip the engine's IncludeOptional on so the chosen optional rules
|
||||
// actually reach the response; the engine has no rule:<uuid> awareness
|
||||
// of its own so without this layer the pick would silently no-op.
|
||||
// (t-paliad-348)
|
||||
function hasOptionalOptIn(flags: Record<string, boolean>): boolean {
|
||||
for (const [k, v] of Object.entries(flags)) {
|
||||
if (v === true && k.startsWith("rule:")) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasAppealTarget(proceedingType: string): boolean {
|
||||
return APPEAL_TARGET_PROCEEDINGS.has(proceedingType);
|
||||
}
|
||||
@@ -408,6 +422,16 @@ async function doCalc() {
|
||||
perCardChoices,
|
||||
includeHidden: showHidden,
|
||||
appealTarget,
|
||||
// t-paliad-348 — match the page-level detail mode to the engine's
|
||||
// optional-rule suppression. The engine drops optional rules by
|
||||
// default (IncludeOptional=false); "all_options" mode needs them
|
||||
// back in the response so filterByDetailMode can dim them. We
|
||||
// also opt-in whenever any per-rule `rule:<uuid>=true` deviation
|
||||
// is set on scenarioFlags so an "Aufnehmen"-ed optional in
|
||||
// "selected" mode still surfaces — the engine doesn't read
|
||||
// rule:<uuid> overrides, so without this the user's pick would
|
||||
// silently no-op server-side.
|
||||
includeOptional: detailMode === "all_options" || hasOptionalOptIn(scenarioFlags),
|
||||
});
|
||||
if (seq !== calcSeq) return;
|
||||
if (!data) return;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type CalculatedDeadline,
|
||||
type DeadlineResponse,
|
||||
bucketDeadlinesIntoColumns,
|
||||
calculateDeadlines,
|
||||
deadlineCardHtml,
|
||||
formatDurationLabel,
|
||||
renderColumnsBody,
|
||||
@@ -773,3 +774,81 @@ describe("stripLeadingDurationFromNotes — render-side dedup (t-paliad-307)", (
|
||||
.toBe("Time limit set by the court");
|
||||
});
|
||||
});
|
||||
|
||||
// Pin the engine-options plumbing surface (t-paliad-348 / yoUPC#178).
|
||||
// calculateDeadlines must forward `includeOptional` and
|
||||
// `triggerEventAnchors` straight into the POST body so the Go handler
|
||||
// (handleFristenrechnerAPI) can pass them into lp.CalcOptions. If a
|
||||
// future refactor drops the fields, the Builder triplet silently
|
||||
// reverts to "engine emits optional rules" and the unified
|
||||
// /tools/procedures page loses its naked-proceeding default.
|
||||
describe("calculateDeadlines — forwards engine options into request body", () => {
|
||||
type CapturedRequest = { url: string; body: Record<string, unknown> };
|
||||
let captured: CapturedRequest | null;
|
||||
let originalFetch: typeof globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
captured = null;
|
||||
originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
|
||||
captured = { url: String(input), body };
|
||||
return new Response(JSON.stringify({
|
||||
proceedingType: "x", proceedingName: "x", triggerDate: "2026-01-01", deadlines: [],
|
||||
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}) as typeof globalThis.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test("default call omits includeOptional and triggerEventAnchors", async () => {
|
||||
await calculateDeadlines({ proceedingType: "upc.inf.cfi", triggerDate: "2026-05-26" });
|
||||
expect(captured).not.toBeNull();
|
||||
expect(captured!.body.includeOptional).toBeUndefined();
|
||||
expect(captured!.body.triggerEventAnchors).toBeUndefined();
|
||||
});
|
||||
|
||||
test("includeOptional=true sends includeOptional: true", async () => {
|
||||
await calculateDeadlines({
|
||||
proceedingType: "upc.inf.cfi",
|
||||
triggerDate: "2026-05-26",
|
||||
includeOptional: true,
|
||||
});
|
||||
expect(captured!.body.includeOptional).toBe(true);
|
||||
});
|
||||
|
||||
test("includeOptional=false is omitted (matches engine default)", async () => {
|
||||
await calculateDeadlines({
|
||||
proceedingType: "upc.inf.cfi",
|
||||
triggerDate: "2026-05-26",
|
||||
includeOptional: false,
|
||||
});
|
||||
expect(captured!.body.includeOptional).toBeUndefined();
|
||||
});
|
||||
|
||||
test("triggerEventAnchors forwarded as object", async () => {
|
||||
await calculateDeadlines({
|
||||
proceedingType: "upc.inf.cfi",
|
||||
triggerDate: "2026-05-26",
|
||||
triggerEventAnchors: {
|
||||
"upc.inf.cfi.oral": "2026-09-01",
|
||||
"upc.inf.cfi.decision": "2026-12-15",
|
||||
},
|
||||
});
|
||||
expect(captured!.body.triggerEventAnchors).toEqual({
|
||||
"upc.inf.cfi.oral": "2026-09-01",
|
||||
"upc.inf.cfi.decision": "2026-12-15",
|
||||
});
|
||||
});
|
||||
|
||||
test("empty triggerEventAnchors is omitted", async () => {
|
||||
await calculateDeadlines({
|
||||
proceedingType: "upc.inf.cfi",
|
||||
triggerDate: "2026-05-26",
|
||||
triggerEventAnchors: {},
|
||||
});
|
||||
expect(captured!.body.triggerEventAnchors).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -271,6 +271,12 @@ export interface DeadlineResponse {
|
||||
// when the toggle is OFF — so users know there's something to
|
||||
// re-surface.
|
||||
hiddenCount?: number;
|
||||
// rulesAwaitingAnchor (t-paliad-348 / yoUPC#178): number of rules the
|
||||
// engine suppressed because their `trigger_event_id` anchor wasn't
|
||||
// supplied via CalcParams.triggerEventAnchors. Mirrors the Go
|
||||
// Timeline.RulesAwaitingAnchor counter — a single integer surface for
|
||||
// "N rules waiting on an anchor" UI affordances.
|
||||
rulesAwaitingAnchor?: number;
|
||||
}
|
||||
|
||||
export interface CourtRow {
|
||||
@@ -311,6 +317,20 @@ export interface CalcParams {
|
||||
// endentscheidung | kostenentscheidung | anordnung |
|
||||
// schadensbemessung | bucheinsicht.
|
||||
appealTarget?: string;
|
||||
// t-paliad-348 / yoUPC#178 — surface the engine's two new CalcOptions
|
||||
// axes to the HTTP boundary:
|
||||
//
|
||||
// includeOptional: when true, the engine returns priority='optional'
|
||||
// rules in the timeline. Default false matches the engine default
|
||||
// (mandatory backbone only). The /tools/procedures detailgrad
|
||||
// toggle ("all_options" mode) drives this to true so the dimmed
|
||||
// optional cards can be rendered for the lawyer to opt into.
|
||||
// triggerEventAnchors: per-event-code anchor dates the engine
|
||||
// consults for rules carrying trigger_event_id. Empty/omitted =
|
||||
// no anchors → such rules render as IsConditional (the engine
|
||||
// refuses to fabricate a date off the proceeding's trigger date).
|
||||
includeOptional?: boolean;
|
||||
triggerEventAnchors?: Record<string, string>;
|
||||
}
|
||||
|
||||
const PARTY_CLASS: Record<string, string> = {
|
||||
@@ -1118,6 +1138,10 @@ export async function calculateDeadlines(params: CalcParams): Promise<DeadlineRe
|
||||
: undefined,
|
||||
includeHidden: params.includeHidden ? true : undefined,
|
||||
appealTarget: params.appealTarget || undefined,
|
||||
includeOptional: params.includeOptional ? true : undefined,
|
||||
triggerEventAnchors: params.triggerEventAnchors && Object.keys(params.triggerEventAnchors).length > 0
|
||||
? params.triggerEventAnchors
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
|
||||
@@ -91,6 +91,19 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
// slugs are silently dropped (no filter) so a stale frontend
|
||||
// chip doesn't 400 the request.
|
||||
AppealTarget string `json:"appealTarget,omitempty"`
|
||||
// t-paliad-348 / yoUPC#178 — surface the engine's two new
|
||||
// CalcOptions axes to the HTTP boundary:
|
||||
//
|
||||
// IncludeOptional: when true, priority='optional' rules
|
||||
// surface on the timeline. Default false matches the
|
||||
// engine's default (mandatory backbone only).
|
||||
// TriggerEventAnchors: per-event-code anchor dates the
|
||||
// engine consults for rules carrying trigger_event_id.
|
||||
// When a rule's anchor is absent the engine renders the
|
||||
// rule as IsConditional rather than fabricating a date
|
||||
// off the proceeding's trigger date.
|
||||
IncludeOptional bool `json:"includeOptional,omitempty"`
|
||||
TriggerEventAnchors map[string]string `json:"triggerEventAnchors,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
|
||||
@@ -130,15 +143,17 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
resp, err := dbSvc.fristenrechner.Calculate(r.Context(), req.ProceedingType, req.TriggerDate, services.CalcOptions{
|
||||
PriorityDateStr: req.PriorityDate,
|
||||
Flags: req.Flags,
|
||||
AnchorOverrides: req.AnchorOverrides,
|
||||
CourtID: req.CourtID,
|
||||
PerCardAppellant: addendum.PerCardAppellant,
|
||||
SkipRules: addendum.SkipRules,
|
||||
IncludeCCRFor: addendum.IncludeCCRFor,
|
||||
IncludeHidden: req.IncludeHidden,
|
||||
AppealTarget: req.AppealTarget,
|
||||
PriorityDateStr: req.PriorityDate,
|
||||
Flags: req.Flags,
|
||||
AnchorOverrides: req.AnchorOverrides,
|
||||
CourtID: req.CourtID,
|
||||
PerCardAppellant: addendum.PerCardAppellant,
|
||||
SkipRules: addendum.SkipRules,
|
||||
IncludeCCRFor: addendum.IncludeCCRFor,
|
||||
IncludeHidden: req.IncludeHidden,
|
||||
AppealTarget: req.AppealTarget,
|
||||
IncludeOptional: req.IncludeOptional,
|
||||
TriggerEventAnchors: req.TriggerEventAnchors,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrUnknownProceedingType) {
|
||||
|
||||
66
internal/services/docforge_shims.go
Normal file
66
internal/services/docforge_shims.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package services
|
||||
|
||||
// Shims bridging the submission generator to the extracted docforge .docx
|
||||
// adapter (pkg/docforge/docx). Slice 1 of the docforge train
|
||||
// (t-paliad-349 / m/paliad#157) relocated the Markdown→OOXML walker, the
|
||||
// placeholder substitution engine, and the .dotm→.docx converter into
|
||||
// pkg/docforge/docx with no behaviour change. These type aliases and
|
||||
// forwarders keep every existing caller in internal/services and
|
||||
// internal/handlers compiling and behaving identically — the names,
|
||||
// signatures, and semantics are unchanged; only the implementation moved.
|
||||
//
|
||||
// Later slices retire these shims as the submission services are
|
||||
// refactored to call docforge directly through the neutral model and the
|
||||
// VariableResolver interface.
|
||||
|
||||
import (
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge/docx"
|
||||
)
|
||||
|
||||
// PlaceholderMap is the variable bag (dotted-key → substituted value),
|
||||
// built by SubmissionVarsService and consumed by the renderer. The
|
||||
// canonical type lives in the docforge root (the format-neutral
|
||||
// variable-bag contract).
|
||||
type PlaceholderMap = docforge.PlaceholderMap
|
||||
|
||||
// MissingPlaceholderFn translates an unbound placeholder key into the
|
||||
// in-document marker token.
|
||||
type MissingPlaceholderFn = docforge.MissingPlaceholderFn
|
||||
|
||||
// SubmissionRenderer renders a .docx template by substituting
|
||||
// {{placeholder}} tokens. Stateless; safe for concurrent use.
|
||||
type SubmissionRenderer = docx.SubmissionRenderer
|
||||
|
||||
// HyperlinkAllocator hands the Markdown walker a rId for each external
|
||||
// URL it encounters in [label](url) inline links.
|
||||
type HyperlinkAllocator = docx.HyperlinkAllocator
|
||||
|
||||
// NewSubmissionRenderer constructs the renderer.
|
||||
func NewSubmissionRenderer() *SubmissionRenderer { return docx.NewSubmissionRenderer() }
|
||||
|
||||
// DefaultMissingMarker returns the standard missing-value marker for the
|
||||
// given UI language ("[KEIN WERT: <key>]" / "[NO VALUE: <key>]").
|
||||
func DefaultMissingMarker(lang string) MissingPlaceholderFn {
|
||||
return docforge.DefaultMissingMarker(lang)
|
||||
}
|
||||
|
||||
// RenderMarkdownToOOXML renders Markdown source into OOXML paragraph
|
||||
// elements using a single paragraph style.
|
||||
func RenderMarkdownToOOXML(md, paragraphStyle string) string {
|
||||
return docx.RenderMarkdownToOOXML(md, paragraphStyle)
|
||||
}
|
||||
|
||||
// RenderMarkdownToOOXMLWithStyles is the full rich-prose entry point
|
||||
// (headings, lists, blockquote, inline hyperlinks via the allocator).
|
||||
func RenderMarkdownToOOXMLWithStyles(md string, stylemap map[string]string, links HyperlinkAllocator) string {
|
||||
return docx.RenderMarkdownToOOXMLWithStyles(md, stylemap, links)
|
||||
}
|
||||
|
||||
// ConvertDotmToDocx rewrites a .dotm/.docm/.dotx zip into a clean .docx
|
||||
// zip. Idempotent on a zip that is already a plain .docx.
|
||||
func ConvertDotmToDocx(dotmBytes []byte) ([]byte, error) { return docx.ConvertDotmToDocx(dotmBytes) }
|
||||
|
||||
// SanitiseSubmissionFileName cleans a string for use inside a download
|
||||
// filename (strips path separators / quotes, ASCII-folds DE umlauts).
|
||||
func SanitiseSubmissionFileName(s string) string { return docx.SanitiseSubmissionFileName(s) }
|
||||
@@ -1,93 +1,73 @@
|
||||
package services
|
||||
|
||||
// Composer render pipeline — t-paliad-313 Slice B (design doc §9.1 +
|
||||
// §9.2). Assembles a base .docx and a draft's section rows into a
|
||||
// merged .docx ready for export.
|
||||
// Composer wrapper — bridges paliad's submission draft model
|
||||
// (SubmissionSection + SubmissionBase) to the format-neutral docforge
|
||||
// .docx composer (pkg/docforge/docx), extracted in slice 2 of the
|
||||
// docforge train (t-paliad-349 / m/paliad#157).
|
||||
//
|
||||
// Pipeline (high-level):
|
||||
// The full splice/assembly pipeline now lives in pkg/docforge/docx
|
||||
// (compose.go): macro pre-pass, anchor-pair splicing, append-before-sectPr,
|
||||
// hyperlink-rels patching, zip repack, and the final placeholder pass. This
|
||||
// wrapper does the one thing the engine must not know about — mapping
|
||||
// paliad's DB row types onto the neutral docx.Section / docx.Carrier
|
||||
// inputs. Behaviour is byte-identical to the pre-extraction composer; the
|
||||
// in-package compose_test still drives this wrapper end-to-end.
|
||||
//
|
||||
// 1. ConvertDotmToDocx pre-pass on the base bytes (idempotent on .docx).
|
||||
// 2. Locate `word/document.xml` inside the zip; pull the body XML.
|
||||
// 3. For each section in the draft (order_index ASC, included=true):
|
||||
// render content_md_<lang> → OOXML via RenderMarkdownToOOXML using
|
||||
// base.section_spec.stylemap.paragraph.
|
||||
// 4. Splice the rendered OOXML into the base body. Two splice modes:
|
||||
// - Anchor mode: when the body carries `{{#section:KEY}}` /
|
||||
// `{{/section:KEY}}` marker pairs, replace the slot's content
|
||||
// (including the anchor paragraphs themselves) with the rendered
|
||||
// section.
|
||||
// - Append mode: when no anchor pair is found for a section, the
|
||||
// rendered OOXML appends at the end of the body, just before any
|
||||
// `<w:sectPr>` element. Sections with `included=false` are
|
||||
// dropped silently.
|
||||
// 5. Strip any leftover unmatched anchor paragraphs.
|
||||
// 6. Re-pack the document.xml into the zip, leaving every other part
|
||||
// untouched.
|
||||
// 7. Run the v1 SubmissionRenderer placeholder pass over the assembly
|
||||
// so `{{path}}` placeholders inside section content (and inside
|
||||
// the base's untouched chrome) get substituted by the merged bag.
|
||||
// Cross-run merge in pass 2 handles autocorrect-fragmented
|
||||
// placeholders the same as v1.
|
||||
//
|
||||
// Result: a fully-merged .docx. No new third-party Go dep — reuses
|
||||
// archive/zip + the existing SubmissionRenderer.
|
||||
// Slice note: the paragraph-level neutral document model (Document / Block
|
||||
// / Slot) the PRD §3.2 sketches lands in slice 6, where the authoring
|
||||
// importer and the format exporters actually consume it. Building it now,
|
||||
// ahead of any consumer, would be speculative and would put the
|
||||
// byte-identical guarantee at risk for no gain (PRD §4 B3 principle:
|
||||
// extractions earn their keep this cycle).
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge/docx"
|
||||
)
|
||||
|
||||
// SubmissionComposer assembles base + sections into a final .docx.
|
||||
// Stateless; safe for concurrent use.
|
||||
// SubmissionComposer assembles a base + a draft's sections into a final
|
||||
// .docx. Stateless; safe for concurrent use.
|
||||
type SubmissionComposer struct {
|
||||
renderer *SubmissionRenderer
|
||||
inner *docx.Composer
|
||||
}
|
||||
|
||||
// NewSubmissionComposer wires the composer. The renderer is required —
|
||||
// a nil renderer is a programmer error and the composer panics at
|
||||
// NewSubmissionComposer wires the composer. The renderer is required — a
|
||||
// nil renderer is a programmer error and the composer panics at
|
||||
// construction.
|
||||
func NewSubmissionComposer(renderer *SubmissionRenderer) *SubmissionComposer {
|
||||
if renderer == nil {
|
||||
panic("submission composer: renderer required")
|
||||
}
|
||||
return &SubmissionComposer{renderer: renderer}
|
||||
return &SubmissionComposer{inner: docx.NewComposer(renderer)}
|
||||
}
|
||||
|
||||
// ComposeOptions carries the per-call composition inputs.
|
||||
// ComposeOptions carries the per-call composition inputs in paliad's own
|
||||
// terms (SubmissionSection rows + the SubmissionBase chrome).
|
||||
type ComposeOptions struct {
|
||||
// Sections are the draft's section rows in display order. The
|
||||
// composer renders included sections; excluded rows are dropped.
|
||||
// Caller is responsible for visibility — by the time the composer
|
||||
// runs, the section rows have already been gated through
|
||||
// SubmissionDraftService.Get + can_see_project.
|
||||
// Sections are the draft's section rows in display order. Included
|
||||
// sections render; excluded rows are dropped. The caller is
|
||||
// responsible for visibility — by the time the composer runs the rows
|
||||
// have already been gated through SubmissionDraftService.Get +
|
||||
// can_see_project.
|
||||
Sections []SubmissionSection
|
||||
|
||||
// Base supplies the document chrome (.docx body host) plus the
|
||||
// stylemap for the MD walker. Must not be nil.
|
||||
// Base supplies the document chrome plus the stylemap for the MD
|
||||
// walker. Must not be nil.
|
||||
Base *SubmissionBase
|
||||
|
||||
// BaseBytes is the raw .docx bytes for the base. Typically fetched
|
||||
// BaseBytes is the raw .docx bytes for the base, typically fetched
|
||||
// from Gitea via the existing template cache.
|
||||
BaseBytes []byte
|
||||
|
||||
// Lang ('de' or 'en') selects which content_md_* column the
|
||||
// composer reads per section. Defaults to 'de' if empty.
|
||||
// Lang ('de' or 'en') selects which content_md_* column the composer
|
||||
// reads per section. Defaults to 'de' if empty.
|
||||
Lang string
|
||||
|
||||
// Vars is the merged placeholder bag the v1 renderer pass
|
||||
// substitutes after the composer assembly. Passed straight through
|
||||
// to SubmissionRenderer.Render.
|
||||
// Vars is the merged placeholder bag the renderer pass substitutes
|
||||
// after assembly.
|
||||
Vars PlaceholderMap
|
||||
|
||||
// Missing translates an unbound placeholder key into the marker
|
||||
// the lawyer sees in Word. Passed straight to the renderer.
|
||||
// Missing translates an unbound placeholder key into the marker the
|
||||
// lawyer sees in Word.
|
||||
Missing MissingPlaceholderFn
|
||||
}
|
||||
|
||||
@@ -96,512 +76,24 @@ func (c *SubmissionComposer) Compose(ctx context.Context, opts ComposeOptions) (
|
||||
if opts.Base == nil {
|
||||
return nil, fmt.Errorf("submission compose: base required")
|
||||
}
|
||||
_ = ctx // reserved for cancellation propagation in later slices
|
||||
sections := opts.Sections
|
||||
|
||||
// Pre-pass: strip macros so the base reads as a plain .docx zip.
|
||||
cleanBytes, err := ConvertDotmToDocx(opts.BaseBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission compose: convert base: %w", err)
|
||||
}
|
||||
|
||||
// Locate + extract word/document.xml so we can splice in-place.
|
||||
documentXML, otherParts, err := splitBaseZip(cleanBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Per-compose hyperlink allocator. Each unique URL gets a fresh
|
||||
// rId outside the base's existing namespace. The post-pass
|
||||
// (patchDocumentXMLRels) writes the matching Relationship rows
|
||||
// before the zip is repacked. Slice D adds inline `[label](url)`
|
||||
// hyperlink support.
|
||||
linkAlloc := newComposerLinkAllocator()
|
||||
|
||||
// Build the rendered-section map: section_key → OOXML span.
|
||||
stylemap := opts.Base.SectionSpec.Stylemap
|
||||
rendered := make(map[string]string, len(sections))
|
||||
keptSections := make([]SubmissionSection, 0, len(sections))
|
||||
for _, sec := range sections {
|
||||
if !sec.Included {
|
||||
continue
|
||||
secs := make([]docx.Section, len(opts.Sections))
|
||||
for i, s := range opts.Sections {
|
||||
secs[i] = docx.Section{
|
||||
Key: s.SectionKey,
|
||||
OrderIndex: s.OrderIndex,
|
||||
Included: s.Included,
|
||||
ContentMDDE: s.ContentMDDE,
|
||||
ContentMDEN: s.ContentMDEN,
|
||||
}
|
||||
md := sec.ContentMDDE
|
||||
if strings.EqualFold(opts.Lang, "en") {
|
||||
md = sec.ContentMDEN
|
||||
}
|
||||
rendered[sec.SectionKey] = RenderMarkdownToOOXMLWithStyles(md, stylemap, linkAlloc.Alloc)
|
||||
keptSections = append(keptSections, sec)
|
||||
}
|
||||
// Stable order — already sorted ascending by ListForDraft, but
|
||||
// belt-and-braces in case the caller swaps the ordering policy
|
||||
// later.
|
||||
sort.SliceStable(keptSections, func(i, j int) bool {
|
||||
return keptSections[i].OrderIndex < keptSections[j].OrderIndex
|
||||
return c.inner.Compose(ctx, docx.ComposeOptions{
|
||||
Sections: secs,
|
||||
Carrier: docx.Carrier{
|
||||
Bytes: opts.BaseBytes,
|
||||
Stylemap: opts.Base.SectionSpec.Stylemap,
|
||||
},
|
||||
Lang: opts.Lang,
|
||||
Vars: opts.Vars,
|
||||
Missing: opts.Missing,
|
||||
})
|
||||
|
||||
assembledBody := spliceSections(documentXML, rendered, keptSections, sections)
|
||||
|
||||
// Slice D hyperlink patch: when the walker emitted hyperlink rIds
|
||||
// for inline `[label](url)` links, the base's
|
||||
// word/_rels/document.xml.rels needs matching <Relationship>
|
||||
// entries so Word can resolve the rIds. Mutates one zip part in
|
||||
// otherParts (or appends if missing).
|
||||
if linkAlloc.HasLinks() {
|
||||
updatedParts, err := patchDocumentXMLRels(otherParts, linkAlloc.Pairs())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
otherParts = updatedParts
|
||||
}
|
||||
|
||||
// Re-pack into a zip with the assembled document.xml. All other
|
||||
// parts (styles, fonts, headers, footers, theme, settings) pass
|
||||
// through bit-for-bit at their original mtime + compression.
|
||||
repacked, err := repackBaseZip(otherParts, assembledBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Final pass: substitute placeholders against the merged bag. The
|
||||
// existing renderer handles cross-run fragmentation, the `{{rule.X}}`
|
||||
// alias contract, and the missing-marker emission. Reusing it
|
||||
// guarantees v1's placeholder grammar stays intact inside section
|
||||
// content + base chrome.
|
||||
merged, err := c.renderer.Render(repacked, opts.Vars, opts.Missing)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission compose: placeholder pass: %w", err)
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Section splicing
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Anchor markers as they appear inside a <w:t> text node. We don't
|
||||
// need a full XML parse — finding the marker text inside the body is
|
||||
// sufficient because:
|
||||
// - {{ and }} are never legitimate document content (placeholders
|
||||
// follow the same convention everywhere else in paliad).
|
||||
// - The anchor key grammar [A-Za-z0-9_]+ rules out any HTML/XML
|
||||
// special characters.
|
||||
// - Each anchor lives in exactly one <w:t>...<w:t>, which lives in
|
||||
// exactly one <w:r>...</w:r>, which lives in exactly one
|
||||
// <w:p>...</w:p>. We expand from the marker outward to find the
|
||||
// enclosing <w:p> span and drop the entire paragraph as part of
|
||||
// the splice.
|
||||
//
|
||||
// RE2 has no lookahead, so the "find enclosing <w:p>" logic is
|
||||
// implemented as manual byte-index search around the marker hit
|
||||
// (anchorParagraphSpan below) rather than a single regex pattern.
|
||||
|
||||
const (
|
||||
anchorOpenPrefix = "{{#section:"
|
||||
anchorClosePrefix = "{{/section:"
|
||||
anchorSuffix = "}}"
|
||||
)
|
||||
|
||||
// anchorKeyRegex validates that the captured anchor key is a clean
|
||||
// identifier. Keys that include other characters (which can't actually
|
||||
// appear in our authored .docx) are treated as no match.
|
||||
var anchorKeyRegex = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
|
||||
|
||||
// anchorPair records the byte span of one matched anchor pair inside
|
||||
// the body — from the start of the opening anchor's <w:p> element
|
||||
// through the end of the closing anchor's </w:p>.
|
||||
type anchorPair struct {
|
||||
key string
|
||||
openStart int // start of <w:p> for the opening anchor
|
||||
closeEnd int // index just past </w:p> for the closing anchor
|
||||
}
|
||||
|
||||
// findAllAnchorPairs scans the body for matched open/close anchor
|
||||
// pairs. Unbalanced markers (open without close, or vice versa) are
|
||||
// dropped from the result. Returns pairs in body-order; each pair's
|
||||
// span is non-overlapping.
|
||||
func findAllAnchorPairs(body string) []anchorPair {
|
||||
type marker struct {
|
||||
key string
|
||||
paraStart int
|
||||
paraEnd int
|
||||
isOpen bool
|
||||
}
|
||||
var markers []marker
|
||||
|
||||
collect := func(prefix string, isOpen bool) {
|
||||
offset := 0
|
||||
for {
|
||||
idx := strings.Index(body[offset:], prefix)
|
||||
if idx < 0 {
|
||||
return
|
||||
}
|
||||
start := offset + idx
|
||||
suffixIdx := strings.Index(body[start+len(prefix):], anchorSuffix)
|
||||
if suffixIdx < 0 {
|
||||
return
|
||||
}
|
||||
key := body[start+len(prefix) : start+len(prefix)+suffixIdx]
|
||||
if !anchorKeyRegex.MatchString(key) {
|
||||
offset = start + len(prefix)
|
||||
continue
|
||||
}
|
||||
markerEnd := start + len(prefix) + suffixIdx + len(anchorSuffix)
|
||||
pStart, pEnd, ok := paragraphSpanAround(body, start, markerEnd)
|
||||
if !ok {
|
||||
offset = markerEnd
|
||||
continue
|
||||
}
|
||||
markers = append(markers, marker{key: key, paraStart: pStart, paraEnd: pEnd, isOpen: isOpen})
|
||||
offset = pEnd
|
||||
}
|
||||
}
|
||||
collect(anchorOpenPrefix, true)
|
||||
collect(anchorClosePrefix, false)
|
||||
|
||||
// Walk markers in body-order, matching each open with the next
|
||||
// close that carries the same key.
|
||||
sort.SliceStable(markers, func(i, j int) bool {
|
||||
return markers[i].paraStart < markers[j].paraStart
|
||||
})
|
||||
var pairs []anchorPair
|
||||
openStack := map[string]marker{}
|
||||
for _, m := range markers {
|
||||
if m.isOpen {
|
||||
openStack[m.key] = m
|
||||
continue
|
||||
}
|
||||
o, ok := openStack[m.key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pairs = append(pairs, anchorPair{
|
||||
key: m.key,
|
||||
openStart: o.paraStart,
|
||||
closeEnd: m.paraEnd,
|
||||
})
|
||||
delete(openStack, m.key)
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
// paragraphSpanAround returns the byte span of the smallest `<w:p>...</w:p>`
|
||||
// element that fully contains the byte range [markerStart, markerEnd).
|
||||
// Returns false when the byte range doesn't sit inside a single
|
||||
// paragraph (which would mean the marker survived a cross-paragraph
|
||||
// edit — defensive guard, shouldn't happen in well-formed input).
|
||||
func paragraphSpanAround(body string, markerStart, markerEnd int) (int, int, bool) {
|
||||
// Walk backwards to find the nearest unclosed <w:p ... > opening.
|
||||
// Since <w:p> doesn't nest, the nearest <w:p before markerStart is
|
||||
// the enclosing paragraph's opening tag.
|
||||
pStart := -1
|
||||
cursor := markerStart
|
||||
for cursor > 0 {
|
||||
idx := strings.LastIndex(body[:cursor], "<w:p")
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
// Confirm this is a paragraph open, not a different
|
||||
// w:p-prefixed tag (e.g. <w:pPr>).
|
||||
if idx+4 <= len(body) {
|
||||
after := body[idx+4]
|
||||
if after == ' ' || after == '>' || after == '/' {
|
||||
// <w:p ...> or <w:p>; not <w:pPr>.
|
||||
close := strings.Index(body[idx:], ">")
|
||||
if close < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
pStart = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
cursor = idx
|
||||
}
|
||||
if pStart < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
// Walk forward to find the matching </w:p>. <w:p> doesn't nest so
|
||||
// the next </w:p> after the marker is the close.
|
||||
pEndIdx := strings.Index(body[markerEnd:], "</w:p>")
|
||||
if pEndIdx < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
pEnd := markerEnd + pEndIdx + len("</w:p>")
|
||||
return pStart, pEnd, true
|
||||
}
|
||||
|
||||
// spliceSections replaces anchor slots with rendered sections and
|
||||
// appends any unanchored sections before sectPr. Returns the assembled
|
||||
// document.xml body.
|
||||
func spliceSections(documentXML []byte, rendered map[string]string, kept []SubmissionSection, all []SubmissionSection) []byte {
|
||||
body := string(documentXML)
|
||||
pairs := findAllAnchorPairs(body)
|
||||
|
||||
// Build a lookup of kept section keys for quick membership tests.
|
||||
keptByKey := map[string]int{}
|
||||
for i, sec := range kept {
|
||||
keptByKey[sec.SectionKey] = i
|
||||
}
|
||||
allByKey := map[string]int{}
|
||||
for i, sec := range all {
|
||||
allByKey[sec.SectionKey] = i
|
||||
}
|
||||
|
||||
matchedKeys := map[string]bool{}
|
||||
|
||||
// Walk pairs in REVERSE body-order so slice mutations don't shift
|
||||
// later offsets.
|
||||
sort.SliceStable(pairs, func(i, j int) bool {
|
||||
return pairs[i].openStart > pairs[j].openStart
|
||||
})
|
||||
for _, p := range pairs {
|
||||
replacement := ""
|
||||
if idx, ok := keptByKey[p.key]; ok {
|
||||
replacement = rendered[p.key]
|
||||
matchedKeys[p.key] = true
|
||||
_ = idx
|
||||
} else if _, isOnDraft := allByKey[p.key]; isOnDraft {
|
||||
// Anchor matches an excluded section on the draft — drop
|
||||
// the entire slot.
|
||||
replacement = ""
|
||||
} else {
|
||||
// Anchor doesn't match any section on this draft — drop
|
||||
// to leave the base's chrome unbroken.
|
||||
replacement = ""
|
||||
}
|
||||
body = body[:p.openStart] + replacement + body[p.closeEnd:]
|
||||
}
|
||||
|
||||
// Append unanchored sections before sectPr in order_index ASC.
|
||||
var unanchored strings.Builder
|
||||
for _, sec := range kept {
|
||||
if matchedKeys[sec.SectionKey] {
|
||||
continue
|
||||
}
|
||||
unanchored.WriteString(rendered[sec.SectionKey])
|
||||
}
|
||||
if unanchored.Len() > 0 {
|
||||
body = appendBeforeSectPr(body, unanchored.String())
|
||||
}
|
||||
|
||||
return []byte(body)
|
||||
}
|
||||
|
||||
// appendBeforeSectPr inserts content immediately before the first
|
||||
// `<w:sectPr` element in the body, or at the end of the body if there
|
||||
// is none. Word documents conventionally close the body with a sectPr
|
||||
// describing page setup; we want to land sections before that element
|
||||
// so they show up on the actual pages.
|
||||
var sectPrRegex = regexp.MustCompile(`<w:sectPr\b`)
|
||||
|
||||
func appendBeforeSectPr(body, content string) string {
|
||||
loc := sectPrRegex.FindStringIndex(body)
|
||||
if loc == nil {
|
||||
// No sectPr → append before `</w:body>` if present, else at
|
||||
// the very end.
|
||||
idx := strings.LastIndex(body, "</w:body>")
|
||||
if idx < 0 {
|
||||
return body + content
|
||||
}
|
||||
return body[:idx] + content + body[idx:]
|
||||
}
|
||||
return body[:loc[0]] + content + body[loc[0]:]
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Zip plumbing
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// baseZipPart captures one zip entry we kept aside while extracting
|
||||
// document.xml.
|
||||
type baseZipPart struct {
|
||||
name string
|
||||
method uint16
|
||||
modTime int64 // wall seconds; converted back to time.Time on repack
|
||||
body []byte
|
||||
}
|
||||
|
||||
// splitBaseZip extracts document.xml and returns it alongside every
|
||||
// other zip entry, ready for repacking.
|
||||
func splitBaseZip(cleanBytes []byte) ([]byte, []baseZipPart, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(cleanBytes), int64(len(cleanBytes)))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("submission compose: open base zip: %w", err)
|
||||
}
|
||||
var documentXML []byte
|
||||
parts := make([]baseZipPart, 0, len(zr.File))
|
||||
for _, f := range zr.File {
|
||||
body, err := readZipEntry(f)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("submission compose: read %s: %w", f.Name, err)
|
||||
}
|
||||
if f.Name == "word/document.xml" {
|
||||
documentXML = body
|
||||
parts = append(parts, baseZipPart{name: f.Name, method: f.Method, modTime: f.Modified.Unix(), body: nil})
|
||||
continue
|
||||
}
|
||||
parts = append(parts, baseZipPart{name: f.Name, method: f.Method, modTime: f.Modified.Unix(), body: body})
|
||||
}
|
||||
if documentXML == nil {
|
||||
return nil, nil, fmt.Errorf("submission compose: base zip missing word/document.xml")
|
||||
}
|
||||
return documentXML, parts, nil
|
||||
}
|
||||
|
||||
// repackBaseZip rebuilds the zip, swapping document.xml for the
|
||||
// assembled body and leaving every other part untouched.
|
||||
func repackBaseZip(parts []baseZipPart, assembledBody []byte) ([]byte, error) {
|
||||
var out bytes.Buffer
|
||||
zw := zip.NewWriter(&out)
|
||||
for _, p := range parts {
|
||||
hdr := &zip.FileHeader{
|
||||
Name: p.name,
|
||||
Method: p.method,
|
||||
}
|
||||
if p.modTime > 0 {
|
||||
hdr.Modified = time.Unix(p.modTime, 0)
|
||||
}
|
||||
w, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission compose: write header %s: %w", p.name, err)
|
||||
}
|
||||
body := p.body
|
||||
if p.name == "word/document.xml" {
|
||||
body = assembledBody
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
return nil, fmt.Errorf("submission compose: write body %s: %w", p.name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("submission compose: finalise zip: %w", err)
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
func readZipEntry(f *zip.File) ([]byte, error) {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Slice D — hyperlink wiring
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// composerLinkAllocator hands out fresh rIds for inline hyperlink
|
||||
// targets discovered by the MD walker. Each unique URL gets one rId
|
||||
// (deduped — repeated links to the same URL share one Relationship).
|
||||
// Allocations land outside the base's rId namespace by prefixing with
|
||||
// "rIdComposer" so they can't collide with existing relationships.
|
||||
type composerLinkAllocator struct {
|
||||
next int
|
||||
byURL map[string]string
|
||||
order []string // URLs in allocation order
|
||||
}
|
||||
|
||||
func newComposerLinkAllocator() *composerLinkAllocator {
|
||||
return &composerLinkAllocator{byURL: map[string]string{}}
|
||||
}
|
||||
|
||||
// Alloc returns the rId for url, allocating one on first sight.
|
||||
func (a *composerLinkAllocator) Alloc(url string) string {
|
||||
if rid, ok := a.byURL[url]; ok {
|
||||
return rid
|
||||
}
|
||||
a.next++
|
||||
rid := fmt.Sprintf("rIdComposer%d", a.next)
|
||||
a.byURL[url] = rid
|
||||
a.order = append(a.order, url)
|
||||
return rid
|
||||
}
|
||||
|
||||
// HasLinks reports whether any links were allocated during this compose.
|
||||
func (a *composerLinkAllocator) HasLinks() bool {
|
||||
return len(a.order) > 0
|
||||
}
|
||||
|
||||
// Pairs returns the (rId, URL) pairs in allocation order. The
|
||||
// document.xml.rels patcher consumes this to emit <Relationship>
|
||||
// elements.
|
||||
func (a *composerLinkAllocator) Pairs() [][2]string {
|
||||
pairs := make([][2]string, 0, len(a.order))
|
||||
for _, url := range a.order {
|
||||
pairs = append(pairs, [2]string{a.byURL[url], url})
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
// patchDocumentXMLRels mutates the word/_rels/document.xml.rels entry
|
||||
// in `parts` to append the given (rId, URL) pairs as hyperlink
|
||||
// relationships. If the rels part doesn't exist (some bases omit it
|
||||
// when the body has no relationships), this function appends a fresh
|
||||
// part with the minimal Relationships wrapper.
|
||||
//
|
||||
// Idempotent on (rId, URL) pairs already present (e.g. when a base
|
||||
// already references the URL for some other reason).
|
||||
//
|
||||
// Returns the (possibly extended) parts slice — callers must overwrite
|
||||
// their reference because the append in the no-rels-yet case grows the
|
||||
// backing array.
|
||||
func patchDocumentXMLRels(parts []baseZipPart, pairs [][2]string) ([]baseZipPart, error) {
|
||||
const path = "word/_rels/document.xml.rels"
|
||||
const hyperlinkType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
|
||||
|
||||
existingIdx := -1
|
||||
for i := range parts {
|
||||
if parts[i].name == path {
|
||||
existingIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var body string
|
||||
if existingIdx >= 0 {
|
||||
body = string(parts[existingIdx].body)
|
||||
} else {
|
||||
body = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
|
||||
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`
|
||||
}
|
||||
|
||||
var inserts strings.Builder
|
||||
for _, p := range pairs {
|
||||
rid := p[0]
|
||||
url := p[1]
|
||||
if strings.Contains(body, `Id="`+rid+`"`) {
|
||||
continue
|
||||
}
|
||||
inserts.WriteString(`<Relationship Id="`)
|
||||
inserts.WriteString(xmlAttrEscape(rid))
|
||||
inserts.WriteString(`" Type="`)
|
||||
inserts.WriteString(hyperlinkType)
|
||||
inserts.WriteString(`" Target="`)
|
||||
inserts.WriteString(xmlAttrEscape(url))
|
||||
inserts.WriteString(`" TargetMode="External"/>`)
|
||||
}
|
||||
|
||||
if inserts.Len() == 0 {
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
closeIdx := strings.LastIndex(body, "</Relationships>")
|
||||
if closeIdx < 0 {
|
||||
return parts, fmt.Errorf("submission compose: malformed document.xml.rels (no closing tag)")
|
||||
}
|
||||
patched := body[:closeIdx] + inserts.String() + body[closeIdx:]
|
||||
|
||||
if existingIdx >= 0 {
|
||||
parts[existingIdx].body = []byte(patched)
|
||||
return parts, nil
|
||||
}
|
||||
parts = append(parts, baseZipPart{
|
||||
name: path,
|
||||
method: zip.Deflate,
|
||||
modTime: time.Now().Unix(),
|
||||
body: []byte(patched),
|
||||
})
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import (
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/branding"
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
)
|
||||
|
||||
// SubmissionVarsService assembles the placeholder map.
|
||||
@@ -151,17 +152,20 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
|
||||
if lang == "" {
|
||||
lang = "de"
|
||||
}
|
||||
bag := PlaceholderMap{}
|
||||
addFirmVars(bag)
|
||||
addTodayVars(bag, time.Now())
|
||||
addUserVars(bag, user)
|
||||
addRuleVars(bag, rule, lang)
|
||||
// firm / today / user / procedural_event apply to every render,
|
||||
// project-bound or not. Each resolver wraps the matching addXxxVars
|
||||
// builder (unchanged); ResolverSet.BuildBag runs them into one bag.
|
||||
resolvers := []docforge.VariableResolver{
|
||||
firmResolver{},
|
||||
todayResolver{now: time.Now()},
|
||||
userResolver{user: user},
|
||||
proceduralEventResolver{rule: rule, lang: lang},
|
||||
}
|
||||
|
||||
out := &SubmissionVarsResult{
|
||||
Placeholders: bag,
|
||||
User: user,
|
||||
Rule: rule,
|
||||
Lang: lang,
|
||||
User: user,
|
||||
Rule: rule,
|
||||
Lang: lang,
|
||||
}
|
||||
|
||||
if in.ProjectID == nil {
|
||||
@@ -169,6 +173,7 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
|
||||
// deadline state to resolve. The lawyer's overrides will fill
|
||||
// the placeholder map; missing keys render as
|
||||
// [KEIN WERT: …] / [NO VALUE: …] in the preview.
|
||||
out.Placeholders = docforge.NewResolverSet(resolvers...).BuildBag()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -195,14 +200,17 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
|
||||
return nil, err
|
||||
}
|
||||
|
||||
addProjectVars(bag, project, pt, lang)
|
||||
addPartyVars(bag, filterPartiesBySelection(parties, in.SelectedParties))
|
||||
addDeadlineVars(bag, next, project, lang)
|
||||
resolvers = append(resolvers,
|
||||
projectResolver{project: project, pt: pt, lang: lang},
|
||||
partiesResolver{parties: filterPartiesBySelection(parties, in.SelectedParties)},
|
||||
deadlineResolver{deadline: next, project: project, lang: lang},
|
||||
)
|
||||
|
||||
out.Project = project
|
||||
out.ProceedingType = pt
|
||||
out.Parties = parties
|
||||
out.NextDeadline = next
|
||||
out.Placeholders = docforge.NewResolverSet(resolvers...).BuildBag()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
81
internal/services/submission_vars_pretty_test.go
Normal file
81
internal/services/submission_vars_pretty_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package services
|
||||
|
||||
// Pretty-printer tests for the variable-resolution layer (legalSourcePretty,
|
||||
// ourSideDE/EN, patentNumberUPC). These live with submission_vars.go;
|
||||
// they were relocated out of the docx engine test suite when the
|
||||
// .docx renderer moved to pkg/docforge/docx (t-paliad-349 slice 1).
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLegalSourcePretty(t *testing.T) {
|
||||
tests := []struct {
|
||||
src, lang, want string
|
||||
}{
|
||||
{"DE.ZPO.276.1", "de", "§ 276 Abs. 1 ZPO"},
|
||||
{"DE.ZPO.276.1", "en", "Section 276(1) ZPO"},
|
||||
{"DE.ZPO.253", "de", "§ 253 ZPO"},
|
||||
{"DE.ZPO.253", "en", "Section 253 ZPO"},
|
||||
{"UPC.RoP.23.1", "de", "Regel 23.1 VerfO UPC"},
|
||||
{"UPC.RoP.23.1", "en", "Rule 23.1 RoP UPC"},
|
||||
{"UPC.RoP.198", "de", "Regel 198 VerfO UPC"},
|
||||
{"DE.PatG.83", "de", "§ 83 PatG"},
|
||||
{"EPC.123", "de", "Art. 123 EPÜ"},
|
||||
{"EPC.123", "en", "Art. 123 EPC"},
|
||||
{"FOO.BAR.123", "de", "FOO.BAR.123"},
|
||||
{"", "de", ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.src+"/"+tc.lang, func(t *testing.T) {
|
||||
got := legalSourcePretty(tc.src, tc.lang)
|
||||
if got != tc.want {
|
||||
t.Errorf("legalSourcePretty(%q, %q) = %q, want %q", tc.src, tc.lang, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOurSideTranslations(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, wantDE, wantEN string
|
||||
}{
|
||||
{"claimant", "Klägerin", "Claimant"},
|
||||
{"defendant", "Beklagte", "Defendant"},
|
||||
{"court", "Gericht", "Court"},
|
||||
{"both", "Klägerin und Beklagte", "Claimant and Defendant"},
|
||||
{"", "", ""},
|
||||
{"unknown", "", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
if got := ourSideDE(tc.in); got != tc.wantDE {
|
||||
t.Errorf("ourSideDE(%q) = %q, want %q", tc.in, got, tc.wantDE)
|
||||
}
|
||||
if got := ourSideEN(tc.in); got != tc.wantEN {
|
||||
t.Errorf("ourSideEN(%q) = %q, want %q", tc.in, got, tc.wantEN)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatentNumberUPC(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"EP 1 234 567 B1", "EP 1 234 567 (B1)"},
|
||||
{"EP 4 056 049 A1", "EP 4 056 049 (A1)"},
|
||||
{"DE 10 2020 123 456 A1", "DE 10 2020 123 456 (A1)"},
|
||||
{"EP 1 234 567", "EP 1 234 567"},
|
||||
{" EP 1 234 567 B1 ", "EP 1 234 567 (B1)"},
|
||||
{"", ""},
|
||||
{"WO/2023/123456", "WO/2023/123456"},
|
||||
{"EP 1 234 567 B12", "EP 1 234 567 B12"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
got := patentNumberUPC(tc.in)
|
||||
if got != tc.want {
|
||||
t.Errorf("patentNumberUPC(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
87
internal/services/submission_vars_resolvers.go
Normal file
87
internal/services/submission_vars_resolvers.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package services
|
||||
|
||||
// Variable resolvers — the paliad-side implementations of
|
||||
// docforge.VariableResolver (t-paliad-349 slice 3). Each wraps one of the
|
||||
// addXxxVars push-builders, capturing the entity it needs, so the proven
|
||||
// builder bodies stay byte-for-byte unchanged while the composition moves
|
||||
// behind the docforge.ResolverSet seam. SubmissionVarsService.Build wires
|
||||
// the applicable resolvers and calls ResolverSet.BuildBag().
|
||||
//
|
||||
// These live in paliad (not docforge) because they read paliad's domain
|
||||
// model — branding, user, project, parties, deadline_rules, deadlines. A
|
||||
// second docforge consumer implements its own resolvers against its own
|
||||
// data and plugs them into a ResolverSet the same way.
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
)
|
||||
|
||||
// Compile-time conformance: each resolver satisfies docforge.VariableResolver.
|
||||
var (
|
||||
_ docforge.VariableResolver = firmResolver{}
|
||||
_ docforge.VariableResolver = todayResolver{}
|
||||
_ docforge.VariableResolver = userResolver{}
|
||||
_ docforge.VariableResolver = proceduralEventResolver{}
|
||||
_ docforge.VariableResolver = projectResolver{}
|
||||
_ docforge.VariableResolver = partiesResolver{}
|
||||
_ docforge.VariableResolver = deadlineResolver{}
|
||||
)
|
||||
|
||||
// firmResolver populates firm.* from process-wide branding.
|
||||
type firmResolver struct{}
|
||||
|
||||
func (firmResolver) Namespace() string { return "firm" }
|
||||
func (firmResolver) Populate(bag PlaceholderMap) { addFirmVars(bag) }
|
||||
|
||||
// todayResolver populates today.* from the build-time clock.
|
||||
type todayResolver struct{ now time.Time }
|
||||
|
||||
func (todayResolver) Namespace() string { return "today" }
|
||||
func (r todayResolver) Populate(bag PlaceholderMap) { addTodayVars(bag, r.now) }
|
||||
|
||||
// userResolver populates user.* from the caller's row.
|
||||
type userResolver struct{ user *models.User }
|
||||
|
||||
func (userResolver) Namespace() string { return "user" }
|
||||
func (r userResolver) Populate(bag PlaceholderMap) { addUserVars(bag, r.user) }
|
||||
|
||||
// proceduralEventResolver populates procedural_event.* and the legacy
|
||||
// rule.* alias from the published deadline_rule.
|
||||
type proceduralEventResolver struct {
|
||||
rule *models.DeadlineRule
|
||||
lang string
|
||||
}
|
||||
|
||||
func (proceduralEventResolver) Namespace() string { return "procedural_event" }
|
||||
func (r proceduralEventResolver) Populate(bag PlaceholderMap) { addRuleVars(bag, r.rule, r.lang) }
|
||||
|
||||
// projectResolver populates project.* from the project + its proceeding type.
|
||||
type projectResolver struct {
|
||||
project *models.Project
|
||||
pt *models.ProceedingType
|
||||
lang string
|
||||
}
|
||||
|
||||
func (projectResolver) Namespace() string { return "project" }
|
||||
func (r projectResolver) Populate(bag PlaceholderMap) { addProjectVars(bag, r.project, r.pt, r.lang) }
|
||||
|
||||
// partiesResolver populates parties.* from the (already filtered) party list.
|
||||
type partiesResolver struct{ parties []models.Party }
|
||||
|
||||
func (partiesResolver) Namespace() string { return "parties" }
|
||||
func (r partiesResolver) Populate(bag PlaceholderMap) { addPartyVars(bag, r.parties) }
|
||||
|
||||
// deadlineResolver populates deadline.* from the next pending deadline.
|
||||
type deadlineResolver struct {
|
||||
deadline *models.Deadline
|
||||
project *models.Project
|
||||
lang string
|
||||
}
|
||||
|
||||
func (deadlineResolver) Namespace() string { return "deadline" }
|
||||
func (r deadlineResolver) Populate(bag PlaceholderMap) {
|
||||
addDeadlineVars(bag, r.deadline, r.project, r.lang)
|
||||
}
|
||||
24
pkg/docforge/doc.go
Normal file
24
pkg/docforge/doc.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Package docforge is paliad's modular document-generator engine — the
|
||||
// format-neutral core that turns templates + variables into rendered
|
||||
// documents, with format-specific adapters living in sub-packages.
|
||||
//
|
||||
// The package is being extracted from the in-tree submission generator
|
||||
// (internal/services/submission_*.go) per the PRD in
|
||||
// docs/plans/prd-docforge-2026-05-29.md (t-paliad-349 / m/paliad#157).
|
||||
// The extraction follows the same packaging discipline as
|
||||
// pkg/litigationplanner: docforge owns its types and exposes interfaces
|
||||
// for the stateful inputs (variable resolution, template storage); the
|
||||
// consuming application (paliad) implements those interfaces against its
|
||||
// own database, and a future second consumer reaches the engine over an
|
||||
// HTTP veneer rather than importing it.
|
||||
//
|
||||
// Slice 1 (this commit) relocates the .docx adapter — the Markdown→OOXML
|
||||
// walker, the placeholder substitution engine, and the .dotm→.docx
|
||||
// converter — into pkg/docforge/docx with no behaviour change. paliad's
|
||||
// internal/services package keeps thin type-alias + forwarder shims so
|
||||
// the submission generator and its HTTP surface compile and behave
|
||||
// identically. Later slices introduce the neutral document model,
|
||||
// hoist the format-neutral placeholder grammar up to this root package,
|
||||
// and add the VariableResolver interface, the TemplateStore, the
|
||||
// authoring surface, and the pluggable Exporter.
|
||||
package docforge
|
||||
636
pkg/docforge/docx/compose.go
Normal file
636
pkg/docforge/docx/compose.go
Normal file
@@ -0,0 +1,636 @@
|
||||
package docx
|
||||
|
||||
// Composer render pipeline — t-paliad-313 Slice B (design doc §9.1 +
|
||||
// §9.2). Assembles a base .docx and a draft's section rows into a
|
||||
// merged .docx ready for export.
|
||||
//
|
||||
// Pipeline (high-level):
|
||||
//
|
||||
// 1. ConvertDotmToDocx pre-pass on the base bytes (idempotent on .docx).
|
||||
// 2. Locate `word/document.xml` inside the zip; pull the body XML.
|
||||
// 3. For each section in the draft (order_index ASC, included=true):
|
||||
// render content_md_<lang> → OOXML via RenderMarkdownToOOXML using
|
||||
// base.section_spec.stylemap.paragraph.
|
||||
// 4. Splice the rendered OOXML into the base body. Two splice modes:
|
||||
// - Anchor mode: when the body carries `{{#section:KEY}}` /
|
||||
// `{{/section:KEY}}` marker pairs, replace the slot's content
|
||||
// (including the anchor paragraphs themselves) with the rendered
|
||||
// section.
|
||||
// - Append mode: when no anchor pair is found for a section, the
|
||||
// rendered OOXML appends at the end of the body, just before any
|
||||
// `<w:sectPr>` element. Sections with `included=false` are
|
||||
// dropped silently.
|
||||
// 5. Strip any leftover unmatched anchor paragraphs.
|
||||
// 6. Re-pack the document.xml into the zip, leaving every other part
|
||||
// untouched.
|
||||
// 7. Run the v1 SubmissionRenderer placeholder pass over the assembly
|
||||
// so `{{path}}` placeholders inside section content (and inside
|
||||
// the base's untouched chrome) get substituted by the merged bag.
|
||||
// Cross-run merge in pass 2 handles autocorrect-fragmented
|
||||
// placeholders the same as v1.
|
||||
//
|
||||
// Result: a fully-merged .docx. No new third-party Go dep — reuses
|
||||
// archive/zip + the existing SubmissionRenderer.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
)
|
||||
|
||||
// Composer assembles base + sections into a final .docx.
|
||||
// Stateless; safe for concurrent use.
|
||||
type Composer struct {
|
||||
renderer *SubmissionRenderer
|
||||
}
|
||||
|
||||
// NewComposer wires the composer. The renderer is required —
|
||||
// a nil renderer is a programmer error and the composer panics at
|
||||
// construction.
|
||||
func NewComposer(renderer *SubmissionRenderer) *Composer {
|
||||
if renderer == nil {
|
||||
panic("submission composer: renderer required")
|
||||
}
|
||||
return &Composer{renderer: renderer}
|
||||
}
|
||||
|
||||
// Carrier is the opaque base document the composer splices rendered
|
||||
// content into. Its bytes are preserved verbatim outside the regions the
|
||||
// splice touches — the {{#section:KEY}} anchor paragraphs and the
|
||||
// {{placeholder}} tokens — so the firm's letterhead, styles, headers, and
|
||||
// footers survive a compose byte-for-byte. This is the docforge "carrier"
|
||||
// for the .docx format: the lossless host for editable content.
|
||||
type Carrier struct {
|
||||
// Bytes is the raw base .docx. May be a .dotm/.docm/.dotx; Compose
|
||||
// runs ConvertDotmToDocx on it first (idempotent on a plain .docx).
|
||||
Bytes []byte
|
||||
|
||||
// Stylemap maps a logical block kind (paragraph, heading_1/2/3,
|
||||
// list_bullet, list_numbered, blockquote) to the Word paragraph
|
||||
// style name the base defines for it. Drives the Markdown walker's
|
||||
// <w:pStyle>. Missing entries fall back to the "paragraph" style.
|
||||
Stylemap map[string]string
|
||||
}
|
||||
|
||||
// Section is one editable content block the composer renders and splices.
|
||||
// It is the format-neutral input the docforge engine consumes; the
|
||||
// consuming application maps its own row type onto it (paliad maps
|
||||
// SubmissionSection → Section).
|
||||
type Section struct {
|
||||
// Key matches a {{#section:KEY}} anchor in the carrier, or — when no
|
||||
// anchor matches — marks an append-mode section.
|
||||
Key string
|
||||
// OrderIndex sets append-mode ordering (ascending).
|
||||
OrderIndex int
|
||||
// Included=false drops the section entirely.
|
||||
Included bool
|
||||
// ContentMDDE / ContentMDEN are the bilingual Markdown sources; Lang
|
||||
// selects which one renders.
|
||||
ContentMDDE string
|
||||
ContentMDEN string
|
||||
}
|
||||
|
||||
// ComposeOptions carries the per-call composition inputs.
|
||||
type ComposeOptions struct {
|
||||
// Sections are the draft's section rows in display order. The
|
||||
// composer renders included sections; excluded rows are dropped.
|
||||
// Caller is responsible for visibility — by the time the composer
|
||||
// runs, the section rows have already been gated by the caller.
|
||||
Sections []Section
|
||||
|
||||
// Carrier is the base .docx chrome plus its stylemap. Required.
|
||||
Carrier Carrier
|
||||
|
||||
// Lang ('de' or 'en') selects which content_md_* column the
|
||||
// composer reads per section. Defaults to 'de' if empty.
|
||||
Lang string
|
||||
|
||||
// Vars is the merged placeholder bag the v1 renderer pass
|
||||
// substitutes after the composer assembly. Passed straight through
|
||||
// to SubmissionRenderer.Render.
|
||||
Vars docforge.PlaceholderMap
|
||||
|
||||
// Missing translates an unbound placeholder key into the marker
|
||||
// the lawyer sees in Word. Passed straight to the renderer.
|
||||
Missing docforge.MissingPlaceholderFn
|
||||
}
|
||||
|
||||
// Compose runs the full pipeline and returns the merged .docx bytes.
|
||||
func (c *Composer) Compose(ctx context.Context, opts ComposeOptions) ([]byte, error) {
|
||||
_ = ctx // reserved for cancellation propagation in later slices
|
||||
sections := opts.Sections
|
||||
|
||||
// Pre-pass: strip macros so the base reads as a plain .docx zip.
|
||||
cleanBytes, err := ConvertDotmToDocx(opts.Carrier.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission compose: convert base: %w", err)
|
||||
}
|
||||
|
||||
// Locate + extract word/document.xml so we can splice in-place.
|
||||
documentXML, otherParts, err := splitBaseZip(cleanBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Per-compose hyperlink allocator. Each unique URL gets a fresh
|
||||
// rId outside the base's existing namespace. The post-pass
|
||||
// (patchDocumentXMLRels) writes the matching Relationship rows
|
||||
// before the zip is repacked. Slice D adds inline `[label](url)`
|
||||
// hyperlink support.
|
||||
linkAlloc := newComposerLinkAllocator()
|
||||
|
||||
// Build the rendered-section map: section_key → OOXML span.
|
||||
stylemap := opts.Carrier.Stylemap
|
||||
rendered := make(map[string]string, len(sections))
|
||||
keptSections := make([]Section, 0, len(sections))
|
||||
for _, sec := range sections {
|
||||
if !sec.Included {
|
||||
continue
|
||||
}
|
||||
md := sec.ContentMDDE
|
||||
if strings.EqualFold(opts.Lang, "en") {
|
||||
md = sec.ContentMDEN
|
||||
}
|
||||
rendered[sec.Key] = RenderMarkdownToOOXMLWithStyles(md, stylemap, linkAlloc.Alloc)
|
||||
keptSections = append(keptSections, sec)
|
||||
}
|
||||
// Stable order — already sorted ascending by ListForDraft, but
|
||||
// belt-and-braces in case the caller swaps the ordering policy
|
||||
// later.
|
||||
sort.SliceStable(keptSections, func(i, j int) bool {
|
||||
return keptSections[i].OrderIndex < keptSections[j].OrderIndex
|
||||
})
|
||||
|
||||
assembledBody := spliceSections(documentXML, rendered, keptSections, sections)
|
||||
|
||||
// Slice D hyperlink patch: when the walker emitted hyperlink rIds
|
||||
// for inline `[label](url)` links, the base's
|
||||
// word/_rels/document.xml.rels needs matching <Relationship>
|
||||
// entries so Word can resolve the rIds. Mutates one zip part in
|
||||
// otherParts (or appends if missing).
|
||||
if linkAlloc.HasLinks() {
|
||||
updatedParts, err := patchDocumentXMLRels(otherParts, linkAlloc.Pairs())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
otherParts = updatedParts
|
||||
}
|
||||
|
||||
// Re-pack into a zip with the assembled document.xml. All other
|
||||
// parts (styles, fonts, headers, footers, theme, settings) pass
|
||||
// through bit-for-bit at their original mtime + compression.
|
||||
repacked, err := repackBaseZip(otherParts, assembledBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Final pass: substitute placeholders against the merged bag. The
|
||||
// existing renderer handles cross-run fragmentation, the `{{rule.X}}`
|
||||
// alias contract, and the missing-marker emission. Reusing it
|
||||
// guarantees v1's placeholder grammar stays intact inside section
|
||||
// content + base chrome.
|
||||
merged, err := c.renderer.Render(repacked, opts.Vars, opts.Missing)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission compose: placeholder pass: %w", err)
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Section splicing
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Anchor markers as they appear inside a <w:t> text node. We don't
|
||||
// need a full XML parse — finding the marker text inside the body is
|
||||
// sufficient because:
|
||||
// - {{ and }} are never legitimate document content (placeholders
|
||||
// follow the same convention everywhere else in paliad).
|
||||
// - The anchor key grammar [A-Za-z0-9_]+ rules out any HTML/XML
|
||||
// special characters.
|
||||
// - Each anchor lives in exactly one <w:t>...<w:t>, which lives in
|
||||
// exactly one <w:r>...</w:r>, which lives in exactly one
|
||||
// <w:p>...</w:p>. We expand from the marker outward to find the
|
||||
// enclosing <w:p> span and drop the entire paragraph as part of
|
||||
// the splice.
|
||||
//
|
||||
// RE2 has no lookahead, so the "find enclosing <w:p>" logic is
|
||||
// implemented as manual byte-index search around the marker hit
|
||||
// (anchorParagraphSpan below) rather than a single regex pattern.
|
||||
|
||||
const (
|
||||
anchorOpenPrefix = "{{#section:"
|
||||
anchorClosePrefix = "{{/section:"
|
||||
anchorSuffix = "}}"
|
||||
)
|
||||
|
||||
// anchorKeyRegex validates that the captured anchor key is a clean
|
||||
// identifier. Keys that include other characters (which can't actually
|
||||
// appear in our authored .docx) are treated as no match.
|
||||
var anchorKeyRegex = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
|
||||
|
||||
// anchorPair records the byte span of one matched anchor pair inside
|
||||
// the body — from the start of the opening anchor's <w:p> element
|
||||
// through the end of the closing anchor's </w:p>.
|
||||
type anchorPair struct {
|
||||
key string
|
||||
openStart int // start of <w:p> for the opening anchor
|
||||
closeEnd int // index just past </w:p> for the closing anchor
|
||||
}
|
||||
|
||||
// findAllAnchorPairs scans the body for matched open/close anchor
|
||||
// pairs. Unbalanced markers (open without close, or vice versa) are
|
||||
// dropped from the result. Returns pairs in body-order; each pair's
|
||||
// span is non-overlapping.
|
||||
func findAllAnchorPairs(body string) []anchorPair {
|
||||
type marker struct {
|
||||
key string
|
||||
paraStart int
|
||||
paraEnd int
|
||||
isOpen bool
|
||||
}
|
||||
var markers []marker
|
||||
|
||||
collect := func(prefix string, isOpen bool) {
|
||||
offset := 0
|
||||
for {
|
||||
idx := strings.Index(body[offset:], prefix)
|
||||
if idx < 0 {
|
||||
return
|
||||
}
|
||||
start := offset + idx
|
||||
suffixIdx := strings.Index(body[start+len(prefix):], anchorSuffix)
|
||||
if suffixIdx < 0 {
|
||||
return
|
||||
}
|
||||
key := body[start+len(prefix) : start+len(prefix)+suffixIdx]
|
||||
if !anchorKeyRegex.MatchString(key) {
|
||||
offset = start + len(prefix)
|
||||
continue
|
||||
}
|
||||
markerEnd := start + len(prefix) + suffixIdx + len(anchorSuffix)
|
||||
pStart, pEnd, ok := paragraphSpanAround(body, start, markerEnd)
|
||||
if !ok {
|
||||
offset = markerEnd
|
||||
continue
|
||||
}
|
||||
markers = append(markers, marker{key: key, paraStart: pStart, paraEnd: pEnd, isOpen: isOpen})
|
||||
offset = pEnd
|
||||
}
|
||||
}
|
||||
collect(anchorOpenPrefix, true)
|
||||
collect(anchorClosePrefix, false)
|
||||
|
||||
// Walk markers in body-order, matching each open with the next
|
||||
// close that carries the same key.
|
||||
sort.SliceStable(markers, func(i, j int) bool {
|
||||
return markers[i].paraStart < markers[j].paraStart
|
||||
})
|
||||
var pairs []anchorPair
|
||||
openStack := map[string]marker{}
|
||||
for _, m := range markers {
|
||||
if m.isOpen {
|
||||
openStack[m.key] = m
|
||||
continue
|
||||
}
|
||||
o, ok := openStack[m.key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pairs = append(pairs, anchorPair{
|
||||
key: m.key,
|
||||
openStart: o.paraStart,
|
||||
closeEnd: m.paraEnd,
|
||||
})
|
||||
delete(openStack, m.key)
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
// paragraphSpanAround returns the byte span of the smallest `<w:p>...</w:p>`
|
||||
// element that fully contains the byte range [markerStart, markerEnd).
|
||||
// Returns false when the byte range doesn't sit inside a single
|
||||
// paragraph (which would mean the marker survived a cross-paragraph
|
||||
// edit — defensive guard, shouldn't happen in well-formed input).
|
||||
func paragraphSpanAround(body string, markerStart, markerEnd int) (int, int, bool) {
|
||||
// Walk backwards to find the nearest unclosed <w:p ... > opening.
|
||||
// Since <w:p> doesn't nest, the nearest <w:p before markerStart is
|
||||
// the enclosing paragraph's opening tag.
|
||||
pStart := -1
|
||||
cursor := markerStart
|
||||
for cursor > 0 {
|
||||
idx := strings.LastIndex(body[:cursor], "<w:p")
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
// Confirm this is a paragraph open, not a different
|
||||
// w:p-prefixed tag (e.g. <w:pPr>).
|
||||
if idx+4 <= len(body) {
|
||||
after := body[idx+4]
|
||||
if after == ' ' || after == '>' || after == '/' {
|
||||
// <w:p ...> or <w:p>; not <w:pPr>.
|
||||
close := strings.Index(body[idx:], ">")
|
||||
if close < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
pStart = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
cursor = idx
|
||||
}
|
||||
if pStart < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
// Walk forward to find the matching </w:p>. <w:p> doesn't nest so
|
||||
// the next </w:p> after the marker is the close.
|
||||
pEndIdx := strings.Index(body[markerEnd:], "</w:p>")
|
||||
if pEndIdx < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
pEnd := markerEnd + pEndIdx + len("</w:p>")
|
||||
return pStart, pEnd, true
|
||||
}
|
||||
|
||||
// spliceSections replaces anchor slots with rendered sections and
|
||||
// appends any unanchored sections before sectPr. Returns the assembled
|
||||
// document.xml body.
|
||||
func spliceSections(documentXML []byte, rendered map[string]string, kept []Section, all []Section) []byte {
|
||||
body := string(documentXML)
|
||||
pairs := findAllAnchorPairs(body)
|
||||
|
||||
// Build a lookup of kept section keys for quick membership tests.
|
||||
keptByKey := map[string]int{}
|
||||
for i, sec := range kept {
|
||||
keptByKey[sec.Key] = i
|
||||
}
|
||||
allByKey := map[string]int{}
|
||||
for i, sec := range all {
|
||||
allByKey[sec.Key] = i
|
||||
}
|
||||
|
||||
matchedKeys := map[string]bool{}
|
||||
|
||||
// Walk pairs in REVERSE body-order so slice mutations don't shift
|
||||
// later offsets.
|
||||
sort.SliceStable(pairs, func(i, j int) bool {
|
||||
return pairs[i].openStart > pairs[j].openStart
|
||||
})
|
||||
for _, p := range pairs {
|
||||
replacement := ""
|
||||
if idx, ok := keptByKey[p.key]; ok {
|
||||
replacement = rendered[p.key]
|
||||
matchedKeys[p.key] = true
|
||||
_ = idx
|
||||
} else if _, isOnDraft := allByKey[p.key]; isOnDraft {
|
||||
// Anchor matches an excluded section on the draft — drop
|
||||
// the entire slot.
|
||||
replacement = ""
|
||||
} else {
|
||||
// Anchor doesn't match any section on this draft — drop
|
||||
// to leave the base's chrome unbroken.
|
||||
replacement = ""
|
||||
}
|
||||
body = body[:p.openStart] + replacement + body[p.closeEnd:]
|
||||
}
|
||||
|
||||
// Append unanchored sections before sectPr in order_index ASC.
|
||||
var unanchored strings.Builder
|
||||
for _, sec := range kept {
|
||||
if matchedKeys[sec.Key] {
|
||||
continue
|
||||
}
|
||||
unanchored.WriteString(rendered[sec.Key])
|
||||
}
|
||||
if unanchored.Len() > 0 {
|
||||
body = appendBeforeSectPr(body, unanchored.String())
|
||||
}
|
||||
|
||||
return []byte(body)
|
||||
}
|
||||
|
||||
// appendBeforeSectPr inserts content immediately before the first
|
||||
// `<w:sectPr` element in the body, or at the end of the body if there
|
||||
// is none. Word documents conventionally close the body with a sectPr
|
||||
// describing page setup; we want to land sections before that element
|
||||
// so they show up on the actual pages.
|
||||
var sectPrRegex = regexp.MustCompile(`<w:sectPr\b`)
|
||||
|
||||
func appendBeforeSectPr(body, content string) string {
|
||||
loc := sectPrRegex.FindStringIndex(body)
|
||||
if loc == nil {
|
||||
// No sectPr → append before `</w:body>` if present, else at
|
||||
// the very end.
|
||||
idx := strings.LastIndex(body, "</w:body>")
|
||||
if idx < 0 {
|
||||
return body + content
|
||||
}
|
||||
return body[:idx] + content + body[idx:]
|
||||
}
|
||||
return body[:loc[0]] + content + body[loc[0]:]
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Zip plumbing
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// baseZipPart captures one zip entry we kept aside while extracting
|
||||
// document.xml.
|
||||
type baseZipPart struct {
|
||||
name string
|
||||
method uint16
|
||||
modTime int64 // wall seconds; converted back to time.Time on repack
|
||||
body []byte
|
||||
}
|
||||
|
||||
// splitBaseZip extracts document.xml and returns it alongside every
|
||||
// other zip entry, ready for repacking.
|
||||
func splitBaseZip(cleanBytes []byte) ([]byte, []baseZipPart, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(cleanBytes), int64(len(cleanBytes)))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("submission compose: open base zip: %w", err)
|
||||
}
|
||||
var documentXML []byte
|
||||
parts := make([]baseZipPart, 0, len(zr.File))
|
||||
for _, f := range zr.File {
|
||||
body, err := readZipEntry(f)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("submission compose: read %s: %w", f.Name, err)
|
||||
}
|
||||
if f.Name == "word/document.xml" {
|
||||
documentXML = body
|
||||
parts = append(parts, baseZipPart{name: f.Name, method: f.Method, modTime: f.Modified.Unix(), body: nil})
|
||||
continue
|
||||
}
|
||||
parts = append(parts, baseZipPart{name: f.Name, method: f.Method, modTime: f.Modified.Unix(), body: body})
|
||||
}
|
||||
if documentXML == nil {
|
||||
return nil, nil, fmt.Errorf("submission compose: base zip missing word/document.xml")
|
||||
}
|
||||
return documentXML, parts, nil
|
||||
}
|
||||
|
||||
// repackBaseZip rebuilds the zip, swapping document.xml for the
|
||||
// assembled body and leaving every other part untouched.
|
||||
func repackBaseZip(parts []baseZipPart, assembledBody []byte) ([]byte, error) {
|
||||
var out bytes.Buffer
|
||||
zw := zip.NewWriter(&out)
|
||||
for _, p := range parts {
|
||||
hdr := &zip.FileHeader{
|
||||
Name: p.name,
|
||||
Method: p.method,
|
||||
}
|
||||
if p.modTime > 0 {
|
||||
hdr.Modified = time.Unix(p.modTime, 0)
|
||||
}
|
||||
w, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission compose: write header %s: %w", p.name, err)
|
||||
}
|
||||
body := p.body
|
||||
if p.name == "word/document.xml" {
|
||||
body = assembledBody
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
return nil, fmt.Errorf("submission compose: write body %s: %w", p.name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("submission compose: finalise zip: %w", err)
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
func readZipEntry(f *zip.File) ([]byte, error) {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Slice D — hyperlink wiring
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// composerLinkAllocator hands out fresh rIds for inline hyperlink
|
||||
// targets discovered by the MD walker. Each unique URL gets one rId
|
||||
// (deduped — repeated links to the same URL share one Relationship).
|
||||
// Allocations land outside the base's rId namespace by prefixing with
|
||||
// "rIdComposer" so they can't collide with existing relationships.
|
||||
type composerLinkAllocator struct {
|
||||
next int
|
||||
byURL map[string]string
|
||||
order []string // URLs in allocation order
|
||||
}
|
||||
|
||||
func newComposerLinkAllocator() *composerLinkAllocator {
|
||||
return &composerLinkAllocator{byURL: map[string]string{}}
|
||||
}
|
||||
|
||||
// Alloc returns the rId for url, allocating one on first sight.
|
||||
func (a *composerLinkAllocator) Alloc(url string) string {
|
||||
if rid, ok := a.byURL[url]; ok {
|
||||
return rid
|
||||
}
|
||||
a.next++
|
||||
rid := fmt.Sprintf("rIdComposer%d", a.next)
|
||||
a.byURL[url] = rid
|
||||
a.order = append(a.order, url)
|
||||
return rid
|
||||
}
|
||||
|
||||
// HasLinks reports whether any links were allocated during this compose.
|
||||
func (a *composerLinkAllocator) HasLinks() bool {
|
||||
return len(a.order) > 0
|
||||
}
|
||||
|
||||
// Pairs returns the (rId, URL) pairs in allocation order. The
|
||||
// document.xml.rels patcher consumes this to emit <Relationship>
|
||||
// elements.
|
||||
func (a *composerLinkAllocator) Pairs() [][2]string {
|
||||
pairs := make([][2]string, 0, len(a.order))
|
||||
for _, url := range a.order {
|
||||
pairs = append(pairs, [2]string{a.byURL[url], url})
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
// patchDocumentXMLRels mutates the word/_rels/document.xml.rels entry
|
||||
// in `parts` to append the given (rId, URL) pairs as hyperlink
|
||||
// relationships. If the rels part doesn't exist (some bases omit it
|
||||
// when the body has no relationships), this function appends a fresh
|
||||
// part with the minimal Relationships wrapper.
|
||||
//
|
||||
// Idempotent on (rId, URL) pairs already present (e.g. when a base
|
||||
// already references the URL for some other reason).
|
||||
//
|
||||
// Returns the (possibly extended) parts slice — callers must overwrite
|
||||
// their reference because the append in the no-rels-yet case grows the
|
||||
// backing array.
|
||||
func patchDocumentXMLRels(parts []baseZipPart, pairs [][2]string) ([]baseZipPart, error) {
|
||||
const path = "word/_rels/document.xml.rels"
|
||||
const hyperlinkType = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
|
||||
|
||||
existingIdx := -1
|
||||
for i := range parts {
|
||||
if parts[i].name == path {
|
||||
existingIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var body string
|
||||
if existingIdx >= 0 {
|
||||
body = string(parts[existingIdx].body)
|
||||
} else {
|
||||
body = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
|
||||
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`
|
||||
}
|
||||
|
||||
var inserts strings.Builder
|
||||
for _, p := range pairs {
|
||||
rid := p[0]
|
||||
url := p[1]
|
||||
if strings.Contains(body, `Id="`+rid+`"`) {
|
||||
continue
|
||||
}
|
||||
inserts.WriteString(`<Relationship Id="`)
|
||||
inserts.WriteString(xmlAttrEscape(rid))
|
||||
inserts.WriteString(`" Type="`)
|
||||
inserts.WriteString(hyperlinkType)
|
||||
inserts.WriteString(`" Target="`)
|
||||
inserts.WriteString(xmlAttrEscape(url))
|
||||
inserts.WriteString(`" TargetMode="External"/>`)
|
||||
}
|
||||
|
||||
if inserts.Len() == 0 {
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
closeIdx := strings.LastIndex(body, "</Relationships>")
|
||||
if closeIdx < 0 {
|
||||
return parts, fmt.Errorf("submission compose: malformed document.xml.rels (no closing tag)")
|
||||
}
|
||||
patched := body[:closeIdx] + inserts.String() + body[closeIdx:]
|
||||
|
||||
if existingIdx >= 0 {
|
||||
parts[existingIdx].body = []byte(patched)
|
||||
return parts, nil
|
||||
}
|
||||
parts = append(parts, baseZipPart{
|
||||
name: path,
|
||||
method: zip.Deflate,
|
||||
modTime: time.Now().Unix(),
|
||||
body: []byte(patched),
|
||||
})
|
||||
return parts, nil
|
||||
}
|
||||
28
pkg/docforge/docx/doc.go
Normal file
28
pkg/docforge/docx/doc.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Package docx is docforge's .docx (OOXML) adapter — the first
|
||||
// format adapter in the docforge engine (t-paliad-349 / m/paliad#157).
|
||||
//
|
||||
// It owns the in-house OOXML machinery extracted from paliad's submission
|
||||
// generator in slice 1, with no behaviour change:
|
||||
//
|
||||
// - merge.go — the placeholder substitution renderer
|
||||
// (SubmissionRenderer.Render / RenderHTML). Two-pass {{placeholder}}
|
||||
// substitution (single-run, then cross-run merge for fragmented
|
||||
// placeholders), plus the preview-HTML emitter that wraps substituted
|
||||
// values in clickable <span class="draft-var" data-var="…"> markup.
|
||||
// - markdown.go — the Markdown→OOXML walker (RenderMarkdownToOOXML*),
|
||||
// including the b78a984 fix that preserves {{…}} placeholders verbatim
|
||||
// through inline-span parsing (underscores in keys survive).
|
||||
// - dotm.go — ConvertDotmToDocx: strips macros from a .dotm/.docm/
|
||||
// .dotx and rewrites the content-types + rels to a clean .docx,
|
||||
// passing every other part through bit-for-bit.
|
||||
//
|
||||
// Why no third-party docx library: lukasjarosch/go-docx treats sibling
|
||||
// placeholders in one run ("{{a}} ./. {{b}}") as nested and refuses to
|
||||
// replace either; patent submissions routinely have several placeholders
|
||||
// per paragraph, so this in-house renderer is required. See merge.go.
|
||||
//
|
||||
// The placeholder grammar — \{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\} — and
|
||||
// the PlaceholderMap type currently live here with the renderer; a later
|
||||
// slice hoists the format-neutral grammar up to the docforge root once
|
||||
// the neutral document model and the VariableResolver interface land.
|
||||
package docx
|
||||
@@ -1,4 +1,4 @@
|
||||
package services
|
||||
package docx
|
||||
|
||||
// Submission .dotm → .docx converter (t-paliad-230, "format-only" scope
|
||||
// reduction of the original t-paliad-215 submission generator).
|
||||
@@ -1,4 +1,4 @@
|
||||
package services
|
||||
package docx
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
@@ -1,4 +1,4 @@
|
||||
package services
|
||||
package docx
|
||||
|
||||
// Markdown → OOXML walker for Composer section content (t-paliad-313
|
||||
// Slice B, design doc §9.2).
|
||||
@@ -1,4 +1,4 @@
|
||||
package services
|
||||
package docx
|
||||
|
||||
// Unit tests for the Composer's Markdown → OOXML walker (t-paliad-313
|
||||
// Slice B). Pure function; no DB dependency.
|
||||
@@ -1,4 +1,4 @@
|
||||
package services
|
||||
package docx
|
||||
|
||||
// Submission template renderer — in-house engine for the submission
|
||||
// draft editor (t-paliad-238, design doc
|
||||
@@ -24,7 +24,7 @@ package services
|
||||
// {{project.case_number}}).
|
||||
//
|
||||
// Missing-value behaviour: when a placeholder has no binding in the
|
||||
// PlaceholderMap, the renderer emits a marker token so the lawyer sees
|
||||
// docforge.PlaceholderMap, the renderer emits a marker token so the lawyer sees
|
||||
// the gap in Word rather than failing the request.
|
||||
|
||||
import (
|
||||
@@ -34,18 +34,15 @@ import (
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
)
|
||||
|
||||
// PlaceholderMap is the variable bag built by SubmissionVarsService.
|
||||
// Keys are dotted paths without braces (e.g. "project.case_number").
|
||||
// Values are the substituted text — already locale-aware, pretty-
|
||||
// printed, and sanitised by the caller.
|
||||
type PlaceholderMap map[string]string
|
||||
|
||||
// MissingPlaceholderFn translates an unbound placeholder key into the
|
||||
// in-document marker token. The default in DefaultMissingMarker is
|
||||
// "[KEIN WERT: <key>]" / "[NO VALUE: <key>]" depending on lang.
|
||||
type MissingPlaceholderFn func(key string) string
|
||||
// docforge.PlaceholderMap, docforge.MissingPlaceholderFn, and docforge.DefaultMissingMarker — the
|
||||
// format-neutral variable-bag contract — live in the docforge root
|
||||
// package (placeholder.go). This adapter consumes them; the {{key}}
|
||||
// substitution grammar below (placeholderRegex, replacePlaceholders, the
|
||||
// PUA preview sentinels) is the .docx renderer's own machinery.
|
||||
|
||||
// valueWrapperFn wraps a substituted value with a marker the HTML
|
||||
// preview emitter can recognise — used by RenderHTML to turn each
|
||||
@@ -74,18 +71,6 @@ func htmlPreviewWrapper(key, value string) string {
|
||||
return previewVarBegin + key + previewVarMid + value + previewVarEnd
|
||||
}
|
||||
|
||||
// DefaultMissingMarker returns the standard missing-value marker for
|
||||
// the given UI language.
|
||||
func DefaultMissingMarker(lang string) MissingPlaceholderFn {
|
||||
prefix := "KEIN WERT"
|
||||
if strings.EqualFold(lang, "en") {
|
||||
prefix = "NO VALUE"
|
||||
}
|
||||
return func(key string) string {
|
||||
return "[" + prefix + ": " + key + "]"
|
||||
}
|
||||
}
|
||||
|
||||
// placeholderRegex matches a single placeholder. The capture group
|
||||
// extracts the key name without braces or surrounding whitespace.
|
||||
//
|
||||
@@ -95,7 +80,7 @@ func DefaultMissingMarker(lang string) MissingPlaceholderFn {
|
||||
var placeholderRegex = regexp.MustCompile(`\{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\}`)
|
||||
|
||||
// SubmissionRenderer renders a .docx template into a .docx output by
|
||||
// substituting {{placeholder}} tokens with values from a PlaceholderMap.
|
||||
// substituting {{placeholder}} tokens with values from a docforge.PlaceholderMap.
|
||||
// Stateless; safe for concurrent use.
|
||||
type SubmissionRenderer struct{}
|
||||
|
||||
@@ -112,9 +97,9 @@ func NewSubmissionRenderer() *SubmissionRenderer {
|
||||
// Pre-pass: ConvertDotmToDocx is called on the input so a .dotm
|
||||
// template (macro-bearing) is downgraded to a plain .docx before the
|
||||
// merge step runs. Idempotent on inputs that are already plain .docx.
|
||||
func (r *SubmissionRenderer) Render(templateBytes []byte, vars PlaceholderMap, missing MissingPlaceholderFn) ([]byte, error) {
|
||||
func (r *SubmissionRenderer) Render(templateBytes []byte, vars docforge.PlaceholderMap, missing docforge.MissingPlaceholderFn) ([]byte, error) {
|
||||
if missing == nil {
|
||||
missing = DefaultMissingMarker("de")
|
||||
missing = docforge.DefaultMissingMarker("de")
|
||||
}
|
||||
cleanBytes, err := ConvertDotmToDocx(templateBytes)
|
||||
if err != nil {
|
||||
@@ -166,9 +151,9 @@ func (r *SubmissionRenderer) Render(templateBytes []byte, vars PlaceholderMap, m
|
||||
// Returns escaped HTML safe to inject into the page via dangerouslySet
|
||||
// or innerHTML. The caller is responsible for wrapping in an outer
|
||||
// container; this method emits only the body fragment.
|
||||
func (r *SubmissionRenderer) RenderHTML(templateBytes []byte, vars PlaceholderMap, missing MissingPlaceholderFn) (string, error) {
|
||||
func (r *SubmissionRenderer) RenderHTML(templateBytes []byte, vars docforge.PlaceholderMap, missing docforge.MissingPlaceholderFn) (string, error) {
|
||||
if missing == nil {
|
||||
missing = DefaultMissingMarker("de")
|
||||
missing = docforge.DefaultMissingMarker("de")
|
||||
}
|
||||
cleanBytes, err := ConvertDotmToDocx(templateBytes)
|
||||
if err != nil {
|
||||
@@ -241,7 +226,7 @@ func readMergeZipEntry(f *zip.File) ([]byte, error) {
|
||||
// paragraph, run the replacement on the merged text, and rewrite
|
||||
// the paragraph's runs as a single <w:r><w:t>…</w:t></w:r> using
|
||||
// the formatting properties of the first run.
|
||||
func substituteInDocumentXML(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) []byte {
|
||||
func substituteInDocumentXML(body []byte, vars docforge.PlaceholderMap, missing docforge.MissingPlaceholderFn, wrap valueWrapperFn) []byte {
|
||||
replaced := substituteInTextNodes(body, vars, missing, wrap)
|
||||
if !needsCrossRunMerge(replaced) {
|
||||
return replaced
|
||||
@@ -256,7 +241,7 @@ var wTextNodeRegex = regexp.MustCompile(`<w:t(\s[^>]*)?>([^<]*)</w:t>`)
|
||||
// substituteInTextNodes runs the placeholder replacement inside each
|
||||
// <w:t> text node independently. Format-preserving for single-run
|
||||
// placeholders.
|
||||
func substituteInTextNodes(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) []byte {
|
||||
func substituteInTextNodes(body []byte, vars docforge.PlaceholderMap, missing docforge.MissingPlaceholderFn, wrap valueWrapperFn) []byte {
|
||||
return wTextNodeRegex.ReplaceAllFunc(body, func(match []byte) []byte {
|
||||
sub := wTextNodeRegex.FindSubmatch(match)
|
||||
attrs := string(sub[1])
|
||||
@@ -297,7 +282,7 @@ var wParagraphPropsRegex = regexp.MustCompile(`(?s)<w:pPr>.*?</w:pPr>`)
|
||||
|
||||
// substituteAcrossRuns is pass 2: concatenate every text node in a
|
||||
// fragmented-placeholder paragraph, run replacement, rewrite as one run.
|
||||
func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) []byte {
|
||||
func substituteAcrossRuns(body []byte, vars docforge.PlaceholderMap, missing docforge.MissingPlaceholderFn, wrap valueWrapperFn) []byte {
|
||||
return wParagraphRegex.ReplaceAllFunc(body, func(para []byte) []byte {
|
||||
textNodes := wTextNodeRegex.FindAllSubmatch(para, -1)
|
||||
if len(textNodes) == 0 {
|
||||
@@ -340,7 +325,7 @@ func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlace
|
||||
// emit clickable spans around every substituted placeholder, including
|
||||
// missing ones (clicking a missing marker jumps to the corresponding
|
||||
// sidebar input).
|
||||
func replacePlaceholders(s string, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) string {
|
||||
func replacePlaceholders(s string, vars docforge.PlaceholderMap, missing docforge.MissingPlaceholderFn, wrap valueWrapperFn) string {
|
||||
return placeholderRegex.ReplaceAllStringFunc(s, func(match string) string {
|
||||
sub := placeholderRegex.FindStringSubmatch(match)
|
||||
if len(sub) < 2 {
|
||||
@@ -1,4 +1,4 @@
|
||||
package services
|
||||
package docx
|
||||
|
||||
// Submission merge-engine tests — resurrected from the original
|
||||
// t-paliad-215 Slice 1 (commit 8ea3509) + Slice 2 (commit 1765d5e).
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
)
|
||||
|
||||
// minimalMergeDOCX builds a tiny .docx zip with one document.xml that
|
||||
@@ -74,7 +76,7 @@ func TestRender_SingleRunPlaceholder(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
|
||||
out, err := r.Render(tmpl, docforge.PlaceholderMap{"firm.name": "HLC"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
@@ -91,7 +93,7 @@ func TestRender_MultiplePlaceholdersPerRun(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{parties.claimant.name}}, vertreten durch {{parties.claimant.representative}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{
|
||||
out, err := r.Render(tmpl, docforge.PlaceholderMap{
|
||||
"parties.claimant.name": "Acme Inc.",
|
||||
"parties.claimant.representative": "Kanzlei Müller",
|
||||
}, nil)
|
||||
@@ -111,7 +113,7 @@ func TestRender_MissingMarker(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{project.case_number}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{}, DefaultMissingMarker("de"))
|
||||
out, err := r.Render(tmpl, docforge.PlaceholderMap{}, docforge.DefaultMissingMarker("de"))
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
@@ -119,7 +121,7 @@ func TestRender_MissingMarker(t *testing.T) {
|
||||
if !strings.Contains(body, "[KEIN WERT: project.case_number]") {
|
||||
t.Errorf("expected KEIN WERT marker, got %q", body)
|
||||
}
|
||||
outEN, err := r.Render(tmpl, PlaceholderMap{}, DefaultMissingMarker("en"))
|
||||
outEN, err := r.Render(tmpl, docforge.PlaceholderMap{}, docforge.DefaultMissingMarker("en"))
|
||||
if err != nil {
|
||||
t.Fatalf("render en: %v", err)
|
||||
}
|
||||
@@ -133,7 +135,7 @@ func TestRender_CrossRunPlaceholder(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>Hello {{</w:t></w:r><w:r><w:t>project</w:t></w:r><w:r><w:t>.case_number}}!</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{"project.case_number": "7 O 1234/26"}, nil)
|
||||
out, err := r.Render(tmpl, docforge.PlaceholderMap{"project.case_number": "7 O 1234/26"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
@@ -150,7 +152,7 @@ func TestRender_XMLEscaping(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{user.display_name}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{
|
||||
out, err := r.Render(tmpl, docforge.PlaceholderMap{
|
||||
"user.display_name": `Müller & Söhne <GmbH> "Special"`,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
@@ -190,79 +192,6 @@ func TestPlaceholderRegex_Boundaries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegalSourcePretty(t *testing.T) {
|
||||
tests := []struct {
|
||||
src, lang, want string
|
||||
}{
|
||||
{"DE.ZPO.276.1", "de", "§ 276 Abs. 1 ZPO"},
|
||||
{"DE.ZPO.276.1", "en", "Section 276(1) ZPO"},
|
||||
{"DE.ZPO.253", "de", "§ 253 ZPO"},
|
||||
{"DE.ZPO.253", "en", "Section 253 ZPO"},
|
||||
{"UPC.RoP.23.1", "de", "Regel 23.1 VerfO UPC"},
|
||||
{"UPC.RoP.23.1", "en", "Rule 23.1 RoP UPC"},
|
||||
{"UPC.RoP.198", "de", "Regel 198 VerfO UPC"},
|
||||
{"DE.PatG.83", "de", "§ 83 PatG"},
|
||||
{"EPC.123", "de", "Art. 123 EPÜ"},
|
||||
{"EPC.123", "en", "Art. 123 EPC"},
|
||||
{"FOO.BAR.123", "de", "FOO.BAR.123"},
|
||||
{"", "de", ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.src+"/"+tc.lang, func(t *testing.T) {
|
||||
got := legalSourcePretty(tc.src, tc.lang)
|
||||
if got != tc.want {
|
||||
t.Errorf("legalSourcePretty(%q, %q) = %q, want %q", tc.src, tc.lang, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOurSideTranslations(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, wantDE, wantEN string
|
||||
}{
|
||||
{"claimant", "Klägerin", "Claimant"},
|
||||
{"defendant", "Beklagte", "Defendant"},
|
||||
{"court", "Gericht", "Court"},
|
||||
{"both", "Klägerin und Beklagte", "Claimant and Defendant"},
|
||||
{"", "", ""},
|
||||
{"unknown", "", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
if got := ourSideDE(tc.in); got != tc.wantDE {
|
||||
t.Errorf("ourSideDE(%q) = %q, want %q", tc.in, got, tc.wantDE)
|
||||
}
|
||||
if got := ourSideEN(tc.in); got != tc.wantEN {
|
||||
t.Errorf("ourSideEN(%q) = %q, want %q", tc.in, got, tc.wantEN)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatentNumberUPC(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"EP 1 234 567 B1", "EP 1 234 567 (B1)"},
|
||||
{"EP 4 056 049 A1", "EP 4 056 049 (A1)"},
|
||||
{"DE 10 2020 123 456 A1", "DE 10 2020 123 456 (A1)"},
|
||||
{"EP 1 234 567", "EP 1 234 567"},
|
||||
{" EP 1 234 567 B1 ", "EP 1 234 567 (B1)"},
|
||||
{"", ""},
|
||||
{"WO/2023/123456", "WO/2023/123456"},
|
||||
{"EP 1 234 567 B12", "EP 1 234 567 B12"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
got := patentNumberUPC(tc.in)
|
||||
if got != tc.want {
|
||||
t.Errorf("patentNumberUPC(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderHTML_ExtractsParagraphsAndFormatting verifies the preview
|
||||
// HTML emitter walks <w:p> / <w:r> / <w:t> correctly and carries
|
||||
// bold/italic through to <strong>/<em>. Substituted placeholders are
|
||||
@@ -276,7 +205,7 @@ func TestRenderHTML_ExtractsParagraphsAndFormatting(t *testing.T) {
|
||||
`</w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
html, err := r.RenderHTML(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
|
||||
html, err := r.RenderHTML(tmpl, docforge.PlaceholderMap{"firm.name": "HLC"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render html: %v", err)
|
||||
}
|
||||
@@ -298,7 +227,7 @@ func TestRenderHTML_EscapesContent(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{user.display_name}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
html, err := r.RenderHTML(tmpl, PlaceholderMap{
|
||||
html, err := r.RenderHTML(tmpl, docforge.PlaceholderMap{
|
||||
"user.display_name": `M&S <Inc> "X"`,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
@@ -317,7 +246,7 @@ func TestRenderHTML_WrapsMissingMarker(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{project.case_number}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
html, err := r.RenderHTML(tmpl, PlaceholderMap{}, nil)
|
||||
html, err := r.RenderHTML(tmpl, docforge.PlaceholderMap{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render html: %v", err)
|
||||
}
|
||||
@@ -335,7 +264,7 @@ func TestRenderHTML_WrapsMissingMarker(t *testing.T) {
|
||||
// value. There is no distinction at the renderer level between a value
|
||||
// that came from the resolved bag (project / parties / deadline lookups)
|
||||
// and a value the lawyer typed into the sidebar — both arrive in the
|
||||
// same PlaceholderMap and both must be wrapped.
|
||||
// same docforge.PlaceholderMap and both must be wrapped.
|
||||
func TestRenderHTML_WrapsOverriddenValueSameAsResolved(t *testing.T) {
|
||||
doc := `<w:document><w:body>` +
|
||||
`<w:p><w:r><w:t>{{project.case_number}} / {{firm.name}}</w:t></w:r></w:p>` +
|
||||
@@ -344,7 +273,7 @@ func TestRenderHTML_WrapsOverriddenValueSameAsResolved(t *testing.T) {
|
||||
r := NewSubmissionRenderer()
|
||||
// project.case_number is the typed-by-lawyer override.
|
||||
// firm.name is the always-resolved value from the firm bag.
|
||||
html, err := r.RenderHTML(tmpl, PlaceholderMap{
|
||||
html, err := r.RenderHTML(tmpl, docforge.PlaceholderMap{
|
||||
"project.case_number": "UPC_CFI_42/2026",
|
||||
"firm.name": "HLC",
|
||||
}, nil)
|
||||
@@ -370,7 +299,7 @@ func TestRender_DocxOutputUnchangedByPreviewWrap(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
|
||||
out, err := r.Render(tmpl, docforge.PlaceholderMap{"firm.name": "HLC"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render docx: %v", err)
|
||||
}
|
||||
33
pkg/docforge/placeholder.go
Normal file
33
pkg/docforge/placeholder.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package docforge
|
||||
|
||||
import "strings"
|
||||
|
||||
// PlaceholderMap is the variable bag a ResolverSet builds and a format
|
||||
// exporter fills into a template. Keys are dotted paths without braces
|
||||
// (e.g. "project.case_number"); values are the substituted text — already
|
||||
// locale-aware, pretty-printed, and sanitised by the resolvers that
|
||||
// produced them.
|
||||
//
|
||||
// It is format-neutral: the .docx exporter substitutes these into OOXML,
|
||||
// but a future PDF/HTML/Markdown exporter consumes the same bag. The
|
||||
// {{key}} substitution grammar itself is the exporter's concern and lives
|
||||
// with the adapter (pkg/docforge/docx), not here.
|
||||
type PlaceholderMap map[string]string
|
||||
|
||||
// MissingPlaceholderFn translates an unbound placeholder key into the
|
||||
// in-document marker token. DefaultMissingMarker returns the standard
|
||||
// "[KEIN WERT: <key>]" / "[NO VALUE: <key>]" form.
|
||||
type MissingPlaceholderFn func(key string) string
|
||||
|
||||
// DefaultMissingMarker returns the standard missing-value marker for the
|
||||
// given UI language. Unbound placeholders render this marker inline so the
|
||||
// lawyer sees the gap in the document rather than the render failing.
|
||||
func DefaultMissingMarker(lang string) MissingPlaceholderFn {
|
||||
prefix := "KEIN WERT"
|
||||
if strings.EqualFold(lang, "en") {
|
||||
prefix = "NO VALUE"
|
||||
}
|
||||
return func(key string) string {
|
||||
return "[" + prefix + ": " + key + "]"
|
||||
}
|
||||
}
|
||||
56
pkg/docforge/vars.go
Normal file
56
pkg/docforge/vars.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package docforge
|
||||
|
||||
// VariableResolver populates one namespace of the placeholder bag.
|
||||
//
|
||||
// Each resolver owns a dotted namespace (e.g. "project", "parties") and
|
||||
// pushes its keys into a shared PlaceholderMap. The push model — rather
|
||||
// than a pull Resolve(key) — is deliberate: some namespaces emit a
|
||||
// data-dependent set of keys (a multi-party suit produces
|
||||
// parties.claimant.0.name, .1.name, … one per party), which a fixed
|
||||
// key-by-key pull interface can't enumerate cleanly. Populate lets each
|
||||
// resolver decide its own (possibly dynamic) key set in one pass.
|
||||
//
|
||||
// The consuming application implements concrete resolvers against its own
|
||||
// data sources (paliad resolves project/party/deadline state from its
|
||||
// Postgres database); docforge owns only the interface and the
|
||||
// composition machinery (ResolverSet). This is the seam a second consumer
|
||||
// (e.g. upc-commentary) plugs its own resolvers into without touching the
|
||||
// engine.
|
||||
type VariableResolver interface {
|
||||
// Namespace returns the dotted prefix this resolver owns, e.g.
|
||||
// "project". Informational — used for diagnostics and (later) the
|
||||
// authoring variable palette's grouping.
|
||||
Namespace() string
|
||||
|
||||
// Populate writes this resolver's keys into bag. Resolvers own
|
||||
// disjoint namespaces, so population order across resolvers does not
|
||||
// affect the final bag.
|
||||
Populate(bag PlaceholderMap)
|
||||
}
|
||||
|
||||
// ResolverSet composes an ordered list of VariableResolvers into a single
|
||||
// PlaceholderMap. It is the replacement for hard-coded "call addFooVars,
|
||||
// then addBarVars, …" sequencing: a consumer registers the resolvers that
|
||||
// apply to a given render and calls BuildBag.
|
||||
type ResolverSet struct {
|
||||
resolvers []VariableResolver
|
||||
}
|
||||
|
||||
// NewResolverSet builds a set from the given resolvers, in order.
|
||||
func NewResolverSet(resolvers ...VariableResolver) *ResolverSet {
|
||||
return &ResolverSet{resolvers: resolvers}
|
||||
}
|
||||
|
||||
// Add appends a resolver to the set.
|
||||
func (s *ResolverSet) Add(r VariableResolver) { s.resolvers = append(s.resolvers, r) }
|
||||
|
||||
// BuildBag runs every resolver's Populate into a fresh PlaceholderMap and
|
||||
// returns it. Because resolvers own disjoint namespaces, the result is
|
||||
// independent of resolver order.
|
||||
func (s *ResolverSet) BuildBag() PlaceholderMap {
|
||||
bag := PlaceholderMap{}
|
||||
for _, r := range s.resolvers {
|
||||
r.Populate(bag)
|
||||
}
|
||||
return bag
|
||||
}
|
||||
Reference in New Issue
Block a user