diff --git a/admin_navigation.go b/admin_navigation.go index eec38f1..0388348 100644 --- a/admin_navigation.go +++ b/admin_navigation.go @@ -24,6 +24,13 @@ func (*Plugin) Navigation() []pact.NavigationItem { Permissions: []string{controllers.PermissionManage}, Controller: controllers.RulesControllerID, }, + { + Code: "history", + Label: "golem15.seo::lang.nav.history", + Icon: "clock", + Permissions: []string{controllers.PermissionManage}, + Controller: controllers.HistoryControllerID, + }, }, }} } diff --git a/classes/entries.go b/classes/entries.go index e5fe17e..8c5583e 100644 --- a/classes/entries.go +++ b/classes/entries.go @@ -129,7 +129,7 @@ func (s *Service) SaveRecord(ctx context.Context, db *gorm.DB, in RecordWrite) ( for locale, e := range existing { before[locale] = e } - urls := s.recordURLs(ctx, in.Type.Key, in.ID) + described := s.recordLocales(ctx, in.Type.Key, in.ID) touched := map[string]models.Entry{} for column, perLocale := range in.Values { for locale, value := range perLocale { @@ -153,8 +153,8 @@ func (s *Service) SaveRecord(ctx context.Context, db *gorm.DB, in RecordWrite) ( continue } e.TypeKey = in.Type.Key - if u, ok := urls[locale]; ok { - e.URL = u + if l, ok := described[locale]; ok { + e.URL = l.URL } if e.ID == 0 { if err := db.Create(&e).Error; err != nil { @@ -164,10 +164,37 @@ func (s *Service) SaveRecord(ctx context.Context, db *gorm.DB, in RecordWrite) ( return nil, nil, err } after[locale] = e + var old map[string]any + if stored { + old = entryValues(existing[locale]) + } + if err := s.Record(ctx, db, Change{ + Kind: models.SubjectEntry, + SubjectID: EntrySubject(modelType, in.ID, locale), + What: s.recordWhat(in.Type, in.ID, described[locale], locale), + Before: old, + After: entryValues(e), + }); err != nil { + return nil, nil, err + } } return before, after, nil } +// recordWhat is the history description of a record's SEO in a locale: +// "SEO of “” (<locale>)". +func (s *Service) recordWhat(typ pact.MenuItemType, id string, l pact.ContentLocale, locale string) string { + title := l.Title + if strings.TrimSpace(title) == "" { + title = "#" + id + } + return s.Phrase("golem15.seo::lang.history.what_record", map[string]string{ + "type": s.Phrase(typ.Label, nil), + "title": title, + "locale": locale, + }) +} + // entryEmpty reports whether no editable column of e carries a value. func entryEmpty(e models.Entry) bool { for _, column := range EntryColumns { @@ -178,10 +205,10 @@ func entryEmpty(e models.Entry) bool { return len(e.Data) == 0 } -// recordURLs returns the record's public path per locale through its -// content descriptor; an error or a missing registry answers none. -func (s *Service) recordURLs(ctx context.Context, key, id string) map[string]string { - out := map[string]string{} +// recordLocales describes the record per locale through its content +// descriptor; an error or a missing registry answers none. +func (s *Service) recordLocales(ctx context.Context, key, id string) map[string]pact.ContentLocale { + out := map[string]pact.ContentLocale{} reg := s.Registry() if reg == nil { return out @@ -191,7 +218,7 @@ func (s *Service) recordURLs(ctx context.Context, key, id string) map[string]str return out } for _, l := range rec.Locales { - out[l.Locale] = l.URL + out[l.Locale] = l } return out } diff --git a/classes/history.go b/classes/history.go new file mode 100644 index 0000000..2a8a8c4 --- /dev/null +++ b/classes/history.go @@ -0,0 +1,260 @@ +package classes + +import ( + "context" + "database/sql/driver" + "encoding/json" + "fmt" + "reflect" + "strings" + "time" + + "git.golem15.com/golem15/sm-seo-plugin/models" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/phrasebook" + "gorm.io/gorm" +) + +type viaKey struct{} + +// WithVia marks ctx as a write through via (import, auto, undo), which +// history records instead of admin or mcp. +func WithVia(ctx context.Context, via string) context.Context { + return context.WithValue(ctx, viaKey{}, via) +} + +// ViaOf is the writer history records for ctx: a WithVia mark, else mcp +// when an MCP dispatch for the backend guard carries the request +// (see mcpPrincipal), else admin. +func ViaOf(ctx context.Context) string { + if v, ok := ctx.Value(viaKey{}).(string); ok && v != "" { + return v + } + if _, ok := mcpPrincipal(ctx); ok { + return models.ViaMCP + } + return models.ViaAdmin +} + +// mcpPrincipal returns the principal of an MCP dispatch for the backend +// guard, which the MCP module attaches to the requests it builds. +func mcpPrincipal(ctx context.Context) (*bouncer.Principal, bool) { + d, ok := bouncer.Dispatched(ctx, "backend") + if !ok { + return nil, false + } + return d.Principal, true +} + +// Change is one changed object for history. Before and After hold the +// object's values; Record keeps only the fields whose value changed. A +// created object has a nil Before, a deleted one a nil After. +type Change struct { + Kind string + SubjectID string + What string + Before map[string]any + After map[string]any +} + +// diffValues keeps the keys whose value differs between before and after; +// nil before or after keeps every key of the other side. +func diffValues(before, after map[string]any) (b, a map[string]any) { + if before == nil || after == nil { + return before, after + } + b, a = map[string]any{}, map[string]any{} + keys := map[string]bool{} + for k := range before { + keys[k] = true + } + for k := range after { + keys[k] = true + } + for k := range keys { + bv, av := before[k], after[k] + if !reflect.DeepEqual(plainValue(bv), plainValue(av)) { + b[k], a[k] = plainValue(bv), plainValue(av) + } + } + return b, a +} + +// plainValue turns a model value into a JSON-friendly one: pointers are +// dereferenced, times formatted, and SQL valuers (jsonable columns) read +// through their stored JSON. +func plainValue(v any) any { + switch val := v.(type) { + case nil: + return nil + case string, bool, float64, int, int64, uint, uint64: + return val + case time.Time: + return val.UTC().Format(time.RFC3339) + case *time.Time: + if val == nil { + return nil + } + return val.UTC().Format(time.RFC3339) + case json.Number: + return val.String() + case driver.Valuer: + raw, err := val.Value() + if err != nil || raw == nil { + return nil + } + if s, ok := raw.(string); ok { + var decoded any + if json.Unmarshal([]byte(s), &decoded) == nil { + return decoded + } + return s + } + return plainValue(raw) + } + rv := reflect.ValueOf(v) + if rv.Kind() == reflect.Pointer { + if rv.IsNil() { + return nil + } + return plainValue(rv.Elem().Interface()) + } + raw, err := json.Marshal(v) + if err != nil { + return fmt.Sprint(v) + } + var decoded any + if json.Unmarshal(raw, &decoded) != nil { + return fmt.Sprint(v) + } + return decoded +} + +// ChangesValues turns a save's record changes into before and after maps: +// the attribute name, with ".<locale>" for a translated value. +func ChangesValues(changes []pact.RecordChange, created bool) (before, after map[string]any) { + after = map[string]any{} + if !created { + before = map[string]any{} + } + for _, c := range changes { + key := c.Attribute + if c.Locale != "" { + key += "." + c.Locale + } + if before != nil { + before[key] = plainValue(c.Old) + } + after[key] = plainValue(c.New) + } + return before, after +} + +// author returns the author of a write: the backend principal's id and a +// label (the administrator's name or login; "MCP: <label>" through MCP). +func (s *Service) author(ctx context.Context, db *gorm.DB, via string) (*uint, string) { + p, ok := mcpPrincipal(ctx) + if !ok { + p, _ = bouncer.User(ctx) + } + if p == nil || p.ID == 0 { + return nil, "" + } + id := p.ID + var row struct { + FirstName string + LastName string + Login string + } + label := fmt.Sprintf("#%d", id) + if err := db.Raw(`SELECT first_name, last_name, login FROM backend_users WHERE id = ?`, id).Scan(&row).Error; err == nil { + if name := strings.TrimSpace(row.FirstName + " " + row.LastName); name != "" { + label = name + } else if row.Login != "" { + label = row.Login + } + } + if via == models.ViaMCP { + label = "MCP: " + label + } + return &id, label +} + +// Record appends one history row per change whose values differ, inside +// db (the caller's transaction): author and via from ctx, What as given, +// before and after of the changed fields only. +func (s *Service) Record(ctx context.Context, db *gorm.DB, changes ...Change) error { + via := ViaOf(ctx) + var authorID *uint + var authorLabel string + authored := false + for _, c := range changes { + before, after := diffValues(c.Before, c.After) + if c.Before != nil && c.After != nil && len(after) == 0 { + continue + } + if !authored { + authorID, authorLabel = s.author(ctx, db, via) + authored = true + } + row := models.Revision{ + AuthorID: authorID, + AuthorLabel: authorLabel, + Via: via, + SubjectKind: c.Kind, + SubjectID: c.SubjectID, + What: c.What, + Before: models.JSONMap(before), + After: models.JSONMap(after), + CreatedAt: time.Now().UTC(), + } + if err := db.Create(&row).Error; err != nil { + return err + } + } + return nil +} + +// translator returns the application translator, nil when none. +func (s *Service) translator() *phrasebook.Translator { + if s == nil || s.app == nil { + return nil + } + tr, _ := s.app.Lookup[*phrasebook.Translator]() + return tr +} + +// Phrase renders a phrase key with params in the application's default +// admin locale (history descriptions are stored as text, so the list can +// search them). +func (s *Service) Phrase(key string, params map[string]string) string { + tr := s.translator() + if tr == nil { + return key + } + return tr.GetIn(tr.Locale(), key, params) +} + +// EntrySubject is the subject id of a record's entry in a locale. +func EntrySubject(modelType, recordID, locale string) string { + return modelType + "|" + recordID + "|" + locale +} + +// entryValues are the editable values of an entry for history. +func entryValues(e models.Entry) map[string]any { + out := map[string]any{} + for _, column := range EntryColumns { + out[column] = entryValue(e, column) + } + return out +} + +// RuleValues are a rule's values for history. +func RuleValues(r models.Rule) map[string]any { + out := map[string]any{"url": r.URL, "match": r.Match, "note": r.Note} + for _, p := range models.RuleProperties { + out[p.Column] = r.Value(p.Column) + } + return out +} diff --git a/classes/service.go b/classes/service.go index 20b8981..8034a68 100644 --- a/classes/service.go +++ b/classes/service.go @@ -24,16 +24,13 @@ type Service struct { app *backpack.App } -// NewService returns the service of a booted application. It needs the -// application's database; the signpost registry is optional (without it no -// content type takes part). +// NewService returns the service of a booted application. The database +// and the signpost registry are looked up when used, so a binary that runs +// a console command without a database still boots. func NewService(app *backpack.App) (*Service, error) { if app == nil { return nil, ErrNotBooted } - if db, ok := app.Lookup[*gorm.DB](); !ok || db == nil { - return nil, errors.New("database is not configured") - } return &Service{app: app}, nil } diff --git a/classes/type_settings.go b/classes/type_settings.go index c80eae9..cba5ded 100644 --- a/classes/type_settings.go +++ b/classes/type_settings.go @@ -41,3 +41,13 @@ func (s *Service) SaveTypeSetting(_ context.Context, db *gorm.DB, row models.Typ DoUpdates: clause.AssignmentColumns([]string{"inject", "title_template", "description_template", "robots"}), }).Create(&row).Error } + +// TypeSettingValues are a type settings row's values for history. +func TypeSettingValues(row models.TypeSetting) map[string]any { + return map[string]any{ + "inject": row.Inject, + "title_template": row.TitleTemplate, + "description_template": row.DescriptionTemplate, + "robots": row.Robots, + } +} diff --git a/controllers/admin_registry.go b/controllers/admin_registry.go index 07b6e28..c671b01 100644 --- a/controllers/admin_registry.go +++ b/controllers/admin_registry.go @@ -30,6 +30,7 @@ func AdminControllers(app func() *backpack.App) []pact.AdminController { return []pact.AdminController{ settingsAdminController{app: app}, rulesAdminController{app: app}, + historyAdminController{app: app}, } } diff --git a/controllers/history.go b/controllers/history.go new file mode 100644 index 0000000..23898a6 --- /dev/null +++ b/controllers/history.go @@ -0,0 +1,241 @@ +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{} +) diff --git a/controllers/history/_history_diff.htm b/controllers/history/_history_diff.htm new file mode 100644 index 0000000..d096896 --- /dev/null +++ b/controllers/history/_history_diff.htm @@ -0,0 +1,10 @@ +{{- if .Data.Rows -}} +<table class="summer-table"> +<thead><tr><th>{{ trans "golem15.seo::lang.history.diff_field" }}</th><th>{{ trans "golem15.seo::lang.history.diff_before" }}</th><th>{{ trans "golem15.seo::lang.history.diff_after" }}</th></tr></thead> +<tbody> +{{- range .Data.Rows -}} +<tr><td>{{ .Field }}</td><td>{{ if and .Created (not .Before) }}<span class="summer-table__sub">{{ trans "golem15.seo::lang.history.created" }}</span>{{ else if .Mono }}<code>{{ .Before }}</code>{{ else }}{{ .Before }}{{ end }}</td><td>{{ if and .Deleted (not .After) }}<span class="summer-table__sub">{{ trans "golem15.seo::lang.history.deleted" }}</span>{{ else if .Mono }}<code>{{ .After }}</code>{{ else }}{{ .After }}{{ end }}</td></tr> +{{- end -}} +</tbody> +</table> +{{- end -}} diff --git a/controllers/history/config_filter.yaml b/controllers/history/config_filter.yaml new file mode 100644 index 0000000..dd05004 --- /dev/null +++ b/controllers/history/config_filter.yaml @@ -0,0 +1,11 @@ +# History filters (UI-SPEC C12): writer and date. +scopes: + via: + label: golem15.seo::lang.history.filter_via + modelClass: Golem15\SEO\Models\Revision + nameFrom: via + scope: FilterVia + created_at: + label: golem15.seo::lang.history.filter_when + type: daterange + column: created_at diff --git a/controllers/history/config_form.yaml b/controllers/history/config_form.yaml new file mode 100644 index 0000000..e90775f --- /dev/null +++ b/controllers/history/config_form.yaml @@ -0,0 +1,8 @@ +# History preview (UI-SPEC C12): read-only with the P5 diff. +name: golem15.seo::lang.history.name +form: ~/plugins/golem15/seo/models/revision/fields.yaml +modelClass: Golem15\SEO\Models\Revision +defaultRedirect: golem15/seo/history + +messages: + preview: golem15.seo::lang.history.preview diff --git a/controllers/history/config_list.yaml b/controllers/history/config_list.yaml new file mode 100644 index 0000000..b246bc7 --- /dev/null +++ b/controllers/history/config_list.yaml @@ -0,0 +1,22 @@ +# History list (UI-SPEC C12): newest first, read-only, undone rows +# disabled. +list: ~/plugins/golem15/seo/models/revision/columns.yaml +modelClass: Golem15\SEO\Models\Revision +title: golem15.seo::lang.history.list_title +recordUrl: golem15/seo/history/preview/:id +noRecordsMessage: golem15.seo::lang.history.list_empty +recordsPerPage: 50 +showSetup: true +filter: config_filter.yaml + +defaultSort: + column: created_at + direction: desc + +toolbar: + search: + prompt: backend::lang.list.search_prompt + +messages: + empty: golem15.seo::lang.history.list_empty + rowStateDisabled: golem15.seo::lang.history.state_undone diff --git a/controllers/rules.go b/controllers/rules.go index b876f80..dc3a8c7 100644 --- a/controllers/rules.go +++ b/controllers/rules.go @@ -2,8 +2,10 @@ package controllers import ( "context" + "strconv" "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/pact" @@ -48,6 +50,29 @@ func (c rulesAdminController) check(ctx context.Context, model any) error { return problemsError(ctx, translator(c.app), r.Problems(db)) } +// FormAfterDelete records the deleted rule in history inside the delete's +// transaction, so its values can be restored. +func (c rulesAdminController) FormAfterDelete(ctx context.Context, model any) error { + r, ok := model.(*models.Rule) + if !ok || r == nil { + return nil + } + svc, err := classes.NewService(appOf(c.app)) + if err != nil { + return err + } + db, err := database(ctx, c.app) + if err != nil { + return err + } + return svc.Record(ctx, db, classes.Change{ + Kind: models.SubjectRule, + SubjectID: strconv.FormatUint(uint64(r.ID), 10), + What: svc.Phrase("golem15.seo::lang.history.what_rule", map[string]string{"url": r.URL}), + Before: classes.RuleValues(*r), + }) +} + // ListExtendRecords fills the overrides column: the translated labels of // the properties each rule sets, comma-joined. func (c rulesAdminController) ListExtendRecords(ctx context.Context, _ *gorm.DB, records []any) error { @@ -87,4 +112,5 @@ var ( _ pact.FormBeforeUpdate = rulesAdminController{} _ pact.ListExtendRecords = rulesAdminController{} _ pact.FilterOptions = rulesAdminController{} + _ pact.FormAfterDelete = rulesAdminController{} ) diff --git a/extensions.go b/extensions.go index 379060a..7a3f5c8 100644 --- a/extensions.go +++ b/extensions.go @@ -7,6 +7,7 @@ import ( "git.golem15.com/golem15/sm-seo-plugin/classes" "git.golem15.com/golem15/sm-seo-plugin/controllers" + "git.golem15.com/golem15/sm-seo-plugin/models" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/signpost" ) @@ -36,9 +37,37 @@ func (p *Plugin) AdminActionExtensions() []pact.AdminActionExtension { if form := p.typeSettingsTabs(types); form != nil { out = append(out, pact.AdminActionExtension{Controller: controllers.SettingsControllerID, Form: form}) } + out = append(out, pact.AdminActionExtension{Controller: controllers.RulesControllerID, Form: p.ruleHistory()}) return out } +// ruleHistory is a field-less extension on the plugin's own rules +// controller: its Save sees the save's changes inside the save transaction +// and records them in history (deletes are recorded by the controller). +func (p *Plugin) ruleHistory() *pact.AdminFormExtension { + return &pact.AdminFormExtension{ + Permissions: []string{controllers.PermissionManage}, + Save: func(ctx context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) { + svc := p.Service() + if svc == nil { + return pact.AdminFormSaveResult{}, classes.ErrNotBooted + } + before, after := classes.ChangesValues(in.Changes, in.Created) + url := "" + if r, ok := in.Record.(*models.Rule); ok && r != nil { + url = r.URL + } + return pact.AdminFormSaveResult{}, svc.Record(ctx, in.DB, classes.Change{ + Kind: models.SubjectRule, + SubjectID: in.ID, + What: svc.Phrase("golem15.seo::lang.history.what_rule", map[string]string{"url": url}), + Before: before, + After: after, + }) + }, + } +} + func (p *Plugin) registry() *signpost.Registry { if p == nil || p.app == nil { return nil diff --git a/extensions_settings.go b/extensions_settings.go index fb1cc8c..9900ec4 100644 --- a/extensions_settings.go +++ b/extensions_settings.go @@ -108,18 +108,23 @@ func typeSettingsDocument(types []pact.MenuItemType) ([]byte, error) { }) } -// typeSettingsTabs is the form extension on the SEO settings controller -// that adds the per-type tabs; nil when no content type is registered. +// typeSettingsTabs is the plugin's form extension on its own settings +// controller: it adds the per-type tabs (none when no content type is +// registered) and records every settings save in history. func (p *Plugin) typeSettingsTabs(types []pact.MenuItemType) *pact.AdminFormExtension { - if len(types) == 0 { - return nil - } - doc, err := typeSettingsDocument(types) - if err != nil { - pluginLog.Error("golem15.seo: per-type settings tabs", "error", err) - return nil + var doc []byte + if len(types) > 0 { + var err error + if doc, err = typeSettingsDocument(types); err != nil { + pluginLog.Error("golem15.seo: per-type settings tabs", "error", err) + return nil + } } fields := typeSettingsFields(types) + labels := map[string]string{} + for _, typ := range types { + labels[typ.Key] = typ.Label + } return &pact.AdminFormExtension{ FieldsYAML: doc, Permissions: []string{controllers.PermissionManage}, @@ -139,7 +144,21 @@ func (p *Plugin) typeSettingsTabs(types []pact.MenuItemType) *pact.AdminFormExte return out, nil }, Save: func(ctx context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) { - return pact.AdminFormSaveResult{}, p.saveTypeSettings(ctx, in, fields) + svc := p.Service() + if svc == nil { + return pact.AdminFormSaveResult{}, classes.ErrNotBooted + } + before, after := classes.ChangesValues(in.Changes, in.Created) + if err := svc.Record(ctx, in.DB, classes.Change{ + Kind: models.SubjectSettings, + SubjectID: in.ID, + What: svc.Phrase("golem15.seo::lang.history.what_settings", nil), + Before: before, + After: after, + }); err != nil { + return pact.AdminFormSaveResult{}, err + } + return pact.AdminFormSaveResult{}, p.saveTypeSettings(ctx, in, fields, labels) }, } } @@ -161,7 +180,7 @@ func typeSettingValue(row models.TypeSetting, column string) any { // saveTypeSettings stores the submitted per-type values through the // service; a template with an unknown placeholder refuses the save. -func (p *Plugin) saveTypeSettings(ctx context.Context, in pact.AdminFormSave, fields []typeField) error { +func (p *Plugin) saveTypeSettings(ctx context.Context, in pact.AdminFormSave, fields []typeField, labels map[string]string) error { svc := p.Service() if svc == nil { return classes.ErrNotBooted @@ -208,10 +227,23 @@ func (p *Plugin) saveTypeSettings(ctx context.Context, in pact.AdminFormSave, fi if len(refusals) > 0 { return refusals } - for _, row := range changed { + for key, row := range changed { + old, ok := rows[key] + if !ok { + old = models.DefaultTypeSetting(key) + } if err := svc.SaveTypeSetting(ctx, in.DB, row); err != nil { return err } + if err := svc.Record(ctx, in.DB, classes.Change{ + Kind: models.SubjectSettings, + SubjectID: "type:" + key, + What: svc.Phrase("golem15.seo::lang.history.what_settings", nil) + " – " + svc.Phrase(labels[key], nil), + Before: classes.TypeSettingValues(old), + After: classes.TypeSettingValues(row), + }); err != nil { + return err + } } return nil } diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index 1cd01e7..a91eccb 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -3,6 +3,7 @@ plugin: description: Search engine metadata for every content type, URL rules and global SEO settings. nav: seo: SEO + history: History rules: URL rules permissions: tab: SEO @@ -113,3 +114,36 @@ rules: delete_selected_confirm: one: Delete :count URL rule? You can undo it in History. other: Delete :count URL rules? You can undo it in History. +history: + list_title: History + name: Change + when: When + who: Who + via: Via + what: What + change: Change + batch: Batch + via_admin: Admin + via_mcp: MCP + via_import: Import + via_auto: Automatic + via_undo: Undo + what_record: SEO of :type “:title” (:locale) + what_rule: URL rule “:url” + what_redirect: Redirect “:url” + what_robots: robots.txt + what_settings: SEO settings + change_cell: + one: ':field: :before → :after' + other: ':count fields changed' + created: (created) + deleted: (deleted) + state_undone: Undone + filter_via: Via + filter_when: When + filter_who: Who + preview: Change details + diff_field: Field + diff_before: Before + diff_after: After + list_empty: No SEO changes recorded yet. Every change from the admin, MCP or an import appears here and can be undone. diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index e7b8b04..72dbd6f 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -3,6 +3,7 @@ plugin: description: Metadane dla wyszukiwarek we wszystkich typach treści, reguły adresów i globalne ustawienia SEO. nav: seo: SEO + history: Historia zmian rules: Reguły adresów permissions: tab: SEO @@ -115,3 +116,38 @@ rules: few: Usunąć :count reguły adresów? Możesz to cofnąć w Historii zmian. many: Usunąć :count reguł adresów? Możesz to cofnąć w Historii zmian. other: Usunąć :count reguły adresu? Możesz to cofnąć w Historii zmian. +history: + list_title: Historia zmian + name: Zmiana + when: Kiedy + who: Kto + via: Skąd + what: Czego dotyczy + change: Zmiana + batch: Partia + via_admin: Panel + via_mcp: MCP + via_import: Import + via_auto: Automatycznie + via_undo: Cofnięcie + what_record: 'SEO: :type „:title” (:locale)' + what_rule: Reguła adresu „:url” + what_redirect: Przekierowanie „:url” + what_robots: robots.txt + what_settings: Ustawienia SEO + change_cell: + one: ':field: :before → :after' + few: Zmieniono :count pola + many: Zmieniono :count pól + other: Zmieniono :count pola + created: (utworzono) + deleted: (usunięto) + state_undone: Cofnięte + filter_via: Skąd + filter_when: Kiedy + filter_who: Kto + preview: Szczegóły zmiany + diff_field: Pole + diff_before: Przed + diff_after: Po + list_empty: Nie zarejestrowano jeszcze zmian SEO. Każda zmiana z panelu, MCP lub importu pojawi się tutaj i będzie można ją cofnąć. diff --git a/models/revision.go b/models/revision.go new file mode 100644 index 0000000..287c34c --- /dev/null +++ b/models/revision.go @@ -0,0 +1,91 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// The writers of a history entry (D-13). +const ( + ViaAdmin = "admin" + ViaMCP = "mcp" + ViaImport = "import" + ViaAuto = "auto" + ViaUndo = "undo" +) + +// The subjects of a history entry. +const ( + SubjectEntry = "entry" + SubjectRule = "rule" + SubjectRedirect = "redirect" + SubjectRobots = "robots" + SubjectSettings = "settings" + SubjectNotFound = "notfound" +) + +// Revision is one recorded SEO change on golem15_seo_revisions (D-13): who +// made it (AuthorID and AuthorLabel, the label kept when the administrator +// is gone), through what (Via), on which object (SubjectKind and +// SubjectID: an entry as "<model type>|<record id>|<locale>", a rule by +// id, settings as "1" or "type:<key>"), a readable description (What) and +// the changed fields only, before and after. A created object has no +// Before and a deleted one no After. BatchID groups the changes of one +// batch; UndoneAt marks an undone change. +type Revision struct { + ID uint `gorm:"column:id;primaryKey"` + BatchID *string `gorm:"column:batch_id"` + AuthorID *uint `gorm:"column:author_id"` + AuthorLabel string `gorm:"column:author_label"` + Via string `gorm:"column:via"` + SubjectKind string `gorm:"column:subject_kind"` + SubjectID string `gorm:"column:subject_id"` + What string `gorm:"column:what"` + Before JSONMap `gorm:"column:before"` + After JSONMap `gorm:"column:after"` + UndoneAt *time.Time `gorm:"column:undone_at"` + UndoneByID *uint `gorm:"column:undone_by_id"` + CreatedAt time.Time `gorm:"column:created_at"` + // Change is the list's summary of the changed fields and ViaLabel the + // via column's label, filled by the history controller. + Change string `gorm:"-:all;column:change"` + ViaLabel string `gorm:"-:all;column:via_label"` + Batch string `gorm:"-:all;column:batch"` +} + +func (Revision) TableName() string { return "golem15_seo_revisions" } + +// Fillable is empty: history is written by the service only. +func (Revision) Fillable() []string { return []string{} } + +// Rules is empty: history is never edited. +func (Revision) Rules() map[string]string { return map[string]string{} } + +// FilterVia is the history list's via filter scope. +const FilterVia = "FilterVia" + +// Vias lists the writers in display order. +var Vias = []string{ViaAdmin, ViaMCP, ViaImport, ViaAuto, ViaUndo} + +func init() { + Register(Revision{}) +} + +// FilterScopes lists the history list's filter scopes. +func (Revision) FilterScopes() []string { return []string{FilterVia} } + +// FilterScope narrows the history list to one writer; an unknown value +// matches nothing. +func (Revision) FilterScope(name string, db *gorm.DB, value any) *gorm.DB { + if db == nil || name != FilterVia { + return db + } + s, _ := value.(string) + for _, v := range Vias { + if v == s { + return db.Where("golem15_seo_revisions.via = ?", s) + } + } + return db.Where("1 = 0") +} diff --git a/models/revision/columns.yaml b/models/revision/columns.yaml new file mode 100644 index 0000000..c7b6d55 --- /dev/null +++ b/models/revision/columns.yaml @@ -0,0 +1,28 @@ +# History list columns (UI-SPEC C12). via_label, change and batch are +# filled by the controller's ListExtendRecords. +columns: + created_at: + label: golem15.seo::lang.history.when + type: datetime + sortable: true + author_label: + label: golem15.seo::lang.history.who + type: text + searchable: true + via_label: + label: golem15.seo::lang.history.via + type: text + sortable: false + what: + label: golem15.seo::lang.history.what + type: text + searchable: true + change: + label: golem15.seo::lang.history.change + type: text + sortable: false + batch: + label: golem15.seo::lang.history.batch + type: text + sortable: false + invisible: true diff --git a/models/revision/fields.yaml b/models/revision/fields.yaml new file mode 100644 index 0000000..797c114 --- /dev/null +++ b/models/revision/fields.yaml @@ -0,0 +1,28 @@ +# History preview (UI-SPEC C12): read-only fields, then the P5 diff. +fields: + created_at: + label: golem15.seo::lang.history.when + type: text + span: left + context: preview + author_label: + label: golem15.seo::lang.history.who + type: text + span: right + context: preview + via: + label: golem15.seo::lang.history.via + type: text + span: left + context: preview + what: + label: golem15.seo::lang.history.what + type: text + span: right + context: preview + _history_diff: + label: golem15.seo::lang.history.change + type: partial + path: history_diff + span: full + context: preview diff --git a/seo_tab_test.go b/seo_tab_test.go index dc052d4..fbaa795 100644 --- a/seo_tab_test.go +++ b/seo_tab_test.go @@ -1,12 +1,16 @@ package seo_test import ( + "encoding/json" + "fmt" "net/http" "net/url" "strings" "testing" seo "git.golem15.com/golem15/sm-seo-plugin" + "git.golem15.com/golem15/sm-seo-plugin/classes" + "git.golem15.com/golem15/summercms/modules/bouncer" ) // seoTabLabel is the English label of seo.TabSEO. @@ -124,3 +128,96 @@ func TestSEOTabPermission(t *testing.T) { t.Fatalf("editor show carries SEO values: %s", show.Body.String()) } } + +// revisionRow is a history row as the tests read it. +type revisionRow struct { + Via string + SubjectKind string + SubjectID string + What string + AuthorLabel string + Before string + After string +} + +func (e *testEnv) revisions(t *testing.T, kind string) []revisionRow { + t.Helper() + var rows []revisionRow + if err := e.db.Raw(`SELECT via, subject_kind, subject_id, what, author_label, COALESCE(before::text, '') AS before, COALESCE(after::text, '') AS after + FROM golem15_seo_revisions WHERE subject_kind = ? ORDER BY id`, kind).Scan(&rows).Error; err != nil { + t.Fatal(err) + } + return rows +} + +// TestHistoryRecordsAdminWrite checks D-13 for admin writes: an SEO tab +// save writes one revision with via admin, the author and the changed +// field before and after; an unchanged save writes none; rule create, +// update and delete and a settings save are recorded too, and the history +// list and preview serve them. +func TestHistoryRecordsAdminWrite(t *testing.T) { + env := bootEnv(t) + token := env.admin(t, true) + id := env.createPage(t, token, map[string]any{"title": "About", "slug": "about", "published": true, "seo_title": map[string]any{"en": "First"}}) + env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/acme/pages/pages/"+id), token, map[string]any{ + "title": "About", "slug": "about", "published": true, "seo_title": map[string]any{"en": "Second"}, + }) + env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/acme/pages/pages/"+id), token, map[string]any{ + "title": "About", "slug": "about", "published": true, "seo_title": map[string]any{"en": "Second"}, + }) + rows := env.revisions(t, "entry") + if len(rows) != 2 { + t.Fatalf("entry revisions = %+v, want create and one change", rows) + } + last := rows[1] + if last.Via != "admin" || last.SubjectID != pagesModel+"|"+id+"|en" || !strings.Contains(last.What, "About") || !strings.Contains(last.What, "(en)") || last.AuthorLabel == "" { + t.Fatalf("revision = %+v", last) + } + if !jsonContains(last.Before, `"title": "First"`) || !jsonContains(last.After, `"title": "Second"`) || strings.Contains(last.After, "description") { + t.Fatalf("revision before/after = %s / %s, want the title only", last.Before, last.After) + } + + rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI("/golem15/seo/rules"), token, map[string]any{"url": "/landing", "match": "exact", "title": "Landing"}) + var created struct { + Data struct { + ID json.Number `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { + t.Fatal(err) + } + ruleID := created.Data.ID.String() + env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/golem15/seo/rules/"+ruleID), token, map[string]any{"url": "/landing", "match": "exact", "title": "Landing page"}) + env.expect(t, http.StatusOK, http.MethodDelete, adminAPI("/golem15/seo/rules/"+ruleID), token, nil) + rules := env.revisions(t, "rule") + if len(rules) != 3 || rules[0].Before != "{}" || !jsonContains(rules[1].After, `"title": "Landing page"`) || rules[2].After != "{}" || !jsonContains(rules[2].Before, `"url": "/landing"`) { + t.Fatalf("rule revisions = %+v", rules) + } + + env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/golem15/seo/settings/1"), token, map[string]any{"twitter_site": "@acme", "type_acme_page_robots": "noindex,follow"}) + settings := env.revisions(t, "settings") + if len(settings) != 2 || !jsonContains(settings[0].After, `"twitter_site": "@acme"`) || settings[1].SubjectID != "type:"+pagesTypeKey { + t.Fatalf("settings revisions = %+v", settings) + } + + list := env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/golem15/seo/history"), token, nil) + if !strings.Contains(list.Body.String(), "SEO title: First → Second") || !strings.Contains(list.Body.String(), `"via_label":"Admin"`) { + t.Fatalf("history list = %s", list.Body.String()) + } + var firstID uint + if err := env.db.Raw(`SELECT id FROM golem15_seo_revisions WHERE subject_kind = 'entry' ORDER BY id DESC LIMIT 1`).Scan(&firstID).Error; err != nil { + t.Fatal(err) + } + partial := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(fmt.Sprintf("/golem15/seo/history/partials/history_diff?id=%d", firstID)), token, nil) + if !strings.Contains(partial.Body.String(), "Second") { + t.Fatalf("history diff partial = %s", partial.Body.String()) + } + + mcp := bouncer.WithDispatch(t.Context(), &bouncer.Dispatch{Guards: []string{"backend"}, Principal: &bouncer.Principal{ID: 1, Backend: true}}) + if got := classes.ViaOf(mcp); got != "mcp" { + t.Fatalf("ViaOf(MCP dispatch) = %q, want mcp", got) + } + if got := classes.ViaOf(classes.WithVia(mcp, "import")); got != "import" { + t.Fatalf("ViaOf(WithVia import) = %q", got) + } +} diff --git a/updates/202610270002_create_golem15_seo_revisions.go b/updates/202610270002_create_golem15_seo_revisions.go new file mode 100644 index 0000000..ab8045a --- /dev/null +++ b/updates/202610270002_create_golem15_seo_revisions.go @@ -0,0 +1,49 @@ +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// createRevisions creates golem15_seo_revisions: the SEO change history +// (D-13) with author, via, subject, a readable description and the changed +// fields before and after; undo marks a change undone. +var createRevisions = []*gormigrate.Migration{ + { + ID: "202610270002_create_golem15_seo_revisions", + Migrate: func(tx *gorm.DB) error { + for _, stmt := range []string{ + `CREATE TABLE golem15_seo_revisions ( + id BIGSERIAL PRIMARY KEY, + batch_id TEXT NULL, + author_id BIGINT NULL, + author_label TEXT NOT NULL DEFAULT '', + via TEXT NOT NULL, + subject_kind TEXT NOT NULL, + subject_id TEXT NOT NULL DEFAULT '', + what TEXT NOT NULL DEFAULT '', + before jsonb NULL, + after jsonb NULL, + undone_at TIMESTAMP(0) NULL, + undone_by_id BIGINT NULL, + created_at TIMESTAMP(0) NOT NULL DEFAULT NOW() +)`, + `CREATE INDEX golem15_seo_revisions_subject ON golem15_seo_revisions (subject_kind, subject_id)`, + `CREATE INDEX golem15_seo_revisions_created ON golem15_seo_revisions (created_at DESC)`, + `CREATE INDEX golem15_seo_revisions_batch ON golem15_seo_revisions (batch_id) WHERE batch_id IS NOT NULL`, + } { + if err := tx.Exec(stmt).Error; err != nil { + return err + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec(`DROP TABLE IF EXISTS golem15_seo_revisions`).Error + }, + }, +} + +func init() { + Register(createRevisions...) +}