diff --git a/client/model.go b/client/model.go index 69f2c227b8a..18c04add3ab 100644 --- a/client/model.go +++ b/client/model.go @@ -121,53 +121,53 @@ type Subscriptions []*Subscription // Feed represents a Miniflux feed. type Feed struct { - ID int64 `json:"id"` - UserID int64 `json:"user_id"` - FeedURL string `json:"feed_url"` - SiteURL string `json:"site_url"` - Title string `json:"title"` - CheckedAt time.Time `json:"checked_at,omitempty"` - EtagHeader string `json:"etag_header,omitempty"` - LastModifiedHeader string `json:"last_modified_header,omitempty"` - ParsingErrorMsg string `json:"parsing_error_message,omitempty"` - ParsingErrorCount int `json:"parsing_error_count,omitempty"` - Disabled bool `json:"disabled"` - IgnoreHTTPCache bool `json:"ignore_http_cache"` - AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"` - FetchViaProxy bool `json:"fetch_via_proxy"` - ScraperRules string `json:"scraper_rules"` - RewriteRules string `json:"rewrite_rules"` - BlocklistRules string `json:"blocklist_rules"` - KeeplistRules string `json:"keeplist_rules"` - Crawler bool `json:"crawler"` - UserAgent string `json:"user_agent"` - Cookie string `json:"cookie"` - Username string `json:"username"` - Password string `json:"password"` - Category *Category `json:"category,omitempty"` - HideGlobally bool `json:"hide_globally"` - DisableHTTP2 bool `json:"disable_http2"` + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + FeedURL string `json:"feed_url"` + SiteURL string `json:"site_url"` + Title string `json:"title"` + CheckedAt time.Time `json:"checked_at,omitempty"` + EtagHeader string `json:"etag_header,omitempty"` + LastModifiedHeader string `json:"last_modified_header,omitempty"` + ParsingErrorMsg string `json:"parsing_error_message,omitempty"` + ParsingErrorCount int `json:"parsing_error_count,omitempty"` + Disabled bool `json:"disabled"` + IgnoreHTTPCache bool `json:"ignore_http_cache"` + AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"` + FetchViaProxy bool `json:"fetch_via_proxy"` + ScraperRules string `json:"scraper_rules"` + RewriteRules string `json:"rewrite_rules"` + BlocklistRules string `json:"blocklist_rules"` + KeeplistRules string `json:"keeplist_rules"` + Crawler bool `json:"crawler"` + UserAgent string `json:"user_agent"` + Cookie string `json:"cookie"` + Username string `json:"username"` + Password string `json:"password"` + Categories []*Category `json:"categories,omitempty"` + HideGlobally bool `json:"hide_globally"` + DisableHTTP2 bool `json:"disable_http2"` } // FeedCreationRequest represents the request to create a feed. type FeedCreationRequest struct { - FeedURL string `json:"feed_url"` - CategoryID int64 `json:"category_id"` - UserAgent string `json:"user_agent"` - Cookie string `json:"cookie"` - Username string `json:"username"` - Password string `json:"password"` - Crawler bool `json:"crawler"` - Disabled bool `json:"disabled"` - IgnoreHTTPCache bool `json:"ignore_http_cache"` - AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"` - FetchViaProxy bool `json:"fetch_via_proxy"` - ScraperRules string `json:"scraper_rules"` - RewriteRules string `json:"rewrite_rules"` - BlocklistRules string `json:"blocklist_rules"` - KeeplistRules string `json:"keeplist_rules"` - HideGlobally bool `json:"hide_globally"` - DisableHTTP2 bool `json:"disable_http2"` + FeedURL string `json:"feed_url"` + CategoryIDs []int64 `json:"category_ids"` + UserAgent string `json:"user_agent"` + Cookie string `json:"cookie"` + Username string `json:"username"` + Password string `json:"password"` + Crawler bool `json:"crawler"` + Disabled bool `json:"disabled"` + IgnoreHTTPCache bool `json:"ignore_http_cache"` + AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"` + FetchViaProxy bool `json:"fetch_via_proxy"` + ScraperRules string `json:"scraper_rules"` + RewriteRules string `json:"rewrite_rules"` + BlocklistRules string `json:"blocklist_rules"` + KeeplistRules string `json:"keeplist_rules"` + HideGlobally bool `json:"hide_globally"` + DisableHTTP2 bool `json:"disable_http2"` } // FeedModificationRequest represents the request to update a feed. @@ -184,7 +184,7 @@ type FeedModificationRequest struct { Cookie *string `json:"cookie"` Username *string `json:"username"` Password *string `json:"password"` - CategoryID *int64 `json:"category_id"` + CategoryIDs []int64 `json:"category_ids"` Disabled *bool `json:"disabled"` IgnoreHTTPCache *bool `json:"ignore_http_cache"` AllowSelfSignedCertificates *bool `json:"allow_self_signed_certificates"` diff --git a/internal/api/api_integration_test.go b/internal/api/api_integration_test.go index 1d06fb8c561..867616ccf78 100644 --- a/internal/api/api_integration_test.go +++ b/internal/api/api_integration_test.go @@ -973,8 +973,8 @@ func TestMarkCategoryAsReadEndpoint(t *testing.T) { } feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{ - FeedURL: testConfig.testFeedURL, - CategoryID: category.ID, + FeedURL: testConfig.testFeedURL, + CategoryIDs: []int64{category.ID}, }) if err != nil { t.Fatal(err) @@ -1017,8 +1017,8 @@ func TestCreateFeedEndpoint(t *testing.T) { } feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{ - FeedURL: testConfig.testFeedURL, - CategoryID: category.ID, + FeedURL: testConfig.testFeedURL, + CategoryIDs: []int64{category.ID}, }) if err != nil { t.Fatal(err) @@ -1081,8 +1081,8 @@ func TestCreateFeedWithInexistingCategory(t *testing.T) { regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword) _, err = regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{ - FeedURL: testConfig.testFeedURL, - CategoryID: 123456789, + FeedURL: testConfig.testFeedURL, + CategoryIDs: []int64{123456789}, }) if err == nil { @@ -1319,7 +1319,7 @@ func TestUpdateFeedWithInvalidCategory(t *testing.T) { } feedUpdateRequest := &miniflux.FeedModificationRequest{ - CategoryID: miniflux.SetOptionalField(int64(123456789)), + CategoryIDs: []int64{int64(123456789)}, } if _, err := regularUserClient.UpdateFeed(feedID, feedUpdateRequest); err == nil { @@ -1659,8 +1659,8 @@ func TestGetCategoryFeedsEndpoint(t *testing.T) { } feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{ - FeedURL: testConfig.testFeedURL, - CategoryID: category.ID, + FeedURL: testConfig.testFeedURL, + CategoryIDs: []int64{category.ID}, }) if err != nil { t.Fatal(err) @@ -1870,8 +1870,8 @@ func TestGetAllCategoryEntriesEndpoint(t *testing.T) { } feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{ - FeedURL: testConfig.testFeedURL, - CategoryID: category.ID, + FeedURL: testConfig.testFeedURL, + CategoryIDs: []int64{category.ID}, }) if err != nil { t.Fatal(err) @@ -2214,7 +2214,7 @@ func TestGetEntryEndpoints(t *testing.T) { t.Fatalf(`Invalid entryID, got %d`, entry.ID) } - entry, err = regularUserClient.CategoryEntry(result.Entries[0].Feed.Category.ID, result.Entries[0].ID) + entry, err = regularUserClient.CategoryEntry(result.Entries[0].Feed.Categories[0].ID, result.Entries[0].ID) if err != nil { t.Fatal(err) } diff --git a/internal/api/category.go b/internal/api/category.go index 7b47e2a3346..612df46fa43 100644 --- a/internal/api/category.go +++ b/internal/api/category.go @@ -120,7 +120,7 @@ func (h *handler) removeCategory(w http.ResponseWriter, r *http.Request) { userID := request.UserID(r) categoryID := request.RouteInt64Param(r, "categoryID") - if !h.store.CategoryIDExists(userID, categoryID) { + if !h.store.CategoryIDsExists(userID, []int64{categoryID}) { json.NotFound(w, r) return } diff --git a/internal/api/entry.go b/internal/api/entry.go index 508bbee451c..a30a20521de 100644 --- a/internal/api/entry.go +++ b/internal/api/entry.go @@ -114,7 +114,7 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int userID := request.UserID(r) categoryID = request.QueryInt64Param(r, "category_id", categoryID) - if categoryID > 0 && !h.store.CategoryIDExists(userID, categoryID) { + if categoryID > 0 && !h.store.CategoryIDsExists(userID, []int64{categoryID}) { json.BadRequest(w, r, errors.New("invalid category ID")) return } diff --git a/internal/api/feed.go b/internal/api/feed.go index 3bcc2edccf8..4f521a9e395 100644 --- a/internal/api/feed.go +++ b/internal/api/feed.go @@ -26,16 +26,6 @@ func (h *handler) createFeed(w http.ResponseWriter, r *http.Request) { return } - // Make the feed category optional for clients who don't support categories. - if feedCreationRequest.CategoryID == 0 { - category, err := h.store.FirstCategory(userID) - if err != nil { - json.ServerError(w, r, err) - return - } - feedCreationRequest.CategoryID = category.ID - } - if validationErr := validator.ValidateFeedCreation(h.store, userID, &feedCreationRequest); validationErr != nil { json.BadRequest(w, r, validationErr.Error()) return diff --git a/internal/database/migrations.go b/internal/database/migrations.go index f7c2bd060ce..a992d1933a0 100644 --- a/internal/database/migrations.go +++ b/internal/database/migrations.go @@ -942,4 +942,21 @@ var migrations = []func(tx *sql.Tx) error{ _, err = tx.Exec(sql) return err }, + func(tx *sql.Tx) (err error) { + sql := ` + CREATE TABLE feed_categories ( + feed_id bigint not null, + category_id bigint not null, + primary key(feed_id, category_id), + foreign key (feed_id) references feeds(id) on delete cascade, + foreign key (category_id) references categories(id) on delete cascade + ); + + INSERT INTO feed_categories (feed_id, category_id) SELECT id AS feed_id, category_id FROM feeds; + + ALTER TABLE feeds DROP COLUMN category_id; + ` + _, err = tx.Exec(sql) + return err + }, } diff --git a/internal/fever/handler.go b/internal/fever/handler.go index ef1c39c7de3..7c175a443cd 100644 --- a/internal/fever/handler.go +++ b/internal/fever/handler.go @@ -570,7 +570,9 @@ A feeds_group object has the following members: func (h *handler) buildFeedGroups(feeds model.Feeds) []feedsGroups { feedsGroupedByCategory := make(map[int64][]string) for _, feed := range feeds { - feedsGroupedByCategory[feed.Category.ID] = append(feedsGroupedByCategory[feed.Category.ID], strconv.FormatInt(feed.ID, 10)) + for _, category := range feed.Categories { + feedsGroupedByCategory[category.ID] = append(feedsGroupedByCategory[category.ID], strconv.FormatInt(feed.ID, 10)) + } } result := make([]feedsGroups, 0) diff --git a/internal/googlereader/handler.go b/internal/googlereader/handler.go index c3eb70afc46..199302785a7 100644 --- a/internal/googlereader/handler.go +++ b/internal/googlereader/handler.go @@ -756,8 +756,8 @@ func subscribe(newFeed Stream, category Stream, title string, store *storage.Sto } feedRequest := model.FeedCreationRequest{ - FeedURL: newFeed.ID, - CategoryID: destCategory.ID, + FeedURL: newFeed.ID, + CategoryIDs: []int64{destCategory.ID}, } verr := validator.ValidateFeedCreation(store, userID, &feedRequest) if verr != nil { @@ -821,7 +821,7 @@ func move(stream Stream, destination Stream, store *storage.Storage, userID int6 return err } feedModification := model.FeedModificationRequest{ - CategoryID: &category.ID, + CategoryIDs: []int64{category.ID}, } feedModification.Patch(feed) return store.UpdateFeed(feed) @@ -991,8 +991,8 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque } categories := make([]string, 0) categories = append(categories, userReadingList) - if entry.Feed.Category.Title != "" { - categories = append(categories, fmt.Sprintf(UserLabelPrefix, userID)+entry.Feed.Category.Title) + for _, category := range entry.Feed.Categories { + categories = append(categories, fmt.Sprintf(UserLabelPrefix, userID)+category.Title) } if entry.Status == model.EntryStatusRead { categories = append(categories, userRead) @@ -1209,11 +1209,15 @@ func (h *handler) subscriptionListHandler(w http.ResponseWriter, r *http.Request } result.Subscriptions = make([]subscription, 0) for _, feed := range feeds { + var categories []subscriptionCategory + for _, category := range feed.Categories { + categories = append(categories, subscriptionCategory{fmt.Sprintf(UserLabelPrefix, userID) + category.Title, category.Title, "folder"}) + } result.Subscriptions = append(result.Subscriptions, subscription{ ID: fmt.Sprintf(FeedPrefix+"%d", feed.ID), Title: feed.Title, URL: feed.FeedURL, - Categories: []subscriptionCategory{{fmt.Sprintf(UserLabelPrefix, userID) + feed.Category.Title, feed.Category.Title, "folder"}}, + Categories: categories, HTMLURL: feed.SiteURL, IconURL: "", // TODO: Icons are base64 encoded in the DB. }) diff --git a/internal/integration/webhook/webhook.go b/internal/integration/webhook/webhook.go index a69730f917f..ec213bbf5b0 100644 --- a/internal/integration/webhook/webhook.go +++ b/internal/integration/webhook/webhook.go @@ -32,6 +32,12 @@ func NewClient(webhookURL, webhookSecret string) *Client { } func (c *Client) SendSaveEntryWebhookEvent(entry *model.Entry) error { + var categoryIDs []int64 + var categories []*WebhookCategory + for _, category := range entry.Feed.Categories { + categoryIDs = append(categoryIDs, category.ID) + categories = append(categories, &WebhookCategory{ID: category.ID, Title: category.Title}) + } return c.makeRequest(SaveEntryEventType, &WebhookSaveEntryEvent{ EventType: SaveEntryEventType, Entry: &WebhookEntry{ @@ -54,14 +60,14 @@ func (c *Client) SendSaveEntryWebhookEvent(entry *model.Entry) error { Enclosures: entry.Enclosures, Tags: entry.Tags, Feed: &WebhookFeed{ - ID: entry.Feed.ID, - UserID: entry.Feed.UserID, - CategoryID: entry.Feed.Category.ID, - Category: &WebhookCategory{ID: entry.Feed.Category.ID, Title: entry.Feed.Category.Title}, - FeedURL: entry.Feed.FeedURL, - SiteURL: entry.Feed.SiteURL, - Title: entry.Feed.Title, - CheckedAt: entry.Feed.CheckedAt, + ID: entry.Feed.ID, + UserID: entry.Feed.UserID, + CategoryIDs: categoryIDs, + Categories: categories, + FeedURL: entry.Feed.FeedURL, + SiteURL: entry.Feed.SiteURL, + Title: entry.Feed.Title, + CheckedAt: entry.Feed.CheckedAt, }, }, }) @@ -95,17 +101,23 @@ func (c *Client) SendNewEntriesWebhookEvent(feed *model.Feed, entries model.Entr Tags: entry.Tags, }) } + var categoryIDs []int64 + var categories []*WebhookCategory + for _, category := range feed.Categories { + categoryIDs = append(categoryIDs, category.ID) + categories = append(categories, &WebhookCategory{ID: category.ID, Title: category.Title}) + } return c.makeRequest(NewEntriesEventType, &WebhookNewEntriesEvent{ EventType: NewEntriesEventType, Feed: &WebhookFeed{ - ID: feed.ID, - UserID: feed.UserID, - CategoryID: feed.Category.ID, - Category: &WebhookCategory{ID: feed.Category.ID, Title: feed.Category.Title}, - FeedURL: feed.FeedURL, - SiteURL: feed.SiteURL, - Title: feed.Title, - CheckedAt: feed.CheckedAt, + ID: feed.ID, + UserID: feed.UserID, + CategoryIDs: categoryIDs, + Categories: categories, + FeedURL: feed.FeedURL, + SiteURL: feed.SiteURL, + Title: feed.Title, + CheckedAt: feed.CheckedAt, }, Entries: webhookEntries, }) @@ -146,14 +158,14 @@ func (c *Client) makeRequest(eventType string, payload any) error { } type WebhookFeed struct { - ID int64 `json:"id"` - UserID int64 `json:"user_id"` - CategoryID int64 `json:"category_id"` - Category *WebhookCategory `json:"category,omitempty"` - FeedURL string `json:"feed_url"` - SiteURL string `json:"site_url"` - Title string `json:"title"` - CheckedAt time.Time `json:"checked_at"` + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + CategoryIDs []int64 `json:"category_ids"` + Categories []*WebhookCategory `json:"categories,omitempty"` + FeedURL string `json:"feed_url"` + SiteURL string `json:"site_url"` + Title string `json:"title"` + CheckedAt time.Time `json:"checked_at"` } type WebhookCategory struct { diff --git a/internal/model/entry.go b/internal/model/entry.go index db4958ca547..5ac2889a84b 100644 --- a/internal/model/entry.go +++ b/internal/model/entry.go @@ -44,8 +44,8 @@ func NewEntry() *Entry { Enclosures: make(EnclosureList, 0), Tags: make([]string, 0), Feed: &Feed{ - Category: &Category{}, - Icon: &FeedIcon{}, + Categories: nil, + Icon: &FeedIcon{}, }, } } diff --git a/internal/model/feed.go b/internal/model/feed.go index 91c582bd64e..c93b93da0ea 100644 --- a/internal/model/feed.go +++ b/internal/model/feed.go @@ -57,9 +57,9 @@ type Feed struct { NtfyPriority int `json:"ntfy_priority"` // Non persisted attributes - Category *Category `json:"category,omitempty"` - Icon *FeedIcon `json:"icon"` - Entries Entries `json:"entries,omitempty"` + Categories []*Category `json:"categories,omitempty"` + Icon *FeedIcon `json:"icon"` + Entries Entries `json:"entries,omitempty"` TTL int `json:"-"` IconURL string `json:"-"` @@ -74,19 +74,20 @@ type FeedCounters struct { } func (f *Feed) String() string { - return fmt.Sprintf("ID=%d, UserID=%d, FeedURL=%s, SiteURL=%s, Title=%s, Category={%s}", + return fmt.Sprintf("ID=%d, UserID=%d, FeedURL=%s, SiteURL=%s, Title=%s", f.ID, f.UserID, f.FeedURL, f.SiteURL, f.Title, - f.Category, ) } // WithCategoryID initializes the category attribute of the feed. -func (f *Feed) WithCategoryID(categoryID int64) { - f.Category = &Category{ID: categoryID} +func (f *Feed) WithCategoryIDs(categoryIDs []int64) { + for _, categoryID := range categoryIDs { + f.Categories = append(f.Categories, &Category{ID: categoryID}) + } } // WithTranslatedErrorMessage adds a new error message and increment the error counter. @@ -136,25 +137,25 @@ func (f *Feed) ScheduleNextCheck(weeklyCount int, newTTL int) { // FeedCreationRequest represents the request to create a feed. type FeedCreationRequest struct { - FeedURL string `json:"feed_url"` - CategoryID int64 `json:"category_id"` - UserAgent string `json:"user_agent"` - Cookie string `json:"cookie"` - Username string `json:"username"` - Password string `json:"password"` - Crawler bool `json:"crawler"` - Disabled bool `json:"disabled"` - NoMediaPlayer bool `json:"no_media_player"` - IgnoreHTTPCache bool `json:"ignore_http_cache"` - AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"` - FetchViaProxy bool `json:"fetch_via_proxy"` - ScraperRules string `json:"scraper_rules"` - RewriteRules string `json:"rewrite_rules"` - BlocklistRules string `json:"blocklist_rules"` - KeeplistRules string `json:"keeplist_rules"` - HideGlobally bool `json:"hide_globally"` - UrlRewriteRules string `json:"urlrewrite_rules"` - DisableHTTP2 bool `json:"disable_http2"` + FeedURL string `json:"feed_url"` + CategoryIDs []int64 `json:"category_ids"` + UserAgent string `json:"user_agent"` + Cookie string `json:"cookie"` + Username string `json:"username"` + Password string `json:"password"` + Crawler bool `json:"crawler"` + Disabled bool `json:"disabled"` + NoMediaPlayer bool `json:"no_media_player"` + IgnoreHTTPCache bool `json:"ignore_http_cache"` + AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"` + FetchViaProxy bool `json:"fetch_via_proxy"` + ScraperRules string `json:"scraper_rules"` + RewriteRules string `json:"rewrite_rules"` + BlocklistRules string `json:"blocklist_rules"` + KeeplistRules string `json:"keeplist_rules"` + HideGlobally bool `json:"hide_globally"` + UrlRewriteRules string `json:"urlrewrite_rules"` + DisableHTTP2 bool `json:"disable_http2"` } type FeedCreationRequestFromSubscriptionDiscovery struct { @@ -181,7 +182,7 @@ type FeedModificationRequest struct { Cookie *string `json:"cookie"` Username *string `json:"username"` Password *string `json:"password"` - CategoryID *int64 `json:"category_id"` + CategoryIDs []int64 `json:"category_ids"` Disabled *bool `json:"disabled"` NoMediaPlayer *bool `json:"no_media_player"` IgnoreHTTPCache *bool `json:"ignore_http_cache"` @@ -249,8 +250,11 @@ func (f *FeedModificationRequest) Patch(feed *Feed) { feed.Password = *f.Password } - if f.CategoryID != nil && *f.CategoryID > 0 { - feed.Category.ID = *f.CategoryID + if len(f.CategoryIDs) > 0 { + feed.Categories = nil + for _, categoryID := range f.CategoryIDs { + feed.Categories = append(feed.Categories, &Category{ID: categoryID}) + } } if f.Disabled != nil { diff --git a/internal/model/feed_test.go b/internal/model/feed_test.go index df5c6885f9d..4b0814c5f40 100644 --- a/internal/model/feed_test.go +++ b/internal/model/feed_test.go @@ -19,14 +19,18 @@ const ( func TestFeedCategorySetter(t *testing.T) { feed := &Feed{} - feed.WithCategoryID(int64(123)) + feed.WithCategoryIDs([]int64{int64(123)}) - if feed.Category == nil { - t.Fatal(`The category field should not be null`) + if feed.Categories == nil { + t.Fatal(`The categories field should not be null`) } - if feed.Category.ID != int64(123) { - t.Error(`The category ID must be set`) + if len(feed.Categories) != 1 { + t.Error(`The categories field must have exactly one entry`) + } + + if feed.Categories[0].ID != int64(123) { + t.Error(`The categories ID must be set`) } } diff --git a/internal/reader/handler/handler.go b/internal/reader/handler/handler.go index 3a588b0418c..465990cf124 100644 --- a/internal/reader/handler/handler.go +++ b/internal/reader/handler/handler.go @@ -36,7 +36,7 @@ func CreateFeedFromSubscriptionDiscovery(store *storage.Storage, userID int64, f return nil, locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr) } - if !store.CategoryIDExists(userID, feedCreationRequest.CategoryID) { + if !store.CategoryIDsExists(userID, feedCreationRequest.CategoryIDs) { return nil, locale.NewLocalizedErrorWrapper(ErrCategoryNotFound, "error.category_not_found") } @@ -68,7 +68,7 @@ func CreateFeedFromSubscriptionDiscovery(store *storage.Storage, userID int64, f subscription.LastModifiedHeader = feedCreationRequest.LastModified subscription.FeedURL = feedCreationRequest.FeedURL subscription.DisableHTTP2 = feedCreationRequest.DisableHTTP2 - subscription.WithCategoryID(feedCreationRequest.CategoryID) + subscription.WithCategoryIDs(feedCreationRequest.CategoryIDs) subscription.CheckedNow() processor.ProcessFeedEntries(store, subscription, user, true) @@ -116,7 +116,7 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model return nil, locale.NewLocalizedErrorWrapper(storeErr, "error.database_error", storeErr) } - if !store.CategoryIDExists(userID, feedCreationRequest.CategoryID) { + if !store.CategoryIDsExists(userID, feedCreationRequest.CategoryIDs) { return nil, locale.NewLocalizedErrorWrapper(ErrCategoryNotFound, "error.category_not_found") } @@ -173,7 +173,7 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model subscription.EtagHeader = responseHandler.ETag() subscription.LastModifiedHeader = responseHandler.LastModified() subscription.FeedURL = responseHandler.EffectiveURL() - subscription.WithCategoryID(feedCreationRequest.CategoryID) + subscription.WithCategoryIDs(feedCreationRequest.CategoryIDs) subscription.CheckedNow() processor.ProcessFeedEntries(store, subscription, user, true) diff --git a/internal/reader/opml/handler.go b/internal/reader/opml/handler.go index 2e7bceb93cb..476b362be3d 100644 --- a/internal/reader/opml/handler.go +++ b/internal/reader/opml/handler.go @@ -25,12 +25,16 @@ func (h *Handler) Export(userID int64) (string, error) { subscriptions := make(SubcriptionList, 0, len(feeds)) for _, feed := range feeds { + var categoryNames CategoryNameList + for _, category := range feed.Categories { + categoryNames = append(categoryNames, category.Title) + } subscriptions = append(subscriptions, &Subcription{ - Title: feed.Title, - FeedURL: feed.FeedURL, - SiteURL: feed.SiteURL, - Description: feed.Description, - CategoryName: feed.Category.Title, + Title: feed.Title, + FeedURL: feed.FeedURL, + SiteURL: feed.SiteURL, + Description: feed.Description, + CategoryNames: categoryNames, }) } @@ -45,39 +49,36 @@ func (h *Handler) Import(userID int64, data io.Reader) error { } for _, subscription := range subscriptions { - if !h.store.FeedURLExists(userID, subscription.FeedURL) { + var categories []*model.Category + for _, categoryName := range subscription.CategoryNames { var category *model.Category var err error + category, err = h.store.CategoryByTitle(userID, categoryName) + if err != nil { + return fmt.Errorf("opml: unable to search category by title: %w", err) + } - if subscription.CategoryName == "" { - category, err = h.store.FirstCategory(userID) + if category == nil { + category, err = h.store.CreateCategory(userID, &model.CategoryRequest{Title: categoryName}) if err != nil { - return fmt.Errorf("opml: unable to find first category: %w", err) - } - } else { - category, err = h.store.CategoryByTitle(userID, subscription.CategoryName) - if err != nil { - return fmt.Errorf("opml: unable to search category by title: %w", err) - } - - if category == nil { - category, err = h.store.CreateCategory(userID, &model.CategoryRequest{Title: subscription.CategoryName}) - if err != nil { - return fmt.Errorf(`opml: unable to create this category: %q`, subscription.CategoryName) - } + return fmt.Errorf(`opml: unable to create this category: %q`, categoryName) } } + categories = append(categories, category) + } - feed := &model.Feed{ - UserID: userID, - Title: subscription.Title, - FeedURL: subscription.FeedURL, - SiteURL: subscription.SiteURL, - Description: subscription.Description, - Category: category, - } - + feed := &model.Feed{ + UserID: userID, + Title: subscription.Title, + FeedURL: subscription.FeedURL, + SiteURL: subscription.SiteURL, + Description: subscription.Description, + Categories: categories, + } + if !h.store.FeedURLExists(userID, subscription.FeedURL) { h.store.CreateFeed(feed) + } else { + h.store.UpdateFeed(feed) // TODO maybe only update categories? } } diff --git a/internal/reader/opml/opml.go b/internal/reader/opml/opml.go index c6caa9c9247..cc746230822 100644 --- a/internal/reader/opml/opml.go +++ b/internal/reader/opml/opml.go @@ -29,6 +29,7 @@ type opmlHeader struct { type opmlOutline struct { Title string `xml:"title,attr,omitempty"` Text string `xml:"text,attr"` + Type string `xml:"type,attr,omitempty"` FeedURL string `xml:"xmlUrl,attr,omitempty"` SiteURL string `xml:"htmlUrl,attr,omitempty"` Description string `xml:"description,attr,omitempty"` diff --git a/internal/reader/opml/parser.go b/internal/reader/opml/parser.go index 6b972d401d7..3e401bd7b49 100644 --- a/internal/reader/opml/parser.go +++ b/internal/reader/opml/parser.go @@ -28,17 +28,38 @@ func Parse(data io.Reader) (SubcriptionList, error) { } func getSubscriptionsFromOutlines(outlines opmlOutlineCollection, category string) (subscriptions SubcriptionList) { + // NOTE Using a map for set semantics to deduplicate subscriptions + subscriptionsMap := make(map[string]*Subcription) for _, outline := range outlines { if outline.IsSubscription() { - subscriptions = append(subscriptions, &Subcription{ - Title: outline.GetTitle(), - FeedURL: outline.FeedURL, - SiteURL: outline.GetSiteURL(), - Description: outline.Description, - CategoryName: category, - }) + subscription, ok := subscriptionsMap[outline.FeedURL] + if !ok || subscription == nil { + // Do not overwrite existing entry + subscription = &Subcription{ + Title: outline.GetTitle(), + FeedURL: outline.FeedURL, + SiteURL: outline.GetSiteURL(), + Description: outline.Description, + } + subscriptions = append(subscriptions, subscription) + subscriptionsMap[outline.FeedURL] = subscription + } + if category != "" { + subscription.CategoryNames = append(subscription.CategoryNames, category) + } } else if outline.Outlines.HasChildren() { - subscriptions = append(subscriptions, getSubscriptionsFromOutlines(outline.Outlines, outline.GetTitle())...) + children := getSubscriptionsFromOutlines(outline.Outlines, outline.GetTitle()) + for _, childSubscription := range children { + childFeedURL := childSubscription.FeedURL + subscription, ok := subscriptionsMap[childFeedURL] + if ok && subscription != nil { + // Do not overwrite existing entry + subscription.CategoryNames = append(subscription.CategoryNames, childSubscription.CategoryNames...) + } else { + subscriptions = append(subscriptions, childSubscription) + subscriptionsMap[childFeedURL] = childSubscription + } + } } } return subscriptions diff --git a/internal/reader/opml/parser_test.go b/internal/reader/opml/parser_test.go index 9210c178f8c..de3097e818f 100644 --- a/internal/reader/opml/parser_test.go +++ b/internal/reader/opml/parser_test.go @@ -68,9 +68,9 @@ func TestParseOpmlWithCategories(t *testing.T) { ` var expected SubcriptionList - expected = append(expected, &Subcription{Title: "Feed 1", FeedURL: "http://example.org/feed1/", SiteURL: "http://example.org/1", CategoryName: "My Category 1"}) - expected = append(expected, &Subcription{Title: "Feed 2", FeedURL: "http://example.org/feed2/", SiteURL: "http://example.org/2", CategoryName: "My Category 1"}) - expected = append(expected, &Subcription{Title: "Feed 3", FeedURL: "http://example.org/feed3/", SiteURL: "http://example.org/3", CategoryName: "My Category 2"}) + expected = append(expected, &Subcription{Title: "Feed 1", FeedURL: "http://example.org/feed1/", SiteURL: "http://example.org/1", CategoryNames: CategoryNameList{"My Category 1"}}) + expected = append(expected, &Subcription{Title: "Feed 2", FeedURL: "http://example.org/feed2/", SiteURL: "http://example.org/2", CategoryNames: CategoryNameList{"My Category 1"}}) + expected = append(expected, &Subcription{Title: "Feed 3", FeedURL: "http://example.org/feed3/", SiteURL: "http://example.org/3", CategoryNames: CategoryNameList{"My Category 2"}}) subscriptions, err := Parse(bytes.NewBufferString(data)) if err != nil { @@ -102,8 +102,8 @@ func TestParseOpmlWithEmptyTitleAndEmptySiteURL(t *testing.T) { ` var expected SubcriptionList - expected = append(expected, &Subcription{Title: "http://example.org/1", FeedURL: "http://example.org/feed1/", SiteURL: "http://example.org/1", CategoryName: ""}) - expected = append(expected, &Subcription{Title: "http://example.org/feed2/", FeedURL: "http://example.org/feed2/", SiteURL: "http://example.org/feed2/", CategoryName: ""}) + expected = append(expected, &Subcription{Title: "http://example.org/1", FeedURL: "http://example.org/feed1/", SiteURL: "http://example.org/1", CategoryNames: CategoryNameList{}}) + expected = append(expected, &Subcription{Title: "http://example.org/feed2/", FeedURL: "http://example.org/feed2/", SiteURL: "http://example.org/feed2/", CategoryNames: CategoryNameList{}}) subscriptions, err := Parse(bytes.NewBufferString(data)) if err != nil { @@ -140,8 +140,8 @@ func TestParseOpmlVersion1(t *testing.T) { ` var expected SubcriptionList - expected = append(expected, &Subcription{Title: "Feed 1", FeedURL: "http://example.org/feed1/", SiteURL: "http://example.org/1", CategoryName: "Category 1"}) - expected = append(expected, &Subcription{Title: "Feed 2", FeedURL: "http://example.org/feed2/", SiteURL: "http://example.org/2", CategoryName: "Category 2"}) + expected = append(expected, &Subcription{Title: "Feed 1", FeedURL: "http://example.org/feed1/", SiteURL: "http://example.org/1", CategoryNames: CategoryNameList{"Category 1"}}) + expected = append(expected, &Subcription{Title: "Feed 2", FeedURL: "http://example.org/feed2/", SiteURL: "http://example.org/2", CategoryNames: CategoryNameList{"Category 2"}}) subscriptions, err := Parse(bytes.NewBufferString(data)) if err != nil { @@ -174,8 +174,8 @@ func TestParseOpmlVersion1WithoutOuterOutline(t *testing.T) { ` var expected SubcriptionList - expected = append(expected, &Subcription{Title: "Feed 1", FeedURL: "http://example.org/feed1/", SiteURL: "http://example.org/1", CategoryName: ""}) - expected = append(expected, &Subcription{Title: "Feed 2", FeedURL: "http://example.org/feed2/", SiteURL: "http://example.org/2", CategoryName: ""}) + expected = append(expected, &Subcription{Title: "Feed 1", FeedURL: "http://example.org/feed1/", SiteURL: "http://example.org/1", CategoryNames: CategoryNameList{}}) + expected = append(expected, &Subcription{Title: "Feed 2", FeedURL: "http://example.org/feed2/", SiteURL: "http://example.org/2", CategoryNames: CategoryNameList{}}) subscriptions, err := Parse(bytes.NewBufferString(data)) if err != nil { @@ -215,9 +215,9 @@ func TestParseOpmlVersion1WithSeveralNestedOutlines(t *testing.T) { ` var expected SubcriptionList - expected = append(expected, &Subcription{Title: "Feed 1", FeedURL: "http://example.org/feed1/", SiteURL: "http://example.org/1", CategoryName: "Some Category"}) - expected = append(expected, &Subcription{Title: "Feed 2", FeedURL: "http://example.org/feed2/", SiteURL: "http://example.org/2", CategoryName: "Some Category"}) - expected = append(expected, &Subcription{Title: "Feed 3", FeedURL: "http://example.org/feed3/", SiteURL: "http://example.org/3", CategoryName: "Another Category"}) + expected = append(expected, &Subcription{Title: "Feed 1", FeedURL: "http://example.org/feed1/", SiteURL: "http://example.org/1", CategoryNames: CategoryNameList{"Some Category"}}) + expected = append(expected, &Subcription{Title: "Feed 2", FeedURL: "http://example.org/feed2/", SiteURL: "http://example.org/2", CategoryNames: CategoryNameList{"Some Category"}}) + expected = append(expected, &Subcription{Title: "Feed 3", FeedURL: "http://example.org/feed3/", SiteURL: "http://example.org/3", CategoryNames: CategoryNameList{"Another Category"}}) subscriptions, err := Parse(bytes.NewBufferString(data)) if err != nil { @@ -250,7 +250,7 @@ func TestParseOpmlWithInvalidCharacterEntity(t *testing.T) { ` var expected SubcriptionList - expected = append(expected, &Subcription{Title: "Feed 1", FeedURL: "http://example.org/feed1/a&b", SiteURL: "http://example.org/c&d", CategoryName: "Feed 1"}) + expected = append(expected, &Subcription{Title: "Feed 1", FeedURL: "http://example.org/feed1/a&b", SiteURL: "http://example.org/c&d", CategoryNames: CategoryNameList{"Feed 1"}}) subscriptions, err := Parse(bytes.NewBufferString(data)) if err != nil { diff --git a/internal/reader/opml/serializer.go b/internal/reader/opml/serializer.go index 229c5a823e9..4c53e24b334 100644 --- a/internal/reader/opml/serializer.go +++ b/internal/reader/opml/serializer.go @@ -37,7 +37,7 @@ func convertSubscriptionsToOPML(subscriptions SubcriptionList) *opmlDocument { opmlDocument.Header.Title = "Miniflux" opmlDocument.Header.DateCreated = time.Now().Format("Mon, 02 Jan 2006 15:04:05 MST") - groupedSubs := groupSubscriptionsByFeed(subscriptions) + groupedSubs := groupSubscriptionsByCategory(subscriptions) categories := make([]string, 0, len(groupedSubs)) for k := range groupedSubs { categories = append(categories, k) @@ -45,7 +45,7 @@ func convertSubscriptionsToOPML(subscriptions SubcriptionList) *opmlDocument { sort.Strings(categories) for _, categoryName := range categories { - category := opmlOutline{Text: categoryName, Outlines: make(opmlOutlineCollection, 0, len(groupedSubs[categoryName]))} + category := opmlOutline{Title: categoryName, Text: categoryName, Outlines: make(opmlOutlineCollection, 0, len(groupedSubs[categoryName]))} for _, subscription := range groupedSubs[categoryName] { category.Outlines = append(category.Outlines, opmlOutline{ Title: subscription.Title, @@ -62,11 +62,13 @@ func convertSubscriptionsToOPML(subscriptions SubcriptionList) *opmlDocument { return opmlDocument } -func groupSubscriptionsByFeed(subscriptions SubcriptionList) map[string]SubcriptionList { +func groupSubscriptionsByCategory(subscriptions SubcriptionList) map[string]SubcriptionList { groups := make(map[string]SubcriptionList) for _, subscription := range subscriptions { - groups[subscription.CategoryName] = append(groups[subscription.CategoryName], subscription) + for _, categoryName := range subscription.CategoryNames { + groups[categoryName] = append(groups[categoryName], subscription) + } } return groups diff --git a/internal/reader/opml/serializer_test.go b/internal/reader/opml/serializer_test.go index b0f5a5a67e6..e7414be5d42 100644 --- a/internal/reader/opml/serializer_test.go +++ b/internal/reader/opml/serializer_test.go @@ -10,9 +10,9 @@ import ( func TestSerialize(t *testing.T) { var subscriptions SubcriptionList - subscriptions = append(subscriptions, &Subcription{Title: "Feed 1", FeedURL: "http://example.org/feed/1", SiteURL: "http://example.org/1", CategoryName: "Category 1"}) - subscriptions = append(subscriptions, &Subcription{Title: "Feed 2", FeedURL: "http://example.org/feed/2", SiteURL: "http://example.org/2", CategoryName: "Category 1"}) - subscriptions = append(subscriptions, &Subcription{Title: "Feed 3", FeedURL: "http://example.org/feed/3", SiteURL: "http://example.org/3", CategoryName: "Category 2"}) + subscriptions = append(subscriptions, &Subcription{Title: "Feed 1", FeedURL: "http://example.org/feed/1", SiteURL: "http://example.org/1", CategoryNames: CategoryNameList{"Category 1"}}) + subscriptions = append(subscriptions, &Subcription{Title: "Feed 2", FeedURL: "http://example.org/feed/2", SiteURL: "http://example.org/2", CategoryNames: CategoryNameList{"Category 1"}}) + subscriptions = append(subscriptions, &Subcription{Title: "Feed 3", FeedURL: "http://example.org/feed/3", SiteURL: "http://example.org/3", CategoryNames: CategoryNameList{"Category 2"}}) output := Serialize(subscriptions) feeds, err := Parse(bytes.NewBufferString(output)) @@ -26,7 +26,7 @@ func TestSerialize(t *testing.T) { found := false for _, feed := range feeds { - if feed.Title == "Feed 1" && feed.CategoryName == "Category 1" && + if feed.Title == "Feed 1" && feed.CategoryNames.Equals(&CategoryNameList{"Category 1"}) && feed.FeedURL == "http://example.org/feed/1" && feed.SiteURL == "http://example.org/1" { found = true break @@ -49,9 +49,9 @@ func TestNormalizedCategoriesOrder(t *testing.T) { } var subscriptions SubcriptionList - subscriptions = append(subscriptions, &Subcription{Title: "Feed 1", FeedURL: "http://example.org/feed/1", SiteURL: "http://example.org/1", CategoryName: orderTests[0].naturalOrderName}) - subscriptions = append(subscriptions, &Subcription{Title: "Feed 2", FeedURL: "http://example.org/feed/2", SiteURL: "http://example.org/2", CategoryName: orderTests[1].naturalOrderName}) - subscriptions = append(subscriptions, &Subcription{Title: "Feed 3", FeedURL: "http://example.org/feed/3", SiteURL: "http://example.org/3", CategoryName: orderTests[2].naturalOrderName}) + subscriptions = append(subscriptions, &Subcription{Title: "Feed 1", FeedURL: "http://example.org/feed/1", SiteURL: "http://example.org/1", CategoryNames: CategoryNameList{orderTests[0].naturalOrderName}}) + subscriptions = append(subscriptions, &Subcription{Title: "Feed 2", FeedURL: "http://example.org/feed/2", SiteURL: "http://example.org/2", CategoryNames: CategoryNameList{orderTests[1].naturalOrderName}}) + subscriptions = append(subscriptions, &Subcription{Title: "Feed 3", FeedURL: "http://example.org/feed/3", SiteURL: "http://example.org/3", CategoryNames: CategoryNameList{orderTests[2].naturalOrderName}}) feeds := convertSubscriptionsToOPML(subscriptions) diff --git a/internal/reader/opml/subscription.go b/internal/reader/opml/subscription.go index f3a5ac342a6..24131084496 100644 --- a/internal/reader/opml/subscription.go +++ b/internal/reader/opml/subscription.go @@ -3,21 +3,45 @@ package opml // import "miniflux.app/v2/internal/reader/opml" +// TODO rename type to Subscription // Subcription represents a feed that will be imported or exported. type Subcription struct { - Title string - SiteURL string - FeedURL string - CategoryName string - Description string + Title string + SiteURL string + FeedURL string + CategoryNames CategoryNameList + Description string +} + +type CategoryNameList []string + +// Equals compares two category lists +func (c1s *CategoryNameList) Equals(c2s *CategoryNameList) bool { + if len(*c1s) != len(*c2s) { + return false + } + for _, c1 := range *c1s { + found := false + for _, c2 := range *c2s { + if c1 == c2 { + found = true + break + } + } + if !found { + return false + } + } + return true } // Equals compare two subscriptions. func (s Subcription) Equals(subscription *Subcription) bool { return s.Title == subscription.Title && s.SiteURL == subscription.SiteURL && - s.FeedURL == subscription.FeedURL && s.CategoryName == subscription.CategoryName && + s.FeedURL == subscription.FeedURL && s.CategoryNames.Equals(&subscription.CategoryNames) && s.Description == subscription.Description } +// TODO rename type to SubscriptionList // SubcriptionList is a list of subscriptions. type SubcriptionList []*Subcription diff --git a/internal/storage/category.go b/internal/storage/category.go index c7270e9b61b..bec2631a9ed 100644 --- a/internal/storage/category.go +++ b/internal/storage/category.go @@ -7,6 +7,7 @@ import ( "database/sql" "errors" "fmt" + "strings" "github.com/lib/pq" "miniflux.app/v2/internal/model" @@ -28,12 +29,17 @@ func (s *Storage) CategoryTitleExists(userID int64, title string) bool { return result } -// CategoryIDExists checks if the given category exists into the database. -func (s *Storage) CategoryIDExists(userID, categoryID int64) bool { - var result bool - query := `SELECT true FROM categories WHERE user_id=$1 AND id=$2` - s.db.QueryRow(query, userID, categoryID).Scan(&result) - return result +// CategoryIDsExists checks if the given categories exists into the database. +func (s *Storage) CategoryIDsExists(userID int64, categoryIDs []int64) bool { + var result int + query := `SELECT count(id) as count FROM categories WHERE user_id=$1 AND id IN $2` + var categoryIDsStr []string + for _, categoryID := range categoryIDs { + categoryIDsStr = append(categoryIDsStr, fmt.Sprintf("%d", categoryID)) + } + categoryFilter := fmt.Sprintf("(%s)", strings.Join(categoryIDsStr, ",")) + s.db.QueryRow(query, userID, categoryFilter).Scan(&result) + return result == len(categoryIDs) } // Category returns a category from the database. @@ -122,11 +128,15 @@ func (s *Storage) CategoriesWithFeedCount(userID int64) (model.Categories, error c.user_id, c.title, c.hide_globally, - (SELECT count(*) FROM feeds WHERE feeds.category_id=c.id) AS count, + (SELECT count(*) + FROM feeds + JOIN feed_categories as fc ON (feeds.id=fc.feed_id) + WHERE c.id = fc.category_id) AS count, (SELECT count(*) FROM feeds JOIN entries ON (feeds.id = entries.feed_id) - WHERE feeds.category_id = c.id AND entries.status = $1) AS count_unread + JOIN feed_categories as fc ON (feeds.id=fc.feed_id) + WHERE c.id = fc.category_id AND entries.status = $1) AS count_unread FROM categories c WHERE user_id=$2 diff --git a/internal/storage/entry.go b/internal/storage/entry.go index f22a424bc18..23cbb3c5414 100644 --- a/internal/storage/entry.go +++ b/internal/storage/entry.go @@ -392,15 +392,17 @@ func (s *Storage) SetEntriesStatusCount(userID int64, entryIDs []int64, status s } query := ` - SELECT count(*) + SELECT count(DISTINCT e.id) FROM entries e - JOIN feeds f ON (f.id = e.feed_id) - JOIN categories c ON (c.id = f.category_id) + LEFT JOIN feeds f ON (f.id = e.feed_id) + LEFT JOIN feed_categories fc ON fc.feed_id=f.id + LEFT JOIN categories c ON c.id=fc.category_id WHERE e.user_id = $1 AND e.id = ANY($2) AND NOT f.hide_globally AND NOT c.hide_globally ` + fmt.Printf("QUERY: %s", query) row := s.db.QueryRow(query, userID, pq.Array(entryIDs)) visible := 0 if err := row.Scan(&visible); err != nil { diff --git a/internal/storage/entry_pagination_builder.go b/internal/storage/entry_pagination_builder.go index 9779f245eaf..f0ef894e8d5 100644 --- a/internal/storage/entry_pagination_builder.go +++ b/internal/storage/entry_pagination_builder.go @@ -45,7 +45,7 @@ func (e *EntryPaginationBuilder) WithFeedID(feedID int64) { // WithCategoryID adds category_id to the condition. func (e *EntryPaginationBuilder) WithCategoryID(categoryID int64) { if categoryID != 0 { - e.conditions = append(e.conditions, fmt.Sprintf("f.category_id = $%d", len(e.args)+1)) + e.conditions = append(e.conditions, fmt.Sprintf("fc.category_id = $%d", len(e.args)+1)) e.args = append(e.args, categoryID) } } @@ -115,8 +115,9 @@ func (e *EntryPaginationBuilder) getPrevNextID(tx *sql.Tx) (prevID int64, nextID lag(e.id) over (order by e.%[1]s asc, e.id desc) as prev_id, lead(e.id) over (order by e.%[1]s asc, e.id desc) as next_id FROM entries AS e - JOIN feeds AS f ON f.id=e.feed_id - JOIN categories c ON c.id = f.category_id + LEFT JOIN feeds AS f ON f.id=e.feed_id + LEFT JOIN feed_categories fc ON fc.feed_id=f.id + LEFT JOIN categories c ON c.id=fc.category_id WHERE %[2]s ORDER BY e.%[1]s asc, e.id desc ) diff --git a/internal/storage/entry_query_builder.go b/internal/storage/entry_query_builder.go index 1245e1d4cba..ca6b49091c0 100644 --- a/internal/storage/entry_query_builder.go +++ b/internal/storage/entry_query_builder.go @@ -6,6 +6,7 @@ package storage // import "miniflux.app/v2/internal/storage" import ( "database/sql" "fmt" + "strconv" "strings" "time" @@ -132,7 +133,7 @@ func (e *EntryQueryBuilder) WithFeedID(feedID int64) *EntryQueryBuilder { // WithCategoryID filter by category ID. func (e *EntryQueryBuilder) WithCategoryID(categoryID int64) *EntryQueryBuilder { if categoryID > 0 { - e.conditions = append(e.conditions, fmt.Sprintf("f.category_id = $%d", len(e.args)+1)) + e.conditions = append(e.conditions, fmt.Sprintf("fc.category_id = $%d", len(e.args)+1)) e.args = append(e.args, categoryID) } return e @@ -220,10 +221,11 @@ func (e *EntryQueryBuilder) WithGloballyVisible() *EntryQueryBuilder { // CountEntries count the number of entries that match the condition. func (e *EntryQueryBuilder) CountEntries() (count int, err error) { query := ` - SELECT count(*) + SELECT count(DISTINCT e.id) FROM entries e - JOIN feeds f ON f.id = e.feed_id - JOIN categories c ON c.id = f.category_id + LEFT JOIN feeds f ON f.id = e.feed_id + LEFT JOIN feed_categories fc ON fc.feed_id=f.id + LEFT JOIN categories c ON c.id=fc.category_id WHERE %s ` condition := e.buildCondition() @@ -256,6 +258,7 @@ func (e *EntryQueryBuilder) GetEntry() (*model.Entry, error) { return entries[0], nil } +// TODO Don't return feeds and categories (still join to allow for filtering) // GetEntries returns a list of entries that match the condition. func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) { query := ` @@ -283,9 +286,9 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) { f.site_url, f.description, f.checked_at, - f.category_id, - c.title as category_title, - c.hide_globally as category_hidden, + STRING_AGG(c.id::text, ',') as category_id, + STRING_AGG(c.title, ',') as category_title, + STRING_AGG(c.hide_globally::text, ',') as category_hidden, f.scraper_rules, f.rewrite_rules, f.crawler, @@ -300,12 +303,16 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) { LEFT JOIN feeds f ON f.id=e.feed_id LEFT JOIN - categories c ON c.id=f.category_id + feed_categories fc ON fc.feed_id=f.id + LEFT JOIN + categories c ON c.id=fc.category_id LEFT JOIN feed_icons fi ON fi.feed_id=f.id LEFT JOIN users u ON u.id=e.user_id - WHERE %s %s + WHERE %s + GROUP BY e.id, f.id, u.timezone, fi.icon_id + %s ` condition := e.buildCondition() @@ -323,6 +330,9 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) { var iconID sql.NullInt64 var tz string var hasEnclosure sql.NullBool + var categoryIDsStr string + var categoryTitlesStr string + var categoryHiddensStr string entry := model.NewEntry() @@ -350,9 +360,9 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) { &entry.Feed.SiteURL, &entry.Feed.Description, &entry.Feed.CheckedAt, - &entry.Feed.Category.ID, - &entry.Feed.Category.Title, - &entry.Feed.Category.HideGlobally, + &categoryIDsStr, + &categoryTitlesStr, + &categoryHiddensStr, &entry.Feed.ScraperRules, &entry.Feed.RewriteRules, &entry.Feed.Crawler, @@ -363,11 +373,26 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) { &iconID, &tz, ) - if err != nil { return nil, fmt.Errorf("store: unable to fetch entry row: %v", err) } + // TODO Better way to get list of categories + categoryIDs := strings.Split(categoryIDsStr, ",") + categoryTitles := strings.Split(categoryTitlesStr, ",") + categoryHiddens := strings.Split(categoryHiddensStr, ",") + for i := range categoryIDs { + categoryID, _ := strconv.Atoi(categoryIDs[i]) + categoryHidden := categoryHiddens[i] == "1" + category := &model.Category{ + ID: int64(categoryID), + Title: categoryTitles[i], + HideGlobally: categoryHidden, + UserID: entry.Feed.UserID, + } + entry.Feed.Categories = append(entry.Feed.Categories, category) + } + if hasEnclosure.Valid && hasEnclosure.Bool && e.fetchEnclosures { entry.Enclosures, err = e.store.GetEnclosures(entry.ID) if err != nil { @@ -390,7 +415,6 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) { entry.Feed.ID = entry.FeedID entry.Feed.UserID = entry.UserID entry.Feed.Icon.FeedID = entry.FeedID - entry.Feed.Category.UserID = entry.UserID entries = append(entries, entry) } diff --git a/internal/storage/feed.go b/internal/storage/feed.go index decb4638ec3..717e087e0e4 100644 --- a/internal/storage/feed.go +++ b/internal/storage/feed.go @@ -9,6 +9,7 @@ import ( "fmt" "log/slog" "sort" + "strings" "miniflux.app/v2/internal/config" "miniflux.app/v2/internal/model" @@ -217,7 +218,6 @@ func (s *Storage) CreateFeed(feed *model.Feed) error { feed_url, site_url, title, - category_id, user_id, etag_header, last_modified_header, @@ -242,7 +242,7 @@ func (s *Storage) CreateFeed(feed *model.Feed) error { description ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26) + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25) RETURNING id ` @@ -251,7 +251,6 @@ func (s *Storage) CreateFeed(feed *model.Feed) error { feed.FeedURL, feed.SiteURL, feed.Title, - feed.Category.ID, feed.UserID, feed.EtagHeader, feed.LastModifiedHeader, @@ -279,6 +278,20 @@ func (s *Storage) CreateFeed(feed *model.Feed) error { return fmt.Errorf(`store: unable to create feed %q: %v`, feed.FeedURL, err) } + sql = ` + INSERT INTO feed_categories (feed_id, category_id) + VALUES + %s + ` + var feedCategoryValues []string + for _, category := range feed.Categories { + feedCategoryValues = append(feedCategoryValues, fmt.Sprintf("(%d,%d)", feed.ID, category.ID)) + } + sql = fmt.Sprintf(sql, strings.Join(feedCategoryValues, ",")) + if _, err = s.db.Exec(sql); err != nil { + return fmt.Errorf(`store: unable to associate categories to feed %q: %v`, feed.FeedURL, err) + } + for _, entry := range feed.Entries { entry.FeedID = feed.ID entry.UserID = feed.UserID @@ -322,42 +335,40 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) { feed_url=$1, site_url=$2, title=$3, - category_id=$4, - etag_header=$5, - last_modified_header=$6, - checked_at=$7, - parsing_error_msg=$8, - parsing_error_count=$9, - scraper_rules=$10, - rewrite_rules=$11, - blocklist_rules=$12, - keeplist_rules=$13, - crawler=$14, - user_agent=$15, - cookie=$16, - username=$17, - password=$18, - disabled=$19, - next_check_at=$20, - ignore_http_cache=$21, - allow_self_signed_certificates=$22, - fetch_via_proxy=$23, - hide_globally=$24, - url_rewrite_rules=$25, - no_media_player=$26, - apprise_service_urls=$27, - disable_http2=$28, - description=$29, - ntfy_enabled=$30, - ntfy_priority=$31 + etag_header=$4, + last_modified_header=$5, + checked_at=$6, + parsing_error_msg=$7, + parsing_error_count=$8, + scraper_rules=$9, + rewrite_rules=$10, + blocklist_rules=$11, + keeplist_rules=$12, + crawler=$13, + user_agent=$14, + cookie=$15, + username=$16, + password=$17, + disabled=$18, + next_check_at=$19, + ignore_http_cache=$20, + allow_self_signed_certificates=$21, + fetch_via_proxy=$22, + hide_globally=$23, + url_rewrite_rules=$24, + no_media_player=$25, + apprise_service_urls=$26, + disable_http2=$27, + description=$28, + ntfy_enabled=$29, + ntfy_priority=$30 WHERE - id=$32 AND user_id=$33 + id=$31 AND user_id=$32 ` _, err = s.db.Exec(query, feed.FeedURL, feed.SiteURL, feed.Title, - feed.Category.ID, feed.EtagHeader, feed.LastModifiedHeader, feed.CheckedAt, @@ -388,11 +399,28 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) { feed.ID, feed.UserID, ) - if err != nil { return fmt.Errorf(`store: unable to update feed #%d (%s): %v`, feed.ID, feed.FeedURL, err) } + // TODO keep track of changes separately + query = ` + DELETE FROM feed_categories + WHERE feed_id=%d; + + INSERT INTO feed_categories (feed_id, category_id) + VALUES + %s + ` + var feedCategoryValues []string + for _, category := range feed.Categories { + feedCategoryValues = append(feedCategoryValues, fmt.Sprintf("(%d,%d)", feed.ID, category.ID)) + } + query = fmt.Sprintf(query, feed.ID, strings.Join(feedCategoryValues, ",")) + if _, err = s.db.Exec(query); err != nil { + return fmt.Errorf(`store: unable to update categories for feed #%d (%s): %v`, feed.ID, feed.FeedURL, err) + } + return nil } diff --git a/internal/storage/feed_query_builder.go b/internal/storage/feed_query_builder.go index 271c011a51b..e2ffa07133c 100644 --- a/internal/storage/feed_query_builder.go +++ b/internal/storage/feed_query_builder.go @@ -6,6 +6,7 @@ package storage // import "miniflux.app/v2/internal/storage" import ( "database/sql" "fmt" + "strconv" "strings" "miniflux.app/v2/internal/model" @@ -40,9 +41,9 @@ func NewFeedQueryBuilder(store *Storage, userID int64) *FeedQueryBuilder { // WithCategoryID filter by category ID. func (f *FeedQueryBuilder) WithCategoryID(categoryID int64) *FeedQueryBuilder { if categoryID > 0 { - f.conditions = append(f.conditions, fmt.Sprintf("f.category_id = $%d", len(f.args)+1)) + f.conditions = append(f.conditions, fmt.Sprintf("fc.category_id = $%d", len(f.args)+1)) f.args = append(f.args, categoryID) - f.counterConditions = append(f.counterConditions, fmt.Sprintf("f.category_id = $%d", len(f.counterArgs)+1)) + f.counterConditions = append(f.counterConditions, fmt.Sprintf("fc.category_id = $%d", len(f.counterArgs)+1)) f.counterArgs = append(f.counterArgs, categoryID) f.counterJoinFeeds = true } @@ -127,6 +128,7 @@ func (f *FeedQueryBuilder) GetFeed() (*model.Feed, error) { return feeds[0], nil } +// TODO Don't return categories (still join to allow for filtering) // GetFeeds returns a list of feeds that match the condition. func (f *FeedQueryBuilder) GetFeeds() (model.Feeds, error) { var query = ` @@ -159,9 +161,9 @@ func (f *FeedQueryBuilder) GetFeeds() (model.Feeds, error) { f.disabled, f.no_media_player, f.hide_globally, - f.category_id, - c.title as category_title, - c.hide_globally as category_hidden, + STRING_AGG(c.id::text, ',') as category_id, + STRING_AGG(c.title, ',') as category_title, + STRING_AGG(c.hide_globally::text, ',') as category_hidden, fi.icon_id, u.timezone, f.apprise_service_urls, @@ -171,12 +173,15 @@ func (f *FeedQueryBuilder) GetFeeds() (model.Feeds, error) { FROM feeds f LEFT JOIN - categories c ON c.id=f.category_id + feed_categories fc ON fc.feed_id=f.id + LEFT JOIN + categories c ON c.id=fc.category_id LEFT JOIN feed_icons fi ON fi.feed_id=f.id LEFT JOIN users u ON u.id=f.user_id WHERE %s + GROUP BY f.id, u.timezone, fi.icon_id %s ` @@ -198,7 +203,9 @@ func (f *FeedQueryBuilder) GetFeeds() (model.Feeds, error) { var feed model.Feed var iconID sql.NullInt64 var tz string - feed.Category = &model.Category{} + var categoryIDsStr string + var categoryTitlesStr string + var categoryHiddensStr string err := rows.Scan( &feed.ID, @@ -229,9 +236,9 @@ func (f *FeedQueryBuilder) GetFeeds() (model.Feeds, error) { &feed.Disabled, &feed.NoMediaPlayer, &feed.HideGlobally, - &feed.Category.ID, - &feed.Category.Title, - &feed.Category.HideGlobally, + &categoryIDsStr, + &categoryTitlesStr, + &categoryHiddensStr, &iconID, &tz, &feed.AppriseServiceURLs, @@ -239,11 +246,26 @@ func (f *FeedQueryBuilder) GetFeeds() (model.Feeds, error) { &feed.NtfyEnabled, &feed.NtfyPriority, ) - if err != nil { return nil, fmt.Errorf(`store: unable to fetch feeds row: %w`, err) } + // TODO Better way to get list of categories + categoryIDs := strings.Split(categoryIDsStr, ",") + categoryTitles := strings.Split(categoryTitlesStr, ",") + categoryHiddens := strings.Split(categoryHiddensStr, ",") + for i := range categoryIDs { + categoryID, _ := strconv.Atoi(categoryIDs[i]) + categoryHidden := categoryHiddens[i] == "1" + category := &model.Category{ + ID: int64(categoryID), + Title: categoryTitles[i], + HideGlobally: categoryHidden, + UserID: feed.UserID, + } + feed.Categories = append(feed.Categories, category) + } + if iconID.Valid { feed.Icon = &model.FeedIcon{FeedID: feed.ID, IconID: iconID.Int64} } else { @@ -264,7 +286,6 @@ func (f *FeedQueryBuilder) GetFeeds() (model.Feeds, error) { feed.NumberOfVisibleEntries = feed.ReadCount + feed.UnreadCount feed.CheckedAt = timezone.Convert(tz, feed.CheckedAt) feed.NextCheckAt = timezone.Convert(tz, feed.NextCheckAt) - feed.Category.UserID = feed.UserID feeds = append(feeds, &feed) } @@ -290,7 +311,11 @@ func (f *FeedQueryBuilder) fetchFeedCounter() (unreadCounters map[int64]int, rea ` join := "" if f.counterJoinFeeds { - join = "LEFT JOIN feeds f ON f.id=e.feed_id" + join = ` + LEFT JOIN feeds f ON f.id=e.feed_id + LEFT JOIN feed_categories fc ON fc.feed_id=f.id + LEFT JOIN categories c ON c.id=fc.category_id + ` } query = fmt.Sprintf(query, join, f.buildCounterCondition()) diff --git a/internal/template/templates/common/feed_list.html b/internal/template/templates/common/feed_list.html index ee937949ef2..86df2ce45a4 100644 --- a/internal/template/templates/common/feed_list.html +++ b/internal/template/templates/common/feed_list.html @@ -25,14 +25,6 @@

- - - {{ .Category.Title }} - -
{{ if .entry.Tags }}
diff --git a/internal/template/templates/views/feed_entries.html b/internal/template/templates/views/feed_entries.html index 8191fc28b5c..049409b5ab7 100644 --- a/internal/template/templates/views/feed_entries.html +++ b/internal/template/templates/views/feed_entries.html @@ -118,14 +118,6 @@

{{ .Title }}

- - - {{ .Feed.Category.Title }} - - {{ template "item_meta" dict "user" $.user "entry" . "hasSaveEntry" $.hasSaveEntry }} diff --git a/internal/template/templates/views/history_entries.html b/internal/template/templates/views/history_entries.html index cf492828712..a7f34ecd999 100644 --- a/internal/template/templates/views/history_entries.html +++ b/internal/template/templates/views/history_entries.html @@ -53,11 +53,6 @@

{{ .Title }}

- - - {{ .Feed.Category.Title }} - - {{ template "item_meta" dict "user" $.user "entry" . "hasSaveEntry" $.hasSaveEntry }} diff --git a/internal/template/templates/views/search.html b/internal/template/templates/views/search.html index c4e07774765..9b5a0dc94e7 100644 --- a/internal/template/templates/views/search.html +++ b/internal/template/templates/views/search.html @@ -39,11 +39,6 @@

{{ .Title }}

- - - {{ .Feed.Category.Title }} - - {{ template "item_meta" dict "user" $.user "entry" . "hasSaveEntry" $.hasSaveEntry }} diff --git a/internal/template/templates/views/shared_entries.html b/internal/template/templates/views/shared_entries.html index edbae16e12e..33ee05bbd49 100644 --- a/internal/template/templates/views/shared_entries.html +++ b/internal/template/templates/views/shared_entries.html @@ -58,7 +58,6 @@

target="_blank">{{ icon "share" }} {{ end }}

- {{ .Feed.Category.Title }}
    diff --git a/internal/template/templates/views/tag_entries.html b/internal/template/templates/views/tag_entries.html index 86d1c2036a2..d37e2aa15ee 100644 --- a/internal/template/templates/views/tag_entries.html +++ b/internal/template/templates/views/tag_entries.html @@ -34,11 +34,6 @@

    {{ .Title }}

    - - - {{ .Feed.Category.Title }} - - {{ template "item_meta" dict "user" $.user "entry" . "hasSaveEntry" $.hasSaveEntry }} diff --git a/internal/template/templates/views/unread_entries.html b/internal/template/templates/views/unread_entries.html index 0253cd94d5b..3362ad89451 100644 --- a/internal/template/templates/views/unread_entries.html +++ b/internal/template/templates/views/unread_entries.html @@ -61,11 +61,6 @@

    {{ .Title }}

    - - - {{ .Feed.Category.Title }} - - {{ template "item_meta" dict "user" $.user "entry" . "hasSaveEntry" $.hasSaveEntry }} diff --git a/internal/ui/feed_edit.go b/internal/ui/feed_edit.go index 21cf5dc25e3..d6ca9900013 100644 --- a/internal/ui/feed_edit.go +++ b/internal/ui/feed_edit.go @@ -39,6 +39,15 @@ func (h *handler) showEditFeedPage(w http.ResponseWriter, r *http.Request) { return } + var categoryIDs []int64 + categoryHiddenGlobally := false + for _, category := range feed.Categories { + if category.HideGlobally { + categoryHiddenGlobally = true + } + categoryIDs = append(categoryIDs, category.ID) + } + feedForm := form.FeedForm{ SiteURL: feed.SiteURL, FeedURL: feed.FeedURL, @@ -52,7 +61,7 @@ func (h *handler) showEditFeedPage(w http.ResponseWriter, r *http.Request) { Crawler: feed.Crawler, UserAgent: feed.UserAgent, Cookie: feed.Cookie, - CategoryID: feed.Category.ID, + CategoryIDs: categoryIDs, Username: feed.Username, Password: feed.Password, IgnoreHTTPCache: feed.IgnoreHTTPCache, @@ -61,7 +70,7 @@ func (h *handler) showEditFeedPage(w http.ResponseWriter, r *http.Request) { Disabled: feed.Disabled, NoMediaPlayer: feed.NoMediaPlayer, HideGlobally: feed.HideGlobally, - CategoryHidden: feed.Category.HideGlobally, + CategoryHidden: categoryHiddenGlobally, AppriseServiceURLs: feed.AppriseServiceURLs, DisableHTTP2: feed.DisableHTTP2, NtfyEnabled: feed.NtfyEnabled, diff --git a/internal/ui/feed_update.go b/internal/ui/feed_update.go index 5a6fd625b9a..0102dafca19 100644 --- a/internal/ui/feed_update.go +++ b/internal/ui/feed_update.go @@ -60,7 +60,7 @@ func (h *handler) updateFeed(w http.ResponseWriter, r *http.Request) { SiteURL: model.OptionalString(feedForm.SiteURL), Title: model.OptionalString(feedForm.Title), Description: model.OptionalString(feedForm.Description), - CategoryID: model.OptionalNumber(feedForm.CategoryID), + CategoryIDs: feedForm.CategoryIDs, BlocklistRules: model.OptionalString(feedForm.BlocklistRules), KeeplistRules: model.OptionalString(feedForm.KeeplistRules), UrlRewriteRules: model.OptionalString(feedForm.UrlRewriteRules), diff --git a/internal/ui/form/feed.go b/internal/ui/form/feed.go index b9b05bc41f7..3e26135595a 100644 --- a/internal/ui/form/feed.go +++ b/internal/ui/form/feed.go @@ -6,6 +6,7 @@ package form // import "miniflux.app/v2/internal/ui/form" import ( "net/http" "strconv" + "strings" "miniflux.app/v2/internal/model" ) @@ -24,7 +25,7 @@ type FeedForm struct { Crawler bool UserAgent string Cookie string - CategoryID int64 + CategoryIDs []int64 Username string Password string IgnoreHTTPCache bool @@ -42,7 +43,9 @@ type FeedForm struct { // Merge updates the fields of the given feed. func (f FeedForm) Merge(feed *model.Feed) *model.Feed { - feed.Category.ID = f.CategoryID + for _, categoryID := range f.CategoryIDs { + feed.Categories = append(feed.Categories, &model.Category{ID: categoryID}) + } feed.Title = f.Title feed.SiteURL = f.SiteURL feed.FeedURL = f.FeedURL @@ -74,9 +77,13 @@ func (f FeedForm) Merge(feed *model.Feed) *model.Feed { // NewFeedForm parses the HTTP request and returns a FeedForm func NewFeedForm(r *http.Request) *FeedForm { - categoryID, err := strconv.Atoi(r.FormValue("category_id")) - if err != nil { - categoryID = 0 + var categoryIDs []int64 + for _, categoryIDStr := range strings.Split(r.FormValue("category_ids"), ",") { + categoryID, err := strconv.Atoi(categoryIDStr) + if err != nil { + continue + } + categoryIDs = append(categoryIDs, int64(categoryID)) } ntfyPriority, err := strconv.Atoi(r.FormValue("ntfy_priority")) if err != nil { @@ -95,7 +102,7 @@ func NewFeedForm(r *http.Request) *FeedForm { KeeplistRules: r.FormValue("keeplist_rules"), UrlRewriteRules: r.FormValue("urlrewrite_rules"), Crawler: r.FormValue("crawler") == "1", - CategoryID: int64(categoryID), + CategoryIDs: categoryIDs, Username: r.FormValue("feed_username"), Password: r.FormValue("feed_password"), IgnoreHTTPCache: r.FormValue("ignore_http_cache") == "1", diff --git a/internal/ui/subscription_choose.go b/internal/ui/subscription_choose.go index af355d3409b..51ec995e517 100644 --- a/internal/ui/subscription_choose.go +++ b/internal/ui/subscription_choose.go @@ -48,7 +48,7 @@ func (h *handler) showChooseSubscriptionPage(w http.ResponseWriter, r *http.Requ } feed, localizedError := feedHandler.CreateFeed(h.store, user.ID, &model.FeedCreationRequest{ - CategoryID: subscriptionForm.CategoryID, + CategoryIDs: []int64{subscriptionForm.CategoryID}, FeedURL: subscriptionForm.URL, Crawler: subscriptionForm.Crawler, AllowSelfSignedCertificates: subscriptionForm.AllowSelfSignedCertificates, diff --git a/internal/ui/subscription_submit.go b/internal/ui/subscription_submit.go index fd8e43ef306..2d96b00886e 100644 --- a/internal/ui/subscription_submit.go +++ b/internal/ui/subscription_submit.go @@ -90,7 +90,7 @@ func (h *handler) submitSubscription(w http.ResponseWriter, r *http.Request) { ETag: subscriptionFinder.FeedResponseInfo().ETag, LastModified: subscriptionFinder.FeedResponseInfo().LastModified, FeedCreationRequest: model.FeedCreationRequest{ - CategoryID: subscriptionForm.CategoryID, + CategoryIDs: []int64{subscriptionForm.CategoryID}, FeedURL: subscriptions[0].URL, AllowSelfSignedCertificates: subscriptionForm.AllowSelfSignedCertificates, Crawler: subscriptionForm.Crawler, @@ -117,7 +117,7 @@ func (h *handler) submitSubscription(w http.ResponseWriter, r *http.Request) { html.Redirect(w, r, route.Path(h.router, "feedEntries", "feedID", feed.ID)) case n == 1 && !subscriptionFinder.IsFeedAlreadyDownloaded(): feed, localizedError := feedHandler.CreateFeed(h.store, user.ID, &model.FeedCreationRequest{ - CategoryID: subscriptionForm.CategoryID, + CategoryIDs: []int64{subscriptionForm.CategoryID}, FeedURL: subscriptions[0].URL, Crawler: subscriptionForm.Crawler, AllowSelfSignedCertificates: subscriptionForm.AllowSelfSignedCertificates, diff --git a/internal/validator/feed.go b/internal/validator/feed.go index 6a353892293..b48893fda42 100644 --- a/internal/validator/feed.go +++ b/internal/validator/feed.go @@ -11,7 +11,7 @@ import ( // ValidateFeedCreation validates feed creation. func ValidateFeedCreation(store *storage.Storage, userID int64, request *model.FeedCreationRequest) *locale.LocalizedError { - if request.FeedURL == "" || request.CategoryID <= 0 { + if request.FeedURL == "" { return locale.NewLocalizedError("error.feed_mandatory_fields") } @@ -23,7 +23,7 @@ func ValidateFeedCreation(store *storage.Storage, userID int64, request *model.F return locale.NewLocalizedError("error.feed_already_exists") } - if !store.CategoryIDExists(userID, request.CategoryID) { + if !store.CategoryIDsExists(userID, request.CategoryIDs) { return locale.NewLocalizedError("error.feed_category_not_found") } @@ -70,8 +70,8 @@ func ValidateFeedModification(store *storage.Storage, userID, feedID int64, requ } } - if request.CategoryID != nil { - if !store.CategoryIDExists(userID, *request.CategoryID) { + if request.CategoryIDs != nil { + if !store.CategoryIDsExists(userID, request.CategoryIDs) { return locale.NewLocalizedError("error.feed_category_not_found") } }