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.
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user