Compare commits

...

53 Commits

Author SHA1 Message Date
Frédéric Guillot 336d44b00e Update ChangeLog 2018-04-07 15:47:18 -07:00
Frédéric Guillot 46d67acf22 Avoid unread counter to be off by one 2018-04-07 14:20:42 -07:00
Frédéric Guillot 702256bcc0 Add unit test for comments url and French translation 2018-04-07 13:56:11 -07:00
Ben Brooks 538d08c16c Add CommentsURL to entry 2018-04-07 13:50:45 -07:00
Frédéric Guillot 449020c1e8 Update .gitignore 2018-03-18 20:48:58 -07:00
MoritzFago c811849771 Add FreeBSD build target 2018-03-18 20:44:13 -07:00
Frédéric Guillot 6ea4da3bce Handle RSS author elements with inner HTML 2018-03-18 11:57:46 -07:00
Frédéric Guillot 34cdffda88 Fix typo in translations 2018-03-17 13:39:08 -07:00
Daan Sprenkels b1da081ae6 Add dutch translations 2018-03-17 13:33:02 -07:00
Frédéric Guillot 482785c5e6 Convert enclosure size field to bigint 2018-03-14 20:09:06 -07:00
Frédéric Guillot fec391a336 Switch Travis to Go 1.10 2018-03-14 18:51:41 -07:00
Frédéric Guillot ec08f45bf5 Fix broken OPML import with Go 1.10 2018-03-14 18:50:06 -07:00
Frédéric Guillot dd68181a83 Update ChangeLog 2018-03-05 21:42:52 -08:00
Frédéric Guillot a8be61cdbb Filter the list of timezones 2018-03-04 17:38:08 -08:00
Frédéric Guillot 609c57332e Show last login and session creation date in current timezone 2018-03-04 17:04:31 -08:00
Frédéric Guillot 5185bf0c7e Fix typo in edit user template 2018-03-01 23:06:28 -08:00
Frédéric Guillot cbd273da2b Improve for/range loop to avoid linter error 2018-03-01 21:43:25 -08:00
Frédéric Guillot 0c7039de0e Entries date should contains user timezone (API) 2018-03-01 21:43:04 -08:00
Frédéric Guillot f110384f11 Improve parser error messages 2018-02-27 21:19:59 -08:00
Frédéric Guillot 953d0a2dc0 Support localized feed errors generated by background workers 2018-02-27 21:08:32 -08:00
ReVanTis, Zhao 9694861cb6 Add Simplified Chinese Localization 2018-02-27 19:58:51 -08:00
Nicolas Carlier 34ce114231 Add Nunux Keeper integration 2018-02-25 11:49:08 -08:00
Frédéric Guillot 3030145b30 Remove parentheses around feed error messages 2018-02-23 19:58:08 -08:00
Frédéric Guillot a9f0fdaf22 Print info message if DATABASE_URL is not set 2018-02-23 18:26:34 -08:00
Frédéric Guillot 73a0a25b6c Update ChangeLog 2018-02-19 20:15:57 -08:00
Sebastian Korotkiewicz 2786b8f163 Update Polish translation 2018-02-19 15:15:03 -08:00
Frédéric Guillot 9292d5d604 Handle Atom feeds with HTML title 2018-02-17 12:21:58 -08:00
Frédéric Guillot 3ba280e10c There are more than 2 plurals in Polish 2018-02-17 11:43:37 -08:00
Sebastian Korotkiewicz 4eceeaaca4 Add Polish translation 2018-02-17 11:29:14 -08:00
Frédéric Guillot 205aef595b Change color of <q> tags for black theme 2018-02-10 22:35:02 -08:00
Frédéric Guillot a006a93a04 Add indexes 2018-02-10 22:30:43 -08:00
stratmaster 30e80c675e Update German translation 2018-02-09 18:20:06 -08:00
Frédéric Guillot dda9114692 Improve error handling for HTTP client 2018-02-08 18:16:54 -08:00
stratmaster 16c2dc4a8c Add missing German string 2018-02-08 12:41:14 -08:00
Frédéric Guillot 7b0bfd9308 Strip invalid XML characters to avoid parsing errors 2018-02-07 20:57:56 -08:00
Frédéric Guillot c6fd9eb9b1 Remove period for feed errors 2018-02-07 19:10:36 -08:00
Frédéric Guillot 0fb87eba3f Improve error handling when the response is empty 2018-02-07 18:47:47 -08:00
stratmaster 1e70ca1a19 Add missing French translation 2018-02-07 18:05:58 -08:00
Frédéric Guillot ccb7306dcb Update ChangeLog 2018-02-05 20:29:42 -08:00
Frédéric Guillot 12ff562d31 Add support for Let's Encrypt http-01 challenge 2018-02-04 18:05:45 -08:00
Frédéric Guillot 3884a33b36 Move template functions outside engine (refactoring) 2018-02-04 15:45:07 -08:00
Frédéric Guillot b5b1930599 Take timezone into consideration when calculating relative time 2018-02-04 14:37:42 -08:00
Frédéric Guillot 61bc012a62 Add support for HTTP Strict Transport Security header 2018-02-03 15:54:02 -08:00
Frédéric Guillot 9c42997209 Add support for base URLs with subfolders 2018-02-03 15:33:17 -08:00
stratmaster 78385a351e Update German translation 2018-02-01 20:46:53 -08:00
Frédéric Guillot 32cec5bad0 Add missing about menu in settings 2018-01-31 22:03:21 -08:00
Frédéric Guillot b78172033f Show API URL endpoints in user interface 2018-01-31 21:57:20 -08:00
Frédéric Guillot b0442e0277 Do not update entry date while refreshing a feed
We do not update the published date because some feeds do not contains any date,
in this case it default to time.Now(), which could change the order of items on
the history page.
2018-01-29 21:07:55 -08:00
Frédéric Guillot bca717873e Enable debug mode for integration tests 2018-01-29 20:58:11 -08:00
Frédéric Guillot 23d2d9109f Add the possiblity to enable debug mode with an environment variable 2018-01-29 20:54:38 -08:00
Frédéric Guillot c8138351c9 Keep code base tidy 2018-01-29 20:47:09 -08:00
Rogier Lommers 24a2f472ec Add flag to enable debug logging 2018-01-29 20:34:00 -08:00
Mahendra Kalkura 40eb1b10fe Improve unread counter updates 2018-01-25 20:13:38 -08:00
135 changed files with 4944 additions and 788 deletions
+1 -4
View File
@@ -1,4 +1 @@
miniflux-linux-amd64
miniflux-linux-arm*
miniflux-darwin-amd64
miniflux-test
miniflux-*
+1 -1
View File
@@ -6,7 +6,7 @@ addons:
postgresql: "9.4"
language: go
go:
- 1.9
- "1.10"
before_install:
- npm install -g jshint
- go get -u github.com/golang/lint/golint
+51
View File
@@ -1,3 +1,54 @@
Version 2.0.5 (Apr 7, 2018)
---------------------------
* Avoid unread counter to be off by one when reading an entry
* Add Comments URL to entries
* Add FreeBSD build target
* Handle RSS author elements with inner HTML
* Fix typo in translations
* Add Dutch translation
* Convert enclosure size field to bigint
* Switch CI to Go v1.10
* Fix broken OPML import when compiling with Go 1.10
Version 2.0.4 (Mar 5, 2018)
---------------------------
* Add Simplified Chinese translation
* Add Nunux Keeper integration
* Filter the list of timezones
* Add timezone to entries dates for REST and Fever API
* Show last login and session creation date in current timezone
* Fix typo in edit user template
* Improve parser error messages
* Remove parentheses around feed error messages
* Support localized feed errors generated by background workers
* Print info message if DATABASE_URL is not set
Version 2.0.3 (Feb 19, 2018)
----------------------------
* Add Polish translation
* Change color of <q> tags for black theme
* Add database indexes (don't forget to run database migrations)
* Handle Atom feeds with HTML title
* Strip invalid XML characters to avoid parsing errors
* Improve error handling for HTTP client
Version 2.0.2 (Feb 5, 2018)
---------------------------
* Add support for Let's Encrypt http-01 challenge
* Move template functions outside engine (refactoring)
* Take timezone into consideration when calculating relative time
* Add support for HTTP Strict Transport Security header
* Add support for base URLs with subfolders
* Add missing about menu in settings
* Show API URL endpoints in user interface
* Do not update entry date while refreshing a feed
* Add flag to toggle debug logging
* Improve unread counter updates
Version 2.0.1 (Jan 22, 2018)
----------------------------
Generated
+68 -10
View File
@@ -34,7 +34,11 @@
[[projects]]
branch = "master"
name = "github.com/lib/pq"
packages = [".","hstore","oid"]
packages = [
".",
"hstore",
"oid"
]
revision = "83612a56d3dd153a94a629cd64925371c9adad78"
[[projects]]
@@ -45,13 +49,23 @@
[[projects]]
name = "github.com/tdewolff/minify"
packages = [".","css","js"]
packages = [
".",
"css",
"js"
]
revision = "222672169d634c440a73abc47685074e1a9daa60"
version = "v2.3.4"
[[projects]]
name = "github.com/tdewolff/parse"
packages = [".","buffer","css","js","strconv"]
packages = [
".",
"buffer",
"css",
"js",
"strconv"
]
revision = "639f6272aec6b52094db77b9ec488214b0b4b1a1"
version = "v2.3.2"
@@ -64,36 +78,80 @@
[[projects]]
branch = "master"
name = "golang.org/x/crypto"
packages = ["acme","acme/autocert","bcrypt","blowfish","ssh/terminal"]
revision = "94eea52f7b742c7cbe0b03b22f0c4c8631ece122"
packages = [
"acme",
"acme/autocert",
"bcrypt",
"blowfish",
"ssh/terminal"
]
revision = "1875d0a70c90e57f11972aefd42276df65e895b9"
[[projects]]
branch = "master"
name = "golang.org/x/net"
packages = ["context","context/ctxhttp","html","html/atom","html/charset"]
packages = [
"context",
"context/ctxhttp",
"html",
"html/atom",
"html/charset"
]
revision = "d866cfc389cec985d6fda2859936a575a55a3ab6"
[[projects]]
branch = "master"
name = "golang.org/x/oauth2"
packages = [".","internal"]
packages = [
".",
"internal"
]
revision = "462316686f20eb6df426961c1c131bdaa5dfa68e"
[[projects]]
branch = "master"
name = "golang.org/x/sys"
packages = ["unix","windows"]
packages = [
"unix",
"windows"
]
revision = "571f7bbbe08da2a8955aed9d4db316e78630e9a3"
[[projects]]
branch = "master"
name = "golang.org/x/text"
packages = ["encoding","encoding/charmap","encoding/htmlindex","encoding/internal","encoding/internal/identifier","encoding/japanese","encoding/korean","encoding/simplifiedchinese","encoding/traditionalchinese","encoding/unicode","internal/gen","internal/tag","internal/utf8internal","language","runes","transform","unicode/cldr"]
packages = [
"encoding",
"encoding/charmap",
"encoding/htmlindex",
"encoding/internal",
"encoding/internal/identifier",
"encoding/japanese",
"encoding/korean",
"encoding/simplifiedchinese",
"encoding/traditionalchinese",
"encoding/unicode",
"internal/gen",
"internal/tag",
"internal/utf8internal",
"language",
"runes",
"transform",
"unicode/cldr"
]
revision = "d5a9226ed7dd70cade6ccae9d37517fe14dd9fee"
[[projects]]
name = "google.golang.org/appengine"
packages = ["internal","internal/base","internal/datastore","internal/log","internal/remote_api","internal/urlfetch","urlfetch"]
packages = [
"internal",
"internal/base",
"internal/datastore",
"internal/log",
"internal/remote_api",
"internal/urlfetch",
"urlfetch"
]
revision = "150dc57a1b433e64154302bdc40b6bb8aefa313a"
version = "v1.0.0"
+8 -4
View File
@@ -4,7 +4,7 @@ BUILD_DATE=`date +%FT%T%z`
PKG_LIST := $(shell go list ./... | grep -v /vendor/)
DB_URL := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable
.PHONY: linux linux-arm darwin build run clean test lint integration-test clean-integration-test
.PHONY: linux linux-arm darwin freebsd build run clean test lint integration-test clean-integration-test
linux:
@ go generate
@@ -21,11 +21,15 @@ darwin:
@ go generate
@ GOOS=darwin GOARCH=amd64 go build -ldflags="-X 'github.com/miniflux/miniflux/version.Version=$(VERSION)' -X 'github.com/miniflux/miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-darwin-amd64 main.go
build: linux linux-arm darwin
freebsd:
@ go generate
@ GOOS=freebsd GOARCH=amd64 go build -ldflags="-X 'githug.com/miniflux/miniflux/version.Version=$(VERSION)' -X 'github.com/miniflux/miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-freebsd-amd64 main.go
build: linux linux-arm darwin freebsd
run:
@ go generate
@ go run main.go
@ go run main.go -debug
clean:
@ rm -f $(APP)-*
@@ -42,7 +46,7 @@ integration-test:
DATABASE_URL=$(DB_URL) go run main.go -migrate
DATABASE_URL=$(DB_URL) ADMIN_USERNAME=admin ADMIN_PASSWORD=test123 go run main.go -create-admin
go build -o miniflux-test main.go
DATABASE_URL=$(DB_URL) ./miniflux-test >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
DATABASE_URL=$(DB_URL) ./miniflux-test -debug >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
while ! echo exit | nc localhost 8080; do sleep 1; done >/dev/null
go test -v -tags=integration || cat /tmp/miniflux.log
+2
View File
@@ -100,6 +100,7 @@ func (c *Controller) Users(ctx *handler.Context, request *handler.Request, respo
return
}
users.UseTimezone(ctx.UserTimezone())
response.JSON().Standard(users)
}
@@ -127,6 +128,7 @@ func (c *Controller) UserByID(ctx *handler.Context, request *handler.Request, re
return
}
user.UseTimezone(ctx.UserTimezone())
response.JSON().Standard(user)
}
+8
View File
@@ -8,6 +8,8 @@ import (
"flag"
"fmt"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/daemon"
"github.com/miniflux/miniflux/storage"
@@ -22,9 +24,15 @@ func Parse() {
flagFlushSessions := flag.Bool("flush-sessions", false, "Flush all sessions (disconnect users)")
flagCreateAdmin := flag.Bool("create-admin", false, "Create admin user")
flagResetPassword := flag.Bool("reset-password", false, "Reset user password")
flagDebugMode := flag.Bool("debug", false, "Enable debug mode (more verbose output)")
flag.Parse()
cfg := config.NewConfig()
if *flagDebugMode || cfg.HasDebugMode() {
logger.EnableDebug()
}
store := storage.NewStorage(
cfg.DatabaseURL(),
cfg.DatabaseMaxConnections(),
+54 -4
View File
@@ -5,8 +5,11 @@
package config
import (
"net/url"
"os"
"strconv"
"github.com/miniflux/miniflux/logger"
)
const (
@@ -26,7 +29,10 @@ const (
// Config manages configuration parameters.
type Config struct {
IsHTTPS bool
IsHTTPS bool
baseURL string
rootURL string
basePath string
}
func (c *Config) get(key, fallback string) string {
@@ -48,14 +54,53 @@ func (c *Config) getInt(key string, fallback int) int {
return v
}
// BaseURL returns the application base URL.
// HasDebugMode returns true if debug mode is enabled.
func (c *Config) HasDebugMode() bool {
return c.get("DEBUG", "") != ""
}
// BaseURL returns the application base URL with path.
func (c *Config) BaseURL() string {
return c.get("BASE_URL", defaultBaseURL)
if c.baseURL == "" {
c.baseURL = c.get("BASE_URL", defaultBaseURL)
if c.baseURL[len(c.baseURL)-1:] == "/" {
c.baseURL = c.baseURL[:len(c.baseURL)-1]
}
}
return c.baseURL
}
// RootURL returns the base URL without path.
func (c *Config) RootURL() string {
if c.rootURL == "" {
u, _ := url.Parse(c.BaseURL())
u.Path = ""
c.rootURL = u.String()
}
return c.rootURL
}
// BasePath returns the application base path according to the base URL.
func (c *Config) BasePath() string {
if c.basePath == "" {
u, _ := url.Parse(c.BaseURL())
c.basePath = u.Path
}
return c.basePath
}
// DatabaseURL returns the database URL.
func (c *Config) DatabaseURL() string {
return c.get("DATABASE_URL", defaultDatabaseURL)
value, exists := os.LookupEnv("DATABASE_URL")
if !exists {
logger.Info("The environment variable DATABASE_URL is not configured (the default value is used instead)")
}
if value == "" {
value = defaultDatabaseURL
}
return value
}
// DatabaseMaxConnections returns the number of maximum database connections.
@@ -133,6 +178,11 @@ func (c *Config) OAuth2Provider() string {
return c.get("OAUTH2_PROVIDER", "")
}
// HasHSTS returns true if HTTP Strict Transport Security is enabled.
func (c *Config) HasHSTS() bool {
return c.get("DISABLE_HSTS", "") == ""
}
// NewConfig returns a new Config.
func NewConfig() *Config {
return &Config{IsHTTPS: os.Getenv("HTTPS") != ""}
+101
View File
@@ -0,0 +1,101 @@
// 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 config
import (
"os"
"testing"
)
func TestDebugModeOn(t *testing.T) {
os.Clearenv()
os.Setenv("DEBUG", "1")
cfg := NewConfig()
if !cfg.HasDebugMode() {
t.Fatalf(`Unexpected debug mode value, got "%v"`, cfg.HasDebugMode())
}
}
func TestDebugModeOff(t *testing.T) {
os.Clearenv()
cfg := NewConfig()
if cfg.HasDebugMode() {
t.Fatalf(`Unexpected debug mode value, got "%v"`, cfg.HasDebugMode())
}
}
func TestCustomBaseURL(t *testing.T) {
os.Clearenv()
os.Setenv("BASE_URL", "http://example.org")
cfg := NewConfig()
if cfg.BaseURL() != "http://example.org" {
t.Fatalf(`Unexpected base URL, got "%s"`, cfg.BaseURL())
}
if cfg.RootURL() != "http://example.org" {
t.Fatalf(`Unexpected root URL, got "%s"`, cfg.RootURL())
}
if cfg.BasePath() != "" {
t.Fatalf(`Unexpected base path, got "%s"`, cfg.BasePath())
}
}
func TestCustomBaseURLWithTrailingSlash(t *testing.T) {
os.Clearenv()
os.Setenv("BASE_URL", "http://example.org/folder/")
cfg := NewConfig()
if cfg.BaseURL() != "http://example.org/folder" {
t.Fatalf(`Unexpected base URL, got "%s"`, cfg.BaseURL())
}
if cfg.RootURL() != "http://example.org" {
t.Fatalf(`Unexpected root URL, got "%s"`, cfg.BaseURL())
}
if cfg.BasePath() != "/folder" {
t.Fatalf(`Unexpected base path, got "%s"`, cfg.BasePath())
}
}
func TestDefaultBaseURL(t *testing.T) {
os.Clearenv()
cfg := NewConfig()
if cfg.BaseURL() != "http://localhost" {
t.Fatalf(`Unexpected base URL, got "%s"`, cfg.BaseURL())
}
if cfg.RootURL() != "http://localhost" {
t.Fatalf(`Unexpected root URL, got "%s"`, cfg.RootURL())
}
if cfg.BasePath() != "" {
t.Fatalf(`Unexpected base path, got "%s"`, cfg.BasePath())
}
}
func TestHSTSOn(t *testing.T) {
os.Clearenv()
cfg := NewConfig()
if !cfg.HasHSTS() {
t.Fatalf(`Unexpected HSTS value, got "%v"`, cfg.HasHSTS())
}
}
func TestHSTSOff(t *testing.T) {
os.Clearenv()
os.Setenv("DISABLE_HSTS", "1")
cfg := NewConfig()
if cfg.HasHSTS() {
t.Fatalf(`Unexpected HSTS value, got "%v"`, cfg.HasHSTS())
}
}
+4 -2
View File
@@ -12,6 +12,7 @@ import (
"time"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/locale"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/reader/feed"
"github.com/miniflux/miniflux/scheduler"
@@ -26,9 +27,10 @@ func Run(cfg *config.Config, store *storage.Storage) {
signal.Notify(stop, os.Interrupt)
signal.Notify(stop, syscall.SIGTERM)
feedHandler := feed.NewFeedHandler(store)
translator := locale.Load()
feedHandler := feed.NewFeedHandler(store, translator)
pool := scheduler.NewWorkerPool(feedHandler, cfg.WorkerPoolSize())
server := newServer(cfg, store, pool, feedHandler)
server := newServer(cfg, store, pool, feedHandler, translator)
scheduler.NewFeedScheduler(
store,
+6 -3
View File
@@ -23,9 +23,8 @@ import (
"github.com/gorilla/mux"
)
func routes(cfg *config.Config, store *storage.Storage, feedHandler *feed.Handler, pool *scheduler.WorkerPool) *mux.Router {
func routes(cfg *config.Config, store *storage.Storage, feedHandler *feed.Handler, pool *scheduler.WorkerPool, translator *locale.Translator) *mux.Router {
router := mux.NewRouter()
translator := locale.Load()
templateEngine := template.NewEngine(cfg, router, translator)
apiController := api.NewController(store, feedHandler)
@@ -45,7 +44,11 @@ func routes(cfg *config.Config, store *storage.Storage, feedHandler *feed.Handle
middleware.NewSessionMiddleware(cfg, store).Handler,
))
router.Handle("/fever/", feverHandler.Use(feverController.Handler))
if cfg.BasePath() != "" {
router = router.PathPrefix(cfg.BasePath()).Subrouter()
}
router.Handle("/fever/", feverHandler.Use(feverController.Handler)).Name("feverEndpoint")
router.Handle("/v1/users", apiHandler.Use(apiController.CreateUser)).Methods("POST")
router.Handle("/v1/users", apiHandler.Use(apiController.Users)).Methods("GET")
+10 -2
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/locale"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/reader/feed"
"github.com/miniflux/miniflux/scheduler"
@@ -18,7 +19,7 @@ import (
"golang.org/x/crypto/acme/autocert"
)
func newServer(cfg *config.Config, store *storage.Storage, pool *scheduler.WorkerPool, feedHandler *feed.Handler) *http.Server {
func newServer(cfg *config.Config, store *storage.Storage, pool *scheduler.WorkerPool, feedHandler *feed.Handler, translator *locale.Translator) *http.Server {
certFile := cfg.CertFile()
keyFile := cfg.KeyFile()
certDomain := cfg.CertDomain()
@@ -28,7 +29,7 @@ func newServer(cfg *config.Config, store *storage.Storage, pool *scheduler.Worke
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
Addr: cfg.ListenAddr(),
Handler: routes(cfg, store, feedHandler, pool),
Handler: routes(cfg, store, feedHandler, pool, translator),
}
if certDomain != "" && certCache != "" {
@@ -40,6 +41,13 @@ func newServer(cfg *config.Config, store *storage.Storage, pool *scheduler.Worke
HostPolicy: autocert.HostWhitelist(certDomain),
}
// Handle http-01 challenge.
s := &http.Server{
Handler: certManager.HTTPHandler(nil),
Addr: ":http",
}
go s.ListenAndServe()
go func() {
logger.Info(`Listening on "%s" by using auto-configured certificate for "%s"`, server.Addr, certDomain)
logger.Fatal(server.Serve(certManager.Listener()).Error())
-21
View File
@@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright (c) 2017 Hervé GOUCHET
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-10
View File
@@ -1,10 +0,0 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the MIT license
// that can be found in the LICENSE file.
/*
Package duration implements helpers to calculate time duration.
*/
package duration
+2 -2
View File
@@ -27,6 +27,6 @@ func (l LocalizedError) Localize(translation *locale.Language) string {
}
// NewLocalizedError returns a new LocalizedError.
func NewLocalizedError(message string, args ...interface{}) LocalizedError {
return LocalizedError{message: message, args: args}
func NewLocalizedError(message string, args ...interface{}) *LocalizedError {
return &LocalizedError{message: message, args: args}
}
+39 -3
View File
@@ -7,21 +7,36 @@ package http
import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/miniflux/miniflux/errors"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/timer"
"github.com/miniflux/miniflux/version"
)
const requestTimeout = 300
const maxBodySize = 1024 * 1024 * 15
const (
// 20 seconds max.
requestTimeout = 20
// 15MB max.
maxBodySize = 1024 * 1024 * 15
)
var (
errInvalidCertificate = "Invalid SSL certificate (original error: %q)"
errTemporaryNetworkOperation = "This website is temporarily unreachable (original error: %q)"
errPermanentNetworkOperation = "This website is permanently unreachable (original error: %q)"
errRequestTimeout = "Website unreachable, the request timed out after %d seconds"
)
// Client is a HTTP Client :)
type Client struct {
@@ -77,6 +92,26 @@ func (c *Client) executeRequest(request *http.Request) (*Response, error) {
client := c.buildClient()
resp, err := client.Do(request)
if err != nil {
if uerr, ok := err.(*url.Error); ok {
switch uerr.Err.(type) {
case x509.CertificateInvalidError, x509.HostnameError:
err = errors.NewLocalizedError(errInvalidCertificate, uerr.Err)
case *net.OpError:
if uerr.Err.(*net.OpError).Temporary() {
err = errors.NewLocalizedError(errTemporaryNetworkOperation, uerr.Err)
} else {
err = errors.NewLocalizedError(errPermanentNetworkOperation, uerr.Err)
}
case net.Error:
nerr := uerr.Err.(net.Error)
if nerr.Timeout() {
err = errors.NewLocalizedError(errRequestTimeout, requestTimeout)
} else if nerr.Temporary() {
err = errors.NewLocalizedError(errTemporaryNetworkOperation, nerr)
}
}
}
return nil, err
}
@@ -114,11 +149,12 @@ func (c *Client) buildRequest(method string, body io.Reader) (*http.Request, err
return nil, err
}
request.Header = c.buildHeaders()
if c.username != "" && c.password != "" {
request.SetBasicAuth(c.username, c.password)
}
request.Header = c.buildHeaders()
return request, nil
}
+11 -4
View File
@@ -19,11 +19,11 @@ const (
)
// New creates a new cookie.
func New(name, value string, isHTTPS bool) *http.Cookie {
func New(name, value string, isHTTPS bool, path string) *http.Cookie {
return &http.Cookie{
Name: name,
Value: value,
Path: "/",
Path: basePath(path),
Secure: isHTTPS,
HttpOnly: true,
Expires: time.Now().Add(cookieDuration * 24 * time.Hour),
@@ -31,14 +31,21 @@ func New(name, value string, isHTTPS bool) *http.Cookie {
}
// Expired returns an expired cookie.
func Expired(name string, isHTTPS bool) *http.Cookie {
func Expired(name string, isHTTPS bool, path string) *http.Cookie {
return &http.Cookie{
Name: name,
Value: "",
Path: "/",
Path: basePath(path),
Secure: isHTTPS,
HttpOnly: true,
MaxAge: -1,
Expires: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC),
}
}
func basePath(path string) string {
if path == "" {
return "/"
}
return path
}
+1 -1
View File
@@ -45,7 +45,7 @@ func (h *Handler) Use(f ControllerFunc) http.Handler {
ctx := NewContext(r, h.store, h.router, h.translator)
request := NewRequest(r)
response := NewResponse(w, r, h.template)
response := NewResponse(h.cfg, w, r, h.template)
language := ctx.UserLanguage()
if language != "" {
+8 -2
View File
@@ -8,11 +8,13 @@ import (
"net/http"
"time"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/template"
)
// Response handles HTTP responses.
type Response struct {
cfg *config.Config
writer http.ResponseWriter
request *http.Request
template *template.Engine
@@ -74,9 +76,13 @@ func (r *Response) commonHeaders() {
// Even if the directive "frame-src" has been deprecated in Firefox,
// we keep it to stay compatible with other browsers.
r.writer.Header().Set("Content-Security-Policy", "default-src 'self'; img-src *; media-src *; frame-src *; child-src *")
if r.cfg.IsHTTPS && r.cfg.HasHSTS() {
r.writer.Header().Set("Strict-Transport-Security", "max-age=31536000")
}
}
// NewResponse returns a new Response.
func NewResponse(w http.ResponseWriter, r *http.Request, template *template.Engine) *Response {
return &Response{writer: w, request: r, template: template}
func NewResponse(cfg *config.Config, w http.ResponseWriter, r *http.Request, template *template.Engine) *Response {
return &Response{cfg: cfg, writer: w, request: r, template: template}
}
+1 -1
View File
@@ -36,7 +36,7 @@ func (s *SessionMiddleware) Handler(next http.Handler) http.Handler {
return
}
http.SetCookie(w, cookie.New(cookie.CookieSessionID, session.ID, s.cfg.IsHTTPS))
http.SetCookie(w, cookie.New(cookie.CookieSessionID, session.ID, s.cfg.IsHTTPS, s.cfg.BasePath()))
} else {
logger.Debug("[Middleware:Session] %s", session)
}
+12
View File
@@ -6,6 +6,7 @@ package integration
import (
"github.com/miniflux/miniflux/integration/instapaper"
"github.com/miniflux/miniflux/integration/nunuxkeeper"
"github.com/miniflux/miniflux/integration/pinboard"
"github.com/miniflux/miniflux/integration/wallabag"
"github.com/miniflux/miniflux/logger"
@@ -48,4 +49,15 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
logger.Error("[Integration] %v", err)
}
}
if integration.NunuxKeeperEnabled {
client := nunuxkeeper.NewClient(
integration.NunuxKeeperURL,
integration.NunuxKeeperAPIKey,
)
if err := client.AddEntry(entry.URL, entry.Title, entry.Content); err != nil {
logger.Error("[Integration] %v", err)
}
}
}
+63
View File
@@ -0,0 +1,63 @@
// 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 nunuxkeeper
import (
"fmt"
"net/url"
"path"
"github.com/miniflux/miniflux/http"
)
// Document structure of a Nununx Keeper document
type Document struct {
Title string `json:"title,omitempty"`
Origin string `json:"origin,omitempty"`
Content string `json:"content,omitempty"`
ContentType string `json:"contentType,omitempty"`
}
// Client represents an Nunux Keeper client.
type Client struct {
baseURL string
apiKey string
}
// AddEntry sends an entry to Nunux Keeper.
func (c *Client) AddEntry(link, title, content string) error {
doc := &Document{
Title: title,
Origin: link,
Content: content,
ContentType: "text/html",
}
apiURL, err := getAPIEndpoint(c.baseURL, "/v2/documents")
if err != nil {
return err
}
client := http.NewClientWithCredentials(apiURL, "api", c.apiKey)
response, err := client.PostJSON(doc)
if response.HasServerFailure() {
return fmt.Errorf("nunux-keeper: unable to send entry, status=%d", response.StatusCode)
}
return err
}
// NewClient returns a new Nunux Keeepr client.
func NewClient(baseURL, apiKey string) *Client {
return &Client{baseURL: baseURL, apiKey: apiKey}
}
func getAPIEndpoint(baseURL, pathURL string) (string, error) {
u, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("nunux-keeper: invalid API endpoint: %v", err)
}
u.Path = path.Join(u.Path, pathURL)
return u.String(), nil
}
Regular → Executable
+3
View File
@@ -30,5 +30,8 @@ func AvailableLanguages() map[string]string {
"en_US": "English",
"fr_FR": "Français",
"de_DE": "Deutsch",
"pl_PL": "Polski",
"zh_CN": "简体中文",
"nl_NL": "Nederlands",
}
}
Regular → Executable
+724 -28
View File
@@ -1,13 +1,13 @@
// Code generated by go generate; DO NOT EDIT.
// 2018-01-20 00:27:28.368359 +0100 CET m=+0.034072224
// 2018-04-07 13:51:33.928362116 -0700 PDT m=+0.024804928
package locale
var translations = map[string]string{
"de_DE": `{
"plural.feed.error_count": [
"%d Error",
"%d Errors"
"%d Fehler",
"%d Fehler"
],
"plural.categories.feed_count": [
"Es gibt %d Abonnement.",
@@ -90,8 +90,8 @@ var translations = map[string]string{
"Last Login": "Letzte Anmeldung",
"Yes": "Ja",
"No": "Nein",
"This feed already exists (%s).": "Diese Abonnement existiert bereits (%s).",
"Unable to fetch feed (statusCode=%d).": "Abonnement konnte nicht abgerufen werden (code=%d).",
"This feed already exists (%s)": "Diese Abonnement existiert bereits (%s)",
"Unable to fetch feed (statusCode=%d)": "Abonnement konnte nicht abgerufen werden (code=%d)",
"Unable to open this link: %v": "Dieser Link konnte nicht geöffnet werden: %v",
"Unable to analyze this page: %v": "Diese Seite konnte nicht analysiert werden: %v",
"Unable to find any subscription.": "Es wurden keine Abonnements gefunden.",
@@ -103,7 +103,7 @@ var translations = map[string]string{
"The username, theme, language and timezone fields are mandatory.": "Die Felder für Benutzername, Thema, Sprache und Zeitzone sind obligatorisch.",
"The title is mandatory.": "Der Titel ist obligatorisch.",
"About": "Über",
"version": "Version",
"Version": "Version",
"Version:": "Version :",
"Build Date:": "Datum der Kompilierung:",
"Author:": "Autor:",
@@ -114,14 +114,14 @@ var translations = map[string]string{
"Invalid username or password.": "Benutzername oder Passwort ungültig.",
"Never": "Niemals",
"Unable to execute request: %v": "Diese Anfrage konnte nicht ausgeführt werden: %v",
"Last Parsing Error": "Letzter Analyse Error",
"Last Parsing Error": "Letzter Analysefehler",
"There is a problem with this feed": "Es gibt ein Problem mit diesem Abonnement",
"Unable to parse OPML file: %v.": "OPML Datei konnte nicht gelesen werden: %v.",
"Unable to parse RSS feed: %v.": "RSS Feed konnte nicht gelesen werden: %v.",
"Unable to parse Atom feed: %v.": "Atom Feed konnte nicht gelesen werden: %v.",
"Unable to parse JSON feed: %v.": "JSON Feed konnte nicht gelesen werden: %v.",
"Unable to parse RDF feed: %v.": "RDF Feed konnte nicht gelesen werden: %v.",
"Unable to normalize encoding: %v.": "Zeichenkodierung konnte nicht normalisiert werden: %v.",
"Unable to parse OPML file: %q": "OPML Datei konnte nicht gelesen werden: %q",
"Unable to parse RSS feed: %q": "RSS Feed konnte nicht gelesen werden: %q",
"Unable to parse Atom feed: %q": "Atom Feed konnte nicht gelesen werden: %q",
"Unable to parse JSON feed: %q": "JSON Feed konnte nicht gelesen werden: %q",
"Unable to parse RDF feed: %q": "RDF Feed konnte nicht gelesen werden: %q",
"Unable to normalize encoding: %q": "Zeichenkodierung konnte nicht normalisiert werden: %q",
"Unable to create this category.": "Diese Kategorie konnte nicht angelegt werden.",
"yes": "ja",
"no": "nein",
@@ -139,7 +139,7 @@ var translations = map[string]string{
"Sign in with Google": "Anmeldung mit Google",
"Unlink my Google account": "Google Konto abmelden",
"Link my Google account": "Google Konto verknüpfen",
"Category not found for this user.": "Diese Kategorie existiert nicht für diesen Benutzer.",
"Category not found for this user": "Diese Kategorie existiert nicht für diesen Benutzer",
"Invalid theme.": "Dieses Thema ist fehlerhaft.",
"Entry Sorting": "Sortierung der Artikel",
"Older entries first": "Älteste Artikel zuerst",
@@ -168,6 +168,9 @@ var translations = map[string]string{
"Wallabag Client Secret": "Wallabag Client-Secret",
"Wallabag Username": "Wallabag Benutzername",
"Wallabag Password": "Wallabag Passwort",
"Save articles to Nunux Keeper": "Artikel in Nunux Keeper speichern",
"Nunux Keeper API Endpoint": "Nunux Keeper API-Endpunkt",
"Nunux Keeper API key": "Nunux Keeper API-Schlüssel",
"Keyboard Shortcut: %s": "Tastenkürzel: %s",
"Favorites": "Lesezeichen",
"Star": "Lesezeichen hinzufügen",
@@ -211,7 +214,16 @@ var translations = map[string]string{
"Logged as %s": "Angemeldet als %s",
"Unread Items": "Ungelesen",
"Change entry status": "Status des Artikels ändern",
"Read": "Gelesen"
"Read": "Gelesen",
"Fever API endpoint:": "Fever API Endpunkt:",
"Miniflux API": "Miniflux API",
"API Endpoint": "API Endpunkt",
"Your account password": "Ihr Konto Passwort",
"This web page is empty": "Diese Webseite ist leer",
"Invalid SSL certificate (original error: %q)": "Ungültiges SSL-Zertifikat (ursprünglicher Fehler: %q)",
"This website is temporarily unreachable (original error: %q)": "Diese Webseite ist vorübergehend nicht erreichbar (ursprünglicher Fehler: %q)",
"This website is permanently unreachable (original error: %q)": "Diese Webseite ist dauerhaft nicht erreichbar (ursprünglicher Fehler: %q)",
"Website unreachable, the request timed out after %d seconds": "Webseite nicht erreichbar, die Anfrage endete nach %d Sekunden"
}
`,
"en_US": `{
@@ -310,8 +322,8 @@ var translations = map[string]string{
"Last Login": "Dernière connexion",
"Yes": "Oui",
"No": "Non",
"This feed already exists (%s).": "Cet abonnement existe déjà (%s).",
"Unable to fetch feed (statusCode=%d).": "Impossible de récupérer cet abonnement (code=%d).",
"This feed already exists (%s)": "Cet abonnement existe déjà (%s)",
"Unable to fetch feed (statusCode=%d)": "Impossible de récupérer cet abonnement (code=%d)",
"Unable to open this link: %v": "Impossible d'ouvrir ce lien : %v",
"Unable to analyze this page: %v": "Impossible d'analyzer cette page : %v",
"Unable to find any subscription.": "Impossible de trouver un abonnement.",
@@ -323,7 +335,7 @@ var translations = map[string]string{
"The username, theme, language and timezone fields are mandatory.": "Le nom d'utilisateur, le thème, la langue et le fuseau horaire sont obligatoire.",
"The title is mandatory.": "Le titre est obligatoire.",
"About": "A propos",
"version": "Version",
"Version": "Version",
"Version:": "Version :",
"Build Date:": "Date de la compilation :",
"Author:": "Auteur :",
@@ -336,12 +348,12 @@ var translations = map[string]string{
"Unable to execute request: %v": "Impossible d'exécuter cette requête: %v",
"Last Parsing Error": "Dernière erreur d'analyse",
"There is a problem with this feed": "Il y a un problème avec cet abonnement",
"Unable to parse OPML file: %v.": "Impossible de lire ce fichier OPML : %v.",
"Unable to parse RSS feed: %v.": "Impossible de lire ce flux RSS: %v.",
"Unable to parse Atom feed: %v.": "Impossible de lire ce flux Atom: %v.",
"Unable to parse JSON feed: %v.": "Impossible de lire ce flux JSON: %v.",
"Unable to parse RDF feed: %v.": "Impossible de lire ce flux RDF: %v.",
"Unable to normalize encoding: %v.": "Impossible de normaliser l'encodage : %v.",
"Unable to parse OPML file: %q": "Impossible de lire ce fichier OPML : %q",
"Unable to parse RSS feed: %q": "Impossible de lire ce flux RSS : %q",
"Unable to parse Atom feed: %q": "Impossible de lire ce flux Atom : %q",
"Unable to parse JSON feed: %q": "Impossible de lire ce flux JSON : %q",
"Unable to parse RDF feed: %q": "Impossible de lire ce flux RDF : %q",
"Unable to normalize encoding: %q": "Impossible de normaliser l'encodage : %q",
"Unable to create this category.": "Impossible de créer cette catégorie.",
"yes": "oui",
"no": "non",
@@ -359,7 +371,7 @@ var translations = map[string]string{
"Sign in with Google": "Se connecter avec Google",
"Unlink my Google account": "Dissocier mon compte Google",
"Link my Google account": "Associer mon compte Google",
"Category not found for this user.": "Cette catégorie n'existe pas pour cet utilisateur.",
"Category not found for this user": "Cette catégorie n'existe pas pour cet utilisateur",
"Invalid theme.": "Le thème est invalide.",
"Entry Sorting": "Ordre des éléments",
"Older entries first": "Ancien éléments en premier",
@@ -388,6 +400,9 @@ var translations = map[string]string{
"Wallabag Client Secret": "Clé secrète du client Wallabag",
"Wallabag Username": "Nom d'utilisateur de Wallabag",
"Wallabag Password": "Mot de passe de Wallabag",
"Save articles to Nunux Keeper": "Sauvegarder les articles vers Nunux Keeper",
"Nunux Keeper API Endpoint": "URL de l'API de Nunux Keeper",
"Nunux Keeper API key": "Clé d'API de Nunux Keeper",
"Keyboard Shortcut: %s": "Raccourci clavier : %s",
"Favorites": "Favoris",
"Star": "Favoris",
@@ -410,6 +425,7 @@ var translations = map[string]string{
"Items Navigation": "Naviguation entre les éléments",
"Go to previous item": "Élément précédent",
"Go to next item": "Élément suivant",
"Pages Navigation": "Naviguation entre les pages",
"Go to previous page": "Page précédente",
"Go to next page": "Page suivante",
"Open selected item": "Ouvrir élément sélectionné",
@@ -430,13 +446,693 @@ var translations = map[string]string{
"Logged as %s": "Connecté en tant que %s",
"Unread Items": "Éléments non lus",
"Change entry status": "Changer le statut de l'élément",
"Read": "Lu"
"Read": "Lu",
"Fever API endpoint:": "Point de terminaison de l'API Fever :",
"Miniflux API": "API de Miniflux",
"API Endpoint": "Point de terminaison de l'API",
"Your account password": "Le mot de passe de votre compte",
"This web page is empty": "Cette page web est vide",
"Invalid SSL certificate (original error: %q)": "Certificat SSL invalide (erreur originale : %q)",
"This website is temporarily unreachable (original error: %q)": "Ce site web est temporairement injoignable (erreur originale : %q)",
"This website is permanently unreachable (original error: %q)": "Ce site web n'est pas joignable de façon permanente (erreur originale : %q)",
"Website unreachable, the request timed out after %d seconds": "Site web injoignable, la requête à échouée après %d secondes",
"Comments": "Commentaires"
}
`,
"nl_NL": `{
"plural.feed.error_count": [
"%d error",
"%d errors"
],
"plural.categories.feed_count": [
"Er is %d feed.",
"Er zijn %d feeds."
],
"Username": "Gebruikersnaam",
"Password": "Wachtwoord",
"Unread": "Ongelezen",
"History": "Geschiedenis",
"Feeds": "Feeds",
"Categories": "Categorieën",
"Settings": "Instellingen",
"Logout": "Uitloggen",
"Next": "Volgende",
"Previous": "Vorige",
"New Subscription": "Nieuwe feed",
"Import": "Importeren",
"Export": "Exporteren",
"There is no category. You must have at least one category.": "Er zijn geen categorieën. Je moet op zijn minst één caterogie hebben.",
"URL": "URL",
"Category": "Categorie",
"Find a subscription": "Feed zoeken",
"Loading...": "Laden...",
"Create a category": "Categorie toevoegen",
"There is no category.": "Er zijn geen categorieën.",
"Edit": "Bewerken",
"Remove": "Verwijderen",
"No feed.": "Geen feeds.",
"There is no article in this category.": "Deze categorie bevat geen feeds.",
"Original": "Origineel",
"Mark this page as read": "Markeer deze pagina als gelezen",
"not yet": "in de toekomst",
"just now": "minder dan een minuut geleden",
"1 minute ago": "een minuut geleden",
"%d minutes ago": "%d minuten geleden",
"1 hour ago": "een uur geleden",
"%d hours ago": "%d uur geleden",
"yesterday": "gisteren",
"%d days ago": "%d dagen geleden",
"%d weeks ago": "%d weken geleden",
"%d months ago": "%d maanden geleden",
"%d years ago": "%d jaar geleden",
"Date": "Datum",
"IP Address": "IP-adres",
"User Agent": "User-agent",
"Actions": "Acties",
"Current session": "Huidige sessie",
"Sessions": "Sessies",
"Users": "Gebruikers",
"Add user": "Gebruiker toevoegen",
"Choose a Subscription": "Feed kiezen",
"Subscribe": "Abboneren",
"New Category": "Nieuwe categorie",
"Title": "Naam",
"Save": "Opslaan",
"or": "of",
"cancel": "annuleren",
"New User": "Nieuwe gebruiker",
"Confirmation": "Bevestig wachtwoord",
"Administrator": "Administrator",
"Edit Category: %s": "Bewerken van categorie: %s",
"Update": "Updaten",
"Edit Feed: %s": "Bewerken van feed: %s",
"There is no category!": "Er zijn geen categorieën!",
"Edit user: %s": "Gebruiker aanpassen: %s",
"There is no article for this feed.": "Er zijn geen artikelen in deze feed.",
"Add subscription": "Feed toevoegen",
"You don't have any subscription.": "Je hebt nog geen feeds geabboneerd staan.",
"Last check:": "Laatste update:",
"Refresh": "Vernieuwen",
"There is no history at the moment.": "Geschiedenis is op dit moment leeg.",
"OPML file": "OPML-bestand",
"Sign In": "Inloggen",
"Theme": "Skin",
"Timezone": "Tijdzone",
"Language": "Taal",
"There is no unread article.": "Er zijn geen ongelezen artikelen.",
"You are the only user.": "Je bent de enige gebruiker.",
"Last Login": "Laatste login",
"Yes": "Ja",
"No": "Nee",
"This feed already exists (%s)": "Deze feed bestaat al (%s)",
"Unable to fetch feed (statusCode=%d)": "Kon feed niet update (code=%d)",
"Unable to open this link: %v": "Kon link niet volgen: %v",
"Unable to analyze this page: %v": "Kon pagina niet analyseren: %v",
"Unable to find any subscription.": "Kon geen feeds vinden.",
"The URL and the category are mandatory.": "The URL en de categorie zijn verplicht.",
"All fields are mandatory.": "Alle velden moeten ingevuld zijn.",
"Passwords are not the same.": "Wachtwoorden zijn niet hetzelfde.",
"You must use at least 6 characters.": "Je moet minstens 6 tekens gebruiken.",
"The username is mandatory.": "Gebruikersnaam is verplicht",
"The username, theme, language and timezone fields are mandatory.": "Gebruikersnaam, skin, taal en tijdzone zijn verplicht.",
"The title is mandatory.": "Naam van categorie is verplicht.",
"About": "Over Miniflux",
"Version": "Versie",
"Version:": "Versie:",
"Build Date:": "Datum build:",
"Author:": "Auteur:",
"Authors": "Auteurs",
"License:": "Licentie:",
"Attachments": "Bijlages",
"Download": "Download",
"Invalid username or password.": "Onjuiste gebruikersnaam of wachtwoord.",
"Never": "Nooit",
"Unable to execute request: %v": "Kon request niet uitvoeren: %v",
"Last Parsing Error": "Laatste parse error",
"There is a problem with this feed": "Er is een probleem met deze feed",
"Unable to parse OPML file: %q": "Kon OPML niet parsen: %q",
"Unable to parse RSS feed: %q": "Kon RSS-feed niet parsen: %q",
"Unable to parse Atom feed: %q": "Kon Atom-feed niet parsen: %q",
"Unable to parse JSON feed: %q": "Kon JSON-feed niet parsen: %q",
"Unable to parse RDF feed: %q": "Kon RDF-feed niet parsen: %q",
"Unable to normalize encoding: %q": "Kon encoding niet normaliseren: %q",
"Unable to create this category.": "Kon categorie niet aanmaken.",
"yes": "ja",
"no": "nee",
"Are you sure?": "Weet je het zeker?",
"Work in progress...": "Bezig...",
"This user already exists.": "Deze gebruiker bestaat al.",
"This category already exists.": "Deze categorie bestaat al.",
"Unable to update this category.": "Kon categorie niet updaten.",
"Integrations": "Integraties",
"Bookmarklet": "Bookmarklet",
"Drag and drop this link to your bookmarks.": "Sleep deze link naar je bookmarks.",
"This special link allows you to subscribe to a website directly by using a bookmark in your web browser.": "Gebruik deze link als bookmark in je browser om je direct te abboneren op een website.",
"Add to Miniflux": "Toevoegen aan Miniflux",
"Refresh all feeds in background": "Vernieuw alle feeds in de achtergrond",
"Sign in with Google": "Inloggen via Google",
"Unlink my Google account": "Ontkoppel mijn Google-account",
"Link my Google account": "Koppel mijn Google-account",
"Category not found for this user": "Categorie niet gevonden voor deze gebruiker",
"Invalid theme.": "Ongeldige skin.",
"Entry Sorting": "Volgorde van items",
"Older entries first": "Oudere items eerst",
"Recent entries first": "Recente items eerst",
"Saving...": "Opslaag...",
"Done!": "Klaar!",
"Save this article": "Artikel opslaan",
"Mark bookmark as unread": "Markeer bookmark als gelezen",
"Pinboard Tags": "Pinboard tags",
"Pinboard API Token": "Pinboard API token",
"Save articles to Pinboard": "Artikelen opslaan naar Pinboard",
"Save articles to Instapaper": "Artikelen opstaan naar Instapaper",
"Instapaper Username": "Instapaper gebruikersnaam",
"Instapaper Password": "Instapaper wachtwoord",
"Activate Fever API": "Activeer Fever API",
"Fever Username": "Fever gebruikersnaam",
"Fever Password": "Fever wachtwoord",
"Fetch original content": "Download originele content",
"Scraper Rules": "Scraper regels",
"Rewrite Rules": "Rewrite regels",
"Preferences saved!": "Instellingen opgeslagen!",
"Your external account is now linked !": "Jouw externe account is nu gekoppeld!",
"Save articles to Wallabag": "Sauvegarder les articles vers Wallabag",
"Wallabag API Endpoint": "Wallabag URL",
"Wallabag Client ID": "Wallabag Client-ID",
"Wallabag Client Secret": "Wallabag Client-Secret",
"Wallabag Username": "Wallabag gebruikersnaam",
"Wallabag Password": "Wallabag wachtwoord",
"Save articles to Nunux Keeper": "Opslaan naar Nunux Keeper",
"Nunux Keeper API Endpoint": "Nunux Keeper URL",
"Nunux Keeper API key": "Nunux Keeper API-sleutel",
"Keyboard Shortcut: %s": "Sneltoets: %s",
"Favorites": "Favorieten",
"Star": "Ster toevoegen",
"Unstar": "Ster weghalen",
"Starred": "Favorieten",
"There is no bookmark at the moment.": "Er zijn op dit moment geen favorieten.",
"Last checked:": "Laatste update:",
"ETag header:": "ETAG-header:",
"LastModified header:": "LastModified-header:",
"None": "Geen",
"Keyboard Shortcuts": "Sneltoetsen",
"Sections Navigation": "Naviguatie tussen menu's",
"Go to unread": "Ga naar ongelezen",
"Go to bookmarks": "Ga naar favorieten",
"Go to history": "Ga naar geschiedenis",
"Go to feeds": "Ga naar feeds",
"Go to categories": "Ga naar categorieën",
"Go to settings": "Ga naar instellingen",
"Show keyboard shortcuts": "Laat sneltoetsen zien",
"Items Navigation": "Navigatie tussen items",
"Go to previous item": "Vorige item",
"Go to next item": "Volgende item",
"Pages Navigation": "Naviguatie tussen pagina's",
"Go to previous page": "Vorige pagina",
"Go to next page": "Volgende pagina",
"Open selected item": "Open geselecteerde link",
"Open original link": "Open originele link",
"Toggle read/unread": "Markeer gelezen/ongelezen",
"Mark current page as read": "Markeer deze pagina als gelezen",
"Download original content": "Download originele content",
"Toggle bookmark": "Ster toevoegen/weghalen",
"Close modal dialog": "Sluit dialoogscherm",
"Save article": "Artikel opslaan",
"There is already someone associated with this provider!": "Er is al iemand geregistreerd met deze provider!",
"There is already someone else with the same Fever username!": "Er is al iemand met dezelfde Fever gebruikersnaam!",
"Mark all as read": "Markeer alle items als gelezen",
"This feed is empty": "Deze feed is leeg",
"Flush history": "Verwijder geschiedenis",
"Site URL": "Website URL",
"Feed URL": "Feed URL",
"Logged as %s": "Ingelogd als %s",
"Unread Items": "Ongelezen items",
"Change entry status": "Verander status van item",
"Read": "Gelezen",
"Fever API endpoint:": "Fever URL:",
"Miniflux API": "Miniflux API",
"API Endpoint": "API-URL",
"Your account password": "Wachtwoord van jouw account",
"This web page is empty": "Deze webpagina is leeg",
"Invalid SSL certificate (original error: %q)": "Ongeldig SSL-certificaat (originele error: %q)",
"This website is temporarily unreachable (original error: %q)": "Deze website is tijdelijk onbereikbaar (originele error: %q)",
"This website is permanently unreachable (original error: %q)": "Deze website is permanent onbereikbaar (originele error: %q)",
"Website unreachable, the request timed out after %d seconds": "Website onbereikbaar, de request gaf een timeout na %d seconden"
}
`,
"pl_PL": `{
"plural.feed.error_count": [
"%d błąd",
"%d błąd",
"%d błędów"
],
"plural.categories.feed_count": [
"Jest %d kanał.",
"Są %d kanały.",
"Jest %d kanałów."
],
"Username": "Nazwa użytkownika",
"Password": "Hasło",
"Unread": "Nieprzeczytane",
"History": "Historia",
"Feeds": "Kanały",
"Categories": "Kategorie",
"Settings": "Ustawienia",
"Logout": "Wyloguj się",
"Next": "Następny",
"Previous": "Poprzedni",
"New Subscription": "Nowa subskrypcja",
"Import": "Importuj",
"Export": "Eksportuj",
"There is no category. You must have at least one category.": "Nie ma żadnej kategorii. Musisz mieć co najmniej jedną kategorię",
"URL": "URL",
"Category": "Kategoria",
"Find a subscription": "Znajdź subskrypcję",
"Loading...": "Ładowanie...",
"Create a category": "Utwórz kategorię",
"There is no category.": "Nie masz żadnej kategorii",
"Edit": "Edytuj",
"Remove": "Usuń",
"No feed.": "Brak kanałów.",
"There is no article in this category.": "W tej kategorii nie ma żadnych artykułów",
"Original": "Oryginalny artykuł",
"Mark this page as read": "Oznacz jako przeczytane",
"not yet": "jeszcze nie",
"just now": "przed chwilą",
"1 minute ago": "minutę temu",
"%d minutes ago": "%d minut temu",
"1 hour ago": "godzinę temu",
"%d hours ago": "%d godzin temu",
"yesterday": "wczoraj",
"%d days ago": "%d dni temu",
"%d weeks ago": "%d tygodni temu",
"%d months ago": "%d miesięcy temu",
"%d years ago": "%d lat temu",
"Date": "Data",
"IP Address": "Adres IP",
"User Agent": "User Agent",
"Actions": "Działania",
"Current session": "Bieżąca sesja",
"Sessions": "Sesje",
"Users": "Użytkownicy",
"Add user": "Dodaj użytkownika",
"Choose a Subscription": "Wybierz subskrypcję",
"Subscribe": "Subskrypcja",
"New Category": "Nowa kategoria",
"Title": "Tytuł",
"Save": "Zapisz",
"or": "lub",
"cancel": "anuluj",
"New User": "Nowy użytkownik",
"Confirmation": "Potwierdź",
"Administrator": "Administrator",
"Edit Category: %s": "Edycja Kategorii: %s",
"Update": "Zaktualizuj",
"Edit Feed: %s": "Edytuj kanał: %s",
"There is no category!": "Nie ma żadnej kategorii!",
"Edit user: %s": "Edytuj użytkownika: %s",
"There is no article for this feed.": "Nie ma artykułu dla tego kanału.",
"Add subscription": "Dodaj subskrypcję",
"You don't have any subscription.": "Nie masz żadnej subskrypcji",
"Last check:": "Ostatnia aktualizacja:",
"Refresh": "Odśwież",
"There is no history at the moment.": "Obecnie nie ma żadnej historii.",
"OPML file": "Plik OPML",
"Sign In": "Zaloguj się",
"Sign in": "Zaloguj się",
"Theme": "Wygląd",
"Timezone": "Strefa czasowa",
"Language": "Język",
"There is no unread article.": "Nie ma żadnych nieprzeczytanych artykułów.",
"You are the only user.": "Jesteś jedynym użytkownikiem.",
"Last Login": "Ostatnie logowanie",
"Yes": "Tak",
"No": "Nie",
"This feed already exists (%s)": "Ten kanał już istnieje (%s)",
"Unable to fetch feed (statusCode=%d)": "Kanał nie mógł zostać pobrany (kod=%d)",
"Unable to open this link: %v": "Nie można było otworzyć tego linku: %v",
"Unable to analyze this page: %v": "Nie można przeanalizować tej strony: %v",
"Unable to find any subscription.": "Nie znaleziono żadnych subskrypcji.",
"The URL and the category are mandatory.": "URL i kategoria są obowiązkowe.",
"All fields are mandatory.": "Wszystkie pola są obowiązkowe.",
"Passwords are not the same.": "Hasła nie są identyczne.",
"You must use at least 6 characters.": "Musisz użyć co najmniej 6 znaków.",
"The username is mandatory.": "Nazwa użytkownika jest obowiązkowa.",
"The username, theme, language and timezone fields are mandatory.": "Pola nazwy użytkownika, tematu, języka i strefy czasowej są obowiązkowe.",
"The title is mandatory.": "Tytuł jest obowiązkowy.",
"About": "O stronie",
"Version": "Wersja",
"Version:": "Wersja :",
"Build Date:": "Data opracowania:",
"Author:": "Autor:",
"Authors": "Autorzy",
"License:": "Licencja:",
"Attachments": "Załączniki",
"Download": "Pobierz",
"Invalid username or password.": "Nieprawidłowa nazwa użytkownika lub hasło.",
"Never": "Nigdy",
"Unable to execute request: %v": "To polecenie nie mogło zostać wykonane: %v",
"Last Parsing Error": "Ostatni błąd analizy",
"There is a problem with this feed": "Z tym kanałem jest problem",
"Unable to parse OPML file: %q": "Plik OPML nie mógł zostać odczytany: %q",
"Unable to parse RSS feed: %q": "Nie można było odczytać kanału RSS: %q",
"Unable to parse Atom feed: %q": "Nie można było odczytać kanału Atom: %q",
"Unable to parse JSON feed: %q": "Nie można było odczytać kanału JSON: %q",
"Unable to parse RDF feed: %q": "Nie można było odczytać kanału RDF: %q",
"Unable to normalize encoding: %q": "Kodowanie znaków nie mogło zostać znormalizowane: %q",
"Unable to create this category.": "Ta kategoria nie mogła zostać utworzona.",
"yes": "tak",
"no": "nie",
"Are you sure?": "Czy jesteś pewny?",
"Work in progress...": "W toku...",
"This user already exists.": "Ten użytkownik już istnieje.",
"This category already exists.": "Ta kategoria już istnieje.",
"Unable to update this category.": "Ta kategoria nie mogła zostać zaktualizowana.",
"Integrations": "Usługi",
"Bookmarklet": "Bookmarklet",
"Drag and drop this link to your bookmarks.": "Przeciągnij i upuść to łącze do zakładek.",
"This special link allows you to subscribe to a website directly by using a bookmark in your web browser.": "Ten link umożliwia subskrypcję strony internetowej bezpośrednio za pomocą zakładki w przeglądarce internetowej",
"Add to Miniflux": "Dodaj do Miniflux",
"Refresh all feeds in background": "Odśwież wszystkie subskrypcje w tle",
"Sign in with Google": "Zaloguj przez Google",
"Unlink my Google account": "Odłącz moje konto Google",
"Link my Google account": "Połącz z moim kontem Google",
"Category not found for this user": "Kategoria nie znaleziona dla tego użytkownika",
"Invalid theme.": "Ten temat jest nieprawidłowy.",
"Entry Sorting": "Sortowanie artykułów",
"Older entries first": "Najstarsze wpisy jako pierwsze",
"Recent entries first": "Najnowsze wpisy jako pierwsze",
"Saving...": "Zapisywanie...",
"Done!": "Gotowe!",
"Save this article": "Zapisz ten artykuł",
"Mark bookmark as unread": "Zaznacz zakładkę jako nieprzeczytaną",
"Pinboard Tags": "Pinboard Tags",
"Pinboard API Token": "Token Pinboard API",
"Save articles to Pinboard": "Zapisz artykuł w Pinboard",
"Save articles to Instapaper": "Zapisz artykuł w Instapaper",
"Instapaper Username": "Login do Instapaper",
"Instapaper Password": "Hasło do Instapaper",
"Activate Fever API": "Aktywuj Fever API",
"Fever Username": "Login do Fever",
"Fever Password": "Hasło do Fever",
"Fetch original content": "Pobierz oryginalną treść",
"Scraper Rules": "Zasady ekstrakcji",
"Rewrite Rules": "Reguły zapisu",
"Preferences saved!": "Ustawienia zapisane!",
"Your external account is now linked !": "Twoje zewnętrzne konto jest teraz połączone!",
"Save articles to Wallabag": "Zapisz artykuły do Wallabag",
"Wallabag API Endpoint": "Wallabag URL",
"Wallabag Client ID": "Wallabag Client-ID",
"Wallabag Client Secret": "Wallabag Client Secret",
"Wallabag Username": "Login do Wallabag",
"Wallabag Password": "Hasło do Wallabag",
"Save articles to Nunux Keeper": "Zapisz artykuly do Nunux Keeper",
"Nunux Keeper API Endpoint": "Nunux Keeper URL",
"Nunux Keeper API key": "Nunux Keeper API key",
"Keyboard Shortcut: %s": "Skróty klawiszowe: %s",
"Favorites": "Ulubione",
"Star": "Oznacz gwiazdką",
"Unstar": "Usuń gwiazdkę",
"Starred": "Oznaczone gwiazdką",
"There is no bookmark at the moment.": "Obecnie nie ma żadnych zakładek.",
"Last checked:": "Ostatnio sprawdzone:",
"ETag header:": "Nagłówek ETag:",
"LastModified header:": "Ostatnio zmienione:",
"None": "Brak",
"Keyboard Shortcuts": "Skróty klawiszowe",
"Sections Navigation": "Nawigacja między punktami menu",
"Go to unread": "Przejdź do nieprzeczytanych artykułów",
"Go to bookmarks": "Przejdź do zakładek",
"Go to history": "Przejdź do historii",
"Go to feeds": "Przejdź do kanałów",
"Go to categories": "Przejdź do kategorii",
"Go to settings": "Przejdź do ustawień",
"Show keyboard shortcuts": "Pokaż listę skrótów klawiszowych",
"Items Navigation": "Nawigacja między artykułami",
"Go to previous item": "Przejdź do poprzedniego artykułu",
"Go to next item": "Przejdź do następnego punktu artykułu",
"Pages Navigation": "Nawigacja między stronami",
"Go to previous page": "Przejdź do poprzedniej strony",
"Go to next page": "Przejdź do następnej strony",
"Open selected item": "Otwórz zaznaczony artykuł",
"Open original link": "Otwórz oryginalny artykuł",
"Toggle read/unread": "Oznacz jako przeczytane/nieprzeczytane",
"Mark current page as read": "Zaznacz aktualną stronę jako przeczytaną",
"Download original content": "Pobierz oryginalną zawartość",
"Toggle bookmark": "Dodaj/usuń zakładki",
"Close modal dialog": "Zamknij listę skrótów klawiszowych",
"Save article": "Zapisz artykuł",
"There is already someone associated with this provider!": "Już ktoś jest powiązany z tym dostawcą!",
"There is already someone else with the same Fever username!": "Już ktoś inny używa tej nazwy użytkownika Fever!",
"Mark all as read": "Oznacz wszystko jako przeczytane",
"This feed is empty": "Ten kanał jest pusty",
"Flush history": "Usuń historię",
"Site URL": "URL strony",
"Feed URL": "URL kanału",
"Logged as %s": "Zalogowany jako %s",
"Unread Items": "Nieprzeczytane",
"Change entry status": "Zmień status artykułu",
"Read": "Przeczytane",
"Fever API endpoint:": "Fever API endpoint:",
"Miniflux API": "Miniflux API",
"API Endpoint": "API endpoint",
"Your account password": "Hasło konta",
"This web page is empty": "Ta strona jest pusta",
"Invalid SSL certificate (original error: %q)": "Certyfikat SSL jest nieprawidłowy (błąd: %q)",
"This website is temporarily unreachable (original error: %q)": "Ta strona jest tymczasowo niedostępna (błąd: %q)",
"This website is permanently unreachable (original error: %q)": "Ta strona jest niedostępna (błąd: %q)",
"Website unreachable, the request timed out after %d seconds": "Strona internetowa nieosiągalna, żądanie wygasło po %d sekundach"
}
`,
"zh_CN": `{
"plural.feed.error_count": [
"%d 错误",
"%d 错误"
],
"plural.categories.feed_count": [
"有 %d 个源.",
"有 %d 个源."
],
"Username": "用户名",
"Password": "密码",
"Unread": "未读",
"History": "历史",
"Feeds": "源",
"Categories": "分类",
"Settings": "设置",
"Logout": "登出",
"Next": "下一页",
"Previous": "上一页",
"New Subscription": "新订阅",
"Import": "导入",
"Export": "导出",
"There is no category. You must have at least one category.": "目前没有分类.需要有一个已有的分类存在.",
"URL": "URL",
"Category": "分类",
"Find a subscription": "寻找订阅",
"Loading...": "载入中...",
"Create a category": "新建分类",
"There is no category.": "目前没有分类.",
"Edit": "编辑",
"Remove": "删除",
"No feed.": "没有源.",
"There is no article in this category.": "该分类下没有文章.",
"Original": "原始链接",
"Mark this page as read": "标记为已读",
"not yet": "尚未",
"just now": "刚刚",
"1 minute ago": "1 分钟前",
"%d minutes ago": "%d 分钟前",
"1 hour ago": "1 小时前",
"%d hours ago": "%d 小时前",
"yesterday": "昨天",
"%d days ago": "%d 天前",
"%d weeks ago": "%d 周前",
"%d months ago": "%d 月前",
"%d years ago": "%d 年前",
"Date": "日期",
"IP Address": "IP地址",
"User Agent": "User Agent",
"Actions": "操作",
"Current session": "当前会话",
"Sessions": "会话",
"Users": "用户",
"Add user": "新建用户",
"Choose a Subscription": "选择一个订阅",
"Subscribe": "订阅",
"New Category": "新分类",
"Title": "标题",
"Save": "保存",
"or": "或",
"cancel": "取消",
"New User": "新用户",
"Confirmation": "确认",
"Administrator": "管理员",
"Edit Category: %s": "编辑分类 : %s",
"Update": "更新",
"Edit Feed: %s": "编辑源 : %s",
"There is no category!": "没有分类!",
"Edit user: %s": "编辑用户: %s",
"There is no article for this feed.": "这个源中没有文章.",
"Add subscription": "新增订阅",
"You don't have any subscription.": "当前没有订阅",
"Last check:": "最后检查时间:",
"Refresh": "更新",
"There is no history at the moment.": "当前没有历史.",
"OPML file": "OPML 文件",
"Sign In": "登陆",
"Sign in": "登陆",
"Theme": "主题",
"Timezone": "时区",
"Language": "语言",
"There is no unread article.": "目前没有未读文章.",
"You are the only user.": "你是目前仅有的用户.",
"Last Login": "最后登录时间",
"Yes": "是",
"No": "否",
"This feed already exists (%s)": "源已存在 (%s)",
"Unable to fetch feed (statusCode=%d)": "无法获取源 (错误代码=%d)",
"Unable to open this link: %v": "无法打开这一链接: %v",
"Unable to analyze this page: %v": "无法分析这一页面: %v",
"Unable to find any subscription.": "找不到任何订阅.",
"The URL and the category are mandatory.": "必须填写URL和分类.",
"All fields are mandatory.": "必须填写全部信息.",
"Passwords are not the same.": "两次输入的密码不同.",
"You must use at least 6 characters.": "请至少使用6个字符.",
"The username is mandatory.": "必须填写用户名.",
"The username, theme, language and timezone fields are mandatory.": "必须填写用户名,主题,语言和时区.",
"The title is mandatory.": "必须填写标题.",
"About": "关于",
"Version": "版本",
"Version:": "版本:",
"Build Date:": "构建日期:",
"Author:": "作者:",
"Authors": "作者",
"License:": "协议:",
"Attachments": "附件",
"Download": "下载",
"Invalid username or password.": "用户名或密码无效.",
"Never": "永不",
"Unable to execute request: %v": "无法执行这一请求: %v",
"Last Parsing Error": "最后一次解析错误",
"There is a problem with this feed": "这一源存在问题",
"Unable to parse OPML file: %q": "无法解析OPML文件: %q",
"Unable to parse RSS feed: %q": "无法解析RSS源: %q",
"Unable to parse Atom feed: %q": "无法解析Atom源: %q",
"Unable to parse JSON feed: %q": "无法解析JSON源: %q",
"Unable to parse RDF feed: %q": "无法解析RDF源: %q",
"Unable to normalize encoding: %q": "无法正则化编码: %q",
"Unable to create this category.": "无法建立这个分类.",
"yes": "是",
"no": "否",
"Are you sure?": "您确认吗?",
"Work in progress...": "执行中...",
"This user already exists.": "用户已存在.",
"This category already exists.": "分类已存在.",
"Unable to update this category.": "无法更新该分类.",
"Integrations": "集成",
"Bookmarklet": "书签小应用",
"Drag and drop this link to your bookmarks.": "拖动这个链接到书签.",
"This special link allows you to subscribe to a website directly by using a bookmark in your web browser.": "你可以打开这个特殊的书签来直接订阅网站.",
"Add to Miniflux": "新增到Miniflux",
"Refresh all feeds in background": "在后台更新全部源",
"Sign in with Google": "使用Google登陆",
"Unlink my Google account": "去除Google账号关联",
"Link my Google account": "关联我的Google账户",
"Category not found for this user": "未找到该用户的这一分类",
"Invalid theme.": "无效的主题.",
"Entry Sorting": "内容排序",
"Older entries first": "旧->新",
"Recent entries first": "新->旧",
"Saving...": "保存中",
"Done!": "完成!",
"Save this article": "保存这篇文章",
"Mark bookmark as unread": "标记为未读",
"Pinboard Tags": "Pinboard 标签",
"Pinboard API Token": "Pinboard API Token",
"Save articles to Pinboard": "保存文章到Pinboard",
"Save articles to Instapaper": "保存文章到Instapaper",
"Instapaper Username": "Instapaper 用户名",
"Instapaper Password": "Instapaper 密码",
"Activate Fever API": "启用 Fever API",
"Fever Username": "Fever 用户名",
"Fever Password": "Fever 密码",
"Fetch original content": "抓取原内容",
"Scraper Rules": "Scraper规则",
"Rewrite Rules": "重写规则",
"Preferences saved!": "偏好已存储!",
"Your external account is now linked !": "您的外部账号已关联!",
"Save articles to Wallabag": "保存文章到Wallabag",
"Wallabag API Endpoint": "Wallabag URL",
"Wallabag Client ID": "Wallabag 客户端ID",
"Wallabag Client Secret": "Wallabag 客户端Secret",
"Wallabag Username": "Wallabag 用户名",
"Wallabag Password": "Wallabag 密码",
"Save articles to Nunux Keeper": "保存文章到Nunux Keeper",
"Nunux Keeper API Endpoint": "Nunux Keeper API Endpoint",
"Nunux Keeper API key": "Nunux Keeper API key",
"Keyboard Shortcut: %s": "快捷键: %s",
"Favorites": "收藏",
"Star": "标记星标",
"Unstar": "去掉星标",
"Starred": "星标",
"There is no bookmark at the moment.": "当前没有书签.",
"Last checked:": "上次检查:",
"ETag header:": "ETag header:",
"LastModified header:": "最后修改的Header:",
"None": "无",
"Keyboard Shortcuts": "快捷键",
"Sections Navigation": "分区导航",
"Go to unread": "去往未读",
"Go to bookmarks": "去往书签",
"Go to history": "去往历史",
"Go to feeds": "去往源",
"Go to categories": "去往分类",
"Go to settings": "去往设定",
"Show keyboard shortcuts": "显示快捷键",
"Items Navigation": "条目导航",
"Go to previous item": "上一条目",
"Go to next item": "下一条目",
"Pages Navigation": "页面导航",
"Go to previous page": "上一页",
"Go to next page": "下一页",
"Open selected item": "打开选定的条目",
"Open original link": "打开原始链接",
"Toggle read/unread": "切换已读/未读状态",
"Mark current page as read": "标记当前",
"Download original content": "下载原始内容",
"Toggle bookmark": "切换收藏状态",
"Close modal dialog": "关闭模态对话窗口",
"Save article": "保存文章",
"There is already someone associated with this provider!": "该Provider已被关联!",
"There is already someone else with the same Fever username!": "Fever用户名已被占用!",
"Mark all as read": "全标记为已读",
"This feed is empty": "该源是空的",
"Flush history": "清理历史",
"Site URL": "站点URL",
"Feed URL": "源URL",
"Logged as %s": "当前登录 %s",
"Unread Items": "未读条目",
"Change entry status": "更改状态",
"Read": "标为已读",
"Fever API endpoint:": "Fever API Endpoint:",
"Miniflux API": "Miniflux API",
"API Endpoint": "API Endpoint",
"Your account password": "您账户的密码",
"This web page is empty": "该网页是空的",
"Invalid SSL certificate (original error: %q)": "无效的SSL证书 (原始错误: %q)",
"This website is temporarily unreachable (original error: %q)": "该网站暂时不可达 (原始错误: %q)",
"This website is permanently unreachable (original error: %q)": "该网站永久不可达 (原始错误: %q)",
"Website unreachable, the request timed out after %d seconds": "网站不可达, 请求已在 %d 秒后超时"
}
`,
}
var translationsChecksums = map[string]string{
"de_DE": "c518cb7782b391da612d7d8d72fc746aa5ae25e107afc995d29b85990e2f07e8",
"de_DE": "af8315375e1801e06110b2efe1e4c74c26cabb2661682fc48be38afe30eb8617",
"en_US": "6fe95384260941e8a5a3c695a655a932e0a8a6a572c1e45cb2b1ae8baa01b897",
"fr_FR": "776658a9a09c49d25f1f259fea794b48c4fa39b92024318d0f223bafb9164aad",
"fr_FR": "b536114691935c5336de43e797ab30575e8168679e02cd462886f4d7fe5df2d2",
"nl_NL": "d427d6a5e843be576040dee004df2b685a839a38b2e5f06435faa2973f1f4c70",
"pl_PL": "4dcf7c3f44c80ca81ecdbef96bdb21d1ae1a8a6caf60cc11403e5e041efc5ca9",
"zh_CN": "bfa05d3b3396df6222414a3a6949b73b486cd021499ecd3a34ce8e04e93aad93",
}
+26 -14
View File
@@ -1,7 +1,7 @@
{
"plural.feed.error_count": [
"%d Error",
"%d Errors"
"%d Fehler",
"%d Fehler"
],
"plural.categories.feed_count": [
"Es gibt %d Abonnement.",
@@ -84,8 +84,8 @@
"Last Login": "Letzte Anmeldung",
"Yes": "Ja",
"No": "Nein",
"This feed already exists (%s).": "Diese Abonnement existiert bereits (%s).",
"Unable to fetch feed (statusCode=%d).": "Abonnement konnte nicht abgerufen werden (code=%d).",
"This feed already exists (%s)": "Diese Abonnement existiert bereits (%s)",
"Unable to fetch feed (statusCode=%d)": "Abonnement konnte nicht abgerufen werden (code=%d)",
"Unable to open this link: %v": "Dieser Link konnte nicht geöffnet werden: %v",
"Unable to analyze this page: %v": "Diese Seite konnte nicht analysiert werden: %v",
"Unable to find any subscription.": "Es wurden keine Abonnements gefunden.",
@@ -97,7 +97,7 @@
"The username, theme, language and timezone fields are mandatory.": "Die Felder für Benutzername, Thema, Sprache und Zeitzone sind obligatorisch.",
"The title is mandatory.": "Der Titel ist obligatorisch.",
"About": "Über",
"version": "Version",
"Version": "Version",
"Version:": "Version :",
"Build Date:": "Datum der Kompilierung:",
"Author:": "Autor:",
@@ -108,14 +108,14 @@
"Invalid username or password.": "Benutzername oder Passwort ungültig.",
"Never": "Niemals",
"Unable to execute request: %v": "Diese Anfrage konnte nicht ausgeführt werden: %v",
"Last Parsing Error": "Letzter Analyse Error",
"Last Parsing Error": "Letzter Analysefehler",
"There is a problem with this feed": "Es gibt ein Problem mit diesem Abonnement",
"Unable to parse OPML file: %v.": "OPML Datei konnte nicht gelesen werden: %v.",
"Unable to parse RSS feed: %v.": "RSS Feed konnte nicht gelesen werden: %v.",
"Unable to parse Atom feed: %v.": "Atom Feed konnte nicht gelesen werden: %v.",
"Unable to parse JSON feed: %v.": "JSON Feed konnte nicht gelesen werden: %v.",
"Unable to parse RDF feed: %v.": "RDF Feed konnte nicht gelesen werden: %v.",
"Unable to normalize encoding: %v.": "Zeichenkodierung konnte nicht normalisiert werden: %v.",
"Unable to parse OPML file: %q": "OPML Datei konnte nicht gelesen werden: %q",
"Unable to parse RSS feed: %q": "RSS Feed konnte nicht gelesen werden: %q",
"Unable to parse Atom feed: %q": "Atom Feed konnte nicht gelesen werden: %q",
"Unable to parse JSON feed: %q": "JSON Feed konnte nicht gelesen werden: %q",
"Unable to parse RDF feed: %q": "RDF Feed konnte nicht gelesen werden: %q",
"Unable to normalize encoding: %q": "Zeichenkodierung konnte nicht normalisiert werden: %q",
"Unable to create this category.": "Diese Kategorie konnte nicht angelegt werden.",
"yes": "ja",
"no": "nein",
@@ -133,7 +133,7 @@
"Sign in with Google": "Anmeldung mit Google",
"Unlink my Google account": "Google Konto abmelden",
"Link my Google account": "Google Konto verknüpfen",
"Category not found for this user.": "Diese Kategorie existiert nicht für diesen Benutzer.",
"Category not found for this user": "Diese Kategorie existiert nicht für diesen Benutzer",
"Invalid theme.": "Dieses Thema ist fehlerhaft.",
"Entry Sorting": "Sortierung der Artikel",
"Older entries first": "Älteste Artikel zuerst",
@@ -162,6 +162,9 @@
"Wallabag Client Secret": "Wallabag Client-Secret",
"Wallabag Username": "Wallabag Benutzername",
"Wallabag Password": "Wallabag Passwort",
"Save articles to Nunux Keeper": "Artikel in Nunux Keeper speichern",
"Nunux Keeper API Endpoint": "Nunux Keeper API-Endpunkt",
"Nunux Keeper API key": "Nunux Keeper API-Schlüssel",
"Keyboard Shortcut: %s": "Tastenkürzel: %s",
"Favorites": "Lesezeichen",
"Star": "Lesezeichen hinzufügen",
@@ -205,5 +208,14 @@
"Logged as %s": "Angemeldet als %s",
"Unread Items": "Ungelesen",
"Change entry status": "Status des Artikels ändern",
"Read": "Gelesen"
"Read": "Gelesen",
"Fever API endpoint:": "Fever API Endpunkt:",
"Miniflux API": "Miniflux API",
"API Endpoint": "API Endpunkt",
"Your account password": "Ihr Konto Passwort",
"This web page is empty": "Diese Webseite ist leer",
"Invalid SSL certificate (original error: %q)": "Ungültiges SSL-Zertifikat (ursprünglicher Fehler: %q)",
"This website is temporarily unreachable (original error: %q)": "Diese Webseite ist vorübergehend nicht erreichbar (ursprünglicher Fehler: %q)",
"This website is permanently unreachable (original error: %q)": "Diese Webseite ist dauerhaft nicht erreichbar (ursprünglicher Fehler: %q)",
"Website unreachable, the request timed out after %d seconds": "Webseite nicht erreichbar, die Anfrage endete nach %d Sekunden"
}
+25 -11
View File
@@ -84,8 +84,8 @@
"Last Login": "Dernière connexion",
"Yes": "Oui",
"No": "Non",
"This feed already exists (%s).": "Cet abonnement existe déjà (%s).",
"Unable to fetch feed (statusCode=%d).": "Impossible de récupérer cet abonnement (code=%d).",
"This feed already exists (%s)": "Cet abonnement existe déjà (%s)",
"Unable to fetch feed (statusCode=%d)": "Impossible de récupérer cet abonnement (code=%d)",
"Unable to open this link: %v": "Impossible d'ouvrir ce lien : %v",
"Unable to analyze this page: %v": "Impossible d'analyzer cette page : %v",
"Unable to find any subscription.": "Impossible de trouver un abonnement.",
@@ -97,7 +97,7 @@
"The username, theme, language and timezone fields are mandatory.": "Le nom d'utilisateur, le thème, la langue et le fuseau horaire sont obligatoire.",
"The title is mandatory.": "Le titre est obligatoire.",
"About": "A propos",
"version": "Version",
"Version": "Version",
"Version:": "Version :",
"Build Date:": "Date de la compilation :",
"Author:": "Auteur :",
@@ -110,12 +110,12 @@
"Unable to execute request: %v": "Impossible d'exécuter cette requête: %v",
"Last Parsing Error": "Dernière erreur d'analyse",
"There is a problem with this feed": "Il y a un problème avec cet abonnement",
"Unable to parse OPML file: %v.": "Impossible de lire ce fichier OPML : %v.",
"Unable to parse RSS feed: %v.": "Impossible de lire ce flux RSS: %v.",
"Unable to parse Atom feed: %v.": "Impossible de lire ce flux Atom: %v.",
"Unable to parse JSON feed: %v.": "Impossible de lire ce flux JSON: %v.",
"Unable to parse RDF feed: %v.": "Impossible de lire ce flux RDF: %v.",
"Unable to normalize encoding: %v.": "Impossible de normaliser l'encodage : %v.",
"Unable to parse OPML file: %q": "Impossible de lire ce fichier OPML : %q",
"Unable to parse RSS feed: %q": "Impossible de lire ce flux RSS : %q",
"Unable to parse Atom feed: %q": "Impossible de lire ce flux Atom : %q",
"Unable to parse JSON feed: %q": "Impossible de lire ce flux JSON : %q",
"Unable to parse RDF feed: %q": "Impossible de lire ce flux RDF : %q",
"Unable to normalize encoding: %q": "Impossible de normaliser l'encodage : %q",
"Unable to create this category.": "Impossible de créer cette catégorie.",
"yes": "oui",
"no": "non",
@@ -133,7 +133,7 @@
"Sign in with Google": "Se connecter avec Google",
"Unlink my Google account": "Dissocier mon compte Google",
"Link my Google account": "Associer mon compte Google",
"Category not found for this user.": "Cette catégorie n'existe pas pour cet utilisateur.",
"Category not found for this user": "Cette catégorie n'existe pas pour cet utilisateur",
"Invalid theme.": "Le thème est invalide.",
"Entry Sorting": "Ordre des éléments",
"Older entries first": "Ancien éléments en premier",
@@ -162,6 +162,9 @@
"Wallabag Client Secret": "Clé secrète du client Wallabag",
"Wallabag Username": "Nom d'utilisateur de Wallabag",
"Wallabag Password": "Mot de passe de Wallabag",
"Save articles to Nunux Keeper": "Sauvegarder les articles vers Nunux Keeper",
"Nunux Keeper API Endpoint": "URL de l'API de Nunux Keeper",
"Nunux Keeper API key": "Clé d'API de Nunux Keeper",
"Keyboard Shortcut: %s": "Raccourci clavier : %s",
"Favorites": "Favoris",
"Star": "Favoris",
@@ -184,6 +187,7 @@
"Items Navigation": "Naviguation entre les éléments",
"Go to previous item": "Élément précédent",
"Go to next item": "Élément suivant",
"Pages Navigation": "Naviguation entre les pages",
"Go to previous page": "Page précédente",
"Go to next page": "Page suivante",
"Open selected item": "Ouvrir élément sélectionné",
@@ -204,5 +208,15 @@
"Logged as %s": "Connecté en tant que %s",
"Unread Items": "Éléments non lus",
"Change entry status": "Changer le statut de l'élément",
"Read": "Lu"
"Read": "Lu",
"Fever API endpoint:": "Point de terminaison de l'API Fever :",
"Miniflux API": "API de Miniflux",
"API Endpoint": "Point de terminaison de l'API",
"Your account password": "Le mot de passe de votre compte",
"This web page is empty": "Cette page web est vide",
"Invalid SSL certificate (original error: %q)": "Certificat SSL invalide (erreur originale : %q)",
"This website is temporarily unreachable (original error: %q)": "Ce site web est temporairement injoignable (erreur originale : %q)",
"This website is permanently unreachable (original error: %q)": "Ce site web n'est pas joignable de façon permanente (erreur originale : %q)",
"Website unreachable, the request timed out after %d seconds": "Site web injoignable, la requête à échouée après %d secondes",
"Comments": "Commentaires"
}
+220
View File
@@ -0,0 +1,220 @@
{
"plural.feed.error_count": [
"%d error",
"%d errors"
],
"plural.categories.feed_count": [
"Er is %d feed.",
"Er zijn %d feeds."
],
"Username": "Gebruikersnaam",
"Password": "Wachtwoord",
"Unread": "Ongelezen",
"History": "Geschiedenis",
"Feeds": "Feeds",
"Categories": "Categorieën",
"Settings": "Instellingen",
"Logout": "Uitloggen",
"Next": "Volgende",
"Previous": "Vorige",
"New Subscription": "Nieuwe feed",
"Import": "Importeren",
"Export": "Exporteren",
"There is no category. You must have at least one category.": "Er zijn geen categorieën. Je moet op zijn minst één caterogie hebben.",
"URL": "URL",
"Category": "Categorie",
"Find a subscription": "Feed zoeken",
"Loading...": "Laden...",
"Create a category": "Categorie toevoegen",
"There is no category.": "Er zijn geen categorieën.",
"Edit": "Bewerken",
"Remove": "Verwijderen",
"No feed.": "Geen feeds.",
"There is no article in this category.": "Deze categorie bevat geen feeds.",
"Original": "Origineel",
"Mark this page as read": "Markeer deze pagina als gelezen",
"not yet": "in de toekomst",
"just now": "minder dan een minuut geleden",
"1 minute ago": "een minuut geleden",
"%d minutes ago": "%d minuten geleden",
"1 hour ago": "een uur geleden",
"%d hours ago": "%d uur geleden",
"yesterday": "gisteren",
"%d days ago": "%d dagen geleden",
"%d weeks ago": "%d weken geleden",
"%d months ago": "%d maanden geleden",
"%d years ago": "%d jaar geleden",
"Date": "Datum",
"IP Address": "IP-adres",
"User Agent": "User-agent",
"Actions": "Acties",
"Current session": "Huidige sessie",
"Sessions": "Sessies",
"Users": "Gebruikers",
"Add user": "Gebruiker toevoegen",
"Choose a Subscription": "Feed kiezen",
"Subscribe": "Abboneren",
"New Category": "Nieuwe categorie",
"Title": "Naam",
"Save": "Opslaan",
"or": "of",
"cancel": "annuleren",
"New User": "Nieuwe gebruiker",
"Confirmation": "Bevestig wachtwoord",
"Administrator": "Administrator",
"Edit Category: %s": "Bewerken van categorie: %s",
"Update": "Updaten",
"Edit Feed: %s": "Bewerken van feed: %s",
"There is no category!": "Er zijn geen categorieën!",
"Edit user: %s": "Gebruiker aanpassen: %s",
"There is no article for this feed.": "Er zijn geen artikelen in deze feed.",
"Add subscription": "Feed toevoegen",
"You don't have any subscription.": "Je hebt nog geen feeds geabboneerd staan.",
"Last check:": "Laatste update:",
"Refresh": "Vernieuwen",
"There is no history at the moment.": "Geschiedenis is op dit moment leeg.",
"OPML file": "OPML-bestand",
"Sign In": "Inloggen",
"Theme": "Skin",
"Timezone": "Tijdzone",
"Language": "Taal",
"There is no unread article.": "Er zijn geen ongelezen artikelen.",
"You are the only user.": "Je bent de enige gebruiker.",
"Last Login": "Laatste login",
"Yes": "Ja",
"No": "Nee",
"This feed already exists (%s)": "Deze feed bestaat al (%s)",
"Unable to fetch feed (statusCode=%d)": "Kon feed niet update (code=%d)",
"Unable to open this link: %v": "Kon link niet volgen: %v",
"Unable to analyze this page: %v": "Kon pagina niet analyseren: %v",
"Unable to find any subscription.": "Kon geen feeds vinden.",
"The URL and the category are mandatory.": "The URL en de categorie zijn verplicht.",
"All fields are mandatory.": "Alle velden moeten ingevuld zijn.",
"Passwords are not the same.": "Wachtwoorden zijn niet hetzelfde.",
"You must use at least 6 characters.": "Je moet minstens 6 tekens gebruiken.",
"The username is mandatory.": "Gebruikersnaam is verplicht",
"The username, theme, language and timezone fields are mandatory.": "Gebruikersnaam, skin, taal en tijdzone zijn verplicht.",
"The title is mandatory.": "Naam van categorie is verplicht.",
"About": "Over Miniflux",
"Version": "Versie",
"Version:": "Versie:",
"Build Date:": "Datum build:",
"Author:": "Auteur:",
"Authors": "Auteurs",
"License:": "Licentie:",
"Attachments": "Bijlages",
"Download": "Download",
"Invalid username or password.": "Onjuiste gebruikersnaam of wachtwoord.",
"Never": "Nooit",
"Unable to execute request: %v": "Kon request niet uitvoeren: %v",
"Last Parsing Error": "Laatste parse error",
"There is a problem with this feed": "Er is een probleem met deze feed",
"Unable to parse OPML file: %q": "Kon OPML niet parsen: %q",
"Unable to parse RSS feed: %q": "Kon RSS-feed niet parsen: %q",
"Unable to parse Atom feed: %q": "Kon Atom-feed niet parsen: %q",
"Unable to parse JSON feed: %q": "Kon JSON-feed niet parsen: %q",
"Unable to parse RDF feed: %q": "Kon RDF-feed niet parsen: %q",
"Unable to normalize encoding: %q": "Kon encoding niet normaliseren: %q",
"Unable to create this category.": "Kon categorie niet aanmaken.",
"yes": "ja",
"no": "nee",
"Are you sure?": "Weet je het zeker?",
"Work in progress...": "Bezig...",
"This user already exists.": "Deze gebruiker bestaat al.",
"This category already exists.": "Deze categorie bestaat al.",
"Unable to update this category.": "Kon categorie niet updaten.",
"Integrations": "Integraties",
"Bookmarklet": "Bookmarklet",
"Drag and drop this link to your bookmarks.": "Sleep deze link naar je bookmarks.",
"This special link allows you to subscribe to a website directly by using a bookmark in your web browser.": "Gebruik deze link als bookmark in je browser om je direct te abboneren op een website.",
"Add to Miniflux": "Toevoegen aan Miniflux",
"Refresh all feeds in background": "Vernieuw alle feeds in de achtergrond",
"Sign in with Google": "Inloggen via Google",
"Unlink my Google account": "Ontkoppel mijn Google-account",
"Link my Google account": "Koppel mijn Google-account",
"Category not found for this user": "Categorie niet gevonden voor deze gebruiker",
"Invalid theme.": "Ongeldige skin.",
"Entry Sorting": "Volgorde van items",
"Older entries first": "Oudere items eerst",
"Recent entries first": "Recente items eerst",
"Saving...": "Opslaag...",
"Done!": "Klaar!",
"Save this article": "Artikel opslaan",
"Mark bookmark as unread": "Markeer bookmark als gelezen",
"Pinboard Tags": "Pinboard tags",
"Pinboard API Token": "Pinboard API token",
"Save articles to Pinboard": "Artikelen opslaan naar Pinboard",
"Save articles to Instapaper": "Artikelen opstaan naar Instapaper",
"Instapaper Username": "Instapaper gebruikersnaam",
"Instapaper Password": "Instapaper wachtwoord",
"Activate Fever API": "Activeer Fever API",
"Fever Username": "Fever gebruikersnaam",
"Fever Password": "Fever wachtwoord",
"Fetch original content": "Download originele content",
"Scraper Rules": "Scraper regels",
"Rewrite Rules": "Rewrite regels",
"Preferences saved!": "Instellingen opgeslagen!",
"Your external account is now linked !": "Jouw externe account is nu gekoppeld!",
"Save articles to Wallabag": "Sauvegarder les articles vers Wallabag",
"Wallabag API Endpoint": "Wallabag URL",
"Wallabag Client ID": "Wallabag Client-ID",
"Wallabag Client Secret": "Wallabag Client-Secret",
"Wallabag Username": "Wallabag gebruikersnaam",
"Wallabag Password": "Wallabag wachtwoord",
"Save articles to Nunux Keeper": "Opslaan naar Nunux Keeper",
"Nunux Keeper API Endpoint": "Nunux Keeper URL",
"Nunux Keeper API key": "Nunux Keeper API-sleutel",
"Keyboard Shortcut: %s": "Sneltoets: %s",
"Favorites": "Favorieten",
"Star": "Ster toevoegen",
"Unstar": "Ster weghalen",
"Starred": "Favorieten",
"There is no bookmark at the moment.": "Er zijn op dit moment geen favorieten.",
"Last checked:": "Laatste update:",
"ETag header:": "ETAG-header:",
"LastModified header:": "LastModified-header:",
"None": "Geen",
"Keyboard Shortcuts": "Sneltoetsen",
"Sections Navigation": "Naviguatie tussen menu's",
"Go to unread": "Ga naar ongelezen",
"Go to bookmarks": "Ga naar favorieten",
"Go to history": "Ga naar geschiedenis",
"Go to feeds": "Ga naar feeds",
"Go to categories": "Ga naar categorieën",
"Go to settings": "Ga naar instellingen",
"Show keyboard shortcuts": "Laat sneltoetsen zien",
"Items Navigation": "Navigatie tussen items",
"Go to previous item": "Vorige item",
"Go to next item": "Volgende item",
"Pages Navigation": "Naviguatie tussen pagina's",
"Go to previous page": "Vorige pagina",
"Go to next page": "Volgende pagina",
"Open selected item": "Open geselecteerde link",
"Open original link": "Open originele link",
"Toggle read/unread": "Markeer gelezen/ongelezen",
"Mark current page as read": "Markeer deze pagina als gelezen",
"Download original content": "Download originele content",
"Toggle bookmark": "Ster toevoegen/weghalen",
"Close modal dialog": "Sluit dialoogscherm",
"Save article": "Artikel opslaan",
"There is already someone associated with this provider!": "Er is al iemand geregistreerd met deze provider!",
"There is already someone else with the same Fever username!": "Er is al iemand met dezelfde Fever gebruikersnaam!",
"Mark all as read": "Markeer alle items als gelezen",
"This feed is empty": "Deze feed is leeg",
"Flush history": "Verwijder geschiedenis",
"Site URL": "Website URL",
"Feed URL": "Feed URL",
"Logged as %s": "Ingelogd als %s",
"Unread Items": "Ongelezen items",
"Change entry status": "Verander status van item",
"Read": "Gelezen",
"Fever API endpoint:": "Fever URL:",
"Miniflux API": "Miniflux API",
"API Endpoint": "API-URL",
"Your account password": "Wachtwoord van jouw account",
"This web page is empty": "Deze webpagina is leeg",
"Invalid SSL certificate (original error: %q)": "Ongeldig SSL-certificaat (originele error: %q)",
"This website is temporarily unreachable (original error: %q)": "Deze website is tijdelijk onbereikbaar (originele error: %q)",
"This website is permanently unreachable (original error: %q)": "Deze website is permanent onbereikbaar (originele error: %q)",
"Website unreachable, the request timed out after %d seconds": "Website onbereikbaar, de request gaf een timeout na %d seconden"
}
+223
View File
@@ -0,0 +1,223 @@
{
"plural.feed.error_count": [
"%d błąd",
"%d błąd",
"%d błędów"
],
"plural.categories.feed_count": [
"Jest %d kanał.",
"Są %d kanały.",
"Jest %d kanałów."
],
"Username": "Nazwa użytkownika",
"Password": "Hasło",
"Unread": "Nieprzeczytane",
"History": "Historia",
"Feeds": "Kanały",
"Categories": "Kategorie",
"Settings": "Ustawienia",
"Logout": "Wyloguj się",
"Next": "Następny",
"Previous": "Poprzedni",
"New Subscription": "Nowa subskrypcja",
"Import": "Importuj",
"Export": "Eksportuj",
"There is no category. You must have at least one category.": "Nie ma żadnej kategorii. Musisz mieć co najmniej jedną kategorię",
"URL": "URL",
"Category": "Kategoria",
"Find a subscription": "Znajdź subskrypcję",
"Loading...": "Ładowanie...",
"Create a category": "Utwórz kategorię",
"There is no category.": "Nie masz żadnej kategorii",
"Edit": "Edytuj",
"Remove": "Usuń",
"No feed.": "Brak kanałów.",
"There is no article in this category.": "W tej kategorii nie ma żadnych artykułów",
"Original": "Oryginalny artykuł",
"Mark this page as read": "Oznacz jako przeczytane",
"not yet": "jeszcze nie",
"just now": "przed chwilą",
"1 minute ago": "minutę temu",
"%d minutes ago": "%d minut temu",
"1 hour ago": "godzinę temu",
"%d hours ago": "%d godzin temu",
"yesterday": "wczoraj",
"%d days ago": "%d dni temu",
"%d weeks ago": "%d tygodni temu",
"%d months ago": "%d miesięcy temu",
"%d years ago": "%d lat temu",
"Date": "Data",
"IP Address": "Adres IP",
"User Agent": "User Agent",
"Actions": "Działania",
"Current session": "Bieżąca sesja",
"Sessions": "Sesje",
"Users": "Użytkownicy",
"Add user": "Dodaj użytkownika",
"Choose a Subscription": "Wybierz subskrypcję",
"Subscribe": "Subskrypcja",
"New Category": "Nowa kategoria",
"Title": "Tytuł",
"Save": "Zapisz",
"or": "lub",
"cancel": "anuluj",
"New User": "Nowy użytkownik",
"Confirmation": "Potwierdź",
"Administrator": "Administrator",
"Edit Category: %s": "Edycja Kategorii: %s",
"Update": "Zaktualizuj",
"Edit Feed: %s": "Edytuj kanał: %s",
"There is no category!": "Nie ma żadnej kategorii!",
"Edit user: %s": "Edytuj użytkownika: %s",
"There is no article for this feed.": "Nie ma artykułu dla tego kanału.",
"Add subscription": "Dodaj subskrypcję",
"You don't have any subscription.": "Nie masz żadnej subskrypcji",
"Last check:": "Ostatnia aktualizacja:",
"Refresh": "Odśwież",
"There is no history at the moment.": "Obecnie nie ma żadnej historii.",
"OPML file": "Plik OPML",
"Sign In": "Zaloguj się",
"Sign in": "Zaloguj się",
"Theme": "Wygląd",
"Timezone": "Strefa czasowa",
"Language": "Język",
"There is no unread article.": "Nie ma żadnych nieprzeczytanych artykułów.",
"You are the only user.": "Jesteś jedynym użytkownikiem.",
"Last Login": "Ostatnie logowanie",
"Yes": "Tak",
"No": "Nie",
"This feed already exists (%s)": "Ten kanał już istnieje (%s)",
"Unable to fetch feed (statusCode=%d)": "Kanał nie mógł zostać pobrany (kod=%d)",
"Unable to open this link: %v": "Nie można było otworzyć tego linku: %v",
"Unable to analyze this page: %v": "Nie można przeanalizować tej strony: %v",
"Unable to find any subscription.": "Nie znaleziono żadnych subskrypcji.",
"The URL and the category are mandatory.": "URL i kategoria są obowiązkowe.",
"All fields are mandatory.": "Wszystkie pola są obowiązkowe.",
"Passwords are not the same.": "Hasła nie są identyczne.",
"You must use at least 6 characters.": "Musisz użyć co najmniej 6 znaków.",
"The username is mandatory.": "Nazwa użytkownika jest obowiązkowa.",
"The username, theme, language and timezone fields are mandatory.": "Pola nazwy użytkownika, tematu, języka i strefy czasowej są obowiązkowe.",
"The title is mandatory.": "Tytuł jest obowiązkowy.",
"About": "O stronie",
"Version": "Wersja",
"Version:": "Wersja :",
"Build Date:": "Data opracowania:",
"Author:": "Autor:",
"Authors": "Autorzy",
"License:": "Licencja:",
"Attachments": "Załączniki",
"Download": "Pobierz",
"Invalid username or password.": "Nieprawidłowa nazwa użytkownika lub hasło.",
"Never": "Nigdy",
"Unable to execute request: %v": "To polecenie nie mogło zostać wykonane: %v",
"Last Parsing Error": "Ostatni błąd analizy",
"There is a problem with this feed": "Z tym kanałem jest problem",
"Unable to parse OPML file: %q": "Plik OPML nie mógł zostać odczytany: %q",
"Unable to parse RSS feed: %q": "Nie można było odczytać kanału RSS: %q",
"Unable to parse Atom feed: %q": "Nie można było odczytać kanału Atom: %q",
"Unable to parse JSON feed: %q": "Nie można było odczytać kanału JSON: %q",
"Unable to parse RDF feed: %q": "Nie można było odczytać kanału RDF: %q",
"Unable to normalize encoding: %q": "Kodowanie znaków nie mogło zostać znormalizowane: %q",
"Unable to create this category.": "Ta kategoria nie mogła zostać utworzona.",
"yes": "tak",
"no": "nie",
"Are you sure?": "Czy jesteś pewny?",
"Work in progress...": "W toku...",
"This user already exists.": "Ten użytkownik już istnieje.",
"This category already exists.": "Ta kategoria już istnieje.",
"Unable to update this category.": "Ta kategoria nie mogła zostać zaktualizowana.",
"Integrations": "Usługi",
"Bookmarklet": "Bookmarklet",
"Drag and drop this link to your bookmarks.": "Przeciągnij i upuść to łącze do zakładek.",
"This special link allows you to subscribe to a website directly by using a bookmark in your web browser.": "Ten link umożliwia subskrypcję strony internetowej bezpośrednio za pomocą zakładki w przeglądarce internetowej",
"Add to Miniflux": "Dodaj do Miniflux",
"Refresh all feeds in background": "Odśwież wszystkie subskrypcje w tle",
"Sign in with Google": "Zaloguj przez Google",
"Unlink my Google account": "Odłącz moje konto Google",
"Link my Google account": "Połącz z moim kontem Google",
"Category not found for this user": "Kategoria nie znaleziona dla tego użytkownika",
"Invalid theme.": "Ten temat jest nieprawidłowy.",
"Entry Sorting": "Sortowanie artykułów",
"Older entries first": "Najstarsze wpisy jako pierwsze",
"Recent entries first": "Najnowsze wpisy jako pierwsze",
"Saving...": "Zapisywanie...",
"Done!": "Gotowe!",
"Save this article": "Zapisz ten artykuł",
"Mark bookmark as unread": "Zaznacz zakładkę jako nieprzeczytaną",
"Pinboard Tags": "Pinboard Tags",
"Pinboard API Token": "Token Pinboard API",
"Save articles to Pinboard": "Zapisz artykuł w Pinboard",
"Save articles to Instapaper": "Zapisz artykuł w Instapaper",
"Instapaper Username": "Login do Instapaper",
"Instapaper Password": "Hasło do Instapaper",
"Activate Fever API": "Aktywuj Fever API",
"Fever Username": "Login do Fever",
"Fever Password": "Hasło do Fever",
"Fetch original content": "Pobierz oryginalną treść",
"Scraper Rules": "Zasady ekstrakcji",
"Rewrite Rules": "Reguły zapisu",
"Preferences saved!": "Ustawienia zapisane!",
"Your external account is now linked !": "Twoje zewnętrzne konto jest teraz połączone!",
"Save articles to Wallabag": "Zapisz artykuły do Wallabag",
"Wallabag API Endpoint": "Wallabag URL",
"Wallabag Client ID": "Wallabag Client-ID",
"Wallabag Client Secret": "Wallabag Client Secret",
"Wallabag Username": "Login do Wallabag",
"Wallabag Password": "Hasło do Wallabag",
"Save articles to Nunux Keeper": "Zapisz artykuly do Nunux Keeper",
"Nunux Keeper API Endpoint": "Nunux Keeper URL",
"Nunux Keeper API key": "Nunux Keeper API key",
"Keyboard Shortcut: %s": "Skróty klawiszowe: %s",
"Favorites": "Ulubione",
"Star": "Oznacz gwiazdką",
"Unstar": "Usuń gwiazdkę",
"Starred": "Oznaczone gwiazdką",
"There is no bookmark at the moment.": "Obecnie nie ma żadnych zakładek.",
"Last checked:": "Ostatnio sprawdzone:",
"ETag header:": "Nagłówek ETag:",
"LastModified header:": "Ostatnio zmienione:",
"None": "Brak",
"Keyboard Shortcuts": "Skróty klawiszowe",
"Sections Navigation": "Nawigacja między punktami menu",
"Go to unread": "Przejdź do nieprzeczytanych artykułów",
"Go to bookmarks": "Przejdź do zakładek",
"Go to history": "Przejdź do historii",
"Go to feeds": "Przejdź do kanałów",
"Go to categories": "Przejdź do kategorii",
"Go to settings": "Przejdź do ustawień",
"Show keyboard shortcuts": "Pokaż listę skrótów klawiszowych",
"Items Navigation": "Nawigacja między artykułami",
"Go to previous item": "Przejdź do poprzedniego artykułu",
"Go to next item": "Przejdź do następnego punktu artykułu",
"Pages Navigation": "Nawigacja między stronami",
"Go to previous page": "Przejdź do poprzedniej strony",
"Go to next page": "Przejdź do następnej strony",
"Open selected item": "Otwórz zaznaczony artykuł",
"Open original link": "Otwórz oryginalny artykuł",
"Toggle read/unread": "Oznacz jako przeczytane/nieprzeczytane",
"Mark current page as read": "Zaznacz aktualną stronę jako przeczytaną",
"Download original content": "Pobierz oryginalną zawartość",
"Toggle bookmark": "Dodaj/usuń zakładki",
"Close modal dialog": "Zamknij listę skrótów klawiszowych",
"Save article": "Zapisz artykuł",
"There is already someone associated with this provider!": "Już ktoś jest powiązany z tym dostawcą!",
"There is already someone else with the same Fever username!": "Już ktoś inny używa tej nazwy użytkownika Fever!",
"Mark all as read": "Oznacz wszystko jako przeczytane",
"This feed is empty": "Ten kanał jest pusty",
"Flush history": "Usuń historię",
"Site URL": "URL strony",
"Feed URL": "URL kanału",
"Logged as %s": "Zalogowany jako %s",
"Unread Items": "Nieprzeczytane",
"Change entry status": "Zmień status artykułu",
"Read": "Przeczytane",
"Fever API endpoint:": "Fever API endpoint:",
"Miniflux API": "Miniflux API",
"API Endpoint": "API endpoint",
"Your account password": "Hasło konta",
"This web page is empty": "Ta strona jest pusta",
"Invalid SSL certificate (original error: %q)": "Certyfikat SSL jest nieprawidłowy (błąd: %q)",
"This website is temporarily unreachable (original error: %q)": "Ta strona jest tymczasowo niedostępna (błąd: %q)",
"This website is permanently unreachable (original error: %q)": "Ta strona jest niedostępna (błąd: %q)",
"Website unreachable, the request timed out after %d seconds": "Strona internetowa nieosiągalna, żądanie wygasło po %d sekundach"
}
+221
View File
@@ -0,0 +1,221 @@
{
"plural.feed.error_count": [
"%d 错误",
"%d 错误"
],
"plural.categories.feed_count": [
"有 %d 个源.",
"有 %d 个源."
],
"Username": "用户名",
"Password": "密码",
"Unread": "未读",
"History": "历史",
"Feeds": "源",
"Categories": "分类",
"Settings": "设置",
"Logout": "登出",
"Next": "下一页",
"Previous": "上一页",
"New Subscription": "新订阅",
"Import": "导入",
"Export": "导出",
"There is no category. You must have at least one category.": "目前没有分类.需要有一个已有的分类存在.",
"URL": "URL",
"Category": "分类",
"Find a subscription": "寻找订阅",
"Loading...": "载入中...",
"Create a category": "新建分类",
"There is no category.": "目前没有分类.",
"Edit": "编辑",
"Remove": "删除",
"No feed.": "没有源.",
"There is no article in this category.": "该分类下没有文章.",
"Original": "原始链接",
"Mark this page as read": "标记为已读",
"not yet": "尚未",
"just now": "刚刚",
"1 minute ago": "1 分钟前",
"%d minutes ago": "%d 分钟前",
"1 hour ago": "1 小时前",
"%d hours ago": "%d 小时前",
"yesterday": "昨天",
"%d days ago": "%d 天前",
"%d weeks ago": "%d 周前",
"%d months ago": "%d 月前",
"%d years ago": "%d 年前",
"Date": "日期",
"IP Address": "IP地址",
"User Agent": "User Agent",
"Actions": "操作",
"Current session": "当前会话",
"Sessions": "会话",
"Users": "用户",
"Add user": "新建用户",
"Choose a Subscription": "选择一个订阅",
"Subscribe": "订阅",
"New Category": "新分类",
"Title": "标题",
"Save": "保存",
"or": "或",
"cancel": "取消",
"New User": "新用户",
"Confirmation": "确认",
"Administrator": "管理员",
"Edit Category: %s": "编辑分类 : %s",
"Update": "更新",
"Edit Feed: %s": "编辑源 : %s",
"There is no category!": "没有分类!",
"Edit user: %s": "编辑用户: %s",
"There is no article for this feed.": "这个源中没有文章.",
"Add subscription": "新增订阅",
"You don't have any subscription.": "当前没有订阅",
"Last check:": "最后检查时间:",
"Refresh": "更新",
"There is no history at the moment.": "当前没有历史.",
"OPML file": "OPML 文件",
"Sign In": "登陆",
"Sign in": "登陆",
"Theme": "主题",
"Timezone": "时区",
"Language": "语言",
"There is no unread article.": "目前没有未读文章.",
"You are the only user.": "你是目前仅有的用户.",
"Last Login": "最后登录时间",
"Yes": "是",
"No": "否",
"This feed already exists (%s)": "源已存在 (%s)",
"Unable to fetch feed (statusCode=%d)": "无法获取源 (错误代码=%d)",
"Unable to open this link: %v": "无法打开这一链接: %v",
"Unable to analyze this page: %v": "无法分析这一页面: %v",
"Unable to find any subscription.": "找不到任何订阅.",
"The URL and the category are mandatory.": "必须填写URL和分类.",
"All fields are mandatory.": "必须填写全部信息.",
"Passwords are not the same.": "两次输入的密码不同.",
"You must use at least 6 characters.": "请至少使用6个字符.",
"The username is mandatory.": "必须填写用户名.",
"The username, theme, language and timezone fields are mandatory.": "必须填写用户名,主题,语言和时区.",
"The title is mandatory.": "必须填写标题.",
"About": "关于",
"Version": "版本",
"Version:": "版本:",
"Build Date:": "构建日期:",
"Author:": "作者:",
"Authors": "作者",
"License:": "协议:",
"Attachments": "附件",
"Download": "下载",
"Invalid username or password.": "用户名或密码无效.",
"Never": "永不",
"Unable to execute request: %v": "无法执行这一请求: %v",
"Last Parsing Error": "最后一次解析错误",
"There is a problem with this feed": "这一源存在问题",
"Unable to parse OPML file: %q": "无法解析OPML文件: %q",
"Unable to parse RSS feed: %q": "无法解析RSS源: %q",
"Unable to parse Atom feed: %q": "无法解析Atom源: %q",
"Unable to parse JSON feed: %q": "无法解析JSON源: %q",
"Unable to parse RDF feed: %q": "无法解析RDF源: %q",
"Unable to normalize encoding: %q": "无法正则化编码: %q",
"Unable to create this category.": "无法建立这个分类.",
"yes": "是",
"no": "否",
"Are you sure?": "您确认吗?",
"Work in progress...": "执行中...",
"This user already exists.": "用户已存在.",
"This category already exists.": "分类已存在.",
"Unable to update this category.": "无法更新该分类.",
"Integrations": "集成",
"Bookmarklet": "书签小应用",
"Drag and drop this link to your bookmarks.": "拖动这个链接到书签.",
"This special link allows you to subscribe to a website directly by using a bookmark in your web browser.": "你可以打开这个特殊的书签来直接订阅网站.",
"Add to Miniflux": "新增到Miniflux",
"Refresh all feeds in background": "在后台更新全部源",
"Sign in with Google": "使用Google登陆",
"Unlink my Google account": "去除Google账号关联",
"Link my Google account": "关联我的Google账户",
"Category not found for this user": "未找到该用户的这一分类",
"Invalid theme.": "无效的主题.",
"Entry Sorting": "内容排序",
"Older entries first": "旧->新",
"Recent entries first": "新->旧",
"Saving...": "保存中",
"Done!": "完成!",
"Save this article": "保存这篇文章",
"Mark bookmark as unread": "标记为未读",
"Pinboard Tags": "Pinboard 标签",
"Pinboard API Token": "Pinboard API Token",
"Save articles to Pinboard": "保存文章到Pinboard",
"Save articles to Instapaper": "保存文章到Instapaper",
"Instapaper Username": "Instapaper 用户名",
"Instapaper Password": "Instapaper 密码",
"Activate Fever API": "启用 Fever API",
"Fever Username": "Fever 用户名",
"Fever Password": "Fever 密码",
"Fetch original content": "抓取原内容",
"Scraper Rules": "Scraper规则",
"Rewrite Rules": "重写规则",
"Preferences saved!": "偏好已存储!",
"Your external account is now linked !": "您的外部账号已关联!",
"Save articles to Wallabag": "保存文章到Wallabag",
"Wallabag API Endpoint": "Wallabag URL",
"Wallabag Client ID": "Wallabag 客户端ID",
"Wallabag Client Secret": "Wallabag 客户端Secret",
"Wallabag Username": "Wallabag 用户名",
"Wallabag Password": "Wallabag 密码",
"Save articles to Nunux Keeper": "保存文章到Nunux Keeper",
"Nunux Keeper API Endpoint": "Nunux Keeper API Endpoint",
"Nunux Keeper API key": "Nunux Keeper API key",
"Keyboard Shortcut: %s": "快捷键: %s",
"Favorites": "收藏",
"Star": "标记星标",
"Unstar": "去掉星标",
"Starred": "星标",
"There is no bookmark at the moment.": "当前没有书签.",
"Last checked:": "上次检查:",
"ETag header:": "ETag header:",
"LastModified header:": "最后修改的Header:",
"None": "无",
"Keyboard Shortcuts": "快捷键",
"Sections Navigation": "分区导航",
"Go to unread": "去往未读",
"Go to bookmarks": "去往书签",
"Go to history": "去往历史",
"Go to feeds": "去往源",
"Go to categories": "去往分类",
"Go to settings": "去往设定",
"Show keyboard shortcuts": "显示快捷键",
"Items Navigation": "条目导航",
"Go to previous item": "上一条目",
"Go to next item": "下一条目",
"Pages Navigation": "页面导航",
"Go to previous page": "上一页",
"Go to next page": "下一页",
"Open selected item": "打开选定的条目",
"Open original link": "打开原始链接",
"Toggle read/unread": "切换已读/未读状态",
"Mark current page as read": "标记当前",
"Download original content": "下载原始内容",
"Toggle bookmark": "切换收藏状态",
"Close modal dialog": "关闭模态对话窗口",
"Save article": "保存文章",
"There is already someone associated with this provider!": "该Provider已被关联!",
"There is already someone else with the same Fever username!": "Fever用户名已被占用!",
"Mark all as read": "全标记为已读",
"This feed is empty": "该源是空的",
"Flush history": "清理历史",
"Site URL": "站点URL",
"Feed URL": "源URL",
"Logged as %s": "当前登录 %s",
"Unread Items": "未读条目",
"Change entry status": "更改状态",
"Read": "标为已读",
"Fever API endpoint:": "Fever API Endpoint:",
"Miniflux API": "Miniflux API",
"API Endpoint": "API Endpoint",
"Your account password": "您账户的密码",
"This web page is empty": "该网页是空的",
"Invalid SSL certificate (original error: %q)": "无效的SSL证书 (原始错误: %q)",
"This website is temporarily unreachable (original error: %q)": "该网站暂时不可达 (原始错误: %q)",
"This website is permanently unreachable (original error: %q)": "该网站永久不可达 (原始错误: %q)",
"Website unreachable, the request timed out after %d seconds": "网站不可达, 请求已在 %d 秒后超时"
}
+55 -7
View File
@@ -10,28 +10,76 @@ import (
"time"
)
var requestedLevel = InfoLevel
// LogLevel type.
type LogLevel uint32
const (
// FatalLevel should be used in fatal situations, the app will exit.
FatalLevel LogLevel = iota
// ErrorLevel should be used when someone should really look at the error.
ErrorLevel
// InfoLevel should be used during normal operations.
InfoLevel
// DebugLevel should be used only during development.
DebugLevel
)
func (level LogLevel) String() string {
switch level {
case DebugLevel:
return "DEBUG"
case InfoLevel:
return "INFO"
case ErrorLevel:
return "ERROR"
case FatalLevel:
return "FATAL"
default:
return "UNKNOWN"
}
}
// EnableDebug increases logging, more verbose (debug)
func EnableDebug() {
requestedLevel = DebugLevel
formatMessage(InfoLevel, "Debug mode enabled")
}
// Debug sends a debug log message.
func Debug(format string, v ...interface{}) {
formatMessage("DEBUG", format, v...)
if requestedLevel >= DebugLevel {
formatMessage(DebugLevel, format, v...)
}
}
// Info sends an info log message.
func Info(format string, v ...interface{}) {
formatMessage("INFO", format, v...)
if requestedLevel >= InfoLevel {
formatMessage(InfoLevel, format, v...)
}
}
// Error sends an error log message.
func Error(format string, v ...interface{}) {
formatMessage("ERROR", format, v...)
if requestedLevel >= ErrorLevel {
formatMessage(ErrorLevel, format, v...)
}
}
// Fatal sends a fatal log message and stop the execution of the program.
func Fatal(format string, v ...interface{}) {
formatMessage("FATAL", format, v...)
os.Exit(1)
if requestedLevel >= FatalLevel {
formatMessage(FatalLevel, format, v...)
os.Exit(1)
}
}
func formatMessage(level, format string, v ...interface{}) {
prefix := fmt.Sprintf("[%s] [%s] ", time.Now().Format("2006-01-02T15:04:05"), level)
func formatMessage(level LogLevel, format string, v ...interface{}) {
prefix := fmt.Sprintf("[%s] [%s] ", time.Now().Format("2006-01-02T15:04:05"), level.String())
fmt.Fprintf(os.Stderr, prefix+format+"\n", v...)
}
+1 -1
View File
@@ -11,7 +11,7 @@ type Enclosure struct {
EntryID int64 `json:"entry_id"`
URL string `json:"url"`
MimeType string `json:"mime_type"`
Size int `json:"size"`
Size int64 `json:"size"`
}
// EnclosureList represents a list of attachments.
+15 -14
View File
@@ -20,20 +20,21 @@ const (
// Entry represents a feed item in the system.
type Entry struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
FeedID int64 `json:"feed_id"`
Status string `json:"status"`
Hash string `json:"hash"`
Title string `json:"title"`
URL string `json:"url"`
Date time.Time `json:"published_at"`
Content string `json:"content"`
Author string `json:"author"`
Starred bool `json:"starred"`
Enclosures EnclosureList `json:"enclosures,omitempty"`
Feed *Feed `json:"feed,omitempty"`
Category *Category `json:"category,omitempty"`
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
FeedID int64 `json:"feed_id"`
Status string `json:"status"`
Hash string `json:"hash"`
Title string `json:"title"`
URL string `json:"url"`
CommentsURL string `json:"comments_url"`
Date time.Time `json:"published_at"`
Content string `json:"content"`
Author string `json:"author"`
Starred bool `json:"starred"`
Enclosures EnclosureList `json:"enclosures,omitempty"`
Feed *Feed `json:"feed,omitempty"`
Category *Category `json:"category,omitempty"`
}
// Entries represents a list of entries.
+3
View File
@@ -24,4 +24,7 @@ type Integration struct {
WallabagClientSecret string
WallabagUsername string
WallabagPassword string
NunuxKeeperEnabled bool
NunuxKeeperURL string
NunuxKeeperAPIKey string
}
+16
View File
@@ -7,6 +7,8 @@ package model
import (
"errors"
"time"
"github.com/miniflux/miniflux/timezone"
)
// User represents a user in the system.
@@ -99,5 +101,19 @@ func (u *User) Merge(override *User) {
}
}
// UseTimezone converts last login date to the given timezone.
func (u *User) UseTimezone(tz string) {
if u.LastLoginAt != nil {
*u.LastLoginAt = timezone.Convert(tz, *u.LastLoginAt)
}
}
// Users represents a list of users.
type Users []*User
// UseTimezone converts last login timestamp of all users to the given timezone.
func (u Users) UseTimezone(tz string) {
for _, user := range u {
user.UseTimezone(tz)
}
}
+20 -4
View File
@@ -4,8 +4,12 @@
package model
import "time"
import "fmt"
import (
"fmt"
"time"
"github.com/miniflux/miniflux/timezone"
)
// UserSession represents a user session in the system.
type UserSession struct {
@@ -17,9 +21,21 @@ type UserSession struct {
IP string
}
func (s *UserSession) String() string {
return fmt.Sprintf(`ID="%d", UserID="%d", IP="%s", Token="%s"`, s.ID, s.UserID, s.IP, s.Token)
func (u *UserSession) String() string {
return fmt.Sprintf(`ID="%d", UserID="%d", IP="%s", Token="%s"`, u.ID, u.UserID, u.IP, u.Token)
}
// UseTimezone converts creation date to the given timezone.
func (u *UserSession) UseTimezone(tz string) {
u.CreatedAt = timezone.Convert(tz, u.CreatedAt)
}
// UserSessions represents a list of sessions.
type UserSessions []*UserSession
// UseTimezone converts creation date of all sessions to the given timezone.
func (u UserSessions) UseTimezone(tz string) {
for _, session := range u {
session.UseTimezone(tz)
}
}
+15 -3
View File
@@ -14,6 +14,7 @@ import (
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/reader/date"
"github.com/miniflux/miniflux/reader/sanitizer"
"github.com/miniflux/miniflux/url"
)
@@ -28,7 +29,7 @@ type atomFeed struct {
type atomEntry struct {
ID string `xml:"id"`
Title string `xml:"title"`
Title atomContent `xml:"title"`
Updated string `xml:"updated"`
Links []atomLink `xml:"link"`
Summary string `xml:"summary"`
@@ -97,7 +98,7 @@ func (a *atomEntry) Transform() *model.Entry {
entry.Author = getAuthor(a.Author)
entry.Hash = getHash(a)
entry.Content = getContent(a)
entry.Title = strings.TrimSpace(a.Title)
entry.Title = getTitle(a)
entry.Enclosures = getEnclosures(a)
return entry
}
@@ -160,6 +161,17 @@ func getContent(a *atomEntry) string {
return ""
}
func getTitle(a *atomEntry) string {
title := ""
if a.Title.Type == "xhtml" {
title = a.Title.XML
} else {
title = a.Title.Data
}
return strings.TrimSpace(sanitizer.StripTags(title))
}
func getHash(a *atomEntry) string {
for _, value := range []string{a.ID, getURL(a.Links)} {
if value != "" {
@@ -175,7 +187,7 @@ func getEnclosures(a *atomEntry) model.EnclosureList {
for _, link := range a.Links {
if strings.ToLower(link.Rel) == "enclosure" {
length, _ := strconv.Atoi(link.Length)
length, _ := strconv.ParseInt(link.Length, 10, 0)
enclosures = append(enclosures, &model.Enclosure{URL: link.URL, MimeType: link.Type, Size: length})
}
}
+2 -2
View File
@@ -14,14 +14,14 @@ import (
)
// Parse returns a normalized feed struct from a Atom feed.
func Parse(data io.Reader) (*model.Feed, error) {
func Parse(data io.Reader) (*model.Feed, *errors.LocalizedError) {
atomFeed := new(atomFeed)
decoder := xml.NewDecoder(data)
decoder.CharsetReader = encoding.CharsetReader
err := decoder.Decode(atomFeed)
if err != nil {
return nil, errors.NewLocalizedError("Unable to parse Atom feed: %v.", err)
return nil, errors.NewLocalizedError("Unable to parse Atom feed: %q", err)
}
return atomFeed.Transform(), nil
+78 -6
View File
@@ -8,8 +8,6 @@ import (
"bytes"
"testing"
"time"
"github.com/miniflux/miniflux/errors"
)
func TestParseAtomSample(t *testing.T) {
@@ -206,6 +204,84 @@ func TestParseEntryTitleWithWhitespaces(t *testing.T) {
}
}
func TestParseEntryTitleWithHTMLAndCDATA(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<entry>
<title type="html"><![CDATA[Test &#8220;Test&#8221;]]></title>
<link href="http://example.org/2003/12/13/atom03"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary>Some text.</summary>
</entry>
</feed>`
feed, err := Parse(bytes.NewBufferString(data))
if err != nil {
t.Error(err)
}
if feed.Entries[0].Title != "Test “Test”" {
t.Errorf("Incorrect entry title, got: %q", feed.Entries[0].Title)
}
}
func TestParseEntryTitleWithHTML(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<entry>
<title type="html">&lt;code&gt;Test&lt;/code&gt; Test</title>
<link href="http://example.org/2003/12/13/atom03"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary>Some text.</summary>
</entry>
</feed>`
feed, err := Parse(bytes.NewBufferString(data))
if err != nil {
t.Error(err)
}
if feed.Entries[0].Title != "Test Test" {
t.Errorf("Incorrect entry title, got: %q", feed.Entries[0].Title)
}
}
func TestParseEntryTitleWithXHTML(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<entry>
<title type="xhtml"><code>Test</code> Test</title>
<link href="http://example.org/2003/12/13/atom03"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary>Some text.</summary>
</entry>
</feed>`
feed, err := Parse(bytes.NewBufferString(data))
if err != nil {
t.Error(err)
}
if feed.Entries[0].Title != "Test Test" {
t.Errorf("Incorrect entry title, got: %q", feed.Entries[0].Title)
}
}
func TestParseEntryWithAuthorName(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
@@ -352,8 +428,4 @@ func TestParseInvalidXml(t *testing.T) {
if err == nil {
t.Error("Parse should returns an error")
}
if _, ok := err.(errors.LocalizedError); !ok {
t.Error("The error returned must be a LocalizedError")
}
}
+36 -18
View File
@@ -10,6 +10,7 @@ import (
"github.com/miniflux/miniflux/errors"
"github.com/miniflux/miniflux/http"
"github.com/miniflux/miniflux/locale"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/reader/icon"
@@ -20,17 +21,18 @@ import (
var (
errRequestFailed = "Unable to execute request: %v"
errServerFailure = "Unable to fetch feed (statusCode=%d)."
errDuplicate = "This feed already exists (%s)."
errServerFailure = "Unable to fetch feed (statusCode=%d)"
errDuplicate = "This feed already exists (%s)"
errNotFound = "Feed %d not found"
errEncoding = "Unable to normalize encoding: %v."
errCategoryNotFound = "Category not found for this user."
errEncoding = "Unable to normalize encoding: %q"
errCategoryNotFound = "Category not found for this user"
errEmptyFeed = "This feed is empty"
)
// Handler contains all the logic to create and refresh feeds.
type Handler struct {
store *storage.Storage
store *storage.Storage
translator *locale.Translator
}
// CreateFeed fetch, parse and store a new feed.
@@ -44,6 +46,9 @@ func (h *Handler) CreateFeed(userID, categoryID int64, url string, crawler bool)
client := http.NewClient(url)
response, err := client.Get()
if err != nil {
if _, ok := err.(*errors.LocalizedError); ok {
return nil, err
}
return nil, errors.NewLocalizedError(errRequestFailed, err)
}
@@ -65,9 +70,9 @@ func (h *Handler) CreateFeed(userID, categoryID int64, url string, crawler bool)
return nil, errors.NewLocalizedError(errEncoding, err)
}
subscription, err := parseFeed(body)
if err != nil {
return nil, err
subscription, feedErr := parseFeed(body)
if feedErr != nil {
return nil, feedErr
}
feedProcessor := processor.NewFeedProcessor(userID, h.store, subscription)
@@ -107,6 +112,13 @@ func (h *Handler) CreateFeed(userID, categoryID int64, url string, crawler bool)
// RefreshFeed fetch and update a feed if necessary.
func (h *Handler) RefreshFeed(userID, feedID int64) error {
defer timer.ExecutionTime(time.Now(), fmt.Sprintf("[Handler:RefreshFeed] feedID=%d", feedID))
userLanguage, err := h.store.UserLanguage(userID)
if err != nil {
logger.Error("[Handler:RefreshFeed] %v", err)
userLanguage = "en_US"
}
currentLanguage := h.translator.GetLanguage(userLanguage)
originalFeed, err := h.store.FeedByID(userID, feedID)
if err != nil {
@@ -120,9 +132,15 @@ func (h *Handler) RefreshFeed(userID, feedID int64) error {
client := http.NewClientWithCacheHeaders(originalFeed.FeedURL, originalFeed.EtagHeader, originalFeed.LastModifiedHeader)
response, err := client.Get()
if err != nil {
customErr := errors.NewLocalizedError(errRequestFailed, err)
var customErr errors.LocalizedError
if lerr, ok := err.(*errors.LocalizedError); ok {
customErr = *lerr
} else {
customErr = *errors.NewLocalizedError(errRequestFailed, err)
}
originalFeed.ParsingErrorCount++
originalFeed.ParsingErrorMsg = customErr.Error()
originalFeed.ParsingErrorMsg = customErr.Localize(currentLanguage)
h.store.UpdateFeed(originalFeed)
return customErr
}
@@ -132,7 +150,7 @@ func (h *Handler) RefreshFeed(userID, feedID int64) error {
if response.HasServerFailure() {
err := errors.NewLocalizedError(errServerFailure, response.StatusCode)
originalFeed.ParsingErrorCount++
originalFeed.ParsingErrorMsg = err.Error()
originalFeed.ParsingErrorMsg = err.Localize(currentLanguage)
h.store.UpdateFeed(originalFeed)
return err
}
@@ -144,7 +162,7 @@ func (h *Handler) RefreshFeed(userID, feedID int64) error {
if response.ContentLength == 0 {
err := errors.NewLocalizedError(errEmptyFeed)
originalFeed.ParsingErrorCount++
originalFeed.ParsingErrorMsg = err.Error()
originalFeed.ParsingErrorMsg = err.Localize(currentLanguage)
h.store.UpdateFeed(originalFeed)
return err
}
@@ -154,10 +172,10 @@ func (h *Handler) RefreshFeed(userID, feedID int64) error {
return errors.NewLocalizedError(errEncoding, err)
}
subscription, err := parseFeed(body)
if err != nil {
subscription, parseErr := parseFeed(body)
if parseErr != nil {
originalFeed.ParsingErrorCount++
originalFeed.ParsingErrorMsg = err.Error()
originalFeed.ParsingErrorMsg = parseErr.Localize(currentLanguage)
h.store.UpdateFeed(originalFeed)
return err
}
@@ -180,7 +198,7 @@ func (h *Handler) RefreshFeed(userID, feedID int64) error {
logger.Debug("[Handler:RefreshFeed] Looking for feed icon")
icon, err := icon.FindIcon(originalFeed.SiteURL)
if err != nil {
logger.Error("[Handler:RefreshFeed] %v", err)
logger.Debug("[Handler:RefreshFeed] %v", err)
} else {
h.store.CreateFeedIcon(originalFeed, icon)
}
@@ -200,6 +218,6 @@ func (h *Handler) RefreshFeed(userID, feedID int64) error {
}
// NewFeedHandler returns a feed handler.
func NewFeedHandler(store *storage.Storage) *Handler {
return &Handler{store: store}
func NewFeedHandler(store *storage.Storage, translator *locale.Translator) *Handler {
return &Handler{store, translator}
}
+33 -5
View File
@@ -7,11 +7,12 @@ package feed
import (
"bytes"
"encoding/xml"
"errors"
"io"
"strings"
"time"
"github.com/miniflux/miniflux/errors"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/reader/atom"
"github.com/miniflux/miniflux/reader/encoding"
@@ -65,13 +66,17 @@ func DetectFeedFormat(r io.Reader) string {
return FormatUnknown
}
func parseFeed(r io.Reader) (*model.Feed, error) {
func parseFeed(r io.Reader) (*model.Feed, *errors.LocalizedError) {
defer timer.ExecutionTime(time.Now(), "[Feed:ParseFeed]")
var buffer bytes.Buffer
io.Copy(&buffer, r)
size, _ := io.Copy(&buffer, r)
if size == 0 {
return nil, errors.NewLocalizedError("This feed is empty")
}
reader := bytes.NewReader(buffer.Bytes())
str := stripInvalidXMLCharacters(buffer.String())
reader := strings.NewReader(str)
format := DetectFeedFormat(reader)
reader.Seek(0, io.SeekStart)
@@ -85,6 +90,29 @@ func parseFeed(r io.Reader) (*model.Feed, error) {
case FormatRDF:
return rdf.Parse(reader)
default:
return nil, errors.New("Unsupported feed format")
return nil, errors.NewLocalizedError("Unsupported feed format")
}
}
func stripInvalidXMLCharacters(input string) string {
return strings.Map(func(r rune) rune {
if isInCharacterRange(r) {
return r
}
logger.Debug("Strip invalid XML characters: %U", r)
return -1
}, input)
}
// Decide whether the given rune is in the XML Character Range, per
// the Char production of http://www.xml.com/axml/testaxml.htm,
// Section 2.2 Characters.
func isInCharacterRange(r rune) (inrange bool) {
return r == 0x09 ||
r == 0x0A ||
r == 0x0D ||
r >= 0x20 && r <= 0xDF77 ||
r >= 0xE000 && r <= 0xFFFD ||
r >= 0x10000 && r <= 0x10FFFF
}
+7
View File
@@ -205,3 +205,10 @@ func TestParseUnknownFeed(t *testing.T) {
t.Error("ParseFeed must returns an error")
}
}
func TestParseEmptyFeed(t *testing.T) {
_, err := parseFeed(bytes.NewBufferString(""))
if err == nil {
t.Error("ParseFeed must returns an error")
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ type jsonAttachment struct {
URL string `json:"url"`
MimeType string `json:"mime_type"`
Title string `json:"title"`
Size int `json:"size_in_bytes"`
Size int64 `json:"size_in_bytes"`
Duration int `json:"duration_in_seconds"`
}
+2 -2
View File
@@ -13,11 +13,11 @@ import (
)
// Parse returns a normalized feed struct from a JON feed.
func Parse(data io.Reader) (*model.Feed, error) {
func Parse(data io.Reader) (*model.Feed, *errors.LocalizedError) {
feed := new(jsonFeed)
decoder := json.NewDecoder(data)
if err := decoder.Decode(&feed); err != nil {
return nil, errors.NewLocalizedError("Unable to parse JSON Feed: %v", err)
return nil, errors.NewLocalizedError("Unable to parse JSON Feed: %q", err)
}
return feed.Transform(), nil
-6
View File
@@ -9,8 +9,6 @@ import (
"strings"
"testing"
"time"
"github.com/miniflux/miniflux/errors"
)
func TestParseJsonFeed(t *testing.T) {
@@ -377,8 +375,4 @@ func TestParseInvalidJSON(t *testing.T) {
if err == nil {
t.Error("Parse should returns an error")
}
if _, ok := err.(errors.LocalizedError); !ok {
t.Error("The error returned must be a LocalizedError")
}
}
+2 -1
View File
@@ -41,7 +41,7 @@ func (h *Handler) Export(userID int64) (string, error) {
}
// Import parses and create feeds from an OPML import.
func (h *Handler) Import(userID int64, data io.Reader) (err error) {
func (h *Handler) Import(userID int64, data io.Reader) error {
subscriptions, err := Parse(data)
if err != nil {
return err
@@ -50,6 +50,7 @@ func (h *Handler) Import(userID int64, data io.Reader) (err error) {
for _, subscription := range subscriptions {
if !h.store.FeedURLExists(userID, subscription.FeedURL) {
var category *model.Category
var err error
if subscription.CategoryName == "" {
category, err = h.store.FirstCategory(userID)
+2 -2
View File
@@ -13,14 +13,14 @@ import (
)
// Parse reads an OPML file and returns a SubcriptionList.
func Parse(data io.Reader) (SubcriptionList, error) {
func Parse(data io.Reader) (SubcriptionList, *errors.LocalizedError) {
feeds := new(opml)
decoder := xml.NewDecoder(data)
decoder.CharsetReader = encoding.CharsetReader
err := decoder.Decode(feeds)
if err != nil {
return nil, errors.NewLocalizedError("Unable to parse OPML file: %v.", err)
return nil, errors.NewLocalizedError("Unable to parse OPML file: %q", err)
}
return feeds.Transform(), nil
-6
View File
@@ -7,8 +7,6 @@ package opml
import (
"bytes"
"testing"
"github.com/miniflux/miniflux/errors"
)
func TestParseOpmlWithoutCategories(t *testing.T) {
@@ -130,8 +128,4 @@ func TestParseInvalidXML(t *testing.T) {
if err == nil {
t.Error("Parse should generate an error")
}
if _, ok := err.(errors.LocalizedError); !ok {
t.Error("The error returned must be a LocalizedError")
}
}
+2 -2
View File
@@ -14,14 +14,14 @@ import (
)
// Parse returns a normalized feed struct from a RDF feed.
func Parse(data io.Reader) (*model.Feed, error) {
func Parse(data io.Reader) (*model.Feed, *errors.LocalizedError) {
feed := new(rdfFeed)
decoder := xml.NewDecoder(data)
decoder.CharsetReader = encoding.CharsetReader
err := decoder.Decode(feed)
if err != nil {
return nil, errors.NewLocalizedError("Unable to parse RDF feed: %v.", err)
return nil, errors.NewLocalizedError("Unable to parse RDF feed: %q", err)
}
return feed.Transform(), nil
-6
View File
@@ -9,8 +9,6 @@ import (
"strings"
"testing"
"time"
"github.com/miniflux/miniflux/errors"
)
func TestParseRDFSample(t *testing.T) {
@@ -330,8 +328,4 @@ func TestParseInvalidXml(t *testing.T) {
if err == nil {
t.Error("Parse should returns an error")
}
if _, ok := err.(errors.LocalizedError); !ok {
t.Error("The error returned must be a LocalizedError")
}
}
+2 -2
View File
@@ -14,14 +14,14 @@ import (
)
// Parse returns a normalized feed struct from a RSS feed.
func Parse(data io.Reader) (*model.Feed, error) {
func Parse(data io.Reader) (*model.Feed, *errors.LocalizedError) {
feed := new(rssFeed)
decoder := xml.NewDecoder(data)
decoder.CharsetReader = encoding.CharsetReader
err := decoder.Decode(feed)
if err != nil {
return nil, errors.NewLocalizedError("Unable to parse RSS feed: %v.", err)
return nil, errors.NewLocalizedError("Unable to parse RSS feed: %q", err)
}
return feed.Transform(), nil
+48 -6
View File
@@ -8,8 +8,6 @@ import (
"bytes"
"testing"
"time"
"github.com/miniflux/miniflux/errors"
)
func TestParseRss2Sample(t *testing.T) {
@@ -232,6 +230,31 @@ func TestParseFeedURLWithAtomLink(t *testing.T) {
}
}
func TestParseEntryWithAuthorAndInnerHTML(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
<channel>
<title>Example</title>
<link>https://example.org/</link>
<atom:link href="https://example.org/rss" type="application/rss+xml" rel="self"></atom:link>
<item>
<title>Test</title>
<link>https://example.org/item</link>
<author>by <a itemprop="url" class="author" rel="author" href="/author/foobar">Foo Bar</a></author>
</item>
</channel>
</rss>`
feed, err := Parse(bytes.NewBufferString(data))
if err != nil {
t.Error(err)
}
if feed.Entries[0].Author != "by Foo Bar" {
t.Errorf("Incorrect entry author, got: %s", feed.Entries[0].Author)
}
}
func TestParseEntryWithAtomAuthor(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
@@ -558,14 +581,33 @@ func TestParseEntryWithRelativeURL(t *testing.T) {
}
}
func TestParseEntryWithCommentsURL(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<link>https://example.org/</link>
<item>
<title>Item 1</title>
<link>https://example.org/item1</link>
<comments>https://example.org/comments</comments>
</item>
</channel>
</rss>`
feed, err := Parse(bytes.NewBufferString(data))
if err != nil {
t.Error(err)
}
if feed.Entries[0].CommentsURL != "https://example.org/comments" {
t.Errorf("Incorrect entry comments URL, got: %q", feed.Entries[0].CommentsURL)
}
}
func TestParseInvalidXml(t *testing.T) {
data := `garbage`
_, err := Parse(bytes.NewBufferString(data))
if err == nil {
t.Error("Parse should returns an error")
}
if _, ok := err.(errors.LocalizedError); !ok {
t.Error("The error returned must be a LocalizedError")
}
}
+8 -4
View File
@@ -15,6 +15,7 @@ import (
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/reader/date"
"github.com/miniflux/miniflux/reader/sanitizer"
"github.com/miniflux/miniflux/url"
)
@@ -42,6 +43,7 @@ type rssItem struct {
Title string `xml:"title"`
Links []rssLink `xml:"link"`
OriginalLink string `xml:"http://rssnamespace.org/feedburner/ext/1.0 origLink"`
Comments string `xml:"comments"`
Description string `xml:"description"`
Content string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
PubDate string `xml:"pubDate"`
@@ -56,6 +58,7 @@ type rssAuthor struct {
XMLName xml.Name
Data string `xml:",chardata"`
Name string `xml:"name"`
Inner string `xml:",innerxml"`
}
type rssEnclosure struct {
@@ -100,7 +103,7 @@ func (r *rssFeed) Transform() *model.Feed {
if entry.Author == "" && r.ItunesAuthor != "" {
entry.Author = r.ItunesAuthor
}
entry.Author = strings.TrimSpace(entry.Author)
entry.Author = strings.TrimSpace(sanitizer.StripTags(entry.Author))
if entry.URL == "" {
entry.URL = feed.SiteURL
@@ -146,8 +149,8 @@ func (r *rssItem) GetAuthor() string {
return element.Name
}
if element.Data != "" {
return element.Data
if element.Inner != "" {
return element.Inner
}
}
@@ -194,7 +197,7 @@ func (r *rssItem) GetEnclosures() model.EnclosureList {
enclosures := make(model.EnclosureList, 0)
for _, enclosure := range r.Enclosures {
length, _ := strconv.Atoi(enclosure.Length)
length, _ := strconv.ParseInt(enclosure.Length, 10, 0)
enclosureURL := enclosure.URL
if r.OrigEnclosureLink != "" {
@@ -217,6 +220,7 @@ func (r *rssItem) GetEnclosures() model.EnclosureList {
func (r *rssItem) Transform() *model.Entry {
entry := new(model.Entry)
entry.URL = r.GetURL()
entry.CommentsURL = r.Comments
entry.Date = r.GetDate()
entry.Author = r.GetAuthor()
entry.Hash = r.GetHash()
+14 -1
View File
@@ -23,6 +23,7 @@ import (
var (
errConnectionFailure = "Unable to open this link: %v"
errUnreadableDoc = "Unable to analyze this page: %v"
errEmptyBody = "This web page is empty"
)
// FindSubscriptions downloads and try to find one or more subscriptions from an URL.
@@ -32,16 +33,28 @@ func FindSubscriptions(websiteURL string) (Subscriptions, error) {
client := http.NewClient(websiteURL)
response, err := client.Get()
if err != nil {
if _, ok := err.(errors.LocalizedError); ok {
return nil, err
}
return nil, errors.NewLocalizedError(errConnectionFailure, err)
}
// Content-Length = -1 when no Content-Length header is sent
if response.ContentLength == 0 {
return nil, errors.NewLocalizedError(errEmptyBody)
}
body, err := response.NormalizeBodyEncoding()
if err != nil {
return nil, err
}
var buffer bytes.Buffer
io.Copy(&buffer, body)
size, _ := io.Copy(&buffer, body)
if size == 0 {
return nil, errors.NewLocalizedError(errEmptyBody)
}
reader := bytes.NewReader(buffer.Bytes())
if format := feed.DetectFeedFormat(reader); format != feed.FormatUnknown {
+2 -2
View File
@@ -15,7 +15,7 @@ import (
func NewFeedScheduler(store *storage.Storage, workerPool *WorkerPool, frequency, batchSize int) {
go func() {
c := time.Tick(time.Duration(frequency) * time.Minute)
for _ = range c {
for range c {
jobs, err := store.NewBatch(batchSize)
if err != nil {
logger.Error("[FeedScheduler] %v", err)
@@ -31,7 +31,7 @@ func NewFeedScheduler(store *storage.Storage, workerPool *WorkerPool, frequency,
func NewSessionScheduler(store *storage.Storage, frequency int) {
go func() {
c := time.Tick(time.Duration(frequency) * time.Hour)
for _ = range c {
for range c {
nbSessions := store.CleanOldSessions()
nbUserSessions := store.CleanOldUserSessions()
logger.Info("[SessionScheduler] cleaned %d sessions and %d user sessions", nbSessions, nbUserSessions)
+1 -1
View File
@@ -20,7 +20,7 @@ type Worker struct {
// Run wait for a job and refresh the given feed.
func (w *Worker) Run(c chan model.Job) {
logger.Debug("[Worker] #%d started", w.id)
logger.Info("[Worker] #%d started", w.id)
for {
job := <-c
+2
View File
@@ -0,0 +1,2 @@
create index entries_user_status_idx on entries(user_id, status);
create index feeds_user_category_idx on feeds(user_id, category_id);
+3
View File
@@ -0,0 +1,3 @@
alter table integrations add column nunux_keeper_enabled bool default 'f';
alter table integrations add column nunux_keeper_url text default '';
alter table integrations add column nunux_keeper_api_key text default '';
+1
View File
@@ -0,0 +1 @@
alter table enclosures alter column size set data type bigint;
+1
View File
@@ -0,0 +1 @@
alter table entries add column comments_url text default '';
+13 -1
View File
@@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT.
// 2018-01-02 21:59:10.075345511 -0800 PST m=+0.002720840
// 2018-04-06 23:00:49.983090069 +0100 BST m=+0.002610702
package sql
@@ -123,6 +123,14 @@ alter table integrations add column wallabag_client_secret text default '';
alter table integrations add column wallabag_username text default '';
alter table integrations add column wallabag_password text default '';`,
"schema_version_12": `alter table entries add column starred bool default 'f';`,
"schema_version_13": `create index entries_user_status_idx on entries(user_id, status);
create index feeds_user_category_idx on feeds(user_id, category_id);
`,
"schema_version_14": `alter table integrations add column nunux_keeper_enabled bool default 'f';
alter table integrations add column nunux_keeper_url text default '';
alter table integrations add column nunux_keeper_api_key text default '';`,
"schema_version_15": `alter table enclosures alter column size set data type bigint;`,
"schema_version_16": `alter table entries add column comments_url text default '';`,
"schema_version_2": `create extension if not exists hstore;
alter table users add column extra hstore;
create index users_extra_idx on users using gin(extra);
@@ -166,6 +174,10 @@ var SqlMapChecksums = map[string]string{
"schema_version_10": "8faf15ddeff7c8cc305e66218face11ed92b97df2bdc2d0d7944d61441656795",
"schema_version_11": "dc5bbc302e01e425b49c48ddcd8e29e3ab2bb8e73a6cd1858a6ba9fbec0b5243",
"schema_version_12": "a95abab6cdf64811fc744abd37457e2928939d999c5ef00d2bdd9398e16f32fb",
"schema_version_13": "9073fae1e796936f4a43a8120ebdb4218442fe7d346ace6387556a357c2d7edf",
"schema_version_14": "4622e42c4a5a88b6fe1e61f3d367b295968f7260ab5b96481760775ba9f9e1fe",
"schema_version_15": "13ff91462bdf4cda5a94a4c7a09f757761b0f2c32b4be713ba4786a4837750e4",
"schema_version_16": "9d006faca62fd7ab787f64aef0e0a5933d142466ec4cab0e096bb920d2797e34",
"schema_version_2": "e8e9ff32478df04fcddad10a34cba2e8bb1e67e7977b5bd6cdc4c31ec94282b4",
"schema_version_3": "a54745dbc1c51c000f74d4e5068f1e2f43e83309f023415b1749a47d5c1e0f12",
"schema_version_4": "216ea3a7d3e1704e40c797b5dc47456517c27dbb6ca98bf88812f4f63d74b5d9",
+8 -5
View File
@@ -25,9 +25,9 @@ func (s *Storage) NewEntryQueryBuilder(userID int64) *EntryQueryBuilder {
func (s *Storage) createEntry(entry *model.Entry) error {
query := `
INSERT INTO entries
(title, hash, url, published_at, content, author, user_id, feed_id)
(title, hash, url, comments_url, published_at, content, author, user_id, feed_id)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8)
($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id
`
err := s.db.QueryRow(
@@ -35,6 +35,7 @@ func (s *Storage) createEntry(entry *model.Entry) error {
entry.Title,
entry.Hash,
entry.URL,
entry.CommentsURL,
entry.Date,
entry.Content,
entry.Author,
@@ -76,11 +77,13 @@ func (s *Storage) UpdateEntryContent(entry *model.Entry) error {
return err
}
// updateEntry update an entry when a feed is refreshed.
// updateEntry updates an entry when a feed is refreshed.
// Note: we do not update the published date because some feeds do not contains any date,
// it default to time.Now() which could change the order of items on the history page.
func (s *Storage) updateEntry(entry *model.Entry) error {
query := `
UPDATE entries SET
title=$1, url=$2, published_at=$3, content=$4, author=$5
title=$1, url=$2, comments_url=$3, content=$4, author=$5
WHERE user_id=$6 AND feed_id=$7 AND hash=$8
RETURNING id
`
@@ -88,7 +91,7 @@ func (s *Storage) updateEntry(entry *model.Entry) error {
query,
entry.Title,
entry.URL,
entry.Date,
entry.CommentsURL,
entry.Content,
entry.Author,
entry.UserID,
+11 -2
View File
@@ -13,6 +13,7 @@ import (
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/timer"
"github.com/miniflux/miniflux/timezone"
)
// EntryQueryBuilder builds a SQL query to fetch entries.
@@ -157,10 +158,11 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
query := `
SELECT
e.id, e.user_id, e.feed_id, e.hash, e.published_at at time zone u.timezone, e.title,
e.url, e.author, e.content, e.status, e.starred,
e.url, e.comments_url, e.author, e.content, e.status, e.starred,
f.title as feed_title, f.feed_url, f.site_url, f.checked_at,
f.category_id, c.title as category_title, f.scraper_rules, f.rewrite_rules, f.crawler,
fi.icon_id
fi.icon_id,
u.timezone
FROM entries e
LEFT JOIN feeds f ON f.id=e.feed_id
LEFT JOIN categories c ON c.id=f.category_id
@@ -183,6 +185,7 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
for rows.Next() {
var entry model.Entry
var iconID interface{}
var tz string
entry.Feed = &model.Feed{UserID: e.userID}
entry.Feed.Category = &model.Category{UserID: e.userID}
@@ -196,6 +199,7 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
&entry.Date,
&entry.Title,
&entry.URL,
&entry.CommentsURL,
&entry.Author,
&entry.Content,
&entry.Status,
@@ -210,6 +214,7 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
&entry.Feed.RewriteRules,
&entry.Feed.Crawler,
&iconID,
&tz,
)
if err != nil {
@@ -222,6 +227,10 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
entry.Feed.Icon.IconID = iconID.(int64)
}
// Make sure that timestamp fields contains timezone information (API)
entry.Date = timezone.Convert(tz, entry.Date)
entry.Feed.CheckedAt = timezone.Convert(tz, entry.Feed.CheckedAt)
entry.Feed.ID = entry.FeedID
entry.Feed.Icon.FeedID = entry.FeedID
entries = append(entries, &entry)
+11 -2
View File
@@ -12,6 +12,7 @@ import (
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/timer"
"github.com/miniflux/miniflux/timezone"
)
// FeedExists checks if the given feed exists.
@@ -56,7 +57,8 @@ func (s *Storage) Feeds(userID int64) (model.Feeds, error) {
f.parsing_error_count, f.parsing_error_msg,
f.scraper_rules, f.rewrite_rules, f.crawler,
f.category_id, c.title as category_title,
fi.icon_id
fi.icon_id,
u.timezone
FROM feeds f
LEFT JOIN categories c ON c.id=f.category_id
LEFT JOIN feed_icons fi ON fi.feed_id=f.id
@@ -73,6 +75,7 @@ func (s *Storage) Feeds(userID int64) (model.Feeds, error) {
for rows.Next() {
var feed model.Feed
var iconID interface{}
var tz string
feed.Category = &model.Category{UserID: userID}
err := rows.Scan(
@@ -92,6 +95,7 @@ func (s *Storage) Feeds(userID int64) (model.Feeds, error) {
&feed.Category.ID,
&feed.Category.Title,
&iconID,
&tz,
)
if err != nil {
@@ -102,6 +106,7 @@ func (s *Storage) Feeds(userID int64) (model.Feeds, error) {
feed.Icon = &model.FeedIcon{FeedID: feed.ID, IconID: iconID.(int64)}
}
feed.CheckedAt = timezone.Convert(tz, feed.CheckedAt)
feeds = append(feeds, &feed)
}
@@ -114,6 +119,7 @@ func (s *Storage) FeedByID(userID, feedID int64) (*model.Feed, error) {
var feed model.Feed
var iconID interface{}
var tz string
feed.Category = &model.Category{UserID: userID}
query := `
@@ -123,7 +129,8 @@ func (s *Storage) FeedByID(userID, feedID int64) (*model.Feed, error) {
f.parsing_error_count, f.parsing_error_msg,
f.scraper_rules, f.rewrite_rules, f.crawler,
f.category_id, c.title as category_title,
fi.icon_id
fi.icon_id,
u.timezone
FROM feeds f
LEFT JOIN categories c ON c.id=f.category_id
LEFT JOIN feed_icons fi ON fi.feed_id=f.id
@@ -147,6 +154,7 @@ func (s *Storage) FeedByID(userID, feedID int64) (*model.Feed, error) {
&feed.Category.ID,
&feed.Category.Title,
&iconID,
&tz,
)
switch {
@@ -160,6 +168,7 @@ func (s *Storage) FeedByID(userID, feedID int64) (*model.Feed, error) {
feed.Icon = &model.FeedIcon{FeedID: feed.ID, IconID: iconID.(int64)}
}
feed.CheckedAt = timezone.Convert(tz, feed.CheckedAt)
return &feed, nil
}
+15 -3
View File
@@ -67,7 +67,10 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
wallabag_client_id,
wallabag_client_secret,
wallabag_username,
wallabag_password
wallabag_password,
nunux_keeper_enabled,
nunux_keeper_url,
nunux_keeper_api_key
FROM integrations
WHERE user_id=$1
`
@@ -91,6 +94,9 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
&integration.WallabagClientSecret,
&integration.WallabagUsername,
&integration.WallabagPassword,
&integration.NunuxKeeperEnabled,
&integration.NunuxKeeperURL,
&integration.NunuxKeeperAPIKey,
)
switch {
case err == sql.ErrNoRows:
@@ -122,8 +128,11 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
wallabag_client_id=$14,
wallabag_client_secret=$15,
wallabag_username=$16,
wallabag_password=$17
WHERE user_id=$18
wallabag_password=$17,
nunux_keeper_enabled=$18,
nunux_keeper_url=$19,
nunux_keeper_api_key=$20
WHERE user_id=$21
`
_, err := s.db.Exec(
query,
@@ -144,6 +153,9 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
integration.WallabagClientSecret,
integration.WallabagUsername,
integration.WallabagPassword,
integration.NunuxKeeperEnabled,
integration.NunuxKeeperURL,
integration.NunuxKeeperAPIKey,
integration.UserID,
)
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"github.com/miniflux/miniflux/sql"
)
const schemaVersion = 12
const schemaVersion = 16
// Migrate run database migrations.
func (s *Storage) Migrate() {
+5 -4
View File
@@ -6,6 +6,7 @@ package storage
import (
"fmt"
"strings"
"time"
"github.com/miniflux/miniflux/timer"
@@ -14,10 +15,8 @@ import (
// Timezones returns all timezones supported by the database.
func (s *Storage) Timezones() (map[string]string, error) {
defer timer.ExecutionTime(time.Now(), "[Storage:Timezones]")
timezones := make(map[string]string)
query := `select name from pg_timezone_names() order by name asc`
rows, err := s.db.Query(query)
rows, err := s.db.Query(`SELECT name FROM pg_timezone_names() ORDER BY name ASC`)
if err != nil {
return nil, fmt.Errorf("unable to fetch timezones: %v", err)
}
@@ -29,7 +28,9 @@ func (s *Storage) Timezones() (map[string]string, error) {
return nil, fmt.Errorf("unable to fetch timezones row: %v", err)
}
timezones[timezone] = timezone
if !strings.HasPrefix(timezone, "posix") && !strings.HasPrefix(timezone, "SystemV") && timezone != "localtime" {
timezones[timezone] = timezone
}
}
return timezones, nil
+13 -2
View File
@@ -11,11 +11,10 @@ import (
"strings"
"time"
"github.com/lib/pq/hstore"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/timer"
"github.com/lib/pq/hstore"
"golang.org/x/crypto/bcrypt"
)
@@ -175,6 +174,18 @@ func (s *Storage) UpdateUser(user *model.User) error {
return nil
}
// UserLanguage returns the language of the given user.
func (s *Storage) UserLanguage(userID int64) (language string, err error) {
defer timer.ExecutionTime(time.Now(), fmt.Sprintf("[Storage:UserLanguage] userID=%d", userID))
err = s.db.QueryRow(`SELECT language FROM users WHERE id = $1`, userID).Scan(&language)
if err == sql.ErrNoRows {
return "en_US", nil
} else if err != nil {
return "", fmt.Errorf("unable to fetch user language: %v", err)
}
return language, nil
}
// UserByID finds a user by the ID.
func (s *Storage) UserByID(userID int64) (*model.User, error) {
defer timer.ExecutionTime(time.Now(), fmt.Sprintf("[Storage:UserByID] userID=%d", userID))
+16 -11
View File
@@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT.
// 2018-01-18 20:15:20.47432327 -0800 PST m=+0.053802055
// 2018-04-07 13:51:33.926223471 -0700 PDT m=+0.022666283
package template
@@ -27,32 +27,32 @@ var templateCommonMap = map[string]string{
<div class="item-meta">
<ul>
<li>
<a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
<a href="{{ route "feedEntries" "feedID" .entry.Feed.ID }}" title="{{ .entry.Feed.Title }}">{{ domain .entry.Feed.SiteURL }}</a>
</li>
<li>
<time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
<time datetime="{{ isodate .entry.Date }}" title="{{ isodate .entry.Date }}">{{ elapsed .user.Timezone .entry.Date }}</time>
</li>
<li>
<a href="#"
title="{{ t "Save this article" }}"
data-save-entry="true"
data-save-url="{{ route "saveEntry" "entryID" .ID }}"
data-save-url="{{ route "saveEntry" "entryID" .entry.ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-done="{{ t "Done!" }}"
>{{ t "Save" }}</a>
</li>
<li>
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
<a href="{{ .entry.URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
</li>
<li>
<a href="#"
data-toggle-bookmark="true"
data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}"
data-bookmark-url="{{ route "toggleBookmark" "entryID" .entry.ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-star="☆ {{ t "Star" }}"
data-label-unstar="★ {{ t "Unstar" }}"
data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}"
>{{ if .Starred }}{{ t "Unstar" }}{{ else }}{{ t "Star" }}{{ end }}</a>
data-value="{{ if .entry.Starred }}star{{ else }}unstar{{ end }}"
>{{ if .entry.Starred }}{{ t "Unstar" }}{{ else }}{{ t "Star" }}{{ end }}</a>
</li>
<li>
<a href="#"
@@ -60,9 +60,14 @@ var templateCommonMap = map[string]string{
data-toggle-status="true"
data-label-read="✔ {{ t "Read" }}"
data-label-unread="✘ {{ t "Unread" }}"
data-value="{{ if eq .Status "read" }}read{{ else }}unread{{ end }}"
>{{ if eq .Status "read" }}{{ t "Unread" }}{{ else }}{{ t "Read" }}{{ end }}</a>
data-value="{{ if eq .entry.Status "read" }}read{{ else }}unread{{ end }}"
>{{ if eq .entry.Status "read" }}{{ t "Unread" }}{{ else }}{{ t "Read" }}{{ end }}</a>
</li>
{{ if .entry.CommentsURL }}
<li>
<a href="{{ .entry.CommentsURL }}" title="{{ t "View Comments" }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ t "Comments" }}</a>
</li>
{{ end }}
</ul>
</div>
{{ end }}`,
@@ -215,7 +220,7 @@ var templateCommonMap = map[string]string{
var templateCommonMapChecksums = map[string]string{
"entry_pagination": "f1465fa70f585ae8043b200ec9de5bf437ffbb0c19fb7aefc015c3555614ee27",
"item_meta": "5f8650332fcc24dd580cea3eb8e7d8ecbb0d794607185039d1f4def8db6a7460",
"item_meta": "c14b5b36076e087346def2ebcef4876ed5e8165218f40dc017db44e754c22d03",
"layout": "c7565e2cf904612e236bc1d7167c6c124ffe5d27348608eb5c2336606f266896",
"pagination": "6ff462c2b2a53bc5448b651da017f40a39f1d4f16cef4b2f09784f0797286924",
}
+22
View File
@@ -0,0 +1,22 @@
// Copyright 2018 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 template
import "fmt"
func dict(values ...interface{}) (map[string]interface{}, error) {
if len(values)%2 != 0 {
return nil, fmt.Errorf("Dict expects an even number of arguments")
}
dict := make(map[string]interface{}, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, fmt.Errorf("Dict keys must be strings")
}
dict[key] = values[i+1]
}
return dict, nil
}
+42
View File
@@ -0,0 +1,42 @@
// Copyright 2018 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 template
import (
"testing"
)
func TestDict(t *testing.T) {
d, err := dict("k1", "v1", "k2", "v2")
if err != nil {
t.Fatalf(`The dict should be valid: %v`, err)
}
if value, found := d["k1"]; found {
if value != "v1" {
t.Fatalf(`Incorrect value for k1: %q`, value)
}
}
if value, found := d["k2"]; found {
if value != "v2" {
t.Fatalf(`Incorrect value for k2: %q`, value)
}
}
}
func TestDictWithIncorrectNumberOfPairs(t *testing.T) {
_, err := dict("k1", "v1", "k2")
if err == nil {
t.Fatalf(`The dict should not be valid because the number of keys/values pairs are incorrect`)
}
}
func TestDictWithInvalidKey(t *testing.T) {
_, err := dict(1, "v1")
if err == nil {
t.Fatalf(`The dict should not be valid because the key is not a string`)
}
}
+23 -15
View File
@@ -2,13 +2,14 @@
// Use of this source code is governed by the MIT License
// that can be found in the LICENSE file.
package duration
package template
import (
"math"
"time"
"github.com/miniflux/miniflux/locale"
"github.com/miniflux/miniflux/timezone"
)
// Texts to be translated if necessary.
@@ -28,35 +29,42 @@ var (
// ElapsedTime returns in a human readable format the elapsed time
// since the given datetime.
func ElapsedTime(translator *locale.Language, t time.Time) string {
if t.IsZero() || time.Now().Before(t) {
return translator.Get(NotYet)
func elapsedTime(language *locale.Language, tz string, t time.Time) string {
if t.IsZero() {
return language.Get(NotYet)
}
diff := time.Since(t)
now := timezone.Now(tz)
t = timezone.Convert(tz, t)
if now.Before(t) {
return language.Get(NotYet)
}
diff := now.Sub(t)
// Duration in seconds
s := diff.Seconds()
// Duration in days
d := int(s / 86400)
switch {
case s < 60:
return translator.Get(JustNow)
return language.Get(JustNow)
case s < 120:
return translator.Get(LastMinute)
return language.Get(LastMinute)
case s < 3600:
return translator.Get(Minutes, int(diff.Minutes()))
return language.Get(Minutes, int(diff.Minutes()))
case s < 7200:
return translator.Get(LastHour)
return language.Get(LastHour)
case s < 86400:
return translator.Get(Hours, int(diff.Hours()))
return language.Get(Hours, int(diff.Hours()))
case d == 1:
return translator.Get(Yesterday)
return language.Get(Yesterday)
case d < 7:
return translator.Get(Days, d)
return language.Get(Days, d)
case d < 31:
return translator.Get(Weeks, int(math.Ceil(float64(d)/7)))
return language.Get(Weeks, int(math.Ceil(float64(d)/7)))
case d < 365:
return translator.Get(Months, int(math.Ceil(float64(d)/30)))
return language.Get(Months, int(math.Ceil(float64(d)/30)))
default:
return translator.Get(Years, int(math.Ceil(float64(d)/365)))
return language.Get(Years, int(math.Ceil(float64(d)/365)))
}
}
@@ -2,7 +2,7 @@
// Use of this source code is governed by the MIT License
// that can be found in the LICENSE file.
package duration
package template
import (
"fmt"
@@ -31,8 +31,8 @@ func TestElapsedTime(t *testing.T) {
{time.Now().Add(-time.Hour * 24 * 365 * 3), fmt.Sprintf(Years, 3)},
}
for i, tt := range dt {
if out := ElapsedTime(&locale.Language{}, tt.in); out != tt.out {
t.Errorf("%d. content mismatch for %v:exp=%q got=%q", i, tt.in, tt.out, out)
if out := elapsedTime(&locale.Language{}, "Local", tt.in); out != tt.out {
t.Errorf(`%d. content mismatch for "%v": expected=%q got=%q`, i, tt.in, tt.out, out)
}
}
}
+69
View File
@@ -0,0 +1,69 @@
// 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 template
import (
"bytes"
"html/template"
"io"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/locale"
"github.com/miniflux/miniflux/logger"
"github.com/gorilla/mux"
)
// Engine handles the templating system.
type Engine struct {
templates map[string]*template.Template
translator *locale.Translator
funcMap *funcMap
}
func (e *Engine) parseAll() {
commonTemplates := ""
for _, content := range templateCommonMap {
commonTemplates += content
}
for name, content := range templateViewsMap {
logger.Debug("[Template] Parsing: %s", name)
e.templates[name] = template.Must(template.New("main").Funcs(e.funcMap.Map()).Parse(commonTemplates + content))
}
}
// SetLanguage change the language for template processing.
func (e *Engine) SetLanguage(language string) {
e.funcMap.Language = e.translator.GetLanguage(language)
}
// Execute process a template.
func (e *Engine) Execute(w io.Writer, name string, data interface{}) {
tpl, ok := e.templates[name]
if !ok {
logger.Fatal("[Template] The template %s does not exists", name)
}
var b bytes.Buffer
err := tpl.ExecuteTemplate(&b, "base", data)
if err != nil {
logger.Fatal("[Template] Unable to render template: %v", err)
}
b.WriteTo(w)
}
// NewEngine returns a new template engine.
func NewEngine(cfg *config.Config, router *mux.Router, translator *locale.Translator) *Engine {
tpl := &Engine{
templates: make(map[string]*template.Template),
translator: translator,
funcMap: newFuncMap(cfg, router, translator.GetLanguage("en_US")),
}
tpl.parseAll()
return tpl
}
+106
View File
@@ -0,0 +1,106 @@
// Copyright 2018 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 template
import (
"html/template"
"net/mail"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/errors"
"github.com/miniflux/miniflux/filter"
"github.com/miniflux/miniflux/http/route"
"github.com/miniflux/miniflux/locale"
"github.com/miniflux/miniflux/url"
)
type funcMap struct {
cfg *config.Config
router *mux.Router
Language *locale.Language
}
func (f *funcMap) Map() template.FuncMap {
return template.FuncMap{
"baseURL": func() string {
return f.cfg.BaseURL()
},
"rootURL": func() string {
return f.cfg.RootURL()
},
"hasOAuth2Provider": func(provider string) bool {
return f.cfg.OAuth2Provider() == provider
},
"hasKey": func(dict map[string]string, key string) bool {
if value, found := dict[key]; found {
return value != ""
}
return false
},
"route": func(name string, args ...interface{}) string {
return route.Path(f.router, name, args...)
},
"noescape": func(str string) template.HTML {
return template.HTML(str)
},
"proxyFilter": func(data string) string {
return filter.ImageProxyFilter(f.router, data)
},
"proxyURL": func(link string) string {
if url.IsHTTPS(link) {
return link
}
return filter.Proxify(f.router, link)
},
"domain": func(websiteURL string) string {
return url.Domain(websiteURL)
},
"isEmail": func(str string) bool {
_, err := mail.ParseAddress(str)
if err != nil {
return false
}
return true
},
"hasPrefix": func(str, prefix string) bool {
return strings.HasPrefix(str, prefix)
},
"contains": func(str, substr string) bool {
return strings.Contains(str, substr)
},
"isodate": func(ts time.Time) string {
return ts.Format("2006-01-02 15:04:05")
},
"elapsed": func(timezone string, t time.Time) string {
return elapsedTime(f.Language, timezone, t)
},
"t": func(key interface{}, args ...interface{}) string {
switch key.(type) {
case string:
return f.Language.Get(key.(string), args...)
case errors.LocalizedError:
return key.(errors.LocalizedError).Localize(f.Language)
case *errors.LocalizedError:
return key.(*errors.LocalizedError).Localize(f.Language)
case error:
return key.(error).Error()
default:
return ""
}
},
"plural": func(key string, n int, args ...interface{}) string {
return f.Language.Plural(key, n, args...)
},
"dict": dict,
}
}
func newFuncMap(cfg *config.Config, router *mux.Router, language *locale.Language) *funcMap {
return &funcMap{cfg, router, language}
}
+1 -1
View File
@@ -27,7 +27,7 @@
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
{{ template "item_meta" . }}
{{ template "item_meta" dict "user" $.user "entry" . }}
</article>
{{ end }}
</div>
+14 -9
View File
@@ -2,32 +2,32 @@
<div class="item-meta">
<ul>
<li>
<a href="{{ route "feedEntries" "feedID" .Feed.ID }}" title="{{ .Feed.Title }}">{{ domain .Feed.SiteURL }}</a>
<a href="{{ route "feedEntries" "feedID" .entry.Feed.ID }}" title="{{ .entry.Feed.Title }}">{{ domain .entry.Feed.SiteURL }}</a>
</li>
<li>
<time datetime="{{ isodate .Date }}" title="{{ isodate .Date }}">{{ elapsed .Date }}</time>
<time datetime="{{ isodate .entry.Date }}" title="{{ isodate .entry.Date }}">{{ elapsed .user.Timezone .entry.Date }}</time>
</li>
<li>
<a href="#"
title="{{ t "Save this article" }}"
data-save-entry="true"
data-save-url="{{ route "saveEntry" "entryID" .ID }}"
data-save-url="{{ route "saveEntry" "entryID" .entry.ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-done="{{ t "Done!" }}"
>{{ t "Save" }}</a>
</li>
<li>
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
<a href="{{ .entry.URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
</li>
<li>
<a href="#"
data-toggle-bookmark="true"
data-bookmark-url="{{ route "toggleBookmark" "entryID" .ID }}"
data-bookmark-url="{{ route "toggleBookmark" "entryID" .entry.ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-star="☆ {{ t "Star" }}"
data-label-unstar="★ {{ t "Unstar" }}"
data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}"
>{{ if .Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</a>
data-value="{{ if .entry.Starred }}star{{ else }}unstar{{ end }}"
>{{ if .entry.Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</a>
</li>
<li>
<a href="#"
@@ -35,9 +35,14 @@
data-toggle-status="true"
data-label-read="✔ {{ t "Read" }}"
data-label-unread="✘ {{ t "Unread" }}"
data-value="{{ if eq .Status "read" }}read{{ else }}unread{{ end }}"
>{{ if eq .Status "read" }}✘ {{ t "Unread" }}{{ else }}✔ {{ t "Read" }}{{ end }}</a>
data-value="{{ if eq .entry.Status "read" }}read{{ else }}unread{{ end }}"
>{{ if eq .entry.Status "read" }}✘ {{ t "Unread" }}{{ else }}✔ {{ t "Read" }}{{ end }}</a>
</li>
{{ if .entry.CommentsURL }}
<li>
<a href="{{ .entry.CommentsURL }}" title="{{ t "View Comments" }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ t "Comments" }}</a>
</li>
{{ end }}
</ul>
</div>
{{ end }}
+3
View File
@@ -16,6 +16,9 @@
<li>
<a href="{{ route "users" }}">{{ t "Users" }}</a>
</li>
<li>
<a href="{{ route "about" }}">{{ t "About" }}</a>
</li>
</ul>
</section>
+1 -1
View File
@@ -67,7 +67,7 @@
<div class="panel">
<ul>
<li><strong>{{ t "Last checked:" }} </strong><time datetime="{{ isodate .feed.CheckedAt }}" title="{{ isodate .feed.CheckedAt }}">{{ elapsed .feed.CheckedAt }}</time></li>
<li><strong>{{ t "Last checked:" }} </strong><time datetime="{{ isodate .feed.CheckedAt }}" title="{{ isodate .feed.CheckedAt }}">{{ elapsed $.user.Timezone .feed.CheckedAt }}</time></li>
<li><strong>{{ t "ETag header:" }} </strong>{{ if .feed.EtagHeader }}{{ .feed.EtagHeader }}{{ else }}{{ t "None" }}{{ end }}</li>
<li><strong>{{ t "LastModified header:" }} </strong>{{ if .feed.LastModifiedHeader }}{{ .feed.LastModifiedHeader }}{{ else }}{{ t "None" }}{{ end }}</li>
</ul>
+4 -1
View File
@@ -2,7 +2,7 @@
{{ define "content"}}
<section class="page-header">
<h1>{{ t "Edit user %s" .selected_user.Username }}"</h1>
<h1>{{ t "Edit user %s" .selected_user.Username }}</h1>
<ul>
<li>
<a href="{{ route "settings" }}">{{ t "Settings" }}</a>
@@ -19,6 +19,9 @@
<li>
<a href="{{ route "createUser" }}">{{ t "Add user" }}</a>
</li>
<li>
<a href="{{ route "about" }}">{{ t "About" }}</a>
</li>
</ul>
</section>
+6 -1
View File
@@ -36,6 +36,11 @@
data-label-done="{{ t "Done!" }}"
>{{ t "Fetch original content" }}</a>
</li>
{{ if .entry.CommentsURL }}
<li>
<a href="{{ .entry.CommentsURL }}" title="{{ t "View Comments" }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ t "Comments" }}</a>
</li>
{{ end }}
</ul>
</div>
<div class="entry-meta">
@@ -59,7 +64,7 @@
</span>
</div>
<div class="entry-date">
<time datetime="{{ isodate .entry.Date }}" title="{{ isodate .entry.Date }}">{{ elapsed .entry.Date }}</time>
<time datetime="{{ isodate .entry.Date }}" title="{{ isodate .entry.Date }}">{{ elapsed $.user.Timezone .entry.Date }}</time>
</div>
</header>
{{ if gt (len .entry.Content) 120 }}
+1 -1
View File
@@ -38,7 +38,7 @@
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
{{ template "item_meta" . }}
{{ template "item_meta" dict "user" $.user "entry" . }}
</article>
{{ end }}
</div>
+2 -2
View File
@@ -42,7 +42,7 @@
<a href="{{ .SiteURL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ domain .SiteURL }}</a>
</li>
<li>
{{ t "Last check:" }} <time datetime="{{ isodate .CheckedAt }}" title="{{ isodate .CheckedAt }}">{{ elapsed .CheckedAt }}</time>
{{ t "Last check:" }} <time datetime="{{ isodate .CheckedAt }}" title="{{ isodate .CheckedAt }}">{{ elapsed $.user.Timezone .CheckedAt }}</time>
</li>
</ul>
<ul>
@@ -66,7 +66,7 @@
{{ if ne .ParsingErrorCount 0 }}
<div class="parsing-error">
<strong title="{{ .ParsingErrorMsg }}" class="parsing-error-count">{{ plural "plural.feed.error_count" .ParsingErrorCount .ParsingErrorCount }}</strong>
<small class="parsing-error-message">({{ .ParsingErrorMsg }})</small>
- <small class="parsing-error-message">{{ .ParsingErrorMsg }}</small>
</div>
{{ end }}
</article>
+1 -1
View File
@@ -27,7 +27,7 @@
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
{{ template "item_meta" . }}
{{ template "item_meta" dict "user" $.user "entry" . }}
</article>
{{ end }}
</div>
+32 -2
View File
@@ -39,6 +39,8 @@
<label for="form-fever-password">{{ t "Fever Password" }}</label>
<input type="password" name="fever_password" id="form-fever-password" value="{{ .form.FeverPassword }}">
<p>{{ t "Fever API endpoint:" }} <strong>{{ rootURL }}{{ route "feverEndpoint" }}</strong></p>
</div>
<h3>Pinboard</h3>
@@ -92,18 +94,46 @@
<label for="form-wallabag-password">{{ t "Wallabag Password" }}</label>
<input type="password" name="wallabag_password" id="form-wallabag-password" value="{{ .form.WallabagPassword }}">
</div>
<h3>Nunux Keeper</h3>
<div class="form-section">
<label>
<input type="checkbox" name="nunux_keeper_enabled" value="1" {{ if .form.NunuxKeeperEnabled }}checked{{ end }}> {{ t "Save articles to Nunux Keeper" }}
</label>
<label for="form-nunux-keeper-url">{{ t "Nunux Keeper API Endpoint" }}</label>
<input type="url" name="nunux_keeper_url" id="form-nunux-keeper-url" value="{{ .form.NunuxKeeperURL }}" placeholder="https://api.nunux.org/keeper">
<label for="form-nunux-keeper-api-key">{{ t "Nunux Keeper API key" }}</label>
<input type="text" name="nunux_keeper_api_key" id="form-nunux-keeper-api-key" value="{{ .form.NunuxKeeperAPIKey }}">
</div>
<div class="buttons">
<button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Update" }}</button>
</div>
</form>
<h3>{{ t "Miniflux API" }}</h3>
<div class="panel">
<ul>
<li>
{{ t "API Endpoint" }} = <strong>{{ baseURL }}/v1/</strong>
</li>
<li>
{{ t "Username" }} = <strong>{{ .user.Username }}</strong>
</li>
<li>
{{ t "Password" }} = <strong>{{ t "Your account password" }}</strong>
</li>
</ul>
</div>
<h3>{{ t "Bookmarklet" }}</h3>
<div class="panel">
<h3>{{ t "Bookmarklet" }}</h3>
<p>{{ t "This special link allows you to subscribe to a website directly by using a bookmark in your web browser." }}</p>
<div class="bookmarklet">
<a href="javascript:location.href='{{ baseURL }}{{ route "bookmarklet" }}?uri='+encodeURIComponent(window.location.href)">{{ t "Add to Miniflux" }}</a>
<a href="javascript:location.href='{{ rootURL }}{{ route "bookmarklet" }}?uri='+encodeURIComponent(window.location.href)">{{ t "Add to Miniflux" }}</a>
</div>
<p>{{ t "Drag and drop this link to your bookmarks." }}</p>
+4 -1
View File
@@ -18,6 +18,9 @@
<a href="{{ route "createUser" }}">{{ t "Add user" }}</a>
</li>
{{ end }}
<li>
<a href="{{ route "about" }}">{{ t "About" }}</a>
</li>
</ul>
</section>
@@ -30,7 +33,7 @@
</tr>
{{ range .sessions }}
<tr {{ if eq .Token $.currentSessionToken }}class="row-highlighted"{{ end }}>
<td class="column-20" title="{{ isodate .CreatedAt }}">{{ elapsed .CreatedAt }}</td>
<td class="column-20" title="{{ isodate .CreatedAt }}">{{ elapsed $.user.Timezone .CreatedAt }}</td>
<td class="column-20" title="{{ .IP }}">{{ .IP }}</td>
<td title="{{ .UserAgent }}">{{ .UserAgent }}</td>
<td class="column-20">
+1 -1
View File
@@ -20,7 +20,7 @@
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
{{ template "item_meta" . }}
{{ template "item_meta" dict "user" $.user "entry" . }}
</article>
{{ end }}
</div>
+1 -1
View File
@@ -30,7 +30,7 @@
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
{{ template "item_meta" . }}
{{ template "item_meta" dict "user" $.user "entry" . }}
</article>
{{ end }}
</div>
+4 -1
View File
@@ -16,6 +16,9 @@
<li>
<a href="{{ route "createUser" }}">{{ t "Add user" }}</a>
</li>
<li>
<a href="{{ route "about" }}">{{ t "About" }}</a>
</li>
</ul>
</section>
@@ -36,7 +39,7 @@
<td>{{ if eq .IsAdmin true }}{{ t "Yes" }}{{ else }}{{ t "No" }}{{ end }}</td>
<td>
{{ if .LastLoginAt }}
<time datetime="{{ isodate .LastLoginAt }}" title="{{ isodate .LastLoginAt }}">{{ elapsed .LastLoginAt }}</time>
<time datetime="{{ isodate .LastLoginAt }}" title="{{ isodate .LastLoginAt }}">{{ elapsed $.user.Timezone .LastLoginAt }}</time>
{{ else }}
{{ t "Never" }}
{{ end }}
-149
View File
@@ -1,149 +0,0 @@
// Copyright 2017 Frédéric Guilloe. 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 template
import (
"bytes"
"html/template"
"io"
"net/mail"
"strings"
"time"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/duration"
"github.com/miniflux/miniflux/errors"
"github.com/miniflux/miniflux/filter"
"github.com/miniflux/miniflux/http/route"
"github.com/miniflux/miniflux/locale"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/url"
"github.com/gorilla/mux"
)
// Engine handles the templating system.
type Engine struct {
templates map[string]*template.Template
router *mux.Router
translator *locale.Translator
currentLocale *locale.Language
cfg *config.Config
}
func (e *Engine) parseAll() {
funcMap := template.FuncMap{
"baseURL": func() string {
return e.cfg.BaseURL()
},
"hasOAuth2Provider": func(provider string) bool {
return e.cfg.OAuth2Provider() == provider
},
"hasKey": func(dict map[string]string, key string) bool {
if value, found := dict[key]; found {
return value != ""
}
return false
},
"route": func(name string, args ...interface{}) string {
return route.Path(e.router, name, args...)
},
"noescape": func(str string) template.HTML {
return template.HTML(str)
},
"proxyFilter": func(data string) string {
return filter.ImageProxyFilter(e.router, data)
},
"proxyURL": func(link string) string {
if url.IsHTTPS(link) {
return link
}
return filter.Proxify(e.router, link)
},
"domain": func(websiteURL string) string {
return url.Domain(websiteURL)
},
"isEmail": func(str string) bool {
_, err := mail.ParseAddress(str)
if err != nil {
return false
}
return true
},
"hasPrefix": func(str, prefix string) bool {
return strings.HasPrefix(str, prefix)
},
"contains": func(str, substr string) bool {
return strings.Contains(str, substr)
},
"isodate": func(ts time.Time) string {
return ts.Format("2006-01-02 15:04:05")
},
"elapsed": func(ts time.Time) string {
return duration.ElapsedTime(e.currentLocale, ts)
},
"t": func(key interface{}, args ...interface{}) string {
switch key.(type) {
case string:
return e.currentLocale.Get(key.(string), args...)
case errors.LocalizedError:
err := key.(errors.LocalizedError)
return err.Localize(e.currentLocale)
case error:
return key.(error).Error()
default:
return ""
}
},
"plural": func(key string, n int, args ...interface{}) string {
return e.currentLocale.Plural(key, n, args...)
},
}
commonTemplates := ""
for _, content := range templateCommonMap {
commonTemplates += content
}
for name, content := range templateViewsMap {
logger.Debug("[Template] Parsing: %s", name)
e.templates[name] = template.Must(template.New("main").Funcs(funcMap).Parse(commonTemplates + content))
}
}
// SetLanguage change the language for template processing.
func (e *Engine) SetLanguage(language string) {
e.currentLocale = e.translator.GetLanguage(language)
}
// Execute process a template.
func (e *Engine) Execute(w io.Writer, name string, data interface{}) {
tpl, ok := e.templates[name]
if !ok {
logger.Fatal("[Template] The template %s does not exists", name)
}
var b bytes.Buffer
err := tpl.ExecuteTemplate(&b, "base", data)
if err != nil {
logger.Fatal("[Template] Unable to render template: %v", err)
}
b.WriteTo(w)
}
// NewEngine returns a new template Engine.
func NewEngine(cfg *config.Config, router *mux.Router, translator *locale.Translator) *Engine {
tpl := &Engine{
templates: make(map[string]*template.Template),
router: router,
translator: translator,
cfg: cfg,
}
tpl.parseAll()
return tpl
}
+75 -28
View File
@@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT.
// 2018-01-18 20:15:20.459420484 -0800 PST m=+0.038899269
// 2018-04-07 13:51:33.918407222 -0700 PDT m=+0.014850034
package template
@@ -179,7 +179,7 @@ var templateViewsMap = map[string]string{
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
{{ template "item_meta" . }}
{{ template "item_meta" dict "user" $.user "entry" . }}
</article>
{{ end }}
</div>
@@ -274,6 +274,9 @@ var templateViewsMap = map[string]string{
<li>
<a href="{{ route "users" }}">{{ t "Users" }}</a>
</li>
<li>
<a href="{{ route "about" }}">{{ t "About" }}</a>
</li>
</ul>
</section>
@@ -401,7 +404,7 @@ var templateViewsMap = map[string]string{
<div class="panel">
<ul>
<li><strong>{{ t "Last checked:" }} </strong><time datetime="{{ isodate .feed.CheckedAt }}" title="{{ isodate .feed.CheckedAt }}">{{ elapsed .feed.CheckedAt }}</time></li>
<li><strong>{{ t "Last checked:" }} </strong><time datetime="{{ isodate .feed.CheckedAt }}" title="{{ isodate .feed.CheckedAt }}">{{ elapsed $.user.Timezone .feed.CheckedAt }}</time></li>
<li><strong>{{ t "ETag header:" }} </strong>{{ if .feed.EtagHeader }}{{ .feed.EtagHeader }}{{ else }}{{ t "None" }}{{ end }}</li>
<li><strong>{{ t "LastModified header:" }} </strong>{{ if .feed.LastModifiedHeader }}{{ .feed.LastModifiedHeader }}{{ else }}{{ t "None" }}{{ end }}</li>
</ul>
@@ -413,7 +416,7 @@ var templateViewsMap = map[string]string{
{{ define "content"}}
<section class="page-header">
<h1>{{ t "Edit user %s" .selected_user.Username }}"</h1>
<h1>{{ t "Edit user %s" .selected_user.Username }}</h1>
<ul>
<li>
<a href="{{ route "settings" }}">{{ t "Settings" }}</a>
@@ -430,6 +433,9 @@ var templateViewsMap = map[string]string{
<li>
<a href="{{ route "createUser" }}">{{ t "Add user" }}</a>
</li>
<li>
<a href="{{ route "about" }}">{{ t "About" }}</a>
</li>
</ul>
</section>
@@ -495,6 +501,11 @@ var templateViewsMap = map[string]string{
data-label-done="{{ t "Done!" }}"
>{{ t "Fetch original content" }}</a>
</li>
{{ if .entry.CommentsURL }}
<li>
<a href="{{ .entry.CommentsURL }}" title="{{ t "View Comments" }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ t "Comments" }}</a>
</li>
{{ end }}
</ul>
</div>
<div class="entry-meta">
@@ -518,7 +529,7 @@ var templateViewsMap = map[string]string{
</span>
</div>
<div class="entry-date">
<time datetime="{{ isodate .entry.Date }}" title="{{ isodate .entry.Date }}">{{ elapsed .entry.Date }}</time>
<time datetime="{{ isodate .entry.Date }}" title="{{ isodate .entry.Date }}">{{ elapsed $.user.Timezone .entry.Date }}</time>
</div>
</header>
{{ if gt (len .entry.Content) 120 }}
@@ -607,7 +618,7 @@ var templateViewsMap = map[string]string{
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
{{ template "item_meta" . }}
{{ template "item_meta" dict "user" $.user "entry" . }}
</article>
{{ end }}
</div>
@@ -660,7 +671,7 @@ var templateViewsMap = map[string]string{
<a href="{{ .SiteURL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ domain .SiteURL }}</a>
</li>
<li>
{{ t "Last check:" }} <time datetime="{{ isodate .CheckedAt }}" title="{{ isodate .CheckedAt }}">{{ elapsed .CheckedAt }}</time>
{{ t "Last check:" }} <time datetime="{{ isodate .CheckedAt }}" title="{{ isodate .CheckedAt }}">{{ elapsed $.user.Timezone .CheckedAt }}</time>
</li>
</ul>
<ul>
@@ -684,7 +695,7 @@ var templateViewsMap = map[string]string{
{{ if ne .ParsingErrorCount 0 }}
<div class="parsing-error">
<strong title="{{ .ParsingErrorMsg }}" class="parsing-error-count">{{ plural "plural.feed.error_count" .ParsingErrorCount .ParsingErrorCount }}</strong>
<small class="parsing-error-message">({{ .ParsingErrorMsg }})</small>
- <small class="parsing-error-message">{{ .ParsingErrorMsg }}</small>
</div>
{{ end }}
</article>
@@ -723,7 +734,7 @@ var templateViewsMap = map[string]string{
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
{{ template "item_meta" . }}
{{ template "item_meta" dict "user" $.user "entry" . }}
</article>
{{ end }}
</div>
@@ -808,6 +819,8 @@ var templateViewsMap = map[string]string{
<label for="form-fever-password">{{ t "Fever Password" }}</label>
<input type="password" name="fever_password" id="form-fever-password" value="{{ .form.FeverPassword }}">
<p>{{ t "Fever API endpoint:" }} <strong>{{ rootURL }}{{ route "feverEndpoint" }}</strong></p>
</div>
<h3>Pinboard</h3>
@@ -861,18 +874,46 @@ var templateViewsMap = map[string]string{
<label for="form-wallabag-password">{{ t "Wallabag Password" }}</label>
<input type="password" name="wallabag_password" id="form-wallabag-password" value="{{ .form.WallabagPassword }}">
</div>
<h3>Nunux Keeper</h3>
<div class="form-section">
<label>
<input type="checkbox" name="nunux_keeper_enabled" value="1" {{ if .form.NunuxKeeperEnabled }}checked{{ end }}> {{ t "Save articles to Nunux Keeper" }}
</label>
<label for="form-nunux-keeper-url">{{ t "Nunux Keeper API Endpoint" }}</label>
<input type="url" name="nunux_keeper_url" id="form-nunux-keeper-url" value="{{ .form.NunuxKeeperURL }}" placeholder="https://api.nunux.org/keeper">
<label for="form-nunux-keeper-api-key">{{ t "Nunux Keeper API key" }}</label>
<input type="text" name="nunux_keeper_api_key" id="form-nunux-keeper-api-key" value="{{ .form.NunuxKeeperAPIKey }}">
</div>
<div class="buttons">
<button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Update" }}</button>
</div>
</form>
<h3>{{ t "Miniflux API" }}</h3>
<div class="panel">
<ul>
<li>
{{ t "API Endpoint" }} = <strong>{{ baseURL }}/v1/</strong>
</li>
<li>
{{ t "Username" }} = <strong>{{ .user.Username }}</strong>
</li>
<li>
{{ t "Password" }} = <strong>{{ t "Your account password" }}</strong>
</li>
</ul>
</div>
<h3>{{ t "Bookmarklet" }}</h3>
<div class="panel">
<h3>{{ t "Bookmarklet" }}</h3>
<p>{{ t "This special link allows you to subscribe to a website directly by using a bookmark in your web browser." }}</p>
<div class="bookmarklet">
<a href="javascript:location.href='{{ baseURL }}{{ route "bookmarklet" }}?uri='+encodeURIComponent(window.location.href)">{{ t "Add to Miniflux" }}</a>
<a href="javascript:location.href='{{ rootURL }}{{ route "bookmarklet" }}?uri='+encodeURIComponent(window.location.href)">{{ t "Add to Miniflux" }}</a>
</div>
<p>{{ t "Drag and drop this link to your bookmarks." }}</p>
@@ -929,6 +970,9 @@ var templateViewsMap = map[string]string{
<a href="{{ route "createUser" }}">{{ t "Add user" }}</a>
</li>
{{ end }}
<li>
<a href="{{ route "about" }}">{{ t "About" }}</a>
</li>
</ul>
</section>
@@ -941,7 +985,7 @@ var templateViewsMap = map[string]string{
</tr>
{{ range .sessions }}
<tr {{ if eq .Token $.currentSessionToken }}class="row-highlighted"{{ end }}>
<td class="column-20" title="{{ isodate .CreatedAt }}">{{ elapsed .CreatedAt }}</td>
<td class="column-20" title="{{ isodate .CreatedAt }}">{{ elapsed $.user.Timezone .CreatedAt }}</td>
<td class="column-20" title="{{ .IP }}">{{ .IP }}</td>
<td title="{{ .UserAgent }}">{{ .UserAgent }}</td>
<td class="column-20">
@@ -1068,7 +1112,7 @@ var templateViewsMap = map[string]string{
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
{{ template "item_meta" . }}
{{ template "item_meta" dict "user" $.user "entry" . }}
</article>
{{ end }}
</div>
@@ -1109,7 +1153,7 @@ var templateViewsMap = map[string]string{
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
{{ template "item_meta" . }}
{{ template "item_meta" dict "user" $.user "entry" . }}
</article>
{{ end }}
</div>
@@ -1135,6 +1179,9 @@ var templateViewsMap = map[string]string{
<li>
<a href="{{ route "createUser" }}">{{ t "Add user" }}</a>
</li>
<li>
<a href="{{ route "about" }}">{{ t "About" }}</a>
</li>
</ul>
</section>
@@ -1155,7 +1202,7 @@ var templateViewsMap = map[string]string{
<td>{{ if eq .IsAdmin true }}{{ t "Yes" }}{{ else }}{{ t "No" }}{{ end }}</td>
<td>
{{ if .LastLoginAt }}
<time datetime="{{ isodate .LastLoginAt }}" title="{{ isodate .LastLoginAt }}">{{ elapsed .LastLoginAt }}</time>
<time datetime="{{ isodate .LastLoginAt }}" title="{{ isodate .LastLoginAt }}">{{ elapsed $.user.Timezone .LastLoginAt }}</time>
{{ else }}
{{ t "Never" }}
{{ end }}
@@ -1184,23 +1231,23 @@ var templateViewsMapChecksums = map[string]string{
"about": "ad2fb778fc73c39b733b3f81b13e5c7d689b041fadd24ee2d4577f545aa788ad",
"add_subscription": "053c920b0d7e109ea19dce6a448e304ce720db8633588ea04db16677f7209a7b",
"categories": "ca1280cd157bb527d4fc907da67b05a8347378f6dce965b9389d4bcdf3600a11",
"category_entries": "a92bd528d1162e7af4972f7ad25feddf7ceb17164db593805caf3d363ff32aac",
"category_entries": "686132d71c52a665329670756ac09959d915f7bc3227970149c623059988b035",
"choose_subscription": "a325f9c976ca2b2dc148e25c8fef0cf6ccab0e04e86e604e7812bb18dc4cdde1",
"create_category": "2b82af5d2dcd67898dc5daa57a6461e6ff8121a6089b2a2a1be909f35e4a2275",
"create_user": "45e226df757126d5fe7c464e295e9a34f07952cfdb71e31e49839850d35af139",
"create_user": "233764778c915754141a20429ec8db9bf80ef2d7704867a2d7232c1e9df233ae",
"edit_category": "cee720faadcec58289b707ad30af623d2ee66c1ce23a732965463250d7ff41c5",
"edit_feed": "30ff6ee5526b8beb9b457526b77ede55938f53eb279b1acc719ce896359e2ed8",
"edit_user": "82d9749d76ddbd2352816d813c4b1f6d92f2222de678b4afe5821090246735c7",
"entry": "6b4405e0c8e4a7d31874659f8835f4e43e01dc3c20686091517ac750196dd70f",
"feed_entries": "3a7b4d942d9ac9da7df5c09950fbadaa0ff564696953c4f1c302d955696cc2ee",
"feeds": "65b0a47c4438810b9d51c60f3f3b2519690e56ff74029e6296c68626b83a470b",
"history": "d2476fd727e4f53428b5ed1f3f9423063583337ec8cfe1dd9c931fcb03852a20",
"edit_feed": "d2c1c8486d7faf4ee58151ccf3e3c690e53bd6872050d291c5db8452a83c3d53",
"edit_user": "321e0a60cf3bf7441bff970f4920e4c5b7c1883f80ab1d1674f8137954b25033",
"entry": "656a33552c3662c5d38a6178c811316ff55b38460b1f57d4087d51d631efe896",
"feed_entries": "420da786e827a77fecc8794207d158af3a30e489ca2b2019f48d5228919af4a7",
"feeds": "2a5abe37968ea34a0576dbef52341645cb1fc9562e351382fbf721491da6f4fa",
"history": "967bc95236269ab3a77455910aca1939f43f93171fe1af77eb3b1b4eac579e55",
"import": "73b5112e20bfd232bf73334544186ea419505936bc237d481517a8622901878f",
"integrations": "3c14d7de904911aad7f3ebec6d1a20b50843287f58125c526e167f429f3d455d",
"integrations": "979193f39c2a3b43cec192aa119713cc9cbe2d5fdaedf8d2b3573c752823446c",
"login": "7d83c3067c02f1f6aafdd8816c7f97a4eb5a5a4bdaaaa4cc1e2fbb9c17ea65e8",
"sessions": "9a3609c52b071a280b85fd886f15d4b91c76bb0ab93a99ad132c695a216ac5cf",
"sessions": "3fa79031dd883847eba92fbafe5f535fa3a4e1614bb610f20588b6f8fc8b3624",
"settings": "ea2505b9d0a6d6bb594dba87a92079de19baa6d494f0651693a7685489fb7de9",
"starred": "121b761ce218de472ebf70879c1aa2fe3b9ae8981c11f1ed53a355e34049b269",
"unread": "17335b21dc5fb79207da317a8e8a6685a122bc94c323662cbda7cfdbf73dd2f9",
"users": "44677e28bb5347799ed0020c90ec785aadec4b1454446d92411cfdaf6e32110b",
"starred": "d006f5bcfca7abc7c68b6bb38fe25838acb81b65f960fbf06b97b259ba03b936",
"unread": "ad71a7bdd46c1d650efecbeeb37b7606a046027c15284c0712912b77397f90d4",
"users": "c6d91b0b29984b4cb3073bec6a2933cfb72981ec60f54b6c7aa05194f0e860bd",
}
+47
View File
@@ -0,0 +1,47 @@
// Copyright 2018 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 timezone
import (
"time"
)
// Convert converts provided date time to actual timezone.
func Convert(tz string, t time.Time) time.Time {
userTimezone := getLocation(tz)
if t.Location().String() == "" {
// In this case, the provided date is already converted to the user timezone by Postgres,
// but the timezone information is not set in the time struct.
// We cannot use time.In() because the date will be converted a second time.
t = time.Date(
t.Year(),
t.Month(),
t.Day(),
t.Hour(),
t.Minute(),
t.Second(),
t.Nanosecond(),
userTimezone,
)
} else if t.Location() != userTimezone {
t = t.In(userTimezone)
}
return t
}
// Now returns the current time with the given timezone.
func Now(tz string) time.Time {
return time.Now().In(getLocation(tz))
}
func getLocation(tz string) *time.Location {
loc, err := time.LoadLocation(tz)
if err != nil {
loc = time.Local
}
return loc
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright 2018 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 timezone
import (
"testing"
"time"
)
func TestNow(t *testing.T) {
tz := "Europe/Paris"
now := Now(tz)
if now.Location().String() != tz {
t.Fatalf(`Unexpected timezone, got %q instead of %q`, now.Location(), tz)
}
}
func TestNowWithInvalidTimezone(t *testing.T) {
tz := "Invalid Timezone"
expected := time.Local
now := Now(tz)
if now.Location().String() != expected.String() {
t.Fatalf(`Unexpected timezone, got %q instead of %q`, now.Location(), expected)
}
}
func TestConvertTimeWithNoTimezoneInformation(t *testing.T) {
tz := "Canada/Pacific"
input := time.Date(2018, 3, 1, 14, 2, 3, 0, time.FixedZone("", 0))
output := Convert(tz, input)
if output.Location().String() != tz {
t.Fatalf(`Unexpected timezone, got %q instead of %s`, output.Location(), tz)
}
hours, minutes, secs := output.Clock()
if hours != 14 || minutes != 2 || secs != 3 {
t.Fatalf(`Unexpected time, got hours=%d, minutes=%d, secs=%d`, hours, minutes, secs)
}
}
func TestConvertTimeWithDifferentTimezone(t *testing.T) {
tz := "Canada/Central"
input := time.Date(2018, 3, 1, 14, 2, 3, 0, time.UTC)
output := Convert(tz, input)
if output.Location().String() != tz {
t.Fatalf(`Unexpected timezone, got %q instead of %s`, output.Location(), tz)
}
hours, minutes, secs := output.Clock()
if hours != 8 || minutes != 2 || secs != 3 {
t.Fatalf(`Unexpected time, got hours=%d, minutes=%d, secs=%d`, hours, minutes, secs)
}
}
func TestConvertTimeWithIdenticalTimezone(t *testing.T) {
tz := "Canada/Central"
loc, _ := time.LoadLocation(tz)
input := time.Date(2018, 3, 1, 14, 2, 3, 0, loc)
output := Convert(tz, input)
if output.Location().String() != tz {
t.Fatalf(`Unexpected timezone, got %q instead of %s`, output.Location(), tz)
}
hours, minutes, secs := output.Clock()
if hours != 14 || minutes != 2 || secs != 3 {
t.Fatalf(`Unexpected time, got hours=%d, minutes=%d, secs=%d`, hours, minutes, secs)
}
}
+7 -6
View File
@@ -266,12 +266,6 @@ func (c *Controller) ShowUnreadEntry(ctx *handler.Context, request *handler.Requ
return
}
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
response.HTML().ServerError(err)
return
}
builder = c.store.NewEntryQueryBuilder(user.ID)
builder.WithStatus(model.EntryStatusUnread)
@@ -301,6 +295,13 @@ func (c *Controller) ShowUnreadEntry(ctx *handler.Context, request *handler.Requ
}
}
// The unread counter have to be fetched after changing the entry status
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
response.HTML().ServerError(err)
return
}
response.HTML().Render("entry", args.Merge(tplParams{
"entry": entry,
"prevEntry": prevEntry,
+9
View File
@@ -28,6 +28,9 @@ type IntegrationForm struct {
WallabagClientSecret string
WallabagUsername string
WallabagPassword string
NunuxKeeperEnabled bool
NunuxKeeperURL string
NunuxKeeperAPIKey string
}
// Merge copy form values to the model.
@@ -48,6 +51,9 @@ func (i IntegrationForm) Merge(integration *model.Integration) {
integration.WallabagClientSecret = i.WallabagClientSecret
integration.WallabagUsername = i.WallabagUsername
integration.WallabagPassword = i.WallabagPassword
integration.NunuxKeeperEnabled = i.NunuxKeeperEnabled
integration.NunuxKeeperURL = i.NunuxKeeperURL
integration.NunuxKeeperAPIKey = i.NunuxKeeperAPIKey
}
// NewIntegrationForm returns a new AuthForm.
@@ -69,5 +75,8 @@ func NewIntegrationForm(r *http.Request) *IntegrationForm {
WallabagClientSecret: r.FormValue("wallabag_client_secret"),
WallabagUsername: r.FormValue("wallabag_username"),
WallabagPassword: r.FormValue("wallabag_password"),
NunuxKeeperEnabled: r.FormValue("nunux_keeper_enabled") == "1",
NunuxKeeperURL: r.FormValue("nunux_keeper_url"),
NunuxKeeperAPIKey: r.FormValue("nunux_keeper_api_key"),
}
}
+3
View File
@@ -46,6 +46,9 @@ func (c *Controller) ShowIntegrations(ctx *handler.Context, request *handler.Req
WallabagClientSecret: integration.WallabagClientSecret,
WallabagUsername: integration.WallabagUsername,
WallabagPassword: integration.WallabagPassword,
NunuxKeeperEnabled: integration.NunuxKeeperEnabled,
NunuxKeeperURL: integration.NunuxKeeperURL,
NunuxKeeperAPIKey: integration.NunuxKeeperAPIKey,
},
}))
}
+2 -2
View File
@@ -59,7 +59,7 @@ func (c *Controller) CheckLogin(ctx *handler.Context, request *handler.Request,
logger.Info("[Controller:CheckLogin] username=%s just logged in", authForm.Username)
response.SetCookie(cookie.New(cookie.CookieUserSessionID, sessionToken, c.cfg.IsHTTPS))
response.SetCookie(cookie.New(cookie.CookieUserSessionID, sessionToken, c.cfg.IsHTTPS, c.cfg.BasePath()))
response.Redirect(ctx.Route("unread"))
}
@@ -75,6 +75,6 @@ func (c *Controller) Logout(ctx *handler.Context, request *handler.Request, resp
logger.Error("[Controller:Logout] %v", err)
}
response.SetCookie(cookie.Expired(cookie.CookieUserSessionID, c.cfg.IsHTTPS))
response.SetCookie(cookie.Expired(cookie.CookieUserSessionID, c.cfg.IsHTTPS, c.cfg.BasePath()))
response.Redirect(ctx.Route("login"))
}
+1 -1
View File
@@ -132,7 +132,7 @@ func (c *Controller) OAuth2Callback(ctx *handler.Context, request *handler.Reque
logger.Info("[Controller:OAuth2Callback] username=%s just logged in", user.Username)
response.SetCookie(cookie.New(cookie.CookieUserSessionID, sessionToken, c.cfg.IsHTTPS))
response.SetCookie(cookie.New(cookie.CookieUserSessionID, sessionToken, c.cfg.IsHTTPS, c.cfg.BasePath()))
response.Redirect(ctx.Route("unread"))
}
+1
View File
@@ -24,6 +24,7 @@ func (c *Controller) ShowSessions(ctx *handler.Context, request *handler.Request
return
}
sessions.UseTimezone(user.Timezone)
response.HTML().Render("sessions", args.Merge(tplParams{
"sessions": sessions,
"currentSessionToken": ctx.UserSessionToken(),
+1 -1
View File
@@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT.
// 2018-01-02 21:59:10.082800492 -0800 PST m=+0.010175821
// 2018-02-24 17:47:34.994475549 +0000 GMT
package static
+3 -3
View File
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More