diff options
-rw-r--r-- | api/entry.go | 3 | ||||
-rw-r--r-- | client/model.go | 1 | ||||
-rw-r--r-- | database/migrations.go | 6 | ||||
-rw-r--r-- | model/entry.go | 1 | ||||
-rw-r--r-- | reader/atom/atom_10.go | 37 | ||||
-rw-r--r-- | reader/atom/atom_10_test.go | 45 | ||||
-rw-r--r-- | reader/json/json.go | 2 | ||||
-rw-r--r-- | reader/json/parser_test.go | 42 | ||||
-rw-r--r-- | reader/rss/parser_test.go | 66 | ||||
-rw-r--r-- | reader/rss/rss.go | 23 | ||||
-rw-r--r-- | storage/entry.go | 24 | ||||
-rw-r--r-- | storage/entry_query_builder.go | 13 |
12 files changed, 252 insertions, 11 deletions
diff --git a/api/entry.go b/api/entry.go index 839f6cac..e8d6c908 100644 --- a/api/entry.go +++ b/api/entry.go @@ -132,6 +132,8 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int return } + tags := request.QueryStringParamList(r, "tags") + builder := h.store.NewEntryQueryBuilder(userID) builder.WithFeedID(feedID) builder.WithCategoryID(categoryID) @@ -140,6 +142,7 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int builder.WithDirection(direction) builder.WithOffset(offset) builder.WithLimit(limit) + builder.WithTags(tags) configureFilters(builder, r) entries, err := builder.GetEntries() diff --git a/client/model.go b/client/model.go index 75822167..170b6cfe 100644 --- a/client/model.go +++ b/client/model.go @@ -218,6 +218,7 @@ type Entry struct { ReadingTime int `json:"reading_time"` Enclosures Enclosures `json:"enclosures,omitempty"` Feed *Feed `json:"feed,omitempty"` + Tags []string `json:"tags"` } // Entries represents a list of entries. diff --git a/database/migrations.go b/database/migrations.go index 208c7b57..433120a4 100644 --- a/database/migrations.go +++ b/database/migrations.go @@ -638,4 +638,10 @@ var migrations = []func(tx *sql.Tx) error{ _, err = tx.Exec(sql) return err }, + func(tx *sql.Tx) (err error) { + _, err = tx.Exec(` + ALTER TABLE entries ADD COLUMN tags text[] default '{}'; + `) + return + }, } diff --git a/model/entry.go b/model/entry.go index ee54208a..3290d273 100644 --- a/model/entry.go +++ b/model/entry.go @@ -37,6 +37,7 @@ type Entry struct { ReadingTime int `json:"reading_time"` Enclosures EnclosureList `json:"enclosures"` Feed *Feed `json:"feed,omitempty"` + Tags []string `json:"tags"` } // Entries represents a list of entries. diff --git a/reader/atom/atom_10.go b/reader/atom/atom_10.go index 6a28c064..344b0aba 100644 --- a/reader/atom/atom_10.go +++ b/reader/atom/atom_10.go @@ -80,14 +80,15 @@ func (a *atom10Feed) Transform(baseURL string) *model.Feed { } type atom10Entry struct { - ID string `xml:"id"` - Title atom10Text `xml:"title"` - Published string `xml:"published"` - Updated string `xml:"updated"` - Links atomLinks `xml:"link"` - Summary atom10Text `xml:"summary"` - Content atom10Text `xml:"http://www.w3.org/2005/Atom content"` - Authors atomAuthors `xml:"author"` + ID string `xml:"id"` + Title atom10Text `xml:"title"` + Published string `xml:"published"` + Updated string `xml:"updated"` + Links atomLinks `xml:"link"` + Summary atom10Text `xml:"summary"` + Content atom10Text `xml:"http://www.w3.org/2005/Atom content"` + Authors atomAuthors `xml:"author"` + Categories []atom10Category `xml:"category"` media.Element } @@ -101,6 +102,7 @@ func (a *atom10Entry) Transform() *model.Entry { entry.Title = a.entryTitle() entry.Enclosures = a.entryEnclosures() entry.CommentsURL = a.entryCommentsURL() + entry.Tags = a.entryCategories() return entry } @@ -214,6 +216,20 @@ func (a *atom10Entry) entryEnclosures() model.EnclosureList { return enclosures } +func (r *atom10Entry) entryCategories() []string { + var categoryList []string + + for _, atomCategory := range r.Categories { + if strings.TrimSpace(atomCategory.Label) != "" { + categoryList = append(categoryList, strings.TrimSpace(atomCategory.Label)) + } else { + categoryList = append(categoryList, strings.TrimSpace(atomCategory.Term)) + } + } + + return categoryList +} + // See https://tools.ietf.org/html/rfc4685#section-4 // If the type attribute of the atom:link is omitted, its value is assumed to be "application/atom+xml". // We accept only HTML or XHTML documents for now since the intention is to have the same behavior as RSS. @@ -232,6 +248,11 @@ type atom10Text struct { XHTMLRootElement atomXHTMLRootElement `xml:"http://www.w3.org/1999/xhtml div"` } +type atom10Category struct { + Term string `xml:"term,attr"` + Label string `xml:"label,attr"` +} + // Text: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.1 // HTML: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.2 // XHTML: https://datatracker.ietf.org/doc/html/rfc4287#section-3.1.1.3 diff --git a/reader/atom/atom_10_test.go b/reader/atom/atom_10_test.go index c6a50ef0..bf45a7a9 100644 --- a/reader/atom/atom_10_test.go +++ b/reader/atom/atom_10_test.go @@ -1604,3 +1604,48 @@ func TestAbsoluteCommentsURL(t *testing.T) { t.Errorf("Incorrect entry comments URL, got: %s", feed.Entries[0].CommentsURL) } } + +func TestParseFeedWithCategories(t *testing.T) { + data := `<?xml version="1.0" encoding="utf-8"?> + <feed xmlns="http://www.w3.org/2005/Atom"> + <title>Example Feed</title> + <link href="http://example.org/"/> + <author> + <name>Alice</name> + </author> + <author> + <name>Bob</name> + </author> + + <entry> + <link href="http://example.org/2003/12/13/atom03"/> + <id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id> + <updated>2003-12-13T18:30:02Z</updated> + <summary>Some text.</summary> + <category term='Tech' /> + <category term='Technology' label='Science' /> + </entry> + + </feed>` + + feed, err := Parse("https://example.org/", bytes.NewBufferString(data)) + if err != nil { + t.Fatal(err) + } + + if len(feed.Entries[0].Tags) != 2 { + t.Errorf("Incorrect number of tags, got: %d", len(feed.Entries[0].Tags)) + } + + expected := "Tech" + result := feed.Entries[0].Tags[0] + if result != expected { + t.Errorf("Incorrect entry category, got %q instead of %q", result, expected) + } + + expected = "Science" + result = feed.Entries[0].Tags[1] + if result != expected { + t.Errorf("Incorrect entry category, got %q instead of %q", result, expected) + } +} diff --git a/reader/json/json.go b/reader/json/json.go index 589d7cb0..759b10c7 100644 --- a/reader/json/json.go +++ b/reader/json/json.go @@ -43,6 +43,7 @@ type jsonItem struct { Authors []jsonAuthor `json:"authors"` Author jsonAuthor `json:"author"` Attachments []jsonAttachment `json:"attachments"` + Tags []string `json:"tags"` } type jsonAttachment struct { @@ -181,6 +182,7 @@ func (j *jsonItem) Transform() *model.Entry { entry.Content = j.GetContent() entry.Title = strings.TrimSpace(j.GetTitle()) entry.Enclosures = j.GetEnclosures() + entry.Tags = j.Tags return entry } diff --git a/reader/json/parser_test.go b/reader/json/parser_test.go index 5ba82d45..186baca6 100644 --- a/reader/json/parser_test.go +++ b/reader/json/parser_test.go @@ -575,3 +575,45 @@ func TestParseInvalidJSON(t *testing.T) { t.Error("Parse should returns an error") } } + +func TestParseTags(t *testing.T) { + data := `{ + "version": "https://jsonfeed.org/version/1", + "user_comment": "This is a microblog feed. You can add this to your feed reader using the following URL: https://example.org/feed.json", + "title": "Brent Simmons’s Microblog", + "home_page_url": "https://example.org/", + "feed_url": "https://example.org/feed.json", + "author": { + "name": "Brent Simmons", + "url": "http://example.org/", + "avatar": "https://example.org/avatar.png" + }, + "items": [ + { + "id": "2347259", + "url": "https://example.org/2347259", + "content_text": "Cats are neat. \n\nhttps://example.org/cats", + "date_published": "2016-02-09T14:22:00-07:00", + "tags": [ + "tag 1", + "tag 2" + ] + } + ] + }` + + feed, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data)) + if err != nil { + t.Fatal(err) + } + + if len(feed.Entries[0].Tags) != 2 { + t.Errorf("Incorrect number of Tags, got: %d", len(feed.Entries[0].Tags)) + } + + expected := "tag 2" + result := feed.Entries[0].Tags[1] + if result != expected { + t.Errorf("Incorrect entry tag, got %q instead of %q", result, expected) + } +} diff --git a/reader/rss/parser_test.go b/reader/rss/parser_test.go index 4b7bf761..d0662c60 100644 --- a/reader/rss/parser_test.go +++ b/reader/rss/parser_test.go @@ -1426,3 +1426,69 @@ func TestEntryDescriptionFromGooglePlayDescription(t *testing.T) { t.Errorf(`Unexpected podcast content, got %q instead of %q`, result, expected) } } + +func TestParseEntryWithCategoryAndInnerHTML(t *testing.T) { + data := `<?xml version="1.0" encoding="utf-8"?> + <rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"> + <channel> + <title>Example</title> + <link>https://example.org/</link> + <atom:link href="https://example.org/rss" type="application/rss+xml" rel="self"></atom:link> + <item> + <title>Test</title> + <link>https://example.org/item</link> + <category>Category 1</category> + <category>Category 2</category> + </item> + </channel> + </rss>` + + feed, err := Parse("https://example.org/", bytes.NewBufferString(data)) + if err != nil { + t.Fatal(err) + } + + if len(feed.Entries[0].Tags) != 2 { + t.Errorf("Incorrect number of tags, got: %d", len(feed.Entries[0].Tags)) + } + + expected := "Category 2" + result := feed.Entries[0].Tags[1] + if result != expected { + t.Errorf("Incorrect entry category, got %q instead of %q", result, expected) + } +} + +func TestParseEntryWithCategoryAndCDATA(t *testing.T) { + data := `<?xml version="1.0" encoding="utf-8"?> + <rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"> + <channel> + <title>Example</title> + <link>https://example.org/</link> + <atom:link href="https://example.org/rss" type="application/rss+xml" rel="self"></atom:link> + <item> + <title>Test</title> + <link>https://example.org/item</link> + <author> + by <![CDATA[Foo Bar]]> + </author> + <category>Sample Category</category> + </item> + </channel> + </rss>` + + feed, err := Parse("https://example.org/", bytes.NewBufferString(data)) + if err != nil { + t.Fatal(err) + } + + if len(feed.Entries[0].Tags) != 1 { + t.Errorf("Incorrect number of tags, got: %d", len(feed.Entries[0].Tags)) + } + + expected := "Sample Category" + result := feed.Entries[0].Tags[0] + if result != expected { + t.Errorf("Incorrect entry category, got %q instead of %q", result, expected) + } +} diff --git a/reader/rss/rss.go b/reader/rss/rss.go index 76ed9c9a..2969f6bc 100644 --- a/reader/rss/rss.go +++ b/reader/rss/rss.go @@ -156,6 +156,12 @@ type rssEnclosure struct { Length string `xml:"length,attr"` } +type rssCategory struct { + XMLName xml.Name + Data string `xml:",chardata"` + Inner string `xml:",innerxml"` +} + func (enclosure *rssEnclosure) Size() int64 { if enclosure.Length == "" { return 0 @@ -173,6 +179,7 @@ type rssItem struct { Authors []rssAuthor `xml:"author"` CommentLinks []rssCommentLink `xml:"comments"` EnclosureLinks []rssEnclosure `xml:"enclosure"` + Categories []rssCategory `xml:"category"` DublinCoreElement FeedBurnerElement PodcastEntryElement @@ -189,6 +196,8 @@ func (r *rssItem) Transform() *model.Entry { entry.Content = r.entryContent() entry.Title = r.entryTitle() entry.Enclosures = r.entryEnclosures() + entry.Tags = r.entryCategories() + return entry } @@ -372,6 +381,20 @@ func (r *rssItem) entryEnclosures() model.EnclosureList { return enclosures } +func (r *rssItem) entryCategories() []string { + var categoryList []string + + for _, rssCategory := range r.Categories { + if strings.Contains(rssCategory.Inner, "< []T { + allKeys := make(map[T]bool) + list := []T{} + for _, item := range sliceList { + if _, value := allKeys[item]; !value { + allKeys[item] = true + list = append(list, item) + } + } + return list +} diff --git a/storage/entry_query_builder.go b/storage/entry_query_builder.go index 437e769c..dae7bc1f 100644 --- a/storage/entry_query_builder.go +++ b/storage/entry_query_builder.go @@ -135,6 +135,17 @@ func (e *EntryQueryBuilder) WithStatuses(statuses []string) *EntryQueryBuilder { return e } +// WithTags filter by a list of entry tags. +func (e *EntryQueryBuilder) WithTags(tags []string) *EntryQueryBuilder { + if len(tags) > 0 { + for _, cat := range tags { + e.conditions = append(e.conditions, fmt.Sprintf("$%d = ANY(e.tags)", len(e.args)+1)) + e.args = append(e.args, cat) + } + } + return e +} + // WithoutStatus set the entry status that should not be returned. func (e *EntryQueryBuilder) WithoutStatus(status string) *EntryQueryBuilder { if status != "" { @@ -250,6 +261,7 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) { e.reading_time, e.created_at, e.changed_at, + e.tags, f.title as feed_title, f.feed_url, f.site_url, @@ -312,6 +324,7 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) { &entry.ReadingTime, &entry.CreatedAt, &entry.ChangedAt, + pq.Array(&entry.Tags), &entry.Feed.Title, &entry.Feed.FeedURL, &entry.Feed.SiteURL, |