package controllers import ( "context" "encoding/json" "errors" "fmt" "sort" "strings" "git.golem15.com/golem15/sm-seo-plugin/classes" "git.golem15.com/golem15/sm-seo-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/cabana" "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") // The history preview's record actions (UI-SPEC C12). const ( HistoryActionUndo = "undo" HistoryActionUndoBatch = "undo_batch" ) // 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. The preview undoes one change or a whole batch. 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 } // AdminRecordActions are the preview's undo actions (UI-SPEC C12): undo // applies while the change is not undone; undo_batch while the change // belongs to a batch with changes not undone. Both confirm without the // danger variant (an undo is itself undoable); a refusal (stale, loop, // gone) is a 403 whose message the admin shows as a toast. func (c historyAdminController) AdminRecordActions() []pact.AdminRecordAction { return []pact.AdminRecordAction{ { Name: HistoryActionUndo, Label: "golem15.seo::lang.history.undo", Confirm: "golem15.seo::lang.history.undo_confirm", Permissions: []string{PermissionManage}, Applies: func(_ context.Context, record any) (bool, error) { r, ok := record.(*models.Revision) return ok && r != nil && classes.Undoable(*r), nil }, Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) { return c.undo(ctx, in.Record, false) }, }, { Name: HistoryActionUndoBatch, Label: "golem15.seo::lang.history.undo_batch", Confirm: "golem15.seo::lang.history.undo_batch_confirm", Permissions: []string{PermissionManage}, Applies: func(ctx context.Context, record any) (bool, error) { r, ok := record.(*models.Revision) if !ok || r == nil || r.BatchID == nil || *r.BatchID == "" { return false, nil } db, err := database(ctx, c.app) if err != nil { return false, err } n, err := classes.BatchSize(db, *r.BatchID) return n > 0, err }, Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) { return c.undo(ctx, in.Record, true) }, }, } } // undo runs an undo inside the action's transaction. func (c historyAdminController) undo(ctx context.Context, record any, batch bool) (pact.AdminRecordActionResult, error) { r, ok := record.(*models.Revision) svc, err := classes.NewService(appOf(c.app)) if !ok || r == nil || err != nil { return pact.AdminRecordActionResult{}, classes.ErrNotBooted } db, err := database(ctx, c.app) if err != nil { return pact.AdminRecordActionResult{}, err } done := "golem15.seo::lang.history.undone" if batch && r.BatchID != nil { _, err = svc.UndoBatch(ctx, db, *r.BatchID) done = "golem15.seo::lang.history.batch_undone" } else { _, err = svc.Undo(ctx, db, r.ID) } var refusal *classes.UndoRefusal if errors.As(err, &refusal) { return pact.AdminRecordActionResult{}, &cabana.ForbiddenError{Message: svc.PhraseCtx(ctx, refusal.Key, refusal.Params)} } if err != nil { return pact.AdminRecordActionResult{}, err } return pact.AdminRecordActionResult{Message: done}, nil } var ( _ pact.HasAdminRecordActions = historyAdminController{} _ 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{} )