Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
199 lines
6.7 KiB
Go
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)
|
|
}
|