package api import ( "context" "reflect" "sort" "strconv" "time" "git.golem15.com/golem15/sm-journal-plugin/models" translate "git.golem15.com/golem15/sm-translate-plugin/classes" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/towel" "gorm.io/gorm" ) // postEventColumns are the post columns a record event reports, in order. var postEventColumns = []struct { name string value func(p *models.Post) any }{ {"user_id", func(p *models.Post) any { return derefValue(p.UserID) }}, {"redactor_id", func(p *models.Post) any { return derefValue(p.RedactorID) }}, {"title", func(p *models.Post) any { return p.Title }}, {"slug", func(p *models.Post) any { return p.Slug }}, {"excerpt", func(p *models.Post) any { return p.Excerpt }}, {"content", func(p *models.Post) any { return p.Content }}, {"content_html", func(p *models.Post) any { return p.ContentHTML }}, {"published_at", func(p *models.Post) any { return derefValue(p.PublishedAt) }}, {"published", func(p *models.Post) any { return p.Published }}, {"is_pinned", func(p *models.Post) any { return p.IsPinned }}, {"metadata", func(p *models.Post) any { return derefValue(p.Metadata) }}, {"sources", func(p *models.Post) any { return derefValue(p.Sources) }}, } // translatedEventFields are the translated fields a write may submit under // "translations" (syncTranslations). var translatedEventFields = []string{"title", "slug", "content", "content_html", "excerpt", "metadata"} // postSnapshot is a post's values before an API write: its columns and the // stored translations of the fields the request submits per locale. type postSnapshot struct { columns map[string]any translations map[string]map[string]any // locale -> field -> old (nil when missing) } // snapshotPost takes the values an update may change, before it changes // them. A translation read error leaves that value out (reported as new). func snapshotPost(ctx context.Context, db *gorm.DB, post *models.Post, body map[string]any) *postSnapshot { snap := &postSnapshot{columns: map[string]any{}, translations: map[string]map[string]any{}} for _, col := range postEventColumns { snap.columns[col.name] = col.value(post) } for locale, fields := range submittedTranslations(body) { snap.translations[locale] = map[string]any{} for _, field := range fields { text, ok, err := translate.TranslatedExact(ctx, db, post, field, locale) if err == nil && ok { snap.translations[locale][field] = text } else { snap.translations[locale][field] = nil } } } return snap } // postSavedEvent is the RecordSaved of an API write, which the handler // fires with cabana.EmitRecordSaved once the write is committed (the API // writes run outside a transaction, so at once). before is nil on create: // every column with a value and every submitted translation is then // reported with a nil Old. Controller is "": the write did not go through an // admin form. func postSavedEvent(ctx context.Context, db *gorm.DB, before *postSnapshot, post *models.Post, body map[string]any) pact.RecordSaved { changes := []pact.RecordChange{} for _, col := range postEventColumns { next := col.value(post) if before == nil { if next == nil || reflect.ValueOf(next).IsZero() { continue } changes = append(changes, pact.RecordChange{Attribute: col.name, New: next}) continue } prev := before.columns[col.name] if sameEventValue(prev, next) { continue } changes = append(changes, pact.RecordChange{Attribute: col.name, Old: prev, New: next}) } submitted := submittedTranslations(body) locales := make([]string, 0, len(submitted)) for locale := range submitted { locales = append(locales, locale) } sort.Strings(locales) for _, locale := range locales { for _, field := range submitted[locale] { text, ok, err := translate.TranslatedExact(ctx, db, post, field, locale) if err != nil || !ok { continue } var prev any if before != nil { prev = before.translations[locale][field] } if old, _ := prev.(string); old == text { continue } changes = append(changes, pact.RecordChange{Attribute: field, Locale: locale, Old: prev, New: text}) } } locale, _ := towel.Locale(ctx) return pact.RecordSaved{ Model: post.MorphName(), ID: strconv.FormatUint(uint64(post.ID), 10), Locale: locale, Created: before == nil, Changes: changes, } } // submittedTranslations lists, per locale, the translated fields a request // body submits with a value, in translatedEventFields order. func submittedTranslations(body map[string]any) map[string][]string { out := map[string][]string{} m, ok := body["translations"].(map[string]any) if !ok { return out } for locale, raw := range m { attrs, ok := raw.(map[string]any) if !ok { continue } for _, field := range translatedEventFields { if v, has := attrs[field]; has && v != nil { out[locale] = append(out[locale], field) } } } return out } // derefValue is a pointer column's value, nil for a nil pointer. func derefValue[T any](p *T) any { if p == nil { return nil } return *p } // sameEventValue compares two column values; times compare as instants. func sameEventValue(a, b any) bool { ta, okA := a.(time.Time) tb, okB := b.(time.Time) if okA && okB { return ta.Equal(tb) } return reflect.DeepEqual(a, b) }