Go's goroutines make it easy to make embarrassingly parallel programs, but in many "real world" cases resources can be limited and attempting to do everything at once can exhaust your access to them.
Go's goroutines make it easy to make embarrassingly parallel programs, but in many "real world" cases resources can be limited and attempting to do everything at once can exhaust your access to them.
`,
+ }}
+
+ if _, err := feed.ToAtom(); err != nil {
+ t.Errorf("unexpected error encoding Atom: %v", err)
+ }
+ var buf bytes.Buffer
+ if err := feed.WriteAtom(&buf); err != nil {
+ t.Errorf("unexpected error writing Atom: %v", err)
+ }
+
+ if _, err := feed.ToRss(); err != nil {
+ t.Errorf("unexpected error encoding RSS: %v", err)
+ }
+ buf.Reset()
+ if err := feed.WriteRss(&buf); err != nil {
+ t.Errorf("unexpected error writing RSS: %v", err)
+ }
+
+ if _, err := feed.ToJSON(); err != nil {
+ t.Errorf("unexpected error encoding JSON: %v", err)
+ }
+ buf.Reset()
+ if err := feed.WriteJSON(&buf); err != nil {
+ t.Errorf("unexpected error writing JSON: %v", err)
+ }
+}
+
+var jsonOutputHub = `{
+ "version": "https://jsonfeed.org/version/1",
+ "title": "feed title",
+ "hubs": [
+ {
+ "type": "WebSub",
+ "url": "https://websub-hub.example"
+ }
+ ]
+}`
+
+func TestJSONHub(t *testing.T) {
+ feed := &JSONFeed{
+ Version: "https://jsonfeed.org/version/1",
+ Title: "feed title",
+ Hubs: []*JSONHub{
+ {
+ Type: "WebSub",
+ Url: "https://websub-hub.example",
+ },
+ },
+ }
+ json, err := feed.ToJSON()
+ if err != nil {
+ t.Errorf("unexpected error encoding JSON: %v", err)
+ }
+ if json != jsonOutputHub {
+ t.Errorf("JSON not what was expected. Got:\n%s\n\nExpected:\n%s\n", json, jsonOutputHub)
+ }
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..a4a2aad
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,10 @@
+module github.com/gorilla/feeds
+
+go 1.20
+
+require github.com/kr/pretty v0.3.1
+
+require (
+ github.com/kr/text v0.2.0 // indirect
+ github.com/rogpeppe/go-internal v1.9.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..f5733ef
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,8 @@
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
+github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
diff --git a/json.go b/json.go
new file mode 100644
index 0000000..ae5deb7
--- /dev/null
+++ b/json.go
@@ -0,0 +1,190 @@
+package feeds
+
+import (
+ "encoding/json"
+ "strings"
+ "time"
+)
+
+const jsonFeedVersion = "https://jsonfeed.org/version/1.1"
+
+// JSONAuthor represents the author of the feed or of an individual item
+// in the feed
+type JSONAuthor struct {
+ Name string `json:"name,omitempty"`
+ Url string `json:"url,omitempty"`
+ Avatar string `json:"avatar,omitempty"`
+}
+
+// JSONAttachment represents a related resource. Podcasts, for instance, would
+// include an attachment that’s an audio or video file.
+type JSONAttachment struct {
+ Url string `json:"url,omitempty"`
+ MIMEType string `json:"mime_type,omitempty"`
+ Title string `json:"title,omitempty"`
+ Size int32 `json:"size,omitempty"`
+ Duration time.Duration `json:"duration_in_seconds,omitempty"`
+}
+
+// MarshalJSON implements the json.Marshaler interface.
+// The Duration field is marshaled in seconds, all other fields are marshaled
+// based upon the definitions in struct tags.
+func (a *JSONAttachment) MarshalJSON() ([]byte, error) {
+ type EmbeddedJSONAttachment JSONAttachment
+ return json.Marshal(&struct {
+ Duration float64 `json:"duration_in_seconds,omitempty"`
+ *EmbeddedJSONAttachment
+ }{
+ EmbeddedJSONAttachment: (*EmbeddedJSONAttachment)(a),
+ Duration: a.Duration.Seconds(),
+ })
+}
+
+// UnmarshalJSON implements the json.Unmarshaler interface.
+// The Duration field is expected to be in seconds, all other field types
+// match the struct definition.
+func (a *JSONAttachment) UnmarshalJSON(data []byte) error {
+ type EmbeddedJSONAttachment JSONAttachment
+ var raw struct {
+ Duration float64 `json:"duration_in_seconds,omitempty"`
+ *EmbeddedJSONAttachment
+ }
+ raw.EmbeddedJSONAttachment = (*EmbeddedJSONAttachment)(a)
+
+ err := json.Unmarshal(data, &raw)
+ if err != nil {
+ return err
+ }
+
+ if raw.Duration > 0 {
+ nsec := int64(raw.Duration * float64(time.Second))
+ raw.EmbeddedJSONAttachment.Duration = time.Duration(nsec)
+ }
+
+ return nil
+}
+
+// JSONItem represents a single entry/post for the feed.
+type JSONItem struct {
+ Id string `json:"id"`
+ Url string `json:"url,omitempty"`
+ ExternalUrl string `json:"external_url,omitempty"`
+ Title string `json:"title,omitempty"`
+ ContentHTML string `json:"content_html,omitempty"`
+ ContentText string `json:"content_text,omitempty"`
+ Summary string `json:"summary,omitempty"`
+ Image string `json:"image,omitempty"`
+ BannerImage string `json:"banner_,omitempty"`
+ PublishedDate *time.Time `json:"date_published,omitempty"`
+ ModifiedDate *time.Time `json:"date_modified,omitempty"`
+ Author *JSONAuthor `json:"author,omitempty"` // deprecated in JSON Feed v1.1, keeping for backwards compatibility
+ Authors []*JSONAuthor `json:"authors,omitempty"`
+ Tags []string `json:"tags,omitempty"`
+ Attachments []JSONAttachment `json:"attachments,omitempty"`
+}
+
+// JSONHub describes an endpoint that can be used to subscribe to real-time
+// notifications from the publisher of this feed.
+type JSONHub struct {
+ Type string `json:"type"`
+ Url string `json:"url"`
+}
+
+// JSONFeed represents a syndication feed in the JSON Feed Version 1 format.
+// Matching the specification found here: https://jsonfeed.org/version/1.
+type JSONFeed struct {
+ Version string `json:"version"`
+ Title string `json:"title"`
+ Language string `json:"language,omitempty"`
+ HomePageUrl string `json:"home_page_url,omitempty"`
+ FeedUrl string `json:"feed_url,omitempty"`
+ Description string `json:"description,omitempty"`
+ UserComment string `json:"user_comment,omitempty"`
+ NextUrl string `json:"next_url,omitempty"`
+ Icon string `json:"icon,omitempty"`
+ Favicon string `json:"favicon,omitempty"`
+ Author *JSONAuthor `json:"author,omitempty"` // deprecated in JSON Feed v1.1, keeping for backwards compatibility
+ Authors []*JSONAuthor `json:"authors,omitempty"`
+ Expired *bool `json:"expired,omitempty"`
+ Hubs []*JSONHub `json:"hubs,omitempty"`
+ Items []*JSONItem `json:"items,omitempty"`
+}
+
+// JSON is used to convert a generic Feed to a JSONFeed.
+type JSON struct {
+ *Feed
+}
+
+// ToJSON encodes f into a JSON string. Returns an error if marshalling fails.
+func (f *JSON) ToJSON() (string, error) {
+ return f.JSONFeed().ToJSON()
+}
+
+// ToJSON encodes f into a JSON string. Returns an error if marshalling fails.
+func (f *JSONFeed) ToJSON() (string, error) {
+ data, err := json.MarshalIndent(f, "", " ")
+ if err != nil {
+ return "", err
+ }
+
+ return string(data), nil
+}
+
+// JSONFeed creates a new JSONFeed with a generic Feed struct's data.
+func (f *JSON) JSONFeed() *JSONFeed {
+ feed := &JSONFeed{
+ Version: jsonFeedVersion,
+ Title: f.Title,
+ Description: f.Description,
+ }
+
+ if f.Link != nil {
+ feed.HomePageUrl = f.Link.Href
+ }
+ if f.Author != nil {
+ author := &JSONAuthor{
+ Name: f.Author.Name,
+ }
+ feed.Author = author
+ feed.Authors = []*JSONAuthor{author}
+ }
+ for _, e := range f.Items {
+ feed.Items = append(feed.Items, newJSONItem(e))
+ }
+ return feed
+}
+
+func newJSONItem(i *Item) *JSONItem {
+ item := &JSONItem{
+ Id: i.Id,
+ Title: i.Title,
+ Summary: i.Description,
+
+ ContentHTML: i.Content,
+ }
+
+ if i.Link != nil {
+ item.Url = i.Link.Href
+ }
+ if i.Source != nil {
+ item.ExternalUrl = i.Source.Href
+ }
+ if i.Author != nil {
+ author := &JSONAuthor{
+ Name: i.Author.Name,
+ }
+ item.Author = author
+ item.Authors = []*JSONAuthor{author}
+ }
+ if !i.Created.IsZero() {
+ item.PublishedDate = &i.Created
+ }
+ if !i.Updated.IsZero() {
+ item.ModifiedDate = &i.Updated
+ }
+ if i.Enclosure != nil && strings.HasPrefix(i.Enclosure.Type, "image/") {
+ item.Image = i.Enclosure.Url
+ }
+
+ return item
+}
diff --git a/rss.go b/rss.go
new file mode 100644
index 0000000..9326cef
--- /dev/null
+++ b/rss.go
@@ -0,0 +1,183 @@
+package feeds
+
+// rss support
+// validation done according to spec here:
+// http://cyber.law.harvard.edu/rss/rss.html
+
+import (
+ "encoding/xml"
+ "fmt"
+ "time"
+)
+
+// private wrapper around the RssFeed which gives us the .. xml
+type RssFeedXml struct {
+ XMLName xml.Name `xml:"rss"`
+ Version string `xml:"version,attr"`
+ ContentNamespace string `xml:"xmlns:content,attr"`
+ Channel *RssFeed
+}
+
+type RssContent struct {
+ XMLName xml.Name `xml:"content:encoded"`
+ Content string `xml:",cdata"`
+}
+
+type RssImage struct {
+ XMLName xml.Name `xml:"image"`
+ Url string `xml:"url"`
+ Title string `xml:"title"`
+ Link string `xml:"link"`
+ Width int `xml:"width,omitempty"`
+ Height int `xml:"height,omitempty"`
+}
+
+type RssTextInput struct {
+ XMLName xml.Name `xml:"textInput"`
+ Title string `xml:"title"`
+ Description string `xml:"description"`
+ Name string `xml:"name"`
+ Link string `xml:"link"`
+}
+
+type RssFeed struct {
+ XMLName xml.Name `xml:"channel"`
+ Title string `xml:"title"` // required
+ Link string `xml:"link"` // required
+ Description string `xml:"description"` // required
+ Language string `xml:"language,omitempty"`
+ Copyright string `xml:"copyright,omitempty"`
+ ManagingEditor string `xml:"managingEditor,omitempty"` // Author used
+ WebMaster string `xml:"webMaster,omitempty"`
+ PubDate string `xml:"pubDate,omitempty"` // created or updated
+ LastBuildDate string `xml:"lastBuildDate,omitempty"` // updated used
+ Category string `xml:"category,omitempty"`
+ Generator string `xml:"generator,omitempty"`
+ Docs string `xml:"docs,omitempty"`
+ Cloud string `xml:"cloud,omitempty"`
+ Ttl int `xml:"ttl,omitempty"`
+ Rating string `xml:"rating,omitempty"`
+ SkipHours string `xml:"skipHours,omitempty"`
+ SkipDays string `xml:"skipDays,omitempty"`
+ Image *RssImage
+ TextInput *RssTextInput
+ Items []*RssItem `xml:"item"`
+}
+
+type RssItem struct {
+ XMLName xml.Name `xml:"item"`
+ Title string `xml:"title"` // required
+ Link string `xml:"link"` // required
+ Description string `xml:"description"` // required
+ Content *RssContent
+ Author string `xml:"author,omitempty"`
+ Category string `xml:"category,omitempty"`
+ Comments string `xml:"comments,omitempty"`
+ Enclosure *RssEnclosure
+ Guid *RssGuid // Id used
+ PubDate string `xml:"pubDate,omitempty"` // created or updated
+ Source string `xml:"source,omitempty"`
+}
+
+type RssEnclosure struct {
+ //RSS 2.0
+ XMLName xml.Name `xml:"enclosure"`
+ Url string `xml:"url,attr"`
+ Length string `xml:"length,attr"`
+ Type string `xml:"type,attr"`
+}
+
+type RssGuid struct {
+ //RSS 2.0 http://inessential.com/2002/09/01.php#a2
+ XMLName xml.Name `xml:"guid"`
+ Id string `xml:",chardata"`
+ IsPermaLink string `xml:"isPermaLink,attr,omitempty"` // "true", "false", or an empty string
+}
+
+type Rss struct {
+ *Feed
+}
+
+// create a new RssItem with a generic Item struct's data
+func newRssItem(i *Item) *RssItem {
+ item := &RssItem{
+ Title: i.Title,
+ Description: i.Description,
+ PubDate: anyTimeFormat(time.RFC1123Z, i.Created, i.Updated),
+ }
+ if i.Id != "" {
+ item.Guid = &RssGuid{Id: i.Id, IsPermaLink: i.IsPermaLink}
+ }
+ if i.Link != nil {
+ item.Link = i.Link.Href
+ }
+ if len(i.Content) > 0 {
+ item.Content = &RssContent{Content: i.Content}
+ }
+ if i.Source != nil {
+ item.Source = i.Source.Href
+ }
+
+ // Define a closure
+ if i.Enclosure != nil && i.Enclosure.Type != "" && i.Enclosure.Length != "" {
+ item.Enclosure = &RssEnclosure{Url: i.Enclosure.Url, Type: i.Enclosure.Type, Length: i.Enclosure.Length}
+ }
+
+ if i.Author != nil {
+ item.Author = i.Author.Name
+ }
+ return item
+}
+
+// create a new RssFeed with a generic Feed struct's data
+func (r *Rss) RssFeed() *RssFeed {
+ pub := anyTimeFormat(time.RFC1123Z, r.Created, r.Updated)
+ build := anyTimeFormat(time.RFC1123Z, r.Updated)
+ author := ""
+ if r.Author != nil {
+ author = r.Author.Email
+ if len(r.Author.Name) > 0 {
+ author = fmt.Sprintf("%s (%s)", r.Author.Email, r.Author.Name)
+ }
+ }
+
+ var image *RssImage
+ if r.Image != nil {
+ image = &RssImage{Url: r.Image.Url, Title: r.Image.Title, Link: r.Image.Link, Width: r.Image.Width, Height: r.Image.Height}
+ }
+
+ var href string
+ if r.Link != nil {
+ href = r.Link.Href
+ }
+ channel := &RssFeed{
+ Title: r.Title,
+ Link: href,
+ Description: r.Description,
+ ManagingEditor: author,
+ PubDate: pub,
+ LastBuildDate: build,
+ Copyright: r.Copyright,
+ Image: image,
+ }
+ for _, i := range r.Items {
+ channel.Items = append(channel.Items, newRssItem(i))
+ }
+ return channel
+}
+
+// FeedXml returns an XML-Ready object for an Rss object
+func (r *Rss) FeedXml() interface{} {
+ // only generate version 2.0 feeds for now
+ return r.RssFeed().FeedXml()
+
+}
+
+// FeedXml returns an XML-ready object for an RssFeed object
+func (r *RssFeed) FeedXml() interface{} {
+ return &RssFeedXml{
+ Version: "2.0",
+ Channel: r,
+ ContentNamespace: "http://purl.org/rss/1.0/modules/content/",
+ }
+}
diff --git a/test.atom b/test.atom
new file mode 100644
index 0000000..aa15214
--- /dev/null
+++ b/test.atom
@@ -0,0 +1,92 @@
+
+
+
+
+ http://example.com/
+ RSS for Node
+ Tue, 30 Oct 2018 23:22:37 GMT
+
+ Tue, 30 Oct 2018 23:22:00 GMT
+
+ 60
+
+
+
+ http://example.com/test/1540941720
+ http://example.com/test/1540941720
+
+ Tue, 30 Oct 2018 23:22:00 GMT
+
+
+
+
+ http://example.com/test/1540941660
+ http://example.com/test/1540941660
+
+ Tue, 30 Oct 2018 23:21:00 GMT
+
+
+
+
+ http://example.com/test/1540941600
+ http://example.com/test/1540941600
+
+ Tue, 30 Oct 2018 23:20:00 GMT
+
+
+
+
+ http://example.com/test/1540941540
+ http://example.com/test/1540941540
+
+ Tue, 30 Oct 2018 23:19:00 GMT
+
+
+
+
+ http://example.com/test/1540941480
+ http://example.com/test/1540941480
+
+ Tue, 30 Oct 2018 23:18:00 GMT
+
+
+
+
+ http://example.com/test/1540941420
+ http://example.com/test/1540941420
+
+ Tue, 30 Oct 2018 23:17:00 GMT
+
+
+
+
+ http://example.com/test/1540941360
+ http://example.com/test/1540941360
+
+ Tue, 30 Oct 2018 23:16:00 GMT
+
+
+
+
+ http://example.com/test/1540941300
+ http://example.com/test/1540941300
+
+ Tue, 30 Oct 2018 23:15:00 GMT
+
+
+
+
+ http://example.com/test/1540941240
+ http://example.com/test/1540941240
+
+ Tue, 30 Oct 2018 23:14:00 GMT
+
+
+
+
+ http://example.com/test/1540941180
+ http://example.com/test/1540941180
+
+ Tue, 30 Oct 2018 23:13:00 GMT
+
+
\ No newline at end of file
diff --git a/test.rss b/test.rss
new file mode 100644
index 0000000..8d912ab
--- /dev/null
+++ b/test.rss
@@ -0,0 +1,96 @@
+
+
+
+
+
+ http://example.com/
+ RSS for Node
+ Tue, 30 Oct 2018 23:22:37 GMT
+
+ Tue, 30 Oct 2018 23:22:00 GMT
+
+ 60
+
+
+
+ http://example.com/test/1540941720
+ http://example.com/test/1540941720
+
+ Tue, 30 Oct 2018 23:22:00 GMT
+
+
+
+
+ http://example.com/test/1540941660
+ http://example.com/test/1540941660
+
+ Tue, 30 Oct 2018 23:21:00 GMT
+
+
+
+
+ http://example.com/test/1540941600
+ http://example.com/test/1540941600
+
+ Tue, 30 Oct 2018 23:20:00 GMT
+
+
+
+
+ http://example.com/test/1540941540
+ http://example.com/test/1540941540
+
+ Tue, 30 Oct 2018 23:19:00 GMT
+
+
+
+
+ http://example.com/test/1540941480
+ http://example.com/test/1540941480
+
+ Tue, 30 Oct 2018 23:18:00 GMT
+
+
+
+
+ http://example.com/test/1540941420
+ http://example.com/test/1540941420
+
+ Tue, 30 Oct 2018 23:17:00 GMT
+
+
+
+
+ http://example.com/test/1540941360
+ http://example.com/test/1540941360
+
+ Tue, 30 Oct 2018 23:16:00 GMT
+
+
+
+
+ http://example.com/test/1540941300
+ http://example.com/test/1540941300
+
+ Tue, 30 Oct 2018 23:15:00 GMT
+
+
+
+
+ http://example.com/test/1540941240
+ http://example.com/test/1540941240
+
+ Tue, 30 Oct 2018 23:14:00 GMT
+
+
+
+
+ http://example.com/test/1540941180
+ http://example.com/test/1540941180
+
+ Tue, 30 Oct 2018 23:13:00 GMT
+
+
+
\ No newline at end of file
diff --git a/to-implement.md b/to-implement.md
new file mode 100644
index 0000000..45fd1e7
--- /dev/null
+++ b/to-implement.md
@@ -0,0 +1,20 @@
+[Full iTunes list](https://help.apple.com/itc/podcasts_connect/#/itcb54353390)
+
+[Example of ideal iTunes RSS feed](https://help.apple.com/itc/podcasts_connect/#/itcbaf351599)
+
+```
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
\ No newline at end of file
diff --git a/uuid.go b/uuid.go
new file mode 100644
index 0000000..51bbafe
--- /dev/null
+++ b/uuid.go
@@ -0,0 +1,27 @@
+package feeds
+
+// relevant bits from https://github.com/abneptis/GoUUID/blob/master/uuid.go
+
+import (
+ "crypto/rand"
+ "fmt"
+)
+
+type UUID [16]byte
+
+// create a new uuid v4
+func NewUUID() *UUID {
+ u := &UUID{}
+ _, err := rand.Read(u[:16])
+ if err != nil {
+ panic(err)
+ }
+
+ u[8] = (u[8] | 0x80) & 0xBf
+ u[6] = (u[6] | 0x40) & 0x4f
+ return u
+}
+
+func (u *UUID) String() string {
+ return fmt.Sprintf("%x-%x-%x-%x-%x", u[:4], u[4:6], u[6:8], u[8:10], u[10:])
+}
diff --git a/uuid_test.go b/uuid_test.go
new file mode 100644
index 0000000..140fbfd
--- /dev/null
+++ b/uuid_test.go
@@ -0,0 +1,19 @@
+package feeds
+
+import (
+ "testing"
+)
+
+func TestUUID(t *testing.T) {
+ s := NewUUID()
+ s2 := NewUUID()
+ if len(s) != 16 {
+ t.Errorf("Expecting len of 16, got %d\n", len(s))
+ }
+ if len(s.String()) != 36 {
+ t.Errorf("Expecting uuid hex string len of 36, got %d\n", len(s.String()))
+ }
+ if s == s2 {
+ t.Errorf("Expecting different UUIDs to be different, but they are the same.\n")
+ }
+}