Files
sm-sitemap-plugin/models/definition_item.go
Jakub Zych 4a82240429 feat(17-03): full sitemap generation, settings, hooks and import commands
- generator in Definition::addItemToSet order: 50000 URL cap, lastmod from
  the record or the updates manifest (regular file, 5 MiB cap, fail safe),
  BeforeAddItem (skip) and AddItem (extra XML) festival events, tier or item
  priority, per-alternate expansion of list type children
- priority tiers from golem15.sitemap.priority_tiers, compiled at boot,
  defaults equal to the PHP getPriorityForUrl rules
- frontend_url rewrite of loc, xhtml:link href and the stylesheet href;
  GET /sitemap.xsl serves the PHP stylesheet byte for byte
- golem15_sitemap_settings singleton and Settings model
- sitemap:import (PHP ImportCommand) and sitemap:import-winter with the
  read-only Winter export SQL
- urlto and full goldens generated from PHP; TestSitemapImportSmoke
2026-10-12 17:07:39 +02:00

256 lines
6.4 KiB
Go

package models
import (
"bytes"
"encoding/json"
"fmt"
"strconv"
"strings"
)
// ItemTypeURL is the built-in item type of an explicit URL. Every other
// item type is a menu item type of the framework's registry (signpost).
const ItemTypeURL = "url"
// DefinitionItem is one sitemap definition item, the PHP
// Golem15\Sitemap\Classes\DefinitionItem. Type is "url" or a registry menu
// item type key; URL is the address of a url item; Reference is the record a
// registry item points at; URLPattern overrides the type's URL pattern;
// Nesting asks a registry item for its sub-items; Priority and Changefreq
// are written to the sitemap as they are stored.
//
// Text fields are pointers so a stored JSON null stays null on the next
// save. Decoding is tolerant of what PHP stored: numbers and booleans are
// accepted for text fields, strings and numbers for Nesting, and unknown keys
// (cmsPage, code, exists, fillable and any other) are ignored. Encoding
// writes the PHP toArray key set: changefreq, priority, nesting, type, url,
// reference, cmsPage (always null, CMS pages are not supported) and
// urlPattern.
type DefinitionItem struct {
Type string
URL *string
Reference *string
URLPattern *string
Nesting bool
Priority *string
Changefreq *string
}
// Text returns the value of a text field, "" for null.
func Text(p *string) string {
if p == nil {
return ""
}
return *p
}
// UnmarshalJSON decodes one stored item tolerantly. A missing type is the
// PHP default "url"; a null type is "".
func (d *DefinitionItem) UnmarshalJSON(raw []byte) error {
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
// PHP initFromArray kept a default item for a null entry.
*d = DefinitionItem{Type: ItemTypeURL}
return nil
}
var fields map[string]json.RawMessage
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
if err := dec.Decode(&fields); err != nil {
return fmt.Errorf("sitemap definition item: %w", err)
}
item := DefinitionItem{Type: ItemTypeURL}
if v, ok := fields["type"]; ok {
s, err := DecodeText(v)
if err != nil {
return fmt.Errorf("sitemap definition item type: %w", err)
}
item.Type = Text(s)
}
for key, dest := range map[string]**string{
"url": &item.URL,
"reference": &item.Reference,
"urlPattern": &item.URLPattern,
"priority": &item.Priority,
"changefreq": &item.Changefreq,
} {
v, ok := fields[key]
if !ok {
continue
}
s, err := DecodeText(v)
if err != nil {
return fmt.Errorf("sitemap definition item %s: %w", key, err)
}
*dest = s
}
if v, ok := fields["nesting"]; ok {
b, err := decodeTruthy(v)
if err != nil {
return fmt.Errorf("sitemap definition item nesting: %w", err)
}
item.Nesting = b
}
*d = item
return nil
}
// MarshalJSON writes the PHP toArray key set in its order.
func (d DefinitionItem) MarshalJSON() ([]byte, error) {
var b bytes.Buffer
b.WriteByte('{')
write := func(key string, v any, last bool) error {
kb, _ := json.Marshal(key)
b.Write(kb)
b.WriteByte(':')
vb, err := json.Marshal(v)
if err != nil {
return err
}
b.Write(vb)
if !last {
b.WriteByte(',')
}
return nil
}
for _, f := range []struct {
key string
v any
}{
{"changefreq", d.Changefreq},
{"priority", d.Priority},
{"nesting", d.Nesting},
{"type", d.Type},
{"url", d.URL},
{"reference", d.Reference},
{"cmsPage", nil},
} {
if err := write(f.key, f.v, false); err != nil {
return nil, err
}
}
if err := write("urlPattern", d.URLPattern, true); err != nil {
return nil, err
}
b.WriteByte('}')
return b.Bytes(), nil
}
// DefinitionItems is the stored item list of a definition. It decodes a
// JSON array, and a JSON object (a PHP array whose keys were not sequential)
// in key order.
type DefinitionItems []DefinitionItem
// UnmarshalJSON accepts a JSON array or object of items; null is no items.
func (l *DefinitionItems) UnmarshalJSON(raw []byte) error {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
*l = nil
return nil
}
if raw[0] == '[' {
var items []DefinitionItem
if err := json.Unmarshal(raw, &items); err != nil {
return err
}
*l = items
return nil
}
if raw[0] != '{' {
return fmt.Errorf("sitemap definition data must be a list of items")
}
dec := json.NewDecoder(bytes.NewReader(raw))
if _, err := dec.Token(); err != nil {
return err
}
var items []DefinitionItem
for dec.More() {
if _, err := dec.Token(); err != nil {
return err
}
var item DefinitionItem
if err := dec.Decode(&item); err != nil {
return err
}
items = append(items, item)
}
*l = items
return nil
}
// MarshalJSON writes a JSON array; no items is [].
func (l DefinitionItems) MarshalJSON() ([]byte, error) {
if l == nil {
return []byte("[]"), nil
}
return json.Marshal([]DefinitionItem(l))
}
// DecodeText decodes a stored text value: a string as it is, a number in
// its PHP string form, a boolean as PHP casts it ("1" or ""), null as nil.
func DecodeText(raw json.RawMessage) (*string, error) {
var v any
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
if err := dec.Decode(&v); err != nil {
return nil, err
}
var s string
switch t := v.(type) {
case nil:
return nil, nil
case string:
s = t
case json.Number:
s = phpNumberString(t)
case bool:
if t {
s = "1"
}
default:
return nil, fmt.Errorf("must be text, got %T", v)
}
return &s, nil
}
// decodeTruthy decodes a stored flag with PHP truthiness: false, 0, "",
// "0" and null are false.
func decodeTruthy(raw json.RawMessage) (bool, error) {
var v any
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
if err := dec.Decode(&v); err != nil {
return false, err
}
switch t := v.(type) {
case nil:
return false, nil
case bool:
return t, nil
case string:
return t != "" && t != "0", nil
case json.Number:
f, err := t.Float64()
if err != nil {
return false, err
}
return f != 0, nil
}
return false, fmt.Errorf("must be a flag, got %T", v)
}
// phpNumberString is PHP's string cast of a json_decode'd number: an
// integer as written, a float in its shortest round-trip form (1.0 is "1").
func phpNumberString(n json.Number) string {
text := n.String()
if !strings.ContainsAny(text, ".eE") {
if i, err := strconv.ParseInt(text, 10, 64); err == nil {
return strconv.FormatInt(i, 10)
}
}
f, err := n.Float64()
if err != nil {
return text
}
return strconv.FormatFloat(f, 'f', -1, 64)
}