Explorar o código

Add Fever API

Frédéric Guillot %!s(int64=8) %!d(string=hai) anos
pai
achega
bc20e0884b

+ 1 - 1
README.md

@@ -38,7 +38,7 @@ TODO
 - [X] Flush history
 - [X] OAuth2
 - [X] Touch events
-- [ ] Fever API?
+- [X] Fever API
 
 Credits
 -------

+ 8 - 5
locale/translations.go

@@ -1,5 +1,5 @@
 // Code generated by go generate; DO NOT EDIT.
-// 2017-12-02 21:11:24.028184492 -0800 PST m=+0.019358340
+// 2017-12-03 17:25:29.428779083 -0800 PST m=+0.041806008
 
 package locale
 
@@ -160,15 +160,18 @@ var translations = map[string]string{
     "Mark bookmark as unread": "Marquer le lien comme non lu",
     "Pinboard Tags": "Libellés de Pinboard",
     "Pinboard API Token": "Jeton de sécurité de l'API de Pinboard",
-    "Enable Pinboard": "Activer Pinboard",
-    "Enable Instapaper": "Activer Instapaper",
+    "Save articles to Pinboard": "Sauvegarder les articles vers Pinboard",
+    "Save articles to Instapaper": "Sauvegarder les articles vers Instapaper",
     "Instapaper Username": "Nom d'utilisateur Instapaper",
-    "Instapaper Password": "Mot de passe Instapaper"
+    "Instapaper Password": "Mot de passe Instapaper",
+    "Activate Fever API": "Activer l'API de Fever",
+    "Fever Username": "Nom d'utilisateur pour l'API de Fever",
+    "Fever Password": "Mot de passe pour l'API de Fever"
 }
 `,
 }
 
 var translationsChecksums = map[string]string{
 	"en_US": "6fe95384260941e8a5a3c695a655a932e0a8a6a572c1e45cb2b1ae8baa01b897",
-	"fr_FR": "17a85afeb45665dc1a74cfb1fde83e0ed4ba335a8da56a328cf20ee4baec7567",
+	"fr_FR": "a2f9b16737041413669e754eddf07ec7817e70dd42dc99a951a162d166663f1c",
 }

+ 6 - 3
locale/translations/fr_FR.json

@@ -144,8 +144,11 @@
     "Mark bookmark as unread": "Marquer le lien comme non lu",
     "Pinboard Tags": "Libellés de Pinboard",
     "Pinboard API Token": "Jeton de sécurité de l'API de Pinboard",
-    "Enable Pinboard": "Activer Pinboard",
-    "Enable Instapaper": "Activer Instapaper",
+    "Save articles to Pinboard": "Sauvegarder les articles vers Pinboard",
+    "Save articles to Instapaper": "Sauvegarder les articles vers Instapaper",
     "Instapaper Username": "Nom d'utilisateur Instapaper",
-    "Instapaper Password": "Mot de passe Instapaper"
+    "Instapaper Password": "Mot de passe Instapaper",
+    "Activate Fever API": "Activer l'API de Fever",
+    "Fever Username": "Nom d'utilisateur pour l'API de Fever",
+    "Fever Password": "Mot de passe pour l'API de Fever"
 }

+ 13 - 0
model/icon.go

@@ -4,6 +4,11 @@
 
 package model
 
+import (
+	"encoding/base64"
+	"fmt"
+)
+
 // Icon represents a website icon (favicon)
 type Icon struct {
 	ID       int64  `json:"id"`
@@ -12,6 +17,14 @@ type Icon struct {
 	Content  []byte `json:"content"`
 }
 
+// DataURL returns the data URL of the icon.
+func (i *Icon) DataURL() string {
+	return fmt.Sprintf("%s;base64,%s", i.MimeType, base64.StdEncoding.EncodeToString(i.Content))
+}
+
+// Icons represents a list of icon.
+type Icons []*Icon
+
 // FeedIcon is a jonction table between feeds and icons
 type FeedIcon struct {
 	FeedID int64 `json:"feed_id"`

+ 4 - 0
model/integration.go

@@ -14,4 +14,8 @@ type Integration struct {
 	InstapaperEnabled    bool
 	InstapaperUsername   string
 	InstapaperPassword   string
+	FeverEnabled         bool
+	FeverUsername        string
+	FeverPassword        string
+	FeverToken           string
 }

+ 5 - 0
server/core/json_response.go

@@ -103,3 +103,8 @@ func (j *JSONResponse) toJSON(v interface{}) []byte {
 
 	return b
 }
+
+// NewJSONResponse returns a new JSONResponse.
+func NewJSONResponse(w http.ResponseWriter, r *http.Request) *JSONResponse {
+	return &JSONResponse{request: r, writer: w}
+}

+ 19 - 0
server/core/request.go

@@ -51,6 +51,18 @@ func (r *Request) Cookie(name string) string {
 	return cookie.Value
 }
 
+// FormValue returns a form value as integer.
+func (r *Request) FormValue(param string) string {
+	return r.request.FormValue(param)
+}
+
+// FormIntegerValue returns a form value as integer.
+func (r *Request) FormIntegerValue(param string) int64 {
+	value := r.request.FormValue(param)
+	integer, _ := strconv.Atoi(value)
+	return int64(integer)
+}
+
 // IntegerParam returns an URL parameter as integer.
 func (r *Request) IntegerParam(param string) (int64, error) {
 	vars := mux.Vars(r.request)
@@ -105,6 +117,13 @@ func (r *Request) QueryIntegerParam(param string, defaultValue int) int {
 	return val
 }
 
+// HasQueryParam checks if the query string contains the given parameter.
+func (r *Request) HasQueryParam(param string) bool {
+	values := r.request.URL.Query()
+	_, ok := values[param]
+	return ok
+}
+
 // NewRequest returns a new Request struct.
 func NewRequest(w http.ResponseWriter, r *http.Request) *Request {
 	return &Request{writer: w, request: r}

+ 1 - 1
server/core/response.go

@@ -26,7 +26,7 @@ func (r *Response) SetCookie(cookie *http.Cookie) {
 // JSON returns a JSONResponse.
 func (r *Response) JSON() *JSONResponse {
 	r.commonHeaders()
-	return &JSONResponse{writer: r.writer, request: r.request}
+	return NewJSONResponse(r.writer, r.request)
 }
 
 // HTML returns a HTMLResponse.

+ 636 - 0
server/fever/fever.go

@@ -0,0 +1,636 @@
+// 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 (
+	"log"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/miniflux/miniflux2/integration"
+	"github.com/miniflux/miniflux2/model"
+	"github.com/miniflux/miniflux2/server/core"
+	"github.com/miniflux/miniflux2/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 linksResponse struct {
+	baseResponse
+	Links []string `json:"links"`
+}
+
+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 *core.Context, request *core.Request, response *core.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.HasQueryParam("links"):
+		c.handleLinks(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 *core.Context, request *core.Request, response *core.Response) {
+	userID := ctx.UserID()
+	log.Printf("[Fever] Fetching groups for userID=%d\n", 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 *core.Context, request *core.Request, response *core.Response) {
+	userID := ctx.UserID()
+	log.Printf("[Fever] Fetching feeds for userID=%d\n", userID)
+
+	feeds, err := c.store.Feeds(userID)
+	if err != nil {
+		response.JSON().ServerError(err)
+		return
+	}
+
+	var result feedsResponse
+	for _, f := range feeds {
+		result.Feeds = append(result.Feeds, feed{
+			ID:          f.ID,
+			FaviconID:   f.Icon.IconID,
+			Title:       f.Title,
+			URL:         f.FeedURL,
+			SiteURL:     f.SiteURL,
+			IsSpark:     0,
+			LastUpdated: f.CheckedAt.Unix(),
+		})
+	}
+
+	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 '<img src="data:'.$favicon['data'].'">';
+*/
+func (c *Controller) handleFavicons(ctx *core.Context, request *core.Request, response *core.Response) {
+	userID := ctx.UserID()
+	log.Printf("[Fever] Fetching favicons for userID=%d\n", 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 *core.Context, request *core.Request, response *core.Response) {
+	var result itemsResponse
+
+	userID := ctx.UserID()
+	timezone := ctx.UserTimezone()
+	log.Printf("[Fever] Fetching items for userID=%d\n", userID)
+
+	builder := c.store.GetEntryQueryBuilder(userID, timezone)
+	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.GetEntryQueryBuilder(userID, timezone)
+	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
+		}
+
+		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:   0,
+			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 *core.Context, request *core.Request, response *core.Response) {
+	userID := ctx.UserID()
+	log.Printf("[Fever] Fetching unread items for userID=%d\n", userID)
+
+	builder := c.store.GetEntryQueryBuilder(userID, ctx.UserTimezone())
+	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 *core.Context, request *core.Request, response *core.Response) {
+	userID := ctx.UserID()
+	log.Printf("[Fever] Fetching saved items for userID=%d\n", userID)
+
+	var result savedResponse
+	result.SetCommonValues()
+	response.JSON().Standard(result)
+}
+
+/*
+A request with the links argument will return one additional member:
+
+    links contains an array of link objects
+
+A link object has the following members:
+
+    id (positive integer)
+    feed_id (positive integer) only use when is_item equals 1
+    item_id (positive integer) only use when is_item equals 1
+    temperature (positive float)
+    is_item (boolean integer)
+    is_local (boolean integer) used to determine if the source feed and favicon should be displayed
+    is_saved (boolean integer) only use when is_item equals 1
+    title (utf-8 string)
+    url (utf-8 string)
+    item_ids (string/comma-separated list of positive integers)
+*/
+func (c *Controller) handleLinks(ctx *core.Context, request *core.Request, response *core.Response) {
+	userID := ctx.UserID()
+	log.Printf("[Fever] Fetching links for userID=%d\n", userID)
+
+	var result linksResponse
+	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 *core.Context, request *core.Request, response *core.Response) {
+	userID := ctx.UserID()
+	log.Printf("[Fever] Receiving mark=item call for userID=%d\n", userID)
+
+	entryID := request.FormIntegerValue("id")
+	if entryID <= 0 {
+		return
+	}
+
+	builder := c.store.GetEntryQueryBuilder(userID, ctx.UserTimezone())
+	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":
+		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 *core.Context, request *core.Request, response *core.Response) {
+	userID := ctx.UserID()
+	log.Printf("[Fever] Receiving mark=feed call for userID=%d\n", userID)
+
+	feedID := request.FormIntegerValue("id")
+	if feedID <= 0 {
+		return
+	}
+
+	builder := c.store.GetEntryQueryBuilder(userID, ctx.UserTimezone())
+	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 *core.Context, request *core.Request, response *core.Response) {
+	userID := ctx.UserID()
+	log.Printf("[Fever] Receiving mark=group call for userID=%d\n", userID)
+
+	groupID := request.FormIntegerValue("id")
+	if groupID < 0 {
+		return
+	}
+
+	builder := c.store.GetEntryQueryBuilder(userID, ctx.UserTimezone())
+	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))
+	}
+
+	var result []feedsGroups
+	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}
+}

+ 57 - 0
server/middleware/fever.go

@@ -0,0 +1,57 @@
+// 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 middleware
+
+import (
+	"context"
+	"log"
+	"net/http"
+
+	"github.com/miniflux/miniflux2/storage"
+)
+
+// FeverMiddleware is the middleware that handles Fever API.
+type FeverMiddleware struct {
+	store *storage.Storage
+}
+
+// Handler executes the middleware.
+func (f *FeverMiddleware) Handler(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		log.Println("[Middleware:Fever]")
+
+		apiKey := r.FormValue("api_key")
+		user, err := f.store.UserByFeverToken(apiKey)
+		if err != nil {
+			log.Println(err)
+			w.Header().Set("Content-Type", "application/json")
+			w.Write([]byte(`{"api_version": 3, "auth": 0}`))
+			return
+		}
+
+		if user == nil {
+			log.Println("[Middleware:Fever] Fever authentication failure")
+			w.Header().Set("Content-Type", "application/json")
+			w.Write([]byte(`{"api_version": 3, "auth": 0}`))
+			return
+		}
+
+		log.Printf("[Middleware:Fever] User #%d is authenticated\n", user.ID)
+		f.store.SetLastLogin(user.ID)
+
+		ctx := r.Context()
+		ctx = context.WithValue(ctx, UserIDContextKey, user.ID)
+		ctx = context.WithValue(ctx, UserTimezoneContextKey, user.Timezone)
+		ctx = context.WithValue(ctx, IsAdminUserContextKey, user.IsAdmin)
+		ctx = context.WithValue(ctx, IsAuthenticatedContextKey, true)
+
+		next.ServeHTTP(w, r.WithContext(ctx))
+	})
+}
+
+// NewFeverMiddleware returns a new FeverMiddleware.
+func NewFeverMiddleware(s *storage.Storage) *FeverMiddleware {
+	return &FeverMiddleware{store: s}
+}

+ 8 - 0
server/routes.go

@@ -15,6 +15,7 @@ import (
 	"github.com/miniflux/miniflux2/reader/opml"
 	api_controller "github.com/miniflux/miniflux2/server/api/controller"
 	"github.com/miniflux/miniflux2/server/core"
+	"github.com/miniflux/miniflux2/server/fever"
 	"github.com/miniflux/miniflux2/server/middleware"
 	"github.com/miniflux/miniflux2/server/template"
 	ui_controller "github.com/miniflux/miniflux2/server/ui/controller"
@@ -29,17 +30,24 @@ func getRoutes(cfg *config.Config, store *storage.Storage, feedHandler *feed.Han
 	templateEngine := template.NewEngine(cfg, router, translator)
 
 	apiController := api_controller.NewController(store, feedHandler)
+	feverController := fever.NewController(store)
 	uiController := ui_controller.NewController(cfg, store, pool, feedHandler, opml.NewHandler(store))
 
 	apiHandler := core.NewHandler(store, router, templateEngine, translator, middleware.NewChain(
 		middleware.NewBasicAuthMiddleware(store).Handler,
 	))
 
+	feverHandler := core.NewHandler(store, router, templateEngine, translator, middleware.NewChain(
+		middleware.NewFeverMiddleware(store).Handler,
+	))
+
 	uiHandler := core.NewHandler(store, router, templateEngine, translator, middleware.NewChain(
 		middleware.NewSessionMiddleware(store, router).Handler,
 		middleware.NewTokenMiddleware(store).Handler,
 	))
 
+	router.Handle("/fever/", feverHandler.Use(feverController.Handler))
+
 	router.Handle("/v1/users", apiHandler.Use(apiController.CreateUser)).Methods("POST")
 	router.Handle("/v1/users", apiHandler.Use(apiController.GetUsers)).Methods("GET")
 	router.Handle("/v1/users/{userID}", apiHandler.Use(apiController.GetUser)).Methods("GET")

+ 1 - 1
server/static/bin.go

@@ -1,5 +1,5 @@
 // Code generated by go generate; DO NOT EDIT.
-// 2017-12-02 21:11:24.016429412 -0800 PST m=+0.007603260
+// 2017-12-03 17:25:29.40151375 -0800 PST m=+0.014540675
 
 package static
 

+ 1 - 1
server/static/css.go

@@ -1,5 +1,5 @@
 // Code generated by go generate; DO NOT EDIT.
-// 2017-12-02 21:11:24.017204599 -0800 PST m=+0.008378447
+// 2017-12-03 17:25:29.40458076 -0800 PST m=+0.017607685
 
 package static
 

+ 1 - 1
server/static/js.go

@@ -1,5 +1,5 @@
 // Code generated by go generate; DO NOT EDIT.
-// 2017-12-02 21:11:24.018743922 -0800 PST m=+0.009917770
+// 2017-12-03 17:25:29.409871548 -0800 PST m=+0.022898473
 
 package static
 

+ 1 - 1
server/template/common.go

@@ -1,5 +1,5 @@
 // Code generated by go generate; DO NOT EDIT.
-// 2017-12-02 21:11:24.027142168 -0800 PST m=+0.018316016
+// 2017-12-03 17:25:29.427766854 -0800 PST m=+0.040793779
 
 package template
 

+ 15 - 2
server/template/html/integrations.html

@@ -28,10 +28,23 @@
         <div class="alert alert-error">{{ t .errorMessage }}</div>
     {{ end }}
 
+    <h3>Fever</h3>
+    <div class="form-section">
+        <label>
+            <input type="checkbox" name="fever_enabled" value="1" {{ if .form.FeverEnabled }}checked{{ end }}> {{ t "Activate Fever API" }}
+        </label>
+
+        <label for="form-fever-username">{{ t "Fever Username" }}</label>
+        <input type="text" name="fever_username" id="form-fever-username" value="{{ .form.FeverUsername }}">
+
+        <label for="form-fever-password">{{ t "Fever Password" }}</label>
+        <input type="password" name="fever_password" id="form-fever-password" value="{{ .form.FeverPassword }}">
+    </div>
+
     <h3>Pinboard</h3>
     <div class="form-section">
         <label>
-            <input type="checkbox" name="pinboard_enabled" value="1" {{ if .form.PinboardEnabled }}checked{{ end }}> {{ t "Enable Pinboard" }}
+            <input type="checkbox" name="pinboard_enabled" value="1" {{ if .form.PinboardEnabled }}checked{{ end }}> {{ t "Save articles to Pinboard" }}
         </label>
 
         <label for="form-pinboard-token">{{ t "Pinboard API Token" }}</label>
@@ -48,7 +61,7 @@
     <h3>Instapaper</h3>
     <div class="form-section">
         <label>
-            <input type="checkbox" name="instapaper_enabled" value="1" {{ if .form.InstapaperEnabled }}checked{{ end }}> {{ t "Enable Instapaper" }}
+            <input type="checkbox" name="instapaper_enabled" value="1" {{ if .form.InstapaperEnabled }}checked{{ end }}> {{ t "Save articles to Instapaper" }}
         </label>
 
         <label for="form-instapaper-username">{{ t "Instapaper Username" }}</label>

+ 17 - 4
server/template/views.go

@@ -1,5 +1,5 @@
 // Code generated by go generate; DO NOT EDIT.
-// 2017-12-02 21:11:24.019569008 -0800 PST m=+0.010742856
+// 2017-12-03 17:25:29.413238818 -0800 PST m=+0.026265743
 
 package template
 
@@ -811,10 +811,23 @@ var templateViewsMap = map[string]string{
         <div class="alert alert-error">{{ t .errorMessage }}</div>
     {{ end }}
 
+    <h3>Fever</h3>
+    <div class="form-section">
+        <label>
+            <input type="checkbox" name="fever_enabled" value="1" {{ if .form.FeverEnabled }}checked{{ end }}> {{ t "Activate Fever API" }}
+        </label>
+
+        <label for="form-fever-username">{{ t "Fever Username" }}</label>
+        <input type="text" name="fever_username" id="form-fever-username" value="{{ .form.FeverUsername }}">
+
+        <label for="form-fever-password">{{ t "Fever Password" }}</label>
+        <input type="password" name="fever_password" id="form-fever-password" value="{{ .form.FeverPassword }}">
+    </div>
+
     <h3>Pinboard</h3>
     <div class="form-section">
         <label>
-            <input type="checkbox" name="pinboard_enabled" value="1" {{ if .form.PinboardEnabled }}checked{{ end }}> {{ t "Enable Pinboard" }}
+            <input type="checkbox" name="pinboard_enabled" value="1" {{ if .form.PinboardEnabled }}checked{{ end }}> {{ t "Save articles to Pinboard" }}
         </label>
 
         <label for="form-pinboard-token">{{ t "Pinboard API Token" }}</label>
@@ -831,7 +844,7 @@ var templateViewsMap = map[string]string{
     <h3>Instapaper</h3>
     <div class="form-section">
         <label>
-            <input type="checkbox" name="instapaper_enabled" value="1" {{ if .form.InstapaperEnabled }}checked{{ end }}> {{ t "Enable Instapaper" }}
+            <input type="checkbox" name="instapaper_enabled" value="1" {{ if .form.InstapaperEnabled }}checked{{ end }}> {{ t "Save articles to Instapaper" }}
         </label>
 
         <label for="form-instapaper-username">{{ t "Instapaper Username" }}</label>
@@ -1160,7 +1173,7 @@ var templateViewsMapChecksums = map[string]string{
 	"feeds":               "c22af39b42ba9ca69ea0914ca789303ec2c5b484abcd4eaa49016e365381257c",
 	"history":             "9a67599a5d8d67ef958e3f07da339b749f42892667547c9e60a54477e8d32a56",
 	"import":              "73b5112e20bfd232bf73334544186ea419505936bc237d481517a8622901878f",
-	"integrations":        "4e51fabe73b4ee2c2268f77dbbf7987c2a176c5a5714ea29ac31986928f22b8a",
+	"integrations":        "30249eefa4e2da62051447537ee5c4ed3dad377656fec3080e0e96c3c697c672",
 	"login":               "04f3ce79bfa5753f69e0d956c2a8999c0da549c7925634a3e8134975da0b0e0f",
 	"sessions":            "878dbe8f8ea783b44130c495814179519fa5c3aa2666ac87508f94d58dd008bf",
 	"settings":            "ea2505b9d0a6d6bb594dba87a92079de19baa6d494f0651693a7685489fb7de9",

+ 11 - 0
server/ui/controller/integrations.go

@@ -5,7 +5,9 @@
 package controller
 
 import (
+	"crypto/md5"
 	"errors"
+	"fmt"
 
 	"github.com/miniflux/miniflux2/integration"
 	"github.com/miniflux/miniflux2/model"
@@ -38,6 +40,9 @@ func (c *Controller) ShowIntegrations(ctx *core.Context, request *core.Request,
 			InstapaperEnabled:    integration.InstapaperEnabled,
 			InstapaperUsername:   integration.InstapaperUsername,
 			InstapaperPassword:   integration.InstapaperPassword,
+			FeverEnabled:         integration.FeverEnabled,
+			FeverUsername:        integration.FeverUsername,
+			FeverPassword:        integration.FeverPassword,
 		},
 	}))
 }
@@ -54,6 +59,12 @@ func (c *Controller) UpdateIntegration(ctx *core.Context, request *core.Request,
 	integrationForm := form.NewIntegrationForm(request.Request())
 	integrationForm.Merge(integration)
 
+	if integration.FeverEnabled {
+		integration.FeverToken = fmt.Sprintf("%x", md5.Sum([]byte(integration.FeverUsername+":"+integration.FeverPassword)))
+	} else {
+		integration.FeverToken = ""
+	}
+
 	err = c.store.UpdateIntegration(integration)
 	if err != nil {
 		response.HTML().ServerError(err)

+ 9 - 0
server/ui/form/integration.go

@@ -19,6 +19,9 @@ type IntegrationForm struct {
 	InstapaperEnabled    bool
 	InstapaperUsername   string
 	InstapaperPassword   string
+	FeverEnabled         bool
+	FeverUsername        string
+	FeverPassword        string
 }
 
 // Merge copy form values to the model.
@@ -30,6 +33,9 @@ func (i IntegrationForm) Merge(integration *model.Integration) {
 	integration.InstapaperEnabled = i.InstapaperEnabled
 	integration.InstapaperUsername = i.InstapaperUsername
 	integration.InstapaperPassword = i.InstapaperPassword
+	integration.FeverEnabled = i.FeverEnabled
+	integration.FeverUsername = i.FeverUsername
+	integration.FeverPassword = i.FeverPassword
 }
 
 // NewIntegrationForm returns a new AuthForm.
@@ -42,5 +48,8 @@ func NewIntegrationForm(r *http.Request) *IntegrationForm {
 		InstapaperEnabled:    r.FormValue("instapaper_enabled") == "1",
 		InstapaperUsername:   r.FormValue("instapaper_username"),
 		InstapaperPassword:   r.FormValue("instapaper_password"),
+		FeverEnabled:         r.FormValue("fever_enabled") == "1",
+		FeverUsername:        r.FormValue("fever_username"),
+		FeverPassword:        r.FormValue("fever_password"),
 	}
 }

+ 4 - 0
sql/schema_version_5.sql

@@ -7,5 +7,9 @@ create table integrations (
     instapaper_enabled bool default 'f',
     instapaper_username text default '',
     instapaper_password text default '',
+    fever_enabled bool default 'f',
+    fever_username text default '',
+    fever_password text default '',
+    fever_token text default '',
     primary key(user_id)
 )

+ 6 - 2
sql/sql.go

@@ -1,5 +1,5 @@
 // Code generated by go generate; DO NOT EDIT.
-// 2017-12-02 21:11:24.01125036 -0800 PST m=+0.002424208
+// 2017-12-03 17:25:29.391052668 -0800 PST m=+0.004079593
 
 package sql
 
@@ -130,6 +130,10 @@ alter table users add column entry_direction entry_sorting_direction default 'as
     instapaper_enabled bool default 'f',
     instapaper_username text default '',
     instapaper_password text default '',
+    fever_enabled bool default 'f',
+    fever_username text default '',
+    fever_password text default '',
+    fever_token text default '',
     primary key(user_id)
 )
 `,
@@ -140,5 +144,5 @@ var SqlMapChecksums = map[string]string{
 	"schema_version_2": "e8e9ff32478df04fcddad10a34cba2e8bb1e67e7977b5bd6cdc4c31ec94282b4",
 	"schema_version_3": "a54745dbc1c51c000f74d4e5068f1e2f43e83309f023415b1749a47d5c1e0f12",
 	"schema_version_4": "216ea3a7d3e1704e40c797b5dc47456517c27dbb6ca98bf88812f4f63d74b5d9",
-	"schema_version_5": "4e7958c01f15def3f8619fc5bee6f0d99e773353aeea08188f77ef089fc9d3e7",
+	"schema_version_5": "46397e2f5f2c82116786127e9f6a403e975b14d2ca7b652a48cd1ba843e6a27c",
 }

+ 88 - 12
storage/entry_query_builder.go

@@ -9,24 +9,47 @@ import (
 	"strings"
 	"time"
 
+	"github.com/lib/pq"
+
 	"github.com/miniflux/miniflux2/helper"
 	"github.com/miniflux/miniflux2/model"
 )
 
 // EntryQueryBuilder builds a SQL query to fetch entries.
 type EntryQueryBuilder struct {
-	store      *Storage
-	feedID     int64
-	userID     int64
-	timezone   string
-	categoryID int64
-	status     string
-	notStatus  string
-	order      string
-	direction  string
-	limit      int
-	offset     int
-	entryID    int64
+	store              *Storage
+	feedID             int64
+	userID             int64
+	timezone           string
+	categoryID         int64
+	status             string
+	notStatus          string
+	order              string
+	direction          string
+	limit              int
+	offset             int
+	entryID            int64
+	greaterThanEntryID int64
+	entryIDs           []int64
+	before             *time.Time
+}
+
+// Before add condition base on the entry date.
+func (e *EntryQueryBuilder) Before(date *time.Time) *EntryQueryBuilder {
+	e.before = date
+	return e
+}
+
+// WithGreaterThanEntryID adds a condition > entryID.
+func (e *EntryQueryBuilder) WithGreaterThanEntryID(entryID int64) *EntryQueryBuilder {
+	e.greaterThanEntryID = entryID
+	return e
+}
+
+// WithEntryIDs adds a condition to fetch only the given entry IDs.
+func (e *EntryQueryBuilder) WithEntryIDs(entryIDs []int64) *EntryQueryBuilder {
+	e.entryIDs = entryIDs
+	return e
 }
 
 // WithEntryID set the entryID.
@@ -195,6 +218,44 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
 	return entries, nil
 }
 
+// GetEntryIDs returns a list of entry IDs that match the condition.
+func (e *EntryQueryBuilder) GetEntryIDs() ([]int64, error) {
+	debugStr := "[EntryQueryBuilder:GetEntryIDs] userID=%d, feedID=%d, categoryID=%d, status=%s, order=%s, direction=%s, offset=%d, limit=%d"
+	defer helper.ExecutionTime(time.Now(), fmt.Sprintf(debugStr, e.userID, e.feedID, e.categoryID, e.status, e.order, e.direction, e.offset, e.limit))
+
+	query := `
+		SELECT
+		e.id
+		FROM entries e
+		LEFT JOIN feeds f ON f.id=e.feed_id
+		WHERE %s %s
+	`
+
+	args, conditions := e.buildCondition()
+	query = fmt.Sprintf(query, conditions, e.buildSorting())
+	// log.Println(query)
+
+	rows, err := e.store.db.Query(query, args...)
+	if err != nil {
+		return nil, fmt.Errorf("unable to get entries: %v", err)
+	}
+	defer rows.Close()
+
+	var entryIDs []int64
+	for rows.Next() {
+		var entryID int64
+
+		err := rows.Scan(&entryID)
+		if err != nil {
+			return nil, fmt.Errorf("unable to fetch entry row: %v", err)
+		}
+
+		entryIDs = append(entryIDs, entryID)
+	}
+
+	return entryIDs, nil
+}
+
 func (e *EntryQueryBuilder) buildCondition() ([]interface{}, string) {
 	args := []interface{}{e.userID}
 	conditions := []string{"e.user_id = $1"}
@@ -214,6 +275,16 @@ func (e *EntryQueryBuilder) buildCondition() ([]interface{}, string) {
 		args = append(args, e.entryID)
 	}
 
+	if e.greaterThanEntryID != 0 {
+		conditions = append(conditions, fmt.Sprintf("e.id > $%d", len(args)+1))
+		args = append(args, e.greaterThanEntryID)
+	}
+
+	if e.entryIDs != nil {
+		conditions = append(conditions, fmt.Sprintf("e.id=ANY($%d)", len(args)+1))
+		args = append(args, pq.Array(e.entryIDs))
+	}
+
 	if e.status != "" {
 		conditions = append(conditions, fmt.Sprintf("e.status=$%d", len(args)+1))
 		args = append(args, e.status)
@@ -224,6 +295,11 @@ func (e *EntryQueryBuilder) buildCondition() ([]interface{}, string) {
 		args = append(args, e.notStatus)
 	}
 
+	if e.before != nil {
+		conditions = append(conditions, fmt.Sprintf("e.published_at < $%d", len(args)+1))
+		args = append(args, e.before)
+	}
+
 	return args, strings.Join(conditions, " AND ")
 }
 

+ 31 - 0
storage/icon.go

@@ -101,6 +101,37 @@ func (s *Storage) CreateFeedIcon(feed *model.Feed, icon *model.Icon) error {
 	return nil
 }
 
+// Icons returns all icons tht belongs to a user.
+func (s *Storage) Icons(userID int64) (model.Icons, error) {
+	defer helper.ExecutionTime(time.Now(), fmt.Sprintf("[Storage:Icons] userID=%d", userID))
+	query := `
+		SELECT
+		icons.id, icons.hash, icons.mime_type, icons.content
+		FROM icons
+		LEFT JOIN feed_icons ON feed_icons.icon_id=icons.id
+		LEFT JOIN feeds ON feeds.id=feed_icons.feed_id
+		WHERE feeds.user_id=$1
+	`
+
+	rows, err := s.db.Query(query, userID)
+	if err != nil {
+		return nil, fmt.Errorf("unable to fetch icons: %v", err)
+	}
+	defer rows.Close()
+
+	var icons model.Icons
+	for rows.Next() {
+		var icon model.Icon
+		err := rows.Scan(&icon.ID, &icon.Hash, &icon.MimeType, &icon.Content)
+		if err != nil {
+			return nil, fmt.Errorf("unable to fetch icons row: %v", err)
+		}
+		icons = append(icons, &icon)
+	}
+
+	return icons, nil
+}
+
 func normalizeMimeType(mimeType string) string {
 	mimeType = strings.ToLower(mimeType)
 	switch mimeType {

+ 41 - 3
storage/integration.go

@@ -11,6 +11,28 @@ import (
 	"github.com/miniflux/miniflux2/model"
 )
 
+// UserByFeverToken returns a user by using the Fever API token.
+func (s *Storage) UserByFeverToken(token string) (*model.User, error) {
+	query := `
+		SELECT
+		users.id, users.is_admin, users.timezone
+		FROM users
+		LEFT JOIN integrations ON integrations.user_id=users.id
+		WHERE integrations.fever_enabled='t' AND integrations.fever_token=$1
+	`
+
+	var user model.User
+	err := s.db.QueryRow(query, token).Scan(&user.ID, &user.IsAdmin, &user.Timezone)
+	switch {
+	case err == sql.ErrNoRows:
+		return nil, nil
+	case err != nil:
+		return nil, fmt.Errorf("unable to fetch user: %v", err)
+	}
+
+	return &user, nil
+}
+
 // Integration returns user integration settings.
 func (s *Storage) Integration(userID int64) (*model.Integration, error) {
 	query := `SELECT
@@ -21,7 +43,11 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
 			pinboard_mark_as_unread,
 			instapaper_enabled,
 			instapaper_username,
-			instapaper_password
+			instapaper_password,
+			fever_enabled,
+			fever_username,
+			fever_password,
+			fever_token
 		FROM integrations
 		WHERE user_id=$1
 	`
@@ -35,6 +61,10 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
 		&integration.InstapaperEnabled,
 		&integration.InstapaperUsername,
 		&integration.InstapaperPassword,
+		&integration.FeverEnabled,
+		&integration.FeverUsername,
+		&integration.FeverPassword,
+		&integration.FeverToken,
 	)
 	switch {
 	case err == sql.ErrNoRows:
@@ -56,8 +86,12 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
 			pinboard_mark_as_unread=$4,
 			instapaper_enabled=$5,
 			instapaper_username=$6,
-			instapaper_password=$7
-		WHERE user_id=$8
+			instapaper_password=$7,
+			fever_enabled=$8,
+			fever_username=$9,
+			fever_password=$10,
+			fever_token=$11
+		WHERE user_id=$12
 	`
 	_, err := s.db.Exec(
 		query,
@@ -68,6 +102,10 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
 		integration.InstapaperEnabled,
 		integration.InstapaperUsername,
 		integration.InstapaperPassword,
+		integration.FeverEnabled,
+		integration.FeverUsername,
+		integration.FeverPassword,
+		integration.FeverToken,
 		integration.UserID,
 	)