feat!: remove the PHP-parity sitemap, tiers, definitions screen and XSL

BREAKING CHANGE: /sitemap.xsl is gone (404), the definitions screen and
the Phase 17 settings tile are replaced by the settings singleton, and
priority, change frequency, priority tiers and the update manifest are
no longer written or read. The config keys golem15.sitemap.cache_seconds
and golem15.sitemap.priority_tiers are removed; site_url, lastmod_ignore
and rebuild_delay_seconds are new.

- sitemap:import and sitemap:import-winter write the additional addresses
  (and site_url, default_locale) instead of the definition
- the golem15_sitemap_definitions table stays in the database, unread
This commit is contained in:
Jakub Zych
2026-11-04 11:12:51 +01:00
parent 3ba70bb69f
commit 84cb27a810
42 changed files with 582 additions and 5371 deletions

View File

@@ -1,6 +1,7 @@
// Package console holds the sitemap plugin's commands: sitemap:import (the
// PHP ImportCommand) and sitemap:import-winter (a copy of a WinterCMS
// site's definition and settings).
// Package console holds the sitemap plugin's commands: sitemap:rebuild,
// sitemap:import (a JSON list of additional addresses) and
// sitemap:import-winter (a copy of a WinterCMS site's url items and
// settings).
package console
import (
@@ -56,20 +57,24 @@ func lookupDB(ctx context.Context, app *backpack.App) (*gorm.DB, func(), error)
return gdb, func() { _ = sqlDB.Close() }, nil
}
// singletonDefinition loads the definition with the fixed theme key,
// creating it when there is none (PHP Definition::firstOrCreate).
func singletonDefinition(tx *gorm.DB) (models.Definition, error) {
var defs []models.Definition
if err := tx.Where("theme = ?", models.DefinitionTheme).Order("id ASC").Limit(1).Find(&defs).Error; err != nil {
return models.Definition{}, err
// loadSettingsForUpdate reads settings row 1 inside tx, locked, creating
// it when the migration's row is gone.
func loadSettingsForUpdate(tx *gorm.DB) (models.Settings, error) {
if err := tx.Exec(`INSERT INTO golem15_sitemap_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING`).Error; err != nil {
return models.Settings{}, err
}
if len(defs) > 0 {
return defs[0], nil
var s models.Settings
if err := tx.Raw(`SELECT * FROM golem15_sitemap_settings WHERE id = 1 FOR UPDATE`).Scan(&s).Error; err != nil {
return models.Settings{}, err
}
theme := models.DefinitionTheme
def := models.Definition{Theme: &theme}
if err := tx.Create(&def).Error; err != nil {
return models.Definition{}, err
}
return def, nil
return s, nil
}
// saveExtra stores the additional addresses on settings row 1.
func saveExtra(tx *gorm.DB, rows []models.ExtraURL) error {
if rows == nil {
rows = []models.ExtraURL{}
}
value := lagoon.Jsonable[[]models.ExtraURL]{Data: rows, Valid: true}
return tx.Exec(`UPDATE golem15_sitemap_settings SET extra_urls = ? WHERE id = 1`, value).Error
}

View File

@@ -1,55 +1,55 @@
package console
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"gorm.io/gorm"
)
// Import is sitemap:import FILE [--theme=] [--append], the PHP
// ImportCommand: it reads a JSON array of URL entries ({"url": ...,
// "changefreq": ..., "priority": ...}; changefreq defaults to monthly,
// priority to 0.5) into the sitemap definition. Without --append the
// definition keeps its non-url items first and the imported URLs replace
// its url items; with --append the URLs are added after every existing item.
// The definition is always the one /sitemap.xml serves (theme default);
// --theme is accepted for PHP compatibility and a theme other than default
// is reported. An entry whose url is not a path or an http(s) URL
// (classes.AllowedItemURL) is refused, as the admin form refuses it.
// Import is sitemap:import FILE [--append]: it reads a JSON array of URL
// entries ({"url": "/path", "lastmod": "2026-10-28"}; lastmod optional) into
// the settings' additional addresses, in one transaction. Without --append
// the imported addresses replace the additional addresses; with --append
// they are added after them, an address already listed is skipped. An
// entry whose url is not a path or an http(s) URL (models.AllowedAddress),
// or whose lastmod is not a date, stops the import before anything is
// written. The Phase 17 keys changefreq and priority are ignored, and
// --theme is accepted for the PHP command's sake. The files follow at the
// next rebuild (sitemap:rebuild).
func Import(app *backpack.App) bonfire.Command {
return bonfire.Command{
Name: "sitemap:import",
Description: "Import sitemap URLs from a JSON manifest file",
Long: `Reads a JSON array of URL entries ({"url": "/path", "changefreq":
"monthly", "priority": "0.5"}; changefreq and priority are optional) and puts
them into the sitemap definition that /sitemap.xml serves, in one
transaction. Every entry needs a url that is a path or an http(s) URL; one
bad entry stops the import before anything is written.
Description: "Import additional sitemap addresses from a JSON manifest file",
Long: `Reads a JSON array of URL entries ({"url": "/path", "lastmod":
"2026-10-28"}; lastmod is optional) and puts them into the sitemap's
additional addresses (Settings > Sitemaps > Additional addresses), in one
transaction. Every entry needs a url that is a path or an http(s) URL; one bad
entry stops the import before anything is written. The old changefreq and
priority keys are ignored: the sitemaps no longer write them.
By default the import replaces the definition's url items and keeps its
dynamic items (pages, posts and other generated types). --append adds the
URLs and keeps every existing item. --theme is accepted for the PHP command's
sake: only the default definition is served, so the URLs always go there.
It ends with "Imported N URLs into sitemap definition for theme 'default'."`,
By default the import replaces the additional addresses. --append adds the
addresses after them and skips those already listed. --theme is accepted for
the PHP command's sake and does nothing. The files change at the next rebuild
(sitemap:rebuild, a settings save or the nightly run). It ends with
"Imported N addresses into the sitemap's additional addresses."`,
Args: []bonfire.Arg{
{Name: "file", Description: "Path to the JSON manifest (relative to the working directory or absolute)", Required: true},
},
Flags: []bonfire.Flag{
{Name: "theme", Description: "Theme code of the PHP plugin; /sitemap.xml serves only the default definition"},
{Name: "append", Description: "Add URLs without removing existing url-type items", Bare: true},
{Name: "theme", Description: "Accepted for the PHP command; ignored"},
{Name: "append", Description: "Add the addresses instead of replacing the additional addresses", Bare: true},
},
Examples: []bonfire.Example{
{Command: "sitemap:import urls.json", Description: "Replace the url items with the manifest's URLs"},
{Command: "sitemap:import urls.json --append", Description: "Add the URLs and keep every existing item"},
{Command: "sitemap:import urls.json", Description: "Replace the additional addresses with the manifest's addresses"},
{Command: "sitemap:import urls.json --append", Description: "Add the addresses and keep the existing ones"},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
file, _ := in.Argument("file")
@@ -72,136 +72,67 @@ func ImportFile(ctx context.Context, db *gorm.DB, file, theme string, appendMode
}
return fmt.Errorf("Cannot read %s: %w", file, err)
}
entries, labels, ok := orderedEntries(raw)
entries, labels, ok := models.LegacyEntries(raw)
if !ok {
return errors.New("Invalid JSON: expected an array of URL entries")
}
newItems := make([]models.DefinitionItem, 0, len(entries))
rows := make([]models.ExtraURL, 0, len(entries))
for i, entry := range entries {
var fields map[string]json.RawMessage
_ = json.Unmarshal(entry, &fields)
url, err := models.DecodeText(fields["url"])
if fields["url"] == nil || err != nil || phpEmpty(fields["url"]) {
if fields["url"] == nil || err != nil || strings.TrimSpace(models.Text(url)) == "" {
return fmt.Errorf("Entry %s is missing a 'url' field", labels[i])
}
if !classes.AllowedItemURL(models.Text(url)) {
u := strings.TrimSpace(models.Text(url))
if !models.AllowedAddress(u) {
return fmt.Errorf("Entry %s has a url that is not a path or an http(s) URL", labels[i])
}
item := models.DefinitionItem{Type: models.ItemTypeURL, URL: url}
item.Changefreq = textOr(fields["changefreq"], "monthly")
item.Priority = textOr(fields["priority"], "0.5")
newItems = append(newItems, item)
row := models.ExtraURL{URL: u}
if raw, ok := fields["lastmod"]; ok {
mod, err := models.DecodeText(raw)
if err != nil || (strings.TrimSpace(models.Text(mod)) != "" && !models.ValidDate(models.Text(mod))) {
return fmt.Errorf("Entry %s has a lastmod that is not a date (YYYY-MM-DD)", labels[i])
}
row.LastMod = strings.TrimSpace(models.Text(mod))
}
rows = append(rows, row)
}
var kept int
var kept, added int
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
def, err := singletonDefinition(tx)
s, err := loadSettingsForUpdate(tx)
if err != nil {
return err
}
existing := def.Items()
if !appendMode {
var dynamic []models.DefinitionItem
for _, item := range existing {
if item.Type != models.ItemTypeURL {
dynamic = append(dynamic, item)
}
next := []models.ExtraURL{}
seen := map[string]bool{}
if appendMode {
for _, e := range s.Extra() {
next = append(next, e)
seen[e.URL] = true
}
existing = dynamic
kept = len(next)
}
kept = len(existing)
def.SetItems(append(append([]models.DefinitionItem{}, existing...), newItems...))
return tx.Save(&def).Error
for _, r := range rows {
if seen[r.URL] {
continue
}
seen[r.URL] = true
next = append(next, r)
added++
}
return saveExtra(tx, next)
})
if err != nil {
return err
}
out.Success(fmt.Sprintf("Imported %d URLs into sitemap definition for theme '%s'.", len(newItems), models.DefinitionTheme))
out.Success(fmt.Sprintf("Imported %d addresses into the sitemap's additional addresses.", added))
if kept > 0 {
label := "existing non-URL (dynamic)"
if appendMode {
label = "existing"
}
out.Info(fmt.Sprintf("Kept %d %s items.", kept, label))
out.Info(fmt.Sprintf("Kept %d existing addresses.", kept))
}
if theme != "" && theme != models.DefinitionTheme {
out.Warning(fmt.Sprintf("Theme '%s' was given, but /sitemap.xml serves only the '%s' definition, so the URLs were imported there.", theme, models.DefinitionTheme))
if theme != "" {
out.Warning(fmt.Sprintf("Theme '%s' was given; the sitemap has one set of additional addresses, so the addresses were imported there.", theme))
}
out.Info("Run sitemap:rebuild (or save the sitemap settings) to update the files now.")
return nil
}
// orderedEntries splits a JSON array, or a JSON object in key order (a PHP
// array with keys), into its values with their PHP labels (index or key).
func orderedEntries(raw []byte) ([]json.RawMessage, []string, bool) {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 {
return nil, nil, false
}
switch raw[0] {
case '[':
var list []json.RawMessage
if err := json.Unmarshal(raw, &list); err != nil {
return nil, nil, false
}
labels := make([]string, len(list))
for i := range list {
labels[i] = fmt.Sprint(i)
}
return list, labels, true
case '{':
dec := json.NewDecoder(bytes.NewReader(raw))
if _, err := dec.Token(); err != nil {
return nil, nil, false
}
var list []json.RawMessage
var labels []string
for dec.More() {
tok, err := dec.Token()
if err != nil {
return nil, nil, false
}
key, _ := tok.(string)
var v json.RawMessage
if err := dec.Decode(&v); err != nil {
return nil, nil, false
}
list = append(list, v)
labels = append(labels, key)
}
return list, labels, true
}
return nil, nil, false
}
// phpEmpty is PHP empty() of a decoded JSON value.
func phpEmpty(raw json.RawMessage) bool {
var v any
if err := json.Unmarshal(raw, &v); err != nil {
return true
}
switch t := v.(type) {
case nil:
return true
case string:
return t == "" || t == "0"
case bool:
return !t
case float64:
return t == 0
case []any:
return len(t) == 0
case map[string]any:
return len(t) == 0
}
return false
}
// textOr is PHP `$entry[key] ?? fallback` as text: a missing or null value
// is the fallback.
func textOr(raw json.RawMessage, fallback string) *string {
if raw != nil {
if s, err := models.DecodeText(raw); err == nil && s != nil {
return s
}
}
return &fallback
}

View File

@@ -8,15 +8,12 @@ import (
"errors"
"fmt"
"os"
"slices"
"sort"
"strings"
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/signpost"
"gorm.io/gorm"
)
@@ -34,11 +31,12 @@ var droppedTypes = map[string]bool{
// of scripts/winter-export-settings.sql (the JSON object of the
// golem15_sitemap_settings system setting). Theme picks a definition when
// the export holds several. Maps rename item types (old to new) before they
// are checked. PageURLs turns a static or CMS page item into a url item:
// its key is the item's reference (the PHP page file name, for example
// blog), its value the page's URL (/blog). Force replaces a definition that
// has items; DryRun reports without writing. A frontend_url that
// models.AllowedFrontendURL refuses stops the import and nothing is written.
// are checked. PageURLs turns a static or CMS page item into an additional
// address: its key is the item's reference (the PHP page file name, for
// example blog), its value the page's URL (/blog). Force replaces
// additional addresses that are already stored; DryRun reports without
// writing. A frontend_url that models.AllowedFrontendURL refuses stops the
// import and nothing is written.
type WinterOptions struct {
DefinitionsFile string
SettingsFile string
@@ -49,7 +47,7 @@ type WinterOptions struct {
DryRun bool
}
// WinterReport is what sitemap:import-winter did: the items kept, the
// WinterReport is what sitemap:import-winter did: the addresses kept, the
// report lines of every dropped or remapped item, and whether the settings
// were copied.
type WinterReport struct {
@@ -60,50 +58,59 @@ type WinterReport struct {
Settings bool
}
// winterSettings are the WinterCMS settings the per-type sitemap reads.
type winterSettings struct {
SiteURL string
DefaultLocale string
}
// ImportWinter is sitemap:import-winter: it copies a WinterCMS site's
// sitemap definition and settings into the definition /sitemap.xml serves
// (theme default) and the settings row. Item types are mapped (url kept,
// static and CMS page types dropped unless --page-url reference=/path names
// their URL, --map old=new renames applied first),
// an item of a registry type that takes references is dropped when its
// reference is not among the type's current references, an item of an
// unknown type is dropped, a url item whose URL is not a path or an http(s)
// URL (classes.AllowedItemURL) is dropped, a URL pattern that is not a path
// (signpost.ValidItemPattern) is cleared, and every dropped, remapped or
// cleared item is printed. A definition that already has items is refused
// without --force. Settings keys missing from the export take the PHP
// defaults. A frontend_url that models.AllowedFrontendURL refuses stops the
// import and nothing is written.
// sitemap definition into the settings' additional addresses and its
// frontend_url and frontend_default_locale into the site address and the
// default locale (D-32). url items are kept (static and CMS page items
// become addresses when --page-url reference=/path names their URL,
// --map old=new renames applied first); every other item is dropped and
// listed, because the content types list their records themselves. A url
// item whose URL is not a path or an http(s) URL (models.AllowedAddress)
// is dropped. Stored additional addresses are replaced only with --force.
// Settings keys of the PHP-parity format (priorities, tiers, the update
// manifest) are reported as ignored. A frontend_url that
// models.AllowedFrontendURL refuses stops the import and nothing is
// written.
func ImportWinter(app *backpack.App) bonfire.Command {
return bonfire.Command{
Name: "sitemap:import-winter",
Description: "Copy a WinterCMS sitemap definition and settings export into this application",
Long: `Copies the sitemap definition of a WinterCMS site (the JSON lines that
scripts/winter-export-definitions.sql prints) and its settings (the JSON
object from scripts/winter-export-settings.sql) in one transaction. Item
types this application knows are kept; --map renames a type (old=new) and
--page-url turns a static or CMS page item into a url item with the given
path. Items that still have no known type are dropped and listed. When the
export holds several themes, --theme picks the one to copy.
object from scripts/winter-export-settings.sql) in one transaction. The
definition's url items become the sitemap's additional addresses; --page-url
turns a static or CMS page item into an address with the given path, and
--map renames a type first (old=new). Every other item is dropped and listed:
the content types (posts, categories and so on) list their records
themselves. The settings' frontend_url becomes the site address and
frontend_default_locale the default locale; priorities, tiers and the update
manifest are no longer used. When the export holds several themes, --theme
picks the one to copy.
Run it once when an application moves from WinterCMS, --dry-run first:
--dry-run reports kept, remapped and dropped items and writes nothing. A
definition that already has items is replaced only with --force. It ends
with "Definition copied: N items kept, N remapped, N dropped." and "Settings
copied." for the parts the export holds.`,
--dry-run reports kept, remapped and dropped items and writes nothing. Stored
additional addresses are replaced only with --force. It ends with "Definition
copied: N items kept, N remapped, N dropped." and "Settings copied." for the
parts the export holds.`,
Flags: []bonfire.Flag{
{Name: "definitions", Description: "Definitions export (JSON lines from scripts/winter-export-definitions.sql)"},
{Name: "settings", Description: "Settings export (the JSON object from scripts/winter-export-settings.sql)"},
{Name: "theme", Description: "The PHP theme whose definition to copy when the export holds several"},
{Name: "map", Description: "Rename an item type, old=new (repeatable)", Repeatable: true},
{Name: "page-url", Description: "Copy a static or CMS page item as a url item, reference=/path (repeatable)", Repeatable: true},
{Name: "force", Description: "Replace a definition that already has items", Bare: true},
{Name: "page-url", Description: "Copy a static or CMS page item as an address, reference=/path (repeatable)", Repeatable: true},
{Name: "force", Description: "Replace additional addresses that are already stored", Bare: true},
{Name: "dry-run", Description: "Report what would be copied without writing", Bare: true},
},
Examples: []bonfire.Example{
{Command: "sitemap:import-winter --definitions definitions.jsonl --settings settings.json --dry-run", Description: "Show what would be kept, remapped and dropped, without writing"},
{Command: "sitemap:import-winter --definitions definitions.jsonl --theme main --map blog-post=journal-post", Description: "Copy the main theme's definition, renaming an item type"},
{Command: "sitemap:import-winter --definitions definitions.jsonl --page-url about=/about --force", Description: "Replace the definition, turning the about page into a url item"},
{Command: "sitemap:import-winter --definitions definitions.jsonl --theme main", Description: "Copy the url items of the main theme's definition"},
{Command: "sitemap:import-winter --definitions definitions.jsonl --page-url about=/about --force", Description: "Replace the additional addresses, turning the about page into an address"},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
maps, err := parseMaps(in.Flags("map"))
@@ -128,11 +135,7 @@ copied." for the parts the export holds.`,
return err
}
defer done()
var reg *signpost.Registry
if app != nil {
reg, _ = app.Lookup[*signpost.Registry]()
}
_, err = ImportWinterFiles(ctx, db, reg, opts, out)
_, err = ImportWinterFiles(ctx, db, opts, out)
return err
},
}
@@ -178,15 +181,14 @@ type winterDefinition struct {
Data json.RawMessage `json:"data"`
}
// ImportWinterFiles runs sitemap:import-winter against db; reg is the menu
// item type registry the item types are checked against (nil knows none).
func ImportWinterFiles(ctx context.Context, db *gorm.DB, reg *signpost.Registry, opts WinterOptions, out bonfire.Output) (WinterReport, error) {
// ImportWinterFiles runs sitemap:import-winter against db.
func ImportWinterFiles(ctx context.Context, db *gorm.DB, opts WinterOptions, out bonfire.Output) (WinterReport, error) {
var report WinterReport
if opts.DefinitionsFile == "" && opts.SettingsFile == "" {
return report, errors.New("give --definitions, --settings or both")
}
var (
items []models.DefinitionItem
rows []models.ExtraURL
haveDef bool
)
if opts.DefinitionsFile != "" {
@@ -194,14 +196,14 @@ func ImportWinterFiles(ctx context.Context, db *gorm.DB, reg *signpost.Registry,
if err != nil {
return report, err
}
out.Info(fmt.Sprintf("Copying definition %s (theme '%s') into the '%s' definition.", strings.TrimSpace(string(row.ID)), models.Text(row.Theme), models.DefinitionTheme))
items, err = mapItems(ctx, reg, row.Data, opts.Maps, opts.PageURLs, &report)
out.Info(fmt.Sprintf("Copying the url items of definition %s (theme '%s') into the additional addresses.", strings.TrimSpace(string(row.ID)), models.Text(row.Theme)))
rows, err = mapItems(row.Data, opts.Maps, opts.PageURLs, &report)
if err != nil {
return report, err
}
haveDef = true
}
var settings models.Settings
var settings winterSettings
if opts.SettingsFile != "" {
var err error
settings, err = readWinterSettings(opts.SettingsFile, &report)
@@ -214,12 +216,12 @@ func ImportWinterFiles(ctx context.Context, db *gorm.DB, reg *signpost.Registry,
out.Warning(line)
}
if haveDef {
var existing []models.Definition
if err := db.WithContext(ctx).Where("theme = ?", models.DefinitionTheme).Order("id ASC").Limit(1).Find(&existing).Error; err != nil {
var existing models.Settings
if err := db.WithContext(ctx).Where("id = ?", 1).Limit(1).Find(&existing).Error; err != nil {
return report, err
}
if len(existing) > 0 && len(existing[0].Items()) > 0 && !opts.Force {
msg := fmt.Sprintf("The sitemap definition already has %d items; rerun with --force to replace them.", len(existing[0].Items()))
if n := len(existing.Extra()); n > 0 && !opts.Force {
msg := fmt.Sprintf("The sitemap already has %d additional addresses; rerun with --force to replace them.", n)
if !opts.DryRun {
return report, errors.New(msg)
}
@@ -238,19 +240,17 @@ func ImportWinterFiles(ctx context.Context, db *gorm.DB, reg *signpost.Registry,
return report, nil
}
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if _, err := loadSettingsForUpdate(tx); err != nil {
return err
}
if haveDef {
def, err := singletonDefinition(tx)
if err != nil {
return err
}
def.SetItems(items)
if err := tx.Save(&def).Error; err != nil {
if err := saveExtra(tx, rows); err != nil {
return err
}
}
if report.Settings {
settings.ID = 1
if err := tx.Save(&settings).Error; err != nil {
if err := tx.Exec(`UPDATE golem15_sitemap_settings SET site_url = ?, default_locale = ? WHERE id = 1`,
nullable(settings.SiteURL), nullable(settings.DefaultLocale)).Error; err != nil {
return err
}
}
@@ -268,6 +268,14 @@ func ImportWinterFiles(ctx context.Context, db *gorm.DB, reg *signpost.Registry,
return report, nil
}
// nullable is nil for "", else s.
func nullable(s string) any {
if s == "" {
return nil
}
return s
}
// pickDefinition reads the definitions export and returns the row of theme,
// or the only row when theme is empty.
func pickDefinition(file, theme string) (winterDefinition, error) {
@@ -316,150 +324,100 @@ func pickDefinition(file, theme string) (winterDefinition, error) {
return rows[0], nil
}
// exportedJSON is a JSON value that the export may hold as text (a TEXT
// column selected as it is) or as JSON: a string is decoded once more.
func exportedJSON(raw json.RawMessage) json.RawMessage {
raw = bytes.TrimSpace(raw)
if len(raw) > 0 && raw[0] == '"' {
var text string
if json.Unmarshal(raw, &text) == nil {
return json.RawMessage(strings.TrimSpace(text))
// mapItems decodes the exported data and keeps its url items as
// additional addresses.
func mapItems(data json.RawMessage, maps, pageURLs map[string]string, report *WinterReport) ([]models.ExtraURL, error) {
items, labels, err := models.ParseLegacyItems(data)
if err != nil {
return nil, err
}
out := []models.ExtraURL{}
seen := map[string]bool{}
keep := func(label, u string) {
if seen[u] {
report.Lines = append(report.Lines, fmt.Sprintf("%s: dropped (address '%s' is already listed)", label, u))
report.Dropped++
return
}
seen[u] = true
out = append(out, models.ExtraURL{URL: u})
report.Kept++
}
return raw
}
// mapItems decodes the exported data and maps its items.
func mapItems(ctx context.Context, reg *signpost.Registry, data json.RawMessage, maps, pageURLs map[string]string, report *WinterReport) ([]models.DefinitionItem, error) {
data = exportedJSON(data)
if len(data) == 0 || bytes.Equal(data, []byte("null")) {
return []models.DefinitionItem{}, nil
}
rawItems, labels, ok := orderedEntries(data)
if !ok {
return nil, errors.New("the definition data is not a list of items")
}
out := []models.DefinitionItem{}
refs := map[string][]string{}
for i, raw := range rawItems {
var item models.DefinitionItem
if err := json.Unmarshal(raw, &item); err != nil {
return nil, fmt.Errorf("definition item %s: %w", labels[i], err)
}
var fields map[string]any
_ = json.Unmarshal(raw, &fields)
for i, item := range items {
label := describeItem(labels[i], item)
if page, ok := fields["cmsPage"].(string); ok && page != "" {
report.Lines = append(report.Lines, fmt.Sprintf("%s: cmsPage '%s' dropped (CMS pages are not supported)", label, page))
if item.CMSPage != "" {
report.Lines = append(report.Lines, fmt.Sprintf("%s: cmsPage '%s' dropped (CMS pages are not supported)", label, item.CMSPage))
}
if p := models.Text(item.URLPattern); p != "" && !signpost.ValidItemPattern(p) {
report.Lines = append(report.Lines, fmt.Sprintf("%s: urlPattern '%s' cleared (not a path starting with a single /)", label, p))
item.URLPattern = nil
}
if url, ok := pageURLs[models.Text(item.Reference)]; ok && droppedTypes[item.Type] {
if !classes.AllowedItemURL(url) {
if url, ok := pageURLs[item.Reference]; ok && droppedTypes[item.Type] {
if !models.AllowedAddress(url) {
report.Lines = append(report.Lines, fmt.Sprintf("%s: dropped (--page-url '%s' is not a path or an http(s) URL)", label, url))
report.Dropped++
continue
}
report.Lines = append(report.Lines, fmt.Sprintf("%s: copied as type 'url' with url '%s'", label, url))
report.Lines = append(report.Lines, fmt.Sprintf("%s: copied as an additional address '%s'", label, url))
report.Remapped++
item.Type = models.ItemTypeURL
item.URL = &url
item.Reference = nil
out = append(out, item)
report.Kept++
keep(label, url)
continue
}
if to, ok := maps[item.Type]; ok && to != item.Type {
report.Lines = append(report.Lines, fmt.Sprintf("%s: type '%s' mapped to '%s'", label, item.Type, to))
report.Remapped++
item.Type = to
if to == "" {
report.Lines = append(report.Lines, fmt.Sprintf("%s: dropped (mapped to nothing)", label))
report.Dropped++
continue
}
}
if item.Type == models.ItemTypeURL {
if !classes.AllowedItemURL(models.Text(item.URL)) {
switch {
case item.Type == models.LegacyItemURL:
if !models.AllowedAddress(item.URL) {
report.Lines = append(report.Lines, fmt.Sprintf("%s: dropped (the url is not a path or an http(s) URL)", label))
report.Dropped++
continue
}
out = append(out, item)
report.Kept++
continue
}
if droppedTypes[item.Type] {
keep(label, item.URL)
case item.Type == "":
report.Lines = append(report.Lines, fmt.Sprintf("%s: dropped (mapped to nothing)", label))
report.Dropped++
case droppedTypes[item.Type]:
report.Lines = append(report.Lines, fmt.Sprintf("%s: dropped (static and CMS page types are not supported)", label))
report.Dropped++
continue
}
if _, known := reg.Type(item.Type); !known {
report.Lines = append(report.Lines, fmt.Sprintf("%s: dropped (type '%s' is not registered; map it with --map %s=<type>)", label, item.Type, item.Type))
default:
report.Lines = append(report.Lines, fmt.Sprintf("%s: dropped (the content types list their records themselves)", label))
report.Dropped++
continue
}
values, cached := refs[item.Type]
if !cached {
set, err := signpost.ReferenceOptions(ctx, reg, item.Type)
if err != nil {
return nil, err
}
if set.Applies {
values = []string{}
for _, o := range set.Options {
values = append(values, o.Value)
}
}
refs[item.Type] = values
}
if values != nil && !slices.Contains(values, models.Text(item.Reference)) {
report.Lines = append(report.Lines, fmt.Sprintf("%s: dropped (reference '%s' is not among the current references of '%s')", label, models.Text(item.Reference), item.Type))
report.Dropped++
continue
}
out = append(out, item)
report.Kept++
}
return out, nil
}
func describeItem(label string, item models.DefinitionItem) string {
func describeItem(label string, item models.LegacyItem) string {
s := fmt.Sprintf("item %s (type '%s'", label, item.Type)
if item.URL != nil && *item.URL != "" {
s += fmt.Sprintf(", url '%s'", *item.URL)
if item.URL != "" {
s += fmt.Sprintf(", url '%s'", item.URL)
}
if item.Reference != nil && *item.Reference != "" {
s += fmt.Sprintf(", reference '%s'", *item.Reference)
if item.Reference != "" {
s += fmt.Sprintf(", reference '%s'", item.Reference)
}
return s + ")"
}
// readWinterSettings reads the exported settings object onto the PHP
// defaults. A frontend_url that models.AllowedFrontendURL refuses is an
// error; the value is quoted so its control characters are not printed raw.
func readWinterSettings(file string, report *WinterReport) (models.Settings, error) {
// ignoredWinterSettings are the PHP settings of the PHP-parity format the
// per-type sitemap no longer reads.
var ignoredWinterSettings = map[string]bool{
"updates_manifest_path": true, "enable_priority_tiers": true,
"priority_homepage": true, "priority_services": true, "priority_blog": true,
"priority_categories": true, "priority_legal": true,
}
// readWinterSettings reads the exported settings object. A frontend_url
// that models.AllowedFrontendURL refuses is an error; the value is quoted so
// its control characters are not printed raw.
func readWinterSettings(file string, report *WinterReport) (winterSettings, error) {
raw, err := os.ReadFile(file)
if err != nil {
return models.Settings{}, fmt.Errorf("File not found: %s", file)
return winterSettings{}, fmt.Errorf("File not found: %s", file)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(exportedJSON(raw), &fields); err != nil {
return models.Settings{}, fmt.Errorf("%s: the settings must be a JSON object: %w", file, err)
}
s := models.DefaultSettings()
texts := map[string]**string{
"frontend_url": &s.FrontendURL,
"frontend_default_locale": &s.FrontendDefaultLocale,
"updates_manifest_path": &s.UpdatesManifestPath,
models.PriorityHomepage: &s.PriorityHomepage,
models.PriorityServices: &s.PriorityServices,
models.PriorityBlog: &s.PriorityBlog,
models.PriorityCategories: &s.PriorityCategories,
models.PriorityLegal: &s.PriorityLegal,
if err := json.Unmarshal(models.LegacyJSON(raw), &fields); err != nil {
return winterSettings{}, fmt.Errorf("%s: the settings must be a JSON object: %w", file, err)
}
var s winterSettings
keys := make([]string, 0, len(fields))
for key := range fields {
keys = append(keys, key)
@@ -467,22 +425,25 @@ func readWinterSettings(file string, report *WinterReport) (models.Settings, err
sort.Strings(keys)
for _, key := range keys {
value := fields[key]
if dest, ok := texts[key]; ok {
switch {
case key == "frontend_url" || key == "frontend_default_locale":
text, err := models.DecodeText(value)
if err != nil {
return models.Settings{}, fmt.Errorf("%s: setting %s: %w", file, key, err)
return winterSettings{}, fmt.Errorf("%s: setting %s: %w", file, key, err)
}
*dest = text
continue
if key == "frontend_url" {
s.SiteURL = models.Text(text)
} else {
s.DefaultLocale = strings.TrimSpace(models.Text(text))
}
case ignoredWinterSettings[key]:
report.Lines = append(report.Lines, fmt.Sprintf("setting '%s' ignored (the per-type sitemaps do not use it)", key))
default:
report.Lines = append(report.Lines, fmt.Sprintf("setting '%s' ignored (not a sitemap setting of this port)", key))
}
if key == "enable_priority_tiers" {
s.EnablePriorityTiers = !phpEmpty(value)
continue
}
report.Lines = append(report.Lines, fmt.Sprintf("setting '%s' ignored (not a sitemap setting of this port)", key))
}
if url := models.Text(s.FrontendURL); !models.AllowedFrontendURL(url) {
return models.Settings{}, fmt.Errorf("%s: setting frontend_url %q must be empty or an absolute http:// or https:// URL without whitespace, control characters, quotes or the characters <, > and &", file, url)
if !models.AllowedFrontendURL(s.SiteURL) {
return winterSettings{}, fmt.Errorf("%s: setting frontend_url %q must be empty or an absolute http:// or https:// URL without whitespace, control characters, quotes or the characters <, > and &", file, s.SiteURL)
}
return s, nil
}

View File

@@ -9,7 +9,6 @@ import (
"strings"
"testing"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
"git.golem15.com/golem15/summercms/modules/bonfire"
)
@@ -50,7 +49,7 @@ func TestWinterSettingsFrontendURL(t *testing.T) {
for _, dry := range []bool{false, true} {
var buf bytes.Buffer
out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf)
report, err := ImportWinterFiles(context.Background(), nil, nil, WinterOptions{SettingsFile: file, DryRun: dry}, out)
report, err := ImportWinterFiles(context.Background(), nil, WinterOptions{SettingsFile: file, DryRun: dry}, out)
if err == nil || !strings.Contains(err.Error(), "setting frontend_url") {
t.Errorf("ImportWinterFiles(%q, dry run %v) = %v, want an error naming setting frontend_url", value, dry, err)
}
@@ -85,7 +84,7 @@ func TestWinterSettingsFrontendURL(t *testing.T) {
t.Errorf("%s: readWinterSettings = %v", name, err)
continue
}
if got := models.Text(s.FrontendURL); got != tc.want {
if got := s.SiteURL; got != tc.want {
t.Errorf("%s: frontend_url = %q, want %q", name, got, tc.want)
}
}