344 lines
11 KiB
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
|
|
},
|
|
}
|
|
}
|