package classes import ( "context" "encoding/json" "errors" "net/url" "strconv" "strings" "git.golem15.com/golem15/sm-seo-plugin/models" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/pact" "gorm.io/gorm" ) // SnippetActionName is the result-preview widget action (E3, UI-SPEC C6). const SnippetActionName = "seo_snippet" // snippetDuplicateLimit caps each duplicate list the action answers. const snippetDuplicateLimit = 20 // SnippetLocale is one locale of the preview context. type SnippetLocale struct { Locale string `json:"locale"` URL *string `json:"url"` H1 *string `json:"h1"` SiteName string `json:"siteName"` TitleTemplate string `json:"titleTemplate"` DefaultTitle string `json:"defaultTitle"` DefaultDescription *string `json:"defaultDescription"` DescriptionSource string `json:"descriptionSource"` BodyWords *int `json:"bodyWords"` } // SnippetContext is the answer of op context. type SnippetContext struct { Op string `json:"op"` Host string `json:"host"` Locales []SnippetLocale `json:"locales"` } // DuplicateItem is another page with the same title or description: a // label and the admin URL of its form. type DuplicateItem struct { Label string `json:"label"` URL string `json:"url"` } // SnippetDuplicates is the answer of op duplicates. type SnippetDuplicates struct { Op string `json:"op"` Locale string `json:"locale"` Title []DuplicateItem `json:"title"` Description []DuplicateItem `json:"description"` } type snippetPayload struct { Op string `json:"op"` Locale string `json:"locale"` Title string `json:"title"` Description string `json:"description"` } // errSnippetOp refuses an unknown op. var errSnippetOp = errors.New("golem15.seo: unknown seo_snippet op") // Locales returns the site's content locales, the default first. func (s *Service) Locales(ctx context.Context, db *gorm.DB) []string { def := s.DefaultLocale(ctx, db) out := []string{def} if s != nil && s.app != nil { if w, ok := s.app.Lookup[cabana.TranslationWriter](); ok && w != nil { if codes, err := w.EnabledLocales(ctx, db); err == nil { for _, code := range codes { if code != def && code != "" { out = append(out, code) } } } } } return out } // host is the site URL's host, "" when none is configured. func (s *Service) host() string { u, err := url.Parse(s.SiteURL()) if err != nil { return "" } return u.Host } // adminFormURL is the admin SPA path of a record's update form. func (s *Service) adminFormURL(controller, id string) string { prefix := cabana.DefaultAdminPrefix if s != nil && s.app != nil { if p, err := cabana.AdminPrefix(s.app); err == nil && p != "" { prefix = p } } return strings.TrimRight(prefix, "/") + "/" + strings.ReplaceAll(controller, ".", "/") + "/update/" + id } func strPtr(s string) *string { if s == "" { return nil } return &s } // RecordSnippetAction is the seo_snippet action of the SEO tab of content // type key: op context answers the preview data per locale (from the saved // record through its descriptor, the per-type and general templates and // the settings), op duplicates the other records and URL rules whose // effective title or description in that locale equals the submitted one. func RecordSnippetAction(svc func() *Service, key, label, permission string) pact.AdminAction { return pact.AdminAction{ Name: SnippetActionName, Label: label, Permissions: []string{permission}, Run: func(ctx context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) { s := svc() if s == nil { return pact.AdminActionResult{}, ErrNotBooted } var p snippetPayload 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.snippet.invalid"}}} } } id := "" if in.RecordID != nil { id = strconv.FormatUint(*in.RecordID, 10) } switch p.Op { case "context", "": data, err := s.recordContext(ctx, key, id) return pact.AdminActionResult{Data: data}, err case "duplicates": data, err := s.duplicates(ctx, key, id, p) return pact.AdminActionResult{Data: data}, err } return pact.AdminActionResult{}, errSnippetOp }, } } // recordContext builds the preview context of a record (id "" on create). func (s *Service) recordContext(ctx context.Context, key, id string) (SnippetContext, error) { out := SnippetContext{Op: "context", Host: s.host(), Locales: []SnippetLocale{}} db, err := s.DB(ctx) if err != nil { return out, err } settings, err := LoadSettings(db) if err != nil { return out, err } ts, err := LoadTypeSetting(db, key) if err != nil { return out, err } described := map[string]pact.ContentLocale{} if id != "" { described = s.recordLocales(ctx, key, id) } tpl := ts.TitleTemplate if strings.TrimSpace(tpl) == "" { tpl = settings.TitleTemplate } for _, locale := range s.Locales(ctx, db) { l := described[locale] site := s.translatedSetting(ctx, db, &settings, "site_name", settings.SiteName, locale) fallback := s.translatedSetting(ctx, db, &settings, "default_description", settings.DefaultDescription, locale) desc, source := pick( candidate{RenderTemplate(ts.DescriptionTemplate, TemplateVars{Title: l.Title, Description: l.Description, Site: site}), SourceTemplate}, candidate{l.Description, SourceContent}, candidate{fallback, SourceGlobal}, ) if desc == "" { source = "" } out.Locales = append(out.Locales, SnippetLocale{ Locale: locale, URL: strPtr(l.URL), H1: strPtr(l.Title), SiteName: site, TitleTemplate: tpl, DefaultTitle: l.Title, DefaultDescription: strPtr(desc), DescriptionSource: source, BodyWords: l.Words, }) } return out, nil } // duplicates answers the records and URL rules whose effective title or // description in the payload's locale equals the submitted value (or the // value the templates give when it is empty). The record itself is left // out; each list holds at most snippetDuplicateLimit items. func (s *Service) duplicates(ctx context.Context, key, id string, p snippetPayload) (SnippetDuplicates, error) { out := SnippetDuplicates{Op: "duplicates", Locale: p.Locale, Title: []DuplicateItem{}, Description: []DuplicateItem{}} db, err := s.DB(ctx) if err != nil { return out, err } title, description := strings.TrimSpace(p.Title), strings.TrimSpace(p.Description) model := "" if key != "" { if reg := s.Registry(); reg != nil { if typ, ok := reg.ContentType(key); ok && typ.Content != nil { model = typ.Content.Model if id != "" && (title == "" || description == "") { settings, _ := LoadSettings(db) ts, _ := LoadTypeSetting(db, key) l := s.recordLocales(ctx, key, id)[p.Locale] site := s.translatedSetting(ctx, db, &settings, "site_name", settings.SiteName, p.Locale) fallback := s.translatedSetting(ctx, db, &settings, "default_description", settings.DefaultDescription, p.Locale) et, ed := EffectiveValues(models.Entry{Title: title, Description: description}, l, settings, ts, site, fallback) if title == "" { title = et } if description == "" { description = ed } } } } } find := func(column, value string, ruleColumn string) ([]DuplicateItem, error) { items := []DuplicateItem{} if value == "" { return items, nil } var entries []models.Entry q := db.Where("locale = ? AND "+column+" = ?", p.Locale, value) if model != "" && id != "" { q = q.Where("NOT (model_type = ? AND record_id = ?)", model, id) } if err := q.Order("id ASC").Limit(snippetDuplicateLimit).Find(&entries).Error; err != nil { return nil, err } reg := s.Registry() for _, e := range entries { label := e.URL if label == "" { label = e.TypeKey + " #" + e.RecordID } link := "" if reg != nil { if typ, ok := reg.ContentType(e.TypeKey); ok && typ.Content != nil && typ.Content.Controller != "" { link = s.adminFormURL(typ.Content.Controller, e.RecordID) } } items = append(items, DuplicateItem{Label: label, URL: link}) } var rules []models.Rule rq := db.Where(ruleColumn+" = ?", value) if key == "" && id != "" { rq = rq.Where("id <> ?", id) } if err := rq.Order("id ASC").Limit(snippetDuplicateLimit).Find(&rules).Error; err != nil { return nil, err } for _, r := range rules { items = append(items, DuplicateItem{Label: r.URL, URL: s.adminFormURL("golem15.seo.rules", strconv.FormatUint(uint64(r.ID), 10))}) } if len(items) > snippetDuplicateLimit { items = items[:snippetDuplicateLimit] } return items, nil } if out.Title, err = find("effective_title", title, "title"); err != nil { return out, err } if out.Description, err = find("effective_description", description, "description"); err != nil { return out, err } return out, nil } // RuleSnippetAction is the seo_snippet action of the URL rule form: op // context answers one pseudo-locale with the rule's address (none for a // pattern) and no H1; op duplicates compares with records and other rules. func RuleSnippetAction(svc func() *Service, label, permission string) pact.AdminAction { return pact.AdminAction{ Name: SnippetActionName, Label: label, Permissions: []string{permission}, Run: func(ctx context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) { s := svc() if s == nil { return pact.AdminActionResult{}, ErrNotBooted } var p snippetPayload 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.snippet.invalid"}}} } } id := "" if in.RecordID != nil { id = strconv.FormatUint(*in.RecordID, 10) } db, err := s.DB(ctx) if err != nil { return pact.AdminActionResult{}, err } locale := s.DefaultLocale(ctx, db) switch p.Op { case "context", "": settings, err := LoadSettings(db) if err != nil { return pact.AdminActionResult{}, err } // The rule form's preview has no fill (an owner form may fill // built-in scalar fields only), so it shows the saved rule. var u *string title, description := "", "" if r, ok := in.Record.(*models.Rule); ok && r != nil { if r.Match == models.MatchExact { u = strPtr(r.URL) } title, description = r.Title, r.Description } site := s.translatedSetting(ctx, db, &settings, "site_name", settings.SiteName, locale) source := "" if description != "" { source = SourceRule } return pact.AdminActionResult{Data: SnippetContext{Op: "context", Host: s.host(), Locales: []SnippetLocale{{ Locale: locale, URL: u, SiteName: site, DefaultTitle: title, DefaultDescription: strPtr(description), DescriptionSource: source, }}}}, nil case "duplicates": if p.Locale == "" { p.Locale = locale } data, err := s.duplicates(ctx, "", id, p) return pact.AdminActionResult{Data: data}, err } return pact.AdminActionResult{}, errSnippetOp }, } }