feat: batches with dry run, undo, and CSV import and export
- POST /_seo/api/v1/batch runs changes in one transaction: dry_run answers the diff and the conflicts (loops, chains, noindex on a published record, canonicals to redirected or noindex addresses) and rolls back; a refused change refuses the batch (D-12) - undo of a change or a whole batch from History and through MCP, with the undo_stale, undo_loop and undo_gone refusals (D-13) - CSV export and import of redirects, rules and records by URL: BOM, comma or semicolon, formula cells escaped, a dry-run preview, all or nothing with via import; the screen's apply token is HMAC-SHA256 over the file and a fingerprint of the data (transfer.stale) - Import and export singleton with the seo_transfer widget action, History record actions undo and undo_batch, navigation entry - MCP tools undo_change, undo_batch, apply_batch, export_csv, import_csv - TestBatch, TestUndo, TestCSVTransfer, TestLoopRefused mcp and import
This commit is contained in:
@@ -34,6 +34,7 @@ func AdminControllers(app func() *backpack.App) []pact.AdminController {
|
||||
rulesAdminController{app: app},
|
||||
robotsAdminController{app: app},
|
||||
historyAdminController{app: app},
|
||||
transferAdminController{app: app},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
61
controllers/api/batch.go
Normal file
61
controllers/api/batch.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
||||
)
|
||||
|
||||
// ApplyBatchInput is the body of POST /_seo/api/v1/batch.
|
||||
type ApplyBatchInput struct {
|
||||
DryRun bool `json:"dry_run,omitempty" jsonschema:"answer the diff and the conflicts without writing anything"`
|
||||
Changes []classes.BatchChange `json:"changes" jsonschema:"the changes, applied in order: op set_seo (url, locale, values, force_rule), create_redirect and update_redirect (id, redirect), delete_redirect (id), create_rule and update_rule (id, rule), delete_rule (id)"`
|
||||
}
|
||||
|
||||
// ApplyBatch serves POST /_seo/api/v1/batch (D-12, P-26): the changes run
|
||||
// in one transaction. With dry_run the answer is every change's diff and
|
||||
// the conflicts (redirect loops and chains, noindex on a published
|
||||
// record's address, a canonical pointing to a redirected or noindex
|
||||
// address) and nothing is written. Without it the batch is applied as a
|
||||
// whole under one batch id, which history and undo_batch use. A refused
|
||||
// change refuses the whole batch with 422: redirect_loop with the change
|
||||
// and the loop's path, or batch_refused with the change's errors.
|
||||
func ApplyBatch(service func() *classes.Service) http.HandlerFunc {
|
||||
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
|
||||
var in ApplyBatchInput
|
||||
if err := decode(r, 4*maxBody, &in); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
res, err := svc.RunBatch(ctx, in.Changes, classes.BatchOptions{DryRun: in.DryRun})
|
||||
var refused *classes.BatchRefusedError
|
||||
if errors.As(err, &refused) {
|
||||
return 0, nil, &apiError{Status: http.StatusUnprocessableEntity, Body: BatchRefusal(refused.Result)}
|
||||
}
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return http.StatusOK, res, nil
|
||||
})
|
||||
}
|
||||
|
||||
// BatchRefusal is the 422 answer of a refused batch: the first refused
|
||||
// change, its error, path or field errors, and every change's outcome.
|
||||
func BatchRefusal(res classes.BatchResult) map[string]any {
|
||||
item := res.Refused()
|
||||
out := map[string]any{"error": "batch_refused", "changes": res.Changes}
|
||||
if item == nil {
|
||||
return out
|
||||
}
|
||||
out["change"] = item.Index
|
||||
out["message"] = item.Message
|
||||
if item.Error == classes.ConflictLoop {
|
||||
out["error"] = "redirect_loop"
|
||||
out["path"] = item.Path
|
||||
}
|
||||
if len(item.Errors) > 0 {
|
||||
out["errors"] = item.Errors
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -2,10 +2,12 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ListHistoryInput is the query of GET /_seo/api/v1/history.
|
||||
@@ -93,3 +95,64 @@ func ShowHistory(service func() *classes.Service) http.HandlerFunc {
|
||||
return http.StatusOK, map[string]any{"data": RevisionJSON(rows[0])}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// HistoryBatchInput names one batch.
|
||||
type HistoryBatchInput struct {
|
||||
Batch string `json:"batch" path:"batch" jsonschema:"the batch id"`
|
||||
}
|
||||
|
||||
// undoAnswer maps an undo refusal to 409 with its code and message.
|
||||
func undoAnswer(ctx context.Context, svc *classes.Service, err error) error {
|
||||
var refusal *classes.UndoRefusal
|
||||
if errors.As(err, &refusal) {
|
||||
return &apiError{Status: http.StatusConflict, Body: map[string]any{
|
||||
"error": refusal.Code,
|
||||
"message": svc.PhraseCtx(ctx, refusal.Key, refusal.Params),
|
||||
}}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// UndoChange serves POST /_seo/api/v1/history/{id}/undo (D-13): the values
|
||||
// before the change are restored and the undo is recorded (via undo). It
|
||||
// answers 409 undo_stale when a later change touched the same values,
|
||||
// undo_loop when the restore would create a redirect loop, undo_gone when
|
||||
// the record is gone and already_undone; nothing is undone then.
|
||||
func UndoChange(service func() *classes.Service) http.HandlerFunc {
|
||||
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
var ids []uint
|
||||
err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
|
||||
var err error
|
||||
ids, err = svc.Undo(ctx, tx, id)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, undoAnswer(ctx, svc, err)
|
||||
}
|
||||
return http.StatusOK, map[string]any{"undone": ids, "count": len(ids)}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// UndoBatch serves POST /_seo/api/v1/history/batches/{batch}/undo: every
|
||||
// change of the batch not undone yet is undone in one transaction, newest
|
||||
// first, or none is (the same refusals as one change). 404 when nothing of
|
||||
// the batch is left to undo.
|
||||
func UndoBatch(service func() *classes.Service) http.HandlerFunc {
|
||||
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
|
||||
batch := r.PathValue("batch")
|
||||
var ids []uint
|
||||
err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
|
||||
var err error
|
||||
ids, err = svc.UndoBatch(ctx, tx, batch)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, undoAnswer(ctx, svc, err)
|
||||
}
|
||||
return http.StatusOK, map[string]any{"undone": ids, "count": len(ids)}, nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
@@ -51,47 +50,6 @@ type UpdateRedirectInput struct {
|
||||
Note *string `json:"note,omitempty" jsonschema:"an internal note"`
|
||||
}
|
||||
|
||||
// RedirectBody is the decoded body of a redirect write; the batch and the
|
||||
// import use it too.
|
||||
type RedirectBody struct {
|
||||
Source *string `json:"source,omitempty"`
|
||||
Target *string `json:"target,omitempty"`
|
||||
Status *int `json:"status,omitempty"`
|
||||
Match *string `json:"match,omitempty"`
|
||||
Query *string `json:"query,omitempty"`
|
||||
IgnoreCase *bool `json:"ignore_case,omitempty"`
|
||||
TrailingSlash *bool `json:"trailing_slash,omitempty"`
|
||||
Note *string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// Apply writes the fields sent onto r.
|
||||
func (b RedirectBody) Apply(r *models.Redirect) {
|
||||
if b.Source != nil {
|
||||
r.Source = strings.TrimSpace(*b.Source)
|
||||
}
|
||||
if b.Target != nil {
|
||||
r.Target = strings.TrimSpace(*b.Target)
|
||||
}
|
||||
if b.Status != nil {
|
||||
r.Status = models.RedirectStatus(*b.Status)
|
||||
}
|
||||
if b.Match != nil {
|
||||
r.Match = strings.TrimSpace(*b.Match)
|
||||
}
|
||||
if b.Query != nil {
|
||||
r.Query = strings.TrimSpace(*b.Query)
|
||||
}
|
||||
if b.IgnoreCase != nil {
|
||||
r.IgnoreCase = *b.IgnoreCase
|
||||
}
|
||||
if b.TrailingSlash != nil {
|
||||
r.TrailingSlash = *b.TrailingSlash
|
||||
}
|
||||
if b.Note != nil {
|
||||
r.Note = strings.TrimSpace(*b.Note)
|
||||
}
|
||||
}
|
||||
|
||||
// RedirectJSON is a redirect in an answer.
|
||||
func RedirectJSON(r models.Redirect) map[string]any {
|
||||
out := classes.RedirectValues(r)
|
||||
@@ -175,7 +133,7 @@ func ShowRedirect(service func() *classes.Service) http.HandlerFunc {
|
||||
// validation_failed for invalid fields. The origin is mcp through MCP.
|
||||
func CreateRedirect(service func() *classes.Service) http.HandlerFunc {
|
||||
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
|
||||
var body RedirectBody
|
||||
var body classes.RedirectFields
|
||||
if err := decode(r, maxBody, &body); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
@@ -203,7 +161,7 @@ func UpdateRedirect(service func() *classes.Service) http.HandlerFunc {
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
var body RedirectBody
|
||||
var body classes.RedirectFields
|
||||
if err := decode(r, maxBody, &body); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
@@ -53,33 +52,6 @@ type UpdateRuleInput struct {
|
||||
Note *string `json:"note,omitempty" jsonschema:"an internal note"`
|
||||
}
|
||||
|
||||
// ruleBody is the decoded body of a rule write.
|
||||
type ruleBody struct {
|
||||
URL *string `json:"url"`
|
||||
Match *string `json:"match"`
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Canonical *string `json:"canonical"`
|
||||
Robots *string `json:"robots"`
|
||||
OGTitle *string `json:"og_title"`
|
||||
OGDescription *string `json:"og_description"`
|
||||
OGImage *string `json:"og_image"`
|
||||
Note *string `json:"note"`
|
||||
}
|
||||
|
||||
// apply writes the fields sent onto r.
|
||||
func (b ruleBody) apply(r *models.Rule) {
|
||||
for column, v := range map[string]*string{
|
||||
"url": b.URL, "match": b.Match, "title": b.Title, "description": b.Description,
|
||||
"canonical": b.Canonical, "robots": b.Robots, "og_title": b.OGTitle,
|
||||
"og_description": b.OGDescription, "og_image": b.OGImage, "note": b.Note,
|
||||
} {
|
||||
if v != nil {
|
||||
classes.SetRuleValue(r, column, strings.TrimSpace(*v))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RuleJSON is a rule in an answer.
|
||||
func RuleJSON(r models.Rule) map[string]any {
|
||||
out := classes.RuleValues(r)
|
||||
@@ -142,12 +114,12 @@ func ShowRule(service func() *classes.Service) http.HandlerFunc {
|
||||
// an invalid or taken address or invalid values.
|
||||
func CreateRule(service func() *classes.Service) http.HandlerFunc {
|
||||
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
|
||||
var body ruleBody
|
||||
var body classes.RuleFields
|
||||
if err := decode(r, maxBody, &body); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
rule := models.Rule{Match: models.MatchExact}
|
||||
body.apply(&rule)
|
||||
body.Apply(&rule)
|
||||
err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
|
||||
return svc.SaveRule(ctx, tx, &rule)
|
||||
})
|
||||
@@ -165,7 +137,7 @@ func UpdateRule(service func() *classes.Service) http.HandlerFunc {
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
var body ruleBody
|
||||
var body classes.RuleFields
|
||||
if err := decode(r, maxBody, &body); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
@@ -175,7 +147,7 @@ func UpdateRule(service func() *classes.Service) http.HandlerFunc {
|
||||
if rule, err = classes.LoadRule(tx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
body.apply(&rule)
|
||||
body.Apply(&rule)
|
||||
return svc.SaveRule(ctx, tx, &rule)
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
127
controllers/api/transfer.go
Normal file
127
controllers/api/transfer.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
||||
)
|
||||
|
||||
// ExportInput is the query of GET /_seo/api/v1/export.
|
||||
type ExportInput struct {
|
||||
Dataset string `json:"dataset" query:"dataset" jsonschema:"redirects, rules or records (the SEO of records, addressed by URL)"`
|
||||
Delimiter string `json:"delimiter,omitempty" query:"delimiter" jsonschema:"the CSV delimiter: , or ; (the SEO settings' delimiter by default)"`
|
||||
Page int `json:"page,omitempty" query:"page" jsonschema:"the page of 1000 rows, from 1"`
|
||||
}
|
||||
|
||||
// ImportInput is the body of POST /_seo/api/v1/import.
|
||||
type ImportInput struct {
|
||||
Dataset string `json:"dataset" jsonschema:"redirects, rules or records"`
|
||||
CSV string `json:"csv" jsonschema:"the file: a header row with the export's column names, then the rows; at most 900 KB per call"`
|
||||
Delimiter string `json:"delimiter,omitempty" jsonschema:"the CSV delimiter: , or ; (detected from the header when omitted)"`
|
||||
DryRun bool `json:"dry_run,omitempty" jsonschema:"preview the rows (new, changed, unchanged, refused, with notes) without writing anything"`
|
||||
}
|
||||
|
||||
// mcpPreviewRows caps the preview rows an import answers through the API.
|
||||
const mcpPreviewRows = 1000
|
||||
|
||||
// transferRefusal maps an import or export refusal: 422 with the code and
|
||||
// the message (and the column of missing_column), 409 for a stale
|
||||
// preview, 422 import_refused with the preview while a row is refused.
|
||||
func transferRefusal(ctx context.Context, svc *classes.Service, err error) error {
|
||||
var te *classes.TransferError
|
||||
var refused *classes.ImportRefusedError
|
||||
switch {
|
||||
case errors.As(err, &te):
|
||||
body := map[string]any{"error": te.Code, "message": svc.PhraseCtx(ctx, te.Key, te.Params)}
|
||||
if c := te.Params["column"]; c != "" {
|
||||
body["column"] = c
|
||||
}
|
||||
status := http.StatusUnprocessableEntity
|
||||
if te.Code == "stale" {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
return &apiError{Status: status, Body: body}
|
||||
case errors.As(err, &refused):
|
||||
res := refused.Result
|
||||
capRows(&res, mcpPreviewRows)
|
||||
return &apiError{Status: http.StatusUnprocessableEntity, Body: map[string]any{
|
||||
"error": "import_refused", "message": svc.PhraseCtx(ctx, "golem15.seo::lang.transfer.refused_blocks", nil),
|
||||
"summary": res.Summary, "rows": res.Rows, "total": res.Total,
|
||||
}}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// capRows keeps the first max preview rows.
|
||||
func capRows(res *classes.ImportResult, max int) {
|
||||
if len(res.Rows) > max {
|
||||
res.Rows = res.Rows[:max]
|
||||
}
|
||||
}
|
||||
|
||||
// delimiterOf is the request's delimiter, else the settings' one.
|
||||
func delimiterOf(ctx context.Context, svc *classes.Service, given string) string {
|
||||
if d := strings.TrimSpace(given); d == "," || d == ";" {
|
||||
return d
|
||||
}
|
||||
db, err := read(ctx, svc)
|
||||
if err != nil {
|
||||
return ","
|
||||
}
|
||||
row, err := classes.LoadSettings(db)
|
||||
if err != nil || row.CSVDelimiter == "" {
|
||||
return ","
|
||||
}
|
||||
return row.CSVDelimiter
|
||||
}
|
||||
|
||||
// ExportCSV serves GET /_seo/api/v1/export?dataset=&delimiter=&page= (D-13,
|
||||
// P-27): one page of 1000 rows of a dataset as CSV text, UTF-8 with a BOM,
|
||||
// formula cells prefixed with an apostrophe, with the page count.
|
||||
func ExportCSV(service func() *classes.Service) http.HandlerFunc {
|
||||
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
|
||||
q := r.URL.Query()
|
||||
page, _ := strconv.Atoi(q.Get("page"))
|
||||
out, err := svc.Export(ctx, q.Get("dataset"), delimiterOf(ctx, svc, q.Get("delimiter")), page, classes.ExportPageRows)
|
||||
if err != nil {
|
||||
return 0, nil, transferRefusal(ctx, svc, err)
|
||||
}
|
||||
return http.StatusOK, out, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ImportCSV serves POST /_seo/api/v1/import (D-13, P-27): with dry_run the
|
||||
// preview of every row; without it the rows are applied as one batch (via
|
||||
// import) or, while a row is refused (a loop, an invalid value), nothing
|
||||
// is (422 import_refused). A file over 900 KB is 413; an unreadable file,
|
||||
// a missing column or an unknown dataset 422.
|
||||
func ImportCSV(service func() *classes.Service) http.HandlerFunc {
|
||||
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
|
||||
var in ImportInput
|
||||
if err := decode(r, 2*maxBody, &in); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if len(in.CSV) > classes.MCPImportLimit {
|
||||
return 0, nil, &apiError{Status: http.StatusRequestEntityTooLarge, Body: map[string]any{
|
||||
"error": "too_large", "message": svc.PhraseCtx(ctx, "golem15.seo::lang.transfer.too_large_mcp", nil),
|
||||
}}
|
||||
}
|
||||
delimiter := strings.TrimSpace(in.Delimiter)
|
||||
if delimiter != "," && delimiter != ";" {
|
||||
delimiter = ""
|
||||
}
|
||||
res, err := svc.Import(ctx, classes.ImportRequest{Dataset: in.Dataset, CSV: in.CSV, Delimiter: delimiter, DryRun: in.DryRun})
|
||||
if err != nil {
|
||||
return 0, nil, transferRefusal(ctx, svc, err)
|
||||
}
|
||||
capRows(&res, mcpPreviewRows)
|
||||
return http.StatusOK, map[string]any{
|
||||
"dry_run": in.DryRun, "applied": res.Applied, "batch_id": res.BatchID,
|
||||
"summary": res.Summary, "rows": res.Rows, "total": res.Total, "count": res.Count,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
@@ -8,8 +8,10 @@ import (
|
||||
"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"
|
||||
)
|
||||
@@ -20,10 +22,15 @@ 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. Undo arrives as record actions with the management
|
||||
// API.
|
||||
// and deletes nothing. The preview undoes one change or a whole batch.
|
||||
type historyAdminController struct {
|
||||
app func() *backpack.App
|
||||
}
|
||||
@@ -227,15 +234,88 @@ func (c historyAdminController) PartialData(ctx context.Context, name string, re
|
||||
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.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{}
|
||||
_ 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{}
|
||||
)
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
# History preview (UI-SPEC C12): read-only with the P5 diff.
|
||||
# History preview (UI-SPEC C12): read-only with the P5 diff, and the undo
|
||||
# of the change or of its whole batch.
|
||||
name: golem15.seo::lang.history.name
|
||||
form: ~/plugins/golem15/seo/models/revision/fields.yaml
|
||||
modelClass: Golem15\SEO\Models\Revision
|
||||
defaultRedirect: golem15/seo/history
|
||||
|
||||
preview: {}
|
||||
|
||||
recordActions: [undo, undo_batch]
|
||||
|
||||
messages:
|
||||
preview: golem15.seo::lang.history.preview
|
||||
|
||||
199
controllers/transfer.go
Normal file
199
controllers/transfer.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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"
|
||||
)
|
||||
|
||||
// TransferControllerID is the import and export screen (UI-SPEC C13).
|
||||
const TransferControllerID = "golem15.seo.transfer"
|
||||
|
||||
// TransferAction is the files widget's action (E6): ops export, preview
|
||||
// and apply.
|
||||
const TransferAction = "seo_transfer"
|
||||
|
||||
// transferAdminController is the import and export singleton (D-12, D-13,
|
||||
// P-27) on the settings row: the delimiter and the E6 files widget.
|
||||
type transferAdminController struct {
|
||||
app func() *backpack.App
|
||||
}
|
||||
|
||||
func (transferAdminController) ID() string { return TransferControllerID }
|
||||
func (transferAdminController) ModelName() string { return `Golem15\SEO\Models\Transfer` }
|
||||
func (transferAdminController) ConfigDir() string { return "controllers/transfer" }
|
||||
func (transferAdminController) NewRecord() any { return &models.Transfer{} }
|
||||
func (transferAdminController) RequiredPermissions() []string {
|
||||
return []string{PermissionManage}
|
||||
}
|
||||
|
||||
func (c transferAdminController) service() *classes.Service {
|
||||
svc, err := classes.NewService(appOf(c.app))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// SingletonRecord returns the settings row as the screen's record; an
|
||||
// unset delimiter shows semicolon when the site's default locale is pl
|
||||
// (Excel in Polish), else comma.
|
||||
func (c transferAdminController) SingletonRecord(ctx context.Context, db *gorm.DB) (any, error) {
|
||||
if db == nil {
|
||||
return nil, errors.New("golem15.seo: database is not configured")
|
||||
}
|
||||
if _, err := classes.SettingsRow(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []models.Transfer
|
||||
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil || len(rows) == 0 {
|
||||
return nil, errors.New("golem15.seo: settings row is missing")
|
||||
}
|
||||
row := rows[0]
|
||||
if row.CSVDelimiter == "" {
|
||||
row.CSVDelimiter = ","
|
||||
if svc := c.service(); svc != nil && strings.HasPrefix(svc.DefaultLocale(ctx, db), "pl") {
|
||||
row.CSVDelimiter = ";"
|
||||
}
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// FormBeforeUpdate refuses a delimiter other than comma or semicolon.
|
||||
func (c transferAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
|
||||
row, ok := model.(*models.Transfer)
|
||||
if !ok || row == nil {
|
||||
return nil
|
||||
}
|
||||
if row.CSVDelimiter != "," && row.CSVDelimiter != ";" {
|
||||
return &cabana.ValidationError{Details: map[string]any{"csv_delimiter": []string{Phrase(ctx, translator(c.app), "golem15.seo::lang.api.value_invalid", nil)}}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdminJS loads the SEO element bundle (E6) on the screen.
|
||||
func (transferAdminController) AdminJS() []string { return []string{"assets/dist/seo.js"} }
|
||||
|
||||
// AdminCSS loads the SEO element styles on the screen.
|
||||
func (transferAdminController) AdminCSS() []string { return []string{"assets/dist/seo.css"} }
|
||||
|
||||
// transferPayload is the widget's request.
|
||||
type transferPayload struct {
|
||||
Op string `json:"op"`
|
||||
Dataset string `json:"dataset"`
|
||||
CSV string `json:"csv"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// AdminActions registers the files widget's action.
|
||||
func (c transferAdminController) AdminActions() []pact.AdminAction {
|
||||
return []pact.AdminAction{{
|
||||
Name: TransferAction,
|
||||
Label: "golem15.seo::lang.transfer.preview",
|
||||
Permissions: []string{PermissionManage},
|
||||
Run: c.run,
|
||||
}}
|
||||
}
|
||||
|
||||
// refusal is an op's answer that the element shows above its actions: a
|
||||
// refusal it can explain, sent as data so the preview stays on screen.
|
||||
func refusal(op, code, message string) pact.AdminActionResult {
|
||||
return pact.AdminActionResult{Data: map[string]any{"op": op, "error": code, "message": message}}
|
||||
}
|
||||
|
||||
// run serves the widget (UI-SPEC C13): export answers {filename, csv};
|
||||
// preview answers {token, summary, rows (the first 200, refused first),
|
||||
// total, count}; apply re-runs the preview of the token's file, refuses
|
||||
// with transfer.stale when the data changed since, and applies every row
|
||||
// as one import batch.
|
||||
func (c transferAdminController) run(ctx context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) {
|
||||
svc := c.service()
|
||||
if svc == nil {
|
||||
return pact.AdminActionResult{}, classes.ErrNotBooted
|
||||
}
|
||||
var p transferPayload
|
||||
if len(in.Payload) > 0 {
|
||||
if err := json.Unmarshal(in.Payload, &p); err != nil {
|
||||
return pact.AdminActionResult{}, &cabana.ValidationError{Details: map[string]any{in.Field: []string{"golem15.seo::lang.transfer.unreadable"}}}
|
||||
}
|
||||
}
|
||||
delimiter, _ := in.Values["csv_delimiter"].(string)
|
||||
if delimiter != ";" {
|
||||
delimiter = ","
|
||||
}
|
||||
phrase := func(key string, params map[string]string) string { return svc.PhraseCtx(ctx, key, params) }
|
||||
failed := func(op string, err error) (pact.AdminActionResult, error) {
|
||||
var te *classes.TransferError
|
||||
var refused *classes.ImportRefusedError
|
||||
switch {
|
||||
case errors.As(err, &te):
|
||||
return refusal(op, te.Code, phrase(te.Key, te.Params)), nil
|
||||
case errors.As(err, &refused):
|
||||
return refusal(op, "refused", phrase("golem15.seo::lang.transfer.refused_blocks", nil)), nil
|
||||
}
|
||||
return pact.AdminActionResult{}, err
|
||||
}
|
||||
switch p.Op {
|
||||
case "export":
|
||||
out, err := svc.Export(ctx, p.Dataset, delimiter, 0, 0)
|
||||
if err != nil {
|
||||
return failed("export", err)
|
||||
}
|
||||
if len(out.CSV) > classes.ExportActionLimit {
|
||||
return refusal("export", "too_large", phrase("golem15.seo::lang.transfer.export_too_large", nil)), nil
|
||||
}
|
||||
return pact.AdminActionResult{Data: map[string]any{"op": "export", "filename": out.Filename, "csv": out.CSV}}, nil
|
||||
case "preview":
|
||||
if len(p.CSV) > classes.AdminImportLimit {
|
||||
return refusal("preview", "too_large", phrase("golem15.seo::lang.transfer.too_large", nil)), nil
|
||||
}
|
||||
res, err := svc.Import(ctx, classes.ImportRequest{Dataset: p.Dataset, CSV: p.CSV, Delimiter: delimiter, DryRun: true})
|
||||
if err != nil {
|
||||
return failed("preview", err)
|
||||
}
|
||||
token, err := svc.TransferToken(p.Dataset, delimiter, p.CSV, res.Fingerprint)
|
||||
if err != nil {
|
||||
return pact.AdminActionResult{}, err
|
||||
}
|
||||
rows := res.Rows
|
||||
if len(rows) > classes.PreviewRowCap {
|
||||
rows = rows[:classes.PreviewRowCap]
|
||||
}
|
||||
return pact.AdminActionResult{Data: map[string]any{
|
||||
"op": "preview", "token": token, "dataset": p.Dataset, "summary": res.Summary,
|
||||
"rows": rows, "total": res.Total, "count": res.Count,
|
||||
}}, nil
|
||||
case "apply":
|
||||
req, err := svc.ReadTransferToken(p.Token)
|
||||
if err != nil {
|
||||
return refusal("apply", "token", phrase("golem15.seo::lang.transfer.token_invalid", nil)), nil
|
||||
}
|
||||
res, err := svc.Import(ctx, req)
|
||||
if err != nil {
|
||||
return failed("apply", err)
|
||||
}
|
||||
return pact.AdminActionResult{
|
||||
Message: svc.ChoiceCtx(ctx, "golem15.seo::lang.transfer.applied", res.Count, nil),
|
||||
Data: map[string]any{"op": "apply", "applied": true, "count": res.Count, "batch_id": res.BatchID},
|
||||
}, nil
|
||||
}
|
||||
return pact.AdminActionResult{}, &cabana.ValidationError{Details: map[string]any{in.Field: []string{"golem15.seo::lang.transfer.unreadable"}}}
|
||||
}
|
||||
|
||||
var (
|
||||
_ pact.AdminController = transferAdminController{}
|
||||
_ pact.AdminPermissioned = transferAdminController{}
|
||||
_ pact.AdminRecordSource = transferAdminController{}
|
||||
_ pact.AdminSingleton = transferAdminController{}
|
||||
_ pact.FormBeforeUpdate = transferAdminController{}
|
||||
_ pact.HasAdminActions = transferAdminController{}
|
||||
_ pact.AdminClientAssets = transferAdminController{}
|
||||
)
|
||||
14
controllers/transfer/config_form.yaml
Normal file
14
controllers/transfer/config_form.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
# Import and export singleton (UI-SPEC C13): the footer Save stores the
|
||||
# delimiter only.
|
||||
name: golem15.seo::lang.transfer.title
|
||||
form: ~/plugins/golem15/seo/models/transfer/fields.yaml
|
||||
modelClass: Golem15\SEO\Models\Transfer
|
||||
defaultRedirect: golem15/seo/transfer
|
||||
|
||||
update:
|
||||
redirect: golem15/seo/transfer
|
||||
redirectClose: golem15/seo/redirects
|
||||
|
||||
messages:
|
||||
update: golem15.seo::lang.transfer.title
|
||||
saved: backend::lang.messages.form.saved
|
||||
Reference in New Issue
Block a user