From 320d1b016747ba4501da9417d9ce5f99368a5768 Mon Sep 17 00:00:00 2001 From: Frédéric Guillot Date: Tue, 2 Jan 2018 22:04:48 -0800 Subject: Refactor packages to have more idiomatic code base --- fever/fever.go | 631 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 631 insertions(+) create mode 100644 fever/fever.go (limited to 'fever') diff --git a/fever/fever.go b/fever/fever.go new file mode 100644 index 0000000..54a4416 --- /dev/null +++ b/fever/fever.go @@ -0,0 +1,631 @@ +// Copyright 2017 Frédéric Guillot. All rights reserved. +// Use of this source code is governed by the Apache 2.0 +// license that can be found in the LICENSE file. + +package fever + +import ( + "strconv" + "strings" + "time" + + "github.com/miniflux/miniflux/http/handler" + "github.com/miniflux/miniflux/integration" + "github.com/miniflux/miniflux/logger" + "github.com/miniflux/miniflux/model" + "github.com/miniflux/miniflux/storage" +) + +type baseResponse struct { + Version int `json:"api_version"` + Authenticated int `json:"auth"` + LastRefresh int64 `json:"last_refreshed_on_time"` +} + +func (b *baseResponse) SetCommonValues() { + b.Version = 3 + b.Authenticated = 1 + b.LastRefresh = time.Now().Unix() +} + +/* +The default response is a JSON object containing two members: + + api_version contains the version of the API responding (positive integer) + auth whether the request was successfully authenticated (boolean integer) + +The API can also return XML by passing xml as the optional value of the api argument like so: + +http://yourdomain.com/fever/?api=xml + +The top level XML element is named response. + +The response to each successfully authenticated request will have auth set to 1 and include +at least one additional member: + + last_refreshed_on_time contains the time of the most recently refreshed (not updated) + feed (Unix timestamp/integer) + +*/ +func newBaseResponse() baseResponse { + r := baseResponse{} + r.SetCommonValues() + return r +} + +type groupsResponse struct { + baseResponse + Groups []group `json:"groups"` + FeedsGroups []feedsGroups `json:"feeds_groups"` +} + +type feedsResponse struct { + baseResponse + Feeds []feed `json:"feeds"` + FeedsGroups []feedsGroups `json:"feeds_groups"` +} + +type faviconsResponse struct { + baseResponse + Favicons []favicon `json:"favicons"` +} + +type itemsResponse struct { + baseResponse + Items []item `json:"items"` + Total int `json:"total_items"` +} + +type unreadResponse struct { + baseResponse + ItemIDs string `json:"unread_item_ids"` +} + +type savedResponse struct { + baseResponse + ItemIDs string `json:"saved_item_ids"` +} + +type group struct { + ID int64 `json:"id"` + Title string `json:"title"` +} + +type feedsGroups struct { + GroupID int64 `json:"group_id"` + FeedIDs string `json:"feed_ids"` +} + +type feed struct { + ID int64 `json:"id"` + FaviconID int64 `json:"favicon_id"` + Title string `json:"title"` + URL string `json:"url"` + SiteURL string `json:"site_url"` + IsSpark int `json:"is_spark"` + LastUpdated int64 `json:"last_updated_on_time"` +} + +type item struct { + ID int64 `json:"id"` + FeedID int64 `json:"feed_id"` + Title string `json:"title"` + Author string `json:"author"` + HTML string `json:"html"` + URL string `json:"url"` + IsSaved int `json:"is_saved"` + IsRead int `json:"is_read"` + CreatedAt int64 `json:"created_on_time"` +} + +type favicon struct { + ID int64 `json:"id"` + Data string `json:"data"` +} + +// Controller implements the Fever API. +type Controller struct { + store *storage.Storage +} + +// Handler handles Fever API calls +func (c *Controller) Handler(ctx *handler.Context, request *handler.Request, response *handler.Response) { + switch { + case request.HasQueryParam("groups"): + c.handleGroups(ctx, request, response) + case request.HasQueryParam("feeds"): + c.handleFeeds(ctx, request, response) + case request.HasQueryParam("favicons"): + c.handleFavicons(ctx, request, response) + case request.HasQueryParam("unread_item_ids"): + c.handleUnreadItems(ctx, request, response) + case request.HasQueryParam("saved_item_ids"): + c.handleSavedItems(ctx, request, response) + case request.HasQueryParam("items"): + c.handleItems(ctx, request, response) + case request.FormValue("mark") == "item": + c.handleWriteItems(ctx, request, response) + case request.FormValue("mark") == "feed": + c.handleWriteFeeds(ctx, request, response) + case request.FormValue("mark") == "group": + c.handleWriteGroups(ctx, request, response) + default: + response.JSON().Standard(newBaseResponse()) + } +} + +/* +A request with the groups argument will return two additional members: + + groups contains an array of group objects + feeds_groups contains an array of feeds_group objects + +A group object has the following members: + + id (positive integer) + title (utf-8 string) + +The feeds_group object is documented under “Feeds/Groups Relationships.” + +The “Kindling” super group is not included in this response and is composed of all feeds with +an is_spark equal to 0. + +The “Sparks” super group is not included in this response and is composed of all feeds with an +is_spark equal to 1. + +*/ +func (c *Controller) handleGroups(ctx *handler.Context, request *handler.Request, response *handler.Response) { + userID := ctx.UserID() + logger.Debug("[Fever] Fetching groups for userID=%d", userID) + + categories, err := c.store.Categories(userID) + if err != nil { + response.JSON().ServerError(err) + return + } + + feeds, err := c.store.Feeds(userID) + if err != nil { + response.JSON().ServerError(err) + return + } + + var result groupsResponse + for _, category := range categories { + result.Groups = append(result.Groups, group{ID: category.ID, Title: category.Title}) + } + + result.FeedsGroups = c.buildFeedGroups(feeds) + result.SetCommonValues() + response.JSON().Standard(result) +} + +/* +A request with the feeds argument will return two additional members: + + feeds contains an array of group objects + feeds_groups contains an array of feeds_group objects + +A feed object has the following members: + + id (positive integer) + favicon_id (positive integer) + title (utf-8 string) + url (utf-8 string) + site_url (utf-8 string) + is_spark (boolean integer) + last_updated_on_time (Unix timestamp/integer) + +The feeds_group object is documented under “Feeds/Groups Relationships.” + +The “All Items” super feed is not included in this response and is composed of all items from all feeds +that belong to a given group. For the “Kindling” super group and all user created groups the items +should be limited to feeds with an is_spark equal to 0. + +For the “Sparks” super group the items should be limited to feeds with an is_spark equal to 1. +*/ +func (c *Controller) handleFeeds(ctx *handler.Context, request *handler.Request, response *handler.Response) { + userID := ctx.UserID() + logger.Debug("[Fever] Fetching feeds for userID=%d", userID) + + feeds, err := c.store.Feeds(userID) + if err != nil { + response.JSON().ServerError(err) + return + } + + var result feedsResponse + result.Feeds = make([]feed, 0) + for _, f := range feeds { + subscripion := feed{ + ID: f.ID, + Title: f.Title, + URL: f.FeedURL, + SiteURL: f.SiteURL, + IsSpark: 0, + LastUpdated: f.CheckedAt.Unix(), + } + + if f.Icon != nil { + subscripion.FaviconID = f.Icon.IconID + } + + result.Feeds = append(result.Feeds, subscripion) + } + + result.FeedsGroups = c.buildFeedGroups(feeds) + result.SetCommonValues() + response.JSON().Standard(result) +} + +/* +A request with the favicons argument will return one additional member: + + favicons contains an array of favicon objects + +A favicon object has the following members: + + id (positive integer) + data (base64 encoded image data; prefixed by image type) + +An example data value: + + image/gif;base64,R0lGODlhAQABAIAAAObm5gAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw== + +The data member of a favicon object can be used with the data: protocol to embed an image in CSS or HTML. +A PHP/HTML example: + + echo ''; +*/ +func (c *Controller) handleFavicons(ctx *handler.Context, request *handler.Request, response *handler.Response) { + userID := ctx.UserID() + logger.Debug("[Fever] Fetching favicons for userID=%d", userID) + + icons, err := c.store.Icons(userID) + if err != nil { + response.JSON().ServerError(err) + return + } + + var result faviconsResponse + for _, i := range icons { + result.Favicons = append(result.Favicons, favicon{ + ID: i.ID, + Data: i.DataURL(), + }) + } + + result.SetCommonValues() + response.JSON().Standard(result) +} + +/* +A request with the items argument will return two additional members: + + items contains an array of item objects + total_items contains the total number of items stored in the database (added in API version 2) + +An item object has the following members: + + id (positive integer) + feed_id (positive integer) + title (utf-8 string) + author (utf-8 string) + html (utf-8 string) + url (utf-8 string) + is_saved (boolean integer) + is_read (boolean integer) + created_on_time (Unix timestamp/integer) + +Most servers won’t have enough memory allocated to PHP to dump all items at once. +Three optional arguments control determine the items included in the response. + + Use the since_id argument with the highest id of locally cached items to request 50 additional items. + Repeat until the items array in the response is empty. + + Use the max_id argument with the lowest id of locally cached items (or 0 initially) to request 50 previous items. + Repeat until the items array in the response is empty. (added in API version 2) + + Use the with_ids argument with a comma-separated list of item ids to request (a maximum of 50) specific items. + (added in API version 2) + +*/ +func (c *Controller) handleItems(ctx *handler.Context, request *handler.Request, response *handler.Response) { + var result itemsResponse + + userID := ctx.UserID() + logger.Debug("[Fever] Fetching items for userID=%d", userID) + + builder := c.store.NewEntryQueryBuilder(userID) + builder.WithoutStatus(model.EntryStatusRemoved) + builder.WithLimit(50) + builder.WithOrder("id") + builder.WithDirection(model.DefaultSortingDirection) + + sinceID := request.QueryIntegerParam("since_id", 0) + if sinceID > 0 { + builder.WithGreaterThanEntryID(int64(sinceID)) + } + + maxID := request.QueryIntegerParam("max_id", 0) + if maxID > 0 { + builder.WithOffset(maxID) + } + + csvItemIDs := request.QueryStringParam("with_ids", "") + if csvItemIDs != "" { + var itemIDs []int64 + + for _, strItemID := range strings.Split(csvItemIDs, ",") { + strItemID = strings.TrimSpace(strItemID) + itemID, _ := strconv.Atoi(strItemID) + itemIDs = append(itemIDs, int64(itemID)) + } + + builder.WithEntryIDs(itemIDs) + } + + entries, err := builder.GetEntries() + if err != nil { + response.JSON().ServerError(err) + return + } + + builder = c.store.NewEntryQueryBuilder(userID) + builder.WithoutStatus(model.EntryStatusRemoved) + result.Total, err = builder.CountEntries() + if err != nil { + response.JSON().ServerError(err) + return + } + + for _, entry := range entries { + isRead := 0 + if entry.Status == model.EntryStatusRead { + isRead = 1 + } + + isSaved := 0 + if entry.Starred { + isSaved = 1 + } + + result.Items = append(result.Items, item{ + ID: entry.ID, + FeedID: entry.FeedID, + Title: entry.Title, + Author: entry.Author, + HTML: entry.Content, + URL: entry.URL, + IsSaved: isSaved, + IsRead: isRead, + CreatedAt: entry.Date.Unix(), + }) + } + + result.SetCommonValues() + response.JSON().Standard(result) +} + +/* +The unread_item_ids and saved_item_ids arguments can be used to keep your local cache synced +with the remote Fever installation. + +A request with the unread_item_ids argument will return one additional member: + unread_item_ids (string/comma-separated list of positive integers) +*/ +func (c *Controller) handleUnreadItems(ctx *handler.Context, request *handler.Request, response *handler.Response) { + userID := ctx.UserID() + logger.Debug("[Fever] Fetching unread items for userID=%d", userID) + + builder := c.store.NewEntryQueryBuilder(userID) + builder.WithStatus(model.EntryStatusUnread) + entries, err := builder.GetEntries() + if err != nil { + response.JSON().ServerError(err) + return + } + + var itemIDs []string + for _, entry := range entries { + itemIDs = append(itemIDs, strconv.FormatInt(entry.ID, 10)) + } + + var result unreadResponse + result.ItemIDs = strings.Join(itemIDs, ",") + result.SetCommonValues() + response.JSON().Standard(result) +} + +/* +The unread_item_ids and saved_item_ids arguments can be used to keep your local cache synced +with the remote Fever installation. + + A request with the saved_item_ids argument will return one additional member: + + saved_item_ids (string/comma-separated list of positive integers) +*/ +func (c *Controller) handleSavedItems(ctx *handler.Context, request *handler.Request, response *handler.Response) { + userID := ctx.UserID() + logger.Debug("[Fever] Fetching saved items for userID=%d", userID) + + builder := c.store.NewEntryQueryBuilder(userID) + builder.WithStarred() + + entryIDs, err := builder.GetEntryIDs() + if err != nil { + response.JSON().ServerError(err) + return + } + + var itemsIDs []string + for _, entryID := range entryIDs { + itemsIDs = append(itemsIDs, strconv.FormatInt(entryID, 10)) + } + + result := &savedResponse{ItemIDs: strings.Join(itemsIDs, ",")} + result.SetCommonValues() + response.JSON().Standard(result) +} + +/* + mark=item + as=? where ? is replaced with read, saved or unsaved + id=? where ? is replaced with the id of the item to modify +*/ +func (c *Controller) handleWriteItems(ctx *handler.Context, request *handler.Request, response *handler.Response) { + userID := ctx.UserID() + logger.Debug("[Fever] Receiving mark=item call for userID=%d", userID) + + entryID := request.FormIntegerValue("id") + if entryID <= 0 { + return + } + + builder := c.store.NewEntryQueryBuilder(userID) + builder.WithEntryID(entryID) + builder.WithoutStatus(model.EntryStatusRemoved) + + entry, err := builder.GetEntry() + if err != nil { + response.JSON().ServerError(err) + return + } + + if entry == nil { + return + } + + switch request.FormValue("as") { + case "read": + c.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusRead) + case "unread": + c.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusUnread) + case "saved": + if err := c.store.ToggleBookmark(userID, entryID); err != nil { + response.JSON().ServerError(err) + return + } + + settings, err := c.store.Integration(userID) + if err != nil { + response.JSON().ServerError(err) + return + } + + go func() { + integration.SendEntry(entry, settings) + }() + } + + response.JSON().Standard(newBaseResponse()) +} + +/* + mark=? where ? is replaced with feed or group + as=read + id=? where ? is replaced with the id of the feed or group to modify + before=? where ? is replaced with the Unix timestamp of the the local client’s most recent items API request +*/ +func (c *Controller) handleWriteFeeds(ctx *handler.Context, request *handler.Request, response *handler.Response) { + userID := ctx.UserID() + logger.Debug("[Fever] Receiving mark=feed call for userID=%d", userID) + + feedID := request.FormIntegerValue("id") + if feedID <= 0 { + return + } + + builder := c.store.NewEntryQueryBuilder(userID) + builder.WithStatus(model.EntryStatusUnread) + builder.WithFeedID(feedID) + + before := request.FormIntegerValue("before") + if before > 0 { + t := time.Unix(before, 0) + builder.Before(&t) + } + + entryIDs, err := builder.GetEntryIDs() + if err != nil { + response.JSON().ServerError(err) + return + } + + err = c.store.SetEntriesStatus(userID, entryIDs, model.EntryStatusRead) + if err != nil { + response.JSON().ServerError(err) + return + } + + response.JSON().Standard(newBaseResponse()) +} + +/* + mark=? where ? is replaced with feed or group + as=read + id=? where ? is replaced with the id of the feed or group to modify + before=? where ? is replaced with the Unix timestamp of the the local client’s most recent items API request +*/ +func (c *Controller) handleWriteGroups(ctx *handler.Context, request *handler.Request, response *handler.Response) { + userID := ctx.UserID() + logger.Debug("[Fever] Receiving mark=group call for userID=%d", userID) + + groupID := request.FormIntegerValue("id") + if groupID < 0 { + return + } + + builder := c.store.NewEntryQueryBuilder(userID) + builder.WithStatus(model.EntryStatusUnread) + builder.WithCategoryID(groupID) + + before := request.FormIntegerValue("before") + if before > 0 { + t := time.Unix(before, 0) + builder.Before(&t) + } + + entryIDs, err := builder.GetEntryIDs() + if err != nil { + response.JSON().ServerError(err) + return + } + + err = c.store.SetEntriesStatus(userID, entryIDs, model.EntryStatusRead) + if err != nil { + response.JSON().ServerError(err) + return + } + + response.JSON().Standard(newBaseResponse()) +} + +/* +A feeds_group object has the following members: + + group_id (positive integer) + feed_ids (string/comma-separated list of positive integers) + +*/ +func (c *Controller) 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)) + } + + result := make([]feedsGroups, 0) + for categoryID, feedIDs := range feedsGroupedByCategory { + result = append(result, feedsGroups{ + GroupID: categoryID, + FeedIDs: strings.Join(feedIDs, ","), + }) + } + + return result +} + +// NewController returns a new Fever API. +func NewController(store *storage.Storage) *Controller { + return &Controller{store: store} +} -- cgit v1.2.3