Files
sm-seo-plugin/classes/snippet.go

344 lines
11 KiB
Go

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
},
}
}