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 := `
+
+ Example Feed
+
+
+ Alice
+
+
+ Bob
+
+
+
+
+ urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a
+ 2003-12-13T18:30:02Z
+ Some text.
+
+
+
+
+ `
+
+ 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 := `
+
+
+ Example
+ https://example.org/
+
+ -
+ Test
+ https://example.org/item
+ Category 1
+ Category 2
+
+
+ `
+
+ 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 := `
+
+
+ Example
+ https://example.org/
+
+ -
+ Test
+ https://example.org/item
+
+ by
+
+ Sample Category
+
+
+ `
+
+ 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, " 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,