Browse Source

First commit

Frédéric Guillot 1 year ago
commit
8ffb773f43
100 changed files with 8936 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. 12
    0
      server/static/bin.go
  90. BIN
      server/static/bin/favicon.ico
  91. 14
    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. 0
    0
      server/template/html/about.html

+ 2
- 0
.gitignore View File

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

+ 5
- 0
.travis.yml View File

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

+ 81
- 0
Gopkg.lock View File

@@ -0,0 +1,81 @@
1
+# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
2
+
3
+
4
+[[projects]]
5
+  name = "github.com/PuerkitoBio/goquery"
6
+  packages = ["."]
7
+  revision = "e1271ee34c6a305e38566ecd27ae374944907ee9"
8
+  version = "v1.1.0"
9
+
10
+[[projects]]
11
+  branch = "master"
12
+  name = "github.com/andybalholm/cascadia"
13
+  packages = ["."]
14
+  revision = "349dd0209470eabd9514242c688c403c0926d266"
15
+
16
+[[projects]]
17
+  name = "github.com/gorilla/context"
18
+  packages = ["."]
19
+  revision = "1ea25387ff6f684839d82767c1733ff4d4d15d0a"
20
+  version = "v1.1"
21
+
22
+[[projects]]
23
+  name = "github.com/gorilla/mux"
24
+  packages = ["."]
25
+  revision = "7f08801859139f86dfafd1c296e2cba9a80d292e"
26
+  version = "v1.6.0"
27
+
28
+[[projects]]
29
+  branch = "master"
30
+  name = "github.com/lib/pq"
31
+  packages = [".","oid"]
32
+  revision = "8c6ee72f3e6bcb1542298dd5f76cb74af9742cec"
33
+
34
+[[projects]]
35
+  name = "github.com/tdewolff/minify"
36
+  packages = [".","css","js"]
37
+  revision = "90df1aae5028a7cbb441bde86e86a55df6b5aa34"
38
+  version = "v2.3.3"
39
+
40
+[[projects]]
41
+  name = "github.com/tdewolff/parse"
42
+  packages = [".","buffer","css","js","strconv"]
43
+  revision = "bace4cf682c41e03b154044b561575ff541b83e8"
44
+  version = "v2.3.1"
45
+
46
+[[projects]]
47
+  branch = "master"
48
+  name = "github.com/tomasen/realip"
49
+  packages = ["."]
50
+  revision = "15489afd3be348430f5f67467d2bb6b2f9b757ed"
51
+
52
+[[projects]]
53
+  branch = "master"
54
+  name = "golang.org/x/crypto"
55
+  packages = ["bcrypt","blowfish","ssh/terminal"]
56
+  revision = "9f005a07e0d31d45e6656d241bb5c0f2efd4bc94"
57
+
58
+[[projects]]
59
+  branch = "master"
60
+  name = "golang.org/x/net"
61
+  packages = ["html","html/atom","html/charset"]
62
+  revision = "9dfe39835686865bff950a07b394c12a98ddc811"
63
+
64
+[[projects]]
65
+  branch = "master"
66
+  name = "golang.org/x/sys"
67
+  packages = ["unix","windows"]
68
+  revision = "0dd5e194bbf5eb84a39666eb4c98a4d007e4203a"
69
+
70
+[[projects]]
71
+  branch = "master"
72
+  name = "golang.org/x/text"
73
+  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"]
74
+  revision = "88f656faf3f37f690df1a32515b479415e1a6769"
75
+
76
+[solve-meta]
77
+  analyzer-name = "dep"
78
+  analyzer-version = 1
79
+  inputs-digest = "27a0ca12f5a709bb76b9c90f6720b6824ac8fc81b2fc66f059f212366443ff5d"
80
+  solver-name = "gps-cdcl"
81
+  solver-version = 1

+ 54
- 0
Gopkg.toml View File

@@ -0,0 +1,54 @@
1
+
2
+# Gopkg.toml example
3
+#
4
+# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md
5
+# for detailed Gopkg.toml documentation.
6
+#
7
+# required = ["github.com/user/thing/cmd/thing"]
8
+# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
9
+#
10
+# [[constraint]]
11
+#   name = "github.com/user/project"
12
+#   version = "1.0.0"
13
+#
14
+# [[constraint]]
15
+#   name = "github.com/user/project2"
16
+#   branch = "dev"
17
+#   source = "github.com/myfork/project2"
18
+#
19
+# [[override]]
20
+#  name = "github.com/x/y"
21
+#  version = "2.4.0"
22
+
23
+
24
+[[constraint]]
25
+  name = "github.com/PuerkitoBio/goquery"
26
+  version = "1.1.0"
27
+
28
+[[constraint]]
29
+  name = "github.com/gorilla/mux"
30
+  version = "1.6.0"
31
+
32
+[[constraint]]
33
+  branch = "master"
34
+  name = "github.com/lib/pq"
35
+
36
+[[constraint]]
37
+  branch = "master"
38
+  name = "github.com/rvflash/elapsed"
39
+
40
+[[constraint]]
41
+  name = "github.com/tdewolff/minify"
42
+  version = "2.3.3"
43
+
44
+[[constraint]]
45
+  branch = "master"
46
+  name = "github.com/tomasen/realip"
47
+
48
+[[constraint]]
49
+  branch = "master"
50
+  name = "golang.org/x/crypto"
51
+
52
+[[constraint]]
53
+  branch = "master"
54
+  name = "golang.org/x/net"

+ 177
- 0
LICENSE View File

@@ -0,0 +1,177 @@
1
+
2
+                                 Apache License
3
+                           Version 2.0, January 2004
4
+                        http://www.apache.org/licenses/
5
+
6
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+   1. Definitions.
9
+
10
+      "License" shall mean the terms and conditions for use, reproduction,
11
+      and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+      "Licensor" shall mean the copyright owner or entity authorized by
14
+      the copyright owner that is granting the License.
15
+
16
+      "Legal Entity" shall mean the union of the acting entity and all
17
+      other entities that control, are controlled by, or are under common
18
+      control with that entity. For the purposes of this definition,
19
+      "control" means (i) the power, direct or indirect, to cause the
20
+      direction or management of such entity, whether by contract or
21
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+      outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+      "You" (or "Your") shall mean an individual or Legal Entity
25
+      exercising permissions granted by this License.
26
+
27
+      "Source" form shall mean the preferred form for making modifications,
28
+      including but not limited to software source code, documentation
29
+      source, and configuration files.
30
+
31
+      "Object" form shall mean any form resulting from mechanical
32
+      transformation or translation of a Source form, including but
33
+      not limited to compiled object code, generated documentation,
34
+      and conversions to other media types.
35
+
36
+      "Work" shall mean the work of authorship, whether in Source or
37
+      Object form, made available under the License, as indicated by a
38
+      copyright notice that is included in or attached to the work
39
+      (an example is provided in the Appendix below).
40
+
41
+      "Derivative Works" shall mean any work, whether in Source or Object
42
+      form, that is based on (or derived from) the Work and for which the
43
+      editorial revisions, annotations, elaborations, or other modifications
44
+      represent, as a whole, an original work of authorship. For the purposes
45
+      of this License, Derivative Works shall not include works that remain
46
+      separable from, or merely link (or bind by name) to the interfaces of,
47
+      the Work and Derivative Works thereof.
48
+
49
+      "Contribution" shall mean any work of authorship, including
50
+      the original version of the Work and any modifications or additions
51
+      to that Work or Derivative Works thereof, that is intentionally
52
+      submitted to Licensor for inclusion in the Work by the copyright owner
53
+      or by an individual or Legal Entity authorized to submit on behalf of
54
+      the copyright owner. For the purposes of this definition, "submitted"
55
+      means any form of electronic, verbal, or written communication sent
56
+      to the Licensor or its representatives, including but not limited to
57
+      communication on electronic mailing lists, source code control systems,
58
+      and issue tracking systems that are managed by, or on behalf of, the
59
+      Licensor for the purpose of discussing and improving the Work, but
60
+      excluding communication that is conspicuously marked or otherwise
61
+      designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+      "Contributor" shall mean Licensor and any individual or Legal Entity
64
+      on behalf of whom a Contribution has been received by Licensor and
65
+      subsequently incorporated within the Work.
66
+
67
+   2. Grant of Copyright License. Subject to the terms and conditions of
68
+      this License, each Contributor hereby grants to You a perpetual,
69
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+      copyright license to reproduce, prepare Derivative Works of,
71
+      publicly display, publicly perform, sublicense, and distribute the
72
+      Work and such Derivative Works in Source or Object form.
73
+
74
+   3. Grant of Patent License. Subject to the terms and conditions of
75
+      this License, each Contributor hereby grants to You a perpetual,
76
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+      (except as stated in this section) patent license to make, have made,
78
+      use, offer to sell, sell, import, and otherwise transfer the Work,
79
+      where such license applies only to those patent claims licensable
80
+      by such Contributor that are necessarily infringed by their
81
+      Contribution(s) alone or by combination of their Contribution(s)
82
+      with the Work to which such Contribution(s) was submitted. If You
83
+      institute patent litigation against any entity (including a
84
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+      or a Contribution incorporated within the Work constitutes direct
86
+      or contributory patent infringement, then any patent licenses
87
+      granted to You under this License for that Work shall terminate
88
+      as of the date such litigation is filed.
89
+
90
+   4. Redistribution. You may reproduce and distribute copies of the
91
+      Work or Derivative Works thereof in any medium, with or without
92
+      modifications, and in Source or Object form, provided that You
93
+      meet the following conditions:
94
+
95
+      (a) You must give any other recipients of the Work or
96
+          Derivative Works a copy of this License; and
97
+
98
+      (b) You must cause any modified files to carry prominent notices
99
+          stating that You changed the files; and
100
+
101
+      (c) You must retain, in the Source form of any Derivative Works
102
+          that You distribute, all copyright, patent, trademark, and
103
+          attribution notices from the Source form of the Work,
104
+          excluding those notices that do not pertain to any part of
105
+          the Derivative Works; and
106
+
107
+      (d) If the Work includes a "NOTICE" text file as part of its
108
+          distribution, then any Derivative Works that You distribute must
109
+          include a readable copy of the attribution notices contained
110
+          within such NOTICE file, excluding those notices that do not
111
+          pertain to any part of the Derivative Works, in at least one
112
+          of the following places: within a NOTICE text file distributed
113
+          as part of the Derivative Works; within the Source form or
114
+          documentation, if provided along with the Derivative Works; or,
115
+          within a display generated by the Derivative Works, if and
116
+          wherever such third-party notices normally appear. The contents
117
+          of the NOTICE file are for informational purposes only and
118
+          do not modify the License. You may add Your own attribution
119
+          notices within Derivative Works that You distribute, alongside
120
+          or as an addendum to the NOTICE text from the Work, provided
121
+          that such additional attribution notices cannot be construed
122
+          as modifying the License.
123
+
124
+      You may add Your own copyright statement to Your modifications and
125
+      may provide additional or different license terms and conditions
126
+      for use, reproduction, or distribution of Your modifications, or
127
+      for any such Derivative Works as a whole, provided Your use,
128
+      reproduction, and distribution of the Work otherwise complies with
129
+      the conditions stated in this License.
130
+
131
+   5. Submission of Contributions. Unless You explicitly state otherwise,
132
+      any Contribution intentionally submitted for inclusion in the Work
133
+      by You to the Licensor shall be under the terms and conditions of
134
+      this License, without any additional terms or conditions.
135
+      Notwithstanding the above, nothing herein shall supersede or modify
136
+      the terms of any separate license agreement you may have executed
137
+      with Licensor regarding such Contributions.
138
+
139
+   6. Trademarks. This License does not grant permission to use the trade
140
+      names, trademarks, service marks, or product names of the Licensor,
141
+      except as required for reasonable and customary use in describing the
142
+      origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+   7. Disclaimer of Warranty. Unless required by applicable law or
145
+      agreed to in writing, Licensor provides the Work (and each
146
+      Contributor provides its Contributions) on an "AS IS" BASIS,
147
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+      implied, including, without limitation, any warranties or conditions
149
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+      PARTICULAR PURPOSE. You are solely responsible for determining the
151
+      appropriateness of using or redistributing the Work and assume any
152
+      risks associated with Your exercise of permissions under this License.
153
+
154
+   8. Limitation of Liability. In no event and under no legal theory,
155
+      whether in tort (including negligence), contract, or otherwise,
156
+      unless required by applicable law (such as deliberate and grossly
157
+      negligent acts) or agreed to in writing, shall any Contributor be
158
+      liable to You for damages, including any direct, indirect, special,
159
+      incidental, or consequential damages of any character arising as a
160
+      result of this License or out of the use or inability to use the
161
+      Work (including but not limited to damages for loss of goodwill,
162
+      work stoppage, computer failure or malfunction, or any and all
163
+      other commercial damages or losses), even if such Contributor
164
+      has been advised of the possibility of such damages.
165
+
166
+   9. Accepting Warranty or Additional Liability. While redistributing
167
+      the Work or Derivative Works thereof, You may choose to offer,
168
+      and charge a fee for, acceptance of support, warranty, indemnity,
169
+      or other liability obligations and/or rights consistent with this
170
+      License. However, in accepting such obligations, You may act only
171
+      on Your own behalf and on Your sole responsibility, not on behalf
172
+      of any other Contributor, and only if You agree to indemnify,
173
+      defend, and hold each Contributor harmless for any liability
174
+      incurred by, or claims asserted against, such Contributor by reason
175
+      of your accepting any such warranty or additional liability.
176
+
177
+   END OF TERMS AND CONDITIONS

+ 25
- 0
Makefile View File

@@ -0,0 +1,25 @@
1
+APP = miniflux
2
+VERSION = $(shell git rev-parse --short HEAD)
3
+BUILD_DATE = `date +%FT%T%z`
4
+
5
+.PHONY: build-linux build-darwin build run clean test
6
+
7
+build-linux:
8
+	@ go generate
9
+	@ GOOS=linux GOARCH=amd64 go build -ldflags="-X 'miniflux/version.Version=$(VERSION)' -X 'miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-linux-amd64 main.go
10
+
11
+build-darwin:
12
+	@ go generate
13
+	@ GOOS=darwin GOARCH=amd64 go build -ldflags="-X 'miniflux/version.Version=$(VERSION)' -X 'miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-darwin-amd64 main.go
14
+
15
+build: build-linux build-darwin
16
+
17
+run:
18
+	@ go generate
19
+	@ go run main.go
20
+
21
+clean:
22
+	@ rm -f $(APP)-*
23
+
24
+test:
25
+	go test -cover -race ./...

+ 38
- 0
README.md View File

@@ -0,0 +1,38 @@
1
+Miniflux 2
2
+==========
3
+[![Build Status](https://travis-ci.org/miniflux/miniflux2.svg?branch=master)](https://travis-ci.org/miniflux/miniflux2)
4
+
5
+Miniflux is a minimalist and opinionated feed reader:
6
+
7
+- Written in Go (Golang)
8
+- Works only with Postgresql
9
+- Doesn't use any ORM
10
+- Doesn't use any complicated framework
11
+- The number of features is volountary limited
12
+
13
+It's simple, fast, lightweight and super easy to install.
14
+
15
+Miniflux 2 is a rewrite of Miniflux 1.x in Golang.
16
+
17
+Notes
18
+-----
19
+
20
+Miniflux 2 still in development and **it's not ready to use**.
21
+
22
+TODO
23
+----
24
+
25
+- [ ] Custom entries sorting
26
+- [ ] Webpage scraper (Readability)
27
+- [ ] Bookmarklet
28
+- [ ] External integrations (Pinboard, Wallabag...)
29
+- [ ] Gzip compression
30
+- [ ] Integration tests
31
+- [ ] Flush history
32
+- [ ] OAuth2
33
+
34
+Credits
35
+-------
36
+
37
+- Author: Frédéric Guillot
38
+- Distributed under Apache 2.0 License

+ 36
- 0
config/config.go View File

@@ -0,0 +1,36 @@
1
+// Copyright 2017 Frédéric Guillot. All rights reserved.
2
+// Use of this source code is governed by the Apache 2.0
3
+// license that can be found in the LICENSE file.
4
+
5
+package config
6
+
7
+import (
8
+	"os"
9
+	"strconv"
10
+)
11
+
12
+type Config struct {
13
+}
14
+
15
+func (c *Config) Get(key, fallback string) string {
16
+	value := os.Getenv(key)
17
+	if value == "" {
18
+		return fallback
19
+	}
20
+
21
+	return value
22
+}
23
+
24
+func (c *Config) GetInt(key string, fallback int) int {
25
+	value := os.Getenv(key)
26
+	if value == "" {
27
+		return fallback
28
+	}
29
+
30
+	v, _ := strconv.Atoi(value)
31
+	return v
32
+}
33
+
34
+func NewConfig() *Config {
35
+	return &Config{}
36
+}

+ 27
- 0
errors/errors.go View File

@@ -0,0 +1,27 @@
1
+// Copyright 2017 Frédéric Guillot. All rights reserved.
2
+// Use of this source code is governed by the Apache 2.0
3
+// license that can be found in the LICENSE file.
4
+
5
+package errors
6
+
7
+import (
8
+	"fmt"
9
+	"github.com/miniflux/miniflux2/locale"
10
+)
11
+
12
+type LocalizedError struct {
13
+	message string
14
+	args    []interface{}
15
+}
16
+
17
+func (l LocalizedError) Error() string {
18
+	return fmt.Sprintf(l.message, l.args...)
19
+}
20
+
21
+func (l LocalizedError) Localize(translation *locale.Language) string {
22
+	return translation.Get(l.message, l.args...)
23
+}
24
+
25
+func NewLocalizedError(message string, args ...interface{}) LocalizedError {
26
+	return LocalizedError{message: message, args: args}
27
+}

+ 120
- 0
generate.go View File

@@ -0,0 +1,120 @@
1
+// Copyright 2017 Frédéric Guillot. All rights reserved.
2
+// Use of this source code is governed by the Apache 2.0
3
+// license that can be found in the LICENSE file.
4
+
5
+// +build ignore
6
+
7
+package main
8
+
9
+import (
10
+	"crypto/sha256"
11
+	"encoding/base64"
12
+	"fmt"
13
+	"io/ioutil"
14
+	"os"
15
+	"path"
16
+	"path/filepath"
17
+	"strings"
18
+	"text/template"
19
+	"time"
20
+
21
+	"github.com/tdewolff/minify"
22
+	"github.com/tdewolff/minify/css"
23
+	"github.com/tdewolff/minify/js"
24
+)
25
+
26
+const tpl = `// Code generated by go generate; DO NOT EDIT.
27
+// {{ .Timestamp }}
28
+
29
+package {{ .Package }}
30
+
31
+var {{ .Map }} = map[string]string{
32
+{{ range $constant, $content := .Files }}` + "\t" + `"{{ $constant }}": ` + "`{{ $content }}`" + `,
33
+{{ end }}}
34
+
35
+var {{ .Map }}Checksums = map[string]string{
36
+{{ range $constant, $content := .Checksums }}` + "\t" + `"{{ $constant }}": "{{ $content }}",
37
+{{ end }}}
38
+`
39
+
40
+var generatedTpl = template.Must(template.New("").Parse(tpl))
41
+
42
+type GeneratedFile struct {
43
+	Package, Map string
44
+	Timestamp    time.Time
45
+	Files        map[string]string
46
+	Checksums    map[string]string
47
+}
48
+
49
+func normalizeBasename(filename string) string {
50
+	filename = strings.TrimSuffix(filename, filepath.Ext(filename))
51
+	return strings.Replace(filename, " ", "_", -1)
52
+}
53
+
54
+func generateFile(serializer, pkg, mapName, pattern, output string) {
55
+	generatedFile := &GeneratedFile{
56
+		Package:   pkg,
57
+		Map:       mapName,
58
+		Timestamp: time.Now(),
59
+		Files:     make(map[string]string),
60
+		Checksums: make(map[string]string),
61
+	}
62
+
63
+	files, _ := filepath.Glob(pattern)
64
+	for _, file := range files {
65
+		basename := path.Base(file)
66
+		content, err := ioutil.ReadFile(file)
67
+		if err != nil {
68
+			panic(err)
69
+		}
70
+
71
+		switch serializer {
72
+		case "css":
73
+			m := minify.New()
74
+			m.AddFunc("text/css", css.Minify)
75
+			content, err = m.Bytes("text/css", content)
76
+			if err != nil {
77
+				panic(err)
78
+			}
79
+
80
+			basename = normalizeBasename(basename)
81
+			generatedFile.Files[basename] = string(content)
82
+		case "js":
83
+			m := minify.New()
84
+			m.AddFunc("text/javascript", js.Minify)
85
+			content, err = m.Bytes("text/javascript", content)
86
+			if err != nil {
87
+				panic(err)
88
+			}
89
+
90
+			basename = normalizeBasename(basename)
91
+			generatedFile.Files[basename] = string(content)
92
+		case "base64":
93
+			encodedContent := base64.StdEncoding.EncodeToString(content)
94
+			generatedFile.Files[basename] = encodedContent
95
+		default:
96
+			basename = normalizeBasename(basename)
97
+			generatedFile.Files[basename] = string(content)
98
+		}
99
+
100
+		generatedFile.Checksums[basename] = fmt.Sprintf("%x", sha256.Sum256(content))
101
+	}
102
+
103
+	f, err := os.Create(output)
104
+	if err != nil {
105
+		panic(err)
106
+	}
107
+	defer f.Close()
108
+
109
+	generatedTpl.Execute(f, generatedFile)
110
+}
111
+
112
+func main() {
113
+	generateFile("none", "sql", "SqlMap", "sql/*.sql", "sql/sql.go")
114
+	generateFile("base64", "static", "Binaries", "server/static/bin/*", "server/static/bin.go")
115
+	generateFile("css", "static", "Stylesheets", "server/static/css/*.css", "server/static/css.go")
116
+	generateFile("js", "static", "Javascript", "server/static/js/*.js", "server/static/js.go")
117
+	generateFile("none", "template", "templateViewsMap", "server/template/html/*.html", "server/template/views.go")
118
+	generateFile("none", "template", "templateCommonMap", "server/template/html/common/*.html", "server/template/common.go")
119
+	generateFile("none", "locale", "Translations", "locale/translations/*.json", "locale/translations.go")
120
+}

+ 38
- 0
helper/crypto.go View File

@@ -0,0 +1,38 @@
1
+// Copyright 2017 Frédéric Guillot. All rights reserved.
2
+// Use of this source code is governed by the Apache 2.0
3
+// license that can be found in the LICENSE file.
4
+
5
+package helper
6
+
7
+import (
8
+	"crypto/rand"
9
+	"crypto/sha256"
10
+	"encoding/base64"
11
+	"fmt"
12
+)
13
+
14
+// HashFromBytes returns a SHA-256 checksum of the input.
15
+func HashFromBytes(value []byte) string {
16
+	sum := sha256.Sum256(value)
17
+	return fmt.Sprintf("%x", sum)
18
+}
19
+
20
+// Hash returns a SHA-256 checksum of a string.
21
+func Hash(value string) string {
22
+	return HashFromBytes([]byte(value))
23
+}
24
+
25
+// GenerateRandomBytes returns random bytes.
26
+func GenerateRandomBytes(size int) []byte {
27
+	b := make([]byte, size)
28
+	if _, err := rand.Read(b); err != nil {
29
+		panic(fmt.Errorf("Unable to generate random string: %v", err))
30
+	}
31
+
32
+	return b
33
+}
34
+
35
+// GenerateRandomString returns a random string.
36
+func GenerateRandomString(size int) string {
37
+	return base64.URLEncoding.EncodeToString(GenerateRandomBytes(size))
38
+}

+ 16
- 0
helper/time.go View File

@@ -0,0 +1,16 @@
1
+// Copyright 2017 Frédéric Guillot. All rights reserved.
2
+// Use of this source code is governed by the Apache 2.0
3
+// license that can be found in the LICENSE file.
4
+
5
+package helper
6
+
7
+import (
8
+	"log"
9
+	"time"
10
+)
11
+
12
+// ExecutionTime returns the elapsed time of a block of code.
13
+func ExecutionTime(start time.Time, name string) {
14
+	elapsed := time.Since(start)
15
+	log.Printf("%s took %s", name, elapsed)
16
+}

+ 47
- 0
locale/language.go View File

@@ -0,0 +1,47 @@
1
+// Copyright 2017 Frédéric Guillot. All rights reserved.
2
+// Use of this source code is governed by the Apache 2.0
3
+// license that can be found in the LICENSE file.
4
+
5
+package locale
6
+
7
+import "fmt"
8
+
9
+type Language struct {
10
+	language     string
11
+	translations Translation
12
+}
13
+
14
+func (l *Language) Get(key string, args ...interface{}) string {
15
+	var translation string
16
+
17
+	str, found := l.translations[key]
18
+	if !found {
19
+		translation = key
20
+	} else {
21
+		translation = str.(string)
22
+	}
23
+
24
+	return fmt.Sprintf(translation, args...)
25
+}
26
+
27
+func (l *Language) Plural(key string, n int, args ...interface{}) string {
28
+	translation := key
29
+	slices, found := l.translations[key]
30
+	if found {
31
+
32
+		pluralForm, found := pluralForms[l.language]
33
+		if !found {
34
+			pluralForm = pluralForms["default"]
35
+		}
36
+
37
+		index := pluralForm(n)
38
+		translations := slices.([]interface{})
39
+		translation = key
40
+
41
+		if len(translations) > index {
42
+			translation = translations[index].(string)
43
+		}
44
+	}
45
+
46
+	return fmt.Sprintf(translation, args...)
47
+}

+ 30
- 0
locale/locale.go View File

@@ -0,0 +1,30 @@
1
+// Copyright 2017 Frédéric Guillot. All rights reserved.
2
+// Use of this source code is governed by the Apache 2.0
3
+// license that can be found in the LICENSE file.
4
+
5
+package locale
6
+
7
+import "log"
8
+
9
+type Translation map[string]interface{}
10
+
11
+type Locales map[string]Translation
12
+
13
+func Load() *Translator {
14
+	translator := NewTranslator()
15
+
16
+	for language, translations := range Translations {
17
+		log.Println("Loading translation:", language)
18
+		translator.AddLanguage(language, translations)
19
+	}
20
+
21
+	return translator
22
+}
23
+
24
+// GetAvailableLanguages returns the list of available languages.
25
+func GetAvailableLanguages() map[string]string {
26
+	return map[string]string{
27
+		"en_US": "English",
28
+		"fr_FR": "Français",
29
+	}
30
+}

+ 103
- 0
locale/locale_test.go View File

@@ -0,0 +1,103 @@
1
+// Copyright 2017 Frédéric Guillot. All rights reserved.
2
+// Use of this source code is governed by the Apache 2.0
3
+// license that can be found in the LICENSE file.
4
+package locale
5
+
6
+import "testing"
7
+
8
+func TestTranslateWithMissingLanguage(t *testing.T) {
9
+	translator := NewTranslator()
10
+	translation := translator.GetLanguage("en_US").Get("auth.username")
11
+
12
+	if translation != "auth.username" {
13
+		t.Errorf("Wrong translation, got %s", translation)
14
+	}
15
+}
16
+
17
+func TestTranslateWithExistingKey(t *testing.T) {
18
+	data := `{"auth.username": "Username"}`
19
+	translator := NewTranslator()
20
+	translator.AddLanguage("en_US", data)
21
+	translation := translator.GetLanguage("en_US").Get("auth.username")
22
+
23
+	if translation != "Username" {
24
+		t.Errorf("Wrong translation, got %s", translation)
25
+	}
26
+}
27
+
28
+func TestTranslateWithMissingKey(t *testing.T) {
29
+	data := `{"auth.username": "Username"}`
30
+	translator := NewTranslator()
31
+	translator.AddLanguage("en_US", data)
32
+	translation := translator.GetLanguage("en_US").Get("auth.password")
33
+
34
+	if translation != "auth.password" {
35
+		t.Errorf("Wrong translation, got %s", translation)
36
+	}
37
+}
38
+
39
+func TestTranslateWithMissingKeyAndPlaceholder(t *testing.T) {
40
+	translator := NewTranslator()
41
+	translator.AddLanguage("fr_FR", "")
42
+	translation := translator.GetLanguage("fr_FR").Get("Status: %s", "ok")
43
+
44
+	if translation != "Status: ok" {
45
+		t.Errorf("Wrong translation, got %s", translation)
46
+	}
47
+}
48
+
49
+func TestTranslatePluralWithDefaultRule(t *testing.T) {
50
+	data := `{"number_of_users": ["Il y a %d utilisateur (%s)", "Il y a %d utilisateurs (%s)"]}`
51
+	translator := NewTranslator()
52
+	translator.AddLanguage("fr_FR", data)
53
+	language := translator.GetLanguage("fr_FR")
54
+
55
+	translation := language.Plural("number_of_users", 1, 1, "some text")
56
+	expected := "Il y a 1 utilisateur (some text)"
57
+	if translation != expected {
58
+		t.Errorf(`Wrong translation, got "%s" instead of "%s"`, translation, expected)
59
+	}
60
+
61
+	translation = language.Plural("number_of_users", 2, 2, "some text")
62
+	expected = "Il y a 2 utilisateurs (some text)"
63
+	if translation != expected {
64
+		t.Errorf(`Wrong translation, got "%s" instead of "%s"`, translation, expected)
65
+	}
66
+}
67
+
68
+func TestTranslatePluralWithRussianRule(t *testing.T) {
69
+	data := `{"key": ["из %d книги за %d день", "из %d книг за %d дня", "из %d книг за %d дней"]}`
70
+	translator := NewTranslator()
71
+	translator.AddLanguage("ru_RU", data)
72
+	language := translator.GetLanguage("ru_RU")
73
+
74
+	translation := language.Plural("key", 1, 1, 1)
75
+	expected := "из 1 книги за 1 день"
76
+	if translation != expected {
77
+		t.Errorf(`Wrong translation, got "%s" instead of "%s"`, translation, expected)
78
+	}
79
+
80
+	translation = language.Plural("key", 2, 2, 2)
81
+	expected = "из 2 книг за 2 дня"
82
+	if translation != expected {
83
+		t.Errorf(`Wrong translation, got "%s" instead of "%s"`, translation, expected)
84
+	}
85
+
86
+	translation = language.Plural("key", 5, 5, 5)
87
+	expected = "из 5 книг за 5 дней"
88
+	if translation != expected {
89
+		t.Errorf(`Wrong translation, got "%s" instead of "%s"`, translation, expected)
90
+	}
91
+}
92
+
93
+func TestTranslatePluralWithMissingTranslation(t *testing.T) {
94
+	translator := NewTranslator()
95
+	translator.AddLanguage("fr_FR", "")
96
+	language := translator.GetLanguage("fr_FR")
97
+
98
+	translation := language.Plural("number_of_users", 2)
99
+	expected := "number_of_users"
100
+	if translation != expected {
101
+		t.Errorf(`Wrong translation, got "%s" instead of "%s"`, translation, expected)
102
+	}
103
+}

+ 101
- 0
locale/plurals.go View File

@@ -0,0 +1,101 @@
1
+// Copyright 2017 Frédéric Guillot. All rights reserved.
2
+// Use of this source code is governed by the Apache 2.0
3
+// license that can be found in the LICENSE file.
4
+
5
+package locale
6
+
7
+// See https://localization-guide.readthedocs.io/en/latest/l10n/pluralforms.html
8
+// And http://www.unicode.org/cldr/charts/29/supplemental/language_plural_rules.html
9
+var pluralForms = map[string]func(n int) int{
10
+	// nplurals=2; plural=(n != 1);
11
+	"default": func(n int) int {
12
+		if n != 1 {
13
+			return 1
14
+		}
15
+
16
+		return 0
17
+	},
18
+	// nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 ? 4 : 5);
19
+	"ar_AR": func(n int) int {
20
+		if n == 0 {
21
+			return 0
22
+		}
23
+
24
+		if n == 1 {
25
+			return 1
26
+		}
27
+
28
+		if n == 2 {
29
+			return 2
30
+		}
31
+
32
+		if n%100 >= 3 && n%100 <= 10 {
33
+			return 3
34
+		}
35
+
36
+		if n%100 >= 11 {
37
+			return 4
38
+		}
39
+
40
+		return 5
41
+	},
42
+	// nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;
43
+	"cs_CZ": func(n int) int {
44
+		if n == 1 {
45
+			return 0
46
+		}
47
+
48
+		if n >= 2 && n <= 4 {
49
+			return 1
50
+		}
51
+
52
+		return 2
53
+	},
54
+	// nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);
55
+	"pl_PL": func(n int) int {
56
+		if n == 1 {
57
+			return 0
58
+		}
59
+
60
+		if n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20) {
61
+			return 1
62
+		}
63
+
64
+		return 2
65
+	},
66
+	// nplurals=2; plural=(n > 1);
67
+	"pt_BR": func(n int) int {
68
+		if n > 1 {
69
+			return 1
70
+		}
71
+		return 0
72
+	},
73
+	// nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);
74
+	"ru_RU": func(n int) int {
75
+		if n%10 == 1 && n%100 != 11 {
76
+			return 0
77
+		}
78
+
79
+		if n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20) {
80
+			return 1
81
+		}
82
+
83
+		return 2
84
+	},
85
+	// nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);
86
+	"sr_RS": func(n int) int {
87
+		if n%10 == 1 && n%100 != 11 {
88
+			return 0
89
+		}
90
+
91
+		if n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20) {
92
+			return 1
93
+		}
94
+
95
+		return 2
96
+	},
97
+	// nplurals=1; plural=0;
98
+	"zh_CN": func(n int) int {
99
+		return 0
100
+	},
101
+}

+ 136
- 0
locale/translations.go View File

@@ -0,0 +1,136 @@
1
+// Code generated by go generate; DO NOT EDIT.
2
+// 2017-11-19 22:01:21.925268372 -0800 PST m=+0.006101515
3
+
4
+package locale
5
+
6
+var Translations = map[string]string{
7
+	"en_US": `{
8
+    "plural.feed.error_count": [
9
+        "%d error",
10
+        "%d errors"
11
+    ],
12
+    "plural.categories.feed_count": [
13
+        "There is %d feed.",
14
+        "There are %d feeds."
15
+    ]
16
+}`,
17
+	"fr_FR": `{
18
+    "plural.feed.error_count": [
19
+        "%d erreur",
20
+        "%d erreurs"
21
+    ],
22
+    "plural.categories.feed_count": [
23
+        "Il y %d abonnement.",
24
+        "Il y %d abonnements."
25
+    ],
26
+    "Username": "Nom d'utilisateur",
27
+    "Password": "Mot de passe",
28
+    "Unread": "Non lus",
29
+    "History": "Historique",
30
+    "Feeds": "Abonnements",
31
+    "Categories": "Catégories",
32
+    "Settings": "Réglages",
33
+    "Logout": "Se déconnecter",
34
+    "Next": "Suivant",
35
+    "Previous": "Précédent",
36
+    "New Subscription": "Nouvel Abonnment",
37
+    "Import": "Importation",
38
+    "Export": "Exportation",
39
+    "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.",
40
+    "URL": "URL",
41
+    "Category": "Catégorie",
42
+    "Find a subscription": "Trouver un abonnement",
43
+    "Loading...": "Chargement...",
44
+    "Create a category": "Créer une catégorie",
45
+    "There is no category.": "Il n'y a aucune catégorie.",
46
+    "Edit": "Modifier",
47
+    "Remove": "Supprimer",
48
+    "No feed.": "Aucun abonnement.",
49
+    "There is no article in this category.": "Il n'y a aucun article dans cette catégorie.",
50
+    "Original": "Original",
51
+    "Mark this page as read": "Marquer cette page comme lu",
52
+    "not yet": "pas encore",
53
+    "just now": "à l'instant",
54
+    "1 minute ago": "il y a une minute",
55
+    "%d minutes ago": "il y a %d minutes",
56
+    "1 hour ago": "il y a une heure",
57
+    "%d hours ago": "il y a %d heures",
58
+    "yesterday": "hier",
59
+    "%d days ago": "il y a %d jours",
60
+    "%d weeks ago": "il y a %d semaines",
61
+    "%d months ago": "il y a %d mois",
62
+    "%d years ago": "il y a %d années",
63
+    "Date": "Date",
64
+    "IP Address": "Adresse IP",
65
+    "User Agent": "Navigateur Web",
66
+    "Actions": "Actions",
67
+    "Current session": "Session actuelle",
68
+    "Sessions": "Sessions",
69
+    "Users": "Utilisateurs",
70
+    "Add user": "Ajouter un utilisateur",
71
+    "Choose a Subscription": "Choisissez un abonnement",
72
+    "Subscribe": "S'abonner",
73
+    "New Category": "Nouvelle Catégorie",
74
+    "Title": "Titre",
75
+    "Save": "Sauvegarder",
76
+    "or": "ou",
77
+    "cancel": "annuler",
78
+    "New User": "Nouvel Utilisateur",
79
+    "Confirmation": "Confirmation",
80
+    "Administrator": "Administrateur",
81
+    "Edit Category: %s": "Modification de la catégorie : %s",
82
+    "Update": "Mettre à jour",
83
+    "Edit Feed: %s": "Modification de l'abonnement : %s",
84
+    "There is no category!": "Il n'y a aucune catégorie !",
85
+    "Edit user: %s": "Modification de l'utilisateur : %s",
86
+    "There is no article for this feed.": "Il n'y a aucun article pour cet abonnement.",
87
+    "Add subscription": "Ajouter un abonnement",
88
+    "You don't have any subscription.": "Vous n'avez aucun abonnement",
89
+    "Last check:": "Dernière vérification :",
90
+    "Refresh": "Actualiser",
91
+    "There is no history at the moment.": "Il n'y a aucun historique pour le moment.",
92
+    "OPML file": "Fichier OPML",
93
+    "Sign In": "Connexion",
94
+    "Sign in": "Connexion",
95
+    "Theme": "Thème",
96
+    "Timezone": "Fuseau horaire",
97
+    "Language": "Langue",
98
+    "There is no unread article.": "Il n'y a rien de nouveau à lire.",
99
+    "You are the only user.": "Vous êtes le seul utilisateur.",
100
+    "Last Login": "Dernière connexion",
101
+    "Yes": "Oui",
102
+    "No": "Non",
103
+    "This feed already exists (%s).": "Cet abonnement existe déjà (%s).",
104
+    "Unable to fetch feed (statusCode=%d).": "Impossible de récupérer cet abonnement (code=%d).",
105
+    "Unable to open this link: %v": "Impossible d'ouvrir ce lien : %v",
106
+    "Unable to analyze this page: %v": "Impossible d'analyzer cette page : %v",
107
+    "Unable to find any subscription.": "Impossible de trouver un abonnement.",
108
+    "The URL and the category are mandatory.": "L'URL et la catégorie sont obligatoire.",
109
+    "All fields are mandatory.": "Tous les champs sont obligatoire.",
110
+    "Passwords are not the same.": "Les mots de passe ne sont pas les mêmes.",
111
+    "You must use at least 6 characters.": "Vous devez utiliser au moins 6 caractères.",
112
+    "The username is mandatory.": "Le nom d'utilisateur est obligatoire.",
113
+    "The username, theme, language and timezone fields are mandatory.": "Le nom d'utilisateur, le thème, la langue et le fuseau horaire sont obligatoire.",
114
+    "The title is mandatory.": "Le titre est obligatoire.",
115
+    "About": "A propos",
116
+    "version": "Version",
117
+    "Version:": "Version :",
118
+    "Build Date:": "Date de la compilation :",
119
+    "Author:": "Auteur :",
120
+    "Authors": "Auteurs",
121
+    "License:": "Licence :",
122
+    "Attachments": "Pièces jointes",
123
+    "Download": "Télécharger",
124
+    "Invalid username or password.": "Mauvais identifiant ou mot de passe.",
125
+    "Never": "Jamais",
126
+    "Unable to execute request: %v": "Impossible d'exécuter cette requête: %v",
127
+    "Last Parsing Error": "Dernière erreur d'analyse",
128
+    "There is a problem with this feed": "Il y a un problème avec cet abonnement"
129
+}
130
+`,
131
+}
132
+
133
+var TranslationsChecksums = map[string]string{
134
+	"en_US": "6fe95384260941e8a5a3c695a655a932e0a8a6a572c1e45cb2b1ae8baa01b897",
135
+	"fr_FR": "1f75e5a4b581755f7f84687126bc5b96aaf0109a2f83a72a8770c2ad3ddb7ba3",
136
+}

+ 10
- 0
locale/translations/en_US.json View File

@@ -0,0 +1,10 @@
1
+{
2
+    "plural.feed.error_count": [
3
+        "%d error",
4
+        "%d errors"
5
+    ],
6
+    "plural.categories.feed_count": [
7
+        "There is %d feed.",
8
+        "There are %d feeds."
9
+    ]
10
+}

+ 113
- 0
locale/translations/fr_FR.json View File

@@ -0,0 +1,113 @@
1
+{
2
+    "plural.feed.error_count": [
3
+        "%d erreur",
4
+        "%d erreurs"
5
+    ],
6
+    "plural.categories.feed_count": [
7
+        "Il y %d abonnement.",
8
+        "Il y %d abonnements."
9
+    ],
10
+    "Username": "Nom d'utilisateur",
11
+    "Password": "Mot de passe",
12
+    "Unread": "Non lus",
13
+    "History": "Historique",
14
+    "Feeds": "Abonnements",
15
+    "Categories": "Catégories",
16
+    "Settings": "Réglages",
17
+    "Logout": "Se déconnecter",
18
+    "Next": "Suivant",
19
+    "Previous": "Précédent",
20
+    "New Subscription": "Nouvel Abonnment",
21
+    "Import": "Importation",
22
+    "Export": "Exportation",
23
+    "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.",
24
+    "URL": "URL",
25
+    "Category": "Catégorie",
26
+    "Find a subscription": "Trouver un abonnement",
27
+    "Loading...": "Chargement...",
28
+    "Create a category": "Créer une catégorie",
29
+    "There is no category.": "Il n'y a aucune catégorie.",
30
+    "Edit": "Modifier",
31
+    "Remove": "Supprimer",
32
+    "No feed.": "Aucun abonnement.",
33
+    "There is no article in this category.": "Il n'y a aucun article dans cette catégorie.",
34
+    "Original": "Original",
35
+    "Mark this page as read": "Marquer cette page comme lu",
36
+    "not yet": "pas encore",
37
+    "just now": "à l'instant",
38
+    "1 minute ago": "il y a une minute",
39
+    "%d minutes ago": "il y a %d minutes",
40
+    "1 hour ago": "il y a une heure",
41
+    "%d hours ago": "il y a %d heures",
42
+    "yesterday": "hier",
43
+    "%d days ago": "il y a %d jours",
44
+    "%d weeks ago": "il y a %d semaines",
45
+    "%d months ago": "il y a %d mois",
46
+    "%d years ago": "il y a %d années",
47
+    "Date": "Date",
48
+    "IP Address": "Adresse IP",
49
+    "User Agent": "Navigateur Web",
50
+    "Actions": "Actions",
51
+    "Current session": "Session actuelle",
52
+    "Sessions": "Sessions",
53
+    "Users": "Utilisateurs",
54
+    "Add user": "Ajouter un utilisateur",
55
+    "Choose a Subscription": "Choisissez un abonnement",
56
+    "Subscribe": "S'abonner",
57
+    "New Category": "Nouvelle Catégorie",
58
+    "Title": "Titre",
59
+    "Save": "Sauvegarder",
60
+    "or": "ou",
61
+    "cancel": "annuler",
62
+    "New User": "Nouvel Utilisateur",
63
+    "Confirmation": "Confirmation",
64
+    "Administrator": "Administrateur",
65
+    "Edit Category: %s": "Modification de la catégorie : %s",
66
+    "Update": "Mettre à jour",
67
+    "Edit Feed: %s": "Modification de l'abonnement : %s",
68
+    "There is no category!": "Il n'y a aucune catégorie !",
69
+    "Edit user: %s": "Modification de l'utilisateur : %s",
70
+    "There is no article for this feed.": "Il n'y a aucun article pour cet abonnement.",
71
+    "Add subscription": "Ajouter un abonnement",
72
+    "You don't have any subscription.": "Vous n'avez aucun abonnement",
73
+    "Last check:": "Dernière vérification :",
74
+    "Refresh": "Actualiser",
75
+    "There is no history at the moment.": "Il n'y a aucun historique pour le moment.",
76
+    "OPML file": "Fichier OPML",
77
+    "Sign In": "Connexion",
78
+    "Sign in": "Connexion",
79
+    "Theme": "Thème",
80
+    "Timezone": "Fuseau horaire",
81
+    "Language": "Langue",
82
+    "There is no unread article.": "Il n'y a rien de nouveau à lire.",
83
+    "You are the only user.": "Vous êtes le seul utilisateur.",
84
+    "Last Login": "Dernière connexion",
85
+    "Yes": "Oui",
86
+    "No": "Non",
87
+    "This feed already exists (%s).": "Cet abonnement existe déjà (%s).",
88
+    "Unable to fetch feed (statusCode=%d).": "Impossible de récupérer cet abonnement (code=%d).",
89
+    "Unable to open this link: %v": "Impossible d'ouvrir ce lien : %v",
90
+    "Unable to analyze this page: %v": "Impossible d'analyzer cette page : %v",
91
+    "Unable to find any subscription.": "Impossible de trouver un abonnement.",
92
+    "The URL and the category are mandatory.": "L'URL et la catégorie sont obligatoire.",
93
+    "All fields are mandatory.": "Tous les champs sont obligatoire.",
94
+    "Passwords are not the same.": "Les mots de passe ne sont pas les mêmes.",
95
+    "You must use at least 6 characters.": "Vous devez utiliser au moins 6 caractères.",
96
+    "The username is mandatory.": "Le nom d'utilisateur est obligatoire.",
97
+    "The username, theme, language and timezone fields are mandatory.": "Le nom d'utilisateur, le thème, la langue et le fuseau horaire sont obligatoire.",
98
+    "The title is mandatory.": "Le titre est obligatoire.",
99
+    "About": "A propos",
100
+    "version": "Version",
101
+    "Version:": "Version :",
102
+    "Build Date:": "Date de la compilation :",
103
+    "Author:": "Auteur :",
104
+    "Authors": "Auteurs",
105
+    "License:": "Licence :",
106
+    "Attachments": "Pièces jointes",
107
+    "Download": "Télécharger",
108
+    "Invalid username or password.": "Mauvais identifiant ou mot de passe.",
109
+    "Never": "Jamais",
110
+    "Unable to execute request: %v": "Impossible d'exécuter cette requête: %v",
111
+    "Last Parsing Error": "Dernière erreur d'analyse",
112
+    "There is a problem with this feed": "Il y a un problème avec cet abonnement"
113
+}

+ 40
- 0
locale/translator.go View File

@@ -0,0 +1,40 @@
1
+// Copyright 2017 Frédéric Guillot. All rights reserved.
2
+// Use of this source code is governed by the Apache 2.0
3
+// license that can be found in the LICENSE file.
4
+
5
+package locale
6
+
7
+import (
8
+	"encoding/json"
9
+	"fmt"
10
+	"strings"
11
+)
12
+
13
+type Translator struct {
14
+	Locales Locales
15
+}
16
+
17
+func (t *Translator) AddLanguage(language, translations string) error {
18
+	var decodedTranslations Translation
19
+
20
+	decoder := json.NewDecoder(strings.NewReader(translations))
21
+	if err := decoder.Decode(&decodedTranslations); err != nil {
22
+		return fmt.Errorf("Invalid JSON file: %v", err)
23
+	}
24
+
25
+	t.Locales[language] = decodedTranslations
26
+	return nil
27
+}
28
+
29
+func (t *Translator) GetLanguage(language string) *Language {
30
+	translations, found := t.Locales[language]
31
+	if !found {
32
+		return &Language{language: language}
33
+	}
34
+
35
+	return &Language{language: language, translations: translations}
36
+}
37
+
38
+func NewTranslator() *Translator {
39
+	return &Translator{Locales: make(Locales)}
40
+}

+ 124
- 0
main.go View File

@@ -0,0 +1,124 @@
1
+// Copyright 2017 Frédéric Guillot. All rights reserved.
2
+// Use of this source code is governed by the Apache 2.0
3
+// license that can be found in the LICENSE file.
4
+
5
+package main
6
+
7
+//go:generate go run generate.go
8
+
9
+import (
10
+	"bufio"
11
+	"context"
12
+	"flag"
13
+	"fmt"
14
+	"github.com/miniflux/miniflux2/config"
15
+	"github.com/miniflux/miniflux2/model"
16
+	"github.com/miniflux/miniflux2/reader/feed"
17
+	"github.com/miniflux/miniflux2/scheduler"
18
+	"github.com/miniflux/miniflux2/server"
19
+	"github.com/miniflux/miniflux2/storage"
20
+	"github.com/miniflux/miniflux2/version"
21
+	"log"
22
+	"os"
23
+	"os/signal"
24
+	"runtime"
25
+	"strings"
26
+	"time"
27
+
28
+	_ "github.com/lib/pq"
29
+	"golang.org/x/crypto/ssh/terminal"
30
+)
31
+
32
+func run(cfg *config.Config, store *storage.Storage) {
33
+	log.Println("Starting Miniflux...")
34
+
35
+	stop := make(chan os.Signal, 1)
36
+	signal.Notify(stop, os.Interrupt)
37
+
38
+	feedHandler := feed.NewFeedHandler(store)
39
+	server := server.NewServer(cfg, store, feedHandler)
40
+
41
+	go func() {
42
+		pool := scheduler.NewWorkerPool(feedHandler, cfg.GetInt("WORKER_POOL_SIZE", 5))
43
+		scheduler.NewScheduler(store, pool, cfg.GetInt("POLLING_FREQUENCY", 30), cfg.GetInt("BATCH_SIZE", 10))
44
+	}()
45
+
46
+	<-stop
47
+	log.Println("Shutting down the server...")
48
+	ctx, _ := context.WithTimeout(context.Background(), 5*time.Second)
49
+	server.Shutdown(ctx)
50
+	store.Close()
51
+	log.Println("Server gracefully stopped")
52
+}
53
+
54
+func askCredentials() (string, string) {
55
+	reader := bufio.NewReader(os.Stdin)
56
+
57
+	fmt.Print("Enter Username: ")
58
+	username, _ := reader.ReadString('\n')
59
+
60
+	fmt.Print("Enter Password: ")
61
+	bytePassword, _ := terminal.ReadPassword(0)
62
+
63
+	fmt.Printf("\n")
64
+	return strings.TrimSpace(username), strings.TrimSpace(string(bytePassword))
65
+}
66
+
67
+func main() {
68
+	flagInfo := flag.Bool("info", false, "Show application information")
69
+	flagVersion := flag.Bool("version", false, "Show application version")
70
+	flagMigrate := flag.Bool("migrate", false, "Migrate database schema")
71
+	flagFlushSessions := flag.Bool("flush-sessions", false, "Flush all sessions (disconnect users)")
72
+	flagCreateAdmin := flag.Bool("create-admin", false, "Create admin user")
73
+	flag.Parse()
74
+
75
+	cfg := config.NewConfig()
76
+	store := storage.NewStorage(
77
+		cfg.Get("DATABASE_URL", "postgres://postgres:postgres@localhost/miniflux2?sslmode=disable"),
78
+		cfg.GetInt("DATABASE_MAX_CONNS", 20),
79
+	)
80
+
81
+	if *flagInfo {
82
+		fmt.Println("Version:", version.Version)
83
+		fmt.Println("Build Date:", version.BuildDate)
84
+		fmt.Println("Go Version:", runtime.Version())
85
+		return
86
+	}
87
+
88
+	if *flagVersion {
89
+		fmt.Println(version.Version)
90
+		return
91
+	}
92
+
93
+	if *flagMigrate {
94
+		store.Migrate()
95
+		return
96
+	}
97
+
98
+	if *flagFlushSessions {
99
+		fmt.Println("Flushing all sessions (disconnect users)")
100
+		if err := store.FlushAllSessions(); err != nil {
101
+			fmt.Println(err)
102
+			os.Exit(1)
103
+		}
104
+		return
105
+	}
106
+
107
+	if *flagCreateAdmin {
108
+		user := &model.User{IsAdmin: true}
109
+		user.Username, user.Password = askCredentials()
110
+		if err := user.ValidateUserCreation(); err != nil {
111
+			fmt.Println(err)
112
+			os.Exit(1)
113
+		}
114
+
115
+		if err := store.CreateUser(user); err != nil {
116
+			fmt.Println(err)
117
+			os.Exit(1)
118
+		}
119
+
120
+		return
121
+	}
122
+
123
+	run(cfg, store)
124
+}

+ 51
- 0
model/category.go View File

@@ -0,0 +1,51 @@
1
+// Copyright 2017 Frédéric Guillot. All rights reserved.
2
+// Use of this source code is governed by the Apache 2.0
3
+// license that can be found in the LICENSE file.
4
+
5
+package model
6
+
7
+import (
8
+	"errors"
9
+	"fmt"
10
+)
11
+
12
+type Category struct {
13
+	ID        int64  `json:"id,omitempty"`
14
+	Title     string `json:"title,omitempty"`
15
+	UserID    int64  `json:"user_id,omitempty"`
16
+	FeedCount int    `json:"nb_feeds,omitempty"`
17
+}
18
+
19
+func (c *Category) String() string {
20
+	return fmt.Sprintf("ID=%d, UserID=%d, Title=%s", c.ID, c.UserID, c.Title)
21
+}
22
+
23
+func (c Category) ValidateCategoryCreation() error {
24
+	if c.Title == "" {
25
+		return errors.New("The title is mandatory")
26
+	}
27
+
28
+	if c.UserID == 0 {
29
+		return errors.New("The userID is mandatory")
30
+	}
31
+
32
+	return nil
33
+}
34
+
35
+func (c Category) ValidateCategoryModification() error {
36
+	if c.Title == "" {
37
+		return errors.New("The title is mandatory")
38
+	}
39
+
40
+	if c.UserID == 0 {
41
+		return errors.New("The userID is mandatory")
42
+	}
43
+
44
+	if c.ID == 0 {
45
+		return errors.New("The ID is mandatory")
46
+	}
47
+
48
+	return nil
49
+}
50
+
51
+type Categories []*Category

+ 18
- 0
model/enclosure.go View File

@@ -0,0 +1,18 @@
1
+// Copyright 2017 Frédéric Guillot. All rights reserved.
2
+// Use of this source code is governed by the Apache 2.0
3
+// license that can be found in the LICENSE file.
4
+
5
+package model
6
+
7
+// Enclosure represents an attachment.
8
+type Enclosure struct {
9
+	ID       int64  `json:"id"`
10
+	UserID   int64  `json:"user_id"`
11
+	EntryID  int64  `json:"entry_id"`
12
+	URL      string `json:"url"`
13
+	MimeType string `json:"mime_type"`
14
+	Size     int    `json:"size"`
15
+}
16
+
17
+// EnclosureList represents a list of attachments.
18
+type EnclosureList []*Enclosure

+ 71
- 0
model/entry.go View File

@@ -0,0 +1,71 @@
1
+// Copyright 2017 Frédéric Guillot. All rights reserved.
2
+// Use of this source code is governed by the Apache 2.0
3
+// license that can be found in the LICENSE file.
4
+
5
+package model
6
+
7
+import (
8
+	"fmt"
9
+	"time"
10
+)
11
+
12
+const (
13
+	EntryStatusUnread       = "unread"
14
+	EntryStatusRead         = "read"
15
+	EntryStatusRemoved      = "removed"
16
+	DefaultSortingOrder     = "published_at"
17
+	DefaultSortingDirection = "desc"
18
+)
19
+
20
+type Entry struct {
21
+	ID         int64         `json:"id"`
22
+	UserID     int64         `json:"user_id"`
23
+	FeedID     int64         `json:"feed_id"`
24
+	Status     string        `json:"status"`
25
+	Hash       string        `json:"hash"`
26
+	Title      string        `json:"title"`
27
+	URL        string        `json:"url"`
28
+	Date       time.Time     `json:"published_at"`
29
+	Content    string        `json:"content"`
30
+	Author     string        `json:"author"`
31
+	Enclosures EnclosureList `json:"enclosures,omitempty"`
32
+	Feed       *Feed         `json:"feed,omitempty"`
33
+	Category   *Category     `json:"category,omitempty"`
34
+}
35
+
36
+type Entries []*Entry
37
+
38
+func ValidateEntryStatus(status string) error {
39
+	switch status {
40
+	case EntryStatusRead, EntryStatusUnread, EntryStatusRemoved:
41
+		return nil
42
+	}
43
+
44
+	return fmt.Errorf(`Invalid entry status, valid status values are: "%s", "%s" and "%s"`, EntryStatusRead, EntryStatusUnread, EntryStatusRemoved)
45
+}
46
+
47
+func ValidateEntryOrder(order string) error {
48
+	switch order {
49
+	case "id", "status", "published_at", "category_title", "category_id":
50
+		return nil
51
+	}
52
+
53
+	return fmt.Errorf(`Invalid entry order, valid order values are: "id", "status", "published_at", "category_title", "category_id"`)
54
+}
55
+
56
+func ValidateDirection(direction string) error {
57
+	switch direction {
58
+	case "asc", "desc":
59
+		return nil
60
+	}
61
+
62
+	return fmt.Errorf(`Invalid direction, valid direction values are: "asc" or "desc"`)
63
+}
64
+
65
+func GetOppositeDirection(direction string) string {
66
+	if direction == "asc" {
67
+		return "desc"
68
+	}
69
+
70
+	return "asc"
71
+}

+ 66
- 0
model/feed.go View File

@@ -0,0 +1,66 @@
1
+// Copyright 2017 Frédéric Guillot. All rights reserved.
2
+// Use of this source code is governed by the Apache 2.0
3
+// license that can be found in the LICENSE file.
4
+
5
+package model
6
+
7
+import (
8
+	"fmt"
9
+	"reflect"
10
+	"time"
11
+)
12
+
13
+// Feed represents a feed in the database
14
+type Feed struct {
15
+	ID                 int64     `json:"id"`
16
+	UserID             int64     `json:"user_id"`
17
+	FeedURL            string    `json:"feed_url"`
18
+	SiteURL            string    `json:"site_url"`
19
+	Title              string    `json:"title"`
20
+	CheckedAt          time.Time `json:"checked_at,omitempty"`
21
+	EtagHeader         string    `json:"etag_header,omitempty"`
22
+	LastModifiedHeader string    `json:"last_modified_header,omitempty"`
23
+	ParsingErrorMsg    string    `json:"parsing_error_message,omitempty"`
24
+	ParsingErrorCount  int       `json:"parsing_error_count,omitempty"`
25
+	Category           *Category `json:"category,omitempty"`
26
+	Entries            Entries   `json:"entries,omitempty"`
27
+	Icon               *FeedIcon `json:"icon,omitempty"`
28
+}
29
+
30
+func (f *Feed) String() string {
31
+	return fmt.Sprintf("ID=%d, UserID=%d, FeedURL=%s, SiteURL=%s, Title=%s, Category={%s}",
32
+		f.ID,
33
+		f.UserID,
34
+		f.FeedURL,
35
+		f.SiteURL,
36
+		f.Title,
37
+		f.Category,
38
+	)
39
+}
40
+
41
+// Merge combine src to the current struct
42
+func (f *Feed) Merge(src *Feed) {
43
+	src.ID = f.ID
44
+	src.UserID = f.UserID
45
+
46
+	new := reflect.ValueOf(src).Elem()
47
+	for i := 0; i < new.NumField(); i++ {
48
+		field := new.Field(i)
49
+
50
+		switch field.Interface().(type) {
51
+		case int64:
52
+			value := field.Int()
53
+			if value != 0 {
54
+				reflect.ValueOf(f).Elem().Field(i).SetInt(value)
55
+			}
56
+		case string:
57
+			value := field.String()
58
+			if value != "" {
59
+				reflect.ValueOf(f).Elem().Field(i).SetString(value)
60
+			}
61
+		}
62
+	}
63
+}
64
+
65
+// Feeds is a list of feed
66
+type Feeds []*Feed

+ 19
- 0
model/icon.go View File

@@ -0,0 +1,19 @@
1
+// Copyright 2017 Frédéric Guillot. All rights reserved.
2
+// Use of this source code is governed by the Apache 2.0
3
+// license that can be found in the LICENSE file.
4
+
5
+package model
6
+
7
+// Icon represents a website icon (favicon)
8
+type Icon struct {
9
+	ID       int64  `json:"id"`
10
+	Hash     string `json:"hash"`
11
+	MimeType string `json:"mime_type"`
12
+	Content  []byte `json:"content"`
13
+}
14
+
15
+// FeedIcon is a jonction table between feeds and icons
16
+type FeedIcon struct {
17
+	FeedID int64 `json:"feed_id"`
18
+	IconID int64 `json:"icon_id"`
19
+}

+ 10
- 0
model/job.go View File

@@ -0,0 +1,10 @@
1
+// Copyright 2017 Frédéric Guillot. All rights reserved.
2
+// Use of this source code is governed by the Apache 2.0
3
+// license that can be found in the LICENSE file.
4
+
5
+package model
6
+
7
+type Job struct {
8
+	UserID int64
9
+	FeedID int64
10
+}

+ 23
- 0
model/session.go View File

@@ -0,0 +1,23 @@
1
+// Copyright 2017 Frédéric Guillot. All rights reserved.
2
+// Use of this source code is governed by the Apache 2.0
3
+// license that can be found in the LICENSE file.
4
+
5
+package model
6
+
7
+import "time"
8
+import "fmt"
9
+
10
+type Session struct {
11
+	ID        int64
12
+	UserID    int64
13
+	Token     string
14
+	CreatedAt time.Time
15
+	UserAgent string
16
+	IP        string
17
+}
18
+
19
+func (s *Session) String() string {
20
+	return fmt.Sprintf("ID=%d, UserID=%d, IP=%s", s.ID, s.UserID, s.IP)
21
+}
22
+
23
+type Sessions []*Session

+ 13
- 0
model/theme.go View File

@@ -0,0 +1,13 @@
1
+// Copyright 2017 Frédéric Guillot. All rights reserved.
2
+// Use of this source code is governed by the Apache 2.0
3
+// license that can be found in the LICENSE file.
4
+
5
+package model
6
+
7
+// GetThemes returns the list of available themes.
8
+func GetThemes() map[string]string {
9
+	return map[string]string{
10
+		"default": "Default",
11
+		"black":   "Black",
12
+	}
13
+}

+ 96
- 0
model/user.go View File

@@ -0,0 +1,96 @@
1
+// Copyright 2017 Frédéric Guillot. All rights reserved.
2
+// Use of this source code is governed by the Apache 2.0
3
+// license that can be found in the LICENSE file.
4
+
5
+package model
6
+
7
+import (
8
+	"errors"
9
+	"time"
10
+)
11
+
12
+// User represents a user in the system.
13
+type User struct {
14
+	ID          int64      `json:"id"`
15
+	Username    string     `json:"username"`
16
+	Password    string     `json:"password,omitempty"`
17
+	IsAdmin     bool       `json:"is_admin"`
18
+	Theme       string     `json:"theme"`
19
+	Language    string     `json:"language"`
20
+	Timezone    string     `json:"timezone"`
21
+	LastLoginAt *time.Time `json:"last_login_at"`
22
+}
23
+
24
+func (u User) ValidateUserCreation() error {
25
+	if err := u.ValidateUserLogin(); err != nil {
26
+		return err
27
+	}
28
+
29
+	if err := u.ValidatePassword(); err != nil {
30
+		return err
31
+	}
32
+
33
+	return nil
34
+}
35
+
36
+func (u User) ValidateUserModification() error {
37
+	if u.Username == "" {
38
+		return errors.New("The username is mandatory")
39
+	}
40
+
41
+	if err := u.ValidatePassword(); err != nil {
42
+		return err
43
+	}
44
+
45
+	return nil
46
+}
47
+
48
+func (u User) ValidateUserLogin() error {
49
+	if u.Username == "" {
50
+		return errors.New("The username is mandatory")
51
+	}
52
+
53
+	if u.Password == "" {
54
+		return errors.New("The password is mandatory")
55
+	}
56
+
57
+	return nil
58
+}
59
+
60
+func (u User) ValidatePassword() error {
61
+	if u.Password != "" && len(u.Password) < 6 {
62
+		return errors.New("The password must have at least 6 characters")
63
+	}
64
+
65
+	return nil
66
+}
67
+
68
+// Merge update the current user with another user.
69
+func (u *User) Merge(override *User) {
70
+	if u.Username != override.Username {
71
+		u.Username = override.Username
72
+	}
73
+
74
+	if u.Password != override.Password {
75
+		u.Password = override.Password
76
+	}
77
+
78
+	if u.IsAdmin != override.IsAdmin {
79
+		u.IsAdmin = override.IsAdmin
80
+	}
81
+
82
+	if u.Theme != override.Theme {
83
+		u.Theme = override.Theme
84
+	}
85
+
86
+	if u.Language != override.Language {
87
+		u.Language = override.Language
88
+	}
89
+
90
+	if u.Timezone != override.Timezone {
91
+		u.Timezone = override.Timezone
92
+	}
93
+}
94
+
95
+// Users represents a list of users.
96
+type Users []*User

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

@@ -0,0 +1,214 @@
1
+// Copyright 2017 Frédéric Guillot. All rights reserved.
2
+// Use of this source code is governed by the Apache 2.0
3
+// license that can be found in the LICENSE file.
4
+
5
+package atom
6
+
7
+import (
8
+	"encoding/xml"
9
+	"github.com/miniflux/miniflux2/helper"
10
+	"github.com/miniflux/miniflux2/model"
11
+	"github.com/miniflux/miniflux2/reader/feed/date"
12
+	"github.com/miniflux/miniflux2/reader/processor"
13
+	"github.com/miniflux/miniflux2/reader/sanitizer"
14
+	"log"
15
+	"strconv"
16
+	"strings"
17
+	"time"
18
+)
19
+
20
+type AtomFeed struct {
21
+	XMLName xml.Name    `xml:"http://www.w3.org/2005/Atom feed"`
22
+	ID      string      `xml:"id"`
23
+	Title   string      `xml:"title"`
24
+	Author  Author      `xml:"author"`
25
+	Links   []Link      `xml:"link"`
26
+	Entries []AtomEntry `xml:"entry"`
27
+}
28
+
29
+type AtomEntry struct {
30
+	ID         string     `xml:"id"`
31
+	Title      string     `xml:"title"`
32
+	Updated    string     `xml:"updated"`
33
+	Links      []Link     `xml:"link"`
34
+	Summary    string     `xml:"summary"`
35
+	Content    Content    `xml:"content"`
36
+	MediaGroup MediaGroup `xml:"http://search.yahoo.com/mrss/ group"`
37
+	Author     Author     `xml:"author"`
38
+}
39
+
40
+type Author struct {
41
+	Name  string `xml:"name"`
42
+	Email string `xml:"email"`
43
+}
44
+
45
+type Link struct {
46
+	Url    string `xml:"href,attr"`
47
+	Type   string `xml:"type,attr"`
48
+	Rel    string `xml:"rel,attr"`
49
+	Length string `xml:"length,attr"`
50
+}
51
+
52
+type Content struct {
53
+	Type string `xml:"type,attr"`
54
+	Data string `xml:",chardata"`
55
+	Xml  string `xml:",innerxml"`
56
+}
57
+
58
+type MediaGroup struct {
59
+	Description string `xml:"http://search.yahoo.com/mrss/ description"`
60
+}
61
+
62
+func (a *AtomFeed) getSiteURL() string {
63
+	for _, link := range a.Links {
64
+		if strings.ToLower(link.Rel) == "alternate" {
65
+			return link.Url
66
+		}
67
+
68
+		if link.Rel == "" && link.Type == "" {
69
+			return link.Url
70
+		}
71
+	}
72
+
73
+	return ""
74
+}
75
+
76
+func (a *AtomFeed) getFeedURL() string {
77
+	for _, link := range a.Links {
78
+		if strings.ToLower(link.Rel) == "self" {
79
+			return link.Url
80
+		}
81
+	}
82
+
83
+	return ""
84
+}
85
+
86
+func (a *AtomFeed) Transform() *model.Feed {
87
+	feed := new(model.Feed)
88
+	feed.FeedURL = a.getFeedURL()
89
+	feed.SiteURL = a.getSiteURL()
90
+	feed.Title = sanitizer.StripTags(a.Title)
91
+
92
+	if feed.Title == "" {
93
+		feed.Title = feed.SiteURL
94
+	}
95
+
96
+	for _, entry := range a.Entries {
97
+		item := entry.Transform()
98
+		if item.Author == "" {
99
+			item.Author = a.GetAuthor()
100
+		}
101
+
102
+		feed.Entries = append(feed.Entries, item)
103
+	}
104
+
105
+	return feed
106
+}
107
+
108
+func (a *AtomFeed) GetAuthor() string {
109
+	return getAuthor(a.Author)
110
+}
111
+
112
+func (e *AtomEntry) GetDate() time.Time {
113
+	if e.Updated != "" {
114
+		result, err := date.Parse(e.Updated)
115
+		if err != nil {
116
+			log.Println(err)
117
+			return time.Now()
118
+		}
119
+
120
+		return result
121
+	}
122
+
123
+	return time.Now()
124
+}
125
+
126
+func (e *AtomEntry) GetURL() string {
127
+	for _, link := range e.Links {
128
+		if strings.ToLower(link.Rel) == "alternate" {
129
+			return link.Url
130
+		}
131
+
132
+		if link.Rel == "" && link.Type == "" {
133
+			return link.Url
134
+		}
135
+	}
136
+
137
+	return ""
138
+}
139
+
140
+func (e *AtomEntry) GetAuthor() string {
141
+	return getAuthor(e.Author)
142
+}
143
+
144
+func (e *AtomEntry) GetHash() string {
145
+	for _, value := range []string{e.ID, e.GetURL()} {
146
+		if value != "" {
147
+			return helper.Hash(value)
148
+		}
149
+	}
150
+
151
+	return ""
152
+}
153
+
154
+func (e *AtomEntry) GetContent() string {
155
+	if e.Content.Type == "html" || e.Content.Type == "text" {
156
+		return e.Content.Data
157
+	}
158
+
159
+	if e.Content.Type == "xhtml" {
160
+		return e.Content.Xml
161
+	}
162
+
163
+	if e.Summary != "" {
164
+		return e.Summary
165
+	}
166
+
167
+	if e.MediaGroup.Description != "" {
168
+		return e.MediaGroup.Description
169
+	}
170
+
171
+	return ""
172
+}
173
+
174
+func (e *AtomEntry) GetEnclosures() model.EnclosureList {
175
+	enclosures := make(model.EnclosureList, 0)
176
+
177
+	for _, link := range e.Links {
178
+		if strings.ToLower(link.Rel) == "enclosure" {
179
+			length, _ := strconv.Atoi(link.Length)
180
+			enclosures = append(enclosures, &model.Enclosure{URL: link.Url, MimeType: link.Type, Size: length})
181
+		}
182
+	}
183
+
184
+	return enclosures
185
+}
186
+
187
+func (e *AtomEntry) Transform() *model.Entry {
188
+	entry := new(model.Entry)
189
+	entry.URL = e.GetURL()
190
+	entry.Date = e.GetDate()
191
+	entry.Author = sanitizer.StripTags(e.GetAuthor())
192
+	entry.Hash = e.GetHash()
193
+	entry.Content = processor.ItemContentProcessor(entry.URL, e.GetContent())
194
+	entry.Title = sanitizer.StripTags(strings.Trim(e.Title, " \n\t"))
195
+	entry.Enclosures = e.GetEnclosures()
196
+
197
+	if entry.Title == "" {
198
+		entry.Title = entry.URL
199
+	}
200
+
201
+	return entry
202
+}
203
+
204
+func getAuthor(author Author) string {
205
+	if author.Name != "" {
206
+		return author.Name
207
+	}
208
+
209
+	if author.Email != "" {
210
+		return author.Email
211
+	}
212
+
213
+	return ""
214
+}

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

@@ -0,0 +1,28 @@
1
+// Copyright 2017 Frédéric Guillot. All rights reserved.
2
+// Use of this source code is governed by the Apache 2.0
3
+// license that can be found in the LICENSE file.
4
+
5
+package atom
6
+
7
+import (
8
+	"encoding/xml"
9
+	"fmt"
10
+	"github.com/miniflux/miniflux2/model"
11
+	"io"
12
+
13
+	"golang.org/x/net/html/charset"
14
+)
15
+
16
+// Parse returns a normalized feed struct.
17
+func Parse(data io.Reader) (*model.Feed, error) {
18
+	atomFeed := new(AtomFeed)
19
+	decoder := xml.NewDecoder(data)
20
+	decoder.CharsetReader = charset.NewReaderLabel
21
+
22
+	err := decoder.Decode(atomFeed)
23
+	if err != nil {
24
+		return nil, fmt.Errorf("Unable to parse Atom feed: %v\n", err)
25
+	}
26
+
27
+	return atomFeed.Transform(), nil
28
+}

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

@@ -0,0 +1,319 @@
1
+// Copyright 2017 Frédéric Guillot. All rights reserved.
2
+// Use of this source code is governed by the Apache 2.0
3
+// license that can be found in the LICENSE file.
4
+
5
+package atom
6
+
7
+import (
8
+	"bytes"
9
+	"testing"
10
+	"time"
11
+)
12
+
13
+func TestParseAtomSample(t *testing.T) {
14
+	data := `<?xml version="1.0" encoding="utf-8"?>
15
+	<feed xmlns="http://www.w3.org/2005/Atom">
16
+
17
+	  <title>Example Feed</title>
18
+	  <link href="http://example.org/"/>
19
+	  <updated>2003-12-13T18:30:02Z</updated>
20
+	  <author>
21
+		<name>John Doe</name>
22
+	  </author>
23
+	  <id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
24
+
25
+	  <entry>
26
+		<title>Atom-Powered Robots Run Amok</title>
27
+		<link href="http://example.org/2003/12/13/atom03"/>
28
+		<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
29
+		<updated>2003-12-13T18:30:02Z</updated>
30
+		<summary>Some text.</summary>
31
+	  </entry>
32
+
33
+	</feed>`
34
+
35
+	feed, err := Parse(bytes.NewBufferString(data))
36
+	if err != nil {
37
+		t.Error(err)
38
+	}
39
+
40
+	if feed.Title != "Example Feed" {
41
+		t.Errorf("Incorrect title, got: %s", feed.Title)
42
+	}
43
+
44
+	if feed.FeedURL != "" {
45
+		t.Errorf("Incorrect feed URL, got: %s", feed.FeedURL)
46
+	}
47
+
48
+	if feed.SiteURL != "http://example.org/" {
49
+		t.Errorf("Incorrect site URL, got: %s", feed.SiteURL)
50
+	}
51
+
52
+	if len(feed.Entries) != 1 {
53
+		t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
54
+	}
55
+
56
+	if !feed.Entries[0].Date.Equal(time.Date(2003, time.December, 13, 18, 30, 2, 0, time.UTC)) {
57
+		t.Errorf("Incorrect entry date, got: %v", feed.Entries[0].Date)
58
+	}
59
+
60
+	if feed.Entries[0].Hash != "3841e5cf232f5111fc5841e9eba5f4b26d95e7d7124902e0f7272729d65601a6" {
61
+		t.Errorf("Incorrect entry hash, got: %s", feed.Entries[0].Hash)
62
+	}
63
+
64
+	if feed.Entries[0].URL != "http://example.org/2003/12/13/atom03" {
65
+		t.Errorf("Incorrect entry URL, got: %s", feed.Entries[0].URL)
66
+	}
67
+
68
+	if feed.Entries[0].Title != "Atom-Powered Robots Run Amok" {
69
+		t.Errorf("Incorrect entry title, got: %s", feed.Entries[0].Title)
70
+	}
71
+
72
+	if feed.Entries[0].Content != "Some text." {
73
+		t.Errorf("Incorrect entry content, got: %s", feed.Entries[0].Content)
74
+	}
75
+
76
+	if feed.Entries[0].Author != "John Doe" {
77
+		t.Errorf("Incorrect entry author, got: %s", feed.Entries[0].Author)
78
+	}
79
+}
80
+
81
+func TestParseFeedWithoutTitle(t *testing.T) {
82
+	data := `<?xml version="1.0" encoding="utf-8"?>
83
+		<feed xmlns="http://www.w3.org/2005/Atom">
84
+			<link rel="alternate" type="text/html" href="https://example.org/"/>
85
+			<link rel="self" type="application/atom+xml" href="https://example.org/feed"/>
86
+			<updated>2003-12-13T18:30:02Z</updated>
87
+		</feed>`
88
+
89
+	feed, err := Parse(bytes.NewBufferString(data))
90
+	if err != nil {
91
+		t.Error(err)
92
+	}
93
+
94
+	if feed.Title != "https://example.org/" {
95
+		t.Errorf("Incorrect feed title, got: %s", feed.Title)
96
+	}
97
+}
98
+
99
+func TestParseEntryWithoutTitle(t *testing.T) {
100
+	data := `<?xml version="1.0" encoding="utf-8"?>
101
+	<feed xmlns="http://www.w3.org/2005/Atom">
102
+
103
+	  <title>Example Feed</title>
104
+	  <link href="http://example.org/"/>
105
+	  <updated>2003-12-13T18:30:02Z</updated>
106
+	  <author>
107
+		<name>John Doe</name>
108
+	  </author>
109
+	  <id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
110
+
111
+	  <entry>
112
+		<link href="http://example.org/2003/12/13/atom03"/>
113
+		<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
114
+		<updated>2003-12-13T18:30:02Z</updated>
115
+		<summary>Some text.</summary>
116
+	  </entry>
117
+
118
+	</feed>`
119
+
120
+	feed, err := Parse(bytes.NewBufferString(data))
121
+	if err != nil {
122
+		t.Error(err)
123
+	}
124
+
125
+	if feed.Entries[0].Title != "http://example.org/2003/12/13/atom03" {
126
+		t.Errorf("Incorrect entry title, got: %s", feed.Entries[0].Title)
127
+	}
128
+}
129
+
130
+func TestParseFeedURL(t *testing.T) {
131
+	data := `<?xml version="1.0" encoding="utf-8"?>
132
+	<feed xmlns="http://www.w3.org/2005/Atom">
133
+	  <title>Example Feed</title>
134
+	  <link rel="alternate" type="text/html" href="https://example.org/"/>
135
+	  <link rel="self" type="application/atom+xml" href="https://example.org/feed"/>
136
+	  <updated>2003-12-13T18:30:02Z</updated>
137
+	</feed>`
138
+
139
+	feed, err := Parse(bytes.NewBufferString(data))
140
+	if err != nil {
141
+		t.Error(err)
142
+	}
143
+
144
+	if feed.SiteURL != "https://example.org/" {
145
+		t.Errorf("Incorrect site URL, got: %s", feed.SiteURL)
146
+	}
147
+
148
+	if feed.FeedURL != "https://example.org/feed" {
149
+		t.Errorf("Incorrect feed URL, got: %s", feed.FeedURL)
150
+	}
151
+}
152
+
153
+func TestParseEntryTitleWithWhitespaces(t *testing.T) {
154
+	data := `<?xml version="1.0" encoding="utf-8"?>
155
+	<feed xmlns="http://www.w3.org/2005/Atom">
156
+	  <title>Example Feed</title>
157
+	  <link href="http://example.org/"/>
158
+
159
+	  <entry>
160
+		<title>
161
+			Some Title
162
+		</title>
163
+		<link href="http://example.org/2003/12/13/atom03"/>
164
+		<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
165
+		<updated>2003-12-13T18:30:02Z</updated>
166
+		<summary>Some text.</summary>
167
+	  </entry>
168
+
169
+	</feed>`
170
+
171
+	feed, err := Parse(bytes.NewBufferString(data))
172
+	if err != nil {
173
+		t.Error(err)
174
+	}
175
+
176
+	if feed.Entries[0].Title != "Some Title" {
177
+		t.Errorf("Incorrect entry title, got: %s", feed.Entries[0].Title)
178
+	}
179
+}
180
+
181
+func TestParseEntryWithAuthorName(t *testing.T) {
182
+	data := `<?xml version="1.0" encoding="utf-8"?>
183
+	<feed xmlns="http://www.w3.org/2005/Atom">
184
+	  <title>Example Feed</title>
185
+	  <link href="http://example.org/"/>
186
+
187
+	  <entry>
188
+		<link href="http://example.org/2003/12/13/atom03"/>
189
+		<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
190
+		<updated>2003-12-13T18:30:02Z</updated>
191
+		<summary>Some text.</summary>
192
+		<author>
193
+			<name>Me</name>
194
+			<email>me@localhost</email>
195
+		</author>
196
+	  </entry>
197
+
198
+	</feed>`
199
+
200
+	feed, err := Parse(bytes.NewBufferString(data))
201
+	if err != nil {
202
+		t.Error(err)
203
+	}
204
+
205
+	if feed.Entries[0].Author != "Me" {
206
+		t.Errorf("Incorrect entry author, got: %s", feed.Entries[0].Author)
207
+	}
208
+}
209
+
210
+func TestParseEntryWithoutAuthorName(t *testing.T) {
211
+	data := `<?xml version="1.0" encoding="utf-8"?>
212
+	<feed xmlns="http://www.w3.org/2005/Atom">
213
+	  <title>Example Feed</title>
214
+	  <link href="http://example.org/"/>
215
+
216
+	  <entry>
217
+		<link href="http://example.org/2003/12/13/atom03"/>
218
+		<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
219
+		<updated>2003-12-13T18:30:02Z</updated>
220
+		<summary>Some text.</summary>
221
+		<author>
222
+			<name/>
223
+			<email>me@localhost</email>
224
+		</author>
225
+	  </entry>
226
+
227
+	</feed>`
228
+
229
+	feed, err := Parse(bytes.NewBufferString(data))
230
+	if err != nil {
231
+		t.Error(err)
232
+	}
233
+
234
+	if feed.Entries[0].Author != "me@localhost" {
235
+		t.Errorf("Incorrect entry author, got: %s", feed.Entries[0].Author)
236
+	}
237
+}
238
+
239
+func TestParseEntryWithEnclosures(t *testing.T) {
240
+	data := `<?xml version="1.0" encoding="utf-8"?>
241
+	<feed xmlns="http://www.w3.org/2005/Atom">
242
+		<id>http://www.example.org/myfeed</id>
243
+		<title>My Podcast Feed</title>
244
+		<updated>2005-07-15T12:00:00Z</updated>
245
+		<author>
246
+		<name>John Doe</name>
247
+		</author>
248
+		<link href="http://example.org" />
249
+		<link rel="self" href="http://example.org/myfeed" />
250
+		<entry>
251
+			<id>http://www.example.org/entries/1</id>
252
+			<title>Atom 1.0</title>
253
+			<updated>2005-07-15T12:00:00Z</updated>
254
+			<link href="http://www.example.org/entries/1" />
255
+			<summary>An overview of Atom 1.0</summary>
256
+			<link rel="enclosure"
257
+					type="audio/mpeg"
258
+					title="MP3"
259
+					href="http://www.example.org/myaudiofile.mp3"
260
+					length="1234" />
261
+			<link rel="enclosure"
262
+					type="application/x-bittorrent"
263
+					title="BitTorrent"
264
+					href="http://www.example.org/myaudiofile.torrent"
265
+					length="4567" />
266
+			<content type="xhtml">
267
+				<div xmlns="http://www.w3.org/1999/xhtml">
268
+				<h1>Show Notes</h1>
269
+				<ul>
270
+					<li>00:01:00 -- Introduction</li>
271
+					<li>00:15:00 -- Talking about Atom 1.0</li>
272
+					<li>00:30:00 -- Wrapping up</li>
273
+				</ul>
274
+				</div>
275
+			</content>
276
+		</entry>
277
+  	</feed>`
278
+
279
+	feed, err := Parse(bytes.NewBufferString(data))
280
+	if err != nil {
281
+		t.Error(err)
282
+	}
283
+
284
+	if len(feed.Entries) != 1 {
285
+		t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
286
+	}
287
+
288
+	if feed.Entries[0].URL != "http://www.example.org/entries/1" {
289
+		t.Errorf("Incorrect entry URL, got: %s", feed.Entries[0].URL)
290
+	}
291
+
292
+	if len(feed.Entries[0].Enclosures) != 2 {
293
+		t.Errorf("Incorrect number of enclosures, got: %d", len(feed.Entries[0].Enclosures))
294
+	}
295
+
296
+	if feed.Entries[0].Enclosures[0].URL != "http://www.example.org/myaudiofile.mp3" {
297
+		t.Errorf("Incorrect enclosure URL, got: %s", feed.Entries[0].Enclosures[0].URL)
298
+	}
299
+
300
+	if feed.Entries[0].Enclosures[0].MimeType != "audio/mpeg" {
301
+		t.Errorf("Incorrect enclosure type, got: %s", feed.Entries[0].Enclosures[0].MimeType)
302
+	}
303
+
304
+	if feed.Entries[0].Enclosures[0].Size != 1234 {
305
+		t.Errorf("Incorrect enclosure length, got: %d", feed.Entries[0].Enclosures[0].Size)
306
+	}
307
+
308
+	if feed.Entries[0].Enclosures[1].URL != "http://www.example.org/myaudiofile.torrent" {
309
+		t.Errorf("Incorrect enclosure URL, got: %s", feed.Entries[0].Enclosures[1].URL)
310
+	}
311
+
312
+	if feed.Entries[0].Enclosures[1].MimeType != "application/x-bittorrent" {
313
+		t.Errorf("Incorrect enclosure type, got: %s", feed.Entries[0].Enclosures[1].MimeType)
314
+	}
315
+
316
+	if feed.Entries[0].Enclosures[1].Size != 4567 {
317
+		t.Errorf("Incorrect enclosure length, got: %d", feed.Entries[0].Enclosures[1].Size)
318
+	}
319
+}

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

@@ -0,0 +1,203 @@
1
+// Copyright 2017 Frédéric Guillot. All rights reserved.
2
+// Use of this source code is governed by the Apache 2.0
3
+// license that can be found in the LICENSE file.
4
+
5
+package date
6
+
7
+import (
8
+	"fmt"
9
+	"strings"
10
+	"time"
11
+)
12
+
13
+// DateFormats taken from github.com/mjibson/goread
14
+var dateFormats = []string{
15
+	time.RFC822,  // RSS
16
+	time.RFC822Z, // RSS
17
+	time.RFC3339, // Atom
18
+	time.UnixDate,
19
+	time.RubyDate,
20
+	time.RFC850,
21
+	time.RFC1123Z,
22
+	time.RFC1123,
23
+	time.ANSIC,
24
+	"Mon, January 2 2006 15:04:05 -0700",
25
+	"Mon, January 02, 2006, 15:04:05 MST",
26
+	"Mon, January 02, 2006 15:04:05 MST",
27
+	"Mon, Jan 2, 2006 15:04 MST",
28
+	"Mon, Jan 2 2006 15:04 MST",
29
+	"Mon, Jan 2, 2006 15:04:05 MST",
30
+	"Mon, Jan 2 2006 15:04:05 -700",
31
+	"Mon, Jan 2 2006 15:04:05 -0700",
32
+	"Mon Jan 2 15:04 2006",
33
+	"Mon Jan 2 15:04:05 2006 MST",
34
+	"Mon Jan 02, 2006 3:04 pm",
35
+	"Mon, Jan 02,2006 15:04:05 MST",
36
+	"Mon Jan 02 2006 15:04:05 -0700",
37
+	"Monday, January 2, 2006 15:04:05 MST",
38
+	"Monday, January 2, 2006 03:04 PM",
39
+	"Monday, January 2, 2006",
40
+	"Monday, January 02, 2006",
41
+	"Monday, 2 January 2006 15:04:05 MST",
42
+	"Monday, 2 January 2006 15:04:05 -0700",
43
+	"Monday, 2 Jan 2006 15:04:05 MST",
44
+	"Monday, 2 Jan 2006 15:04:05 -0700",
45
+	"Monday, 02 January 2006 15:04:05 MST",
46
+	"Monday, 02 January 2006 15:04:05 -0700",
47
+	"Monday, 02 January 2006 15:04:05",
48
+	"Mon, 2 January 2006 15:04 MST",
49
+	"Mon, 2 January 2006, 15:04 -0700",
50
+	"Mon, 2 January 2006, 15:04:05 MST",
51
+	"Mon, 2 January 2006 15:04:05 MST",
52
+	"Mon, 2 January 2006 15:04:05 -0700",
53
+	"Mon, 2 January 2006",
54
+	"Mon, 2 Jan 2006 3:04:05 PM -0700",
55
+	"Mon, 2 Jan 2006 15:4:5 MST",
56
+	"Mon, 2 Jan 2006 15:4:5 -0700 GMT",
57
+	"Mon, 2, Jan 2006 15:4",
58
+	"Mon, 2 Jan 2006 15:04 MST",
59
+	"Mon, 2 Jan 2006, 15:04 -0700",
60
+	"Mon, 2 Jan 2006 15:04 -0700",
61
+	"Mon, 2 Jan 2006 15:04:05 UT",
62
+	"Mon, 2 Jan 2006 15:04:05MST",
63
+	"Mon, 2 Jan 2006 15:04:05 MST",
64
+	"Mon 2 Jan 2006 15:04:05 MST",
65
+	"mon,2 Jan 2006 15:04:05 MST",
66
+	"Mon, 2 Jan 2006 15:04:05 -0700 MST",
67
+	"Mon, 2 Jan 2006 15:04:05-0700",
68
+	"Mon, 2 Jan 2006 15:04:05 -0700",
69
+	"Mon, 2 Jan 2006 15:04:05",
70
+	"Mon, 2 Jan 2006 15:04",
71
+	"Mon,2 Jan 2006",
72
+	"Mon, 2 Jan 2006",
73
+	"Mon, 2 Jan 15:04:05 MST",
74
+	"Mon, 2 Jan 06 15:04:05 MST",
75
+	"Mon, 2 Jan 06 15:04:05 -0700",
76
+	"Mon, 2006-01-02 15:04",
77
+	"Mon,02 January 2006 14:04:05 MST",
78
+	"Mon, 02 January 2006",
79
+	"Mon, 02 Jan 2006 3:04:05 PM MST",
80
+	"Mon, 02 Jan 2006 15 -0700",
81
+	"Mon,02 Jan 2006 15:04 MST",
82
+	"Mon, 02 Jan 2006 15:04 MST",
83
+	"Mon, 02 Jan 2006 15:04 -0700",
84
+	"Mon, 02 Jan 2006 15:04:05 Z",
85
+	"Mon, 02 Jan 2006 15:04:05 UT",
86
+	"Mon, 02 Jan 2006 15:04:05 MST-07:00",
87
+	"Mon, 02 Jan 2006 15:04:05 MST -0700",
88
+	"Mon, 02 Jan 2006, 15:04:05 MST",
89
+	"Mon, 02 Jan 2006 15:04:05MST",
90
+	"Mon, 02 Jan 2006 15:04:05 MST",
91
+	"Mon , 02 Jan 2006 15:04:05 MST",
92
+	"Mon, 02 Jan 2006 15:04:05 GMT-0700",
93
+	"Mon,02 Jan 2006 15:04:05 -0700",
94
+	"Mon, 02 Jan 2006 15:04:05 -0700",
95
+	"Mon, 02 Jan 2006 15:04:05 -07:00",
96
+	"Mon, 02 Jan 2006 15:04:05 --0700",
97
+	"Mon 02 Jan 2006 15:04:05 -0700",
98
+	"Mon, 02 Jan 2006 15:04:05 -07",
99
+	"Mon, 02 Jan 2006 15:04:05 00",
100
+	"Mon, 02 Jan 2006 15:04:05",
101
+	"Mon, 02 Jan 2006",
102
+	"Mon, 02 Jan 06 15:04:05 MST",
103
+	"January 2, 2006 3:04 PM",
104
+	"January 2, 2006, 3:04 p.m.",
105
+	"January 2, 2006 15:04:05 MST",
106
+	"January 2, 2006 15:04:05",
107
+	"January 2, 2006 03:04 PM",
108
+	"January 2, 2006",
109
+	"January 02, 2006 15:04:05 MST",
110
+	"January 02, 2006 15:04",
111
+	"January 02, 2006 03:04 PM",
112
+	"January 02, 2006",
113
+	"Jan 2, 2006 3:04:05 PM MST",
114
+	"Jan 2, 2006 3:04:05 PM",
115
+	"Jan 2, 2006 15:04:05 MST",
116
+	"Jan 2, 2006",
117
+	"Jan 02 2006 03:04:05PM",
118
+	"Jan 02, 2006",
119
+	"6/1/2 15:04",
120
+	"6-1-2 15:04",
121
+	"2 January 2006 15:04:05 MST",
122
+	"2 January 2006 15:04:05 -0700",
123
+	"2 January 2006",
124
+	"2 Jan 2006 15:04:05 Z",
125
+	"2 Jan 2006 15:04:05 MST",
126
+	"2 Jan 2006 15:04:05 -0700",
127
+	"2 Jan 2006",
128
+	"2.1.2006 15:04:05",
129
+	"2/1/2006",
130
+	"2-1-2006",
131
+	"2006 January 02",
132
+	"2006-1-2T15:04:05Z",
133
+	"2006-1-2 15:04:05",
134
+	"2006-1-2",
135
+	"2006-1-02T15:04:05Z",
136
+	"2006-01-02T15:04Z",
137
+	"2006-01-02T15:04-07:00",
138
+	"2006-01-02T15:04:05Z",
139
+	"2006-01-02T15:04:05-07:00:00",
140
+	"2006-01-02T15:04:05:-0700",
141
+	"2006-01-02T15:04:05-0700",
142
+	"2006-01-02T15:04:05-07:00",
143
+	"2006-01-02T15:04:05 -0700",
144
+	"2006-01-02T15:04:05:00",
145
+	"2006-01-02T15:04:05",
146
+	"2006-01-02 at 15:04:05",
147
+	"2006-01-02 15:04:05Z",
148
+	"2006-01-02 15:04:05 MST",
149
+	"2006-01-02 15:04:05-0700",
150
+	"2006-01-02 15:04:05-07:00",
151
+	"2006-01-02 15:04:05 -0700",
152
+	"2006-01-02 15:04",
153
+	"2006-01-02 00:00:00.0 15:04:05.0 -0700",
154
+	"2006/01/02",
155
+	"2006-01-02",
156
+	"15:04 02.01.2006 -0700",
157
+	"1/2/2006 3:04 PM MST",
158
+	"1/2/2006 3:04:05 PM MST",
159
+	"1/2/2006 3:04:05 PM",
160
+	"1/2/2006 15:04:05 MST",
161
+	"1/2/2006",
162
+	"06/1/2 15:04",
163
+	"06-1-2 15:04",
164
+	"02 Monday, Jan 2006 15:04",
165
+	"02 Jan 2006 15:04 MST",
166
+	"02 Jan 2006 15:04:05 UT",
167
+	"02 Jan 2006 15:04:05 MST",
168
+	"02 Jan 2006 15:04:05 -0700",
169
+	"02 Jan 2006 15:04:05",
170
+	"02 Jan 2006",
171
+	"02/01/2006 15:04 MST",
172
+	"02-01-2006 15:04:05 MST",
173
+	"02.01.2006 15:04:05",
174
+	"02/01/2006 15:04:05",
175
+	"02.01.2006 15:04",
176
+	"02/01/2006 - 15:04",
177
+	"02.01.2006 -0700",
178
+	"02/01/2006",
179
+	"02-01-2006",
180
+	"01/02/2006 3:04 PM",
181
+	"01/02/2006 15:04:05 MST",
182
+	"01/02/2006 - 15:04",
183
+	"01/02/2006",
184
+	"01-02-2006",
185
+}
186
+
187
+// Parse parses a given date string using a large
188
+// list of commonly found feed date formats.
189
+func Parse(ds string) (t time.Time, err error) {
190
+	d := strings.TrimSpace(ds)
191
+	if d == "" {
192
+		return t, fmt.Errorf("Date string is empty")
193
+	}
194
+
195
+	for _, f := range dateFormats {
196
+		if t, err = time.Parse(f, d); err == nil {
197
+			return
198
+		}
199
+	}
200
+
201
+	err = fmt.Errorf("Failed to parse date: %s", ds)
202
+	return
203
+}

+ 152
- 0
reader/feed/handler.go View File

@@ -0,0 +1,152 @@
1
+// Copyright 2017 Frédéric Guillot. All rights reserved.
2
+// Use of this source code is governed by the Apache 2.0
3
+// license that can be found in the LICENSE file.
4
+
5
+package feed
6
+
7
+import (
8
+	"fmt"
9
+	"github.com/miniflux/miniflux2/errors"
10
+	"github.com/miniflux/miniflux2/helper"
11
+	"github.com/miniflux/miniflux2/model"
12
+	"github.com/miniflux/miniflux2/reader/http"
13
+	"github.com/miniflux/miniflux2/reader/icon"
14
+	"github.com/miniflux/miniflux2/storage"
15
+	"log"
16
+	"time"
17
+)
18
+
19
+var (
20
+	errRequestFailed = "Unable to execute request: %v"
21
+	errServerFailure = "Unable to fetch feed (statusCode=%d)."
22
+	errDuplicate     = "This feed already exists (%s)."
23
+	errNotFound      = "Feed %d not found"
24
+)
25
+
26