1
0
Fork 0
mirror of https://github.com/miniflux/v2.git synced 2025-08-26 18:21:01 +00:00

Refactor packages to have more idiomatic code base

This commit is contained in:
Frédéric Guillot 2018-01-02 22:04:48 -08:00
parent c39f2e1a8d
commit 320d1b0167
109 changed files with 449 additions and 402 deletions

25
ui/about.go Normal file
View file

@ -0,0 +1,25 @@
// 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 ui
import (
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/version"
)
// AboutPage shows the about page.
func (c *Controller) AboutPage(ctx *handler.Context, request *handler.Request, response *handler.Response) {
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
response.HTML().ServerError(err)
return
}
response.HTML().Render("about", args.Merge(tplParams{
"version": version.Version,
"build_date": version.BuildDate,
"menu": "settings",
}))
}

257
ui/category.go Normal file
View file

@ -0,0 +1,257 @@
// 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 ui
import (
"errors"
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/ui/form"
)
// ShowCategories shows the page with all categories.
func (c *Controller) ShowCategories(ctx *handler.Context, request *handler.Request, response *handler.Response) {
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
response.HTML().ServerError(err)
return
}
user := ctx.LoggedUser()
categories, err := c.store.CategoriesWithFeedCount(user.ID)
if err != nil {
response.HTML().ServerError(err)
return
}
response.HTML().Render("categories", args.Merge(tplParams{
"categories": categories,
"total": len(categories),
"menu": "categories",
}))
}
// ShowCategoryEntries shows all entries for the given category.
func (c *Controller) ShowCategoryEntries(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
offset := request.QueryIntegerParam("offset", 0)
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
response.HTML().ServerError(err)
return
}
category, err := c.getCategoryFromURL(ctx, request, response)
if err != nil {
return
}
builder := c.store.NewEntryQueryBuilder(user.ID)
builder.WithCategoryID(category.ID)
builder.WithOrder(model.DefaultSortingOrder)
builder.WithDirection(user.EntryDirection)
builder.WithoutStatus(model.EntryStatusRemoved)
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("category_entries", args.Merge(tplParams{
"category": category,
"entries": entries,
"total": count,
"pagination": c.getPagination(ctx.Route("categoryEntries", "categoryID", category.ID), count, offset),
"menu": "categories",
}))
}
// CreateCategory shows the form to create a new category.
func (c *Controller) CreateCategory(ctx *handler.Context, request *handler.Request, response *handler.Response) {
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
response.HTML().ServerError(err)
return
}
response.HTML().Render("create_category", args.Merge(tplParams{
"menu": "categories",
}))
}
// SaveCategory validate and save the new category into the database.
func (c *Controller) SaveCategory(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
response.HTML().ServerError(err)
return
}
categoryForm := form.NewCategoryForm(request.Request())
if err := categoryForm.Validate(); err != nil {
response.HTML().Render("create_category", args.Merge(tplParams{
"errorMessage": err.Error(),
}))
return
}
duplicateCategory, err := c.store.CategoryByTitle(user.ID, categoryForm.Title)
if err != nil {
response.HTML().ServerError(err)
return
}
if duplicateCategory != nil {
response.HTML().Render("create_category", args.Merge(tplParams{
"errorMessage": "This category already exists.",
}))
return
}
category := model.Category{Title: categoryForm.Title, UserID: user.ID}
err = c.store.CreateCategory(&category)
if err != nil {
logger.Info("[Controller:CreateCategory] %v", err)
response.HTML().Render("create_category", args.Merge(tplParams{
"errorMessage": "Unable to create this category.",
}))
return
}
response.Redirect(ctx.Route("categories"))
}
// EditCategory shows the form to modify a category.
func (c *Controller) EditCategory(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
category, err := c.getCategoryFromURL(ctx, request, response)
if err != nil {
logger.Error("[Controller:EditCategory] %v", err)
return
}
args, err := c.getCategoryFormTemplateArgs(ctx, user, category, nil)
if err != nil {
response.HTML().ServerError(err)
return
}
response.HTML().Render("edit_category", args)
}
// UpdateCategory validate and update a category.
func (c *Controller) UpdateCategory(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
category, err := c.getCategoryFromURL(ctx, request, response)
if err != nil {
logger.Error("[Controller:UpdateCategory] %v", err)
return
}
categoryForm := form.NewCategoryForm(request.Request())
args, err := c.getCategoryFormTemplateArgs(ctx, user, category, categoryForm)
if err != nil {
response.HTML().ServerError(err)
return
}
if err := categoryForm.Validate(); err != nil {
response.HTML().Render("edit_category", args.Merge(tplParams{
"errorMessage": err.Error(),
}))
return
}
if c.store.AnotherCategoryExists(user.ID, category.ID, categoryForm.Title) {
response.HTML().Render("edit_category", args.Merge(tplParams{
"errorMessage": "This category already exists.",
}))
return
}
err = c.store.UpdateCategory(categoryForm.Merge(category))
if err != nil {
logger.Error("[Controller:UpdateCategory] %v", err)
response.HTML().Render("edit_category", args.Merge(tplParams{
"errorMessage": "Unable to update this category.",
}))
return
}
response.Redirect(ctx.Route("categories"))
}
// RemoveCategory delete a category from the database.
func (c *Controller) RemoveCategory(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
category, err := c.getCategoryFromURL(ctx, request, response)
if err != nil {
return
}
if err := c.store.RemoveCategory(user.ID, category.ID); err != nil {
response.HTML().ServerError(err)
return
}
response.Redirect(ctx.Route("categories"))
}
func (c *Controller) getCategoryFromURL(ctx *handler.Context, request *handler.Request, response *handler.Response) (*model.Category, error) {
categoryID, err := request.IntegerParam("categoryID")
if err != nil {
response.HTML().BadRequest(err)
return nil, err
}
user := ctx.LoggedUser()
category, err := c.store.Category(user.ID, categoryID)
if err != nil {
response.HTML().ServerError(err)
return nil, err
}
if category == nil {
response.HTML().NotFound()
return nil, errors.New("Category not found")
}
return category, nil
}
func (c *Controller) getCategoryFormTemplateArgs(ctx *handler.Context, user *model.User, category *model.Category, categoryForm *form.CategoryForm) (tplParams, error) {
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
return nil, err
}
if categoryForm == nil {
args["form"] = form.CategoryForm{
Title: category.Title,
}
} else {
args["form"] = categoryForm
}
args["category"] = category
args["menu"] = "categories"
return args, nil
}

66
ui/controller.go Normal file
View file

@ -0,0 +1,66 @@
// 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 ui
import (
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/reader/feed"
"github.com/miniflux/miniflux/reader/opml"
"github.com/miniflux/miniflux/scheduler"
"github.com/miniflux/miniflux/storage"
)
type tplParams map[string]interface{}
func (t tplParams) Merge(d tplParams) tplParams {
for k, v := range d {
t[k] = v
}
return t
}
// Controller contains all HTTP handlers for the user interface.
type Controller struct {
cfg *config.Config
store *storage.Storage
pool *scheduler.WorkerPool
feedHandler *feed.Handler
opmlHandler *opml.Handler
}
func (c *Controller) getCommonTemplateArgs(ctx *handler.Context) (tplParams, error) {
user := ctx.LoggedUser()
builder := c.store.NewEntryQueryBuilder(user.ID)
builder.WithStatus(model.EntryStatusUnread)
countUnread, err := builder.CountEntries()
if err != nil {
return nil, err
}
params := tplParams{
"menu": "",
"user": user,
"countUnread": countUnread,
"csrf": ctx.CSRF(),
"flashMessage": ctx.FlashMessage(),
"flashErrorMessage": ctx.FlashErrorMessage(),
}
return params, nil
}
// NewController returns a new Controller.
func NewController(cfg *config.Config, store *storage.Storage, pool *scheduler.WorkerPool, feedHandler *feed.Handler, opmlHandler *opml.Handler) *Controller {
return &Controller{
cfg: cfg,
store: store,
pool: pool,
feedHandler: feedHandler,
opmlHandler: opmlHandler,
}
}

493
ui/entry.go Normal file
View file

@ -0,0 +1,493 @@
// 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 ui
import (
"errors"
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/integration"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/reader/sanitizer"
"github.com/miniflux/miniflux/reader/scraper"
"github.com/miniflux/miniflux/storage"
)
// FetchContent downloads the original HTML page and returns relevant contents.
func (c *Controller) FetchContent(ctx *handler.Context, request *handler.Request, response *handler.Response) {
entryID, err := request.IntegerParam("entryID")
if err != nil {
response.HTML().BadRequest(err)
return
}
user := ctx.LoggedUser()
builder := c.store.NewEntryQueryBuilder(user.ID)
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
entry, err := builder.GetEntry()
if err != nil {
response.JSON().ServerError(err)
return
}
if entry == nil {
response.JSON().NotFound(errors.New("Entry not found"))
return
}
content, err := scraper.Fetch(entry.URL, entry.Feed.ScraperRules)
if err != nil {
response.JSON().ServerError(err)
return
}
entry.Content = sanitizer.Sanitize(entry.URL, content)
c.store.UpdateEntryContent(entry)
response.JSON().Created(map[string]string{"content": entry.Content})
}
// SaveEntry send the link to external services.
func (c *Controller) SaveEntry(ctx *handler.Context, request *handler.Request, response *handler.Response) {
entryID, err := request.IntegerParam("entryID")
if err != nil {
response.HTML().BadRequest(err)
return
}
user := ctx.LoggedUser()
builder := c.store.NewEntryQueryBuilder(user.ID)
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
entry, err := builder.GetEntry()
if err != nil {
response.JSON().ServerError(err)
return
}
if entry == nil {
response.JSON().NotFound(errors.New("Entry not found"))
return
}
settings, err := c.store.Integration(user.ID)
if err != nil {
response.JSON().ServerError(err)
return
}
go func() {
integration.SendEntry(entry, settings)
}()
response.JSON().Created(map[string]string{"message": "saved"})
}
// ShowFeedEntry shows a single feed entry in "feed" mode.
func (c *Controller) ShowFeedEntry(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
entryID, err := request.IntegerParam("entryID")
if err != nil {
response.HTML().BadRequest(err)
return
}
feedID, err := request.IntegerParam("feedID")
if err != nil {
response.HTML().BadRequest(err)
return
}
builder := c.store.NewEntryQueryBuilder(user.ID)
builder.WithFeedID(feedID)
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:ShowFeedEntry] %v", err)
response.HTML().ServerError(nil)
return
}
}
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
response.HTML().ServerError(err)
return
}
builder = c.store.NewEntryQueryBuilder(user.ID)
builder.WithFeedID(feedID)
prevEntry, nextEntry, err := c.getEntryPrevNext(user, builder, entry.ID)
if err != nil {
response.HTML().ServerError(err)
return
}
nextEntryRoute := ""
if nextEntry != nil {
nextEntryRoute = ctx.Route("feedEntry", "feedID", feedID, "entryID", nextEntry.ID)
}
prevEntryRoute := ""
if prevEntry != nil {
prevEntryRoute = ctx.Route("feedEntry", "feedID", feedID, "entryID", prevEntry.ID)
}
response.HTML().Render("entry", args.Merge(tplParams{
"entry": entry,
"prevEntry": prevEntry,
"nextEntry": nextEntry,
"nextEntryRoute": nextEntryRoute,
"prevEntryRoute": prevEntryRoute,
"menu": "feeds",
}))
}
// ShowCategoryEntry shows a single feed entry in "category" mode.
func (c *Controller) ShowCategoryEntry(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
categoryID, err := request.IntegerParam("categoryID")
if err != nil {
response.HTML().BadRequest(err)
return
}
entryID, err := request.IntegerParam("entryID")
if err != nil {
response.HTML().BadRequest(err)
return
}
builder := c.store.NewEntryQueryBuilder(user.ID)
builder.WithCategoryID(categoryID)
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:ShowCategoryEntry] %v", err)
response.HTML().ServerError(nil)
return
}
}
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
response.HTML().ServerError(err)
return
}
builder = c.store.NewEntryQueryBuilder(user.ID)
builder.WithCategoryID(categoryID)
prevEntry, nextEntry, err := c.getEntryPrevNext(user, builder, entry.ID)
if err != nil {
response.HTML().ServerError(err)
return
}
nextEntryRoute := ""
if nextEntry != nil {
nextEntryRoute = ctx.Route("categoryEntry", "categoryID", categoryID, "entryID", nextEntry.ID)
}
prevEntryRoute := ""
if prevEntry != nil {
prevEntryRoute = ctx.Route("categoryEntry", "categoryID", categoryID, "entryID", prevEntry.ID)
}
response.HTML().Render("entry", args.Merge(tplParams{
"entry": entry,
"prevEntry": prevEntry,
"nextEntry": nextEntry,
"nextEntryRoute": nextEntryRoute,
"prevEntryRoute": prevEntryRoute,
"menu": "categories",
}))
}
// ShowUnreadEntry shows a single feed entry in "unread" mode.
func (c *Controller) ShowUnreadEntry(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
entryID, err := request.IntegerParam("entryID")
if err != nil {
response.HTML().BadRequest(err)
return
}
builder := c.store.NewEntryQueryBuilder(user.ID)
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
}
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
response.HTML().ServerError(err)
return
}
builder = c.store.NewEntryQueryBuilder(user.ID)
builder.WithStatus(model.EntryStatusUnread)
prevEntry, nextEntry, err := c.getEntryPrevNext(user, builder, entry.ID)
if err != nil {
response.HTML().ServerError(err)
return
}
nextEntryRoute := ""
if nextEntry != nil {
nextEntryRoute = ctx.Route("unreadEntry", "entryID", nextEntry.ID)
}
prevEntryRoute := ""
if prevEntry != nil {
prevEntryRoute = ctx.Route("unreadEntry", "entryID", prevEntry.ID)
}
// We change the status here, otherwise we cannot get the pagination for unread items.
if entry.Status == model.EntryStatusUnread {
err = c.store.SetEntriesStatus(user.ID, []int64{entry.ID}, model.EntryStatusRead)
if err != nil {
logger.Error("[Controller:ShowUnreadEntry] %v", err)
response.HTML().ServerError(nil)
return
}
}
response.HTML().Render("entry", args.Merge(tplParams{
"entry": entry,
"prevEntry": prevEntry,
"nextEntry": nextEntry,
"nextEntryRoute": nextEntryRoute,
"prevEntryRoute": prevEntryRoute,
"menu": "unread",
}))
}
// ShowReadEntry shows a single feed entry in "history" mode.
func (c *Controller) ShowReadEntry(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
entryID, err := request.IntegerParam("entryID")
if err != nil {
response.HTML().BadRequest(err)
return
}
builder := c.store.NewEntryQueryBuilder(user.ID)
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
}
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
response.HTML().ServerError(err)
return
}
builder = c.store.NewEntryQueryBuilder(user.ID)
builder.WithStatus(model.EntryStatusRead)
prevEntry, nextEntry, err := c.getEntryPrevNext(user, builder, entry.ID)
if err != nil {
response.HTML().ServerError(err)
return
}
nextEntryRoute := ""
if nextEntry != nil {
nextEntryRoute = ctx.Route("readEntry", "entryID", nextEntry.ID)
}
prevEntryRoute := ""
if prevEntry != nil {
prevEntryRoute = ctx.Route("readEntry", "entryID", prevEntry.ID)
}
response.HTML().Render("entry", args.Merge(tplParams{
"entry": entry,
"prevEntry": prevEntry,
"nextEntry": nextEntry,
"nextEntryRoute": nextEntryRoute,
"prevEntryRoute": prevEntryRoute,
"menu": "history",
}))
}
// ShowStarredEntry shows a single feed entry in "starred" mode.
func (c *Controller) ShowStarredEntry(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
entryID, err := request.IntegerParam("entryID")
if err != nil {
response.HTML().BadRequest(err)
return
}
builder := c.store.NewEntryQueryBuilder(user.ID)
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.NewEntryQueryBuilder(user.ID)
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 *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
entryIDs, status, err := decodeEntryStatusPayload(request.Body())
if err != nil {
logger.Error("[Controller:UpdateEntryStatus] %v", err)
response.JSON().BadRequest(nil)
return
}
if len(entryIDs) == 0 {
response.JSON().BadRequest(errors.New("The list of entryID is empty"))
return
}
err = c.store.SetEntriesStatus(user.ID, entryIDs, status)
if err != nil {
logger.Error("[Controller:UpdateEntryStatus] %v", err)
response.JSON().ServerError(nil)
return
}
response.JSON().Standard("OK")
}
func (c *Controller) getEntryPrevNext(user *model.User, builder *storage.EntryQueryBuilder, entryID int64) (prev *model.Entry, next *model.Entry, err error) {
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithOrder(model.DefaultSortingOrder)
builder.WithDirection(user.EntryDirection)
entries, err := builder.GetEntries()
if err != nil {
return nil, nil, err
}
n := len(entries)
for i := 0; i < n; i++ {
if entries[i].ID == entryID {
if i-1 >= 0 {
prev = entries[i-1]
}
if i+1 < n {
next = entries[i+1]
}
}
}
return prev, next, nil
}

236
ui/feed.go Normal file
View file

@ -0,0 +1,236 @@
// 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 ui
import (
"errors"
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/ui/form"
)
// RefreshAllFeeds refresh all feeds in the background for the current user.
func (c *Controller) RefreshAllFeeds(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
jobs, err := c.store.NewUserBatch(user.ID, c.store.CountFeeds(user.ID))
if err != nil {
response.HTML().ServerError(err)
return
}
go func() {
c.pool.Push(jobs)
}()
response.Redirect(ctx.Route("feeds"))
}
// ShowFeedsPage shows the page with all subscriptions.
func (c *Controller) ShowFeedsPage(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
response.HTML().ServerError(err)
return
}
feeds, err := c.store.Feeds(user.ID)
if err != nil {
response.HTML().ServerError(err)
return
}
response.HTML().Render("feeds", args.Merge(tplParams{
"feeds": feeds,
"total": len(feeds),
"menu": "feeds",
}))
}
// ShowFeedEntries shows all entries for the given feed.
func (c *Controller) ShowFeedEntries(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
offset := request.QueryIntegerParam("offset", 0)
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
response.HTML().ServerError(err)
return
}
feed, err := c.getFeedFromURL(request, response, user)
if err != nil {
return
}
builder := c.store.NewEntryQueryBuilder(user.ID)
builder.WithFeedID(feed.ID)
builder.WithoutStatus(model.EntryStatusRemoved)
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("feed_entries", args.Merge(tplParams{
"feed": feed,
"entries": entries,
"total": count,
"pagination": c.getPagination(ctx.Route("feedEntries", "feedID", feed.ID), count, offset),
"menu": "feeds",
}))
}
// EditFeed shows the form to modify a subscription.
func (c *Controller) EditFeed(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
feed, err := c.getFeedFromURL(request, response, user)
if err != nil {
return
}
args, err := c.getFeedFormTemplateArgs(ctx, user, feed, nil)
if err != nil {
response.HTML().ServerError(err)
return
}
response.HTML().Render("edit_feed", args)
}
// UpdateFeed update a subscription and redirect to the feed entries page.
func (c *Controller) UpdateFeed(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
feed, err := c.getFeedFromURL(request, response, user)
if err != nil {
return
}
feedForm := form.NewFeedForm(request.Request())
args, err := c.getFeedFormTemplateArgs(ctx, user, feed, feedForm)
if err != nil {
response.HTML().ServerError(err)
return
}
if err := feedForm.ValidateModification(); err != nil {
response.HTML().Render("edit_feed", args.Merge(tplParams{
"errorMessage": err.Error(),
}))
return
}
err = c.store.UpdateFeed(feedForm.Merge(feed))
if err != nil {
logger.Error("[Controller:EditFeed] %v", err)
response.HTML().Render("edit_feed", args.Merge(tplParams{
"errorMessage": "Unable to update this feed.",
}))
return
}
response.Redirect(ctx.Route("feedEntries", "feedID", feed.ID))
}
// RemoveFeed delete a subscription from the database and redirect to the list of feeds page.
func (c *Controller) RemoveFeed(ctx *handler.Context, request *handler.Request, response *handler.Response) {
feedID, err := request.IntegerParam("feedID")
if err != nil {
response.HTML().ServerError(err)
return
}
user := ctx.LoggedUser()
if err := c.store.RemoveFeed(user.ID, feedID); err != nil {
response.HTML().ServerError(err)
return
}
response.Redirect(ctx.Route("feeds"))
}
// RefreshFeed refresh a subscription and redirect to the feed entries page.
func (c *Controller) RefreshFeed(ctx *handler.Context, request *handler.Request, response *handler.Response) {
feedID, err := request.IntegerParam("feedID")
if err != nil {
response.HTML().BadRequest(err)
return
}
user := ctx.LoggedUser()
if err := c.feedHandler.RefreshFeed(user.ID, feedID); err != nil {
logger.Error("[Controller:RefreshFeed] %v", err)
}
response.Redirect(ctx.Route("feedEntries", "feedID", feedID))
}
func (c *Controller) getFeedFromURL(request *handler.Request, response *handler.Response, user *model.User) (*model.Feed, error) {
feedID, err := request.IntegerParam("feedID")
if err != nil {
response.HTML().BadRequest(err)
return nil, err
}
feed, err := c.store.FeedByID(user.ID, feedID)
if err != nil {
response.HTML().ServerError(err)
return nil, err
}
if feed == nil {
response.HTML().NotFound()
return nil, errors.New("Feed not found")
}
return feed, nil
}
func (c *Controller) getFeedFormTemplateArgs(ctx *handler.Context, user *model.User, feed *model.Feed, feedForm *form.FeedForm) (tplParams, error) {
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
return nil, err
}
categories, err := c.store.Categories(user.ID)
if err != nil {
return nil, err
}
if feedForm == nil {
args["form"] = form.FeedForm{
SiteURL: feed.SiteURL,
FeedURL: feed.FeedURL,
Title: feed.Title,
ScraperRules: feed.ScraperRules,
RewriteRules: feed.RewriteRules,
Crawler: feed.Crawler,
CategoryID: feed.Category.ID,
}
} else {
args["form"] = feedForm
}
args["categories"] = categories
args["feed"] = feed
args["menu"] = "feeds"
return args, nil
}

34
ui/form/auth.go Normal file
View file

@ -0,0 +1,34 @@
// 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 form
import (
"net/http"
"github.com/miniflux/miniflux/errors"
)
// AuthForm represents the authentication form.
type AuthForm struct {
Username string
Password string
}
// Validate makes sure the form values are valid.
func (a AuthForm) Validate() error {
if a.Username == "" || a.Password == "" {
return errors.NewLocalizedError("All fields are mandatory.")
}
return nil
}
// NewAuthForm returns a new AuthForm.
func NewAuthForm(r *http.Request) *AuthForm {
return &AuthForm{
Username: r.FormValue("username"),
Password: r.FormValue("password"),
}
}

38
ui/form/category.go Normal file
View file

@ -0,0 +1,38 @@
// 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 form
import (
"net/http"
"github.com/miniflux/miniflux/errors"
"github.com/miniflux/miniflux/model"
)
// CategoryForm represents a feed form in the UI
type CategoryForm struct {
Title string
}
// Validate makes sure the form values are valid.
func (c CategoryForm) Validate() error {
if c.Title == "" {
return errors.NewLocalizedError("The title is mandatory.")
}
return nil
}
// Merge update the given category fields.
func (c CategoryForm) Merge(category *model.Category) *model.Category {
category.Title = c.Title
return category
}
// NewCategoryForm returns a new CategoryForm.
func NewCategoryForm(r *http.Request) *CategoryForm {
return &CategoryForm{
Title: r.FormValue("title"),
}
}

64
ui/form/feed.go Normal file
View file

@ -0,0 +1,64 @@
// 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 form
import (
"net/http"
"strconv"
"github.com/miniflux/miniflux/errors"
"github.com/miniflux/miniflux/model"
)
// FeedForm represents a feed form in the UI
type FeedForm struct {
FeedURL string
SiteURL string
Title string
ScraperRules string
RewriteRules string
Crawler bool
CategoryID int64
}
// ValidateModification validates FeedForm fields
func (f FeedForm) ValidateModification() error {
if f.FeedURL == "" || f.SiteURL == "" || f.Title == "" || f.CategoryID == 0 {
return errors.NewLocalizedError("All fields are mandatory.")
}
return nil
}
// Merge updates the fields of the given feed.
func (f FeedForm) Merge(feed *model.Feed) *model.Feed {
feed.Category.ID = f.CategoryID
feed.Title = f.Title
feed.SiteURL = f.SiteURL
feed.FeedURL = f.FeedURL
feed.ScraperRules = f.ScraperRules
feed.RewriteRules = f.RewriteRules
feed.Crawler = f.Crawler
feed.ParsingErrorCount = 0
feed.ParsingErrorMsg = ""
return feed
}
// NewFeedForm parses the HTTP request and returns a FeedForm
func NewFeedForm(r *http.Request) *FeedForm {
categoryID, err := strconv.Atoi(r.FormValue("category_id"))
if err != nil {
categoryID = 0
}
return &FeedForm{
FeedURL: r.FormValue("feed_url"),
SiteURL: r.FormValue("site_url"),
Title: r.FormValue("title"),
ScraperRules: r.FormValue("scraper_rules"),
RewriteRules: r.FormValue("rewrite_rules"),
Crawler: r.FormValue("crawler") == "1",
CategoryID: int64(categoryID),
}
}

73
ui/form/integration.go Normal file
View file

@ -0,0 +1,73 @@
// 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 form
import (
"net/http"
"github.com/miniflux/miniflux/model"
)
// IntegrationForm represents user integration settings form.
type IntegrationForm struct {
PinboardEnabled bool
PinboardToken string
PinboardTags string
PinboardMarkAsUnread bool
InstapaperEnabled bool
InstapaperUsername string
InstapaperPassword string
FeverEnabled bool
FeverUsername string
FeverPassword string
WallabagEnabled bool
WallabagURL string
WallabagClientID string
WallabagClientSecret string
WallabagUsername string
WallabagPassword string
}
// Merge copy form values to the model.
func (i IntegrationForm) Merge(integration *model.Integration) {
integration.PinboardEnabled = i.PinboardEnabled
integration.PinboardToken = i.PinboardToken
integration.PinboardTags = i.PinboardTags
integration.PinboardMarkAsUnread = i.PinboardMarkAsUnread
integration.InstapaperEnabled = i.InstapaperEnabled
integration.InstapaperUsername = i.InstapaperUsername
integration.InstapaperPassword = i.InstapaperPassword
integration.FeverEnabled = i.FeverEnabled
integration.FeverUsername = i.FeverUsername
integration.FeverPassword = i.FeverPassword
integration.WallabagEnabled = i.WallabagEnabled
integration.WallabagURL = i.WallabagURL
integration.WallabagClientID = i.WallabagClientID
integration.WallabagClientSecret = i.WallabagClientSecret
integration.WallabagUsername = i.WallabagUsername
integration.WallabagPassword = i.WallabagPassword
}
// NewIntegrationForm returns a new AuthForm.
func NewIntegrationForm(r *http.Request) *IntegrationForm {
return &IntegrationForm{
PinboardEnabled: r.FormValue("pinboard_enabled") == "1",
PinboardToken: r.FormValue("pinboard_token"),
PinboardTags: r.FormValue("pinboard_tags"),
PinboardMarkAsUnread: r.FormValue("pinboard_mark_as_unread") == "1",
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"),
WallabagEnabled: r.FormValue("wallabag_enabled") == "1",
WallabagURL: r.FormValue("wallabag_url"),
WallabagClientID: r.FormValue("wallabag_client_id"),
WallabagClientSecret: r.FormValue("wallabag_client_secret"),
WallabagUsername: r.FormValue("wallabag_username"),
WallabagPassword: r.FormValue("wallabag_password"),
}
}

70
ui/form/settings.go Normal file
View file

@ -0,0 +1,70 @@
// 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 form
import (
"net/http"
"github.com/miniflux/miniflux/errors"
"github.com/miniflux/miniflux/model"
)
// SettingsForm represents the settings form.
type SettingsForm struct {
Username string
Password string
Confirmation string
Theme string
Language string
Timezone string
EntryDirection string
}
// Merge updates the fields of the given user.
func (s *SettingsForm) Merge(user *model.User) *model.User {
user.Username = s.Username
user.Theme = s.Theme
user.Language = s.Language
user.Timezone = s.Timezone
user.EntryDirection = s.EntryDirection
if s.Password != "" {
user.Password = s.Password
}
return user
}
// Validate makes sure the form values are valid.
func (s *SettingsForm) Validate() error {
if s.Username == "" || s.Theme == "" || s.Language == "" || s.Timezone == "" || s.EntryDirection == "" {
return errors.NewLocalizedError("The username, theme, language and timezone fields are mandatory.")
}
if s.Password != "" {
if s.Password != s.Confirmation {
return errors.NewLocalizedError("Passwords are not the same.")
}
if len(s.Password) < 6 {
return errors.NewLocalizedError("You must use at least 6 characters")
}
}
return nil
}
// NewSettingsForm returns a new SettingsForm.
func NewSettingsForm(r *http.Request) *SettingsForm {
return &SettingsForm{
Username: r.FormValue("username"),
Password: r.FormValue("password"),
Confirmation: r.FormValue("confirmation"),
Theme: r.FormValue("theme"),
Language: r.FormValue("language"),
Timezone: r.FormValue("timezone"),
EntryDirection: r.FormValue("entry_direction"),
}
}

42
ui/form/subscription.go Normal file
View file

@ -0,0 +1,42 @@
// 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 form
import (
"net/http"
"strconv"
"github.com/miniflux/miniflux/errors"
)
// SubscriptionForm represents the subscription form.
type SubscriptionForm struct {
URL string
CategoryID int64
Crawler bool
}
// Validate makes sure the form values are valid.
func (s *SubscriptionForm) Validate() error {
if s.URL == "" || s.CategoryID == 0 {
return errors.NewLocalizedError("The URL and the category are mandatory.")
}
return nil
}
// NewSubscriptionForm returns a new SubscriptionForm.
func NewSubscriptionForm(r *http.Request) *SubscriptionForm {
categoryID, err := strconv.Atoi(r.FormValue("category_id"))
if err != nil {
categoryID = 0
}
return &SubscriptionForm{
URL: r.FormValue("url"),
Crawler: r.FormValue("crawler") == "1",
CategoryID: int64(categoryID),
}
}

87
ui/form/user.go Normal file
View file

@ -0,0 +1,87 @@
// 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 form
import (
"net/http"
"github.com/miniflux/miniflux/errors"
"github.com/miniflux/miniflux/model"
)
// UserForm represents the user form.
type UserForm struct {
Username string
Password string
Confirmation string
IsAdmin bool
}
// ValidateCreation validates user creation.
func (u UserForm) ValidateCreation() error {
if u.Username == "" || u.Password == "" || u.Confirmation == "" {
return errors.NewLocalizedError("All fields are mandatory.")
}
if u.Password != u.Confirmation {
return errors.NewLocalizedError("Passwords are not the same.")
}
if len(u.Password) < 6 {
return errors.NewLocalizedError("You must use at least 6 characters.")
}
return nil
}
// ValidateModification validates user modification.
func (u UserForm) ValidateModification() error {
if u.Username == "" {
return errors.NewLocalizedError("The username is mandatory.")
}
if u.Password != "" {
if u.Password != u.Confirmation {
return errors.NewLocalizedError("Passwords are not the same.")
}
if len(u.Password) < 6 {
return errors.NewLocalizedError("You must use at least 6 characters.")
}
}
return nil
}
// ToUser returns a User from the form values.
func (u UserForm) ToUser() *model.User {
return &model.User{
Username: u.Username,
Password: u.Password,
IsAdmin: u.IsAdmin,
}
}
// Merge updates the fields of the given user.
func (u UserForm) Merge(user *model.User) *model.User {
user.Username = u.Username
user.IsAdmin = u.IsAdmin
if u.Password != "" {
user.Password = u.Password
}
return user
}
// NewUserForm returns a new UserForm.
func NewUserForm(r *http.Request) *UserForm {
return &UserForm{
Username: r.FormValue("username"),
Password: r.FormValue("password"),
Confirmation: r.FormValue("confirmation"),
IsAdmin: r.FormValue("is_admin") == "1",
}
}

61
ui/history.go Normal file
View file

@ -0,0 +1,61 @@
// 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 ui
import (
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/model"
)
// ShowHistoryPage renders the page with all read entries.
func (c *Controller) ShowHistoryPage(ctx *handler.Context, request *handler.Request, response *handler.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.NewEntryQueryBuilder(user.ID)
builder.WithStatus(model.EntryStatusRead)
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("history", args.Merge(tplParams{
"entries": entries,
"total": count,
"pagination": c.getPagination(ctx.Route("history"), count, offset),
"menu": "history",
}))
}
// FlushHistory changes all "read" items to "removed".
func (c *Controller) FlushHistory(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
err := c.store.FlushHistory(user.ID)
if err != nil {
response.HTML().ServerError(err)
return
}
response.Redirect(ctx.Route("history"))
}

33
ui/icon.go Normal file
View file

@ -0,0 +1,33 @@
// 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 ui
import (
"time"
"github.com/miniflux/miniflux/http/handler"
)
// ShowIcon shows the feed icon.
func (c *Controller) ShowIcon(ctx *handler.Context, request *handler.Request, response *handler.Response) {
iconID, err := request.IntegerParam("iconID")
if err != nil {
response.HTML().BadRequest(err)
return
}
icon, err := c.store.IconByID(iconID)
if err != nil {
response.HTML().ServerError(err)
return
}
if icon == nil {
response.HTML().NotFound()
return
}
response.Cache(icon.MimeType, icon.Hash, icon.Content, 72*time.Hour)
}

84
ui/integrations.go Normal file
View file

@ -0,0 +1,84 @@
// 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 ui
import (
"crypto/md5"
"fmt"
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/ui/form"
)
// ShowIntegrations renders the page with all external integrations.
func (c *Controller) ShowIntegrations(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
integration, err := c.store.Integration(user.ID)
if err != nil {
response.HTML().ServerError(err)
return
}
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
response.HTML().ServerError(err)
return
}
response.HTML().Render("integrations", args.Merge(tplParams{
"menu": "settings",
"form": form.IntegrationForm{
PinboardEnabled: integration.PinboardEnabled,
PinboardToken: integration.PinboardToken,
PinboardTags: integration.PinboardTags,
PinboardMarkAsUnread: integration.PinboardMarkAsUnread,
InstapaperEnabled: integration.InstapaperEnabled,
InstapaperUsername: integration.InstapaperUsername,
InstapaperPassword: integration.InstapaperPassword,
FeverEnabled: integration.FeverEnabled,
FeverUsername: integration.FeverUsername,
FeverPassword: integration.FeverPassword,
WallabagEnabled: integration.WallabagEnabled,
WallabagURL: integration.WallabagURL,
WallabagClientID: integration.WallabagClientID,
WallabagClientSecret: integration.WallabagClientSecret,
WallabagUsername: integration.WallabagUsername,
WallabagPassword: integration.WallabagPassword,
},
}))
}
// UpdateIntegration updates integration settings.
func (c *Controller) UpdateIntegration(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
integration, err := c.store.Integration(user.ID)
if err != nil {
response.HTML().ServerError(err)
return
}
integrationForm := form.NewIntegrationForm(request.Request())
integrationForm.Merge(integration)
if integration.FeverUsername != "" && c.store.HasDuplicateFeverUsername(user.ID, integration.FeverUsername) {
ctx.SetFlashErrorMessage(ctx.Translate("There is already someone else with the same Fever username!"))
response.Redirect(ctx.Route("integrations"))
return
}
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)
return
}
response.Redirect(ctx.Route("integrations"))
}

76
ui/login.go Normal file
View file

@ -0,0 +1,76 @@
// 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 ui
import (
"github.com/miniflux/miniflux/http/cookie"
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/ui/form"
"github.com/tomasen/realip"
)
// ShowLoginPage shows the login form.
func (c *Controller) ShowLoginPage(ctx *handler.Context, request *handler.Request, response *handler.Response) {
if ctx.IsAuthenticated() {
response.Redirect(ctx.Route("unread"))
return
}
response.HTML().Render("login", tplParams{
"csrf": ctx.CSRF(),
})
}
// CheckLogin validates the username/password and redirects the user to the unread page.
func (c *Controller) CheckLogin(ctx *handler.Context, request *handler.Request, response *handler.Response) {
authForm := form.NewAuthForm(request.Request())
tplParams := tplParams{
"errorMessage": "Invalid username or password.",
"csrf": ctx.CSRF(),
"form": authForm,
}
if err := authForm.Validate(); err != nil {
logger.Error("[Controller:CheckLogin] %v", err)
response.HTML().Render("login", tplParams)
return
}
if err := c.store.CheckPassword(authForm.Username, authForm.Password); err != nil {
logger.Error("[Controller:CheckLogin] %v", err)
response.HTML().Render("login", tplParams)
return
}
sessionToken, err := c.store.CreateUserSession(
authForm.Username,
request.Request().UserAgent(),
realip.RealIP(request.Request()),
)
if err != nil {
response.HTML().ServerError(err)
return
}
logger.Info("[Controller:CheckLogin] username=%s just logged in", authForm.Username)
response.SetCookie(cookie.New(cookie.CookieUserSessionID, sessionToken, c.cfg.IsHTTPS))
response.Redirect(ctx.Route("unread"))
}
// Logout destroy the session and redirects the user to the login page.
func (c *Controller) Logout(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
if err := c.store.RemoveUserSessionByToken(user.ID, ctx.UserSessionToken()); err != nil {
logger.Error("[Controller:Logout] %v", err)
}
response.SetCookie(cookie.Expired(cookie.CookieUserSessionID, c.cfg.IsHTTPS))
response.Redirect(ctx.Route("login"))
}

170
ui/oauth2.go Normal file
View file

@ -0,0 +1,170 @@
// 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 ui
import (
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/http/cookie"
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/oauth2"
"github.com/tomasen/realip"
)
// OAuth2Redirect redirects the user to the consent page to ask for permission.
func (c *Controller) OAuth2Redirect(ctx *handler.Context, request *handler.Request, response *handler.Response) {
provider := request.StringParam("provider", "")
if provider == "" {
logger.Error("[OAuth2] Invalid or missing provider: %s", provider)
response.Redirect(ctx.Route("login"))
return
}
authProvider, err := getOAuth2Manager(c.cfg).Provider(provider)
if err != nil {
logger.Error("[OAuth2] %v", err)
response.Redirect(ctx.Route("login"))
return
}
response.Redirect(authProvider.GetRedirectURL(ctx.GenerateOAuth2State()))
}
// OAuth2Callback receives the authorization code and create a new session.
func (c *Controller) OAuth2Callback(ctx *handler.Context, request *handler.Request, response *handler.Response) {
provider := request.StringParam("provider", "")
if provider == "" {
logger.Error("[OAuth2] Invalid or missing provider")
response.Redirect(ctx.Route("login"))
return
}
code := request.QueryStringParam("code", "")
if code == "" {
logger.Error("[OAuth2] No code received on callback")
response.Redirect(ctx.Route("login"))
return
}
state := request.QueryStringParam("state", "")
if state == "" || state != ctx.OAuth2State() {
logger.Error(`[OAuth2] Invalid state value: got "%s" instead of "%s"`, state, ctx.OAuth2State())
response.Redirect(ctx.Route("login"))
return
}
authProvider, err := getOAuth2Manager(c.cfg).Provider(provider)
if err != nil {
logger.Error("[OAuth2] %v", err)
response.Redirect(ctx.Route("login"))
return
}
profile, err := authProvider.GetProfile(code)
if err != nil {
logger.Error("[OAuth2] %v", err)
response.Redirect(ctx.Route("login"))
return
}
if ctx.IsAuthenticated() {
user, err := c.store.UserByExtraField(profile.Key, profile.ID)
if err != nil {
response.HTML().ServerError(err)
return
}
if user != nil {
logger.Error("[OAuth2] User #%d cannot be associated because %s is already associated", ctx.UserID(), user.Username)
ctx.SetFlashErrorMessage(ctx.Translate("There is already someone associated with this provider!"))
response.Redirect(ctx.Route("settings"))
return
}
user = ctx.LoggedUser()
if err := c.store.UpdateExtraField(user.ID, profile.Key, profile.ID); err != nil {
response.HTML().ServerError(err)
return
}
ctx.SetFlashMessage(ctx.Translate("Your external account is now linked !"))
response.Redirect(ctx.Route("settings"))
return
}
user, err := c.store.UserByExtraField(profile.Key, profile.ID)
if err != nil {
response.HTML().ServerError(err)
return
}
if user == nil {
if c.cfg.GetInt("OAUTH2_USER_CREATION", 0) == 0 {
response.HTML().Forbidden()
return
}
user = model.NewUser()
user.Username = profile.Username
user.IsAdmin = false
user.Extra[profile.Key] = profile.ID
if err := c.store.CreateUser(user); err != nil {
response.HTML().ServerError(err)
return
}
}
sessionToken, err := c.store.CreateUserSession(
user.Username,
request.Request().UserAgent(),
realip.RealIP(request.Request()),
)
if err != nil {
response.HTML().ServerError(err)
return
}
logger.Info("[Controller:OAuth2Callback] username=%s just logged in", user.Username)
response.SetCookie(cookie.New(cookie.CookieUserSessionID, sessionToken, c.cfg.IsHTTPS))
response.Redirect(ctx.Route("unread"))
}
// OAuth2Unlink unlink an account from the external provider.
func (c *Controller) OAuth2Unlink(ctx *handler.Context, request *handler.Request, response *handler.Response) {
provider := request.StringParam("provider", "")
if provider == "" {
logger.Info("[OAuth2] Invalid or missing provider")
response.Redirect(ctx.Route("login"))
return
}
authProvider, err := getOAuth2Manager(c.cfg).Provider(provider)
if err != nil {
logger.Error("[OAuth2] %v", err)
response.Redirect(ctx.Route("settings"))
return
}
user := ctx.LoggedUser()
if err := c.store.RemoveExtraField(user.ID, authProvider.GetUserExtraKey()); err != nil {
response.HTML().ServerError(err)
return
}
response.Redirect(ctx.Route("settings"))
return
}
func getOAuth2Manager(cfg *config.Config) *oauth2.Manager {
return oauth2.NewManager(
cfg.Get("OAUTH2_CLIENT_ID", ""),
cfg.Get("OAUTH2_CLIENT_SECRET", ""),
cfg.Get("OAUTH2_REDIRECT_URL", ""),
)
}

71
ui/opml.go Normal file
View file

@ -0,0 +1,71 @@
// 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 ui
import (
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/logger"
)
// Export generates the OPML file.
func (c *Controller) Export(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
opml, err := c.opmlHandler.Export(user.ID)
if err != nil {
response.HTML().ServerError(err)
return
}
response.XML().Download("feeds.opml", opml)
}
// Import shows the import form.
func (c *Controller) Import(ctx *handler.Context, request *handler.Request, response *handler.Response) {
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
response.HTML().ServerError(err)
return
}
response.HTML().Render("import", args.Merge(tplParams{
"menu": "feeds",
}))
}
// UploadOPML handles OPML file importation.
func (c *Controller) UploadOPML(ctx *handler.Context, request *handler.Request, response *handler.Response) {
file, fileHeader, err := request.File("file")
if err != nil {
logger.Error("[Controller:UploadOPML] %v", err)
response.Redirect(ctx.Route("import"))
return
}
defer file.Close()
user := ctx.LoggedUser()
logger.Info(
"[Controller:UploadOPML] User #%d uploaded this file: %s (%d bytes)",
user.ID,
fileHeader.Filename,
fileHeader.Size,
)
if impErr := c.opmlHandler.Import(user.ID, file); impErr != nil {
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
response.HTML().ServerError(err)
return
}
response.HTML().Render("import", args.Merge(tplParams{
"errorMessage": impErr,
"menu": "feeds",
}))
return
}
response.Redirect(ctx.Route("feeds"))
}

46
ui/pagination.go Normal file
View file

@ -0,0 +1,46 @@
// 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 ui
const (
nbItemsPerPage = 100
)
type pagination struct {
Route string
Total int
Offset int
ItemsPerPage int
ShowNext bool
ShowPrev bool
NextOffset int
PrevOffset int
}
func (c *Controller) getPagination(route string, total, offset int) pagination {
nextOffset := 0
prevOffset := 0
showNext := (total - offset) > nbItemsPerPage
showPrev := offset > 0
if showNext {
nextOffset = offset + nbItemsPerPage
}
if showPrev {
prevOffset = offset - nbItemsPerPage
}
return pagination{
Route: route,
Total: total,
Offset: offset,
ItemsPerPage: nbItemsPerPage,
ShowNext: showNext,
NextOffset: nextOffset,
ShowPrev: showPrev,
PrevOffset: prevOffset,
}
}

32
ui/payload.go Normal file
View file

@ -0,0 +1,32 @@
// 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 ui
import (
"encoding/json"
"fmt"
"io"
"github.com/miniflux/miniflux/model"
)
func decodeEntryStatusPayload(data io.Reader) (entryIDs []int64, status string, err error) {
type payload struct {
EntryIDs []int64 `json:"entry_ids"`
Status string `json:"status"`
}
var p payload
decoder := json.NewDecoder(data)
if err = decoder.Decode(&p); err != nil {
return nil, "", fmt.Errorf("invalid JSON payload: %v", err)
}
if err := model.ValidateEntryStatus(p.Status); err != nil {
return nil, "", err
}
return p.EntryIDs, p.Status, nil
}

56
ui/proxy.go Normal file
View file

@ -0,0 +1,56 @@
// 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 ui
import (
"encoding/base64"
"errors"
"io/ioutil"
"time"
"github.com/miniflux/miniflux/crypto"
"github.com/miniflux/miniflux/http"
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/logger"
)
// ImageProxy fetch an image from a remote server and sent it back to the browser.
func (c *Controller) ImageProxy(ctx *handler.Context, request *handler.Request, response *handler.Response) {
// If we receive a "If-None-Match" header we assume the image in stored in browser cache
if request.Request().Header.Get("If-None-Match") != "" {
response.NotModified()
return
}
encodedURL := request.StringParam("encodedURL", "")
if encodedURL == "" {
response.HTML().BadRequest(errors.New("No URL provided"))
return
}
decodedURL, err := base64.URLEncoding.DecodeString(encodedURL)
if err != nil {
response.HTML().BadRequest(errors.New("Unable to decode this URL"))
return
}
client := http.NewClient(string(decodedURL))
resp, err := client.Get()
if err != nil {
logger.Error("[Controller:ImageProxy] %v", err)
response.HTML().NotFound()
return
}
if resp.HasServerFailure() {
response.HTML().NotFound()
return
}
body, _ := ioutil.ReadAll(resp.Body)
etag := crypto.HashFromBytes(body)
response.Cache(resp.ContentType, etag, body, 72*time.Hour)
}

50
ui/session.go Normal file
View file

@ -0,0 +1,50 @@
// 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 ui
import (
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/logger"
)
// ShowSessions shows the list of active user sessions.
func (c *Controller) ShowSessions(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
response.HTML().ServerError(err)
return
}
sessions, err := c.store.UserSessions(user.ID)
if err != nil {
response.HTML().ServerError(err)
return
}
response.HTML().Render("sessions", args.Merge(tplParams{
"sessions": sessions,
"currentSessionToken": ctx.UserSessionToken(),
"menu": "settings",
}))
}
// RemoveSession remove a user session.
func (c *Controller) RemoveSession(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
sessionID, err := request.IntegerParam("sessionID")
if err != nil {
response.HTML().BadRequest(err)
return
}
err = c.store.RemoveUserSessionByID(user.ID, sessionID)
if err != nil {
logger.Error("[Controller:RemoveSession] %v", err)
}
response.Redirect(ctx.Route("sessions"))
}

96
ui/settings.go Normal file
View file

@ -0,0 +1,96 @@
// 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 ui
import (
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/locale"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/ui/form"
)
// ShowSettings shows the settings page.
func (c *Controller) ShowSettings(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
args, err := c.getSettingsFormTemplateArgs(ctx, user, nil)
if err != nil {
response.HTML().ServerError(err)
return
}
response.HTML().Render("settings", args)
}
// UpdateSettings update the settings.
func (c *Controller) UpdateSettings(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
settingsForm := form.NewSettingsForm(request.Request())
args, err := c.getSettingsFormTemplateArgs(ctx, user, settingsForm)
if err != nil {
response.HTML().ServerError(err)
return
}
if err := settingsForm.Validate(); err != nil {
response.HTML().Render("settings", args.Merge(tplParams{
"form": settingsForm,
"errorMessage": err.Error(),
}))
return
}
if c.store.AnotherUserExists(user.ID, settingsForm.Username) {
response.HTML().Render("settings", args.Merge(tplParams{
"form": settingsForm,
"errorMessage": "This user already exists.",
}))
return
}
err = c.store.UpdateUser(settingsForm.Merge(user))
if err != nil {
logger.Error("[Controller:UpdateSettings] %v", err)
response.HTML().Render("settings", args.Merge(tplParams{
"form": settingsForm,
"errorMessage": "Unable to update this user.",
}))
return
}
ctx.SetFlashMessage(ctx.Translate("Preferences saved!"))
response.Redirect(ctx.Route("settings"))
}
func (c *Controller) getSettingsFormTemplateArgs(ctx *handler.Context, user *model.User, settingsForm *form.SettingsForm) (tplParams, error) {
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
return args, err
}
if settingsForm == nil {
args["form"] = form.SettingsForm{
Username: user.Username,
Theme: user.Theme,
Language: user.Language,
Timezone: user.Timezone,
EntryDirection: user.EntryDirection,
}
} else {
args["form"] = settingsForm
}
args["menu"] = "settings"
args["themes"] = model.Themes()
args["languages"] = locale.AvailableLanguages()
args["timezones"], err = c.store.Timezones()
if err != nil {
return args, err
}
return args, nil
}

68
ui/starred.go Normal file
View file

@ -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 ui
import (
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
)
// ShowStarredPage renders the page with all starred entries.
func (c *Controller) ShowStarredPage(ctx *handler.Context, request *handler.Request, response *handler.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.NewEntryQueryBuilder(user.ID)
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 *handler.Context, request *handler.Request, response *handler.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")
}

97
ui/static.go Normal file
View file

@ -0,0 +1,97 @@
// 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 ui
import (
"encoding/base64"
"time"
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/ui/static"
)
// Stylesheet renders the CSS.
func (c *Controller) Stylesheet(ctx *handler.Context, request *handler.Request, response *handler.Response) {
stylesheet := request.StringParam("name", "white")
body := static.Stylesheets["common"]
etag := static.StylesheetsChecksums["common"]
if theme, found := static.Stylesheets[stylesheet]; found {
body += theme
etag += static.StylesheetsChecksums[stylesheet]
}
response.Cache("text/css; charset=utf-8", etag, []byte(body), 48*time.Hour)
}
// Javascript renders application client side code.
func (c *Controller) Javascript(ctx *handler.Context, request *handler.Request, response *handler.Response) {
response.Cache("text/javascript; charset=utf-8", static.JavascriptChecksums["app"], []byte(static.Javascript["app"]), 48*time.Hour)
}
// Favicon renders the application favicon.
func (c *Controller) Favicon(ctx *handler.Context, request *handler.Request, response *handler.Response) {
blob, err := base64.StdEncoding.DecodeString(static.Binaries["favicon.ico"])
if err != nil {
logger.Error("[Controller:Favicon] %v", err)
response.HTML().NotFound()
return
}
response.Cache("image/x-icon", static.BinariesChecksums["favicon.ico"], blob, 48*time.Hour)
}
// AppIcon returns application icons.
func (c *Controller) AppIcon(ctx *handler.Context, request *handler.Request, response *handler.Response) {
filename := request.StringParam("filename", "favicon.png")
encodedBlob, found := static.Binaries[filename]
if !found {
logger.Info("[Controller:AppIcon] This icon doesn't exists: %s", filename)
response.HTML().NotFound()
return
}
blob, err := base64.StdEncoding.DecodeString(encodedBlob)
if err != nil {
logger.Error("[Controller:AppIcon] %v", err)
response.HTML().NotFound()
return
}
response.Cache("image/png", static.BinariesChecksums[filename], blob, 48*time.Hour)
}
// WebManifest renders web manifest file.
func (c *Controller) WebManifest(ctx *handler.Context, request *handler.Request, response *handler.Response) {
type webManifestIcon struct {
Source string `json:"src"`
Sizes string `json:"sizes"`
Type string `json:"type"`
}
type webManifest struct {
Name string `json:"name"`
Description string `json:"description"`
ShortName string `json:"short_name"`
StartURL string `json:"start_url"`
Icons []webManifestIcon `json:"icons"`
Display string `json:"display"`
}
manifest := &webManifest{
Name: "Miniflux",
ShortName: "Miniflux",
Description: "Minimalist Feed Reader",
Display: "minimal-ui",
StartURL: ctx.Route("unread"),
Icons: []webManifestIcon{
webManifestIcon{Source: ctx.Route("appIcon", "filename", "touch-icon-ipad-retina.png"), Sizes: "144x144", Type: "image/png"},
webManifestIcon{Source: ctx.Route("appIcon", "filename", "touch-icon-iphone-retina.png"), Sizes: "114x114", Type: "image/png"},
},
}
response.JSON().Standard(manifest)
}

22
ui/static/bin.go Normal file

File diff suppressed because one or more lines are too long

BIN
ui/static/bin/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
ui/static/bin/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 847 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 947 B

14
ui/static/css.go Normal file

File diff suppressed because one or more lines are too long

219
ui/static/css/black.css Normal file
View file

@ -0,0 +1,219 @@
/* Layout */
body {
background: #222;
color: #efefef;
}
h1, h2, h3 {
color: #aaa;
}
a {
color: #aaa;
}
a:focus,
a:hover {
color: #ddd;
}
.header li {
border-color: #333;
}
.header a {
color: #ddd;
font-weight: 400;
}
.header .active a {
font-weight: 400;
color: #9b9494;
}
.header a:focus,
.header a:hover {
color: rgba(82, 168, 236, 0.85);
}
.page-header h1 {
border-color: #333;
}
.logo a:hover span {
color: #555;
}
/* Tables */
table, th, td {
border: 1px solid #555;
}
th {
background: #333;
color: #aaa;
font-weight: 400;
}
tr:hover {
background-color: #333;
color: #aaa;
}
/* Forms */
input[type="url"],
input[type="password"],
input[type="text"] {
border: 1px solid #555;
background: #333;
color: #ccc;
}
input[type="url"]:focus,
input[type="password"]:focus,
input[type="text"]:focus {
color: #efefef;
border-color: rgba(82, 168, 236, 0.8);
box-shadow: 0 0 8px rgba(82, 168, 236, 0.6);
}
/* Buttons */
.button-primary {
border-color: #444;
background: #333;
color: #efefef;
}
.button-primary:hover,
.button-primary:focus {
border-color: #888;
background: #555;
}
/* Alerts */
.alert,
.alert-success,
.alert-error,
.alert-info,
.alert-normal {
color: #efefef;
background-color: #333;
border-color: #444;
}
/* Panel */
.panel {
background: #333;
border-color: #555;
color: #9b9b9b;
}
/* Modals */
#modal-left {
background: #333;
color: #efefef;
box-shadow: 0 0 10px rgba(82, 168, 236, 0.6);
}
/* Keyboard Shortcuts */
.keyboard-shortcuts li {
color: #9b9b9b;
}
/* Counter */
.unread-counter-wrapper {
color: #bbb;
}
/* Category label */
.category {
color: #efefef;
background-color: #333;
border-color: #444;
}
.category a {
color: #999;
}
.category a:hover,
.category a:focus {
color: #aaa;
}
/* Pagination */
.pagination a {
color: #aaa;
}
.pagination-bottom {
border-color: #333;
}
/* List view */
.item {
border-color: #666;
padding: 4px;
}
.item.current-item {
border-width: 2px;
border-color: rgba(82, 168, 236, 0.8);
box-shadow: 0 0 8px rgba(82, 168, 236, 0.6);
}
.item-title a {
font-weight: 400;
}
.item-status-read .item-title a {
color: #666;
}
.item-status-read .item-title a:focus,
.item-status-read .item-title a:hover {
color: rgba(82, 168, 236, 0.6);
}
.item-meta a:hover,
.item-meta a:focus {
color: #aaa;
}
.item-meta li:after {
color: #ddd;
}
/* Feeds list */
article.feed-parsing-error {
background-color: #343434;
}
.parsing-error {
color: #eee;
}
/* Entry view */
.entry header {
border-color: #333;
}
.entry header h1 a {
color: #bbb;
}
.entry-content,
.entry-content p, ul {
color: #999;
}
.entry-content pre,
.entry-content code {
color: #fff;
background: #555;
border-color: #888;
}
.entry-enclosure {
border-color: #333;
}

778
ui/static/css/common.css Normal file
View file

@ -0,0 +1,778 @@
/* Layout */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
text-rendering: optimizeLegibility;
}
main {
padding-left: 5px;
padding-right: 5px;
}
a {
color: #3366CC;
}
a:focus {
outline: 0;
color: red;
text-decoration: none;
border: 1px dotted #aaa;
}
a:hover {
color: #333;
text-decoration: none;
}
.header {
margin-top: 10px;
margin-bottom: 20px;
}
.header nav ul {
display: none;
}
.header li {
cursor: pointer;
padding-left: 10px;
line-height: 2.1em;
font-size: 1.2em;
border-bottom: 1px dotted #ddd;
}
.header li:hover a {
color: #888;
}
.header a {
font-size: 0.9em;
color: #444;
text-decoration: none;
border: none;
}
.header .active a {
font-weight: 600;
}
.header a:hover,
.header a:focus {
color: #888;
}
.page-header {
margin-bottom: 25px;
}
.page-header h1 {
font-weight: 500;
border-bottom: 1px dotted #ddd;
}
.page-header ul {
margin-left: 25px;
}
.page-header li {
list-style-type: circle;
line-height: 1.8em;
}
.logo {
cursor: pointer;
text-align: center;
}
.logo a {
color: #000;
letter-spacing: 1px;
}
.logo a:hover {
color: #339966;
}
.logo a span {
color: #339966;
}
.logo a:hover span {
color: #000;
}
@media (min-width: 600px) {
body {
margin: auto;
max-width: 750px;
}
.logo {
text-align: left;
float: left;
margin-right: 15px;
}
.header nav ul {
display: block;
}
.header li {
display: inline;
padding: 0;
padding-right: 15px;
line-height: normal;
border: none;
font-size: 1.0em;
}
.page-header ul {
margin-left: 0;
}
.page-header li {
display: inline;
padding-right: 15px;
}
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
}
table, th, td {
border: 1px solid #ddd;
}
th, td {
padding: 5px;
text-align: left;
}
td {
vertical-align: top;
}
th {
background: #fcfcfc;
}
tr:hover {
background-color: #f9f9f9;
}
.column-40 {
width: 40%;
}
.column-25 {
width: 25%;
}
.column-20 {
width: 20%;
}
/* Forms */
label {
cursor: pointer;
display: block;
}
.radio-group {
line-height: 1.9em;
}
div.radio-group label {
display: inline-block;
}
select {
margin-bottom: 15px;
}
input[type="url"],
input[type="password"],
input[type="text"] {
border: 1px solid #ccc;
padding: 3px;
line-height: 20px;
width: 250px;
font-size: 99%;
margin-bottom: 10px;
margin-top: 5px;
-webkit-appearance: none;
}
input[type="url"]:focus,
input[type="password"]:focus,
input[type="text"]:focus {
color: #000;
border-color: rgba(82, 168, 236, 0.8);
outline: 0;
box-shadow: 0 0 8px rgba(82, 168, 236, 0.6);
}
input[type="checkbox"] {
margin-bottom: 15px;
}
::-moz-placeholder,
::-ms-input-placeholder,
::-webkit-input-placeholder {
color: #ddd;
padding-top: 2px;
}
.form-help {
font-size: 0.9em;
color: brown;
margin-bottom: 15px;
}
.form-section {
border-left: 2px dotted #ddd;
padding-left: 20px;
margin-left: 10px;
}
/* Buttons */
a.button {
text-decoration: none;
}
.button {
display: inline-block;
-webkit-appearance: none;
-moz-appearance: none;
font-size: 1.1em;
cursor: pointer;
padding: 3px 10px;
border: 1px solid;
border-radius: unset;
}
.button-primary {
border-color: #3079ed;
background: #4d90fe;
color: #fff;
}
.button-primary:hover,
.button-primary:focus {
border-color: #2f5bb7;
background: #357ae8;
}
.button-danger {
border-color: #b0281a;
background: #d14836;
color: #fff;
}
.button-danger:hover,
.button-danger:focus {
color: #fff;
background: #c53727;
}
.button:disabled {
color: #ccc;
background: #f7f7f7;
border-color: #ccc;
}
.buttons {
margin-top: 10px;
margin-bottom: 20px;
}
/* Alerts */
.alert {
padding: 8px 35px 8px 14px;
margin-bottom: 20px;
color: #c09853;
background-color: #fcf8e3;
border: 1px solid #fbeed5;
border-radius: 4px;
overflow: auto;
}
.alert h3 {
margin-top: 0;
margin-bottom: 15px;
}
.alert-success {
color: #468847;
background-color: #dff0d8;
border-color: #d6e9c6;
}
.alert-error {
color: #b94a48;
background-color: #f2dede;
border-color: #eed3d7;
}
.alert-error a {
color: #b94a48;
}
.alert-info {
color: #3a87ad;
background-color: #d9edf7;
border-color: #bce8f1;
}
/* Panel */
.panel {
color: #333;
background-color: #fcfcfc;
border: 1px solid #ddd;
border-radius: 5px;
padding: 10px;
margin-bottom: 15px;
}
.panel h3 {
font-weight: 500;
margin-top: 0;
margin-bottom: 20px;
}
.panel ul {
margin-left: 30px;
}
/* Modals */
#modal-left {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 350px;
overflow: auto;
background: #f0f0f0;
box-shadow: 2px 0 5px 0 #ccc;
padding: 5px;
padding-top: 30px;
}
#modal-left h3 {
font-weight: 400;
}
.btn-close-modal {
position: absolute;
top: 0;
right: 0;
font-size: 1.7em;
color: #ccc;
padding:0 .2em;
margin: 10px;
text-decoration: none;
}
.btn-close-modal:hover {
color: #999;
}
/* Keyboard Shortcuts */
.keyboard-shortcuts li {
margin-left: 25px;
list-style-type: square;
color: #333;
font-size: 0.95em;
line-height: 1.45em;
}
.keyboard-shortcuts p {
line-height: 1.9em;
}
/* Login form */
.login-form {
margin: 50px auto 0;
max-width: 280px;
}
/* Counter */
.unread-counter-wrapper {
font-size: 0.8em;
font-weight: 300;
color: #666;
}
/* Category label */
.category {
font-size: 0.75em;
background-color: #fffcd7;
border: 1px solid #d5d458;
border-radius: 5px;
margin-left: 0.25em;
padding: 1px 0.4em 1px 0.4em;
white-space: nowrap;
}
.category a {
color: #555;
text-decoration: none;
}
.category a:hover,
.category a:focus {
color: #000;
}
/* Pagination */
.pagination {
font-size: 1.1em;
display: flex;
align-items: center;
padding-top: 8px;
}
.pagination-bottom {
border-top: 1px dotted #ddd;
margin-bottom: 15px;
margin-top: 50px;
}
.pagination > div {
flex: 1;
}
.pagination-next {
text-align: right;
}
.pagination-prev:before {
content: "« ";
}
.pagination-next:after {
content: " »";
}
.pagination a {
color: #333;
}
.pagination a:hover,
.pagination a:focus {
text-decoration: none;
}
/* List view */
.item {
border: 1px dotted #ddd;
margin-bottom: 20px;
padding: 5px;
overflow: hidden;
}
.item.current-item {
border: 3px solid #bce;
padding: 3px;
}
.item-title a {
text-decoration: none;
font-weight: 600;
}
.item-status-read .item-title a {
color: #777;
}
.item-meta {
color: #777;
font-size: 0.8em;
}
.item-meta a {
color: #777;
text-decoration: none;
}
.item-meta a:hover,
.item-meta a:focus {
color: #333;
}
.item-meta ul {
margin-top: 5px;
}
.item-meta li {
display: inline;
}
.item-meta li:after {
content: "|";
color: #aaa;
}
.item-meta li:last-child:after {
content: "";
}
.hide-read-items .item-status-read {
display: none;
}
/* Feeds list */
article.feed-parsing-error {
background-color: #fcf8e3;
border-color: #aaa;
}
.parsing-error {
font-size: 0.85em;
margin-top: 2px;
color: #333;
}
.parsing-error-count {
cursor: pointer;
}
/* Entry view */
.entry header {
padding-bottom: 5px;
border-bottom: 1px dotted #ddd;
}
.entry header h1 {
font-size: 2.0em;
line-height: 1.25em;
margin: 30px 0;
}
.entry header h1 a {
text-decoration: none;
color: #333;
}
.entry header h1 a:hover,
.entry header h1 a:focus {
color: #666;
}
.entry-actions {
margin-bottom: 20px;
}
.entry-actions li {
display: inline;
}
.entry-actions li:not(:last-child):after {
content: "|";
}
.entry-meta {
font-size: 0.95em;
margin: 0 0 20px;
color: #666;
overflow-wrap: break-word;
}
.entry-website img {
vertical-align: top;
}
.entry-website a {
color: #666;
vertical-align: top;
text-decoration: none;
}
.entry-website a:hover,
.entry-website a:focus {
text-decoration: underline;
}
.entry-date {
font-size: 0.65em;
font-style: italic;
color: #555;
}
.entry-content {
padding-top: 15px;
font-size: 1.2em;
font-weight: 300;
font-family: Georgia, 'Times New Roman', Times, serif;
color: #555;
line-height: 1.4em;
overflow-wrap: break-word;
}
.entry-content h1, h2, h3, h4, h5, h6 {
margin-top: 15px;
margin-bottom: 10px;
}
.entry-content iframe,
.entry-content video,
.entry-content img {
max-width: 100%;
}
.entry-content figure {
margin-top: 15px;
margin-bottom: 15px;
}
.entry-content figure img {
border: 1px solid #000;
}
.entry-content figcaption {
font-size: 0.75em;
text-transform: uppercase;
color: #777;
}
.entry-content p {
margin-top: 10px;
margin-bottom: 15px;
}
.entry-content a {
overflow-wrap: break-word;
}
.entry-content a:visited {
color: purple;
}
.entry-content dt {
font-weight: 500;
margin-top: 15px;
color: #555;
}
.entry-content dd {
margin-left: 15px;
margin-top: 5px;
padding-left: 20px;
border-left: 3px solid #ddd;
color: #777;
font-weight: 300;
line-height: 1.4em;
}
.entry-content blockquote {
border-left: 4px solid #ddd;
padding-left: 25px;
margin-left: 20px;
margin-top: 20px;
margin-bottom: 20px;
color: #888;
line-height: 1.4em;
font-family: Georgia, serif;
}
.entry-content blockquote + p {
color: #555;
font-style: italic;
font-weight: 200;
}
.entry-content q {
color: purple;
font-family: Georgia, serif;
font-style: italic;
}
.entry-content q:before {
content: "“";
}
.entry-content q:after {
content: "”";
}
.entry-content pre {
padding: 5px;
background: #f0f0f0;
border: 1px solid #ddd;
overflow: scroll;
overflow-wrap: initial;
}
.entry-content table {
table-layout: fixed;
max-width: 100%;
}
.entry-content ul,
.entry-content ol {
margin-left: 30px;
}
.entry-content ul {
list-style-type: square;
}
.entry-enclosures h3 {
font-weight: 500;
}
.entry-enclosure {
border: 1px dotted #ddd;
padding: 5px;
margin-top: 10px;
max-width: 100%;
}
.entry-enclosure-download {
font-size: 0.85em;
overflow-wrap: break-word;
}
.enclosure-video video,
.enclosure-image img {
max-width: 100%;
}
/* Confirmation */
.confirm {
font-weight: 500;
color: #ed2d04;
}
.confirm a {
color: #ed2d04;
}
.loading {
font-style: italic;
}
/* Bookmarlet */
.bookmarklet {
border: 1px dashed #ccc;
border-radius: 5px;
padding: 15px;
margin: 15px;
text-align: center;
}
.bookmarklet a {
font-weight: 600;
text-decoration: none;
font-size: 1.2em;
}

92
ui/static/js.go Normal file
View file

@ -0,0 +1,92 @@
// Code generated by go generate; DO NOT EDIT.
// 2018-01-02 21:59:10.089270078 -0800 PST m=+0.016645407
package static
var Javascript = map[string]string{
"app": `(function(){'use strict';class DomHelper{static isVisible(element){return element.offsetParent!==null;}
static openNewTab(url){let win=window.open(url,"_blank");win.focus();}
static scrollPageTo(element){let windowScrollPosition=window.pageYOffset;let windowHeight=document.documentElement.clientHeight;let viewportPosition=windowScrollPosition+windowHeight;let itemBottomPosition=element.offsetTop+element.offsetHeight;if(viewportPosition-itemBottomPosition<0||viewportPosition-element.offsetTop>windowHeight){window.scrollTo(0,element.offsetTop-10);}}
static getVisibleElements(selector){let elements=document.querySelectorAll(selector);let result=[];for(let i=0;i<elements.length;i++){if(this.isVisible(elements[i])){result.push(elements[i]);}}
return result;}}
class TouchHandler{constructor(){this.reset();}
reset(){this.touch={start:{x:-1,y:-1},move:{x:-1,y:-1},element:null};}
calculateDistance(){if(this.touch.start.x>=-1&&this.touch.move.x>=-1){let horizontalDistance=Math.abs(this.touch.move.x-this.touch.start.x);let verticalDistance=Math.abs(this.touch.move.y-this.touch.start.y);if(horizontalDistance>30&&verticalDistance<70){return this.touch.move.x-this.touch.start.x;}}
return 0;}
findElement(element){if(element.classList.contains("touch-item")){return element;}
for(;element&&element!==document;element=element.parentNode){if(element.classList.contains("touch-item")){return element;}}
return null;}
onTouchStart(event){if(event.touches===undefined||event.touches.length!==1){return;}
this.reset();this.touch.start.x=event.touches[0].clientX;this.touch.start.y=event.touches[0].clientY;this.touch.element=this.findElement(event.touches[0].target);}
onTouchMove(event){if(event.touches===undefined||event.touches.length!==1||this.element===null){return;}
this.touch.move.x=event.touches[0].clientX;this.touch.move.y=event.touches[0].clientY;let distance=this.calculateDistance();let absDistance=Math.abs(distance);if(absDistance>0){let opacity=1-(absDistance>75?0.9:absDistance/75*0.9);let tx=distance>75?75:(distance<-75?-75:distance);this.touch.element.style.opacity=opacity;this.touch.element.style.transform="translateX("+tx+"px)";}}
onTouchEnd(event){if(event.touches===undefined){return;}
if(this.touch.element!==null){let distance=Math.abs(this.calculateDistance());if(distance>75){EntryHandler.toggleEntryStatus(this.touch.element);this.touch.element.style.opacity=1;this.touch.element.style.transform="none";}}
this.reset();}
listen(){let elements=document.querySelectorAll(".touch-item");elements.forEach((element)=>{element.addEventListener("touchstart",(e)=>this.onTouchStart(e),false);element.addEventListener("touchmove",(e)=>this.onTouchMove(e),false);element.addEventListener("touchend",(e)=>this.onTouchEnd(e),false);element.addEventListener("touchcancel",()=>this.reset(),false);});}}
class KeyboardHandler{constructor(){this.queue=[];this.shortcuts={};}
on(combination,callback){this.shortcuts[combination]=callback;}
listen(){document.onkeydown=(event)=>{if(this.isEventIgnored(event)){return;}
let key=this.getKey(event);this.queue.push(key);for(let combination in this.shortcuts){let keys=combination.split(" ");if(keys.every((value,index)=>value===this.queue[index])){this.queue=[];this.shortcuts[combination]();return;}
if(keys.length===1&&key===keys[0]){this.queue=[];this.shortcuts[combination]();return;}}
if(this.queue.length>=2){this.queue=[];}};}
isEventIgnored(event){return event.target.tagName==="INPUT"||event.target.tagName==="TEXTAREA";}
getKey(event){const mapping={'Esc':'Escape','Up':'ArrowUp','Down':'ArrowDown','Left':'ArrowLeft','Right':'ArrowRight'};for(let key in mapping){if(mapping.hasOwnProperty(key)&&key===event.key){return mapping[key];}}
return event.key;}}
class FormHandler{static handleSubmitButtons(){let elements=document.querySelectorAll("form");elements.forEach((element)=>{element.onsubmit=()=>{let button=document.querySelector("button");if(button){button.innerHTML=button.dataset.labelLoading;button.disabled=true;}};});}}
class MouseHandler{onClick(selector,callback){let elements=document.querySelectorAll(selector);elements.forEach((element)=>{element.onclick=(event)=>{event.preventDefault();callback(event);};});}}
class RequestBuilder{constructor(url){this.callback=null;this.url=url;this.options={method:"POST",cache:"no-cache",credentials:"include",body:null,headers:new Headers({"Content-Type":"application/json","X-Csrf-Token":this.getCsrfToken()})};}
withBody(body){this.options.body=JSON.stringify(body);return this;}
withCallback(callback){this.callback=callback;return this;}
getCsrfToken(){let element=document.querySelector("meta[name=X-CSRF-Token]");if(element!==null){return element.getAttribute("value");}
return "";}
execute(){fetch(new Request(this.url,this.options)).then((response)=>{if(this.callback){this.callback(response);}});}}
class UnreadCounterHandler{static decrement(n){this.updateValue((current)=>{return current-n;});}
static increment(n){this.updateValue((current)=>{return current+n;});}
static updateValue(callback){let counterElements=document.querySelectorAll("span.unread-counter");counterElements.forEach((element)=>{let oldValue=parseInt(element.textContent,10);element.innerHTML=callback(oldValue);});}}
class EntryHandler{static updateEntriesStatus(entryIDs,status,callback){let url=document.body.dataset.entriesStatusUrl;let request=new RequestBuilder(url);request.withBody({entry_ids:entryIDs,status:status});request.withCallback(callback);request.execute();}
static toggleEntryStatus(element){let entryID=parseInt(element.dataset.id,10);let statuses={read:"unread",unread:"read"};for(let currentStatus in statuses){let newStatus=statuses[currentStatus];if(element.classList.contains("item-status-"+currentStatus)){element.classList.remove("item-status-"+currentStatus);element.classList.add("item-status-"+newStatus);this.updateEntriesStatus([entryID],newStatus);if(newStatus==="read"){UnreadCounterHandler.decrement(1);}else{UnreadCounterHandler.increment(1);}
break;}}}
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");element.classList.add("item-status-read");let entryID=parseInt(element.dataset.id,10);this.updateEntriesStatus([entryID],"read");}}
static saveEntry(element){if(element.dataset.completed){return;}
element.innerHTML=element.dataset.labelLoading;let request=new RequestBuilder(element.dataset.saveUrl);request.withCallback(()=>{element.innerHTML=element.dataset.labelDone;element.dataset.completed=true;});request.execute();}
static fetchOriginalContent(element){if(element.dataset.completed){return;}
element.innerHTML=element.dataset.labelLoading;let request=new RequestBuilder(element.dataset.fetchContentUrl);request.withCallback((response)=>{element.innerHTML=element.dataset.labelDone;element.dataset.completed=true;response.json().then((data)=>{if(data.hasOwnProperty("content")){document.querySelector(".entry-content").innerHTML=data.content;}});});request.execute();}}
class ConfirmHandler{remove(url){let request=new RequestBuilder(url);request.withCallback(()=>window.location.reload());request.execute();}
handle(event){let questionElement=document.createElement("span");let linkElement=event.target;let containerElement=linkElement.parentNode;linkElement.style.display="none";let yesElement=document.createElement("a");yesElement.href="#";yesElement.appendChild(document.createTextNode(linkElement.dataset.labelYes));yesElement.onclick=(event)=>{event.preventDefault();let loadingElement=document.createElement("span");loadingElement.className="loading";loadingElement.appendChild(document.createTextNode(linkElement.dataset.labelLoading));questionElement.remove();containerElement.appendChild(loadingElement);this.remove(linkElement.dataset.url);};let noElement=document.createElement("a");noElement.href="#";noElement.appendChild(document.createTextNode(linkElement.dataset.labelNo));noElement.onclick=(event)=>{event.preventDefault();linkElement.style.display="inline";questionElement.remove();};questionElement.className="confirm";questionElement.appendChild(document.createTextNode(linkElement.dataset.labelQuestion+" "));questionElement.appendChild(yesElement);questionElement.appendChild(document.createTextNode(", "));questionElement.appendChild(noElement);containerElement.appendChild(questionElement);}}
class MenuHandler{clickMenuListItem(event){let element=event.target;if(element.tagName==="A"){window.location.href=element.getAttribute("href");}else{window.location.href=element.querySelector("a").getAttribute("href");}}
toggleMainMenu(){let menu=document.querySelector(".header nav ul");if(DomHelper.isVisible(menu)){menu.style.display="none";}else{menu.style.display="block";}}}
class ModalHandler{static exists(){return document.getElementById("modal-container")!==null;}
static open(fragment){if(ModalHandler.exists()){return;}
let container=document.createElement("div");container.id="modal-container";container.appendChild(document.importNode(fragment,true));document.body.appendChild(container);let closeButton=document.querySelector("a.btn-close-modal");if(closeButton!==null){closeButton.onclick=(event)=>{event.preventDefault();ModalHandler.close();};}}
static close(){let container=document.getElementById("modal-container");if(container!==null){container.parentNode.removeChild(container);}}}
class NavHandler{showKeyboardShortcuts(){let template=document.getElementById("keyboard-shortcuts");if(template!==null){ModalHandler.open(template.content);}}
markPageAsRead(){let items=DomHelper.getVisibleElements(".items .item");let entryIDs=[];items.forEach((element)=>{element.classList.add("item-status-read");entryIDs.push(parseInt(element.dataset.id,10));});if(entryIDs.length>0){EntryHandler.updateEntriesStatus(entryIDs,"read",()=>{this.goToPage("next",true);});}}
saveEntry(){if(this.isListView()){let currentItem=document.querySelector(".current-item");if(currentItem!==null){let saveLink=currentItem.querySelector("a[data-save-entry]");if(saveLink){EntryHandler.saveEntry(saveLink);}}}else{let saveLink=document.querySelector("a[data-save-entry]");if(saveLink){EntryHandler.saveEntry(saveLink);}}}
fetchOriginalContent(){if(!this.isListView()){let link=document.querySelector("a[data-fetch-content-entry]");if(link){EntryHandler.fetchOriginalContent(link);}}}
toggleEntryStatus(){let currentItem=document.querySelector(".current-item");if(currentItem!==null){this.goToNextListItem();EntryHandler.toggleEntryStatus(currentItem);}}
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){DomHelper.openNewTab(entryLink.getAttribute("href"));return;}
let currentItemOriginalLink=document.querySelector(".current-item a[data-original-link]");if(currentItemOriginalLink!==null){DomHelper.openNewTab(currentItemOriginalLink.getAttribute("href"));let currentItem=document.querySelector(".current-item");this.goToNextListItem();EntryHandler.markEntryAsRead(currentItem);}}
openSelectedItem(){let currentItemLink=document.querySelector(".current-item .item-title a");if(currentItemLink!==null){window.location.href=currentItemLink.getAttribute("href");}}
goToPage(page,fallbackSelf){let element=document.querySelector("a[data-page="+page+"]");if(element){document.location.href=element.href;}else if(fallbackSelf){window.location.reload();}}
goToPrevious(){if(this.isListView()){this.goToPreviousListItem();}else{this.goToPage("previous");}}
goToNext(){if(this.isListView()){this.goToNextListItem();}else{this.goToPage("next");}}
goToPreviousListItem(){let items=DomHelper.getVisibleElements(".items .item");if(items.length===0){return;}
if(document.querySelector(".current-item")===null){items[0].classList.add("current-item");return;}
for(let i=0;i<items.length;i++){if(items[i].classList.contains("current-item")){items[i].classList.remove("current-item");if(i-1>=0){items[i-1].classList.add("current-item");DomHelper.scrollPageTo(items[i-1]);}
break;}}}
goToNextListItem(){let currentItem=document.querySelector(".current-item");let items=DomHelper.getVisibleElements(".items .item");if(items.length===0){return;}
if(currentItem===null){items[0].classList.add("current-item");return;}
for(let i=0;i<items.length;i++){if(items[i].classList.contains("current-item")){items[i].classList.remove("current-item");if(i+1<items.length){items[i+1].classList.add("current-item");DomHelper.scrollPageTo(items[i+1]);}
break;}}}
isListView(){return document.querySelector(".items")!==null;}}
document.addEventListener("DOMContentLoaded",function(){FormHandler.handleSubmitButtons();let touchHandler=new TouchHandler();touchHandler.listen();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"));keyboardHandler.on("g s",()=>navHandler.goToPage("settings"));keyboardHandler.on("ArrowLeft",()=>navHandler.goToPrevious());keyboardHandler.on("ArrowRight",()=>navHandler.goToNext());keyboardHandler.on("j",()=>navHandler.goToPrevious());keyboardHandler.on("p",()=>navHandler.goToPrevious());keyboardHandler.on("k",()=>navHandler.goToNext());keyboardHandler.on("n",()=>navHandler.goToNext());keyboardHandler.on("h",()=>navHandler.goToPage("previous"));keyboardHandler.on("l",()=>navHandler.goToPage("next"));keyboardHandler.on("o",()=>navHandler.openSelectedItem());keyboardHandler.on("v",()=>navHandler.openOriginalLink());keyboardHandler.on("m",()=>navHandler.toggleEntryStatus());keyboardHandler.on("A",()=>navHandler.markPageAsRead());keyboardHandler.on("s",()=>navHandler.saveEntry());keyboardHandler.on("d",()=>navHandler.fetchOriginalContent());keyboardHandler.on("f",()=>navHandler.toggleBookmark());keyboardHandler.on("?",()=>navHandler.showKeyboardShortcuts());keyboardHandler.on("Escape",()=>ModalHandler.close());keyboardHandler.listen();let mouseHandler=new MouseHandler();mouseHandler.onClick("a[data-save-entry]",(event)=>{event.preventDefault();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);});mouseHandler.onClick("a[data-on-click=markPageAsRead]",()=>navHandler.markPageAsRead());mouseHandler.onClick("a[data-confirm]",(event)=>{(new ConfirmHandler()).handle(event);});if(document.documentElement.clientWidth<600){let menuHandler=new MenuHandler();mouseHandler.onClick(".logo",()=>menuHandler.toggleMainMenu());mouseHandler.onClick(".header nav li",(event)=>menuHandler.clickMenuListItem(event));}});})();`,
}
var JavascriptChecksums = map[string]string{
"app": "36dfcfb33ddc3f75f701fc4353873e2ce6da813dbfdd3b37100a4475a32b0545",
}

748
ui/static/js/app.js Normal file
View file

@ -0,0 +1,748 @@
/*jshint esversion: 6 */
(function() {
'use strict';
class DomHelper {
static isVisible(element) {
return element.offsetParent !== null;
}
static openNewTab(url) {
let win = window.open(url, "_blank");
win.focus();
}
static scrollPageTo(element) {
let windowScrollPosition = window.pageYOffset;
let windowHeight = document.documentElement.clientHeight;
let viewportPosition = windowScrollPosition + windowHeight;
let itemBottomPosition = element.offsetTop + element.offsetHeight;
if (viewportPosition - itemBottomPosition < 0 || viewportPosition - element.offsetTop > windowHeight) {
window.scrollTo(0, element.offsetTop - 10);
}
}
static getVisibleElements(selector) {
let elements = document.querySelectorAll(selector);
let result = [];
for (let i = 0; i < elements.length; i++) {
if (this.isVisible(elements[i])) {
result.push(elements[i]);
}
}
return result;
}
}
class TouchHandler {
constructor() {
this.reset();
}
reset() {
this.touch = {
start: {x: -1, y: -1},
move: {x: -1, y: -1},
element: null
};
}
calculateDistance() {
if (this.touch.start.x >= -1 && this.touch.move.x >= -1) {
let horizontalDistance = Math.abs(this.touch.move.x - this.touch.start.x);
let verticalDistance = Math.abs(this.touch.move.y - this.touch.start.y);
if (horizontalDistance > 30 && verticalDistance < 70) {
return this.touch.move.x - this.touch.start.x;
}
}
return 0;
}
findElement(element) {
if (element.classList.contains("touch-item")) {
return element;
}
for (; element && element !== document; element = element.parentNode) {
if (element.classList.contains("touch-item")) {
return element;
}
}
return null;
}
onTouchStart(event) {
if (event.touches === undefined || event.touches.length !== 1) {
return;
}
this.reset();
this.touch.start.x = event.touches[0].clientX;
this.touch.start.y = event.touches[0].clientY;
this.touch.element = this.findElement(event.touches[0].target);
}
onTouchMove(event) {
if (event.touches === undefined || event.touches.length !== 1 || this.element === null) {
return;
}
this.touch.move.x = event.touches[0].clientX;
this.touch.move.y = event.touches[0].clientY;
let distance = this.calculateDistance();
let absDistance = Math.abs(distance);
if (absDistance > 0) {
let opacity = 1 - (absDistance > 75 ? 0.9 : absDistance / 75 * 0.9);
let tx = distance > 75 ? 75 : (distance < -75 ? -75 : distance);
this.touch.element.style.opacity = opacity;
this.touch.element.style.transform = "translateX(" + tx + "px)";
}
}
onTouchEnd(event) {
if (event.touches === undefined) {
return;
}
if (this.touch.element !== null) {
let distance = Math.abs(this.calculateDistance());
if (distance > 75) {
EntryHandler.toggleEntryStatus(this.touch.element);
this.touch.element.style.opacity = 1;
this.touch.element.style.transform = "none";
}
}
this.reset();
}
listen() {
let elements = document.querySelectorAll(".touch-item");
elements.forEach((element) => {
element.addEventListener("touchstart", (e) => this.onTouchStart(e), false);
element.addEventListener("touchmove", (e) => this.onTouchMove(e), false);
element.addEventListener("touchend", (e) => this.onTouchEnd(e), false);
element.addEventListener("touchcancel", () => this.reset(), false);
});
}
}
class KeyboardHandler {
constructor() {
this.queue = [];
this.shortcuts = {};
}
on(combination, callback) {
this.shortcuts[combination] = callback;
}
listen() {
document.onkeydown = (event) => {
if (this.isEventIgnored(event)) {
return;
}
let key = this.getKey(event);
this.queue.push(key);
for (let combination in this.shortcuts) {
let keys = combination.split(" ");
if (keys.every((value, index) => value === this.queue[index])) {
this.queue = [];
this.shortcuts[combination]();
return;
}
if (keys.length === 1 && key === keys[0]) {
this.queue = [];
this.shortcuts[combination]();
return;
}
}
if (this.queue.length >= 2) {
this.queue = [];
}
};
}
isEventIgnored(event) {
return event.target.tagName === "INPUT" || event.target.tagName === "TEXTAREA";
}
getKey(event) {
const mapping = {
'Esc': 'Escape',
'Up': 'ArrowUp',
'Down': 'ArrowDown',
'Left': 'ArrowLeft',
'Right': 'ArrowRight'
};
for (let key in mapping) {
if (mapping.hasOwnProperty(key) && key === event.key) {
return mapping[key];
}
}
return event.key;
}
}
class FormHandler {
static handleSubmitButtons() {
let elements = document.querySelectorAll("form");
elements.forEach((element) => {
element.onsubmit = () => {
let button = document.querySelector("button");
if (button) {
button.innerHTML = button.dataset.labelLoading;
button.disabled = true;
}
};
});
}
}
class MouseHandler {
onClick(selector, callback) {
let elements = document.querySelectorAll(selector);
elements.forEach((element) => {
element.onclick = (event) => {
event.preventDefault();
callback(event);
};
});
}
}
class RequestBuilder {
constructor(url) {
this.callback = null;
this.url = url;
this.options = {
method: "POST",
cache: "no-cache",
credentials: "include",
body: null,
headers: new Headers({
"Content-Type": "application/json",
"X-Csrf-Token": this.getCsrfToken()
})
};
}
withBody(body) {
this.options.body = JSON.stringify(body);
return this;
}
withCallback(callback) {
this.callback = callback;
return this;
}
getCsrfToken() {
let element = document.querySelector("meta[name=X-CSRF-Token]");
if (element !== null) {
return element.getAttribute("value");
}
return "";
}
execute() {
fetch(new Request(this.url, this.options)).then((response) => {
if (this.callback) {
this.callback(response);
}
});
}
}
class UnreadCounterHandler {
static decrement(n) {
this.updateValue((current) => {
return current - n;
});
}
static increment(n) {
this.updateValue((current) => {
return current + n;
});
}
static updateValue(callback) {
let counterElements = document.querySelectorAll("span.unread-counter");
counterElements.forEach((element) => {
let oldValue = parseInt(element.textContent, 10);
element.innerHTML = callback(oldValue);
});
}
}
class EntryHandler {
static updateEntriesStatus(entryIDs, status, callback) {
let url = document.body.dataset.entriesStatusUrl;
let request = new RequestBuilder(url);
request.withBody({entry_ids: entryIDs, status: status});
request.withCallback(callback);
request.execute();
}
static toggleEntryStatus(element) {
let entryID = parseInt(element.dataset.id, 10);
let statuses = {read: "unread", unread: "read"};
for (let currentStatus in statuses) {
let newStatus = statuses[currentStatus];
if (element.classList.contains("item-status-" + currentStatus)) {
element.classList.remove("item-status-" + currentStatus);
element.classList.add("item-status-" + newStatus);
this.updateEntriesStatus([entryID], newStatus);
if (newStatus === "read") {
UnreadCounterHandler.decrement(1);
} else {
UnreadCounterHandler.increment(1);
}
break;
}
}
}
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");
element.classList.add("item-status-read");
let entryID = parseInt(element.dataset.id, 10);
this.updateEntriesStatus([entryID], "read");
}
}
static saveEntry(element) {
if (element.dataset.completed) {
return;
}
element.innerHTML = element.dataset.labelLoading;
let request = new RequestBuilder(element.dataset.saveUrl);
request.withCallback(() => {
element.innerHTML = element.dataset.labelDone;
element.dataset.completed = true;
});
request.execute();
}
static fetchOriginalContent(element) {
if (element.dataset.completed) {
return;
}
element.innerHTML = element.dataset.labelLoading;
let request = new RequestBuilder(element.dataset.fetchContentUrl);
request.withCallback((response) => {
element.innerHTML = element.dataset.labelDone;
element.dataset.completed = true;
response.json().then((data) => {
if (data.hasOwnProperty("content")) {
document.querySelector(".entry-content").innerHTML = data.content;
}
});
});
request.execute();
}
}
class ConfirmHandler {
remove(url) {
let request = new RequestBuilder(url);
request.withCallback(() => window.location.reload());
request.execute();
}
handle(event) {
let questionElement = document.createElement("span");
let linkElement = event.target;
let containerElement = linkElement.parentNode;
linkElement.style.display = "none";
let yesElement = document.createElement("a");
yesElement.href = "#";
yesElement.appendChild(document.createTextNode(linkElement.dataset.labelYes));
yesElement.onclick = (event) => {
event.preventDefault();
let loadingElement = document.createElement("span");
loadingElement.className = "loading";
loadingElement.appendChild(document.createTextNode(linkElement.dataset.labelLoading));
questionElement.remove();
containerElement.appendChild(loadingElement);
this.remove(linkElement.dataset.url);
};
let noElement = document.createElement("a");
noElement.href = "#";
noElement.appendChild(document.createTextNode(linkElement.dataset.labelNo));
noElement.onclick = (event) => {
event.preventDefault();
linkElement.style.display = "inline";
questionElement.remove();
};
questionElement.className = "confirm";
questionElement.appendChild(document.createTextNode(linkElement.dataset.labelQuestion + " "));
questionElement.appendChild(yesElement);
questionElement.appendChild(document.createTextNode(", "));
questionElement.appendChild(noElement);
containerElement.appendChild(questionElement);
}
}
class MenuHandler {
clickMenuListItem(event) {
let element = event.target;
if (element.tagName === "A") {
window.location.href = element.getAttribute("href");
} else {
window.location.href = element.querySelector("a").getAttribute("href");
}
}
toggleMainMenu() {
let menu = document.querySelector(".header nav ul");
if (DomHelper.isVisible(menu)) {
menu.style.display = "none";
} else {
menu.style.display = "block";
}
}
}
class ModalHandler {
static exists() {
return document.getElementById("modal-container") !== null;
}
static open(fragment) {
if (ModalHandler.exists()) {
return;
}
let container = document.createElement("div");
container.id = "modal-container";
container.appendChild(document.importNode(fragment, true));
document.body.appendChild(container);
let closeButton = document.querySelector("a.btn-close-modal");
if (closeButton !== null) {
closeButton.onclick = (event) => {
event.preventDefault();
ModalHandler.close();
};
}
}
static close() {
let container = document.getElementById("modal-container");
if (container !== null) {
container.parentNode.removeChild(container);
}
}
}
class NavHandler {
showKeyboardShortcuts() {
let template = document.getElementById("keyboard-shortcuts");
if (template !== null) {
ModalHandler.open(template.content);
}
}
markPageAsRead() {
let items = DomHelper.getVisibleElements(".items .item");
let entryIDs = [];
items.forEach((element) => {
element.classList.add("item-status-read");
entryIDs.push(parseInt(element.dataset.id, 10));
});
if (entryIDs.length > 0) {
EntryHandler.updateEntriesStatus(entryIDs, "read", () => {
// This callback make sure the Ajax request reach the server before we reload the page.
this.goToPage("next", true);
});
}
}
saveEntry() {
if (this.isListView()) {
let currentItem = document.querySelector(".current-item");
if (currentItem !== null) {
let saveLink = currentItem.querySelector("a[data-save-entry]");
if (saveLink) {
EntryHandler.saveEntry(saveLink);
}
}
} else {
let saveLink = document.querySelector("a[data-save-entry]");
if (saveLink) {
EntryHandler.saveEntry(saveLink);
}
}
}
fetchOriginalContent() {
if (! this.isListView()){
let link = document.querySelector("a[data-fetch-content-entry]");
if (link) {
EntryHandler.fetchOriginalContent(link);
}
}
}
toggleEntryStatus() {
let currentItem = document.querySelector(".current-item");
if (currentItem !== null) {
// The order is important here,
// On the unread page, the read item will be hidden.
this.goToNextListItem();
EntryHandler.toggleEntryStatus(currentItem);
}
}
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) {
DomHelper.openNewTab(entryLink.getAttribute("href"));
return;
}
let currentItemOriginalLink = document.querySelector(".current-item a[data-original-link]");
if (currentItemOriginalLink !== null) {
DomHelper.openNewTab(currentItemOriginalLink.getAttribute("href"));
// Move to the next item and if we are on the unread page mark this item as read.
let currentItem = document.querySelector(".current-item");
this.goToNextListItem();
EntryHandler.markEntryAsRead(currentItem);
}
}
openSelectedItem() {
let currentItemLink = document.querySelector(".current-item .item-title a");
if (currentItemLink !== null) {
window.location.href = currentItemLink.getAttribute("href");
}
}
/**
* @param {string} page Page to redirect to.
* @param {boolean} fallbackSelf Refresh actual page if the page is not found.
*/
goToPage(page, fallbackSelf) {
let element = document.querySelector("a[data-page=" + page + "]");
if (element) {
document.location.href = element.href;
} else if (fallbackSelf) {
window.location.reload();
}
}
goToPrevious() {
if (this.isListView()) {
this.goToPreviousListItem();
} else {
this.goToPage("previous");
}
}
goToNext() {
if (this.isListView()) {
this.goToNextListItem();
} else {
this.goToPage("next");
}
}
goToPreviousListItem() {
let items = DomHelper.getVisibleElements(".items .item");
if (items.length === 0) {
return;
}
if (document.querySelector(".current-item") === null) {
items[0].classList.add("current-item");
return;
}
for (let i = 0; i < items.length; i++) {
if (items[i].classList.contains("current-item")) {
items[i].classList.remove("current-item");
if (i - 1 >= 0) {
items[i - 1].classList.add("current-item");
DomHelper.scrollPageTo(items[i - 1]);
}
break;
}
}
}
goToNextListItem() {
let currentItem = document.querySelector(".current-item");
let items = DomHelper.getVisibleElements(".items .item");
if (items.length === 0) {
return;
}
if (currentItem === null) {
items[0].classList.add("current-item");
return;
}
for (let i = 0; i < items.length; i++) {
if (items[i].classList.contains("current-item")) {
items[i].classList.remove("current-item");
if (i + 1 < items.length) {
items[i + 1].classList.add("current-item");
DomHelper.scrollPageTo(items[i + 1]);
}
break;
}
}
}
isListView() {
return document.querySelector(".items") !== null;
}
}
document.addEventListener("DOMContentLoaded", function() {
FormHandler.handleSubmitButtons();
let touchHandler = new TouchHandler();
touchHandler.listen();
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"));
keyboardHandler.on("g s", () => navHandler.goToPage("settings"));
keyboardHandler.on("ArrowLeft", () => navHandler.goToPrevious());
keyboardHandler.on("ArrowRight", () => navHandler.goToNext());
keyboardHandler.on("j", () => navHandler.goToPrevious());
keyboardHandler.on("p", () => navHandler.goToPrevious());
keyboardHandler.on("k", () => navHandler.goToNext());
keyboardHandler.on("n", () => navHandler.goToNext());
keyboardHandler.on("h", () => navHandler.goToPage("previous"));
keyboardHandler.on("l", () => navHandler.goToPage("next"));
keyboardHandler.on("o", () => navHandler.openSelectedItem());
keyboardHandler.on("v", () => navHandler.openOriginalLink());
keyboardHandler.on("m", () => navHandler.toggleEntryStatus());
keyboardHandler.on("A", () => navHandler.markPageAsRead());
keyboardHandler.on("s", () => navHandler.saveEntry());
keyboardHandler.on("d", () => navHandler.fetchOriginalContent());
keyboardHandler.on("f", () => navHandler.toggleBookmark());
keyboardHandler.on("?", () => navHandler.showKeyboardShortcuts());
keyboardHandler.on("Escape", () => ModalHandler.close());
keyboardHandler.listen();
let mouseHandler = new MouseHandler();
mouseHandler.onClick("a[data-save-entry]", (event) => {
event.preventDefault();
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);
});
mouseHandler.onClick("a[data-on-click=markPageAsRead]", () => navHandler.markPageAsRead());
mouseHandler.onClick("a[data-confirm]", (event) => {
(new ConfirmHandler()).handle(event);
});
if (document.documentElement.clientWidth < 600) {
let menuHandler = new MenuHandler();
mouseHandler.onClick(".logo", () => menuHandler.toggleMainMenu());
mouseHandler.onClick(".header nav li", (event) => menuHandler.clickMenuListItem(event));
}
});
})();

145
ui/subscription.go Normal file
View file

@ -0,0 +1,145 @@
// 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 ui
import (
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/reader/subscription"
"github.com/miniflux/miniflux/ui/form"
)
// Bookmarklet prefill the form to add a subscription from the URL provided by the bookmarklet.
func (c *Controller) Bookmarklet(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
args, err := c.getSubscriptionFormTemplateArgs(ctx, user)
if err != nil {
response.HTML().ServerError(err)
return
}
bookmarkletURL := request.QueryStringParam("uri", "")
response.HTML().Render("add_subscription", args.Merge(tplParams{
"form": &form.SubscriptionForm{URL: bookmarkletURL},
}))
}
// AddSubscription shows the form to add a new feed.
func (c *Controller) AddSubscription(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
args, err := c.getSubscriptionFormTemplateArgs(ctx, user)
if err != nil {
response.HTML().ServerError(err)
return
}
response.HTML().Render("add_subscription", args)
}
// SubmitSubscription try to find a feed from the URL provided by the user.
func (c *Controller) SubmitSubscription(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
args, err := c.getSubscriptionFormTemplateArgs(ctx, user)
if err != nil {
response.HTML().ServerError(err)
return
}
subscriptionForm := form.NewSubscriptionForm(request.Request())
if err := subscriptionForm.Validate(); err != nil {
response.HTML().Render("add_subscription", args.Merge(tplParams{
"form": subscriptionForm,
"errorMessage": err.Error(),
}))
return
}
subscriptions, err := subscription.FindSubscriptions(subscriptionForm.URL)
if err != nil {
logger.Error("[Controller:SubmitSubscription] %v", err)
response.HTML().Render("add_subscription", args.Merge(tplParams{
"form": subscriptionForm,
"errorMessage": err,
}))
return
}
logger.Info("[UI:SubmitSubscription] %s", subscriptions)
n := len(subscriptions)
switch {
case n == 0:
response.HTML().Render("add_subscription", args.Merge(tplParams{
"form": subscriptionForm,
"errorMessage": "Unable to find any subscription.",
}))
case n == 1:
feed, err := c.feedHandler.CreateFeed(user.ID, subscriptionForm.CategoryID, subscriptions[0].URL, subscriptionForm.Crawler)
if err != nil {
response.HTML().Render("add_subscription", args.Merge(tplParams{
"form": subscriptionForm,
"errorMessage": err,
}))
return
}
response.Redirect(ctx.Route("feedEntries", "feedID", feed.ID))
case n > 1:
response.HTML().Render("choose_subscription", args.Merge(tplParams{
"categoryID": subscriptionForm.CategoryID,
"subscriptions": subscriptions,
}))
}
}
// ChooseSubscription shows a page to choose a subscription.
func (c *Controller) ChooseSubscription(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
args, err := c.getSubscriptionFormTemplateArgs(ctx, user)
if err != nil {
response.HTML().ServerError(err)
return
}
subscriptionForm := form.NewSubscriptionForm(request.Request())
if err := subscriptionForm.Validate(); err != nil {
response.HTML().Render("add_subscription", args.Merge(tplParams{
"form": subscriptionForm,
"errorMessage": err.Error(),
}))
return
}
feed, err := c.feedHandler.CreateFeed(user.ID, subscriptionForm.CategoryID, subscriptionForm.URL, subscriptionForm.Crawler)
if err != nil {
response.HTML().Render("add_subscription", args.Merge(tplParams{
"form": subscriptionForm,
"errorMessage": err,
}))
return
}
response.Redirect(ctx.Route("feedEntries", "feedID", feed.ID))
}
func (c *Controller) getSubscriptionFormTemplateArgs(ctx *handler.Context, user *model.User) (tplParams, error) {
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
return nil, err
}
categories, err := c.store.Categories(user.ID)
if err != nil {
return nil, err
}
args["categories"] = categories
args["menu"] = "feeds"
return args, nil
}

49
ui/unread.go Normal file
View file

@ -0,0 +1,49 @@
// 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 ui
import (
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/model"
)
// ShowUnreadPage render the page with all unread entries.
func (c *Controller) ShowUnreadPage(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
offset := request.QueryIntegerParam("offset", 0)
builder := c.store.NewEntryQueryBuilder(user.ID)
builder.WithStatus(model.EntryStatusUnread)
countUnread, err := builder.CountEntries()
if err != nil {
response.HTML().ServerError(err)
return
}
if offset >= countUnread {
offset = 0
}
builder = c.store.NewEntryQueryBuilder(user.ID)
builder.WithStatus(model.EntryStatusUnread)
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
}
response.HTML().Render("unread", tplParams{
"user": user,
"countUnread": countUnread,
"entries": entries,
"pagination": c.getPagination(ctx.Route("unread"), countUnread, offset),
"menu": "unread",
"csrf": ctx.CSRF(),
})
}

238
ui/user.go Normal file
View file

@ -0,0 +1,238 @@
// 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 ui
import (
"errors"
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/ui/form"
)
// ShowUsers shows the list of users.
func (c *Controller) ShowUsers(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
if !user.IsAdmin {
response.HTML().Forbidden()
return
}
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
response.HTML().ServerError(err)
return
}
users, err := c.store.Users()
if err != nil {
response.HTML().ServerError(err)
return
}
response.HTML().Render("users", args.Merge(tplParams{
"users": users,
"menu": "settings",
}))
}
// CreateUser shows the user creation form.
func (c *Controller) CreateUser(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
if !user.IsAdmin {
response.HTML().Forbidden()
return
}
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
response.HTML().ServerError(err)
return
}
response.HTML().Render("create_user", args.Merge(tplParams{
"menu": "settings",
"form": &form.UserForm{},
}))
}
// SaveUser validate and save the new user into the database.
func (c *Controller) SaveUser(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
if !user.IsAdmin {
response.HTML().Forbidden()
return
}
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
response.HTML().ServerError(err)
return
}
userForm := form.NewUserForm(request.Request())
if err := userForm.ValidateCreation(); err != nil {
response.HTML().Render("create_user", args.Merge(tplParams{
"menu": "settings",
"form": userForm,
"errorMessage": err.Error(),
}))
return
}
if c.store.UserExists(userForm.Username) {
response.HTML().Render("create_user", args.Merge(tplParams{
"menu": "settings",
"form": userForm,
"errorMessage": "This user already exists.",
}))
return
}
newUser := userForm.ToUser()
if err := c.store.CreateUser(newUser); err != nil {
logger.Error("[Controller:SaveUser] %v", err)
response.HTML().Render("edit_user", args.Merge(tplParams{
"menu": "settings",
"form": userForm,
"errorMessage": "Unable to create this user.",
}))
return
}
response.Redirect(ctx.Route("users"))
}
// EditUser shows the form to edit a user.
func (c *Controller) EditUser(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
if !user.IsAdmin {
response.HTML().Forbidden()
return
}
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
response.HTML().ServerError(err)
return
}
selectedUser, err := c.getUserFromURL(ctx, request, response)
if err != nil {
return
}
response.HTML().Render("edit_user", args.Merge(tplParams{
"menu": "settings",
"selected_user": selectedUser,
"form": &form.UserForm{
Username: selectedUser.Username,
IsAdmin: selectedUser.IsAdmin,
},
}))
}
// UpdateUser validate and update a user.
func (c *Controller) UpdateUser(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
if !user.IsAdmin {
response.HTML().Forbidden()
return
}
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
response.HTML().ServerError(err)
return
}
selectedUser, err := c.getUserFromURL(ctx, request, response)
if err != nil {
return
}
userForm := form.NewUserForm(request.Request())
if err := userForm.ValidateModification(); err != nil {
response.HTML().Render("edit_user", args.Merge(tplParams{
"menu": "settings",
"selected_user": selectedUser,
"form": userForm,
"errorMessage": err.Error(),
}))
return
}
if c.store.AnotherUserExists(selectedUser.ID, userForm.Username) {
response.HTML().Render("edit_user", args.Merge(tplParams{
"menu": "settings",
"selected_user": selectedUser,
"form": userForm,
"errorMessage": "This user already exists.",
}))
return
}
userForm.Merge(selectedUser)
if err := c.store.UpdateUser(selectedUser); err != nil {
logger.Error("[Controller:UpdateUser] %v", err)
response.HTML().Render("edit_user", args.Merge(tplParams{
"menu": "settings",
"selected_user": selectedUser,
"form": userForm,
"errorMessage": "Unable to update this user.",
}))
return
}
response.Redirect(ctx.Route("users"))
}
// RemoveUser deletes a user from the database.
func (c *Controller) RemoveUser(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
if !user.IsAdmin {
response.HTML().Forbidden()
return
}
selectedUser, err := c.getUserFromURL(ctx, request, response)
if err != nil {
return
}
if err := c.store.RemoveUser(selectedUser.ID); err != nil {
response.HTML().ServerError(err)
return
}
response.Redirect(ctx.Route("users"))
}
func (c *Controller) getUserFromURL(ctx *handler.Context, request *handler.Request, response *handler.Response) (*model.User, error) {
userID, err := request.IntegerParam("userID")
if err != nil {
response.HTML().BadRequest(err)
return nil, err
}
user, err := c.store.UserByID(userID)
if err != nil {
response.HTML().ServerError(err)
return nil, err
}
if user == nil {
response.HTML().NotFound()
return nil, errors.New("User not found")
}
return user, nil
}