package sitemap import ( "context" "fmt" "regexp" "strings" "time" "git.golem15.com/golem15/sm-sitemap-plugin/classes" "git.golem15.com/golem15/sm-sitemap-plugin/controllers" "git.golem15.com/golem15/sm-sitemap-plugin/models" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/phrasebook" "git.golem15.com/golem15/summercms/modules/signpost" "github.com/goccy/go-yaml" "gorm.io/gorm" "gorm.io/gorm/clause" ) // The settings form's tab keys: the Content types tab follows Files; the // HTML sitemap tab's address field is added after html_enabled. const ( TabFiles = "golem15.sitemap::lang.settings.tab_files" TabTypes = "golem15.sitemap::lang.settings.tab_types" TabHTML = "golem15.sitemap::lang.settings.tab_html" ) // htmlPathField is the HTML sitemap address field: translatable, stored // as a locale map in html_paths by the plugin's form extension (the // settings row has no translation writer of its own). const htmlPathField = "html_path" // The phrase keys of the Content types refusals (UI-SPEC C26). const ( errSlug = "golem15.sitemap::lang.errors.slug" errSlugTaken = "golem15.sitemap::lang.errors.slug_taken" ) var typeFieldUnsafe = regexp.MustCompile(`[^a-z0-9_]`) // typeFieldKey turns a content type key into the part of a field name: // every character outside [a-z0-9_] becomes "_". func typeFieldKey(key string) string { return typeFieldUnsafe.ReplaceAllString(strings.ToLower(key), "_") } // AdminActionExtensions extends the plugin's own settings singleton: the // Content types tab (one block per signpost content type, in registration // order; none without content types) and the HTML sitemap address. func (p *Plugin) AdminActionExtensions() []pact.AdminActionExtension { var types []pact.MenuItemType if reg := p.registry(); reg != nil { types = reg.ContentTypes() } form := p.typeSettingsTab(types) if form == nil { return nil } return []pact.AdminActionExtension{{Controller: controllers.SettingsControllerID, Form: form}} } func (p *Plugin) registry() *signpost.Registry { if p == nil || p.app == nil { return nil } reg, _ := p.app.Lookup[*signpost.Registry]() return reg } // typeSettingsDocument is the fields document of the extension (UI-SPEC // C26): per type, the include switch (labelled with the type's label), the // file name and the HTML sitemap heading on the Content types tab, then // the HTML sitemap address after html_enabled. func typeSettingsDocument(types []pact.MenuItemType) ([]byte, error) { fields := yaml.MapSlice{} for _, typ := range types { base := "type_" + typeFieldKey(typ.Key) + "_" label := typ.Label if strings.TrimSpace(label) == "" { label = typ.Key } fields = append(fields, yaml.MapItem{Key: base + "include", Value: yaml.MapSlice{ {Key: "label", Value: label}, {Key: "comment", Value: "golem15.sitemap::lang.settings.type_include"}, {Key: "type", Value: "switch"}, {Key: "span", Value: "left"}, {Key: "tab", Value: TabTypes}, }}, yaml.MapItem{Key: base + "slug", Value: yaml.MapSlice{ {Key: "label", Value: "golem15.sitemap::lang.settings.type_slug"}, {Key: "comment", Value: "golem15.sitemap::lang.settings.type_slug_comment"}, {Key: "type", Value: "text"}, {Key: "span", Value: "right"}, {Key: "tab", Value: TabTypes}, }}, yaml.MapItem{Key: base + "title", Value: yaml.MapSlice{ {Key: "label", Value: "golem15.sitemap::lang.settings.type_title"}, {Key: "type", Value: "mltext"}, {Key: "span", Value: "full"}, {Key: "tab", Value: TabTypes}, }}, ) } fields = append(fields, yaml.MapItem{Key: htmlPathField, Value: yaml.MapSlice{ {Key: "label", Value: "golem15.sitemap::lang.settings.html_path"}, {Key: "comment", Value: "golem15.sitemap::lang.settings.html_path_comment"}, {Key: "type", Value: "mltext"}, {Key: "span", Value: "left"}, {Key: "tab", Value: TabHTML}, {Key: "after", Value: "html_enabled"}, }}) doc := yaml.MapSlice{} if len(types) > 0 { doc = append(doc, yaml.MapItem{Key: "tabs", Value: []yaml.MapSlice{{{Key: "name", Value: TabTypes}, {Key: "after", Value: TabFiles}}}}) } return yaml.Marshal(append(doc, yaml.MapItem{Key: "fields", Value: fields})) } // typeSettingsTab is the plugin's form extension on its own settings // controller: the Content types tab, loaded from and saved to // golem15_sitemap_types, and the HTML sitemap address (html_paths). func (p *Plugin) typeSettingsTab(types []pact.MenuItemType) *pact.AdminFormExtension { doc, err := typeSettingsDocument(types) if err != nil { pluginLog.Error("golem15.sitemap: content types tab", "error", err) return nil } return &pact.AdminFormExtension{ FieldsYAML: doc, Permissions: []string{controllers.PermissionAccessDefinitions}, Load: func(ctx context.Context, in pact.AdminFormLoad) (map[string]any, error) { rows, err := classes.LoadTypeSettings(in.DB.WithContext(ctx)) if err != nil { return nil, err } def := p.defaultLocale(ctx, in.DB) tr := p.translator() out := map[string]any{} for _, typ := range types { cfg := classes.EffectiveType(typ, rows) base := "type_" + typeFieldKey(typ.Key) + "_" out[base+"include"] = cfg.Include out[base+"slug"] = cfg.Slug titles := cfg.Titles if len(titles) == 0 { titles = map[string]string{def: controllers.Phrase(ctx, tr, typ.Label, nil)} } out[base+"title"] = titles } settings, err := classes.LoadSettings(in.DB.WithContext(ctx)) if err != nil { return nil, err } out[htmlPathField] = settings.EffectiveHTMLPaths() return out, nil }, Save: func(ctx context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) { if err := saveTypeSettings(ctx, in, types); err != nil { return pact.AdminFormSaveResult{}, err } return pact.AdminFormSaveResult{}, saveHTMLPaths(in) }, } } // saveTypeSettings stores the submitted Content types values. A slug // outside [a-z0-9-] is refused with errors.slug, one that another type (or // the image and News sitemaps) uses with errors.slug_taken; an empty slug // is the type's default. func saveTypeSettings(ctx context.Context, in pact.AdminFormSave, types []pact.MenuItemType) error { rows, err := classes.LoadTypeSettings(in.DB) if err != nil { return err } refusals := pact.AdminFormErrors{} next := make([]models.TypeSetting, 0, len(types)) slugOwner := map[string]string{} for _, typ := range types { base := "type_" + typeFieldKey(typ.Key) + "_" cfg := classes.EffectiveType(typ, rows) row, ok := rows[typ.Key] if !ok { row = models.TypeSetting{TypeKey: typ.Key, Include: true} } if raw, sent := in.Values[base+"include"]; sent { row.Include = truthy(raw) } slug := cfg.Slug if raw, sent := in.Values[base+"slug"]; sent { slug = strings.TrimSpace(stringOf(raw)) if slug == "" { slug = classes.DefaultSlug(typ.Key) } else if !classes.ValidSlug(slug) { refusals[base+"slug"] = append(refusals[base+"slug"], errSlug) } } if classes.ReservedSlugs[slug] { refusals[base+"slug"] = append(refusals[base+"slug"], errSlugTaken) } else if owner, taken := slugOwner[slug]; taken && owner != typ.Key { refusals[base+"slug"] = append(refusals[base+"slug"], errSlugTaken) } slugOwner[slug] = typ.Key if slug == classes.DefaultSlug(typ.Key) { row.Slug = "" } else { row.Slug = slug } if raw, sent := in.Values[base+"title"]; sent { row.Titles = lagoon.Jsonable[map[string]string]{Data: titlesOf(raw), Valid: true} } next = append(next, row) } if len(refusals) > 0 { return refusals } now := time.Now().UTC() for _, row := range next { row.UpdatedAt = now if !row.Titles.Valid { row.Titles = lagoon.Jsonable[map[string]string]{Data: map[string]string{}, Valid: true} } if err := in.DB.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "type_key"}}, DoUpdates: clause.AssignmentColumns([]string{"include", "slug", "titles", "updated_at"}), }).Create(&row).Error; err != nil { return err } } return nil } // saveHTMLPaths stores a submitted HTML sitemap address: the locales sent // replace theirs in html_paths (a blank one removes it), the others keep // theirs. An address that models.ValidHTMLPath refuses is refused with // errors.html_path. func saveHTMLPaths(in pact.AdminFormSave) error { raw, sent := in.Values[htmlPathField] if !sent { return nil } paths := map[string]string{} switch v := raw.(type) { case map[string]string: paths = v case map[string]any: for k, s := range v { text, _ := s.(string) paths[k] = text } } merged, problem := classes.MergeHTMLPaths(in.DB, paths) if problem { return pact.AdminFormErrors{htmlPathField: {models.HTMLPathProblem}} } return classes.StoreHTMLPaths(in.DB, merged) } // titlesOf reads a submitted translatable value (a map of locale to text); // blank texts are dropped. func titlesOf(v any) map[string]string { out := map[string]string{} switch m := v.(type) { case map[string]string: for k, s := range m { if strings.TrimSpace(s) != "" { out[k] = strings.TrimSpace(s) } } case map[string]any: for k, s := range m { if text, ok := s.(string); ok && strings.TrimSpace(text) != "" { out[k] = strings.TrimSpace(text) } } } return out } // truthy reads a submitted switch value. func truthy(v any) bool { switch b := v.(type) { case bool: return b case string: return b == "1" || strings.EqualFold(b, "true") || strings.EqualFold(b, "on") case fmt.Stringer: return b.String() == "1" } return false } // stringOf reads a submitted text value. func stringOf(v any) string { switch s := v.(type) { case string: return s case nil: return "" } return fmt.Sprint(v) } // translator returns the application's translator, nil when none. func (p *Plugin) translator() *phrasebook.Translator { if p == nil || p.app == nil { return nil } tr, _ := p.app.Lookup[*phrasebook.Translator]() return tr } // defaultLocale is the site's default content locale: the translation // writer's default, else app.locale, else en. func (p *Plugin) defaultLocale(ctx context.Context, db *gorm.DB) string { if p != nil && p.app != nil { if w, ok := p.app.Lookup[cabana.TranslationWriter](); ok && w != nil { if code, err := w.DefaultLocale(ctx, db); err == nil && code != "" { return code } } if p.app.Config != nil { if code := strings.TrimSpace(p.app.Config.String("app.locale")); code != "" { return code } } } return "en" } var _ pact.HasAdminActionExtensions = (*Plugin)(nil)