package controllers import ( "context" "encoding/json" "errors" "fmt" "sort" "strings" "git.golem15.com/golem15/sm-seo-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/pact" "gorm.io/gorm" ) // HistoryControllerID is the history screen (UI-SPEC C12). const HistoryControllerID = "golem15.seo.history" // errHistoryReadOnly refuses writes through the history form. var errHistoryReadOnly = errors.New("golem15.seo: history is read-only") // historyAdminController lists and previews the SEO change history // (D-13). History is written by the plugin's service only: the form saves // and deletes nothing. Undo arrives as record actions with the management // API. type historyAdminController struct { app func() *backpack.App } func (historyAdminController) ID() string { return HistoryControllerID } func (historyAdminController) ModelName() string { return `Golem15\SEO\Models\Revision` } func (historyAdminController) ConfigDir() string { return "controllers/history" } func (historyAdminController) NewRecord() any { return &models.Revision{} } func (historyAdminController) RequiredPermissions() []string { return []string{PermissionManage} } // FormBeforeCreate refuses: history is written by the service only. func (historyAdminController) FormBeforeCreate(context.Context, any) error { return errHistoryReadOnly } // FormBeforeUpdate refuses: history is never edited. func (historyAdminController) FormBeforeUpdate(context.Context, any) error { return errHistoryReadOnly } // FormBeforeDelete refuses: history is never deleted from the admin. func (historyAdminController) FormBeforeDelete(context.Context, any) error { return errHistoryReadOnly } // viaLabel is the phrase key of a writer. func viaLabel(via string) string { return "golem15.seo::lang.history.via_" + via } // fieldLabel is the label of a changed field: the SEO tab's and the rule // form's phrase for known columns ("title.pl" keeps its locale), else the // field name. func fieldLabel(ctx context.Context, tr phraser, key string) string { base, locale, _ := strings.Cut(key, ".") label := key for _, p := range models.RuleProperties { if p.Column == base { label = tr(ctx, p.Label, nil) if locale != "" { label += " (" + locale + ")" } break } } return label } // phraser translates a key with params. type phraser func(ctx context.Context, key string, params map[string]string) string // displayValue is a stored history value as text. func displayValue(v any) string { switch val := v.(type) { case nil: return "" case string: return val case bool: if val { return "true" } return "false" } raw, err := json.Marshal(v) if err != nil { return fmt.Sprint(v) } return string(raw) } // changedKeys lists the keys of a revision's before and after in order. func changedKeys(r *models.Revision) []string { keys := map[string]bool{} for k := range r.Before { keys[k] = true } for k := range r.After { keys[k] = true } out := make([]string, 0, len(keys)) for k := range keys { out = append(out, k) } sort.Strings(out) return out } // ListExtendRecords fills the via label, the one-line change summary and // the short batch id of each row. func (c historyAdminController) ListExtendRecords(ctx context.Context, _ *gorm.DB, records []any) error { tr := translator(c.app) phrase := func(ctx context.Context, key string, params map[string]string) string { return Phrase(ctx, tr, key, params) } for _, record := range records { r, ok := record.(*models.Revision) if !ok || r == nil { continue } r.ViaLabel = phrase(ctx, viaLabel(r.Via), nil) if r.BatchID != nil { r.Batch = *r.BatchID if len(r.Batch) > 8 { r.Batch = r.Batch[:8] } } keys := changedKeys(r) switch { case len(r.Before) == 0 && len(r.After) > 0 && r.SubjectKind != models.SubjectSettings: r.Change = phrase(ctx, "golem15.seo::lang.history.created", nil) case len(r.After) == 0 && len(r.Before) > 0: r.Change = phrase(ctx, "golem15.seo::lang.history.deleted", nil) case len(keys) == 1: if tr != nil { r.Change = tr.Choice(ctx, "golem15.seo::lang.history.change_cell", 1, map[string]string{ "field": fieldLabel(ctx, phrase, keys[0]), "before": displayValue(r.Before[keys[0]]), "after": displayValue(r.After[keys[0]]), }) } default: if tr != nil { r.Change = tr.Choice(ctx, "golem15.seo::lang.history.change_cell", len(keys), map[string]string{ "count": fmt.Sprint(len(keys)), }) } } } return nil } // ListRowStates marks undone changes disabled (label history.state_undone). func (historyAdminController) ListRowStates(_ context.Context, _ *gorm.DB, records []any) ([][]pact.RowState, error) { out := make([][]pact.RowState, len(records)) for i, record := range records { if r, ok := record.(*models.Revision); ok && r != nil && r.UndoneAt != nil { out[i] = []pact.RowState{pact.RowStateDisabled} } } return out, nil } // FilterOptions serves the via filter's choices. func (historyAdminController) FilterOptions(scope string) []pact.Option { if scope != models.FilterVia { return nil } out := make([]pact.Option, 0, len(models.Vias)) for _, v := range models.Vias { out = append(out, pact.Option{Value: v, Label: viaLabel(v)}) } return out } // diffRow is one row of the P5 history diff. type diffRow struct { Field string Before string After string Mono bool Created bool Deleted bool } // diffView is the view model of the P5 history diff partial. type diffView struct { Rows []diffRow } // PartialData serves the P5 diff of a change: one row per changed field, // URL values in monospace, created and deleted markers. func (c historyAdminController) PartialData(ctx context.Context, name string, record any) (any, error) { if name != "history_diff" { return nil, nil } r, ok := record.(*models.Revision) if !ok || r == nil { return diffView{}, nil } tr := translator(c.app) phrase := func(ctx context.Context, key string, params map[string]string) string { return Phrase(ctx, tr, key, params) } view := diffView{} for _, key := range changedKeys(r) { base, _, _ := strings.Cut(key, ".") _, inBefore := r.Before[key] _, inAfter := r.After[key] view.Rows = append(view.Rows, diffRow{ Field: fieldLabel(ctx, phrase, key), Before: displayValue(r.Before[key]), After: displayValue(r.After[key]), Mono: base == "url" || base == "canonical" || base == "og_image" || base == "default_og_image", Created: !inBefore || len(r.Before) == 0, Deleted: !inAfter || len(r.After) == 0, }) } return view, nil } var ( _ pact.AdminController = historyAdminController{} _ pact.AdminPermissioned = historyAdminController{} _ pact.AdminRecordSource = historyAdminController{} _ pact.FormBeforeCreate = historyAdminController{} _ pact.FormBeforeUpdate = historyAdminController{} _ pact.FormBeforeDelete = historyAdminController{} _ pact.ListExtendRecords = historyAdminController{} _ pact.ListRowStates = historyAdminController{} _ pact.FilterOptions = historyAdminController{} _ pact.AdminPartialData = historyAdminController{} )