diff --git a/atom/parser.go b/atom/parser.go index bd9cd640..c9fad0b2 100644 --- a/atom/parser.go +++ b/atom/parser.go @@ -109,7 +109,9 @@ func (ap *Parser) parseRoot(p *xpp.Parser) (*Feed, error) { atom.Entries = append(atom.Entries, entry) } default: - err = p.Skip() + // Not part of the spec: capture it into the extension map + // under the _custom pseudo namespace instead of dropping it. + extensions, _, err = shared.ParseCustom(extensions, p) } return err }) @@ -215,7 +217,9 @@ func (ap *Parser) parseEntry(p *xpp.Parser) (*Entry, error) { case "content": entry.Content, err = ap.parseContent(p) default: - err = p.Skip() + // Not part of the spec: capture it into the extension map + // under the _custom pseudo namespace instead of dropping it. + extensions, _, err = shared.ParseCustom(extensions, p) } return err }) @@ -310,7 +314,9 @@ func (ap *Parser) parseSource(p *xpp.Parser) (*Source, error) { categories = append(categories, cat) } default: - err = p.Skip() + // Not part of the spec: capture it into the extension map + // under the _custom pseudo namespace instead of dropping it. + extensions, _, err = shared.ParseCustom(extensions, p) } return err }) diff --git a/extension_helpers.go b/extension_helpers.go new file mode 100644 index 00000000..f911d98c --- /dev/null +++ b/extension_helpers.go @@ -0,0 +1,71 @@ +package gofeed + +import ( + ext "github.com/mmcdole/gofeed/extensions" +) + +// CustomNamespace is the pseudo namespace under which non-namespaced unknown +// elements are filed in Extensions, preserving nesting, attributes and +// repetition. +const CustomNamespace = "_custom" + +// GetExtension returns the extension elements for the given namespace prefix +// and element name, or nil when absent. Non-namespaced custom elements live +// under CustomNamespace. +func (f *Feed) GetExtension(namespace, element string) []ext.Extension { + return extensionsFor(f.Extensions, namespace, element) +} + +// GetExtension returns the extension elements for the given namespace prefix +// and element name, or nil when absent. Non-namespaced custom elements live +// under CustomNamespace. +func (i *Item) GetExtension(namespace, element string) []ext.Extension { + return extensionsFor(i.Extensions, namespace, element) +} + +// GetExtensionValue returns the text of the first matching extension +// element, or "" when absent. +func (f *Feed) GetExtensionValue(namespace, element string) string { + return firstExtensionValue(f.Extensions, namespace, element) +} + +// GetExtensionValue returns the text of the first matching extension +// element, or "" when absent. +func (i *Item) GetExtensionValue(namespace, element string) string { + return firstExtensionValue(i.Extensions, namespace, element) +} + +// GetCustomValue returns the text of the first non-namespaced custom element +// with the given name, or "" when absent. Unlike the flat Custom map, the +// backing extension tree also holds nested elements, attributes and repeated +// values; use GetExtension(CustomNamespace, element) for those. +func (f *Feed) GetCustomValue(element string) string { + return firstExtensionValue(f.Extensions, CustomNamespace, element) +} + +// GetCustomValue returns the text of the first non-namespaced custom element +// with the given name, or "" when absent. Unlike the flat Custom map, the +// backing extension tree also holds nested elements, attributes and repeated +// values; use GetExtension(CustomNamespace, element) for those. +func (i *Item) GetCustomValue(element string) string { + return firstExtensionValue(i.Extensions, CustomNamespace, element) +} + +func extensionsFor(exts ext.Extensions, namespace, element string) []ext.Extension { + if exts == nil { + return nil + } + nsMap, ok := exts[namespace] + if !ok { + return nil + } + return nsMap[element] +} + +func firstExtensionValue(exts ext.Extensions, namespace, element string) string { + matches := extensionsFor(exts, namespace, element) + if len(matches) == 0 { + return "" + } + return matches[0].Value +} diff --git a/extension_helpers_test.go b/extension_helpers_test.go new file mode 100644 index 00000000..f6ad2bcf --- /dev/null +++ b/extension_helpers_test.go @@ -0,0 +1,56 @@ +package gofeed_test + +import ( + "strings" + "testing" + + "github.com/mmcdole/gofeed" + "github.com/stretchr/testify/assert" +) + +func TestExtensionHelpers(t *testing.T) { + feed := ` + + feed-1 + + Jane + Hall + plain + again + + + ` + + f, err := gofeed.NewParser().Parse(strings.NewReader(feed)) + assert.NoError(t, err) + item := f.Items[0] + + // Namespaced extensions through the same accessors. + assert.Equal(t, "Jane", item.GetExtensionValue("dc", "creator")) + assert.Len(t, item.GetExtension("dc", "creator"), 1) + + // Feed level custom element, previously dropped entirely. + assert.Equal(t, "feed-1", f.GetCustomValue("customFeedId")) + + // Item level custom values, including repetition the flat map loses. + assert.Equal(t, "plain", item.GetCustomValue("simple")) + assert.Len(t, item.GetExtension(gofeed.CustomNamespace, "simple"), 2) + + // Nested custom elements keep children and attributes in the tree. + events := item.GetExtension(gofeed.CustomNamespace, "event") + if assert.Len(t, events, 1) { + venue := events[0].Children["venue"][0] + assert.Equal(t, "Hall", venue.Value) + assert.Equal(t, "Austin", venue.Attrs["city"]) + } + + // The flat Custom map still works for childless elements (last wins), + // and nested elements no longer corrupt it. + assert.Equal(t, "again", item.Custom["simple"]) + _, hasEvent := item.Custom["event"] + assert.False(t, hasEvent) + + // Absent lookups are empty, not panics. + assert.Nil(t, f.GetExtension("nope", "x")) + assert.Equal(t, "", item.GetCustomValue("absent")) +} diff --git a/feed.go b/feed.go index c63c8d8b..f5530b03 100644 --- a/feed.go +++ b/feed.go @@ -32,10 +32,13 @@ type Feed struct { DublinCoreExt *ext.DublinCoreExtension `json:"dcExt,omitempty"` ITunesExt *ext.ITunesFeedExtension `json:"itunesExt,omitempty"` Extensions ext.Extensions `json:"extensions,omitempty"` - Custom map[string]string `json:"custom,omitempty"` - Items []*Item `json:"items"` - FeedType string `json:"feedType"` - FeedVersion string `json:"feedVersion"` + // Custom is a flat view of non-namespaced unknown elements. Deprecated: + // use Extensions[CustomNamespace] (or GetCustomValue), which keeps + // nesting, attributes and repeated elements. + Custom map[string]string `json:"custom,omitempty"` + Items []*Item `json:"items"` + FeedType string `json:"feedType"` + FeedVersion string `json:"feedVersion"` // originalFeed holds the source *rss.Feed, *atom.Feed, or *json.Feed when // the parser was configured with KeepOriginalFeed. It is unexported (and so @@ -81,7 +84,11 @@ type Item struct { DublinCoreExt *ext.DublinCoreExtension `json:"dcExt,omitempty"` ITunesExt *ext.ITunesItemExtension `json:"itunesExt,omitempty"` Extensions ext.Extensions `json:"extensions,omitempty"` - Custom map[string]string `json:"custom,omitempty"` + // Custom is a flat view of childless non-namespaced unknown elements, + // last value wins. Deprecated: use Extensions[CustomNamespace] (or + // GetCustomValue), which keeps nesting, attributes and repeated + // elements. + Custom map[string]string `json:"custom,omitempty"` } // Person is an individual specified in a feed diff --git a/internal/shared/extparser.go b/internal/shared/extparser.go index 3005c3fc..b65dfca9 100644 --- a/internal/shared/extparser.go +++ b/internal/shared/extparser.go @@ -175,3 +175,25 @@ var canonicalNamespaces = map[string]string{ "http://www.w3.org/XML/1998/namespace": "xml", "http://podlove.org/simple-chapters": "psc", } + +// CustomPrefix is the pseudo namespace prefix that files non-namespaced +// unknown elements in the extension map. +const CustomPrefix = "_custom" + +// ParseCustom parses the current element as an arbitrary non-namespaced +// element and files it in the extension map under CustomPrefix, preserving +// nesting, attributes and repetition the same way namespaced extensions are +// kept. The parsed element is also returned so callers can maintain +// compatibility shims from it. +func ParseCustom(fe ext.Extensions, p *xpp.Parser) (ext.Extensions, ext.Extension, error) { + result, err := parseExtensionElement(p) + if err != nil { + return nil, ext.Extension{}, err + } + + if _, ok := fe[CustomPrefix]; !ok { + fe[CustomPrefix] = map[string][]ext.Extension{} + } + fe[CustomPrefix][result.Name] = append(fe[CustomPrefix][result.Name], result) + return fe, result, nil +} diff --git a/internal/shared/extparser_test.go b/internal/shared/extparser_test.go index c8ce0534..2a2a0652 100644 --- a/internal/shared/extparser_test.go +++ b/internal/shared/extparser_test.go @@ -156,3 +156,67 @@ func TestNewXMLParserLeniencyAndCharset(t *testing.T) { t.Errorf("text = %q, want café", text) } } + +func TestParseCustom(t *testing.T) { + doc := ` + + + Hall + plain + again + + + ` + + p := NewXMLParser(strings.NewReader(doc)) + extensions := ext.Extensions{} + var parsed []ext.Extension + for { + tok, err := p.NextToken() + if err != nil { + t.Fatal(err) + } + if tok == xpp.EndDocument { + break + } + if tok == xpp.StartTag { + switch p.Name() { + case "event", "simple": + var e ext.Extension + var err error + extensions, e, err = ParseCustom(extensions, p) + if err != nil { + t.Fatal(err) + } + parsed = append(parsed, e) + } + } + } + + if len(parsed) != 3 { + t.Fatalf("parsed %d elements, want 3", len(parsed)) + } + + // Nesting and attributes survive in the tree. + events := extensions[CustomPrefix]["event"] + if len(events) != 1 { + t.Fatalf("event entries = %d, want 1", len(events)) + } + venue := events[0].Children["venue"][0] + if venue.Value != "Hall" || venue.Attrs["city"] != "Austin" { + t.Fatalf("venue = %+v", venue) + } + + // Repetition appends rather than overwrites. + if n := len(extensions[CustomPrefix]["simple"]); n != 2 { + t.Fatalf("simple entries = %d, want 2", n) + } + + // The returned element mirrors what was filed. + if parsed[0].Name != "event" || len(parsed[0].Children) != 1 { + t.Fatalf("returned element = %+v", parsed[0]) + } + if parsed[1].Value != "plain" || len(parsed[1].Children) != 0 { + t.Fatalf("returned simple = %+v", parsed[1]) + } +} diff --git a/rss/parser.go b/rss/parser.go index 0fb7e9d8..78c518a8 100644 --- a/rss/parser.go +++ b/rss/parser.go @@ -178,10 +178,14 @@ func (rp *Parser) parseChannel(p *xpp.Parser) (rss *Feed, err error) { rss.Image, err = rp.parseImage(p) case "textinput": rss.TextInput, err = rp.parseTextInput(p) - default: - // Skip element as it isn't an extension and not part of - // the spec. + case "items": + // The RSS 1.0 rdf:Seq is a structural list of item + // references, not content; skip it rather than capture it. err = p.Skip() + default: + // Not part of the spec: capture it into the extension map + // under the _custom pseudo namespace instead of dropping it. + extensions, _, err = shared.ParseCustom(extensions, p) } return err }) @@ -227,6 +231,26 @@ func (rp *Parser) parseItem(p *xpp.Parser) (item *Item, err error) { enclosures := []*Enclosure{} links := []string{} + // captureCustom files an unrecognized child in the extension map under + // the _custom pseudo namespace, preserving nesting and attributes, and + // keeps the flat Custom map populated for elements without children so + // existing Custom reads are unchanged. + captureCustom := func(p *xpp.Parser) error { + var e ext.Extension + var err error + extensions, e, err = shared.ParseCustom(extensions, p) + if err != nil { + return err + } + if len(e.Children) == 0 { + if item.Custom == nil { + item.Custom = make(map[string]string) + } + item.Custom[e.Name] = shared.DecodeEntities(e.Value) + } + return nil + } + err = shared.ForEachChild(p, func(name string) error { if shared.IsExtension(p) { extensions, err = shared.ParseExtension(extensions, p) @@ -242,7 +266,7 @@ func (rp *Parser) parseItem(p *xpp.Parser) (item *Item, err error) { if shared.PrefixForNamespace(p.Space(), p) == "content" { item.Content, err = shared.ParseText(p) } else { - err = rp.parseItemCustom(p, item) + err = captureCustom(p) } case "link": if item.Link, err = rp.parseLink(p); err == nil { @@ -275,7 +299,7 @@ func (rp *Parser) parseItem(p *xpp.Parser) (item *Item, err error) { categories = append(categories, cat) } default: - err = rp.parseItemCustom(p, item) + err = captureCustom(p) } return err }) @@ -314,21 +338,6 @@ func (rp *Parser) parseItem(p *xpp.Parser) (item *Item, err error) { return item, nil } -// parseItemCustom stores an unrecognized item child in the Custom map, -// keyed by its original-case name. Duplicate names keep the last value. -func (rp *Parser) parseItemCustom(p *xpp.Parser, item *Item) error { - key := p.Name() - result, err := shared.ParseText(p) - if err != nil { - return err - } - if item.Custom == nil { - item.Custom = make(map[string]string) - } - item.Custom[key] = result - return nil -} - func (rp *Parser) parseLink(p *xpp.Parser) (url string, err error) { base := p.BaseURL() href := p.Attribute("href") diff --git a/testdata/parser/atom/atom10_feed_custom_elements.json b/testdata/parser/atom/atom10_feed_custom_elements.json new file mode 100644 index 00000000..61f5374f --- /dev/null +++ b/testdata/parser/atom/atom10_feed_custom_elements.json @@ -0,0 +1,40 @@ +{ + "title": "Test Feed", + "id": "urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6", + "updated": "2003-12-13T18:30:02Z", + "updatedParsed": "2003-12-13T18:30:02Z", + "entries": [], + "extensions": { + "_custom": { + "customFeedId": [ + { + "name": "customFeedId", + "value": "feed-123", + "attrs": {}, + "children": {} + } + ], + "customMetadata": [ + { + "name": "customMetadata", + "value": "Some metadata", + "attrs": { + "type": "internal" + }, + "children": {} + } + ], + "updateInterval": [ + { + "name": "updateInterval", + "value": "30", + "attrs": { + "units": "minutes" + }, + "children": {} + } + ] + } + }, + "version": "1.0" +} diff --git a/testdata/parser/atom/atom10_feed_custom_elements.xml b/testdata/parser/atom/atom10_feed_custom_elements.xml new file mode 100644 index 00000000..93606959 --- /dev/null +++ b/testdata/parser/atom/atom10_feed_custom_elements.xml @@ -0,0 +1,12 @@ + + + + Test Feed + urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6 + 2003-12-13T18:30:02Z + feed-123 + 30 + Some metadata + \ No newline at end of file diff --git a/testdata/parser/atom/atom10_feed_entry_custom_elements.json b/testdata/parser/atom/atom10_feed_entry_custom_elements.json new file mode 100644 index 00000000..3112f1d1 --- /dev/null +++ b/testdata/parser/atom/atom10_feed_entry_custom_elements.json @@ -0,0 +1,45 @@ +{ + "title": "Test Feed", + "id": "urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6", + "updated": "2003-12-13T18:30:02Z", + "updatedParsed": "2003-12-13T18:30:02Z", + "entries": [ + { + "title": "Test Entry", + "id": "urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a", + "updated": "2003-12-13T18:30:02Z", + "updatedParsed": "2003-12-13T18:30:02Z", + "extensions": { + "_custom": { + "customId": [ + { + "name": "customId", + "value": "entry-123", + "attrs": {}, + "children": {} + } + ], + "customTag": [ + { + "name": "customTag", + "value": "Custom Value", + "attrs": {}, + "children": {} + } + ], + "priority": [ + { + "name": "priority", + "value": "1", + "attrs": { + "level": "high" + }, + "children": {} + } + ] + } + } + } + ], + "version": "1.0" +} diff --git a/testdata/parser/atom/atom10_feed_entry_custom_elements.xml b/testdata/parser/atom/atom10_feed_entry_custom_elements.xml new file mode 100644 index 00000000..41fe6d81 --- /dev/null +++ b/testdata/parser/atom/atom10_feed_entry_custom_elements.xml @@ -0,0 +1,18 @@ + + + + Test Feed + urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6 + 2003-12-13T18:30:02Z + + + Test Entry + urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a + 2003-12-13T18:30:02Z + entry-123 + 1 + Custom Value + + \ No newline at end of file diff --git a/testdata/parser/atom/atom10_feed_entry_custom_multiple.json b/testdata/parser/atom/atom10_feed_entry_custom_multiple.json new file mode 100644 index 00000000..87abb485 --- /dev/null +++ b/testdata/parser/atom/atom10_feed_entry_custom_multiple.json @@ -0,0 +1,57 @@ +{ + "title": "Test Feed", + "id": "urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6", + "updated": "2003-12-13T18:30:02Z", + "updatedParsed": "2003-12-13T18:30:02Z", + "entries": [ + { + "title": "Test Entry", + "id": "urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a", + "updated": "2003-12-13T18:30:02Z", + "updatedParsed": "2003-12-13T18:30:02Z", + "extensions": { + "_custom": { + "keyword": [ + { + "name": "keyword", + "value": "atom", + "attrs": { + "type": "primary" + }, + "children": {} + }, + { + "name": "keyword", + "value": "feed", + "attrs": { + "type": "secondary" + }, + "children": {} + } + ], + "tag": [ + { + "name": "tag", + "value": "First Tag", + "attrs": {}, + "children": {} + }, + { + "name": "tag", + "value": "Second Tag", + "attrs": {}, + "children": {} + }, + { + "name": "tag", + "value": "Third Tag", + "attrs": {}, + "children": {} + } + ] + } + } + } + ], + "version": "1.0" +} diff --git a/testdata/parser/atom/atom10_feed_entry_custom_multiple.xml b/testdata/parser/atom/atom10_feed_entry_custom_multiple.xml new file mode 100644 index 00000000..7f5aa636 --- /dev/null +++ b/testdata/parser/atom/atom10_feed_entry_custom_multiple.xml @@ -0,0 +1,20 @@ + + + + Test Feed + urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6 + 2003-12-13T18:30:02Z + + + Test Entry + urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a + 2003-12-13T18:30:02Z + First Tag + Second Tag + Third Tag + atom + feed + + \ No newline at end of file diff --git a/testdata/parser/atom/issue_275_extensions_and_source.json b/testdata/parser/atom/issue_275_extensions_and_source.json index 40c828b1..1793ef2e 100644 --- a/testdata/parser/atom/issue_275_extensions_and_source.json +++ b/testdata/parser/atom/issue_275_extensions_and_source.json @@ -19,6 +19,16 @@ } ], "extensions": { + "_custom": { + "unknownsource": [ + { + "name": "unknownsource", + "value": "z", + "attrs": {}, + "children": {} + } + ] + }, "dc": { "rights": [ { @@ -32,6 +42,16 @@ } }, "extensions": { + "_custom": { + "unknownentry": [ + { + "name": "unknownentry", + "value": "y", + "attrs": {}, + "children": {} + } + ] + }, "dc": { "subject": [ { @@ -72,6 +92,16 @@ } ], "extensions": { + "_custom": { + "unknownfeed": [ + { + "name": "unknownfeed", + "value": "x", + "attrs": {}, + "children": {} + } + ] + }, "dc": { "creator": [ { diff --git a/testdata/parser/rss/issue_303_encoded_no_namespace.json b/testdata/parser/rss/issue_303_encoded_no_namespace.json index 9cc9cced..1222b6ad 100644 --- a/testdata/parser/rss/issue_303_encoded_no_namespace.json +++ b/testdata/parser/rss/issue_303_encoded_no_namespace.json @@ -2,6 +2,18 @@ "items": [ { "title": "First", + "extensions": { + "_custom": { + "encoded": [ + { + "name": "encoded", + "value": "hello", + "attrs": {}, + "children": {} + } + ] + } + }, "custom": { "encoded": "hello" } diff --git a/testdata/parser/rss/rss_channel_custom_elements.json b/testdata/parser/rss/rss_channel_custom_elements.json new file mode 100644 index 00000000..e80ab5f2 --- /dev/null +++ b/testdata/parser/rss/rss_channel_custom_elements.json @@ -0,0 +1,35 @@ +{ + "title": "Test Feed", + "extensions": { + "_custom": { + "customFeedId": [ + { + "name": "customFeedId", + "value": "feed-123", + "attrs": {}, + "children": {} + } + ], + "customMetadata": [ + { + "name": "customMetadata", + "value": "Some metadata", + "attrs": { + "type": "internal" + }, + "children": {} + } + ], + "updateFrequency": [ + { + "name": "updateFrequency", + "value": "hourly", + "attrs": {}, + "children": {} + } + ] + } + }, + "items": [], + "version": "2.0" +} diff --git a/testdata/parser/rss/rss_channel_custom_elements.xml b/testdata/parser/rss/rss_channel_custom_elements.xml new file mode 100644 index 00000000..2cc220fc --- /dev/null +++ b/testdata/parser/rss/rss_channel_custom_elements.xml @@ -0,0 +1,11 @@ + + + + Test Feed + feed-123 + hourly + Some metadata + + \ No newline at end of file diff --git a/testdata/parser/rss/rss_channel_item_custom.json b/testdata/parser/rss/rss_channel_item_custom.json index 5e739a61..a487aa30 100644 --- a/testdata/parser/rss/rss_channel_item_custom.json +++ b/testdata/parser/rss/rss_channel_item_custom.json @@ -1,6 +1,26 @@ { "items": [ { + "extensions": { + "_custom": { + "apcategory": [ + { + "name": "apcategory", + "value": "s", + "attrs": {}, + "children": {} + } + ], + "test": [ + { + "name": "test", + "value": "test", + "attrs": {}, + "children": {} + } + ] + } + }, "custom": { "apcategory": "s", "test": "test" diff --git a/testdata/parser/rss/rss_channel_item_custom_and_extension.json b/testdata/parser/rss/rss_channel_item_custom_and_extension.json new file mode 100644 index 00000000..45791ceb --- /dev/null +++ b/testdata/parser/rss/rss_channel_item_custom_and_extension.json @@ -0,0 +1,45 @@ +{ + "items": [ + { + "title": "Test Item", + "extensions": { + "_custom": { + "customTag": [ + { + "name": "customTag", + "value": "Custom Content", + "attrs": {}, + "children": {} + } + ], + "internalId": [ + { + "name": "internalId", + "value": "12345", + "attrs": {}, + "children": {} + } + ] + }, + "media": { + "content": [ + { + "name": "content", + "value": "", + "attrs": { + "type": "image/jpeg", + "url": "http://example.com/media.jpg" + }, + "children": {} + } + ] + } + }, + "custom": { + "customTag": "Custom Content", + "internalId": "12345" + } + } + ], + "version": "2.0" +} diff --git a/testdata/parser/rss/rss_channel_item_custom_and_extension.xml b/testdata/parser/rss/rss_channel_item_custom_and_extension.xml new file mode 100644 index 00000000..312e1b53 --- /dev/null +++ b/testdata/parser/rss/rss_channel_item_custom_and_extension.xml @@ -0,0 +1,13 @@ + + + + + Test Item + Custom Content + + 12345 + + + \ No newline at end of file diff --git a/testdata/parser/rss/rss_channel_item_custom_cdata.json b/testdata/parser/rss/rss_channel_item_custom_cdata.json new file mode 100644 index 00000000..c3d34719 --- /dev/null +++ b/testdata/parser/rss/rss_channel_item_custom_cdata.json @@ -0,0 +1,31 @@ +{ + "items": [ + { + "extensions": { + "_custom": { + "htmlContent": [ + { + "name": "htmlContent", + "value": "\u003cdiv\u003eSome \u003cb\u003eHTML\u003c/b\u003e content\u003c/div\u003e", + "attrs": {}, + "children": {} + } + ], + "script": [ + { + "name": "script", + "value": "function test() {\n return \"Hello \u0026 \u003cWorld\u003e\";\n }", + "attrs": {}, + "children": {} + } + ] + } + }, + "custom": { + "htmlContent": "\u003cdiv\u003eSome \u003cb\u003eHTML\u003c/b\u003e content\u003c/div\u003e", + "script": "function test() {\n return \"Hello \u0026 \u003cWorld\u003e\";\n }" + } + } + ], + "version": "2.0" +} diff --git a/testdata/parser/rss/rss_channel_item_custom_cdata.xml b/testdata/parser/rss/rss_channel_item_custom_cdata.xml new file mode 100644 index 00000000..9d7c9886 --- /dev/null +++ b/testdata/parser/rss/rss_channel_item_custom_cdata.xml @@ -0,0 +1,15 @@ + + + + + Some HTML content]]> + + + + \ No newline at end of file diff --git a/testdata/parser/rss/rss_channel_item_custom_multiple.json b/testdata/parser/rss/rss_channel_item_custom_multiple.json new file mode 100644 index 00000000..7dc2629f --- /dev/null +++ b/testdata/parser/rss/rss_channel_item_custom_multiple.json @@ -0,0 +1,53 @@ +{ + "items": [ + { + "extensions": { + "_custom": { + "customId": [ + { + "name": "customId", + "value": "123", + "attrs": { + "type": "primary" + }, + "children": {} + }, + { + "name": "customId", + "value": "456", + "attrs": { + "type": "secondary" + }, + "children": {} + } + ], + "tag": [ + { + "name": "tag", + "value": "First Tag", + "attrs": {}, + "children": {} + }, + { + "name": "tag", + "value": "Second Tag", + "attrs": {}, + "children": {} + }, + { + "name": "tag", + "value": "Third Tag", + "attrs": {}, + "children": {} + } + ] + } + }, + "custom": { + "customId": "456", + "tag": "Third Tag" + } + } + ], + "version": "2.0" +} diff --git a/testdata/parser/rss/rss_channel_item_custom_multiple.xml b/testdata/parser/rss/rss_channel_item_custom_multiple.xml new file mode 100644 index 00000000..88b7647a --- /dev/null +++ b/testdata/parser/rss/rss_channel_item_custom_multiple.xml @@ -0,0 +1,14 @@ + + + + + First Tag + Second Tag + Third Tag + 123 + 456 + + + \ No newline at end of file diff --git a/testdata/parser/rss/rss_channel_item_custom_nested.json b/testdata/parser/rss/rss_channel_item_custom_nested.json new file mode 100644 index 00000000..dc881ee6 --- /dev/null +++ b/testdata/parser/rss/rss_channel_item_custom_nested.json @@ -0,0 +1,49 @@ +{ + "items": [ + { + "extensions": { + "_custom": { + "event": [ + { + "name": "event", + "value": "", + "attrs": {}, + "children": { + "date": [ + { + "name": "date", + "value": "2026-07-04", + "attrs": {}, + "children": {} + } + ], + "venue": [ + { + "name": "venue", + "value": "The \u003cOld\u003e Hall", + "attrs": { + "city": "Austin" + }, + "children": {} + } + ] + } + } + ], + "simple": [ + { + "name": "simple", + "value": "plain", + "attrs": {}, + "children": {} + } + ] + } + }, + "custom": { + "simple": "plain" + } + } + ], + "version": "2.0" +} diff --git a/testdata/parser/rss/rss_channel_item_custom_nested.xml b/testdata/parser/rss/rss_channel_item_custom_nested.xml new file mode 100644 index 00000000..ebba26aa --- /dev/null +++ b/testdata/parser/rss/rss_channel_item_custom_nested.xml @@ -0,0 +1,16 @@ + + + + + + Hall]]> + 2026-07-04 + + plain + + + diff --git a/testdata/parser/rss/rss_channel_item_custom_with_attrs.json b/testdata/parser/rss/rss_channel_item_custom_with_attrs.json new file mode 100644 index 00000000..eb3f7362 --- /dev/null +++ b/testdata/parser/rss/rss_channel_item_custom_with_attrs.json @@ -0,0 +1,37 @@ +{ + "items": [ + { + "extensions": { + "_custom": { + "customField": [ + { + "name": "customField", + "value": "Custom Value", + "attrs": { + "id": "123", + "type": "special" + }, + "children": {} + } + ], + "rating": [ + { + "name": "rating", + "value": "8", + "attrs": { + "reviewer": "John", + "scale": "1-10" + }, + "children": {} + } + ] + } + }, + "custom": { + "customField": "Custom Value", + "rating": "8" + } + } + ], + "version": "2.0" +} diff --git a/testdata/parser/rss/rss_channel_item_custom_with_attrs.xml b/testdata/parser/rss/rss_channel_item_custom_with_attrs.xml new file mode 100644 index 00000000..be899c41 --- /dev/null +++ b/testdata/parser/rss/rss_channel_item_custom_with_attrs.xml @@ -0,0 +1,11 @@ + + + + + Custom Value + 8 + + + \ No newline at end of file diff --git a/testdata/translator/rss/feed_item_category_-_rss_channel_item_custom.json b/testdata/translator/rss/feed_item_category_-_rss_channel_item_custom.json index 24add264..389dc7a5 100644 --- a/testdata/translator/rss/feed_item_category_-_rss_channel_item_custom.json +++ b/testdata/translator/rss/feed_item_category_-_rss_channel_item_custom.json @@ -1,6 +1,26 @@ { "items": [ { + "extensions": { + "_custom": { + "apcategory": [ + { + "name": "apcategory", + "value": "s", + "attrs": {}, + "children": {} + } + ], + "test": [ + { + "name": "test", + "value": "test", + "attrs": {}, + "children": {} + } + ] + } + }, "custom": { "apcategory": "s", "test": "test"