Browse Source

First commit

Frédéric Guillot 4 years ago
commit
8ffb773f43
100 changed files with 8960 additions and 0 deletions
  1. 2 0
      .gitignore
  2. 5 0
      .travis.yml
  3. 81 0
      Gopkg.lock
  4. 54 0
      Gopkg.toml
  5. 177 0
      LICENSE
  6. 25 0
      Makefile
  7. 38 0
      README.md
  8. 36 0
      config/config.go
  9. 27 0
      errors/errors.go
  10. 120 0
      generate.go
  11. 38 0
      helper/crypto.go
  12. 16 0
      helper/time.go
  13. 47 0
      locale/language.go
  14. 30 0
      locale/locale.go
  15. 103 0
      locale/locale_test.go
  16. 101 0
      locale/plurals.go
  17. 136 0
      locale/translations.go
  18. 10 0
      locale/translations/en_US.json
  19. 113 0
      locale/translations/fr_FR.json
  20. 40 0
      locale/translator.go
  21. 124 0
      main.go
  22. 51 0
      model/category.go
  23. 18 0
      model/enclosure.go
  24. 71 0
      model/entry.go
  25. 66 0
      model/feed.go
  26. 19 0
      model/icon.go
  27. 10 0
      model/job.go
  28. 23 0
      model/session.go
  29. 13 0
      model/theme.go
  30. 96 0
      model/user.go
  31. 214 0
      reader/feed/atom/atom.go
  32. 28 0
      reader/feed/atom/parser.go
  33. 319 0
      reader/feed/atom/parser_test.go
  34. 203 0
      reader/feed/date/parser.go
  35. 152 0
      reader/feed/handler.go
  36. 170 0
      reader/feed/json/json.go
  37. 23 0
      reader/feed/json/parser.go
  38. 345 0
      reader/feed/json/parser_test.go
  39. 82 0
      reader/feed/parser.go
  40. 169 0
      reader/feed/parser_test.go
  41. 28 0
      reader/feed/rss/parser.go
  42. 466 0
      reader/feed/rss/parser_test.go
  43. 207 0
      reader/feed/rss/rss.go
  44. 95 0
      reader/http/client.go
  45. 32 0
      reader/http/response.go
  46. 109 0
      reader/icon/finder.go
  47. 94 0
      reader/opml/handler.go
  48. 82 0
      reader/opml/opml.go
  49. 26 0
      reader/opml/parser.go
  50. 138 0
      reader/opml/parser_test.go
  51. 58 0
      reader/opml/serializer.go
  52. 31 0
      reader/opml/serializer_test.go
  53. 18 0
      reader/opml/subscription.go
  54. 15 0
      reader/processor/processor.go
  55. 47 0
      reader/rewrite/rewriter.go
  56. 34 0
      reader/rewrite/rewriter_test.go
  57. 360 0
      reader/sanitizer/sanitizer.go
  58. 144 0
      reader/sanitizer/sanitizer_test.go
  59. 35 0
      reader/sanitizer/strip_tags.go
  60. 17 0
      reader/sanitizer/strip_tags_test.go
  61. 96 0
      reader/subscription/finder.go
  62. 21 0
      reader/subscription/subscription.go
  63. 61 0
      reader/url/url.go
  64. 107 0
      reader/url/url_test.go
  65. 24 0
      scheduler/scheduler.go
  66. 35 0
      scheduler/worker.go
  67. 34 0
      scheduler/worker_pool.go
  68. 97 0
      server/api/controller/category.go
  69. 21 0
      server/api/controller/controller.go
  70. 156 0
      server/api/controller/entry.go
  71. 138 0
      server/api/controller/feed.go
  72. 35 0
      server/api/controller/subscription.go
  73. 163 0
      server/api/controller/user.go
  74. 93 0
      server/api/payload/payload.go
  75. 99 0
      server/core/context.go
  76. 57 0
      server/core/handler.go
  77. 58 0
      server/core/html_response.go
  78. 94 0
      server/core/json_response.go
  79. 108 0
      server/core/request.go
  80. 63 0
      server/core/response.go
  81. 21 0
      server/core/xml_response.go
  82. 61 0
      server/middleware/basic_auth.go
  83. 48 0
      server/middleware/csrf.go
  84. 31 0
      server/middleware/middleware.go
  85. 72 0
      server/middleware/session.go
  86. 37 0
      server/route/route.go
  87. 132 0
      server/routes.go
  88. 33 0
      server/server.go
  89. 6 0
      server/static/bin.go
  90. BIN
      server/static/bin/favicon.ico
  91. 7 0
      server/static/css.go
  92. 197 0
      server/static/css/black.css
  93. 654 0
      server/static/css/common.css
  94. 52 0
      server/static/js.go
  95. 351 0
      server/static/js/app.js
  96. 111 0
      server/template/common.go
  97. 21 0
      server/template/helper/LICENSE
  98. 61 0
      server/template/helper/elapsed.go
  99. 37 0
      server/template/helper/elapsed_test.go
  100. 37 0
      server/template/html/about.html

+ 2 - 0
.gitignore

@@ -0,0 +1,2 @@
+miniflux-linux-amd64
+miniflux-darwin-amd64

+ 5 - 0
.travis.yml

@@ -0,0 +1,5 @@
+language: go
+go:
+  - 1.9
+script:
+  - go test -cover -race ./...

+ 81 - 0
Gopkg.lock

@@ -0,0 +1,81 @@
+# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
+
+
+[[projects]]
+  name = "github.com/PuerkitoBio/goquery"
+  packages = ["."]
+  revision = "e1271ee34c6a305e38566ecd27ae374944907ee9"
+  version = "v1.1.0"
+
+[[projects]]
+  branch = "master"
+  name = "github.com/andybalholm/cascadia"
+  packages = ["."]
+  revision = "349dd0209470eabd9514242c688c403c0926d266"
+
+[[projects]]
+  name = "github.com/gorilla/context"
+  packages = ["."]
+  revision = "1ea25387ff6f684839d82767c1733ff4d4d15d0a"
+  version = "v1.1"
+
+[[projects]]
+  name = "github.com/gorilla/mux"
+  packages = ["."]
+  revision = "7f08801859139f86dfafd1c296e2cba9a80d292e"
+  version = "v1.6.0"
+
+[[projects]]
+  branch = "master"
+  name = "github.com/lib/pq"
+  packages = [".","oid"]
+  revision = "8c6ee72f3e6bcb1542298dd5f76cb74af9742cec"
+
+[[projects]]
+  name = "github.com/tdewolff/minify"
+  packages = [".","css","js"]
+  revision = "90df1aae5028a7cbb441bde86e86a55df6b5aa34"
+  version = "v2.3.3"
+
+[[projects]]
+  name = "github.com/tdewolff/parse"
+  packages = [".","buffer","css","js","strconv"]
+  revision = "bace4cf682c41e03b154044b561575ff541b83e8"
+  version = "v2.3.1"
+
+[[projects]]
+  branch = "master"
+  name = "github.com/tomasen/realip"
+  packages = ["."]
+  revision = "15489afd3be348430f5f67467d2bb6b2f9b757ed"
+
+[[projects]]
+  branch = "master"
+  name = "golang.org/x/crypto"
+  packages = ["bcrypt","blowfish","ssh/terminal"]
+  revision = "9f005a07e0d31d45e6656d241bb5c0f2efd4bc94"
+
+[[projects]]
+  branch = "master"
+  name = "golang.org/x/net"
+  packages = ["html","html/atom","html/charset"]
+  revision = "9dfe39835686865bff950a07b394c12a98ddc811"
+
+[[projects]]
+  branch = "master"
+  name = "golang.org/x/sys"
+  packages = ["unix","windows"]
+  revision = "0dd5e194bbf5eb84a39666eb4c98a4d007e4203a"
+
+[[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"]
+  revision = "88f656faf3f37f690df1a32515b479415e1a6769"
+
+[solve-meta]
+  analyzer-name = "dep"
+  analyzer-version = 1
+  inputs-digest = "27a0ca12f5a709bb76b9c90f6720b6824ac8fc81b2fc66f059f212366443ff5d"
+  solver-name = "gps-cdcl"
+  solver-version = 1

+ 54 - 0
Gopkg.toml

@@ -0,0 +1,54 @@
+
+# Gopkg.toml example
+#
+# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md
+# for detailed Gopkg.toml documentation.
+#
+# required = ["github.com/user/thing/cmd/thing"]
+# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
+#
+# [[constraint]]
+#   name = "github.com/user/project"
+#   version = "1.0.0"
+#
+# [[constraint]]
+#   name = "github.com/user/project2"
+#   branch = "dev"
+#   source = "github.com/myfork/project2"
+#
+# [[override]]
+#  name = "github.com/x/y"
+#  version = "2.4.0"
+
+
+[[constraint]]
+  name = "github.com/PuerkitoBio/goquery"
+  version = "1.1.0"
+
+[[constraint]]
+  name = "github.com/gorilla/mux"
+  version = "1.6.0"
+
+[[constraint]]
+  branch = "master"
+  name = "github.com/lib/pq"
+
+[[constraint]]
+  branch = "master"
+  name = "github.com/rvflash/elapsed"
+
+[[constraint]]
+  name = "github.com/tdewolff/minify"
+  version = "2.3.3"
+
+[[constraint]]
+  branch = "master"
+  name = "github.com/tomasen/realip"
+
+[[constraint]]
+  branch = "master"
+  name = "golang.org/x/crypto"
+
+[[constraint]]
+  branch = "master"
+  name = "golang.org/x/net"

+ 177 - 0
LICENSE

@@ -0,0 +1,177 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS

+ 25 - 0
Makefile

@@ -0,0 +1,25 @@
+APP = miniflux
+VERSION = $(shell git rev-parse --short HEAD)
+BUILD_DATE = `date +%FT%T%z`
+
+.PHONY: build-linux build-darwin build run clean test
+
+build-linux:
+	@ go generate
+	@ GOOS=linux GOARCH=amd64 go build -ldflags="-X 'miniflux/version.Version=$(VERSION)' -X 'miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-linux-amd64 main.go
+
+build-darwin:
+	@ go generate
+	@ GOOS=darwin GOARCH=amd64 go build -ldflags="-X 'miniflux/version.Version=$(VERSION)' -X 'miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-darwin-amd64 main.go
+
+build: build-linux build-darwin
+
+run:
+	@ go generate
+	@ go run main.go
+
+clean:
+	@ rm -f $(APP)-*
+
+test:
+	go test -cover -race ./...

+ 38 - 0
README.md

@@ -0,0 +1,38 @@
+Miniflux 2
+==========
+[![Build Status](https://travis-ci.org/miniflux/miniflux2.svg?branch=master)](https://travis-ci.org/miniflux/miniflux2)
+
+Miniflux is a minimalist and opinionated feed reader:
+
+- Written in Go (Golang)
+- Works only with Postgresql
+- Doesn't use any ORM
+- Doesn't use any complicated framework
+- The number of features is volountary limited
+
+It's simple, fast, lightweight and super easy to install.
+
+Miniflux 2 is a rewrite of Miniflux 1.x in Golang.
+
+Notes
+-----
+
+Miniflux 2 still in development and **it's not ready to use**.
+
+TODO
+----
+
+- [ ] Custom entries sorting
+- [ ] Webpage scraper (Readability)
+- [ ] Bookmarklet
+- [ ] External integrations (Pinboard, Wallabag...)
+- [ ] Gzip compression
+- [ ] Integration tests
+- [ ] Flush history
+- [ ] OAuth2
+
+Credits
+-------
+
+- Author: Frédéric Guillot
+- Distributed under Apache 2.0 License

+ 36 - 0
config/config.go

@@ -0,0 +1,36 @@
+// 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"
+	"strconv"
+)
+
+type Config struct {
+}
+
+func (c *Config) Get(key, fallback string) string {
+	value := os.Getenv(key)
+	if value == "" {
+		return fallback
+	}
+
+	return value
+}
+
+func (c *Config) GetInt(key string, fallback int) int {
+	value := os.Getenv(key)
+	if value == "" {
+		return fallback
+	}
+
+	v, _ := strconv.Atoi(value)
+	return v
+}
+
+func NewConfig() *Config {
+	return &Config{}
+}

+ 27 - 0
errors/errors.go

@@ -0,0 +1,27 @@
+// 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 errors
+
+import (
+	"fmt"
+	"github.com/miniflux/miniflux2/locale"
+)
+
+type LocalizedError struct {
+	message string
+	args    []interface{}
+}
+
+func (l LocalizedError) Error() string {
+	return fmt.Sprintf(l.message, l.args...)
+}
+
+func (l LocalizedError) Localize(translation *locale.Language) string {
+	return translation.Get(l.message, l.args...)
+}
+
+func NewLocalizedError(message string, args ...interface{}) LocalizedError {
+	return LocalizedError{message: message, args: args}
+}

+ 120 - 0
generate.go

@@ -0,0 +1,120 @@
+// 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.
+
+// +build ignore
+
+package main
+
+import (
+	"crypto/sha256"
+	"encoding/base64"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path"
+	"path/filepath"
+	"strings"
+	"text/template"
+	"time"
+
+	"github.com/tdewolff/minify"
+	"github.com/tdewolff/minify/css"
+	"github.com/tdewolff/minify/js"
+)
+
+const tpl = `// Code generated by go generate; DO NOT EDIT.
+// {{ .Timestamp }}
+
+package {{ .Package }}
+
+var {{ .Map }} = map[string]string{
+{{ range $constant, $content := .Files }}` + "\t" + `"{{ $constant }}": ` + "`{{ $content }}`" + `,
+{{ end }}}
+
+var {{ .Map }}Checksums = map[string]string{
+{{ range $constant, $content := .Checksums }}` + "\t" + `"{{ $constant }}": "{{ $content }}",
+{{ end }}}
+`
+
+var generatedTpl = template.Must(template.New("").Parse(tpl))
+
+type GeneratedFile struct {
+	Package, Map string
+	Timestamp    time.Time
+	Files        map[string]string
+	Checksums    map[string]string
+}
+
+func normalizeBasename(filename string) string {
+	filename = strings.TrimSuffix(filename, filepath.Ext(filename))
+	return strings.Replace(filename, " ", "_", -1)
+}
+
+func generateFile(serializer, pkg, mapName, pattern, output string) {
+	generatedFile := &GeneratedFile{
+		Package:   pkg,
+		Map:       mapName,
+		Timestamp: time.Now(),
+		Files:     make(map[string]string),
+		Checksums: make(map[string]string),
+	}
+
+	files, _ := filepath.Glob(pattern)
+	for _, file := range files {
+		basename := path.Base(file)
+		content, err := ioutil.ReadFile(file)
+		if err != nil {
+			panic(err)
+		}
+
+		switch serializer {
+		case "css":
+			m := minify.New()
+			m.AddFunc("text/css", css.Minify)
+			content, err = m.Bytes("text/css", content)
+			if err != nil {
+				panic(err)
+			}
+
+			basename = normalizeBasename(basename)
+			generatedFile.Files[basename] = string(content)
+		case "js":
+			m := minify.New()
+			m.AddFunc("text/javascript", js.Minify)
+			content, err = m.Bytes("text/javascript", content)
+			if err != nil {
+				panic(err)
+			}
+
+			basename = normalizeBasename(basename)
+			generatedFile.Files[basename] = string(content)
+		case "base64":
+			encodedContent := base64.StdEncoding.EncodeToString(content)
+			generatedFile.Files[basename] = encodedContent
+		default:
+			basename = normalizeBasename(basename)
+			generatedFile.Files[basename] = string(content)
+		}
+
+		generatedFile.Checksums[basename] = fmt.Sprintf("%x", sha256.Sum256(content))
+	}
+
+	f, err := os.Create(output)
+	if err != nil {
+		panic(err)
+	}
+	defer f.Close()
+
+	generatedTpl.Execute(f, generatedFile)
+}
+
+func main() {
+	generateFile("none", "sql", "SqlMap", "sql/*.sql", "sql/sql.go")
+	generateFile("base64", "static", "Binaries", "server/static/bin/*", "server/static/bin.go")
+	generateFile("css", "static", "Stylesheets", "server/static/css/*.css", "server/static/css.go")
+	generateFile("js", "static", "Javascript", "server/static/js/*.js", "server/static/js.go")
+	generateFile("none", "template", "templateViewsMap", "server/template/html/*.html", "server/template/views.go")
+	generateFile("none", "template", "templateCommonMap", "server/template/html/common/*.html", "server/template/common.go")
+	generateFile("none", "locale", "Translations", "locale/translations/*.json", "locale/translations.go")
+}

+ 38 - 0
helper/crypto.go

@@ -0,0 +1,38 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package helper
+
+import (
+	"crypto/rand"
+	"crypto/sha256"
+	"encoding/base64"
+	"fmt"
+)
+
+// HashFromBytes returns a SHA-256 checksum of the input.
+func HashFromBytes(value []byte) string {
+	sum := sha256.Sum256(value)
+	return fmt.Sprintf("%x", sum)
+}
+
+// Hash returns a SHA-256 checksum of a string.
+func Hash(value string) string {
+	return HashFromBytes([]byte(value))
+}
+
+// GenerateRandomBytes returns random bytes.
+func GenerateRandomBytes(size int) []byte {
+	b := make([]byte, size)
+	if _, err := rand.Read(b); err != nil {
+		panic(fmt.Errorf("Unable to generate random string: %v", err))
+	}
+
+	return b
+}
+
+// GenerateRandomString returns a random string.
+func GenerateRandomString(size int) string {
+	return base64.URLEncoding.EncodeToString(GenerateRandomBytes(size))
+}

+ 16 - 0
helper/time.go

@@ -0,0 +1,16 @@
+// 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 helper
+
+import (
+	"log"
+	"time"
+)
+
+// ExecutionTime returns the elapsed time of a block of code.
+func ExecutionTime(start time.Time, name string) {
+	elapsed := time.Since(start)
+	log.Printf("%s took %s", name, elapsed)
+}

+ 47 - 0
locale/language.go

@@ -0,0 +1,47 @@
+// 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 locale
+
+import "fmt"
+
+type Language struct {
+	language     string
+	translations Translation
+}
+
+func (l *Language) Get(key string, args ...interface{}) string {
+	var translation string
+
+	str, found := l.translations[key]
+	if !found {
+		translation = key
+	} else {
+		translation = str.(string)
+	}
+
+	return fmt.Sprintf(translation, args...)
+}
+
+func (l *Language) Plural(key string, n int, args ...interface{}) string {
+	translation := key
+	slices, found := l.translations[key]
+	if found {
+
+		pluralForm, found := pluralForms[l.language]
+		if !found {
+			pluralForm = pluralForms["default"]
+		}
+
+		index := pluralForm(n)
+		translations := slices.([]interface{})
+		translation = key
+
+		if len(translations) > index {
+			translation = translations[index].(string)
+		}
+	}
+
+	return fmt.Sprintf(translation, args...)
+}

+ 30 - 0
locale/locale.go

@@ -0,0 +1,30 @@
+// 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 locale
+
+import "log"
+
+type Translation map[string]interface{}
+
+type Locales map[string]Translation
+
+func Load() *Translator {
+	translator := NewTranslator()
+
+	for language, translations := range Translations {
+		log.Println("Loading translation:", language)
+		translator.AddLanguage(language, translations)
+	}
+
+	return translator
+}
+
+// GetAvailableLanguages returns the list of available languages.
+func GetAvailableLanguages() map[string]string {
+	return map[string]string{
+		"en_US": "English",
+		"fr_FR": "Français",
+	}
+}

+ 103 - 0
locale/locale_test.go

@@ -0,0 +1,103 @@
+// 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 locale
+
+import "testing"
+
+func TestTranslateWithMissingLanguage(t *testing.T) {
+	translator := NewTranslator()
+	translation := translator.GetLanguage("en_US").Get("auth.username")
+
+	if translation != "auth.username" {
+		t.Errorf("Wrong translation, got %s", translation)
+	}
+}
+
+func TestTranslateWithExistingKey(t *testing.T) {
+	data := `{"auth.username": "Username"}`
+	translator := NewTranslator()
+	translator.AddLanguage("en_US", data)
+	translation := translator.GetLanguage("en_US").Get("auth.username")
+
+	if translation != "Username" {
+		t.Errorf("Wrong translation, got %s", translation)
+	}
+}
+
+func TestTranslateWithMissingKey(t *testing.T) {
+	data := `{"auth.username": "Username"}`
+	translator := NewTranslator()
+	translator.AddLanguage("en_US", data)
+	translation := translator.GetLanguage("en_US").Get("auth.password")
+
+	if translation != "auth.password" {
+		t.Errorf("Wrong translation, got %s", translation)
+	}
+}
+
+func TestTranslateWithMissingKeyAndPlaceholder(t *testing.T) {
+	translator := NewTranslator()
+	translator.AddLanguage("fr_FR", "")
+	translation := translator.GetLanguage("fr_FR").Get("Status: %s", "ok")
+
+	if translation != "Status: ok" {
+		t.Errorf("Wrong translation, got %s", translation)
+	}
+}
+
+func TestTranslatePluralWithDefaultRule(t *testing.T) {
+	data := `{"number_of_users": ["Il y a %d utilisateur (%s)", "Il y a %d utilisateurs (%s)"]}`
+	translator := NewTranslator()
+	translator.AddLanguage("fr_FR", data)
+	language := translator.GetLanguage("fr_FR")
+
+	translation := language.Plural("number_of_users", 1, 1, "some text")
+	expected := "Il y a 1 utilisateur (some text)"
+	if translation != expected {
+		t.Errorf(`Wrong translation, got "%s" instead of "%s"`, translation, expected)
+	}
+
+	translation = language.Plural("number_of_users", 2, 2, "some text")
+	expected = "Il y a 2 utilisateurs (some text)"
+	if translation != expected {
+		t.Errorf(`Wrong translation, got "%s" instead of "%s"`, translation, expected)
+	}
+}
+
+func TestTranslatePluralWithRussianRule(t *testing.T) {
+	data := `{"key": ["из %d книги за %d день", "из %d книг за %d дня", "из %d книг за %d дней"]}`
+	translator := NewTranslator()
+	translator.AddLanguage("ru_RU", data)
+	language := translator.GetLanguage("ru_RU")
+
+	translation := language.Plural("key", 1, 1, 1)
+	expected := "из 1 книги за 1 день"
+	if translation != expected {
+		t.Errorf(`Wrong translation, got "%s" instead of "%s"`, translation, expected)
+	}
+
+	translation = language.Plural("key", 2, 2, 2)
+	expected = "из 2 книг за 2 дня"
+	if translation != expected {
+		t.Errorf(`Wrong translation, got "%s" instead of "%s"`, translation, expected)
+	}
+
+	translation = language.Plural("key", 5, 5, 5)
+	expected = "из 5 книг за 5 дней"
+	if translation != expected {
+		t.Errorf(`Wrong translation, got "%s" instead of "%s"`, translation, expected)
+	}
+}
+
+func TestTranslatePluralWithMissingTranslation(t *testing.T) {
+	translator := NewTranslator()
+	translator.AddLanguage("fr_FR", "")
+	language := translator.GetLanguage("fr_FR")
+
+	translation := language.Plural("number_of_users", 2)
+	expected := "number_of_users"
+	if translation != expected {
+		t.Errorf(`Wrong translation, got "%s" instead of "%s"`, translation, expected)
+	}
+}

+ 101 - 0
locale/plurals.go

@@ -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 locale
+
+// See https://localization-guide.readthedocs.io/en/latest/l10n/pluralforms.html
+// And http://www.unicode.org/cldr/charts/29/supplemental/language_plural_rules.html
+var pluralForms = map[string]func(n int) int{
+	// nplurals=2; plural=(n != 1);
+	"default": func(n int) int {
+		if n != 1 {
+			return 1
+		}
+
+		return 0
+	},
+	// nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 ? 4 : 5);
+	"ar_AR": func(n int) int {
+		if n == 0 {
+			return 0
+		}
+
+		if n == 1 {
+			return 1
+		}
+
+		if n == 2 {
+			return 2
+		}
+
+		if n%100 >= 3 && n%100 <= 10 {
+			return 3
+		}
+
+		if n%100 >= 11 {
+			return 4
+		}
+
+		return 5
+	},
+	// nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;
+	"cs_CZ": func(n int) int {
+		if n == 1 {
+			return 0
+		}
+
+		if n >= 2 && n <= 4 {
+			return 1
+		}
+
+		return 2
+	},
+	// nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);
+	"pl_PL": func(n int) int {
+		if n == 1 {
+			return 0
+		}
+
+		if n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20) {
+			return 1
+		}
+
+		return 2
+	},
+	// nplurals=2; plural=(n > 1);
+	"pt_BR": func(n int) int {
+		if n > 1 {
+			return 1
+		}
+		return 0
+	},
+	// nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);
+	"ru_RU": func(n int) int {
+		if n%10 == 1 && n%100 != 11 {
+			return 0
+		}
+
+		if n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20) {
+			return 1
+		}
+
+		return 2
+	},
+	// nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);
+	"sr_RS": func(n int) int {
+		if n%10 == 1 && n%100 != 11 {
+			return 0
+		}
+
+		if n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20) {
+			return 1
+		}
+
+		return 2
+	},
+	// nplurals=1; plural=0;
+	"zh_CN": func(n int) int {
+		return 0
+	},
+}

+ 136 - 0
locale/translations.go

@@ -0,0 +1,136 @@
+// Code generated by go generate; DO NOT EDIT.
+// 2017-11-19 22:01:21.925268372 -0800 PST m=+0.006101515
+
+package locale
+
+var Translations = map[string]string{
+	"en_US": `{
+    "plural.feed.error_count": [
+        "%d error",
+        "%d errors"
+    ],
+    "plural.categories.feed_count": [
+        "There is %d feed.",
+        "There are %d feeds."
+    ]
+}`,
+	"fr_FR": `{
+    "plural.feed.error_count": [
+        "%d erreur",
+        "%d erreurs"
+    ],
+    "plural.categories.feed_count": [
+        "Il y %d abonnement.",
+        "Il y %d abonnements."
+    ],
+    "Username": "Nom d'utilisateur",
+    "Password": "Mot de passe",
+    "Unread": "Non lus",
+    "History": "Historique",
+    "Feeds": "Abonnements",
+    "Categories": "Catégories",
+    "Settings": "Réglages",
+    "Logout": "Se déconnecter",
+    "Next": "Suivant",
+    "Previous": "Précédent",
+    "New Subscription": "Nouvel Abonnment",
+    "Import": "Importation",
+    "Export": "Exportation",
+    "There is no category. You must have at least one category.": "Il n'y a aucune catégorie. Vous devez avoir au moins une catégorie.",
+    "URL": "URL",
+    "Category": "Catégorie",
+    "Find a subscription": "Trouver un abonnement",
+    "Loading...": "Chargement...",
+    "Create a category": "Créer une catégorie",
+    "There is no category.": "Il n'y a aucune catégorie.",
+    "Edit": "Modifier",
+    "Remove": "Supprimer",
+    "No feed.": "Aucun abonnement.",
+    "There is no article in this category.": "Il n'y a aucun article dans cette catégorie.",
+    "Original": "Original",
+    "Mark this page as read": "Marquer cette page comme lu",
+    "not yet": "pas encore",
+    "just now": "à l'instant",
+    "1 minute ago": "il y a une minute",
+    "%d minutes ago": "il y a %d minutes",
+    "1 hour ago": "il y a une heure",
+    "%d hours ago": "il y a %d heures",
+    "yesterday": "hier",
+    "%d days ago": "il y a %d jours",
+    "%d weeks ago": "il y a %d semaines",
+    "%d months ago": "il y a %d mois",
+    "%d years ago": "il y a %d années",
+    "Date": "Date",
+    "IP Address": "Adresse IP",
+    "User Agent": "Navigateur Web",
+    "Actions": "Actions",
+    "Current session": "Session actuelle",
+    "Sessions": "Sessions",
+    "Users": "Utilisateurs",
+    "Add user": "Ajouter un utilisateur",
+    "Choose a Subscription": "Choisissez un abonnement",
+    "Subscribe": "S'abonner",
+    "New Category": "Nouvelle Catégorie",
+    "Title": "Titre",
+    "Save": "Sauvegarder",
+    "or": "ou",
+    "cancel": "annuler",
+    "New User": "Nouvel Utilisateur",
+    "Confirmation": "Confirmation",
+    "Administrator": "Administrateur",
+    "Edit Category: %s": "Modification de la catégorie : %s",
+    "Update": "Mettre à jour",
+    "Edit Feed: %s": "Modification de l'abonnement : %s",
+    "There is no category!": "Il n'y a aucune catégorie !",
+    "Edit user: %s": "Modification de l'utilisateur : %s",
+    "There is no article for this feed.": "Il n'y a aucun article pour cet abonnement.",
+    "Add subscription": "Ajouter un abonnement",
+    "You don't have any subscription.": "Vous n'avez aucun abonnement",
+    "Last check:": "Dernière vérification :",
+    "Refresh": "Actualiser",
+    "There is no history at the moment.": "Il n'y a aucun historique pour le moment.",
+    "OPML file": "Fichier OPML",
+    "Sign In": "Connexion",
+    "Sign in": "Connexion",
+    "Theme": "Thème",
+    "Timezone": "Fuseau horaire",
+    "Language": "Langue",
+    "There is no unread article.": "Il n'y a rien de nouveau à lire.",
+    "You are the only user.": "Vous êtes le seul utilisateur.",
+    "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).",
+    "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.",
+    "The URL and the category are mandatory.": "L'URL et la catégorie sont obligatoire.",
+    "All fields are mandatory.": "Tous les champs sont obligatoire.",
+    "Passwords are not the same.": "Les mots de passe ne sont pas les mêmes.",
+    "You must use at least 6 characters.": "Vous devez utiliser au moins 6 caractères.",
+    "The username is mandatory.": "Le nom d'utilisateur est obligatoire.",
+    "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 :",
+    "Build Date:": "Date de la compilation :",
+    "Author:": "Auteur :",
+    "Authors": "Auteurs",
+    "License:": "Licence :",
+    "Attachments": "Pièces jointes",
+    "Download": "Télécharger",
+    "Invalid username or password.": "Mauvais identifiant ou mot de passe.",
+    "Never": "Jamais",
+    "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"
+}
+`,
+}
+
+var TranslationsChecksums = map[string]string{
+	"en_US": "6fe95384260941e8a5a3c695a655a932e0a8a6a572c1e45cb2b1ae8baa01b897",
+	"fr_FR": "1f75e5a4b581755f7f84687126bc5b96aaf0109a2f83a72a8770c2ad3ddb7ba3",
+}

+ 10 - 0
locale/translations/en_US.json

@@ -0,0 +1,10 @@
+{
+    "plural.feed.error_count": [
+        "%d error",
+        "%d errors"
+    ],
+    "plural.categories.feed_count": [
+        "There is %d feed.",
+        "There are %d feeds."
+    ]
+}

+ 113 - 0
locale/translations/fr_FR.json

@@ -0,0 +1,113 @@
+{
+    "plural.feed.error_count": [
+        "%d erreur",
+        "%d erreurs"
+    ],
+    "plural.categories.feed_count": [
+        "Il y %d abonnement.",
+        "Il y %d abonnements."
+    ],
+    "Username": "Nom d'utilisateur",
+    "Password": "Mot de passe",
+    "Unread": "Non lus",
+    "History": "Historique",
+    "Feeds": "Abonnements",
+    "Categories": "Catégories",
+    "Settings": "Réglages",
+    "Logout": "Se déconnecter",
+    "Next": "Suivant",
+    "Previous": "Précédent",
+    "New Subscription": "Nouvel Abonnment",
+    "Import": "Importation",
+    "Export": "Exportation",
+    "There is no category. You must have at least one category.": "Il n'y a aucune catégorie. Vous devez avoir au moins une catégorie.",
+    "URL": "URL",
+    "Category": "Catégorie",
+    "Find a subscription": "Trouver un abonnement",
+    "Loading...": "Chargement...",
+    "Create a category": "Créer une catégorie",
+    "There is no category.": "Il n'y a aucune catégorie.",
+    "Edit": "Modifier",
+    "Remove": "Supprimer",
+    "No feed.": "Aucun abonnement.",
+    "There is no article in this category.": "Il n'y a aucun article dans cette catégorie.",
+    "Original": "Original",
+    "Mark this page as read": "Marquer cette page comme lu",
+    "not yet": "pas encore",
+    "just now": "à l'instant",
+    "1 minute ago": "il y a une minute",
+    "%d minutes ago": "il y a %d minutes",
+    "1 hour ago": "il y a une heure",
+    "%d hours ago": "il y a %d heures",
+    "yesterday": "hier",
+    "%d days ago": "il y a %d jours",
+    "%d weeks ago": "il y a %d semaines",
+    "%d months ago": "il y a %d mois",
+    "%d years ago": "il y a %d années",
+    "Date": "Date",
+    "IP Address": "Adresse IP",
+    "User Agent": "Navigateur Web",
+    "Actions": "Actions",
+    "Current session": "Session actuelle",
+    "Sessions": "Sessions",
+    "Users": "Utilisateurs",
+    "Add user": "Ajouter un utilisateur",
+    "Choose a Subscription": "Choisissez un abonnement",
+    "Subscribe": "S'abonner",
+    "New Category": "Nouvelle Catégorie",
+    "Title": "Titre",
+    "Save": "Sauvegarder",
+    "or": "ou",
+    "cancel": "annuler",
+    "New User": "Nouvel Utilisateur",
+    "Confirmation": "Confirmation",
+    "Administrator": "Administrateur",
+    "Edit Category: %s": "Modification de la catégorie : %s",
+    "Update": "Mettre à jour",
+    "Edit Feed: %s": "Modification de l'abonnement : %s",
+    "There is no category!": "Il n'y a aucune catégorie !",
+    "Edit user: %s": "Modification de l'utilisateur : %s",
+    "There is no article for this feed.": "Il n'y a aucun article pour cet abonnement.",
+    "Add subscription": "Ajouter un abonnement",
+    "You don't have any subscription.": "Vous n'avez aucun abonnement",
+    "Last check:": "Dernière vérification :",
+    "Refresh": "Actualiser",
+    "There is no history at the moment.": "Il n'y a aucun historique pour le moment.",
+    "OPML file": "Fichier OPML",
+    "Sign In": "Connexion",
+    "Sign in": "Connexion",
+    "Theme": "Thème",
+    "Timezone": "Fuseau horaire",
+    "Language": "Langue",
+    "There is no unread article.": "Il n'y a rien de nouveau à lire.",
+    "You are the only user.": "Vous êtes le seul utilisateur.",
+    "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).",
+    "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.",
+    "The URL and the category are mandatory.": "L'URL et la catégorie sont obligatoire.",
+    "All fields are mandatory.": "Tous les champs sont obligatoire.",
+    "Passwords are not the same.": "Les mots de passe ne sont pas les mêmes.",
+    "You must use at least 6 characters.": "Vous devez utiliser au moins 6 caractères.",
+    "The username is mandatory.": "Le nom d'utilisateur est obligatoire.",
+    "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 :",
+    "Build Date:": "Date de la compilation :",
+    "Author:": "Auteur :",
+    "Authors": "Auteurs",
+    "License:": "Licence :",
+    "Attachments": "Pièces jointes",
+    "Download": "Télécharger",
+    "Invalid username or password.": "Mauvais identifiant ou mot de passe.",
+    "Never": "Jamais",
+    "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"
+}

+ 40 - 0
locale/translator.go

@@ -0,0 +1,40 @@
+// 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 locale
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+)
+
+type Translator struct {
+	Locales Locales
+}
+
+func (t *Translator) AddLanguage(language, translations string) error {
+	var decodedTranslations Translation
+
+	decoder := json.NewDecoder(strings.NewReader(translations))
+	if err := decoder.Decode(&decodedTranslations); err != nil {
+		return fmt.Errorf("Invalid JSON file: %v", err)
+	}
+
+	t.Locales[language] = decodedTranslations
+	return nil
+}
+
+func (t *Translator) GetLanguage(language string) *Language {
+	translations, found := t.Locales[language]
+	if !found {
+		return &Language{language: language}
+	}
+
+	return &Language{language: language, translations: translations}
+}
+
+func NewTranslator() *Translator {
+	return &Translator{Locales: make(Locales)}
+}

+ 124 - 0
main.go

@@ -0,0 +1,124 @@
+// 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 main
+
+//go:generate go run generate.go
+
+import (
+	"bufio"
+	"context"
+	"flag"
+	"fmt"
+	"github.com/miniflux/miniflux2/config"
+	"github.com/miniflux/miniflux2/model"
+	"github.com/miniflux/miniflux2/reader/feed"
+	"github.com/miniflux/miniflux2/scheduler"
+	"github.com/miniflux/miniflux2/server"
+	"github.com/miniflux/miniflux2/storage"
+	"github.com/miniflux/miniflux2/version"
+	"log"
+	"os"
+	"os/signal"
+	"runtime"
+	"strings"
+	"time"
+
+	_ "github.com/lib/pq"
+	"golang.org/x/crypto/ssh/terminal"
+)
+
+func run(cfg *config.Config, store *storage.Storage) {
+	log.Println("Starting Miniflux...")
+
+	stop := make(chan os.Signal, 1)
+	signal.Notify(stop, os.Interrupt)
+
+	feedHandler := feed.NewFeedHandler(store)
+	server := server.NewServer(cfg, store, feedHandler)
+
+	go func() {
+		pool := scheduler.NewWorkerPool(feedHandler, cfg.GetInt("WORKER_POOL_SIZE", 5))
+		scheduler.NewScheduler(store, pool, cfg.GetInt("POLLING_FREQUENCY", 30), cfg.GetInt("BATCH_SIZE", 10))
+	}()
+
+	<-stop
+	log.Println("Shutting down the server...")
+	ctx, _ := context.WithTimeout(context.Background(), 5*time.Second)
+	server.Shutdown(ctx)
+	store.Close()
+	log.Println("Server gracefully stopped")
+}
+
+func askCredentials() (string, string) {
+	reader := bufio.NewReader(os.Stdin)
+
+	fmt.Print("Enter Username: ")
+	username, _ := reader.ReadString('\n')
+
+	fmt.Print("Enter Password: ")
+	bytePassword, _ := terminal.ReadPassword(0)
+
+	fmt.Printf("\n")
+	return strings.TrimSpace(username), strings.TrimSpace(string(bytePassword))
+}
+
+func main() {
+	flagInfo := flag.Bool("info", false, "Show application information")
+	flagVersion := flag.Bool("version", false, "Show application version")
+	flagMigrate := flag.Bool("migrate", false, "Migrate database schema")
+	flagFlushSessions := flag.Bool("flush-sessions", false, "Flush all sessions (disconnect users)")
+	flagCreateAdmin := flag.Bool("create-admin", false, "Create admin user")
+	flag.Parse()
+
+	cfg := config.NewConfig()
+	store := storage.NewStorage(
+		cfg.Get("DATABASE_URL", "postgres://postgres:postgres@localhost/miniflux2?sslmode=disable"),
+		cfg.GetInt("DATABASE_MAX_CONNS", 20),
+	)
+
+	if *flagInfo {
+		fmt.Println("Version:", version.Version)
+		fmt.Println("Build Date:", version.BuildDate)
+		fmt.Println("Go Version:", runtime.Version())
+		return
+	}
+
+	if *flagVersion {
+		fmt.Println(version.Version)
+		return
+	}
+
+	if *flagMigrate {
+		store.Migrate()
+		return
+	}
+
+	if *flagFlushSessions {
+		fmt.Println("Flushing all sessions (disconnect users)")
+		if err := store.FlushAllSessions(); err != nil {
+			fmt.Println(err)
+			os.Exit(1)
+		}
+		return
+	}
+
+	if *flagCreateAdmin {
+		user := &model.User{IsAdmin: true}
+		user.Username, user.Password = askCredentials()
+		if err := user.ValidateUserCreation(); err != nil {
+			fmt.Println(err)
+			os.Exit(1)
+		}
+
+		if err := store.CreateUser(user); err != nil {
+			fmt.Println(err)
+			os.Exit(1)
+		}
+
+		return
+	}
+
+	run(cfg, store)
+}

+ 51 - 0
model/category.go

@@ -0,0 +1,51 @@
+// 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 model
+
+import (
+	"errors"
+	"fmt"
+)
+
+type Category struct {
+	ID        int64  `json:"id,omitempty"`
+	Title     string `json:"title,omitempty"`
+	UserID    int64  `json:"user_id,omitempty"`
+	FeedCount int    `json:"nb_feeds,omitempty"`
+}
+
+func (c *Category) String() string {
+	return fmt.Sprintf("ID=%d, UserID=%d, Title=%s", c.ID, c.UserID, c.Title)
+}
+
+func (c Category) ValidateCategoryCreation() error {
+	if c.Title == "" {
+		return errors.New("The title is mandatory")
+	}
+
+	if c.UserID == 0 {
+		return errors.New("The userID is mandatory")
+	}
+
+	return nil
+}
+
+func (c Category) ValidateCategoryModification() error {
+	if c.Title == "" {
+		return errors.New("The title is mandatory")
+	}
+
+	if c.UserID == 0 {
+		return errors.New("The userID is mandatory")
+	}
+
+	if c.ID == 0 {
+		return errors.New("The ID is mandatory")
+	}
+
+	return nil
+}
+
+type Categories []*Category

+ 18 - 0
model/enclosure.go

@@ -0,0 +1,18 @@
+// 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 model
+
+// Enclosure represents an attachment.
+type Enclosure struct {
+	ID       int64  `json:"id"`
+	UserID   int64  `json:"user_id"`
+	EntryID  int64  `json:"entry_id"`
+	URL      string `json:"url"`
+	MimeType string `json:"mime_type"`
+	Size     int    `json:"size"`
+}
+
+// EnclosureList represents a list of attachments.
+type EnclosureList []*Enclosure

+ 71 - 0
model/entry.go

@@ -0,0 +1,71 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package model
+
+import (
+	"fmt"
+	"time"
+)
+
+const (
+	EntryStatusUnread       = "unread"
+	EntryStatusRead         = "read"
+	EntryStatusRemoved      = "removed"
+	DefaultSortingOrder     = "published_at"
+	DefaultSortingDirection = "desc"
+)
+
+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"`
+	Enclosures EnclosureList `json:"enclosures,omitempty"`
+	Feed       *Feed         `json:"feed,omitempty"`
+	Category   *Category     `json:"category,omitempty"`
+}
+
+type Entries []*Entry
+
+func ValidateEntryStatus(status string) error {
+	switch status {
+	case EntryStatusRead, EntryStatusUnread, EntryStatusRemoved:
+		return nil
+	}
+
+	return fmt.Errorf(`Invalid entry status, valid status values are: "%s", "%s" and "%s"`, EntryStatusRead, EntryStatusUnread, EntryStatusRemoved)
+}
+
+func ValidateEntryOrder(order string) error {
+	switch order {
+	case "id", "status", "published_at", "category_title", "category_id":
+		return nil
+	}
+
+	return fmt.Errorf(`Invalid entry order, valid order values are: "id", "status", "published_at", "category_title", "category_id"`)
+}
+
+func ValidateDirection(direction string) error {
+	switch direction {
+	case "asc", "desc":
+		return nil
+	}
+
+	return fmt.Errorf(`Invalid direction, valid direction values are: "asc" or "desc"`)
+}
+
+func GetOppositeDirection(direction string) string {
+	if direction == "asc" {
+		return "desc"
+	}
+
+	return "asc"
+}

+ 66 - 0
model/feed.go

@@ -0,0 +1,66 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package model
+
+import (
+	"fmt"
+	"reflect"
+	"time"
+)
+
+// Feed represents a feed in the database
+type Feed struct {
+	ID                 int64     `json:"id"`
+	UserID             int64     `json:"user_id"`
+	FeedURL            string    `json:"feed_url"`
+	SiteURL            string    `json:"site_url"`
+	Title              string    `json:"title"`
+	CheckedAt          time.Time `json:"checked_at,omitempty"`
+	EtagHeader         string    `json:"etag_header,omitempty"`
+	LastModifiedHeader string    `json:"last_modified_header,omitempty"`
+	ParsingErrorMsg    string    `json:"parsing_error_message,omitempty"`
+	ParsingErrorCount  int       `json:"parsing_error_count,omitempty"`
+	Category           *Category `json:"category,omitempty"`
+	Entries            Entries   `json:"entries,omitempty"`
+	Icon               *FeedIcon `json:"icon,omitempty"`
+}
+
+func (f *Feed) String() string {
+	return fmt.Sprintf("ID=%d, UserID=%d, FeedURL=%s, SiteURL=%s, Title=%s, Category={%s}",
+		f.ID,
+		f.UserID,
+		f.FeedURL,
+		f.SiteURL,
+		f.Title,
+		f.Category,
+	)
+}
+
+// Merge combine src to the current struct
+func (f *Feed) Merge(src *Feed) {
+	src.ID = f.ID
+	src.UserID = f.UserID
+
+	new := reflect.ValueOf(src).Elem()
+	for i := 0; i < new.NumField(); i++ {
+		field := new.Field(i)
+
+		switch field.Interface().(type) {
+		case int64:
+			value := field.Int()
+			if value != 0 {
+				reflect.ValueOf(f).Elem().Field(i).SetInt(value)
+			}
+		case string:
+			value := field.String()
+			if value != "" {
+				reflect.ValueOf(f).Elem().Field(i).SetString(value)
+			}
+		}
+	}
+}
+
+// Feeds is a list of feed
+type Feeds []*Feed

+ 19 - 0
model/icon.go

@@ -0,0 +1,19 @@
+// 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 model
+
+// Icon represents a website icon (favicon)
+type Icon struct {
+	ID       int64  `json:"id"`
+	Hash     string `json:"hash"`
+	MimeType string `json:"mime_type"`
+	Content  []byte `json:"content"`
+}
+
+// FeedIcon is a jonction table between feeds and icons
+type FeedIcon struct {
+	FeedID int64 `json:"feed_id"`
+	IconID int64 `json:"icon_id"`
+}

+ 10 - 0
model/job.go

@@ -0,0 +1,10 @@
+// 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 model
+
+type Job struct {
+	UserID int64
+	FeedID int64
+}

+ 23 - 0
model/session.go

@@ -0,0 +1,23 @@
+// 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 model
+
+import "time"
+import "fmt"
+
+type Session struct {
+	ID        int64
+	UserID    int64
+	Token     string
+	CreatedAt time.Time
+	UserAgent string
+	IP        string
+}
+
+func (s *Session) String() string {
+	return fmt.Sprintf("ID=%d, UserID=%d, IP=%s", s.ID, s.UserID, s.IP)
+}
+
+type Sessions []*Session

+ 13 - 0
model/theme.go

@@ -0,0 +1,13 @@
+// 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 model
+
+// GetThemes returns the list of available themes.
+func GetThemes() map[string]string {
+	return map[string]string{
+		"default": "Default",
+		"black":   "Black",
+	}
+}

+ 96 - 0
model/user.go

@@ -0,0 +1,96 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package model
+
+import (
+	"errors"
+	"time"
+)
+
+// User represents a user in the system.
+type User struct {
+	ID          int64      `json:"id"`
+	Username    string     `json:"username"`
+	Password    string     `json:"password,omitempty"`
+	IsAdmin     bool       `json:"is_admin"`
+	Theme       string     `json:"theme"`
+	Language    string     `json:"language"`
+	Timezone    string     `json:"timezone"`
+	LastLoginAt *time.Time `json:"last_login_at"`
+}
+
+func (u User) ValidateUserCreation() error {
+	if err := u.ValidateUserLogin(); err != nil {
+		return err
+	}
+
+	if err := u.ValidatePassword(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (u User) ValidateUserModification() error {
+	if u.Username == "" {
+		return errors.New("The username is mandatory")
+	}
+
+	if err := u.ValidatePassword(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (u User) ValidateUserLogin() error {
+	if u.Username == "" {
+		return errors.New("The username is mandatory")
+	}
+
+	if u.Password == "" {
+		return errors.New("The password is mandatory")
+	}
+
+	return nil
+}
+
+func (u User) ValidatePassword() error {
+	if u.Password != "" && len(u.Password) < 6 {
+		return errors.New("The password must have at least 6 characters")
+	}
+
+	return nil
+}
+
+// Merge update the current user with another user.
+func (u *User) Merge(override *User) {
+	if u.Username != override.Username {
+		u.Username = override.Username
+	}
+
+	if u.Password != override.Password {
+		u.Password = override.Password
+	}
+
+	if u.IsAdmin != override.IsAdmin {
+		u.IsAdmin = override.IsAdmin
+	}
+
+	if u.Theme != override.Theme {
+		u.Theme = override.Theme
+	}
+
+	if u.Language != override.Language {
+		u.Language = override.Language
+	}
+
+	if u.Timezone != override.Timezone {
+		u.Timezone = override.Timezone
+	}
+}
+
+// Users represents a list of users.
+type Users []*User

+ 214 - 0
reader/feed/atom/atom.go

@@ -0,0 +1,214 @@
+// 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 atom
+
+import (
+	"encoding/xml"
+	"github.com/miniflux/miniflux2/helper"
+	"github.com/miniflux/miniflux2/model"
+	"github.com/miniflux/miniflux2/reader/feed/date"
+	"github.com/miniflux/miniflux2/reader/processor"
+	"github.com/miniflux/miniflux2/reader/sanitizer"
+	"log"
+	"strconv"
+	"strings"
+	"time"
+)
+
+type AtomFeed struct {
+	XMLName xml.Name    `xml:"http://www.w3.org/2005/Atom feed"`
+	ID      string      `xml:"id"`
+	Title   string      `xml:"title"`
+	Author  Author      `xml:"author"`
+	Links   []Link      `xml:"link"`
+	Entries []AtomEntry `xml:"entry"`
+}
+
+type AtomEntry struct {
+	ID         string     `xml:"id"`
+	Title      string     `xml:"title"`
+	Updated    string     `xml:"updated"`
+	Links      []Link     `xml:"link"`
+	Summary    string     `xml:"summary"`
+	Content    Content    `xml:"content"`
+	MediaGroup MediaGroup `xml:"http://search.yahoo.com/mrss/ group"`
+	Author     Author     `xml:"author"`
+}
+
+type Author struct {
+	Name  string `xml:"name"`
+	Email string `xml:"email"`
+}
+
+type Link struct {
+	Url    string `xml:"href,attr"`
+	Type   string `xml:"type,attr"`
+	Rel    string `xml:"rel,attr"`
+	Length string `xml:"length,attr"`
+}
+
+type Content struct {
+	Type string `xml:"type,attr"`
+	Data string `xml:",chardata"`
+	Xml  string `xml:",innerxml"`
+}
+
+type MediaGroup struct {
+	Description string `xml:"http://search.yahoo.com/mrss/ description"`
+}
+
+func (a *AtomFeed) getSiteURL() string {
+	for _, link := range a.Links {
+		if strings.ToLower(link.Rel) == "alternate" {
+			return link.Url
+		}
+
+		if link.Rel == "" && link.Type == "" {
+			return link.Url
+		}
+	}
+
+	return ""
+}
+
+func (a *AtomFeed) getFeedURL() string {
+	for _, link := range a.Links {
+		if strings.ToLower(link.Rel) == "self" {
+			return link.Url
+		}
+	}
+
+	return ""
+}
+
+func (a *AtomFeed) Transform() *model.Feed {
+	feed := new(model.Feed)
+	feed.FeedURL = a.getFeedURL()
+	feed.SiteURL = a.getSiteURL()
+	feed.Title = sanitizer.StripTags(a.Title)
+
+	if feed.Title == "" {
+		feed.Title = feed.SiteURL
+	}
+
+	for _, entry := range a.Entries {
+		item := entry.Transform()
+		if item.Author == "" {
+			item.Author = a.GetAuthor()
+		}
+
+		feed.Entries = append(feed.Entries, item)
+	}
+
+	return feed
+}
+
+func (a *AtomFeed) GetAuthor() string {
+	return getAuthor(a.Author)
+}
+
+func (e *AtomEntry) GetDate() time.Time {
+	if e.Updated != "" {
+		result, err := date.Parse(e.Updated)
+		if err != nil {
+			log.Println(err)
+			return time.Now()
+		}
+
+		return result
+	}
+
+	return time.Now()
+}
+
+func (e *AtomEntry) GetURL() string {
+	for _, link := range e.Links {
+		if strings.ToLower(link.Rel) == "alternate" {
+			return link.Url
+		}
+
+		if link.Rel == "" && link.Type == "" {
+			return link.Url
+		}
+	}
+
+	return ""
+}
+
+func (e *AtomEntry) GetAuthor() string {
+	return getAuthor(e.Author)
+}
+
+func (e *AtomEntry) GetHash() string {
+	for _, value := range []string{e.ID, e.GetURL()} {
+		if value != "" {
+			return helper.Hash(value)
+		}
+	}
+
+	return ""
+}
+
+func (e *AtomEntry) GetContent() string {
+	if e.Content.Type == "html" || e.Content.Type == "text" {
+		return e.Content.Data
+	}
+
+	if e.Content.Type == "xhtml" {
+		return e.Content.Xml
+	}
+
+	if e.Summary != "" {
+		return e.Summary
+	}
+
+	if e.MediaGroup.Description != "" {
+		return e.MediaGroup.Description
+	}
+
+	return ""
+}
+
+func (e *AtomEntry) GetEnclosures() model.EnclosureList {
+	enclosures := make(model.EnclosureList, 0)
+
+	for _, link := range e.Links {
+		if strings.ToLower(link.Rel) == "enclosure" {
+			length, _ := strconv.Atoi(link.Length)
+			enclosures = append(enclosures, &model.Enclosure{URL: link.Url, MimeType: link.Type, Size: length})
+		}
+	}
+
+	return enclosures
+}
+
+func (e *AtomEntry) Transform() *model.Entry {
+	entry := new(model.Entry)
+	entry.URL = e.GetURL()
+	entry.Date = e.GetDate()
+	entry.Author = sanitizer.StripTags(e.GetAuthor())
+	entry.Hash = e.GetHash()
+	entry.Content = processor.ItemContentProcessor(entry.URL, e.GetContent())
+	entry.Title = sanitizer.StripTags(strings.Trim(e.Title, " \n\t"))
+	entry.Enclosures = e.GetEnclosures()
+
+	if entry.Title == "" {
+		entry.Title = entry.URL
+	}
+
+	return entry
+}
+
+func getAuthor(author Author) string {
+	if author.Name != "" {
+		return author.Name
+	}
+
+	if author.Email != "" {
+		return author.Email
+	}
+
+	return ""
+}

+ 28 - 0
reader/feed/atom/parser.go

@@ -0,0 +1,28 @@
+// 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 atom
+
+import (
+	"encoding/xml"
+	"fmt"
+	"github.com/miniflux/miniflux2/model"
+	"io"
+
+	"golang.org/x/net/html/charset"
+)
+
+// Parse returns a normalized feed struct.
+func Parse(data io.Reader) (*model.Feed, error) {
+	atomFeed := new(AtomFeed)
+	decoder := xml.NewDecoder(data)
+	decoder.CharsetReader = charset.NewReaderLabel
+
+	err := decoder.Decode(atomFeed)
+	if err != nil {
+		return nil, fmt.Errorf("Unable to parse Atom feed: %v\n", err)
+	}
+
+	return atomFeed.Transform(), nil
+}

+ 319 - 0
reader/feed/atom/parser_test.go

@@ -0,0 +1,319 @@
+// 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 atom
+
+import (
+	"bytes"
+	"testing"
+	"time"
+)
+
+func TestParseAtomSample(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/"/>
+	  <updated>2003-12-13T18:30:02Z</updated>
+	  <author>
+		<name>John Doe</name>
+	  </author>
+	  <id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
+
+	  <entry>
+		<title>Atom-Powered Robots Run Amok</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.Title != "Example Feed" {
+		t.Errorf("Incorrect title, got: %s", feed.Title)
+	}
+
+	if feed.FeedURL != "" {
+		t.Errorf("Incorrect feed URL, got: %s", feed.FeedURL)
+	}
+
+	if feed.SiteURL != "http://example.org/" {
+		t.Errorf("Incorrect site URL, got: %s", feed.SiteURL)
+	}
+
+	if len(feed.Entries) != 1 {
+		t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
+	}
+
+	if !feed.Entries[0].Date.Equal(time.Date(2003, time.December, 13, 18, 30, 2, 0, time.UTC)) {
+		t.Errorf("Incorrect entry date, got: %v", feed.Entries[0].Date)
+	}
+
+	if feed.Entries[0].Hash != "3841e5cf232f5111fc5841e9eba5f4b26d95e7d7124902e0f7272729d65601a6" {
+		t.Errorf("Incorrect entry hash, got: %s", feed.Entries[0].Hash)
+	}
+
+	if feed.Entries[0].URL != "http://example.org/2003/12/13/atom03" {
+		t.Errorf("Incorrect entry URL, got: %s", feed.Entries[0].URL)
+	}
+
+	if feed.Entries[0].Title != "Atom-Powered Robots Run Amok" {
+		t.Errorf("Incorrect entry title, got: %s", feed.Entries[0].Title)
+	}
+
+	if feed.Entries[0].Content != "Some text." {
+		t.Errorf("Incorrect entry content, got: %s", feed.Entries[0].Content)
+	}
+
+	if feed.Entries[0].Author != "John Doe" {
+		t.Errorf("Incorrect entry author, got: %s", feed.Entries[0].Author)
+	}
+}
+
+func TestParseFeedWithoutTitle(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+		<feed xmlns="http://www.w3.org/2005/Atom">
+			<link rel="alternate" type="text/html" href="https://example.org/"/>
+			<link rel="self" type="application/atom+xml" href="https://example.org/feed"/>
+			<updated>2003-12-13T18:30:02Z</updated>
+		</feed>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if feed.Title != "https://example.org/" {
+		t.Errorf("Incorrect feed title, got: %s", feed.Title)
+	}
+}
+
+func TestParseEntryWithoutTitle(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/"/>
+	  <updated>2003-12-13T18:30:02Z</updated>
+	  <author>
+		<name>John Doe</name>
+	  </author>
+	  <id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
+
+	  <entry>
+		<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 != "http://example.org/2003/12/13/atom03" {
+		t.Errorf("Incorrect entry title, got: %s", feed.Entries[0].Title)
+	}
+}
+
+func TestParseFeedURL(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+	<feed xmlns="http://www.w3.org/2005/Atom">
+	  <title>Example Feed</title>
+	  <link rel="alternate" type="text/html" href="https://example.org/"/>
+	  <link rel="self" type="application/atom+xml" href="https://example.org/feed"/>
+	  <updated>2003-12-13T18:30:02Z</updated>
+	</feed>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if feed.SiteURL != "https://example.org/" {
+		t.Errorf("Incorrect site URL, got: %s", feed.SiteURL)
+	}
+
+	if feed.FeedURL != "https://example.org/feed" {
+		t.Errorf("Incorrect feed URL, got: %s", feed.FeedURL)
+	}
+}
+
+func TestParseEntryTitleWithWhitespaces(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>
+			Some Title
+		</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 != "Some Title" {
+		t.Errorf("Incorrect entry title, got: %s", 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">
+	  <title>Example Feed</title>
+	  <link href="http://example.org/"/>
+
+	  <entry>
+		<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>
+		<author>
+			<name>Me</name>
+			<email>me@localhost</email>
+		</author>
+	  </entry>
+
+	</feed>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if feed.Entries[0].Author != "Me" {
+		t.Errorf("Incorrect entry author, got: %s", feed.Entries[0].Author)
+	}
+}
+
+func TestParseEntryWithoutAuthorName(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>
+		<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>
+		<author>
+			<name/>
+			<email>me@localhost</email>
+		</author>
+	  </entry>
+
+	</feed>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if feed.Entries[0].Author != "me@localhost" {
+		t.Errorf("Incorrect entry author, got: %s", feed.Entries[0].Author)
+	}
+}
+
+func TestParseEntryWithEnclosures(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+	<feed xmlns="http://www.w3.org/2005/Atom">
+		<id>http://www.example.org/myfeed</id>
+		<title>My Podcast Feed</title>
+		<updated>2005-07-15T12:00:00Z</updated>
+		<author>
+		<name>John Doe</name>
+		</author>
+		<link href="http://example.org" />
+		<link rel="self" href="http://example.org/myfeed" />
+		<entry>
+			<id>http://www.example.org/entries/1</id>
+			<title>Atom 1.0</title>
+			<updated>2005-07-15T12:00:00Z</updated>
+			<link href="http://www.example.org/entries/1" />
+			<summary>An overview of Atom 1.0</summary>
+			<link rel="enclosure"
+					type="audio/mpeg"
+					title="MP3"
+					href="http://www.example.org/myaudiofile.mp3"
+					length="1234" />
+			<link rel="enclosure"
+					type="application/x-bittorrent"
+					title="BitTorrent"
+					href="http://www.example.org/myaudiofile.torrent"
+					length="4567" />
+			<content type="xhtml">
+				<div xmlns="http://www.w3.org/1999/xhtml">
+				<h1>Show Notes</h1>
+				<ul>
+					<li>00:01:00 -- Introduction</li>
+					<li>00:15:00 -- Talking about Atom 1.0</li>
+					<li>00:30:00 -- Wrapping up</li>
+				</ul>
+				</div>
+			</content>
+		</entry>
+  	</feed>`
+
+	feed, err := Parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Error(err)
+	}
+
+	if len(feed.Entries) != 1 {
+		t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
+	}
+
+	if feed.Entries[0].URL != "http://www.example.org/entries/1" {
+		t.Errorf("Incorrect entry URL, got: %s", feed.Entries[0].URL)
+	}
+
+	if len(feed.Entries[0].Enclosures) != 2 {
+		t.Errorf("Incorrect number of enclosures, got: %d", len(feed.Entries[0].Enclosures))
+	}
+
+	if feed.Entries[0].Enclosures[0].URL != "http://www.example.org/myaudiofile.mp3" {
+		t.Errorf("Incorrect enclosure URL, got: %s", feed.Entries[0].Enclosures[0].URL)
+	}
+
+	if feed.Entries[0].Enclosures[0].MimeType != "audio/mpeg" {
+		t.Errorf("Incorrect enclosure type, got: %s", feed.Entries[0].Enclosures[0].MimeType)
+	}
+
+	if feed.Entries[0].Enclosures[0].Size != 1234 {
+		t.Errorf("Incorrect enclosure length, got: %d", feed.Entries[0].Enclosures[0].Size)
+	}
+
+	if feed.Entries[0].Enclosures[1].URL != "http://www.example.org/myaudiofile.torrent" {
+		t.Errorf("Incorrect enclosure URL, got: %s", feed.Entries[0].Enclosures[1].URL)
+	}
+
+	if feed.Entries[0].Enclosures[1].MimeType != "application/x-bittorrent" {
+		t.Errorf("Incorrect enclosure type, got: %s", feed.Entries[0].Enclosures[1].MimeType)
+	}
+
+	if feed.Entries[0].Enclosures[1].Size != 4567 {
+		t.Errorf("Incorrect enclosure length, got: %d", feed.Entries[0].Enclosures[1].Size)
+	}
+}

+ 203 - 0
reader/feed/date/parser.go

@@ -0,0 +1,203 @@
+// 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 date
+
+import (
+	"fmt"
+	"strings"
+	"time"
+)
+
+// DateFormats taken from github.com/mjibson/goread
+var dateFormats = []string{
+	time.RFC822,  // RSS
+	time.RFC822Z, // RSS
+	time.RFC3339, // Atom
+	time.UnixDate,
+	time.RubyDate,
+	time.RFC850,
+	time.RFC1123Z,
+	time.RFC1123,
+	time.ANSIC,
+	"Mon, January 2 2006 15:04:05 -0700",
+	"Mon, January 02, 2006, 15:04:05 MST",
+	"Mon, January 02, 2006 15:04:05 MST",
+	"Mon, Jan 2, 2006 15:04 MST",
+	"Mon, Jan 2 2006 15:04 MST",
+	"Mon, Jan 2, 2006 15:04:05 MST",
+	"Mon, Jan 2 2006 15:04:05 -700",
+	"Mon, Jan 2 2006 15:04:05 -0700",
+	"Mon Jan 2 15:04 2006",
+	"Mon Jan 2 15:04:05 2006 MST",
+	"Mon Jan 02, 2006 3:04 pm",
+	"Mon, Jan 02,2006 15:04:05 MST",
+	"Mon Jan 02 2006 15:04:05 -0700",
+	"Monday, January 2, 2006 15:04:05 MST",
+	"Monday, January 2, 2006 03:04 PM",
+	"Monday, January 2, 2006",
+	"Monday, January 02, 2006",
+	"Monday, 2 January 2006 15:04:05 MST",
+	"Monday, 2 January 2006 15:04:05 -0700",
+	"Monday, 2 Jan 2006 15:04:05 MST",
+	"Monday, 2 Jan 2006 15:04:05 -0700",
+	"Monday, 02 January 2006 15:04:05 MST",
+	"Monday, 02 January 2006 15:04:05 -0700",
+	"Monday, 02 January 2006 15:04:05",
+	"Mon, 2 January 2006 15:04 MST",
+	"Mon, 2 January 2006, 15:04 -0700",
+	"Mon, 2 January 2006, 15:04:05 MST",
+	"Mon, 2 January 2006 15:04:05 MST",
+	"Mon, 2 January 2006 15:04:05 -0700",
+	"Mon, 2 January 2006",
+	"Mon, 2 Jan 2006 3:04:05 PM -0700",
+	"Mon, 2 Jan 2006 15:4:5 MST",
+	"Mon, 2 Jan 2006 15:4:5 -0700 GMT",
+	"Mon, 2, Jan 2006 15:4",
+	"Mon, 2 Jan 2006 15:04 MST",
+	"Mon, 2 Jan 2006, 15:04 -0700",
+	"Mon, 2 Jan 2006 15:04 -0700",
+	"Mon, 2 Jan 2006 15:04:05 UT",
+	"Mon, 2 Jan 2006 15:04:05MST",
+	"Mon, 2 Jan 2006 15:04:05 MST",
+	"Mon 2 Jan 2006 15:04:05 MST",
+	"mon,2 Jan 2006 15:04:05 MST",
+	"Mon, 2 Jan 2006 15:04:05 -0700 MST",
+	"Mon, 2 Jan 2006 15:04:05-0700",
+	"Mon, 2 Jan 2006 15:04:05 -0700",
+	"Mon, 2 Jan 2006 15:04:05",
+	"Mon, 2 Jan 2006 15:04",
+	"Mon,2 Jan 2006",
+	"Mon, 2 Jan 2006",
+	"Mon, 2 Jan 15:04:05 MST",
+	"Mon, 2 Jan 06 15:04:05 MST",
+	"Mon, 2 Jan 06 15:04:05 -0700",
+	"Mon, 2006-01-02 15:04",
+	"Mon,02 January 2006 14:04:05 MST",
+	"Mon, 02 January 2006",
+	"Mon, 02 Jan 2006 3:04:05 PM MST",
+	"Mon, 02 Jan 2006 15 -0700",
+	"Mon,02 Jan 2006 15:04 MST",
+	"Mon, 02 Jan 2006 15:04 MST",
+	"Mon, 02 Jan 2006 15:04 -0700",
+	"Mon, 02 Jan 2006 15:04:05 Z",
+	"Mon, 02 Jan 2006 15:04:05 UT",
+	"Mon, 02 Jan 2006 15:04:05 MST-07:00",
+	"Mon, 02 Jan 2006 15:04:05 MST -0700",
+	"Mon, 02 Jan 2006, 15:04:05 MST",
+	"Mon, 02 Jan 2006 15:04:05MST",
+	"Mon, 02 Jan 2006 15:04:05 MST",
+	"Mon , 02 Jan 2006 15:04:05 MST",
+	"Mon, 02 Jan 2006 15:04:05 GMT-0700",
+	"Mon,02 Jan 2006 15:04:05 -0700",
+	"Mon, 02 Jan 2006 15:04:05 -0700",
+	"Mon, 02 Jan 2006 15:04:05 -07:00",
+	"Mon, 02 Jan 2006 15:04:05 --0700",
+	"Mon 02 Jan 2006 15:04:05 -0700",
+	"Mon, 02 Jan 2006 15:04:05 -07",
+	"Mon, 02 Jan 2006 15:04:05 00",
+	"Mon, 02 Jan 2006 15:04:05",
+	"Mon, 02 Jan 2006",
+	"Mon, 02 Jan 06 15:04:05 MST",
+	"January 2, 2006 3:04 PM",
+	"January 2, 2006, 3:04 p.m.",
+	"January 2, 2006 15:04:05 MST",
+	"January 2, 2006 15:04:05",
+	"January 2, 2006 03:04 PM",
+	"January 2, 2006",
+	"January 02, 2006 15:04:05 MST",
+	"January 02, 2006 15:04",
+	"January 02, 2006 03:04 PM",
+	"January 02, 2006",
+	"Jan 2, 2006 3:04:05 PM MST",
+	"Jan 2, 2006 3:04:05 PM",
+	"Jan 2, 2006 15:04:05 MST",
+	"Jan 2, 2006",
+	"Jan 02 2006 03:04:05PM",
+	"Jan 02, 2006",
+	"6/1/2 15:04",
+	"6-1-2 15:04",
+	"2 January 2006 15:04:05 MST",
+	"2 January 2006 15:04:05 -0700",
+	"2 January 2006",
+	"2 Jan 2006 15:04:05 Z",
+	"2 Jan 2006 15:04:05 MST",
+	"2 Jan 2006 15:04:05 -0700",
+	"2 Jan 2006",
+	"2.1.2006 15:04:05",
+	"2/1/2006",
+	"2-1-2006",
+	"2006 January 02",
+	"2006-1-2T15:04:05Z",
+	"2006-1-2 15:04:05",
+	"2006-1-2",
+	"2006-1-02T15:04:05Z",
+	"2006-01-02T15:04Z",
+	"2006-01-02T15:04-07:00",
+	"2006-01-02T15:04:05Z",
+	"2006-01-02T15:04:05-07:00:00",
+	"2006-01-02T15:04:05:-0700",
+	"2006-01-02T15:04:05-0700",
+	"2006-01-02T15:04:05-07:00",
+	"2006-01-02T15:04:05 -0700",
+	"2006-01-02T15:04:05:00",
+	"2006-01-02T15:04:05",
+	"2006-01-02 at 15:04:05",
+	"2006-01-02 15:04:05Z",
+	"2006-01-02 15:04:05 MST",
+	"2006-01-02 15:04:05-0700",
+	"2006-01-02 15:04:05-07:00",
+	"2006-01-02 15:04:05 -0700",
+	"2006-01-02 15:04",
+	"2006-01-02 00:00:00.0 15:04:05.0 -0700",
+	"2006/01/02",
+	"2006-01-02",
+	"15:04 02.01.2006 -0700",
+	"1/2/2006 3:04 PM MST",
+	"1/2/2006 3:04:05 PM MST",
+	"1/2/2006 3:04:05 PM",
+	"1/2/2006 15:04:05 MST",
+	"1/2/2006",
+	"06/1/2 15:04",
+	"06-1-2 15:04",
+	"02 Monday, Jan 2006 15:04",
+	"02 Jan 2006 15:04 MST",
+	"02 Jan 2006 15:04:05 UT",
+	"02 Jan 2006 15:04:05 MST",
+	"02 Jan 2006 15:04:05 -0700",
+	"02 Jan 2006 15:04:05",
+	"02 Jan 2006",
+	"02/01/2006 15:04 MST",
+	"02-01-2006 15:04:05 MST",
+	"02.01.2006 15:04:05",
+	"02/01/2006 15:04:05",
+	"02.01.2006 15:04",
+	"02/01/2006 - 15:04",
+	"02.01.2006 -0700",
+	"02/01/2006",
+	"02-01-2006",
+	"01/02/2006 3:04 PM",
+	"01/02/2006 15:04:05 MST",
+	"01/02/2006 - 15:04",
+	"01/02/2006",
+	"01-02-2006",
+}
+
+// Parse parses a given date string using a large
+// list of commonly found feed date formats.
+func Parse(ds string) (t time.Time, err error) {
+	d := strings.TrimSpace(ds)
+	if d == "" {
+		return t, fmt.Errorf("Date string is empty")
+	}
+
+	for _, f := range dateFormats {
+		if t, err = time.Parse(f, d); err == nil {
+			return
+		}
+	}
+
+	err = fmt.Errorf("Failed to parse date: %s", ds)
+	return
+}

+ 152 - 0
reader/feed/handler.go

@@ -0,0 +1,152 @@
+// 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 feed
+
+import (
+	"fmt"
+	"github.com/miniflux/miniflux2/errors"
+	"github.com/miniflux/miniflux2/helper"
+	"github.com/miniflux/miniflux2/model"
+	"github.com/miniflux/miniflux2/reader/http"
+	"github.com/miniflux/miniflux2/reader/icon"
+	"github.com/miniflux/miniflux2/storage"
+	"log"
+	"time"
+)
+
+var (
+	errRequestFailed = "Unable to execute request: %v"
+	errServerFailure = "Unable to fetch feed (statusCode=%d)."
+	errDuplicate     = "This feed already exists (%s)."
+	errNotFound      = "Feed %d not found"
+)
+
+// Handler contains all the logic to create and refresh feeds.
+type Handler struct {
+	store *storage.Storage
+}
+
+// CreateFeed fetch, parse and store a new feed.
+func (h *Handler) CreateFeed(userID, categoryID int64, url string) (*model.Feed, error) {
+	defer helper.ExecutionTime(time.Now(), fmt.Sprintf("[Handler:CreateFeed] feedUrl=%s", url))
+
+	client := http.NewHttpClient(url)
+	response, err := client.Get()
+	if err != nil {
+		return nil, errors.NewLocalizedError(errRequestFailed, err)
+	}
+
+	if response.HasServerFailure() {
+		return nil, errors.NewLocalizedError(errServerFailure, response.StatusCode)
+	}
+
+	if h.store.FeedURLExists(userID, response.EffectiveURL) {
+		return nil, errors.NewLocalizedError(errDuplicate, response.EffectiveURL)
+	}
+
+	subscription, err := parseFeed(response.Body)
+	if err != nil {
+		return nil, err
+	}
+
+	subscription.Category = &model.Category{ID: categoryID}
+	subscription.EtagHeader = response.ETag
+	subscription.LastModifiedHeader = response.LastModified
+	subscription.FeedURL = response.EffectiveURL
+	subscription.UserID = userID
+
+	err = h.store.CreateFeed(subscription)
+	if err != nil {
+		return nil, err
+	}
+
+	log.Println("[Handler:CreateFeed] Feed saved with ID:", subscription.ID)
+
+	icon, err := icon.FindIcon(subscription.SiteURL)
+	if err != nil {
+		log.Println(err)
+	} else if icon == nil {
+		log.Printf("No icon found for feedID=%d\n", subscription.ID)
+	} else {
+		h.store.CreateFeedIcon(subscription, icon)
+	}
+
+	return subscription, nil
+}
+
+// RefreshFeed fetch and update a feed if necessary.
+func (h *Handler) RefreshFeed(userID, feedID int64) error {
+	defer helper.ExecutionTime(time.Now(), fmt.Sprintf("[Handler:RefreshFeed] feedID=%d", feedID))
+
+	originalFeed, err := h.store.GetFeedById(userID, feedID)
+	if err != nil {
+		return err
+	}
+
+	if originalFeed == nil {
+		return errors.NewLocalizedError(errNotFound, feedID)
+	}
+
+	client := http.NewHttpClientWithCacheHeaders(originalFeed.FeedURL, originalFeed.EtagHeader, originalFeed.LastModifiedHeader)
+	response, err := client.Get()
+	if err != nil {
+		customErr := errors.NewLocalizedError(errRequestFailed, err)
+		originalFeed.ParsingErrorCount++
+		originalFeed.ParsingErrorMsg = customErr.Error()
+		h.store.UpdateFeed(originalFeed)
+		return customErr
+	}
+
+	originalFeed.CheckedAt = time.Now()
+
+	if response.HasServerFailure() {
+		err := errors.NewLocalizedError(errServerFailure, response.StatusCode)
+		originalFeed.ParsingErrorCount++
+		originalFeed.ParsingErrorMsg = err.Error()
+		h.store.UpdateFeed(originalFeed)
+		return err
+	}
+
+	if response.IsModified(originalFeed.EtagHeader, originalFeed.LastModifiedHeader) {
+		log.Printf("[Handler:RefreshFeed] Feed #%d has been modified\n", feedID)
+
+		subscription, err := parseFeed(response.Body)
+		if err != nil {
+			originalFeed.ParsingErrorCount++
+			originalFeed.ParsingErrorMsg = err.Error()
+			h.store.UpdateFeed(originalFeed)
+			return err
+		}
+
+		originalFeed.EtagHeader = response.ETag
+		originalFeed.LastModifiedHeader = response.LastModified
+
+		if err := h.store.UpdateEntries(originalFeed.UserID, originalFeed.ID, subscription.Entries); err != nil {
+			return err
+		}
+
+		if !h.store.HasIcon(originalFeed.ID) {
+			log.Println("[Handler:RefreshFeed] Looking for feed icon")
+			icon, err := icon.FindIcon(originalFeed.SiteURL)
+			if err != nil {
+				log.Println("[Handler:RefreshFeed]", err)
+			} else {
+				h.store.CreateFeedIcon(originalFeed, icon)
+			}
+		}
+	} else {
+		log.Printf("[Handler:RefreshFeed] Feed #%d not modified\n", feedID)
+	}
+
+	originalFeed.ParsingErrorCount = 0
+	originalFeed.ParsingErrorMsg = ""
+
+	return h.store.UpdateFeed(originalFeed)
+}
+
+// NewFeedHandler returns a feed handler.
+func NewFeedHandler(store *storage.Storage) *Handler {
+	return &Handler{store: store}
+}

+ 170 - 0
reader/feed/json/json.go

@@ -0,0 +1,170 @@
+// Copyright 2017 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package json
+
+import (
+	"github.com/miniflux/miniflux2/helper"
+	"github.com/miniflux/miniflux2/model"
+	"github.com/miniflux/miniflux2/reader/feed/date"
+	"github.com/miniflux/miniflux2/reader/processor"
+	"github.com/miniflux/miniflux2/reader/sanitizer"
+	"log"
+	"strings"
+	"time"
+)
+
+type JsonFeed struct {
+	Version string     `json:"version"`
+	Title   string     `json:"title"`
+	SiteURL string     `json:"home_page_url"`
+	FeedURL string     `json:"feed_url"`
+	Author  JsonAuthor `json:"author"`
+	Items   []JsonItem `json:"items"`
+}
+
+type JsonAuthor struct {
+	Name string `json:"name"`
+	URL  string `json:"url"`
+}
+
+type JsonItem struct {
+	ID            string           `json:"id"`
+	URL           string           `json:"url"`
+	Title         string           `json:"title"`
+	Summary       string           `json:"summary"`
+	Text          string           `json:"content_text"`
+	Html          string           `json:"content_html"`
+	DatePublished string           `json:"date_published"`
+	DateModified  string           `json:"date_modified"`
+	Author        JsonAuthor       `json:"author"`
+	Attachments   []JsonAttachment `json:"attachments"`
+}
+
+type JsonAttachment struct {
+	URL      string `json:"url"`
+	MimeType string `json:"mime_type"`
+	Title    string `json:"title"`
+	Size     int    `json:"size_in_bytes"`
+	Duration int    `json:"duration_in_seconds"`
+}
+
+func (j *JsonFeed) GetAuthor() string {
+	return getAuthor(j.Author)
+}
+
+func (j *JsonFeed) Transform() *model.Feed {
+	feed := new(model.Feed)
+	feed.FeedURL = j.FeedURL
+	feed.SiteURL = j.SiteURL
+	feed.Title = sanitizer.StripTags(j.Title)
+
+	if feed.Title == "" {
+		feed.Title = feed.SiteURL
+	}
+
+	for _, item := range j.Items {
+		entry := item.Transform()
+		if entry.Author == "" {
+			entry.Author = j.GetAuthor()
+		}
+
+		feed.Entries = append(feed.Entries, entry)
+	}
+
+	return feed
+}
+
+func (j *JsonItem) GetDate() time.Time {
+	for _, value := range []string{j.DatePublished, j.DateModified} {
+		if value != "" {
+			d, err := date.Parse(value)
+			if err != nil {
+				log.Println(err)
+				return time.Now()
+			}
+
+			return d
+		}
+	}
+
+	return time.Now()
+}
+
+func (j *JsonItem) GetAuthor() string {
+	return getAuthor(j.Author)
+}
+
+func (j *JsonItem) GetHash() string {
+	for _, value := range []string{j.ID, j.URL, j.Text + j.Html + j.Summary} {
+		if value != "" {
+			return helper.Hash(value)
+		}
+	}
+
+	return ""
+}
+
+func (j *JsonItem) GetTitle() string {
+	for _, value := range []string{j.Title, j.Summary, j.Text, j.Html} {
+		if value != "" {
+			return truncate(value)
+		}
+	}
+
+	return j.URL
+}
+
+func (j *JsonItem) GetContent() string {
+	for _, value := range []string{j.Html, j.Text, j.Summary} {
+		if value != "" {
+			return value
+		}
+	}
+
+	return ""
+}
+
+func (j *JsonItem) GetEnclosures() model.EnclosureList {
+	enclosures := make(model.EnclosureList, 0)
+