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) }