Files
archivdms/internal/storage/classification_templates_title.go
T
patrick 9a24ea29e1 FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
2026-08-11 21:27:53 +02:00

199 lines
6.7 KiB
Go

package storage
import (
"bytes"
"context"
"errors"
"fmt"
"strings"
"text/template"
"time"
"github.com/jackc/pgx/v5"
)
// titleTemplateData is the data model exposed to a classification template's
// (or the tenant-wide default's) title Go text/template. Fields are kept simple
// (plain strings / times) so patterns stay readable, e.g.
//
// {{.Correspondent}} {{.DocumentType}} {{dateFormat "02.01.2006" .Belegdatum}}
//
// Belegdatum and UploadDate are passed as time.Time (zero value when unknown);
// use the dateFormat template func to render them, which yields "" for a zero
// time instead of Go's "0001-01-01..." default.
type titleTemplateData struct {
Correspondent string
DocumentType string
Belegdatum time.Time
UploadDate time.Time
Tags string
OCRTitle string
}
// titleTemplateFuncs provides the custom template functions available inside a
// title template. dateFormat takes a Go reference layout ("02.01.2006") and a
// time.Time, returning "" for a zero time so an unknown Belegdatum does not
// leak a placeholder date into the title.
var titleTemplateFuncs = template.FuncMap{
"dateFormat": func(layout string, t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format(layout)
},
}
// ValidateTitleTemplate parses (but does not execute) a title template pattern
// so the API layer can reject a syntactically invalid pattern up front. An
// empty pattern is valid (means "no template title"). Exported for the
// settings / template CRUD handlers.
func ValidateTitleTemplate(pattern string) error {
if strings.TrimSpace(pattern) == "" {
return nil
}
_, err := template.New("title").Option("missingkey=zero").Funcs(titleTemplateFuncs).Parse(pattern)
if err != nil {
return fmt.Errorf("invalid title template: %w", err)
}
return nil
}
// renderTitleTemplate parses and executes a title template against data. The
// result is whitespace-trimmed. missingkey=zero guards against crashes when a
// pattern references a field that does not exist. A parse/execute error or an
// empty result is signalled to the caller so it can fall back (never an empty
// title).
func renderTitleTemplate(pattern string, data titleTemplateData) (string, error) {
tmpl, err := template.New("title").Option("missingkey=zero").Funcs(titleTemplateFuncs).Parse(pattern)
if err != nil {
return "", fmt.Errorf("storage: parse title template: %w", err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("storage: execute title template: %w", err)
}
return strings.TrimSpace(buf.String()), nil
}
// tenantDefaultTitleTemplate reads the tenant-wide fallback title template
// straight from the tenants table (same DB pool). Returns "" when unset
// (NULL) so callers can treat "no default" uniformly.
func (s *Store) tenantDefaultTitleTemplate(ctx context.Context, tenantID int64) (string, error) {
var v *string
err := s.db.QueryRow(ctx, `SELECT default_title_template FROM tenants WHERE id = $1`, tenantID).Scan(&v)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
return "", fmt.Errorf("storage: read tenant default_title_template: %w", err)
}
if v == nil {
return "", nil
}
return strings.TrimSpace(*v), nil
}
// taxonomyNameByID resolves a taxonomy entity's display name (tenant-scoped).
// table must be a fixed internal literal ("correspondents" / "document_types"),
// never user input. Returns "" (not an error) when the row does not exist so a
// dangling reference cannot break title generation.
func (s *Store) taxonomyNameByID(ctx context.Context, table string, id, tenantID int64) (string, error) {
var name string
err := s.db.QueryRow(ctx, fmt.Sprintf(`SELECT name FROM %s WHERE id = $1 AND tenant_id = $2`, table), id, tenantID).Scan(&name)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
return "", fmt.Errorf("storage: lookup %s name: %w", table, err)
}
return name, nil
}
// applyTemplateTitle derives and persists a document title from a classification
// template's title_template (or, if that is empty, the tenant-wide
// default_title_template). Rules (see feature spec):
//
// - Never touches a manually renamed document (title_manually_set = true).
// - Chooses the template's own pattern first, then the tenant default; if both
// are empty, does nothing (existing title kept).
// - On an empty or errored render result, falls back to keeping the existing
// title — never sets an empty string.
// - Persists via UpdateDocumentTitleAuto so title_manually_set stays false
// (a later correction + re-apply must still work).
//
// Called at the end of ApplyTemplate, so it runs identically for the manual
// endpoint and the workflow trigger (both funnel through ApplyTemplate).
func (s *Store) applyTemplateTitle(ctx context.Context, documentID, tenantID int64, tmpl *ClassificationTemplate) error {
doc, err := s.GetDocument(ctx, documentID, tenantID)
if err != nil {
return err
}
if doc.TitleManuallySet {
return nil
}
// Resolve the effective pattern: template-own first, then tenant default.
pattern := ""
if tmpl.TitleTemplate != nil {
pattern = strings.TrimSpace(*tmpl.TitleTemplate)
}
if pattern == "" {
def, err := s.tenantDefaultTitleTemplate(ctx, tenantID)
if err != nil {
return err
}
pattern = def
}
if pattern == "" {
return nil // no template title configured at either level
}
// Build the render data. Name lookups are best-effort (missing rows -> "").
data := titleTemplateData{
OCRTitle: doc.Title,
UploadDate: doc.CreatedAt,
}
if doc.DocumentDate != nil {
data.Belegdatum = *doc.DocumentDate
}
if doc.CorrespondentID != nil {
name, err := s.taxonomyNameByID(ctx, "correspondents", *doc.CorrespondentID, tenantID)
if err != nil {
return err
}
data.Correspondent = name
}
// Document type: prefer the template's target type (what is being applied),
// falling back to the document's current type.
docTypeID := tmpl.DocTypeID
if docTypeID == nil {
docTypeID = doc.DocTypeID
}
if docTypeID != nil {
name, err := s.taxonomyNameByID(ctx, "document_types", *docTypeID, tenantID)
if err != nil {
return err
}
data.DocumentType = name
}
tags, err := s.ListDocumentTags(ctx, documentID, tenantID)
if err != nil {
return err
}
names := make([]string, 0, len(tags))
for _, t := range tags {
names = append(names, t.Name)
}
data.Tags = strings.Join(names, ", ")
rendered, err := renderTitleTemplate(pattern, data)
if err != nil || rendered == "" {
// Fallback: keep the existing (OCR-derived) title, never blank it.
return nil //nolint:nilerr // intentional: a bad template must not fail the apply
}
if rendered == doc.Title {
return nil
}
return s.UpdateDocumentTitleAuto(ctx, documentID, tenantID, rendered)
}