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:
@@ -1,48 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
)
|
||||
|
||||
// DefinitionTheme is the fixed theme key of the one sitemap definition of an
|
||||
// application (D-11). The theme column stays so the table matches PHP; the
|
||||
// import commands write this key whatever the PHP theme was.
|
||||
const DefinitionTheme = "default"
|
||||
|
||||
// Definition is a sitemap definition stored on golem15_sitemap_definitions,
|
||||
// the PHP Golem15\Sitemap\Models\Definition. Data holds the items as JSON
|
||||
// text (PHP $jsonable data).
|
||||
type Definition struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Theme *string `gorm:"column:theme"`
|
||||
Data lagoon.Jsonable[DefinitionItems] `gorm:"column:data"`
|
||||
CreatedAt *time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt *time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (Definition) TableName() string { return "golem15_sitemap_definitions" }
|
||||
|
||||
// MorphName is the PHP class string of the model.
|
||||
func (Definition) MorphName() string { return `Golem15\Sitemap\Models\Definition` }
|
||||
|
||||
// Fillable is exactly theme and data, the PHP $fillable with $guarded = ['*']
|
||||
// (MassAssignmentTest).
|
||||
func (Definition) Fillable() []string { return []string{"theme", "data"} }
|
||||
|
||||
// Rules is empty: the PHP model validated nothing. The admin form checks
|
||||
// each item row's reference against its type.
|
||||
func (Definition) Rules() map[string]string { return map[string]string{} }
|
||||
|
||||
// Items returns the stored items.
|
||||
func (d Definition) Items() []DefinitionItem { return d.Data.Get() }
|
||||
|
||||
// SetItems stores items as the definition's data.
|
||||
func (d *Definition) SetItems(items []DefinitionItem) {
|
||||
d.Data = lagoon.Jsonable[DefinitionItems]{Data: DefinitionItems(items), Valid: true}
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(Definition{})
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
# ===================================
|
||||
# Form Field Definitions
|
||||
# ===================================
|
||||
#
|
||||
# The WinterCMS form was one tab holding the SitemapItems widget. Here the
|
||||
# items are a repeater on data; each row is one DefinitionItem. The CMS page
|
||||
# field is not ported: items are URLs or registry types.
|
||||
|
||||
fields:
|
||||
data:
|
||||
label: golem15.sitemap::lang.item.definition
|
||||
type: repeater
|
||||
prompt: golem15.sitemap::lang.item.add_item
|
||||
titleFrom: url
|
||||
form:
|
||||
fields:
|
||||
type:
|
||||
label: golem15.sitemap::lang.item.type
|
||||
type: dropdown
|
||||
options: getTypeOptions
|
||||
span: left
|
||||
reference:
|
||||
label: golem15.sitemap::lang.item.reference
|
||||
type: dropdown
|
||||
dependsOn: type
|
||||
emptyOption: backend::lang.form.none
|
||||
span: right
|
||||
url:
|
||||
label: golem15.sitemap::lang.item.url
|
||||
type: text
|
||||
span: left
|
||||
urlPattern:
|
||||
label: golem15.sitemap::lang.item.url_pattern
|
||||
comment: golem15.sitemap::lang.item.url_pattern_comment
|
||||
type: text
|
||||
span: right
|
||||
priority:
|
||||
label: golem15.sitemap::lang.item.priority
|
||||
type: dropdown
|
||||
span: left
|
||||
options:
|
||||
'0.1': '0.1'
|
||||
'0.2': '0.2'
|
||||
'0.3': '0.3'
|
||||
'0.4': '0.4'
|
||||
'0.5': '0.5'
|
||||
'0.6': '0.6'
|
||||
'0.7': '0.7'
|
||||
'0.8': '0.8'
|
||||
'0.9': '0.9'
|
||||
'1.0': '1.0'
|
||||
changefreq:
|
||||
label: golem15.sitemap::lang.item.changefreq
|
||||
type: dropdown
|
||||
span: right
|
||||
options:
|
||||
always: golem15.sitemap::lang.item.always
|
||||
hourly: golem15.sitemap::lang.item.hourly
|
||||
daily: golem15.sitemap::lang.item.daily
|
||||
weekly: golem15.sitemap::lang.item.weekly
|
||||
monthly: golem15.sitemap::lang.item.monthly
|
||||
yearly: golem15.sitemap::lang.item.yearly
|
||||
never: golem15.sitemap::lang.item.never
|
||||
nesting:
|
||||
label: golem15.sitemap::lang.item.allow_nested_items
|
||||
comment: golem15.sitemap::lang.item.allow_nested_items_comment
|
||||
type: checkbox
|
||||
default: true
|
||||
span: full
|
||||
@@ -1,255 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
)
|
||||
|
||||
// TestFillable ports the PHP MassAssignmentTest (SITE-001) as behaviour:
|
||||
// Definition.Fillable is exactly theme and data, and a fill with extra keys
|
||||
// (id, timestamps, unknown keys) writes only those two.
|
||||
func TestFillable(t *testing.T) {
|
||||
if got := (models.Definition{}).Fillable(); !slices.Equal(got, []string{"theme", "data"}) {
|
||||
t.Fatalf("Fillable = %v, want [theme data]", got)
|
||||
}
|
||||
def := models.Definition{ID: 4}
|
||||
requested := map[string]any{
|
||||
"id": 99,
|
||||
"theme": "site",
|
||||
"data": `[{"type":"url","url":"/a"}]`,
|
||||
"created_at": "2020-01-01 00:00:00",
|
||||
"updated_at": "2020-01-01 00:00:00",
|
||||
"is_admin": true,
|
||||
}
|
||||
for _, production := range []bool{true, false} {
|
||||
d := def
|
||||
if err := lagoon.Fill(&d, d.Fillable(), requested, production); err != nil {
|
||||
t.Fatalf("Fill: %v", err)
|
||||
}
|
||||
if d.ID != 4 || d.CreatedAt != nil || d.UpdatedAt != nil || models.Text(d.Theme) != "site" {
|
||||
t.Fatalf("filled definition = %+v", d)
|
||||
}
|
||||
if items := d.Items(); len(items) != 1 || models.Text(items[0].URL) != "/a" {
|
||||
t.Fatalf("filled data = %+v", items)
|
||||
}
|
||||
}
|
||||
if (models.Definition{}).TableName() != "golem15_sitemap_definitions" || (models.Definition{}).MorphName() != `Golem15\Sitemap\Models\Definition` {
|
||||
t.Fatal("table or morph name changed")
|
||||
}
|
||||
if rules := (models.Definition{}).Rules(); len(rules) != 0 {
|
||||
t.Fatalf("Rules = %v", rules)
|
||||
}
|
||||
if got := (models.Settings{}).Fillable(); slices.Contains(got, "id") || len(got) != 3 {
|
||||
t.Fatalf("Settings.Fillable = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefinitionItemJSON pins the tolerant decoding of PHP-stored items
|
||||
// (strings, numbers, booleans and null for text fields, PHP truthiness for
|
||||
// nesting, unknown keys ignored, a missing type is url, a null item a
|
||||
// default url item, an object-keyed list in key order) and the PHP toArray
|
||||
// encoding (key set and order, cmsPage always null, null text kept).
|
||||
func TestDefinitionItemJSON(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{`{"type":"url","url":"/a","priority":"0.5","changefreq":"daily","nesting":"0","reference":null,"urlPattern":null,"cmsPage":"x","code":"c","exists":false,"fillable":["a"]}`,
|
||||
`type=url url=/a ref=<nil> pattern=<nil> nesting=false priority=0.5 changefreq=daily`},
|
||||
{`{"url":"/b"}`, `type=url url=/b ref=<nil> pattern=<nil> nesting=false priority=<nil> changefreq=<nil>`},
|
||||
{`{"type":null}`, `type= url=<nil> ref=<nil> pattern=<nil> nesting=false priority=<nil> changefreq=<nil>`},
|
||||
{`{"type":"journal-post","reference":7,"priority":0.5,"changefreq":true,"nesting":1}`,
|
||||
`type=journal-post url=<nil> ref=7 pattern=<nil> nesting=true priority=0.5 changefreq=1`},
|
||||
{`{"type":"x","priority":1.0,"reference":12.50,"changefreq":false,"nesting":"1"}`,
|
||||
`type=x url=<nil> ref=12.5 pattern=<nil> nesting=true priority=1 changefreq=`},
|
||||
{`{"type":"x","nesting":"false"}`, `type=x url=<nil> ref=<nil> pattern=<nil> nesting=true priority=<nil> changefreq=<nil>`},
|
||||
{`{"type":"x","nesting":""}`, `type=x url=<nil> ref=<nil> pattern=<nil> nesting=false priority=<nil> changefreq=<nil>`},
|
||||
{`{"type":"x","nesting":null,"urlPattern":"/p/:slug"}`, `type=x url=<nil> ref=<nil> pattern=/p/:slug nesting=false priority=<nil> changefreq=<nil>`},
|
||||
{`{"type":"x","reference":12345678901234}`, `type=x url=<nil> ref=12345678901234 pattern=<nil> nesting=false priority=<nil> changefreq=<nil>`},
|
||||
{`null`, `type=url url=<nil> ref=<nil> pattern=<nil> nesting=false priority=<nil> changefreq=<nil>`},
|
||||
} {
|
||||
var item models.DefinitionItem
|
||||
if err := json.Unmarshal([]byte(tc.raw), &item); err != nil {
|
||||
t.Fatalf("%s: %v", tc.raw, err)
|
||||
}
|
||||
if got := describe(item); got != tc.want {
|
||||
t.Errorf("%s\n got %s\nwant %s", tc.raw, got, tc.want)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{`{"type":["x"]}`, `{"url":{"a":1}}`, `{"nesting":[1]}`, `[1]`, `"x"`} {
|
||||
var item models.DefinitionItem
|
||||
if err := json.Unmarshal([]byte(raw), &item); err == nil {
|
||||
t.Errorf("%s decoded without an error", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// A list, an object keyed like a PHP array with gaps, null and empty.
|
||||
var list models.DefinitionItems
|
||||
if err := json.Unmarshal([]byte(`{"3":{"url":"/c"},"1":{"url":"/a"},"x":{"url":"/x"}}`), &list); err != nil || len(list) != 3 ||
|
||||
models.Text(list[0].URL) != "/c" || models.Text(list[1].URL) != "/a" || models.Text(list[2].URL) != "/x" {
|
||||
t.Fatalf("object list = %+v %v", list, err)
|
||||
}
|
||||
for raw, n := range map[string]int{`[]`: 0, `null`: 0, `[{"url":"/a"},null]`: 2} {
|
||||
var l models.DefinitionItems
|
||||
if err := json.Unmarshal([]byte(raw), &l); err != nil || len(l) != n {
|
||||
t.Fatalf("%s = %+v %v", raw, l, err)
|
||||
}
|
||||
}
|
||||
var bad models.DefinitionItems
|
||||
if err := json.Unmarshal([]byte(`"items"`), &bad); err == nil {
|
||||
t.Fatal("a string decoded as an item list")
|
||||
}
|
||||
|
||||
// Encoding: the PHP toArray key set and order.
|
||||
pattern := "/p/:slug"
|
||||
item := models.DefinitionItem{Type: "journal-post", Reference: str("7"), URLPattern: &pattern, Nesting: true, Priority: str("0.5")}
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := `{"changefreq":null,"priority":"0.5","nesting":true,"type":"journal-post","url":null,"reference":"7","cmsPage":null,"urlPattern":"/p/:slug"}`; string(raw) != want {
|
||||
t.Fatalf("Marshal = %s\nwant %s", raw, want)
|
||||
}
|
||||
if raw, _ := json.Marshal(models.DefinitionItems(nil)); string(raw) != "[]" {
|
||||
t.Fatalf("nil list = %s", raw)
|
||||
}
|
||||
var back models.DefinitionItem
|
||||
if err := json.Unmarshal(raw, &back); err != nil || describe(back) != describe(item) {
|
||||
t.Fatalf("round trip = %s, %v", describe(back), err)
|
||||
}
|
||||
if models.Text(nil) != "" || models.Text(str("x")) != "x" {
|
||||
t.Fatal("Text")
|
||||
}
|
||||
if s, err := models.DecodeText(json.RawMessage(`1e3`)); err != nil || *s != "1000" {
|
||||
t.Fatalf("DecodeText(1e3) = %v %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func str(s string) *string { return &s }
|
||||
|
||||
func describe(d models.DefinitionItem) string {
|
||||
p := func(s *string) string {
|
||||
if s == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return *s
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprintf("type=%s url=%s ref=%s pattern=%s nesting=%v priority=%s changefreq=%s",
|
||||
d.Type, p(d.URL), p(d.Reference), p(d.URLPattern), d.Nesting, p(d.Priority), p(d.Changefreq)))
|
||||
}
|
||||
177
models/legacy.go
Normal file
177
models/legacy.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Text returns *p, "" for nil.
|
||||
func Text(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// LegacyItemURL is the type of a Phase 17 (and WinterCMS) sitemap
|
||||
// definition item that is an explicit address.
|
||||
const LegacyItemURL = "url"
|
||||
|
||||
// LegacyItem is one item of a Phase 17 or WinterCMS sitemap definition, as
|
||||
// golem15_sitemap_definitions.data and the WinterCMS export store it. Only
|
||||
// the fields the per-type format still reads are kept: the type (a missing
|
||||
// type is url, as in PHP), the address of a url item and the reference of a
|
||||
// registry item.
|
||||
type LegacyItem struct {
|
||||
Type string
|
||||
URL string
|
||||
Reference string
|
||||
CMSPage string
|
||||
}
|
||||
|
||||
// LegacyEntries 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).
|
||||
// ok is false for any other value.
|
||||
func LegacyEntries(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] = strconv.Itoa(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
|
||||
}
|
||||
|
||||
// ParseLegacyItem decodes one stored item tolerantly: text fields may be
|
||||
// strings, numbers, booleans or null; a missing type is url; unknown keys
|
||||
// are ignored.
|
||||
func ParseLegacyItem(raw json.RawMessage) (LegacyItem, error) {
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &fields); err != nil {
|
||||
return LegacyItem{}, fmt.Errorf("an item must be an object: %w", err)
|
||||
}
|
||||
item := LegacyItem{Type: LegacyItemURL}
|
||||
for key, dest := range map[string]*string{"type": &item.Type, "url": &item.URL, "reference": &item.Reference, "cmsPage": &item.CMSPage} {
|
||||
v, ok := fields[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
text, err := DecodeText(v)
|
||||
if err != nil {
|
||||
return LegacyItem{}, fmt.Errorf("%s %w", key, err)
|
||||
}
|
||||
*dest = strings.TrimSpace(Text(text))
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// ParseLegacyItems decodes the data of a definition: a JSON array or a PHP
|
||||
// keyed object of items, itself possibly stored as a JSON string. Empty or
|
||||
// null data has no items.
|
||||
func ParseLegacyItems(data []byte) ([]LegacyItem, []string, error) {
|
||||
data = LegacyJSON(data)
|
||||
if len(data) == 0 || bytes.Equal(data, []byte("null")) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
raws, labels, ok := LegacyEntries(data)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("the definition data is not a list of items")
|
||||
}
|
||||
items := make([]LegacyItem, 0, len(raws))
|
||||
for i, raw := range raws {
|
||||
item, err := ParseLegacyItem(raw)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("definition item %s: %w", labels[i], err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, labels, nil
|
||||
}
|
||||
|
||||
// LegacyJSON is a JSON value that may be held as text (a TEXT column
|
||||
// selected as it is) or as JSON: a string is decoded once more.
|
||||
func LegacyJSON(raw []byte) []byte {
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) > 0 && raw[0] == '"' {
|
||||
var text string
|
||||
if json.Unmarshal(raw, &text) == nil {
|
||||
return []byte(strings.TrimSpace(text))
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// DecodeText decodes a stored text value with PHP's string conversion:
|
||||
// null is nil, a number its PHP text, true "1" and false "".
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -12,50 +12,23 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
)
|
||||
|
||||
// errFrontendURL is the phrase key of the frontend_url refusal.
|
||||
// errFrontendURL is the phrase key of the site address refusal (the
|
||||
// Phase 17 frontend_url phrase, unchanged).
|
||||
const errFrontendURL = "golem15.sitemap::lang.errors.frontend_url"
|
||||
|
||||
// The settings keys of the five priority tiers, in the order PHP checked
|
||||
// them. A tier rule names one of them.
|
||||
const (
|
||||
PriorityHomepage = "priority_homepage"
|
||||
PriorityServices = "priority_services"
|
||||
PriorityBlog = "priority_blog"
|
||||
PriorityCategories = "priority_categories"
|
||||
PriorityLegal = "priority_legal"
|
||||
)
|
||||
|
||||
// PriorityDefaults are the PHP defaults of the priority settings.
|
||||
var PriorityDefaults = map[string]string{
|
||||
PriorityHomepage: "1.0",
|
||||
PriorityServices: "0.8",
|
||||
PriorityBlog: "0.6",
|
||||
PriorityCategories: "0.4",
|
||||
PriorityLegal: "0.2",
|
||||
}
|
||||
|
||||
// Settings is the sitemap settings singleton (row id 1) on
|
||||
// golem15_sitemap_settings, the PHP Golem15\Sitemap\Models\Settings that
|
||||
// WinterCMS kept in system_settings under golem15_sitemap_settings.
|
||||
// FrontendURL replaces the backend URL in the generated XML;
|
||||
// FrontendDefaultLocale is the locale whose URLs carry no prefix;
|
||||
// UpdatesManifestPath names a JSON file of URL path to last-modified date;
|
||||
// EnablePriorityTiers turns the tier priorities on, and the priority fields
|
||||
// hold each tier's value as text. A null priority reads as its PHP default.
|
||||
// The PHP settings form validated nothing; the port checks FrontendURL in
|
||||
// BeforeValidate.
|
||||
// golem15_sitemap_settings (UI-SPEC C26). SiteURL is the public site
|
||||
// address every <loc> starts with (empty: golem15.sitemap.site_url, then
|
||||
// app.url), FileSize the addresses per file, DefaultLocale the site's
|
||||
// default locale (the unprefixed one, and the target of x-default) and
|
||||
// ExtraURLs the additional addresses. RebuildPendingUntil is the debounce
|
||||
// time of the next queued rebuild, NumberedSize the file size the files are
|
||||
// numbered for, BuiltAt the end of the last rebuild and RebuildRequestedAt
|
||||
// the last rebuild asked for from the admin. The Phase 17 columns
|
||||
// (frontend_url, priorities, tiers, the update manifest) stay in the table
|
||||
// and are no longer read (D-32).
|
||||
type Settings struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
FrontendURL *string `gorm:"column:frontend_url"`
|
||||
FrontendDefaultLocale *string `gorm:"column:frontend_default_locale"`
|
||||
UpdatesManifestPath *string `gorm:"column:updates_manifest_path"`
|
||||
EnablePriorityTiers bool `gorm:"column:enable_priority_tiers"`
|
||||
PriorityHomepage *string `gorm:"column:priority_homepage"`
|
||||
PriorityServices *string `gorm:"column:priority_services"`
|
||||
PriorityBlog *string `gorm:"column:priority_blog"`
|
||||
PriorityCategories *string `gorm:"column:priority_categories"`
|
||||
PriorityLegal *string `gorm:"column:priority_legal"`
|
||||
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
SiteURL *string `gorm:"column:site_url"`
|
||||
FileSize int `gorm:"column:file_size"`
|
||||
DefaultLocale *string `gorm:"column:default_locale"`
|
||||
@@ -100,8 +73,7 @@ func (Settings) Fillable() []string {
|
||||
return []string{"site_url", "file_size", "extra_urls"}
|
||||
}
|
||||
|
||||
// Rules is empty: the PHP settings form validated nothing. The one check of
|
||||
// the port, on frontend_url, is in BeforeValidate.
|
||||
// Rules is empty: the settings controller runs Validate before a save.
|
||||
func (Settings) Rules() map[string]string { return map[string]string{} }
|
||||
|
||||
// The phrase keys of the other settings refusals.
|
||||
@@ -127,8 +99,7 @@ func (s Settings) Validate() error {
|
||||
}
|
||||
|
||||
// Problems lists the settings' refusals by field (UI-SPEC C26): a site
|
||||
// address that AllowedFrontendURL refuses (site_url and the Phase 17
|
||||
// frontend_url), a file size outside 1..MaxFileSize and an additional
|
||||
// address that AllowedFrontendURL refuses, a file size outside 1..MaxFileSize and an additional
|
||||
// address that is not a path or an http(s) URL (extra_urls.<i>.url) or
|
||||
// whose lastmod is not a date (extra_urls.<i>.lastmod).
|
||||
func (s Settings) Problems() map[string][]string {
|
||||
@@ -136,9 +107,6 @@ func (s Settings) Problems() map[string][]string {
|
||||
if !AllowedFrontendURL(Text(s.SiteURL)) {
|
||||
out["site_url"] = []string{errFrontendURL}
|
||||
}
|
||||
if !AllowedFrontendURL(Text(s.FrontendURL)) {
|
||||
out["frontend_url"] = []string{errFrontendURL}
|
||||
}
|
||||
if s.FileSize < 1 || s.FileSize > MaxFileSize {
|
||||
out["file_size"] = []string{errFileSize}
|
||||
}
|
||||
@@ -153,14 +121,12 @@ func (s Settings) Problems() map[string][]string {
|
||||
return out
|
||||
}
|
||||
|
||||
// AllowedFrontendURL reports whether u may be the frontend_url setting: the
|
||||
// empty string, or an absolute http or https URL with a host and without
|
||||
// whitespace, control characters, a double quote, <, > or &. The PHP plugin
|
||||
// stored any text. The value is written into every loc, every hreflang link
|
||||
// and the stylesheet instruction of the sitemap, and sitemap.xsl renders
|
||||
// each loc as a link on the public origin, so a value outside this form
|
||||
// (a javascript: URL, or text that closes the attribute it is written into)
|
||||
// would reach the readers of /sitemap.xml (T-17-15).
|
||||
// AllowedFrontendURL reports whether u may be the site address: the empty
|
||||
// string, or an absolute http or https URL with a host and without
|
||||
// whitespace, control characters, a double quote, <, > or &. The value
|
||||
// starts every <loc> and hreflang link of the sitemaps, so a value outside
|
||||
// this form (a javascript: URL, or text that closes the attribute it is
|
||||
// written into) would reach their readers (T-17-15, T-33-12).
|
||||
func AllowedFrontendURL(u string) bool {
|
||||
if u == "" {
|
||||
return true
|
||||
@@ -183,32 +149,9 @@ func AllowedFrontendURL(u string) bool {
|
||||
return parsed.Opaque == "" && parsed.Hostname() != ""
|
||||
}
|
||||
|
||||
// DefaultSettings is the settings PHP read when nothing was saved: tiers on
|
||||
// with the default priorities.
|
||||
// DefaultSettings is the settings read when no row is stored.
|
||||
func DefaultSettings() Settings {
|
||||
return Settings{ID: 1, EnablePriorityTiers: true, FileSize: DefaultFileSize}
|
||||
}
|
||||
|
||||
// Priority returns the value of a priority setting: the stored text, or the
|
||||
// PHP default when it is null.
|
||||
func (s Settings) Priority(key string) string {
|
||||
var p *string
|
||||
switch key {
|
||||
case PriorityHomepage:
|
||||
p = s.PriorityHomepage
|
||||
case PriorityServices:
|
||||
p = s.PriorityServices
|
||||
case PriorityBlog:
|
||||
p = s.PriorityBlog
|
||||
case PriorityCategories:
|
||||
p = s.PriorityCategories
|
||||
case PriorityLegal:
|
||||
p = s.PriorityLegal
|
||||
}
|
||||
if p == nil {
|
||||
return PriorityDefaults[key]
|
||||
}
|
||||
return *p
|
||||
return Settings{ID: 1, FileSize: DefaultFileSize}
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
Reference in New Issue
Block a user