Frédéric Guillot пре 8 година
родитељ
комит
9868f900e9

+ 1 - 1
Gopkg.lock

@@ -41,7 +41,7 @@
   branch = "master"
   name = "github.com/miniflux/miniflux-go"
   packages = ["."]
-  revision = "ecd111d16e0ce1468cb3b786135c18b3fdc96213"
+  revision = "60d72460e62282aa90cb43fa3a87596900b87678"
 
 [[projects]]
   name = "github.com/tdewolff/minify"

+ 0 - 2
http/client.go

@@ -36,8 +36,6 @@ type Client struct {
 
 // Get execute a GET HTTP request.
 func (c *Client) Get() (*Response, error) {
-	defer helper.ExecutionTime(time.Now(), fmt.Sprintf("[HttpClient:Get] url=%s", c.url))
-
 	request, err := c.buildRequest(http.MethodGet, nil)
 	if err != nil {
 		return nil, err

+ 87 - 33
integration_test.go

@@ -22,6 +22,8 @@ const (
 	testAdminPassword    = "test123"
 	testStandardPassword = "secret"
 	testFeedURL          = "https://github.com/miniflux/miniflux/commits/master.atom"
+	testFeedTitle        = "Recent Commits to miniflux:master"
+	testWebsiteURL       = "https://github.com/miniflux/miniflux/commits/master"
 )
 
 func TestWithBadEndpoint(t *testing.T) {
@@ -486,7 +488,7 @@ func TestCannotDeleteCategoryOfAnotherUser(t *testing.T) {
 
 func TestDiscoverSubscriptions(t *testing.T) {
 	client := miniflux.NewClient(testBaseURL, testAdminUsername, testAdminPassword)
-	subscriptions, err := client.Discover("https://miniflux.net")
+	subscriptions, err := client.Discover(testWebsiteURL)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -495,16 +497,16 @@ func TestDiscoverSubscriptions(t *testing.T) {
 		t.Fatalf(`Invalid number of subscriptions, got "%v" instead of "%v"`, len(subscriptions), 2)
 	}
 
-	if subscriptions[0].Title != "Feed" {
-		t.Fatalf(`Invalid feed title, got "%v" instead of "%v"`, subscriptions[0].Title, "Feed")
+	if subscriptions[0].Title != testFeedTitle {
+		t.Fatalf(`Invalid feed title, got "%v" instead of "%v"`, subscriptions[0].Title, testFeedTitle)
 	}
 
 	if subscriptions[0].Type != "atom" {
 		t.Fatalf(`Invalid feed type, got "%v" instead of "%v"`, subscriptions[0].Type, "atom")
 	}
 
-	if subscriptions[0].URL != "https://miniflux.net/feed" {
-		t.Fatalf(`Invalid feed URL, got "%v" instead of "%v"`, subscriptions[0].URL, "https://miniflux.net/feed")
+	if subscriptions[0].URL != testFeedURL {
+		t.Fatalf(`Invalid feed URL, got "%v" instead of "%v"`, subscriptions[0].URL, testFeedURL)
 	}
 }
 
@@ -522,7 +524,7 @@ func TestCreateFeed(t *testing.T) {
 		t.Fatal(err)
 	}
 
-	feedID, err := client.CreateFeed("https://miniflux.net/feed", categories[0].ID)
+	feedID, err := client.CreateFeed(testFeedURL, categories[0].ID)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -546,7 +548,7 @@ func TestCannotCreateDuplicatedFeed(t *testing.T) {
 		t.Fatal(err)
 	}
 
-	feedID, err := client.CreateFeed("https://miniflux.net/feed", categories[0].ID)
+	feedID, err := client.CreateFeed(testFeedURL, categories[0].ID)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -555,7 +557,7 @@ func TestCannotCreateDuplicatedFeed(t *testing.T) {
 		t.Fatalf(`Invalid feed ID, got "%v"`, feedID)
 	}
 
-	_, err = client.CreateFeed("https://miniflux.net/feed", categories[0].ID)
+	_, err = client.CreateFeed(testFeedURL, categories[0].ID)
 	if err == nil {
 		t.Fatal(`Duplicated feeds should not be allowed`)
 	}
@@ -570,7 +572,7 @@ func TestCreateFeedWithInexistingCategory(t *testing.T) {
 	}
 
 	client = miniflux.NewClient(testBaseURL, username, testStandardPassword)
-	_, err = client.CreateFeed("https://miniflux.net/feed", -1)
+	_, err = client.CreateFeed(testFeedURL, -1)
 	if err == nil {
 		t.Fatal(`Feeds should not be created with inexisting category`)
 	}
@@ -590,7 +592,7 @@ func TestUpdateFeed(t *testing.T) {
 		t.Fatal(err)
 	}
 
-	feedID, err := client.CreateFeed("https://miniflux.net/feed", categories[0].ID)
+	feedID, err := client.CreateFeed(testFeedURL, categories[0].ID)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -630,7 +632,7 @@ func TestDeleteFeed(t *testing.T) {
 		t.Fatal(err)
 	}
 
-	feedID, err := client.CreateFeed("https://miniflux.net/feed", categories[0].ID)
+	feedID, err := client.CreateFeed(testFeedURL, categories[0].ID)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -655,7 +657,7 @@ func TestRefreshFeed(t *testing.T) {
 		t.Fatal(err)
 	}
 
-	feedID, err := client.CreateFeed("https://miniflux.net/feed", categories[0].ID)
+	feedID, err := client.CreateFeed(testFeedURL, categories[0].ID)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -680,7 +682,7 @@ func TestGetFeed(t *testing.T) {
 		t.Fatal(err)
 	}
 
-	feedID, err := client.CreateFeed("https://miniflux.net/feed", categories[0].ID)
+	feedID, err := client.CreateFeed(testFeedURL, categories[0].ID)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -690,16 +692,16 @@ func TestGetFeed(t *testing.T) {
 		t.Fatal(err)
 	}
 
-	if feed.Title != "Miniflux" {
-		t.Fatalf(`Invalid feed title, got "%v" instead of "%v"`, feed.Title, "Miniflux")
+	if feed.Title != testFeedTitle {
+		t.Fatalf(`Invalid feed title, got "%v" instead of "%v"`, feed.Title, testFeedTitle)
 	}
 
-	if feed.SiteURL != "https://miniflux.net/" {
-		t.Fatalf(`Invalid site URL, got "%v" instead of "%v"`, feed.SiteURL, "https://miniflux.net/")
+	if feed.SiteURL != testWebsiteURL {
+		t.Fatalf(`Invalid site URL, got "%v" instead of "%v"`, feed.SiteURL, testWebsiteURL)
 	}
 
-	if feed.FeedURL != "https://miniflux.net/feed" {
-		t.Fatalf(`Invalid feed URL, got "%v" instead of "%v"`, feed.FeedURL, "https://miniflux.net/feed")
+	if feed.FeedURL != testFeedURL {
+		t.Fatalf(`Invalid feed URL, got "%v" instead of "%v"`, feed.FeedURL, testFeedURL)
 	}
 
 	if feed.Category.ID != categories[0].ID {
@@ -780,7 +782,7 @@ func TestGetFeeds(t *testing.T) {
 		t.Fatal(err)
 	}
 
-	feedID, err := client.CreateFeed("https://miniflux.net/feed", categories[0].ID)
+	feedID, err := client.CreateFeed(testFeedURL, categories[0].ID)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -798,16 +800,16 @@ func TestGetFeeds(t *testing.T) {
 		t.Fatalf(`Invalid feed ID, got "%v" instead of "%v"`, feeds[0].ID, feedID)
 	}
 
-	if feeds[0].Title != "Miniflux" {
-		t.Fatalf(`Invalid feed title, got "%v" instead of "%v"`, feeds[0].Title, "Miniflux")
+	if feeds[0].Title != testFeedTitle {
+		t.Fatalf(`Invalid feed title, got "%v" instead of "%v"`, feeds[0].Title, testFeedTitle)
 	}
 
-	if feeds[0].SiteURL != "https://miniflux.net/" {
-		t.Fatalf(`Invalid site URL, got "%v" instead of "%v"`, feeds[0].SiteURL, "https://miniflux.net/")
+	if feeds[0].SiteURL != testWebsiteURL {
+		t.Fatalf(`Invalid site URL, got "%v" instead of "%v"`, feeds[0].SiteURL, testWebsiteURL)
 	}
 
-	if feeds[0].FeedURL != "https://miniflux.net/feed" {
-		t.Fatalf(`Invalid feed URL, got "%v" instead of "%v"`, feeds[0].FeedURL, "https://miniflux.net/feed")
+	if feeds[0].FeedURL != testFeedURL {
+		t.Fatalf(`Invalid feed URL, got "%v" instead of "%v"`, feeds[0].FeedURL, testFeedURL)
 	}
 
 	if feeds[0].Category.ID != categories[0].ID {
@@ -837,7 +839,7 @@ func TestGetAllFeedEntries(t *testing.T) {
 		t.Fatal(err)
 	}
 
-	feedID, err := client.CreateFeed("https://miniflux.net/feed", categories[0].ID)
+	feedID, err := client.CreateFeed(testFeedURL, categories[0].ID)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -883,7 +885,7 @@ func TestGetAllEntries(t *testing.T) {
 		t.Fatal(err)
 	}
 
-	_, err = client.CreateFeed("https://miniflux.net/feed", categories[0].ID)
+	_, err = client.CreateFeed(testFeedURL, categories[0].ID)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -930,7 +932,7 @@ func TestInvalidFilters(t *testing.T) {
 		t.Fatal(err)
 	}
 
-	_, err = client.CreateFeed("https://miniflux.net/feed", categories[0].ID)
+	_, err = client.CreateFeed(testFeedURL, categories[0].ID)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -965,7 +967,7 @@ func TestGetEntry(t *testing.T) {
 		t.Fatal(err)
 	}
 
-	_, err = client.CreateFeed("https://miniflux.net/feed", categories[0].ID)
+	_, err = client.CreateFeed(testFeedURL, categories[0].ID)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -975,7 +977,16 @@ func TestGetEntry(t *testing.T) {
 		t.Fatal(err)
 	}
 
-	entry, err := client.Entry(result.Entries[0].FeedID, result.Entries[0].ID)
+	entry, err := client.FeedEntry(result.Entries[0].FeedID, result.Entries[0].ID)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if entry.ID != result.Entries[0].ID {
+		t.Fatal("Wrong entry returned")
+	}
+
+	entry, err = client.Entry(result.Entries[0].ID)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -999,7 +1010,7 @@ func TestUpdateStatus(t *testing.T) {
 		t.Fatal(err)
 	}
 
-	_, err = client.CreateFeed("https://miniflux.net/feed", categories[0].ID)
+	_, err = client.CreateFeed(testFeedURL, categories[0].ID)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -1014,7 +1025,7 @@ func TestUpdateStatus(t *testing.T) {
 		t.Fatal(err)
 	}
 
-	entry, err := client.Entry(result.Entries[0].FeedID, result.Entries[0].ID)
+	entry, err := client.Entry(result.Entries[0].ID)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -1029,6 +1040,49 @@ func TestUpdateStatus(t *testing.T) {
 	}
 }
 
+func TestToggleBookmark(t *testing.T) {
+	username := getRandomUsername()
+	client := miniflux.NewClient(testBaseURL, testAdminUsername, testAdminPassword)
+	_, err := client.CreateUser(username, testStandardPassword, false)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	client = miniflux.NewClient(testBaseURL, username, testStandardPassword)
+	categories, err := client.Categories()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	_, err = client.CreateFeed(testFeedURL, categories[0].ID)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	result, err := client.Entries(&miniflux.Filter{Limit: 1})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if result.Entries[0].Starred {
+		t.Fatal("The entry should not be starred")
+	}
+
+	err = client.ToggleBookmark(result.Entries[0].ID)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	entry, err := client.Entry(result.Entries[0].ID)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if !entry.Starred {
+		t.Fatal("The entry should be starred")
+	}
+}
+
 func getRandomUsername() string {
 	rand.Seed(time.Now().UnixNano())
 	var suffix []string

+ 9 - 3
locale/translations.go

@@ -1,5 +1,5 @@
 // Code generated by go generate; DO NOT EDIT.
-// 2017-12-18 18:49:32.159555255 -0800 PST m=+0.041213049
+// 2017-12-22 11:25:01.98320223 -0800 PST m=+0.048169992
 
 package locale
 
@@ -177,12 +177,18 @@ var translations = map[string]string{
     "Wallabag Client ID": "Identifiant du client Wallabag",
     "Wallabag Client Secret": "Clé secrète du client Wallabag",
     "Wallabag Username": "Nom d'utilisateur de Wallabag",
-    "Wallabag Password": "Mot de passe de Wallabag"
+    "Wallabag Password": "Mot de passe de Wallabag",
+    "Keyboard Shortcut: %s": "Raccourci clavier : %s",
+    "Favorites": "Favoris",
+    "Star": "Favoris",
+    "Unstar": "Enlever favoris",
+    "Starred": "Favoris",
+    "There is no bookmark at the moment.": "Il n'y a aucun favoris pour le moment."
 }
 `,
 }
 
 var translationsChecksums = map[string]string{
 	"en_US": "6fe95384260941e8a5a3c695a655a932e0a8a6a572c1e45cb2b1ae8baa01b897",
-	"fr_FR": "3a71dbf4fcdb488acdaf43530e521a0c17a28ef637fbd60b204e468afb0dbe09",
+	"fr_FR": "e6817ae43e1412d2687036fb4c1b9f6ea4a2329dcb1eddfa01ebbad732c7b401",
 }

+ 7 - 1
locale/translations/fr_FR.json

@@ -161,5 +161,11 @@
     "Wallabag Client ID": "Identifiant du client Wallabag",
     "Wallabag Client Secret": "Clé secrète du client Wallabag",
     "Wallabag Username": "Nom d'utilisateur de Wallabag",
-    "Wallabag Password": "Mot de passe de Wallabag"
+    "Wallabag Password": "Mot de passe de Wallabag",
+    "Keyboard Shortcut: %s": "Raccourci clavier : %s",
+    "Favorites": "Favoris",
+    "Star": "Favoris",
+    "Unstar": "Enlever favoris",
+    "Starred": "Favoris",
+    "There is no bookmark at the moment.": "Il n'y a aucun favoris pour le moment."
 }

+ 1 - 0
model/entry.go

@@ -30,6 +30,7 @@ type Entry struct {
 	Date       time.Time     `json:"published_at"`
 	Content    string        `json:"content"`
 	Author     string        `json:"author"`
+	Starred    bool          `json:"starred"`
 	Enclosures EnclosureList `json:"enclosures,omitempty"`
 	Feed       *Feed         `json:"feed,omitempty"`
 	Category   *Category     `json:"category,omitempty"`

+ 45 - 2
server/api/controller/entry.go

@@ -12,8 +12,8 @@ import (
 	"github.com/miniflux/miniflux/server/core"
 )
 
-// GetEntry is the API handler to get a single feed entry.
-func (c *Controller) GetEntry(ctx *core.Context, request *core.Request, response *core.Response) {
+// GetFeedEntry is the API handler to get a single feed entry.
+func (c *Controller) GetFeedEntry(ctx *core.Context, request *core.Request, response *core.Response) {
 	userID := ctx.UserID()
 	feedID, err := request.IntegerParam("feedID")
 	if err != nil {
@@ -45,6 +45,32 @@ func (c *Controller) GetEntry(ctx *core.Context, request *core.Request, response
 	response.JSON().Standard(entry)
 }
 
+// GetEntry is the API handler to get a single entry.
+func (c *Controller) GetEntry(ctx *core.Context, request *core.Request, response *core.Response) {
+	userID := ctx.UserID()
+	entryID, err := request.IntegerParam("entryID")
+	if err != nil {
+		response.JSON().BadRequest(err)
+		return
+	}
+
+	builder := c.store.GetEntryQueryBuilder(userID, ctx.UserTimezone())
+	builder.WithEntryID(entryID)
+
+	entry, err := builder.GetEntry()
+	if err != nil {
+		response.JSON().ServerError(errors.New("Unable to fetch this entry from the database"))
+		return
+	}
+
+	if entry == nil {
+		response.JSON().NotFound(errors.New("Entry not found"))
+		return
+	}
+
+	response.JSON().Standard(entry)
+}
+
 // GetFeedEntries is the API handler to get all feed entries.
 func (c *Controller) GetFeedEntries(ctx *core.Context, request *core.Request, response *core.Response) {
 	userID := ctx.UserID()
@@ -179,3 +205,20 @@ func (c *Controller) SetEntryStatus(ctx *core.Context, request *core.Request, re
 
 	response.JSON().NoContent()
 }
+
+// ToggleBookmark is the API handler to toggle bookmark status.
+func (c *Controller) ToggleBookmark(ctx *core.Context, request *core.Request, response *core.Response) {
+	userID := ctx.UserID()
+	entryID, err := request.IntegerParam("entryID")
+	if err != nil {
+		response.JSON().BadRequest(err)
+		return
+	}
+
+	if err := c.store.ToggleBookmark(userID, entryID); err != nil {
+		response.JSON().ServerError(errors.New("Unable to toggle bookmark value"))
+		return
+	}
+
+	response.JSON().NoContent()
+}

+ 30 - 5
server/fever/fever.go

@@ -88,7 +88,7 @@ type savedResponse struct {
 
 type linksResponse struct {
 	baseResponse
-	Links []string `json:"links"`
+	Links string `json:"links"`
 }
 
 type group struct {
@@ -242,6 +242,7 @@ func (c *Controller) handleFeeds(ctx *core.Context, request *core.Request, respo
 	}
 
 	var result feedsResponse
+	result.Feeds = make([]feed, 0)
 	for _, f := range feeds {
 		result.Feeds = append(result.Feeds, feed{
 			ID:          f.ID,
@@ -387,6 +388,11 @@ func (c *Controller) handleItems(ctx *core.Context, request *core.Request, respo
 			isRead = 1
 		}
 
+		isSaved := 0
+		if entry.Starred {
+			isSaved = 1
+		}
+
 		result.Items = append(result.Items, item{
 			ID:        entry.ID,
 			FeedID:    entry.FeedID,
@@ -394,7 +400,7 @@ func (c *Controller) handleItems(ctx *core.Context, request *core.Request, respo
 			Author:    entry.Author,
 			HTML:      entry.Content,
 			URL:       entry.URL,
-			IsSaved:   0,
+			IsSaved:   isSaved,
 			IsRead:    isRead,
 			CreatedAt: entry.Date.Unix(),
 		})
@@ -446,7 +452,21 @@ func (c *Controller) handleSavedItems(ctx *core.Context, request *core.Request,
 	userID := ctx.UserID()
 	logger.Debug("[Fever] Fetching saved items for userID=%d", userID)
 
-	var result savedResponse
+	builder := c.store.GetEntryQueryBuilder(userID, ctx.UserTimezone())
+	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)
 }
@@ -473,7 +493,7 @@ func (c *Controller) handleLinks(ctx *core.Context, request *core.Request, respo
 	userID := ctx.UserID()
 	logger.Debug("[Fever] Fetching links for userID=%d", userID)
 
-	var result linksResponse
+	result := &linksResponse{Links: ""}
 	result.SetCommonValues()
 	response.JSON().Standard(result)
 }
@@ -512,6 +532,11 @@ func (c *Controller) handleWriteItems(ctx *core.Context, request *core.Request,
 	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)
@@ -619,7 +644,7 @@ func (c *Controller) buildFeedGroups(feeds model.Feeds) []feedsGroups {
 		feedsGroupedByCategory[feed.Category.ID] = append(feedsGroupedByCategory[feed.Category.ID], strconv.FormatInt(feed.ID, 10))
 	}
 
-	var result []feedsGroups
+	result := make([]feedsGroups, 0)
 	for categoryID, feedIDs := range feedsGroupedByCategory {
 		result = append(result, feedsGroups{
 			GroupID: categoryID,

+ 6 - 1
server/routes.go

@@ -70,9 +70,11 @@ func getRoutes(cfg *config.Config, store *storage.Storage, feedHandler *feed.Han
 	router.Handle("/v1/feeds/{feedID}/icon", apiHandler.Use(apiController.FeedIcon)).Methods("GET")
 
 	router.Handle("/v1/feeds/{feedID}/entries", apiHandler.Use(apiController.GetFeedEntries)).Methods("GET")
-	router.Handle("/v1/feeds/{feedID}/entries/{entryID}", apiHandler.Use(apiController.GetEntry)).Methods("GET")
+	router.Handle("/v1/feeds/{feedID}/entries/{entryID}", apiHandler.Use(apiController.GetFeedEntry)).Methods("GET")
 	router.Handle("/v1/entries", apiHandler.Use(apiController.GetEntries)).Methods("GET")
 	router.Handle("/v1/entries", apiHandler.Use(apiController.SetEntryStatus)).Methods("PUT")
+	router.Handle("/v1/entries/{entryID}", apiHandler.Use(apiController.GetEntry)).Methods("GET")
+	router.Handle("/v1/entries/{entryID}/bookmark", apiHandler.Use(apiController.ToggleBookmark)).Methods("PUT")
 
 	router.Handle("/stylesheets/{name}.css", uiHandler.Use(uiController.Stylesheet)).Name("stylesheet").Methods("GET")
 	router.Handle("/js", uiHandler.Use(uiController.Javascript)).Name("javascript").Methods("GET")
@@ -85,6 +87,7 @@ func getRoutes(cfg *config.Config, store *storage.Storage, feedHandler *feed.Han
 
 	router.Handle("/unread", uiHandler.Use(uiController.ShowUnreadPage)).Name("unread").Methods("GET")
 	router.Handle("/history", uiHandler.Use(uiController.ShowHistoryPage)).Name("history").Methods("GET")
+	router.Handle("/starred", uiHandler.Use(uiController.ShowStarredPage)).Name("starred").Methods("GET")
 
 	router.Handle("/feed/{feedID}/refresh", uiHandler.Use(uiController.RefreshFeed)).Name("refreshFeed").Methods("GET")
 	router.Handle("/feed/{feedID}/edit", uiHandler.Use(uiController.EditFeed)).Name("editFeed").Methods("GET")
@@ -99,10 +102,12 @@ func getRoutes(cfg *config.Config, store *storage.Storage, feedHandler *feed.Han
 	router.Handle("/history/flush", uiHandler.Use(uiController.FlushHistory)).Name("flushHistory").Methods("GET")
 	router.Handle("/feed/{feedID}/entry/{entryID}", uiHandler.Use(uiController.ShowFeedEntry)).Name("feedEntry").Methods("GET")
 	router.Handle("/category/{categoryID}/entry/{entryID}", uiHandler.Use(uiController.ShowCategoryEntry)).Name("categoryEntry").Methods("GET")
+	router.Handle("/starred/entry/{entryID}", uiHandler.Use(uiController.ShowStarredEntry)).Name("starredEntry").Methods("GET")
 
 	router.Handle("/entry/status", uiHandler.Use(uiController.UpdateEntriesStatus)).Name("updateEntriesStatus").Methods("POST")
 	router.Handle("/entry/save/{entryID}", uiHandler.Use(uiController.SaveEntry)).Name("saveEntry").Methods("POST")
 	router.Handle("/entry/download/{entryID}", uiHandler.Use(uiController.FetchContent)).Name("fetchContent").Methods("POST")
+	router.Handle("/entry/bookmark/{entryID}", uiHandler.Use(uiController.ToggleBookmark)).Name("toggleBookmark").Methods("POST")
 
 	router.Handle("/categories", uiHandler.Use(uiController.ShowCategories)).Name("categories").Methods("GET")
 	router.Handle("/category/create", uiHandler.Use(uiController.CreateCategory)).Name("createCategory").Methods("GET")

+ 1 - 1
server/static/bin.go

@@ -1,5 +1,5 @@
 // Code generated by go generate; DO NOT EDIT.
-// 2017-12-15 21:24:38.374067217 -0800 PST m=+0.003159627
+// 2017-12-22 11:25:01.957187237 -0800 PST m=+0.022154999
 
 package static
 

+ 1 - 1
server/static/css.go

@@ -1,5 +1,5 @@
 // Code generated by go generate; DO NOT EDIT.
-// 2017-12-15 18:49:24.040014054 -0800 PST m=+0.012609926
+// 2017-12-22 11:25:01.96382557 -0800 PST m=+0.028793332
 
 package static
 

Разлика између датотеке није приказан због своје велике величине
+ 5 - 1
server/static/js.go


+ 42 - 0
server/static/js/app.js

@@ -300,6 +300,22 @@ class EntryHandler {
         }
     }
 
+    static toggleBookmark(element) {
+        element.innerHTML = element.dataset.labelLoading;
+
+        let request = new RequestBuilder(element.dataset.bookmarkUrl);
+        request.withCallback(() => {
+            if (element.dataset.value === "star") {
+                element.innerHTML = element.dataset.labelStar;
+                element.dataset.value = "unstar";
+            } else {
+                element.innerHTML = element.dataset.labelUnstar;
+                element.dataset.value = "star";
+            }
+        });
+        request.execute();
+    }
+
     static markEntryAsRead(element) {
         if (element.classList.contains("item-status-unread")) {
             element.classList.remove("item-status-unread");
@@ -468,6 +484,25 @@ class NavHandler {
         }
     }
 
+    toggleBookmark() {
+        if (! this.isListView()) {
+            this.toggleBookmarkLink(document.querySelector(".entry"));
+            return;
+        }
+
+        let currentItem = document.querySelector(".current-item");
+        if (currentItem !== null) {
+            this.toggleBookmarkLink(currentItem);
+        }
+    }
+
+    toggleBookmarkLink(parent) {
+        let bookmarkLink = parent.querySelector("a[data-toggle-bookmark]");
+        if (bookmarkLink) {
+            EntryHandler.toggleBookmark(bookmarkLink);
+        }
+    }
+
     openOriginalLink() {
         let entryLink = document.querySelector(".entry h1 a");
         if (entryLink !== null) {
@@ -588,6 +623,7 @@ document.addEventListener("DOMContentLoaded", function() {
     let navHandler = new NavHandler();
     let keyboardHandler = new KeyboardHandler();
     keyboardHandler.on("g u", () => navHandler.goToPage("unread"));
+    keyboardHandler.on("g b", () => navHandler.goToPage("starred"));
     keyboardHandler.on("g h", () => navHandler.goToPage("history"));
     keyboardHandler.on("g f", () => navHandler.goToPage("feeds"));
     keyboardHandler.on("g c", () => navHandler.goToPage("categories"));
@@ -606,6 +642,7 @@ document.addEventListener("DOMContentLoaded", function() {
     keyboardHandler.on("A", () => navHandler.markPageAsRead());
     keyboardHandler.on("s", () => navHandler.saveEntry());
     keyboardHandler.on("d", () => navHandler.fetchOriginalContent());
+    keyboardHandler.on("f", () => navHandler.toggleBookmark());
     keyboardHandler.listen();
 
     let mouseHandler = new MouseHandler();
@@ -614,6 +651,11 @@ document.addEventListener("DOMContentLoaded", function() {
         EntryHandler.saveEntry(event.target);
     });
 
+    mouseHandler.onClick("a[data-toggle-bookmark]", (event) => {
+        event.preventDefault();
+        EntryHandler.toggleBookmark(event.target);
+    });
+
     mouseHandler.onClick("a[data-fetch-content-entry]", (event) => {
         event.preventDefault();
         EntryHandler.fetchOriginalContent(event.target);

+ 10 - 7
server/template/common.go

@@ -1,5 +1,5 @@
 // Code generated by go generate; DO NOT EDIT.
-// 2017-12-16 17:48:32.321995978 -0800 PST m=+0.055632657
+// 2017-12-22 11:25:01.981502305 -0800 PST m=+0.046470067
 
 package template
 
@@ -63,22 +63,25 @@ var templateCommonMap = map[string]string{
                 <a href="{{ route "unread" }}">Mini<span>flux</span></a>
             </div>
             <ul>
-                <li {{ if eq .menu "unread" }}class="active"{{ end }}>
+                <li {{ if eq .menu "unread" }}class="active"{{ end }} title="{{ t "Keyboard Shortcut: %s" "g u" }}">
                     <a href="{{ route "unread" }}" data-page="unread">{{ t "Unread" }}</a>
                     {{ if gt .countUnread 0 }}
                         <span class="unread-counter" title="Unread articles">({{ .countUnread }})</span>
                     {{ end }}
                 </li>
-                <li {{ if eq .menu "history" }}class="active"{{ end }}>
+                <li {{ if eq .menu "starred" }}class="active"{{ end }} title="{{ t "Keyboard Shortcut: %s" "g b" }}">
+                    <a href="{{ route "starred" }}" data-page="starred">{{ t "Starred" }}</a>
+                </li>
+                <li {{ if eq .menu "history" }}class="active"{{ end }} title="{{ t "Keyboard Shortcut: %s" "g h" }}">
                     <a href="{{ route "history" }}" data-page="history">{{ t "History" }}</a>
                 </li>
-                <li {{ if eq .menu "feeds" }}class="active"{{ end }}>
+                <li {{ if eq .menu "feeds" }}class="active"{{ end }} title="{{ t "Keyboard Shortcut: %s" "g f" }}">
                     <a href="{{ route "feeds" }}" data-page="feeds">{{ t "Feeds" }}</a>
                 </li>
-                <li {{ if eq .menu "categories" }}class="active"{{ end }}>
+                <li {{ if eq .menu "categories" }}class="active"{{ end }} title="{{ t "Keyboard Shortcut: %s" "g c" }}">
                     <a href="{{ route "categories" }}" data-page="categories">{{ t "Categories" }}</a>
                 </li>
-                <li {{ if eq .menu "settings" }}class="active"{{ end }}>
+                <li {{ if eq .menu "settings" }}class="active"{{ end }} title="{{ t "Keyboard Shortcut: %s" "g s" }}">
                     <a href="{{ route "settings" }}" data-page="settings">{{ t "Settings" }}</a>
                 </li>
                 <li>
@@ -124,6 +127,6 @@ var templateCommonMap = map[string]string{
 
 var templateCommonMapChecksums = map[string]string{
 	"entry_pagination": "f1465fa70f585ae8043b200ec9de5bf437ffbb0c19fb7aefc015c3555614ee27",
-	"layout":           "ff5e3d87a48e4d3aeceda4aabe6c2c2f607006c6b6e83dfcab6c5eb255a1e6f2",
+	"layout":           "ade38fbe1058c8dac86b973c289a716e3f97289735e7ad8e8d1731dc6807e38c",
 	"pagination":       "6ff462c2b2a53bc5448b651da017f40a39f1d4f16cef4b2f09784f0797286924",
 }

+ 10 - 0
server/template/html/category_entries.html

@@ -47,6 +47,16 @@
                     <li>
                         <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
                     </li>
+                    <li>
+                        <a href="#"
+                            data-toggle-bookmark="true"
+                            data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}"
+                            data-label-loading="{{ t "Saving..." }}"
+                            data-label-star="☆ {{ t "Star" }}"
+                            data-label-unstar="★ {{ t "Unstar" }}"
+                            data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}"
+                            >{{ if .Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</a>
+                    </li>
                 </ul>
             </div>
         </article>

+ 8 - 5
server/template/html/common/layout.html

@@ -38,22 +38,25 @@
                 <a href="{{ route "unread" }}">Mini<span>flux</span></a>
             </div>
             <ul>
-                <li {{ if eq .menu "unread" }}class="active"{{ end }}>
+                <li {{ if eq .menu "unread" }}class="active"{{ end }} title="{{ t "Keyboard Shortcut: %s" "g u" }}">
                     <a href="{{ route "unread" }}" data-page="unread">{{ t "Unread" }}</a>
                     {{ if gt .countUnread 0 }}
                         <span class="unread-counter" title="Unread articles">({{ .countUnread }})</span>
                     {{ end }}
                 </li>
-                <li {{ if eq .menu "history" }}class="active"{{ end }}>
+                <li {{ if eq .menu "starred" }}class="active"{{ end }} title="{{ t "Keyboard Shortcut: %s" "g b" }}">
+                    <a href="{{ route "starred" }}" data-page="starred">{{ t "Starred" }}</a>
+                </li>
+                <li {{ if eq .menu "history" }}class="active"{{ end }} title="{{ t "Keyboard Shortcut: %s" "g h" }}">
                     <a href="{{ route "history" }}" data-page="history">{{ t "History" }}</a>
                 </li>
-                <li {{ if eq .menu "feeds" }}class="active"{{ end }}>
+                <li {{ if eq .menu "feeds" }}class="active"{{ end }} title="{{ t "Keyboard Shortcut: %s" "g f" }}">
                     <a href="{{ route "feeds" }}" data-page="feeds">{{ t "Feeds" }}</a>
                 </li>
-                <li {{ if eq .menu "categories" }}class="active"{{ end }}>
+                <li {{ if eq .menu "categories" }}class="active"{{ end }} title="{{ t "Keyboard Shortcut: %s" "g c" }}">
                     <a href="{{ route "categories" }}" data-page="categories">{{ t "Categories" }}</a>
                 </li>
-                <li {{ if eq .menu "settings" }}class="active"{{ end }}>
+                <li {{ if eq .menu "settings" }}class="active"{{ end }} title="{{ t "Keyboard Shortcut: %s" "g s" }}">
                     <a href="{{ route "settings" }}" data-page="settings">{{ t "Settings" }}</a>
                 </li>
                 <li>

+ 10 - 0
server/template/html/entry.html

@@ -8,6 +8,16 @@
         </h1>
         <div class="entry-actions">
             <ul>
+                <li>
+                    <a href="#"
+                        data-toggle-bookmark="true"
+                        data-bookmark-url="{{ route "toggleBookmark" "entryID" .entry.ID }}"
+                        data-label-loading="{{ t "Saving..." }}"
+                        data-label-star="☆ {{ t "Star" }}"
+                        data-label-unstar="★ {{ t "Unstar" }}"
+                        data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}"
+                        >{{ if .entry.Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</a>
+                </li>
                 <li>
                     <a href="#"
                         title="{{ t "Save this article" }}"

+ 10 - 0
server/template/html/feed_entries.html

@@ -58,6 +58,16 @@
                     <li>
                         <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
                     </li>
+                    <li>
+                        <a href="#"
+                            data-toggle-bookmark="true"
+                            data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}"
+                            data-label-loading="{{ t "Saving..." }}"
+                            data-label-star="☆ {{ t "Star" }}"
+                            data-label-unstar="★ {{ t "Unstar" }}"
+                            data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}"
+                            >{{ if .Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</a>
+                    </li>
                 </ul>
             </div>
         </article>

+ 10 - 0
server/template/html/history.html

@@ -47,6 +47,16 @@
                     <li>
                         <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
                     </li>
+                    <li>
+                        <a href="#"
+                            data-toggle-bookmark="true"
+                            data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}"
+                            data-label-loading="{{ t "Saving..." }}"
+                            data-label-star="☆ {{ t "Star" }}"
+                            data-label-unstar="★ {{ t "Unstar" }}"
+                            data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}"
+                            >{{ if .Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</a>
+                    </li>
                 </ul>
             </div>
         </article>

+ 61 - 0
server/template/html/starred.html

@@ -0,0 +1,61 @@
+{{ define "title"}}{{ t "Favorites" }} ({{ .total }}){{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+    <h1>{{ t "Favorites" }} ({{ .total }})</h1>
+</section>
+
+{{ if not .entries }}
+    <p class="alert alert-info">{{ t "There is no bookmark at the moment." }}</p>
+{{ else }}
+    <div class="items">
+        {{ range .entries }}
+        <article class="item touch-item item-status-{{ .Status }}" data-id="{{ .ID }}">
+            <div class="item-header">
+                <span class="item-title">
+                    {{ if ne .Feed.Icon.IconID 0 }}
+                        <img src="{{ route "icon" "iconID" .Feed.Icon.IconID }}" width="16" height="16">
+                    {{ end }}
+                    <a href="{{ route "starredEntry" "entryID" .ID }}">{{ .Title }}</a>
+                </span>
+                <span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
+            </div>
+            <div class="item-meta">
+                <ul>
+                    <li>
+                        <a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
+                    </li>
+                    <li>
+                        <time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
+                    </li>
+                    <li>
+                        <a href="#"
+                            title="{{ t "Save this article" }}"
+                            data-save-entry="true"
+                            data-save-url="{{ route "saveEntry" "entryID" .ID }}"
+                            data-label-loading="{{ t "Saving..." }}"
+                            data-label-done="{{ t "Done!" }}"
+                            >{{ t "Save" }}</a>
+                    </li>
+                    <li>
+                        <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
+                    </li>
+                    <li>
+                        <a href="#"
+                            data-toggle-bookmark="true"
+                            data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}"
+                            data-label-loading="{{ t "Saving..." }}"
+                            data-label-star="☆ {{ t "Star" }}"
+                            data-label-unstar="★ {{ t "Unstar" }}"
+                            data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}"
+                            >{{ if .Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</a>
+                    </li>
+                </ul>
+            </div>
+        </article>
+        {{ end }}
+    </div>
+    {{ template "pagination" .pagination }}
+{{ end }}
+
+{{ end }}

+ 10 - 0
server/template/html/unread.html

@@ -47,6 +47,16 @@
                     <li>
                         <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
                     </li>
+                    <li>
+                        <a href="#"
+                            data-toggle-bookmark="true"
+                            data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}"
+                            data-label-loading="{{ t "Saving..." }}"
+                            data-label-star="☆ {{ t "Star" }}"
+                            data-label-unstar="★ {{ t "Unstar" }}"
+                            data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}"
+                            >{{ if .Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</a>
+                    </li>
                 </ul>
             </div>
         </article>

+ 119 - 6
server/template/views.go

@@ -1,5 +1,5 @@
 // Code generated by go generate; DO NOT EDIT.
-// 2017-12-18 18:49:32.144679579 -0800 PST m=+0.026337373
+// 2017-12-22 11:25:01.96909666 -0800 PST m=+0.034064422
 
 package template
 
@@ -199,6 +199,16 @@ var templateViewsMap = map[string]string{
                     <li>
                         <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
                     </li>
+                    <li>
+                        <a href="#"
+                            data-toggle-bookmark="true"
+                            data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}"
+                            data-label-loading="{{ t "Saving..." }}"
+                            data-label-star="☆ {{ t "Star" }}"
+                            data-label-unstar="★ {{ t "Unstar" }}"
+                            data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}"
+                            >{{ if .Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</a>
+                    </li>
                 </ul>
             </div>
         </article>
@@ -480,6 +490,16 @@ var templateViewsMap = map[string]string{
         </h1>
         <div class="entry-actions">
             <ul>
+                <li>
+                    <a href="#"
+                        data-toggle-bookmark="true"
+                        data-bookmark-url="{{ route "toggleBookmark" "entryID" .entry.ID }}"
+                        data-label-loading="{{ t "Saving..." }}"
+                        data-label-star="☆ {{ t "Star" }}"
+                        data-label-unstar="★ {{ t "Unstar" }}"
+                        data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}"
+                        >{{ if .entry.Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</a>
+                </li>
                 <li>
                     <a href="#"
                         title="{{ t "Save this article" }}"
@@ -630,6 +650,16 @@ var templateViewsMap = map[string]string{
                     <li>
                         <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
                     </li>
+                    <li>
+                        <a href="#"
+                            data-toggle-bookmark="true"
+                            data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}"
+                            data-label-loading="{{ t "Saving..." }}"
+                            data-label-star="☆ {{ t "Star" }}"
+                            data-label-unstar="★ {{ t "Unstar" }}"
+                            data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}"
+                            >{{ if .Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</a>
+                    </li>
                 </ul>
             </div>
         </article>
@@ -764,6 +794,16 @@ var templateViewsMap = map[string]string{
                     <li>
                         <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
                     </li>
+                    <li>
+                        <a href="#"
+                            data-toggle-bookmark="true"
+                            data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}"
+                            data-label-loading="{{ t "Saving..." }}"
+                            data-label-star="☆ {{ t "Star" }}"
+                            data-label-unstar="★ {{ t "Unstar" }}"
+                            data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}"
+                            >{{ if .Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</a>
+                    </li>
                 </ul>
             </div>
         </article>
@@ -1084,6 +1124,68 @@ var templateViewsMap = map[string]string{
 </div>
 {{ end }}
 
+{{ end }}
+`,
+	"starred": `{{ define "title"}}{{ t "Favorites" }} ({{ .total }}){{ end }}
+
+{{ define "content"}}
+<section class="page-header">
+    <h1>{{ t "Favorites" }} ({{ .total }})</h1>
+</section>
+
+{{ if not .entries }}
+    <p class="alert alert-info">{{ t "There is no bookmark at the moment." }}</p>
+{{ else }}
+    <div class="items">
+        {{ range .entries }}
+        <article class="item touch-item item-status-{{ .Status }}" data-id="{{ .ID }}">
+            <div class="item-header">
+                <span class="item-title">
+                    {{ if ne .Feed.Icon.IconID 0 }}
+                        <img src="{{ route "icon" "iconID" .Feed.Icon.IconID }}" width="16" height="16">
+                    {{ end }}
+                    <a href="{{ route "starredEntry" "entryID" .ID }}">{{ .Title }}</a>
+                </span>
+                <span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
+            </div>
+            <div class="item-meta">
+                <ul>
+                    <li>
+                        <a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
+                    </li>
+                    <li>
+                        <time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
+                    </li>
+                    <li>
+                        <a href="#"
+                            title="{{ t "Save this article" }}"
+                            data-save-entry="true"
+                            data-save-url="{{ route "saveEntry" "entryID" .ID }}"
+                            data-label-loading="{{ t "Saving..." }}"
+                            data-label-done="{{ t "Done!" }}"
+                            >{{ t "Save" }}</a>
+                    </li>
+                    <li>
+                        <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
+                    </li>
+                    <li>
+                        <a href="#"
+                            data-toggle-bookmark="true"
+                            data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}"
+                            data-label-loading="{{ t "Saving..." }}"
+                            data-label-star="☆ {{ t "Star" }}"
+                            data-label-unstar="★ {{ t "Unstar" }}"
+                            data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}"
+                            >{{ if .Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</a>
+                    </li>
+                </ul>
+            </div>
+        </article>
+        {{ end }}
+    </div>
+    {{ template "pagination" .pagination }}
+{{ end }}
+
 {{ end }}
 `,
 	"unread": `{{ define "title"}}{{ t "Unread Items" }} {{ if gt .countUnread 0 }}({{ .countUnread }}){{ end }} {{ end }}
@@ -1135,6 +1237,16 @@ var templateViewsMap = map[string]string{
                     <li>
                         <a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
                     </li>
+                    <li>
+                        <a href="#"
+                            data-toggle-bookmark="true"
+                            data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}"
+                            data-label-loading="{{ t "Saving..." }}"
+                            data-label-star="☆ {{ t "Star" }}"
+                            data-label-unstar="★ {{ t "Unstar" }}"
+                            data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}"
+                            >{{ if .Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</a>
+                    </li>
                 </ul>
             </div>
         </article>
@@ -1211,22 +1323,23 @@ var templateViewsMapChecksums = map[string]string{
 	"about":               "ad2fb778fc73c39b733b3f81b13e5c7d689b041fadd24ee2d4577f545aa788ad",
 	"add_subscription":    "053c920b0d7e109ea19dce6a448e304ce720db8633588ea04db16677f7209a7b",
 	"categories":          "ca1280cd157bb527d4fc907da67b05a8347378f6dce965b9389d4bcdf3600a11",
-	"category_entries":    "951cdacf38fcaed5cdd63a00dc800e26039236b94b556a68e4409012b0095ece",
+	"category_entries":    "ce59529666520b8363c9588ce2c437de5a3f6d91941e5c46be25ca08f6900364",
 	"choose_subscription": "a325f9c976ca2b2dc148e25c8fef0cf6ccab0e04e86e604e7812bb18dc4cdde1",
 	"create_category":     "2b82af5d2dcd67898dc5daa57a6461e6ff8121a6089b2a2a1be909f35e4a2275",
 	"create_user":         "45e226df757126d5fe7c464e295e9a34f07952cfdb71e31e49839850d35af139",
 	"edit_category":       "cee720faadcec58289b707ad30af623d2ee66c1ce23a732965463250d7ff41c5",
 	"edit_feed":           "7e78f0821312557ca05eb840fd52bcb60509c6da205e8ffce11eb08f65ae143d",
 	"edit_user":           "82d9749d76ddbd2352816d813c4b1f6d92f2222de678b4afe5821090246735c7",
-	"entry":               "ebcf9bb35812dd02759718f7f7411267e6a6c8efd59a9aa0a0e735bcb88efeff",
-	"feed_entries":        "547c19eb36b20e350ce70ed045173b064cdcd6b114afb241c9f2dda9d88fcc27",
+	"entry":               "6b4405e0c8e4a7d31874659f8835f4e43e01dc3c20686091517ac750196dd70f",
+	"feed_entries":        "ac93cb9a90f93ddd9dd8a67d7e160592ecb9f5e465ee9679bb14eecd8d4caf20",
 	"feeds":               "c22af39b42ba9ca69ea0914ca789303ec2c5b484abcd4eaa49016e365381257c",
-	"history":             "9a67599a5d8d67ef958e3f07da339b749f42892667547c9e60a54477e8d32a56",
+	"history":             "abc7ea29f7d54f28f73fe14979bbd03dbc41fa6a7c86f95f56d6e94f7b09b9ba",
 	"import":              "73b5112e20bfd232bf73334544186ea419505936bc237d481517a8622901878f",
 	"integrations":        "3c14d7de904911aad7f3ebec6d1a20b50843287f58125c526e167f429f3d455d",
 	"login":               "04f3ce79bfa5753f69e0d956c2a8999c0da549c7925634a3e8134975da0b0e0f",
 	"sessions":            "878dbe8f8ea783b44130c495814179519fa5c3aa2666ac87508f94d58dd008bf",
 	"settings":            "ea2505b9d0a6d6bb594dba87a92079de19baa6d494f0651693a7685489fb7de9",
-	"unread":              "745d9a1c70c7327aa0ae37328c2736ba6a5f6493db44ef7f12d4da241491b71f",
+	"starred":             "33dd40d1a24739e9d05f9cc4b66497cfdb8c86a7abb209a66ca65c2fbafc7d87",
+	"unread":              "d990b41e03912600f10069b33376c541a8ef518f302a60fd28763e97d44c85ba",
 	"users":               "44677e28bb5347799ed0020c90ec785aadec4b1454446d92411cfdaf6e32110b",
 }

+ 70 - 1
server/ui/controller/entry.go

@@ -373,6 +373,75 @@ func (c *Controller) ShowReadEntry(ctx *core.Context, request *core.Request, res
 	}))
 }
 
+// ShowStarredEntry shows a single feed entry in "starred" mode.
+func (c *Controller) ShowStarredEntry(ctx *core.Context, request *core.Request, response *core.Response) {
+	user := ctx.LoggedUser()
+
+	entryID, err := request.IntegerParam("entryID")
+	if err != nil {
+		response.HTML().BadRequest(err)
+		return
+	}
+
+	builder := c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
+	builder.WithEntryID(entryID)
+	builder.WithoutStatus(model.EntryStatusRemoved)
+
+	entry, err := builder.GetEntry()
+	if err != nil {
+		response.HTML().ServerError(err)
+		return
+	}
+
+	if entry == nil {
+		response.HTML().NotFound()
+		return
+	}
+
+	if entry.Status == model.EntryStatusUnread {
+		err = c.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)
+		if err != nil {
+			logger.Error("[Controller:ShowReadEntry] %v", err)
+			response.HTML().ServerError(nil)
+			return
+		}
+	}
+
+	args, err := c.getCommonTemplateArgs(ctx)
+	if err != nil {
+		response.HTML().ServerError(err)
+		return
+	}
+
+	builder = c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
+	builder.WithStarred()
+
+	prevEntry, nextEntry, err := c.getEntryPrevNext(user, builder, entry.ID)
+	if err != nil {
+		response.HTML().ServerError(err)
+		return
+	}
+
+	nextEntryRoute := ""
+	if nextEntry != nil {
+		nextEntryRoute = ctx.Route("starredEntry", "entryID", nextEntry.ID)
+	}
+
+	prevEntryRoute := ""
+	if prevEntry != nil {
+		prevEntryRoute = ctx.Route("starredEntry", "entryID", prevEntry.ID)
+	}
+
+	response.HTML().Render("entry", args.Merge(tplParams{
+		"entry":          entry,
+		"prevEntry":      prevEntry,
+		"nextEntry":      nextEntry,
+		"nextEntryRoute": nextEntryRoute,
+		"prevEntryRoute": prevEntryRoute,
+		"menu":           "starred",
+	}))
+}
+
 // UpdateEntriesStatus handles Ajax request to update the status for a list of entries.
 func (c *Controller) UpdateEntriesStatus(ctx *core.Context, request *core.Request, response *core.Response) {
 	user := ctx.LoggedUser()
@@ -412,7 +481,7 @@ func (c *Controller) getEntryPrevNext(user *model.User, builder *storage.EntryQu
 	n := len(entries)
 	for i := 0; i < n; i++ {
 		if entries[i].ID == entryID {
-			if i-1 > 0 {
+			if i-1 >= 0 {
 				prev = entries[i-1]
 			}
 

+ 68 - 0
server/ui/controller/starred.go

@@ -0,0 +1,68 @@
+// 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 controller
+
+import (
+	"github.com/miniflux/miniflux/logger"
+	"github.com/miniflux/miniflux/model"
+	"github.com/miniflux/miniflux/server/core"
+)
+
+// ShowStarredPage renders the page with all starred entries.
+func (c *Controller) ShowStarredPage(ctx *core.Context, request *core.Request, response *core.Response) {
+	user := ctx.LoggedUser()
+	offset := request.QueryIntegerParam("offset", 0)
+
+	args, err := c.getCommonTemplateArgs(ctx)
+	if err != nil {
+		response.HTML().ServerError(err)
+		return
+	}
+
+	builder := c.store.GetEntryQueryBuilder(user.ID, user.Timezone)
+	builder.WithoutStatus(model.EntryStatusRemoved)
+	builder.WithStarred()
+	builder.WithOrder(model.DefaultSortingOrder)
+	builder.WithDirection(user.EntryDirection)
+	builder.WithOffset(offset)
+	builder.WithLimit(nbItemsPerPage)
+
+	entries, err := builder.GetEntries()
+	if err != nil {
+		response.HTML().ServerError(err)
+		return
+	}
+
+	count, err := builder.CountEntries()
+	if err != nil {
+		response.HTML().ServerError(err)
+		return
+	}
+
+	response.HTML().Render("starred", args.Merge(tplParams{
+		"entries":    entries,
+		"total":      count,
+		"pagination": c.getPagination(ctx.Route("starred"), count, offset),
+		"menu":       "starred",
+	}))
+}
+
+// ToggleBookmark handles Ajax request to toggle bookmark value.
+func (c *Controller) ToggleBookmark(ctx *core.Context, request *core.Request, response *core.Response) {
+	user := ctx.LoggedUser()
+	entryID, err := request.IntegerParam("entryID")
+	if err != nil {
+		response.HTML().BadRequest(err)
+		return
+	}
+
+	if err := c.store.ToggleBookmark(user.ID, entryID); err != nil {
+		logger.Error("[Controller:UpdateEntryStatus] %v", err)
+		response.JSON().ServerError(nil)
+		return
+	}
+
+	response.JSON().Standard("OK")
+}

+ 1 - 0
sql/schema_version_12.sql

@@ -0,0 +1 @@
+alter table entries add column starred bool default 'f';

+ 3 - 1
sql/sql.go

@@ -1,5 +1,5 @@
 // Code generated by go generate; DO NOT EDIT.
-// 2017-12-18 18:49:32.121198779 -0800 PST m=+0.002856573
+// 2017-12-22 11:25:01.937552528 -0800 PST m=+0.002520290
 
 package sql
 
@@ -122,6 +122,7 @@ alter table integrations add column wallabag_client_id text default '';
 alter table integrations add column wallabag_client_secret text default '';
 alter table integrations add column wallabag_username text default '';
 alter table integrations add column wallabag_password text default '';`,
+	"schema_version_12": `alter table entries add column starred bool default 'f';`,
 	"schema_version_2": `create extension if not exists hstore;
 alter table users add column extra hstore;
 create index users_extra_idx on users using gin(extra);
@@ -164,6 +165,7 @@ var SqlMapChecksums = map[string]string{
 	"schema_version_1":  "7be580fc8a93db5da54b2f6e87019803c33b0b0c28482c7af80cef873bdac4e2",
 	"schema_version_10": "8faf15ddeff7c8cc305e66218face11ed92b97df2bdc2d0d7944d61441656795",
 	"schema_version_11": "dc5bbc302e01e425b49c48ddcd8e29e3ab2bb8e73a6cd1858a6ba9fbec0b5243",
+	"schema_version_12": "a95abab6cdf64811fc744abd37457e2928939d999c5ef00d2bdd9398e16f32fb",
 	"schema_version_2":  "e8e9ff32478df04fcddad10a34cba2e8bb1e67e7977b5bd6cdc4c31ec94282b4",
 	"schema_version_3":  "a54745dbc1c51c000f74d4e5068f1e2f43e83309f023415b1749a47d5c1e0f12",
 	"schema_version_4":  "216ea3a7d3e1704e40c797b5dc47456517c27dbb6ca98bf88812f4f63d74b5d9",

+ 14 - 1
storage/entry.go

@@ -179,11 +179,24 @@ func (s *Storage) SetEntriesStatus(userID int64, entryIDs []int64, status string
 	return nil
 }
 
+// ToggleBookmark toggles entry bookmark value.
+func (s *Storage) ToggleBookmark(userID int64, entryID int64) error {
+	defer helper.ExecutionTime(time.Now(), fmt.Sprintf("[Storage:ToggleBookmark] userID=%d, entryID=%d", userID, entryID))
+
+	query := `UPDATE entries SET starred = NOT starred WHERE user_id=$1 AND id=$2`
+	_, err := s.db.Exec(query, userID, entryID)
+	if err != nil {
+		return fmt.Errorf("unable to update toggle bookmark: %v", err)
+	}
+
+	return nil
+}
+
 // FlushHistory set all entries with the status "read" to "removed".
 func (s *Storage) FlushHistory(userID int64) error {
 	defer helper.ExecutionTime(time.Now(), fmt.Sprintf("[Storage:FlushHistory] userID=%d", userID))
 
-	query := `UPDATE entries SET status=$1 WHERE user_id=$2 AND status=$3`
+	query := `UPDATE entries SET status=$1 WHERE user_id=$2 AND status=$3 AND starred='f'`
 	_, err := s.db.Exec(query, model.EntryStatusRemoved, userID, model.EntryStatusRead)
 	if err != nil {
 		return fmt.Errorf("unable to flush history: %v", err)

+ 15 - 1
storage/entry_query_builder.go

@@ -32,6 +32,13 @@ type EntryQueryBuilder struct {
 	greaterThanEntryID int64
 	entryIDs           []int64
 	before             *time.Time
+	starred            bool
+}
+
+// WithStarred adds starred filter.
+func (e *EntryQueryBuilder) WithStarred() *EntryQueryBuilder {
+	e.starred = true
+	return e
 }
 
 // Before add condition base on the entry date.
@@ -150,7 +157,8 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
 
 	query := `
 		SELECT
-		e.id, e.user_id, e.feed_id, e.hash, e.published_at at time zone '%s', e.title, e.url, e.author, e.content, e.status,
+		e.id, e.user_id, e.feed_id, e.hash, e.published_at at time zone '%s', e.title,
+		e.url, e.author, e.content, e.status, e.starred,
 		f.title as feed_title, f.feed_url, f.site_url, f.checked_at,
 		f.category_id, c.title as category_title, f.scraper_rules, f.rewrite_rules, f.crawler,
 		fi.icon_id
@@ -191,6 +199,7 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
 			&entry.Author,
 			&entry.Content,
 			&entry.Status,
+			&entry.Starred,
 			&entry.Feed.Title,
 			&entry.Feed.FeedURL,
 			&entry.Feed.SiteURL,
@@ -303,6 +312,10 @@ func (e *EntryQueryBuilder) buildCondition() ([]interface{}, string) {
 		args = append(args, e.before)
 	}
 
+	if e.starred {
+		conditions = append(conditions, "e.starred is true")
+	}
+
 	return args, strings.Join(conditions, " AND ")
 }
 
@@ -334,5 +347,6 @@ func NewEntryQueryBuilder(store *Storage, userID int64, timezone string) *EntryQ
 		store:    store,
 		userID:   userID,
 		timezone: timezone,
+		starred:  false,
 	}
 }

+ 1 - 1
storage/migration.go

@@ -12,7 +12,7 @@ import (
 	"github.com/miniflux/miniflux/sql"
 )
 
-const schemaVersion = 11
+const schemaVersion = 12
 
 // Migrate run database migrations.
 func (s *Storage) Migrate() {

+ 30 - 2
vendor/github.com/miniflux/miniflux-go/client.go

@@ -291,8 +291,8 @@ func (c *Client) FeedIcon(feedID int64) (*FeedIcon, error) {
 	return feedIcon, nil
 }
 
-// Entry gets a single feed entry.
-func (c *Client) Entry(feedID, entryID int64) (*Entry, error) {
+// FeedEntry gets a single feed entry.
+func (c *Client) FeedEntry(feedID, entryID int64) (*Entry, error) {
 	body, err := c.request.Get(fmt.Sprintf("/v1/feeds/%d/entries/%d", feedID, entryID))
 	if err != nil {
 		return nil, err
@@ -308,6 +308,23 @@ func (c *Client) Entry(feedID, entryID int64) (*Entry, error) {
 	return entry, nil
 }
 
+// Entry gets a single entry.
+func (c *Client) Entry(entryID int64) (*Entry, error) {
+	body, err := c.request.Get(fmt.Sprintf("/v1/entries/%d", entryID))
+	if err != nil {
+		return nil, err
+	}
+	defer body.Close()
+
+	var entry *Entry
+	decoder := json.NewDecoder(body)
+	if err := decoder.Decode(&entry); err != nil {
+		return nil, fmt.Errorf("miniflux: response error (%v)", err)
+	}
+
+	return entry, nil
+}
+
 // Entries fetch entries.
 func (c *Client) Entries(filter *Filter) (*EntryResultSet, error) {
 	path := buildFilterQueryString("/v1/entries", filter)
@@ -362,6 +379,17 @@ func (c *Client) UpdateEntries(entryIDs []int64, status string) error {
 	return nil
 }
 
+// ToggleBookmark toggles entry bookmark value.
+func (c *Client) ToggleBookmark(entryID int64) error {
+	body, err := c.request.Put(fmt.Sprintf("/v1/entries/%d/bookmark", entryID), nil)
+	if err != nil {
+		return err
+	}
+	body.Close()
+
+	return nil
+}
+
 // NewClient returns a new Client.
 func NewClient(endpoint, username, password string) *Client {
 	return &Client{request: &request{endpoint: endpoint, username: username, password: password}}

+ 1 - 0
vendor/github.com/miniflux/miniflux-go/miniflux.go

@@ -101,6 +101,7 @@ type Entry struct {
 	Date       time.Time  `json:"published_at"`
 	Content    string     `json:"content"`
 	Author     string     `json:"author"`
+	Starred    bool       `json:"starred"`
 	Enclosures Enclosures `json:"enclosures,omitempty"`
 	Feed       *Feed      `json:"feed,omitempty"`
 	Category   *Category  `json:"category,omitempty"`

Неке датотеке нису приказане због велике количине промена