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{} )