From 04bba8eb8a2713993dc1fe69d1732821b5a30a4c Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 18 May 2025 17:35:34 +0200 Subject: [PATCH] Adding upstream version 1.2.0. Signed-off-by: Daniel Baumann --- .editorconfig | 20 ++ .github/workflows/issues.yml | 21 ++ .github/workflows/security.yml | 37 ++ .github/workflows/test.yml | 35 ++ .github/workflows/verify.yml | 32 ++ .gitignore | 1 + LICENSE | 28 ++ Makefile | 34 ++ README.md | 198 +++++++++++ atom.go | 178 ++++++++++ consume_test.go | 405 ++++++++++++++++++++++ doc.go | 73 ++++ feed.go | 146 ++++++++ feed_test.go | 603 +++++++++++++++++++++++++++++++++ go.mod | 10 + go.sum | 8 + json.go | 190 +++++++++++ rss.go | 183 ++++++++++ test.atom | 92 +++++ test.rss | 96 ++++++ to-implement.md | 20 ++ uuid.go | 27 ++ uuid_test.go | 19 ++ 23 files changed, 2456 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/workflows/issues.yml create mode 100644 .github/workflows/security.yml create mode 100644 .github/workflows/test.yml create mode 100644 .github/workflows/verify.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 atom.go create mode 100644 consume_test.go create mode 100644 doc.go create mode 100644 feed.go create mode 100644 feed_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 json.go create mode 100644 rss.go create mode 100644 test.atom create mode 100644 test.rss create mode 100644 to-implement.md create mode 100644 uuid.go create mode 100644 uuid_test.go diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2940ec9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +; https://editorconfig.org/ + +root = true + +[*] +insert_final_newline = true +charset = utf-8 +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[{Makefile,go.mod,go.sum,*.go,.gitmodules}] +indent_style = tab +indent_size = 4 + +[*.md] +indent_size = 4 +trim_trailing_whitespace = false + +eclint_indent_style = unset diff --git a/.github/workflows/issues.yml b/.github/workflows/issues.yml new file mode 100644 index 0000000..768b05b --- /dev/null +++ b/.github/workflows/issues.yml @@ -0,0 +1,21 @@ +# Add all the issues created to the project. +name: Add issue or pull request to Project + +on: + issues: + types: + - opened + pull_request_target: + types: + - opened + - reopened + +jobs: + add-to-project: + runs-on: ubuntu-latest + steps: + - name: Add issue to project + uses: actions/add-to-project@v0.5.0 + with: + project-url: https://github.com/orgs/gorilla/projects/4 + github-token: ${{ secrets.ADD_TO_PROJECT_TOKEN }} diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..ff4a613 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,37 @@ +name: Security +on: + push: + branches: + - main + pull_request: + branches: + - main +permissions: + contents: read +jobs: + scan: + strategy: + matrix: + go: ['1.20','1.21'] + fail-fast: true + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Setup Go ${{ matrix.go }} + uses: actions/setup-go@v4 + with: + go-version: ${{ matrix.go }} + cache: false + + - name: Run GoSec + uses: securego/gosec@master + with: + args: -exclude-dir examples ./... + + - name: Run GoVulnCheck + uses: golang/govulncheck-action@v1 + with: + go-version-input: ${{ matrix.go }} + go-package: ./... diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..50a3946 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,35 @@ +name: Test +on: + push: + branches: + - main + pull_request: + branches: + - main +permissions: + contents: read +jobs: + unit: + strategy: + matrix: + go: ['1.20','1.21'] + os: [ubuntu-latest, macos-latest, windows-latest] + fail-fast: true + runs-on: ${{ matrix.os }} + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Setup Go ${{ matrix.go }} + uses: actions/setup-go@v4 + with: + go-version: ${{ matrix.go }} + cache: false + + - name: Run Tests + run: go test -race -cover -coverprofile=coverage -covermode=atomic -v ./... + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + files: ./coverage diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..a3eb74b --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,32 @@ +name: Verify +on: + push: + branches: + - main + pull_request: + branches: + - main +permissions: + contents: read +jobs: + lint: + strategy: + matrix: + go: ['1.20','1.21'] + fail-fast: true + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Setup Go ${{ matrix.go }} + uses: actions/setup-go@v4 + with: + go-version: ${{ matrix.go }} + cache: false + + - name: Run GolangCI-Lint + uses: golangci/golangci-lint-action@v3 + with: + version: v1.53 + args: --timeout=5m diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..84039fe --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +coverage.coverprofile diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ee0d53c --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +Copyright (c) 2023 The Gorilla Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ac37ffd --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +GO_LINT=$(shell which golangci-lint 2> /dev/null || echo '') +GO_LINT_URI=github.com/golangci/golangci-lint/cmd/golangci-lint@latest + +GO_SEC=$(shell which gosec 2> /dev/null || echo '') +GO_SEC_URI=github.com/securego/gosec/v2/cmd/gosec@latest + +GO_VULNCHECK=$(shell which govulncheck 2> /dev/null || echo '') +GO_VULNCHECK_URI=golang.org/x/vuln/cmd/govulncheck@latest + +.PHONY: golangci-lint +golangci-lint: + $(if $(GO_LINT), ,go install $(GO_LINT_URI)) + @echo "##### Running golangci-lint" + golangci-lint run -v + +.PHONY: gosec +gosec: + $(if $(GO_SEC), ,go install $(GO_SEC_URI)) + @echo "##### Running gosec" + gosec ./... + +.PHONY: govulncheck +govulncheck: + $(if $(GO_VULNCHECK), ,go install $(GO_VULNCHECK_URI)) + @echo "##### Running govulncheck" + govulncheck ./... + +.PHONY: verify +verify: golangci-lint gosec govulncheck + +.PHONY: test +test: + @echo "##### Running tests" + go test -race -cover -coverprofile=coverage.coverprofile -covermode=atomic -v ./... diff --git a/README.md b/README.md new file mode 100644 index 0000000..7d7137b --- /dev/null +++ b/README.md @@ -0,0 +1,198 @@ +## gorilla/feeds +![testing](https://github.com/gorilla/feeds/actions/workflows/test.yml/badge.svg) +[![codecov](https://codecov.io/github/gorilla/feeds/branch/main/graph/badge.svg)](https://codecov.io/github/gorilla/feeds) +[![godoc](https://godoc.org/github.com/gorilla/feeds?status.svg)](https://godoc.org/github.com/gorilla/feeds) +[![sourcegraph](https://sourcegraph.com/github.com/gorilla/feeds/-/badge.svg)](https://sourcegraph.com/github.com/gorilla/feeds?badge) + +![Gorilla Logo](https://github.com/gorilla/.github/assets/53367916/d92caabf-98e0-473e-bfbf-ab554ba435e5) + +feeds is a web feed generator library for generating RSS, Atom and JSON feeds from Go +applications. + +### Goals + + * Provide a simple interface to create both Atom & RSS 2.0 feeds + * Full support for [Atom][atom], [RSS 2.0][rss], and [JSON Feed Version 1][jsonfeed] spec elements + * Ability to modify particulars for each spec + +[atom]: https://tools.ietf.org/html/rfc4287 +[rss]: http://www.rssboard.org/rss-specification +[jsonfeed]: https://jsonfeed.org/version/1.1 + +### Usage + +```go +package main + +import ( + "fmt" + "log" + "time" + "github.com/gorilla/feeds" +) + +func main() { + now := time.Now() + feed := &feeds.Feed{ + Title: "jmoiron.net blog", + Link: &feeds.Link{Href: "http://jmoiron.net/blog"}, + Description: "discussion about tech, footie, photos", + Author: &feeds.Author{Name: "Jason Moiron", Email: "jmoiron@jmoiron.net"}, + Created: now, + } + + feed.Items = []*feeds.Item{ + &feeds.Item{ + Title: "Limiting Concurrency in Go", + Link: &feeds.Link{Href: "http://jmoiron.net/blog/limiting-concurrency-in-go/"}, + Description: "A discussion on controlled parallelism in golang", + Author: &feeds.Author{Name: "Jason Moiron", Email: "jmoiron@jmoiron.net"}, + Created: now, + }, + &feeds.Item{ + Title: "Logic-less Template Redux", + Link: &feeds.Link{Href: "http://jmoiron.net/blog/logicless-template-redux/"}, + Description: "More thoughts on logicless templates", + Created: now, + }, + &feeds.Item{ + Title: "Idiomatic Code Reuse in Go", + Link: &feeds.Link{Href: "http://jmoiron.net/blog/idiomatic-code-reuse-in-go/"}, + Description: "How to use interfaces effectively", + Created: now, + }, + } + + atom, err := feed.ToAtom() + if err != nil { + log.Fatal(err) + } + + rss, err := feed.ToRss() + if err != nil { + log.Fatal(err) + } + + json, err := feed.ToJSON() + if err != nil { + log.Fatal(err) + } + + fmt.Println(atom, "\n", rss, "\n", json) +} +``` + +Outputs: + +```xml + + + jmoiron.net blog + + http://jmoiron.net/blog + 2013-01-16T03:26:01-05:00 + discussion about tech, footie, photos + + Limiting Concurrency in Go + + 2013-01-16T03:26:01-05:00 + tag:jmoiron.net,2013-01-16:/blog/limiting-concurrency-in-go/ + A discussion on controlled parallelism in golang + + Jason Moiron + jmoiron@jmoiron.net + + + + Logic-less Template Redux + + 2013-01-16T03:26:01-05:00 + tag:jmoiron.net,2013-01-16:/blog/logicless-template-redux/ + More thoughts on logicless templates + + + + Idiomatic Code Reuse in Go + + 2013-01-16T03:26:01-05:00 + tag:jmoiron.net,2013-01-16:/blog/idiomatic-code-reuse-in-go/ + How to use interfaces <em>effectively</em> + + + + + + + + jmoiron.net blog + http://jmoiron.net/blog + discussion about tech, footie, photos + jmoiron@jmoiron.net (Jason Moiron) + 2013-01-16T03:22:24-05:00 + + Limiting Concurrency in Go + http://jmoiron.net/blog/limiting-concurrency-in-go/ + A discussion on controlled parallelism in golang + 2013-01-16T03:22:24-05:00 + + + Logic-less Template Redux + http://jmoiron.net/blog/logicless-template-redux/ + More thoughts on logicless templates + 2013-01-16T03:22:24-05:00 + + + Idiomatic Code Reuse in Go + http://jmoiron.net/blog/idiomatic-code-reuse-in-go/ + How to use interfaces <em>effectively</em> + 2013-01-16T03:22:24-05:00 + + + + +{ + "version": "https://jsonfeed.org/version/1.1", + "title": "jmoiron.net blog", + "home_page_url": "http://jmoiron.net/blog", + "description": "discussion about tech, footie, photos", + "author": { + "name": "Jason Moiron" + }, + "authors": [ + { + "name": "Jason Moiron" + } + ], + "items": [ + { + "id": "", + "url": "http://jmoiron.net/blog/limiting-concurrency-in-go/", + "title": "Limiting Concurrency in Go", + "summary": "A discussion on controlled parallelism in golang", + "date_published": "2013-01-16T03:22:24.530817846-05:00", + "author": { + "name": "Jason Moiron" + }, + "authors": [ + { + "name": "Jason Moiron" + } + ] + }, + { + "id": "", + "url": "http://jmoiron.net/blog/logicless-template-redux/", + "title": "Logic-less Template Redux", + "summary": "More thoughts on logicless templates", + "date_published": "2013-01-16T03:22:24.530817846-05:00" + }, + { + "id": "", + "url": "http://jmoiron.net/blog/idiomatic-code-reuse-in-go/", + "title": "Idiomatic Code Reuse in Go", + "summary": "How to use interfaces \u003cem\u003eeffectively\u003c/em\u003e", + "date_published": "2013-01-16T03:22:24.530817846-05:00" + } + ] +} +``` diff --git a/atom.go b/atom.go new file mode 100644 index 0000000..73de995 --- /dev/null +++ b/atom.go @@ -0,0 +1,178 @@ +package feeds + +import ( + "encoding/xml" + "fmt" + "net/url" + "time" +) + +// Generates Atom feed as XML + +const ns = "http://www.w3.org/2005/Atom" + +type AtomPerson struct { + Name string `xml:"name,omitempty"` + Uri string `xml:"uri,omitempty"` + Email string `xml:"email,omitempty"` +} + +type AtomSummary struct { + XMLName xml.Name `xml:"summary"` + Content string `xml:",chardata"` + Type string `xml:"type,attr"` +} + +type AtomContent struct { + XMLName xml.Name `xml:"content"` + Content string `xml:",chardata"` + Type string `xml:"type,attr"` +} + +type AtomAuthor struct { + XMLName xml.Name `xml:"author"` + AtomPerson +} + +type AtomContributor struct { + XMLName xml.Name `xml:"contributor"` + AtomPerson +} + +type AtomEntry struct { + XMLName xml.Name `xml:"entry"` + Xmlns string `xml:"xmlns,attr,omitempty"` + Title string `xml:"title"` // required + Updated string `xml:"updated"` // required + Id string `xml:"id"` // required + Category string `xml:"category,omitempty"` + Content *AtomContent + Rights string `xml:"rights,omitempty"` + Source string `xml:"source,omitempty"` + Published string `xml:"published,omitempty"` + Contributor *AtomContributor + Links []AtomLink // required if no child 'content' elements + Summary *AtomSummary // required if content has src or content is base64 + Author *AtomAuthor // required if feed lacks an author +} + +// Multiple links with different rel can coexist +type AtomLink struct { + //Atom 1.0 + XMLName xml.Name `xml:"link"` + Href string `xml:"href,attr"` + Rel string `xml:"rel,attr,omitempty"` + Type string `xml:"type,attr,omitempty"` + Length string `xml:"length,attr,omitempty"` +} + +type AtomFeed struct { + XMLName xml.Name `xml:"feed"` + Xmlns string `xml:"xmlns,attr"` + Title string `xml:"title"` // required + Id string `xml:"id"` // required + Updated string `xml:"updated"` // required + Category string `xml:"category,omitempty"` + Icon string `xml:"icon,omitempty"` + Logo string `xml:"logo,omitempty"` + Rights string `xml:"rights,omitempty"` // copyright used + Subtitle string `xml:"subtitle,omitempty"` + Link *AtomLink + Author *AtomAuthor `xml:"author,omitempty"` + Contributor *AtomContributor + Entries []*AtomEntry `xml:"entry"` +} + +type Atom struct { + *Feed +} + +func newAtomEntry(i *Item) *AtomEntry { + id := i.Id + link := i.Link + if link == nil { + link = &Link{} + } + if len(id) == 0 { + // if there's no id set, try to create one, either from data or just a uuid + if len(link.Href) > 0 && (!i.Created.IsZero() || !i.Updated.IsZero()) { + dateStr := anyTimeFormat("2006-01-02", i.Updated, i.Created) + host, path := link.Href, "/invalid.html" + if url, err := url.Parse(link.Href); err == nil { + host, path = url.Host, url.Path + } + id = fmt.Sprintf("tag:%s,%s:%s", host, dateStr, path) + } else { + id = "urn:uuid:" + NewUUID().String() + } + } + var name, email string + if i.Author != nil { + name, email = i.Author.Name, i.Author.Email + } + + link_rel := link.Rel + if link_rel == "" { + link_rel = "alternate" + } + x := &AtomEntry{ + Title: i.Title, + Links: []AtomLink{{Href: link.Href, Rel: link_rel, Type: link.Type}}, + Id: id, + Updated: anyTimeFormat(time.RFC3339, i.Updated, i.Created), + } + + // if there's a description, assume it's html + if len(i.Description) > 0 { + x.Summary = &AtomSummary{Content: i.Description, Type: "html"} + } + + // if there's a content, assume it's html + if len(i.Content) > 0 { + x.Content = &AtomContent{Content: i.Content, Type: "html"} + } + + if i.Enclosure != nil && link_rel != "enclosure" { + x.Links = append(x.Links, AtomLink{Href: i.Enclosure.Url, Rel: "enclosure", Type: i.Enclosure.Type, Length: i.Enclosure.Length}) + } + + if len(name) > 0 || len(email) > 0 { + x.Author = &AtomAuthor{AtomPerson: AtomPerson{Name: name, Email: email}} + } + return x +} + +// create a new AtomFeed with a generic Feed struct's data +func (a *Atom) AtomFeed() *AtomFeed { + updated := anyTimeFormat(time.RFC3339, a.Updated, a.Created) + link := a.Link + if link == nil { + link = &Link{} + } + feed := &AtomFeed{ + Xmlns: ns, + Title: a.Title, + Link: &AtomLink{Href: link.Href, Rel: link.Rel}, + Subtitle: a.Description, + Id: link.Href, + Updated: updated, + Rights: a.Copyright, + } + if a.Author != nil { + feed.Author = &AtomAuthor{AtomPerson: AtomPerson{Name: a.Author.Name, Email: a.Author.Email}} + } + for _, e := range a.Items { + feed.Entries = append(feed.Entries, newAtomEntry(e)) + } + return feed +} + +// FeedXml returns an XML-Ready object for an Atom object +func (a *Atom) FeedXml() interface{} { + return a.AtomFeed() +} + +// FeedXml returns an XML-ready object for an AtomFeed object +func (a *AtomFeed) FeedXml() interface{} { + return a +} diff --git a/consume_test.go b/consume_test.go new file mode 100644 index 0000000..5abe4d1 --- /dev/null +++ b/consume_test.go @@ -0,0 +1,405 @@ +package feeds + +import ( + "encoding/xml" + "io" + "os" + "reflect" + "testing" + + "github.com/kr/pretty" +) + +var testRssFeedXML = RssFeedXml{ + XMLName: xml.Name{Space: "", Local: "rss"}, + Version: "2.0", + ContentNamespace: "", + Channel: &RssFeed{ + XMLName: xml.Name{Space: "", Local: "channel"}, + Title: "Lorem ipsum feed for an interval of 1 minutes", + Link: "http://example.com/", + Description: "This is a constantly updating lorem ipsum feed", + Language: "", + Copyright: "Michael Bertolacci, licensed under a Creative Commons Attribution 3.0 Unported License.", + ManagingEditor: "", + WebMaster: "", + PubDate: "Tue, 30 Oct 2018 23:22:00 GMT", + LastBuildDate: "Tue, 30 Oct 2018 23:22:37 GMT", + Category: "", + Generator: "RSS for Node", + Docs: "", + Cloud: "", + Ttl: 60, + Rating: "", + SkipHours: "", + SkipDays: "", + Image: (*RssImage)(nil), + TextInput: (*RssTextInput)(nil), + Items: []*RssItem{ + { + XMLName: xml.Name{Space: "", Local: "item"}, + Title: "Lorem ipsum 2018-10-30T23:22:00+00:00", + Link: "http://example.com/test/1540941720", + Description: "Exercitation ut Lorem sint proident.", + Content: (*RssContent)(nil), + Author: "", + Category: "", + Comments: "", + Enclosure: (*RssEnclosure)(nil), + Guid: &RssGuid{XMLName: xml.Name{Local: "guid"}, Id: "http://example.com/test/1540941720", IsPermaLink: "true"}, + PubDate: "Tue, 30 Oct 2018 23:22:00 GMT", + Source: "", + }, + { + XMLName: xml.Name{Space: "", Local: "item"}, + Title: "Lorem ipsum 2018-10-30T23:21:00+00:00", + Link: "http://example.com/test/1540941660", + Description: "Ea est do quis fugiat exercitation.", + Content: (*RssContent)(nil), + Author: "", + Category: "", + Comments: "", + Enclosure: (*RssEnclosure)(nil), + Guid: &RssGuid{XMLName: xml.Name{Local: "guid"}, Id: "http://example.com/test/1540941660", IsPermaLink: "true"}, + PubDate: "Tue, 30 Oct 2018 23:21:00 GMT", + Source: "", + }, + { + XMLName: xml.Name{Space: "", Local: "item"}, + Title: "Lorem ipsum 2018-10-30T23:20:00+00:00", + Link: "http://example.com/test/1540941600", + Description: "Ipsum velit cillum ad laborum sit nulla exercitation consequat sint veniam culpa veniam voluptate incididunt.", + Content: (*RssContent)(nil), + Author: "", + Category: "", + Comments: "", + Enclosure: (*RssEnclosure)(nil), + Guid: &RssGuid{XMLName: xml.Name{Local: "guid"}, Id: "http://example.com/test/1540941600", IsPermaLink: "true"}, + PubDate: "Tue, 30 Oct 2018 23:20:00 GMT", + Source: "", + }, + { + XMLName: xml.Name{Space: "", Local: "item"}, + Title: "Lorem ipsum 2018-10-30T23:19:00+00:00", + Link: "http://example.com/test/1540941540", + Description: "Ullamco pariatur aliqua consequat ea veniam id qui incididunt laborum.", + Content: (*RssContent)(nil), + Author: "", + Category: "", + Comments: "", + Enclosure: (*RssEnclosure)(nil), + Guid: &RssGuid{XMLName: xml.Name{Local: "guid"}, Id: "http://example.com/test/1540941540", IsPermaLink: "true"}, + PubDate: "Tue, 30 Oct 2018 23:19:00 GMT", + Source: "", + }, + { + XMLName: xml.Name{Space: "", Local: "item"}, + Title: "Lorem ipsum 2018-10-30T23:18:00+00:00", + Link: "http://example.com/test/1540941480", + Description: "Velit proident aliquip aliquip anim mollit voluptate laboris voluptate et occaecat occaecat laboris ea nulla.", + Content: (*RssContent)(nil), + Author: "", + Category: "", + Comments: "", + Enclosure: (*RssEnclosure)(nil), + Guid: &RssGuid{XMLName: xml.Name{Local: "guid"}, Id: "http://example.com/test/1540941480", IsPermaLink: "true"}, + PubDate: "Tue, 30 Oct 2018 23:18:00 GMT", + Source: "", + }, + { + XMLName: xml.Name{Space: "", Local: "item"}, + Title: "Lorem ipsum 2018-10-30T23:17:00+00:00", + Link: "http://example.com/test/1540941420", + Description: "Do in quis mollit consequat id in minim laborum sint exercitation laborum elit officia.", + Content: (*RssContent)(nil), + Author: "", + Category: "", + Comments: "", + Enclosure: (*RssEnclosure)(nil), + Guid: &RssGuid{XMLName: xml.Name{Local: "guid"}, Id: "http://example.com/test/1540941420", IsPermaLink: "true"}, + PubDate: "Tue, 30 Oct 2018 23:17:00 GMT", + Source: "", + }, + { + XMLName: xml.Name{Space: "", Local: "item"}, + Title: "Lorem ipsum 2018-10-30T23:16:00+00:00", + Link: "http://example.com/test/1540941360", + Description: "Irure id sint ullamco Lorem magna consectetur officia adipisicing duis incididunt.", + Content: (*RssContent)(nil), + Author: "", + Category: "", + Comments: "", + Enclosure: (*RssEnclosure)(nil), + Guid: &RssGuid{XMLName: xml.Name{Local: "guid"}, Id: "http://example.com/test/1540941360", IsPermaLink: "true"}, + PubDate: "Tue, 30 Oct 2018 23:16:00 GMT", + Source: "", + }, + { + XMLName: xml.Name{Space: "", Local: "item"}, + Title: "Lorem ipsum 2018-10-30T23:15:00+00:00", + Link: "http://example.com/test/1540941300", + Description: "Sunt anim excepteur esse nisi commodo culpa laborum exercitation ad anim ex elit.", + Content: (*RssContent)(nil), + Author: "", + Category: "", + Comments: "", + Enclosure: (*RssEnclosure)(nil), + Guid: &RssGuid{XMLName: xml.Name{Local: "guid"}, Id: "http://example.com/test/1540941300", IsPermaLink: "true"}, + PubDate: "Tue, 30 Oct 2018 23:15:00 GMT", + Source: "", + }, + { + XMLName: xml.Name{Space: "", Local: "item"}, + Title: "Lorem ipsum 2018-10-30T23:14:00+00:00", + Link: "http://example.com/test/1540941240", + Description: "Excepteur aliquip fugiat ex labore nisi.", + Content: (*RssContent)(nil), + Author: "", + Category: "", + Comments: "", + Enclosure: (*RssEnclosure)(nil), + Guid: &RssGuid{XMLName: xml.Name{Local: "guid"}, Id: "http://example.com/test/1540941240", IsPermaLink: "true"}, + PubDate: "Tue, 30 Oct 2018 23:14:00 GMT", + Source: "", + }, + { + XMLName: xml.Name{Space: "", Local: "item"}, + Title: "Lorem ipsum 2018-10-30T23:13:00+00:00", + Link: "http://example.com/test/1540941180", + Description: "Id proident adipisicing proident pariatur aute pariatur pariatur dolor dolor in voluptate dolor.", + Content: (*RssContent)(nil), + Author: "", + Category: "", + Comments: "", + Enclosure: (*RssEnclosure)(nil), + Guid: &RssGuid{XMLName: xml.Name{Local: "guid"}, Id: "http://example.com/test/1540941180", IsPermaLink: "true"}, + PubDate: "Tue, 30 Oct 2018 23:13:00 GMT", + Source: "", + }, + }, + }, +} + +var testAtomFeedXML = AtomFeed{ + XMLName: xml.Name{Space: "", Local: "feed"}, + Xmlns: "", + Title: "Lorem ipsum feed for an interval of 1 minutes", + Id: "", + Updated: "", + Category: "", + Icon: "", + Logo: "", + Rights: "", + Subtitle: "", + Link: &AtomLink{ + XMLName: xml.Name{Space: "", Local: "link"}, + Href: "", + Rel: "", + Type: "", + Length: "", + }, + Author: &AtomAuthor{ + XMLName: xml.Name{Space: "", Local: "author"}, + AtomPerson: AtomPerson{}, + }, + Contributor: (*AtomContributor)(nil), + Entries: []*AtomEntry{ + { + XMLName: xml.Name{Space: "", Local: "entry"}, + Xmlns: "", + Title: "Lorem ipsum 2018-10-30T23:22:00+00:00", + Updated: "", + Id: "", + Category: "", + Content: (*AtomContent)(nil), + Rights: "", + Source: "", + Published: "", + Contributor: (*AtomContributor)(nil), + Links: nil, + Summary: (*AtomSummary)(nil), + Author: (*AtomAuthor)(nil), + }, + { + XMLName: xml.Name{Space: "", Local: "entry"}, + Xmlns: "", + Title: "Lorem ipsum 2018-10-30T23:21:00+00:00", + Updated: "", + Id: "", + Category: "", + Content: (*AtomContent)(nil), + Rights: "", + Source: "", + Published: "", + Contributor: (*AtomContributor)(nil), + Links: nil, + Summary: (*AtomSummary)(nil), + Author: (*AtomAuthor)(nil), + }, + { + XMLName: xml.Name{Space: "", Local: "entry"}, + Xmlns: "", + Title: "Lorem ipsum 2018-10-30T23:20:00+00:00", + Updated: "", + Id: "", + Category: "", + Content: (*AtomContent)(nil), + Rights: "", + Source: "", + Published: "", + Contributor: (*AtomContributor)(nil), + Links: nil, + Summary: (*AtomSummary)(nil), + Author: (*AtomAuthor)(nil), + }, + { + XMLName: xml.Name{Space: "", Local: "entry"}, + Xmlns: "", + Title: "Lorem ipsum 2018-10-30T23:19:00+00:00", + Updated: "", + Id: "", + Category: "", + Content: (*AtomContent)(nil), + Rights: "", + Source: "", + Published: "", + Contributor: (*AtomContributor)(nil), + Links: nil, + Summary: (*AtomSummary)(nil), + Author: (*AtomAuthor)(nil), + }, + { + XMLName: xml.Name{Space: "", Local: "entry"}, + Xmlns: "", + Title: "Lorem ipsum 2018-10-30T23:18:00+00:00", + Updated: "", + Id: "", + Category: "", + Content: (*AtomContent)(nil), + Rights: "", + Source: "", + Published: "", + Contributor: (*AtomContributor)(nil), + Links: nil, + Summary: (*AtomSummary)(nil), + Author: (*AtomAuthor)(nil), + }, + { + XMLName: xml.Name{Space: "", Local: "entry"}, + Xmlns: "", + Title: "Lorem ipsum 2018-10-30T23:17:00+00:00", + Updated: "", + Id: "", + Category: "", + Content: (*AtomContent)(nil), + Rights: "", + Source: "", + Published: "", + Contributor: (*AtomContributor)(nil), + Links: nil, + Summary: (*AtomSummary)(nil), + Author: (*AtomAuthor)(nil), + }, + { + XMLName: xml.Name{Space: "", Local: "entry"}, + Xmlns: "", + Title: "Lorem ipsum 2018-10-30T23:16:00+00:00", + Updated: "", + Id: "", + Category: "", + Content: (*AtomContent)(nil), + Rights: "", + Source: "", + Published: "", + Contributor: (*AtomContributor)(nil), + Links: nil, + Summary: (*AtomSummary)(nil), + Author: (*AtomAuthor)(nil), + }, + { + XMLName: xml.Name{Space: "", Local: "entry"}, + Xmlns: "", + Title: "Lorem ipsum 2018-10-30T23:15:00+00:00", + Updated: "", + Id: "", + Category: "", + Content: (*AtomContent)(nil), + Rights: "", + Source: "", + Published: "", + Contributor: (*AtomContributor)(nil), + Links: nil, + Summary: (*AtomSummary)(nil), + Author: (*AtomAuthor)(nil), + }, + { + XMLName: xml.Name{Space: "", Local: "entry"}, + Xmlns: "", + Title: "Lorem ipsum 2018-10-30T23:14:00+00:00", + Updated: "", + Id: "", + Category: "", + Content: (*AtomContent)(nil), + Rights: "", + Source: "", + Published: "", + Contributor: (*AtomContributor)(nil), + Links: nil, + Summary: (*AtomSummary)(nil), + Author: (*AtomAuthor)(nil), + }, + { + XMLName: xml.Name{Space: "", Local: "entry"}, + Xmlns: "", + Title: "Lorem ipsum 2018-10-30T23:13:00+00:00", + Updated: "", + Id: "", + Category: "", + Content: (*AtomContent)(nil), + Rights: "", + Source: "", + Published: "", + Contributor: (*AtomContributor)(nil), + Links: nil, + Summary: (*AtomSummary)(nil), + Author: (*AtomAuthor)(nil), + }, + }, +} + +func TestRssUnmarshal(t *testing.T) { + var xmlFeed RssFeedXml + xmlFile, err := os.Open("test.rss") + if err != nil { + panic("AHH file bad") + } + bytes, _ := io.ReadAll(xmlFile) + if err := xml.Unmarshal(bytes, &xmlFeed); err != nil { + panic(err) + } + + if !reflect.DeepEqual(testRssFeedXML, xmlFeed) { + diffs := pretty.Diff(testRssFeedXML, xmlFeed) + t.Log(pretty.Println(diffs)) + t.Error("object was not unmarshalled correctly") + } + +} + +func TestAtomUnmarshal(t *testing.T) { + var xmlFeed AtomFeed + xmlFile, err := os.Open("test.atom") + if err != nil { + panic("AHH file bad") + } + bytes, _ := io.ReadAll(xmlFile) + if err := xml.Unmarshal(bytes, &xmlFeed); err != nil { + panic(err) + } + + if !reflect.DeepEqual(testAtomFeedXML, xmlFeed) { + diffs := pretty.Diff(testAtomFeedXML, xmlFeed) + t.Log(pretty.Println(diffs)) + t.Error("object was not unmarshalled correctly") + } +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..4e0759c --- /dev/null +++ b/doc.go @@ -0,0 +1,73 @@ +/* +Syndication (feed) generator library for golang. + +Installing + + go get github.com/gorilla/feeds + +Feeds provides a simple, generic Feed interface with a generic Item object as well as RSS, Atom and JSON Feed specific RssFeed, AtomFeed and JSONFeed objects which allow access to all of each spec's defined elements. + +Examples + +Create a Feed and some Items in that feed using the generic interfaces: + + import ( + "time" + . "github.com/gorilla/feeds" + ) + + now = time.Now() + + feed := &Feed{ + Title: "jmoiron.net blog", + Link: &Link{Href: "http://jmoiron.net/blog"}, + Description: "discussion about tech, footie, photos", + Author: &Author{Name: "Jason Moiron", Email: "jmoiron@jmoiron.net"}, + Created: now, + Copyright: "This work is copyright © Benjamin Button", + } + + feed.Items = []*Item{ + &Item{ + Title: "Limiting Concurrency in Go", + Link: &Link{Href: "http://jmoiron.net/blog/limiting-concurrency-in-go/"}, + Description: "A discussion on controlled parallelism in golang", + Author: &Author{Name: "Jason Moiron", Email: "jmoiron@jmoiron.net"}, + Created: now, + }, + &Item{ + Title: "Logic-less Template Redux", + Link: &Link{Href: "http://jmoiron.net/blog/logicless-template-redux/"}, + Description: "More thoughts on logicless templates", + Created: now, + }, + &Item{ + Title: "Idiomatic Code Reuse in Go", + Link: &Link{Href: "http://jmoiron.net/blog/idiomatic-code-reuse-in-go/"}, + Description: "How to use interfaces effectively", + Created: now, + }, + } + +From here, you can output Atom, RSS, or JSON Feed versions of this feed easily + + atom, err := feed.ToAtom() + rss, err := feed.ToRss() + json, err := feed.ToJSON() + +You can also get access to the underlying objects that feeds uses to export its XML + + atomFeed := (&Atom{Feed: feed}).AtomFeed() + rssFeed := (&Rss{Feed: feed}).RssFeed() + jsonFeed := (&JSON{Feed: feed}).JSONFeed() + +From here, you can modify or add each syndication's specific fields before outputting + + atomFeed.Subtitle = "plays the blues" + atom, err := ToXML(atomFeed) + rssFeed.Generator = "gorilla/feeds v1.0 (github.com/gorilla/feeds)" + rss, err := ToXML(rssFeed) + jsonFeed.NextUrl = "https://www.example.com/feed.json?page=2" + json, err := jsonFeed.ToJSON() +*/ +package feeds diff --git a/feed.go b/feed.go new file mode 100644 index 0000000..929c226 --- /dev/null +++ b/feed.go @@ -0,0 +1,146 @@ +package feeds + +import ( + "encoding/json" + "encoding/xml" + "io" + "sort" + "time" +) + +type Link struct { + Href, Rel, Type, Length string +} + +type Author struct { + Name, Email string +} + +type Image struct { + Url, Title, Link string + Width, Height int +} + +type Enclosure struct { + Url, Length, Type string +} + +type Item struct { + Title string + Link *Link + Source *Link + Author *Author + Description string // used as description in rss, summary in atom + Id string // used as guid in rss, id in atom + IsPermaLink string // an optional parameter for guid in rss + Updated time.Time + Created time.Time + Enclosure *Enclosure + Content string +} + +type Feed struct { + Title string + Link *Link + Description string + Author *Author + Updated time.Time + Created time.Time + Id string + Subtitle string + Items []*Item + Copyright string + Image *Image +} + +// add a new Item to a Feed +func (f *Feed) Add(item *Item) { + f.Items = append(f.Items, item) +} + +// returns the first non-zero time formatted as a string or "" +func anyTimeFormat(format string, times ...time.Time) string { + for _, t := range times { + if !t.IsZero() { + return t.Format(format) + } + } + return "" +} + +// interface used by ToXML to get a object suitable for exporting XML. +type XmlFeed interface { + FeedXml() interface{} +} + +// turn a feed object (either a Feed, AtomFeed, or RssFeed) into xml +// returns an error if xml marshaling fails +func ToXML(feed XmlFeed) (string, error) { + x := feed.FeedXml() + data, err := xml.MarshalIndent(x, "", " ") + if err != nil { + return "", err + } + // strip empty line from default xml header + s := xml.Header[:len(xml.Header)-1] + string(data) + return s, nil +} + +// WriteXML writes a feed object (either a Feed, AtomFeed, or RssFeed) as XML into +// the writer. Returns an error if XML marshaling fails. +func WriteXML(feed XmlFeed, w io.Writer) error { + x := feed.FeedXml() + // write default xml header, without the newline + if _, err := w.Write([]byte(xml.Header[:len(xml.Header)-1])); err != nil { + return err + } + e := xml.NewEncoder(w) + e.Indent("", " ") + return e.Encode(x) +} + +// creates an Atom representation of this feed +func (f *Feed) ToAtom() (string, error) { + a := &Atom{f} + return ToXML(a) +} + +// WriteAtom writes an Atom representation of this feed to the writer. +func (f *Feed) WriteAtom(w io.Writer) error { + return WriteXML(&Atom{f}, w) +} + +// creates an Rss representation of this feed +func (f *Feed) ToRss() (string, error) { + r := &Rss{f} + return ToXML(r) +} + +// WriteRss writes an RSS representation of this feed to the writer. +func (f *Feed) WriteRss(w io.Writer) error { + return WriteXML(&Rss{f}, w) +} + +// ToJSON creates a JSON Feed representation of this feed +func (f *Feed) ToJSON() (string, error) { + j := &JSON{f} + return j.ToJSON() +} + +// WriteJSON writes an JSON representation of this feed to the writer. +func (f *Feed) WriteJSON(w io.Writer) error { + j := &JSON{f} + feed := j.JSONFeed() + + e := json.NewEncoder(w) + e.SetIndent("", " ") + return e.Encode(feed) +} + +// Sort sorts the Items in the feed with the given less function. +func (f *Feed) Sort(less func(a, b *Item) bool) { + lessFunc := func(i, j int) bool { + return less(f.Items[i], f.Items[j]) + } + sort.SliceStable(f.Items, lessFunc) +} diff --git a/feed_test.go b/feed_test.go new file mode 100644 index 0000000..837cfa6 --- /dev/null +++ b/feed_test.go @@ -0,0 +1,603 @@ +package feeds + +import ( + "bytes" + "testing" + "time" +) + +var atomOutput = ` + jmoiron.net blog + http://jmoiron.net/blog + 2013-01-16T21:52:35-05:00 + This work is copyright © Benjamin Button + discussion about tech, footie, photos + + + Jason Moiron + jmoiron@jmoiron.net + + + Limiting Concurrency in Go + 2013-01-16T21:52:35-05:00 + tag:jmoiron.net,2013-01-16:/blog/limiting-concurrency-in-go/ + <p>Go's goroutines make it easy to make <a href="http://collectiveidea.com/blog/archives/2012/12/03/playing-with-go-embarrassingly-parallel-scripts/">embarrassingly parallel programs</a>, but in many &quot;real world&quot; cases resources can be limited and attempting to do everything at once can exhaust your access to them.</p> + + A discussion on controlled parallelism in golang + + Jason Moiron + jmoiron@jmoiron.net + + + + Logic-less Template Redux + 2013-01-16T21:52:35-05:00 + tag:jmoiron.net,2013-01-16:/blog/logicless-template-redux/ + + More thoughts on logicless templates + + + Idiomatic Code Reuse in Go + 2013-01-16T21:52:35-05:00 + tag:jmoiron.net,2013-01-16:/blog/idiomatic-code-reuse-in-go/ + + + How to use interfaces <em>effectively</em> + + + Never Gonna Give You Up Mp3 + 2013-01-16T21:52:35-05:00 + tag:example.com,2013-01-16:/RickRoll.mp3 + + + Never gonna give you up - Never gonna let you down. + + + String formatting in Go + 2013-01-16T21:52:35-05:00 + tag:example.com,2013-01-16:/strings + + How to use things like %s, %v, %d, etc. + + + Go Proverb #1 + 2013-01-16T21:52:35-05:00 + tag:go-proverbs.github.io,2013-01-16:/ + Don't communicate by sharing memory, share memory by communicating. + + +` + +var rssOutput = ` + + jmoiron.net blog + http://jmoiron.net/blog + discussion about tech, footie, photos + This work is copyright © Benjamin Button + jmoiron@jmoiron.net (Jason Moiron) + Wed, 16 Jan 2013 21:52:35 -0500 + + Limiting Concurrency in Go + http://jmoiron.net/blog/limiting-concurrency-in-go/ + A discussion on controlled parallelism in golang + 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.

]]>
+ Jason Moiron + Wed, 16 Jan 2013 21:52:35 -0500 +
+ + Logic-less Template Redux + http://jmoiron.net/blog/logicless-template-redux/ + More thoughts on logicless templates + Wed, 16 Jan 2013 21:52:35 -0500 + + + Idiomatic Code Reuse in Go + http://jmoiron.net/blog/idiomatic-code-reuse-in-go/ + How to use interfaces <em>effectively</em> + + Wed, 16 Jan 2013 21:52:35 -0500 + + + Never Gonna Give You Up Mp3 + http://example.com/RickRoll.mp3 + Never gonna give you up - Never gonna let you down. + + Wed, 16 Jan 2013 21:52:35 -0500 + + + String formatting in Go + http://example.com/strings + How to use things like %s, %v, %d, etc. + Wed, 16 Jan 2013 21:52:35 -0500 + + + Go Proverb #1 + https://go-proverbs.github.io/ + + + Wed, 16 Jan 2013 21:52:35 -0500 + +
+
` + +var jsonOutput = `{ + "version": "https://jsonfeed.org/version/1.1", + "title": "jmoiron.net blog", + "home_page_url": "http://jmoiron.net/blog", + "description": "discussion about tech, footie, photos", + "author": { + "name": "Jason Moiron" + }, + "authors": [ + { + "name": "Jason Moiron" + } + ], + "items": [ + { + "id": "", + "url": "http://jmoiron.net/blog/limiting-concurrency-in-go/", + "title": "Limiting Concurrency in Go", + "content_html": "\u003cp\u003eGo's goroutines make it easy to make \u003ca href=\"http://collectiveidea.com/blog/archives/2012/12/03/playing-with-go-embarrassingly-parallel-scripts/\"\u003eembarrassingly parallel programs\u003c/a\u003e, but in many \u0026quot;real world\u0026quot; cases resources can be limited and attempting to do everything at once can exhaust your access to them.\u003c/p\u003e", + "summary": "A discussion on controlled parallelism in golang", + "date_published": "2013-01-16T21:52:35-05:00", + "author": { + "name": "Jason Moiron" + }, + "authors": [ + { + "name": "Jason Moiron" + } + ] + }, + { + "id": "", + "url": "http://jmoiron.net/blog/logicless-template-redux/", + "title": "Logic-less Template Redux", + "summary": "More thoughts on logicless templates", + "date_published": "2013-01-16T21:52:35-05:00" + }, + { + "id": "", + "url": "http://jmoiron.net/blog/idiomatic-code-reuse-in-go/", + "title": "Idiomatic Code Reuse in Go", + "summary": "How to use interfaces \u003cem\u003eeffectively\u003c/em\u003e", + "image": "http://example.com/cover.jpg", + "date_published": "2013-01-16T21:52:35-05:00" + }, + { + "id": "", + "url": "http://example.com/RickRoll.mp3", + "title": "Never Gonna Give You Up Mp3", + "summary": "Never gonna give you up - Never gonna let you down.", + "date_published": "2013-01-16T21:52:35-05:00" + }, + { + "id": "", + "url": "http://example.com/strings", + "title": "String formatting in Go", + "summary": "How to use things like %s, %v, %d, etc.", + "date_published": "2013-01-16T21:52:35-05:00" + }, + { + "id": "", + "url": "https://go-proverbs.github.io/", + "title": "Go Proverb #1", + "content_html": "Don't communicate by sharing memory, share memory by communicating.", + "date_published": "2013-01-16T21:52:35-05:00" + } + ] +}` + +func TestFeed(t *testing.T) { + now, err := time.Parse(time.RFC3339, "2013-01-16T21:52:35-05:00") + if err != nil { + t.Error(err) + } + tz := time.FixedZone("EST", -5*60*60) + now = now.In(tz) + + feed := &Feed{ + Title: "jmoiron.net blog", + Link: &Link{Href: "http://jmoiron.net/blog"}, + Description: "discussion about tech, footie, photos", + Author: &Author{Name: "Jason Moiron", Email: "jmoiron@jmoiron.net"}, + Created: now, + Copyright: "This work is copyright © Benjamin Button", + } + + feed.Items = []*Item{ + { + Title: "Limiting Concurrency in Go", + Link: &Link{Href: "http://jmoiron.net/blog/limiting-concurrency-in-go/"}, + Description: "A discussion on controlled parallelism in golang", + Author: &Author{Name: "Jason Moiron", Email: "jmoiron@jmoiron.net"}, + Created: now, + Content: `

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.

`, + }, + { + Title: "Logic-less Template Redux", + Link: &Link{Href: "http://jmoiron.net/blog/logicless-template-redux/"}, + Description: "More thoughts on logicless templates", + Created: now, + }, + { + Title: "Idiomatic Code Reuse in Go", + Link: &Link{Href: "http://jmoiron.net/blog/idiomatic-code-reuse-in-go/"}, + Description: "How to use interfaces effectively", + Enclosure: &Enclosure{Url: "http://example.com/cover.jpg", Length: "123456", Type: "image/jpg"}, + Created: now, + }, + { + Title: "Never Gonna Give You Up Mp3", + Link: &Link{Href: "http://example.com/RickRoll.mp3"}, + Enclosure: &Enclosure{Url: "http://example.com/RickRoll.mp3", Length: "123456", Type: "audio/mpeg"}, + Description: "Never gonna give you up - Never gonna let you down.", + Created: now, + }, + { + Title: "String formatting in Go", + Link: &Link{Href: "http://example.com/strings"}, + Description: "How to use things like %s, %v, %d, etc.", + Created: now, + }, + { + Title: "Go Proverb #1", + Link: &Link{Href: "https://go-proverbs.github.io/"}, + Content: "Don't communicate by sharing memory, share memory by communicating.", + Created: now, + }} + + atom, err := feed.ToAtom() + if err != nil { + t.Errorf("unexpected error encoding Atom: %v", err) + } + if atom != atomOutput { + t.Errorf("Atom not what was expected. Got:\n%s\n\nExpected:\n%s\n", atom, atomOutput) + } + var buf bytes.Buffer + if err := feed.WriteAtom(&buf); err != nil { + t.Errorf("unexpected error writing Atom: %v", err) + } + if got := buf.String(); got != atomOutput { + t.Errorf("Atom not what was expected. Got:\n%s\n\nExpected:\n%s\n", got, atomOutput) + } + + rss, err := feed.ToRss() + if err != nil { + t.Errorf("unexpected error encoding RSS: %v", err) + } + if rss != rssOutput { + t.Errorf("Rss not what was expected. Got:\n%s\n\nExpected:\n%s\n", rss, rssOutput) + } + buf.Reset() + if err := feed.WriteRss(&buf); err != nil { + t.Errorf("unexpected error writing RSS: %v", err) + } + if got := buf.String(); got != rssOutput { + t.Errorf("Rss not what was expected. Got:\n%s\n\nExpected:\n%s\n", got, rssOutput) + } + + json, err := feed.ToJSON() + if err != nil { + t.Errorf("unexpected error encoding JSON: %v", err) + } + if json != jsonOutput { + t.Errorf("JSON not what was expected. Got:\n%s\n\nExpected:\n%s\n", json, jsonOutput) + } + buf.Reset() + if err := feed.WriteJSON(&buf); err != nil { + t.Errorf("unexpected error writing JSON: %v", err) + } + if got := buf.String(); got != jsonOutput+"\n" { //json.Encode appends a newline after the JSON output: https://github.com/golang/go/commit/6f25f1d4c901417af1da65e41992d71c30f64f8f#diff-50848cbd686f250623a2ef6ddb07e157 + t.Errorf("JSON not what was expected. Got:\n||%s||\n\nExpected:\n||%s||\n", got, jsonOutput) + } +} + +var atomOutputSorted = ` + jmoiron.net blog + http://jmoiron.net/blog + 2013-01-16T21:52:35-05:00 + This work is copyright © Benjamin Button + discussion about tech, footie, photos + + + Jason Moiron + jmoiron@jmoiron.net + + + Limiting Concurrency in Go + 2013-01-18T21:52:35-05:00 + tag:jmoiron.net,2013-01-18:/blog/limiting-concurrency-in-go/ + + + + Logic-less Template Redux + 2013-01-17T21:52:35-05:00 + tag:jmoiron.net,2013-01-17:/blog/logicless-template-redux/ + + + + Idiomatic Code Reuse in Go + 2013-01-17T09:52:35-05:00 + tag:jmoiron.net,2013-01-17:/blog/idiomatic-code-reuse-in-go/ + + + + Never Gonna Give You Up Mp3 + 2013-01-17T07:52:35-05:00 + tag:example.com,2013-01-17:/RickRoll.mp3 + + + + String formatting in Go + 2013-01-16T21:52:35-05:00 + tag:example.com,2013-01-16:/strings + + +` + +var rssOutputSorted = ` + + jmoiron.net blog + http://jmoiron.net/blog + discussion about tech, footie, photos + This work is copyright © Benjamin Button + jmoiron@jmoiron.net (Jason Moiron) + Wed, 16 Jan 2013 21:52:35 -0500 + + Limiting Concurrency in Go + http://jmoiron.net/blog/limiting-concurrency-in-go/ + + Fri, 18 Jan 2013 21:52:35 -0500 + + + Logic-less Template Redux + http://jmoiron.net/blog/logicless-template-redux/ + + Thu, 17 Jan 2013 21:52:35 -0500 + + + Idiomatic Code Reuse in Go + http://jmoiron.net/blog/idiomatic-code-reuse-in-go/ + + Thu, 17 Jan 2013 09:52:35 -0500 + + + Never Gonna Give You Up Mp3 + http://example.com/RickRoll.mp3 + + Thu, 17 Jan 2013 07:52:35 -0500 + + + String formatting in Go + http://example.com/strings + + Wed, 16 Jan 2013 21:52:35 -0500 + + +` + +var jsonOutputSorted = `{ + "version": "https://jsonfeed.org/version/1.1", + "title": "jmoiron.net blog", + "home_page_url": "http://jmoiron.net/blog", + "description": "discussion about tech, footie, photos", + "author": { + "name": "Jason Moiron" + }, + "authors": [ + { + "name": "Jason Moiron" + } + ], + "items": [ + { + "id": "", + "url": "http://jmoiron.net/blog/limiting-concurrency-in-go/", + "title": "Limiting Concurrency in Go", + "date_published": "2013-01-18T21:52:35-05:00" + }, + { + "id": "", + "url": "http://jmoiron.net/blog/logicless-template-redux/", + "title": "Logic-less Template Redux", + "date_published": "2013-01-17T21:52:35-05:00" + }, + { + "id": "", + "url": "http://jmoiron.net/blog/idiomatic-code-reuse-in-go/", + "title": "Idiomatic Code Reuse in Go", + "date_published": "2013-01-17T09:52:35-05:00" + }, + { + "id": "", + "url": "http://example.com/RickRoll.mp3", + "title": "Never Gonna Give You Up Mp3", + "date_published": "2013-01-17T07:52:35-05:00" + }, + { + "id": "", + "url": "http://example.com/strings", + "title": "String formatting in Go", + "date_published": "2013-01-16T21:52:35-05:00" + } + ] +}` + +func TestFeedSorted(t *testing.T) { + now, err := time.Parse(time.RFC3339, "2013-01-16T21:52:35-05:00") + if err != nil { + t.Error(err) + } + tz := time.FixedZone("EST", -5*60*60) + now = now.In(tz) + + feed := &Feed{ + Title: "jmoiron.net blog", + Link: &Link{Href: "http://jmoiron.net/blog"}, + Description: "discussion about tech, footie, photos", + Author: &Author{Name: "Jason Moiron", Email: "jmoiron@jmoiron.net"}, + Created: now, + Copyright: "This work is copyright © Benjamin Button", + } + + feed.Items = []*Item{ + { + Title: "Limiting Concurrency in Go", + Link: &Link{Href: "http://jmoiron.net/blog/limiting-concurrency-in-go/"}, + Created: now.Add(time.Duration(time.Hour * 48)), + }, + { + Title: "Logic-less Template Redux", + Link: &Link{Href: "http://jmoiron.net/blog/logicless-template-redux/"}, + Created: now.Add(time.Duration(time.Hour * 24)), + }, + { + Title: "Idiomatic Code Reuse in Go", + Link: &Link{Href: "http://jmoiron.net/blog/idiomatic-code-reuse-in-go/"}, + Created: now.Add(time.Duration(time.Hour * 12)), + }, + { + Title: "Never Gonna Give You Up Mp3", + Link: &Link{Href: "http://example.com/RickRoll.mp3"}, + Created: now.Add(time.Duration(time.Hour * 10)), + }, + { + Title: "String formatting in Go", + Link: &Link{Href: "http://example.com/strings"}, + Created: now, + }} + + feed.Sort(func(a, b *Item) bool { + return a.Created.After(b.Created) + }) + atom, err := feed.ToAtom() + if err != nil { + t.Errorf("unexpected error encoding Atom: %v", err) + } + if atom != atomOutputSorted { + t.Errorf("Atom not what was expected. Got:\n%s\n\nExpected:\n%s\n", atom, atomOutputSorted) + } + var buf bytes.Buffer + if err := feed.WriteAtom(&buf); err != nil { + t.Errorf("unexpected error writing Atom: %v", err) + } + if got := buf.String(); got != atomOutputSorted { + t.Errorf("Atom not what was expected. Got:\n%s\n\nExpected:\n%s\n", got, atomOutputSorted) + } + + rss, err := feed.ToRss() + if err != nil { + t.Errorf("unexpected error encoding RSS: %v", err) + } + + if rss != rssOutputSorted { + t.Errorf("Rss not what was expected. Got:\n%s\n\nExpected:\n%s\n", rss, rssOutputSorted) + } + buf.Reset() + if err := feed.WriteRss(&buf); err != nil { + t.Errorf("unexpected error writing RSS: %v", err) + } + if got := buf.String(); got != rssOutputSorted { + t.Errorf("Rss not what was expected. Got:\n%s\n\nExpected:\n%s\n", got, rssOutputSorted) + } + + json, err := feed.ToJSON() + if err != nil { + t.Errorf("unexpected error encoding JSON: %v", err) + } + if json != jsonOutputSorted { + t.Errorf("JSON not what was expected. Got:\n%s\n\nExpected:\n%s\n", json, jsonOutputSorted) + } + buf.Reset() + if err := feed.WriteJSON(&buf); err != nil { + t.Errorf("unexpected error writing JSON: %v", err) + } + if got := buf.String(); got != jsonOutputSorted+"\n" { //json.Encode appends a newline after the JSON output: https://github.com/golang/go/commit/6f25f1d4c901417af1da65e41992d71c30f64f8f#diff-50848cbd686f250623a2ef6ddb07e157 + t.Errorf("JSON not what was expected. Got:\n||%s||\n\nExpected:\n||%s||\n", got, jsonOutputSorted) + } +} + +func TestFeedNil(t *testing.T) { + now, err := time.Parse(time.RFC3339, "2013-01-16T21:52:35-05:00") + if err != nil { + t.Error(err) + } + tz := time.FixedZone("EST", -5*60*60) + now = now.In(tz) + + feed := &Feed{ + Title: "jmoiron.net blog", + Link: nil, + Description: "discussion about tech, footie, photos", + Author: nil, + Created: now, + Copyright: "This work is copyright © Benjamin Button", + } + + feed.Items = []*Item{ + { + Title: "Limiting Concurrency in Go", + Link: nil, + Description: "A discussion on controlled parallelism in golang", + Author: nil, + Created: now, + Content: `

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 @@ + + + <![CDATA[Lorem ipsum feed for an interval of 1 minutes]]> + + http://example.com/ + RSS for Node + Tue, 30 Oct 2018 23:22:37 GMT + + Tue, 30 Oct 2018 23:22:00 GMT + + 60 + + <![CDATA[Lorem ipsum 2018-10-30T23:22:00+00:00]]> + + http://example.com/test/1540941720 + http://example.com/test/1540941720 + + Tue, 30 Oct 2018 23:22:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:21:00+00:00]]> + + http://example.com/test/1540941660 + http://example.com/test/1540941660 + + Tue, 30 Oct 2018 23:21:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:20:00+00:00]]> + + http://example.com/test/1540941600 + http://example.com/test/1540941600 + + Tue, 30 Oct 2018 23:20:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:19:00+00:00]]> + + http://example.com/test/1540941540 + http://example.com/test/1540941540 + + Tue, 30 Oct 2018 23:19:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:18:00+00:00]]> + + http://example.com/test/1540941480 + http://example.com/test/1540941480 + + Tue, 30 Oct 2018 23:18:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:17:00+00:00]]> + + http://example.com/test/1540941420 + http://example.com/test/1540941420 + + Tue, 30 Oct 2018 23:17:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:16:00+00:00]]> + + http://example.com/test/1540941360 + http://example.com/test/1540941360 + + Tue, 30 Oct 2018 23:16:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:15:00+00:00]]> + + http://example.com/test/1540941300 + http://example.com/test/1540941300 + + Tue, 30 Oct 2018 23:15:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:14:00+00:00]]> + + http://example.com/test/1540941240 + http://example.com/test/1540941240 + + Tue, 30 Oct 2018 23:14:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:13:00+00:00]]> + + 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 @@ + + + + <![CDATA[Lorem ipsum feed for an interval of 1 minutes]]> + + http://example.com/ + RSS for Node + Tue, 30 Oct 2018 23:22:37 GMT + + Tue, 30 Oct 2018 23:22:00 GMT + + 60 + + <![CDATA[Lorem ipsum 2018-10-30T23:22:00+00:00]]> + + http://example.com/test/1540941720 + http://example.com/test/1540941720 + + Tue, 30 Oct 2018 23:22:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:21:00+00:00]]> + + http://example.com/test/1540941660 + http://example.com/test/1540941660 + + Tue, 30 Oct 2018 23:21:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:20:00+00:00]]> + + http://example.com/test/1540941600 + http://example.com/test/1540941600 + + Tue, 30 Oct 2018 23:20:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:19:00+00:00]]> + + http://example.com/test/1540941540 + http://example.com/test/1540941540 + + Tue, 30 Oct 2018 23:19:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:18:00+00:00]]> + + http://example.com/test/1540941480 + http://example.com/test/1540941480 + + Tue, 30 Oct 2018 23:18:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:17:00+00:00]]> + + http://example.com/test/1540941420 + http://example.com/test/1540941420 + + Tue, 30 Oct 2018 23:17:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:16:00+00:00]]> + + http://example.com/test/1540941360 + http://example.com/test/1540941360 + + Tue, 30 Oct 2018 23:16:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:15:00+00:00]]> + + http://example.com/test/1540941300 + http://example.com/test/1540941300 + + Tue, 30 Oct 2018 23:15:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:14:00+00:00]]> + + http://example.com/test/1540941240 + http://example.com/test/1540941240 + + Tue, 30 Oct 2018 23:14:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:13:00+00:00]]> + + 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") + } +}