Add Fever API
This commit is contained in:
parent
ae62e543d3
commit
bc20e0884b
24 changed files with 984 additions and 37 deletions
|
@ -38,7 +38,7 @@ TODO
|
|||
- [X] Flush history
|
||||
- [X] OAuth2
|
||||
- [X] Touch events
|
||||
- [ ] Fever API?
|
||||
- [X] Fever API
|
||||
|
||||
Credits
|
||||
-------
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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"`
|
||||
|
|
|
@ -14,4 +14,8 @@ type Integration struct {
|
|||
InstapaperEnabled bool
|
||||
InstapaperUsername string
|
||||
InstapaperPassword string
|
||||
FeverEnabled bool
|
||||
FeverUsername string
|
||||
FeverPassword string
|
||||
FeverToken string
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
server/fever/fever.go
Normal file
636
server/fever/fever.go
Normal file
|
@ -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
server/middleware/fever.go
Normal file
57
server/middleware/fever.go
Normal file
|
@ -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}
|
||||
}
|
|
@ -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,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,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,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,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
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
|
|
|
@ -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 ")
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
Loading…
Reference in a new issue