Compare commits

...

55 Commits

Author SHA1 Message Date
Frédéric Guillot 0c56b4d580 Update ChangeLog 2021-05-07 16:35:08 -07:00
Frédéric Guillot 32439ca2f0 Security fix: any user can delete any feed
Regression introduced in commit 51fb949.
2021-05-07 16:25:44 -07:00
jacekk fa49bcaf8b Fix password reset via CLI 2021-05-02 20:34:33 -07:00
Frédéric Guillot 89e8e94bf1 Increase default batch size value 2021-04-30 23:02:45 -07:00
Frédéric Guillot 5b8eb4735c Handle RSS feed title with encoded Unicode entities 2021-04-30 22:57:29 -07:00
pennae 1c9f000576 show #unread per category in category list, not #feeds
the number of feeds in the category is currently displayed twice, and a lot less
useful than the number of unread items in the category.
2021-04-26 18:49:32 -07:00
dependabot[bot] de53d8762f Bump github.com/lib/pq from 1.10.0 to 1.10.1
Bumps [github.com/lib/pq](https://github.com/lib/pq) from 1.10.0 to 1.10.1.
- [Release notes](https://github.com/lib/pq/releases)
- [Commits](https://github.com/lib/pq/compare/v1.10.0...v1.10.1)

Signed-off-by: dependabot[bot] <support@github.com>
2021-04-25 23:15:20 -07:00
野辺かない daa56efe27 Fix filtering doesn't work when selecting from multiple found feeds 2021-04-25 21:06:39 -07:00
dependabot[bot] f1db010a68 Bump github.com/tdewolff/minify/v2 from 2.9.15 to 2.9.16
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.9.15 to 2.9.16.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.9.15...v2.9.16)

Signed-off-by: dependabot[bot] <support@github.com>
2021-04-19 20:26:01 -07:00
Vincent Bernat dbb5906767 Use an appropriate color for visited links on dark theme
The contrast between background and purple for visited links was too
low (1.69). Use a brighter purple for the dark theme (contrast 6.87).
2021-04-14 21:28:31 -07:00
yue 18e414ec45 Fix typo in reader/json/doc.go 2021-04-02 19:00:06 -07:00
fguillot fc00a1b3a8 Create SECURITY.md 2021-03-30 20:51:34 -07:00
Benedikt Hopmann 556b74f4fc Update de_DE.json 2021-03-28 17:22:23 -07:00
Frédéric Guillot 6e2e2d1665 Setup golangci-lint Github Action 2021-03-22 21:34:48 -07:00
Darius 9242350f0e Add per feed cookies option 2021-03-22 20:27:58 -07:00
dependabot[bot] b0c14aa8f9 Bump github.com/prometheus/client_golang from 1.9.0 to 1.10.0
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.9.0 to 1.10.0.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.9.0...v1.10.0)

Signed-off-by: dependabot[bot] <support@github.com>
2021-03-22 17:25:02 -07:00
dependabot[bot] 4b2516532e Bump github.com/tdewolff/minify/v2 from 2.9.13 to 2.9.15
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.9.13 to 2.9.15.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.9.13...v2.9.15)

Signed-off-by: dependabot[bot] <support@github.com>
2021-03-22 17:24:36 -07:00
Frédéric Guillot 1aec8d727c Update ChangeLog 2021-03-21 11:29:24 -07:00
Frédéric Guillot e60e0ba3c4 Add workaround to handle some invalid dates 2021-03-21 10:52:27 -07:00
Frédéric Guillot 5877048749 Improve handling of Atom text content with CDATA 2021-03-20 20:47:35 -07:00
Frédéric Guillot c8c1f05328 Add better support of Atom text constructs
- Note that Miniflux does not render entry title with HTML tags as of now
- Omit XHTML div element because it should not be part of the content
2021-03-19 22:05:00 -07:00
Frédéric Guillot 96f3e888cf Handle RDF feed with HTML encoded entry title
Example: http://rss.slashdot.org/Slashdot/slashdotMain
2021-03-19 18:49:51 -07:00
Frédéric Guillot 14888f1cb8 Fix incorrect parsing of Atom entry content of type HTML 2021-03-18 21:43:59 -07:00
Taylan Tatlı 49171c5e8c Turkish language 2021-03-17 21:17:03 -07:00
Frédéric Guillot 51fb9495ad Improve large feed deletion
First patch to avoid the app hanging when deleting large feeds
2021-03-16 22:22:50 -07:00
Ilya Mateyko 89c1b3b4d8 Systemd readiness notification
This change implements the systemd readiness notification, using
the sd_notify protocol.

See https://www.freedesktop.org/software/systemd/man/sd_notify.html.
2021-03-16 20:14:43 -07:00
Gabriel Augendre 1d80c12e18 Prevent Youtube scraping if entry already exists 2021-03-08 20:10:53 -08:00
dependabot[bot] d092fc8436 Bump github.com/lib/pq from 1.9.0 to 1.10.0
Bumps [github.com/lib/pq](https://github.com/lib/pq) from 1.9.0 to 1.10.0.
- [Release notes](https://github.com/lib/pq/releases)
- [Commits](https://github.com/lib/pq/compare/v1.9.0...v1.10.0)

Signed-off-by: dependabot[bot] <support@github.com>
2021-03-07 23:01:21 -08:00
Frédéric Guillot 8a812cd8ec Add basic PWA offline page
- Remove feed_icons cache because it's causing more problems that it solve.
- Add basic offline mode when using the service worker.
- Starting in Chrome 93, offline mode is going to be a requirement to install the PWA.

https://developer.chrome.com/blog/improved-pwa-offline-detection/#enforcement-starting-chrome-93-august-2021
2021-03-07 15:44:42 -08:00
Frédéric Guillot ae13b4e420 Replace icon for "Add to home screen" button 2021-03-07 12:07:54 -08:00
Frédéric Guillot 548c4d4efe Use SVG icons for toast notifications 2021-03-07 12:03:43 -08:00
Frédéric Guillot f6ed2feab4 Use SVG sprite for icons 2021-03-07 11:18:42 -08:00
1pav b730aa520d Reset scroll position on mark page as read 2021-03-06 18:08:13 -08:00
Alexandros Kosiaris 3d16c3e4e6 Add link to mark all feed entries as read 2021-03-06 17:45:25 -08:00
1pav 0d935a863f Make web app display mode configurable
The change is visible after reinstalling the web app. 

It's not compatible with all browsers.

See https://developer.mozilla.org/en-US/docs/Web/Manifest/display
2021-02-28 13:29:51 -08:00
hykhd 053b1d0f8d Handle RSS feeds with CDATA in author item element 2021-02-28 12:26:52 -08:00
Gabriel Augendre b247f3f089 Add read time on article page 2021-02-24 20:06:11 -08:00
Frédéric Guillot 20ec435d6d Move healthcheck from Dockerfile to docker-compose because it's optional 2021-02-22 21:47:30 -08:00
Frédéric Guillot 53cd369b05 Avoid showing a broken image when there is no feed icon 2021-02-22 21:15:08 -08:00
Frédéric Guillot ec3c604a83 Add option to allow self-signed or invalid certificates 2021-02-21 13:58:52 -08:00
Ilya Mateyko c3f871b49b Use YouTube video duration as read time
This feature works by scraping YouTube website.

To enable it, set the FETCH_YOUTUBE_WATCH_TIME environment variable to
1.

Resolves #972.
2021-02-21 11:13:52 -08:00
Gabriel Augendre e5b2eab727 Send full article content to wallabag 2021-02-21 11:05:29 -08:00
Frédéric Guillot bbf93430b7 Add more extensive healthcheck support
- Add new cli argument: -healthcheck
- Add HEALTHCHECK instruction to Dockerfile
- Update Docker Compose examples
2021-02-20 12:58:04 -08:00
hykhd 3cb04b2c56 update whitelist fix bilibili video 2021-02-20 10:29:42 -08:00
Frédéric Guillot e3c28a6c96 Improve health check endpoint to test database connection 2021-02-19 19:32:13 -08:00
Frédéric Guillot c2571f9f47 Remove completely generated files 2021-02-18 21:50:27 -08:00
Frédéric Guillot 4855fbd13f Use embed package for Javascript bundles instead of generated files 2021-02-18 20:49:06 -08:00
Frédéric Guillot 9569666259 Use embed package for CSS bundles instead of generated files 2021-02-17 22:07:28 -08:00
Frédéric Guillot 42edd357bc Add pull-request template 2021-02-17 20:54:39 -08:00
fguillot b33706006a Update issue templates 2021-02-17 20:46:19 -08:00
Frédéric Guillot 0de80c2ff0 Use embed package for binary assets instead of generated files 2021-02-17 20:15:28 -08:00
Frédéric Guillot 5d65a85bdb Use embed package for translations instead of generated files
Replace "go generate" with the new embed package.
2021-02-16 23:09:01 -08:00
Frédéric Guillot a352aff93b Remove deprecated io/ioutil package
Miniflux now requires at least Go 1.16 and io/util is deprecated.

https://golang.org/doc/go1.16#ioutil
2021-02-16 21:25:21 -08:00
Frédéric Guillot 713d575bad Update CI check for Go 1.16 2021-02-16 21:13:13 -08:00
Rogier Lommers 421bb467d0 Show Postgres version in about page 2021-02-16 07:37:24 -08:00
173 changed files with 2907 additions and 7350 deletions
+10
View File
@@ -0,0 +1,10 @@
---
name: Bug report
about: Create a bug report
title: ''
labels: bug
assignees: ''
---
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
+10
View File
@@ -0,0 +1,10 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: feature request
assignees: ''
---
- [ ] I have read this document: https://miniflux.app/opinionated.html#feature-request
+10
View File
@@ -0,0 +1,10 @@
---
name: Improvement
about: Do you have an idea to improve an existing feature?
title: ''
labels: improvements
assignees: ''
---
@@ -0,0 +1,10 @@
---
name: Installation Issue
about: Do you need help to install Miniflux?
title: ''
labels: installation issue
assignees: ''
---
@@ -0,0 +1,10 @@
---
name: Question / Discussion
about: Open discussions
title: ''
labels: question / discussion
assignees: ''
---
+4
View File
@@ -0,0 +1,4 @@
Do you follow the guidelines?
- [ ] I have tested my changes
- [ ] I read this document: https://miniflux.app/faq.html#pull-request
+6 -48
View File
@@ -5,36 +5,6 @@ on:
- master
jobs:
linters:
name: Linter Check
runs-on: ubuntu-latest
steps:
- name: Set up Go
uses: actions/setup-go@v1
with:
go-version: 1.15
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Checkout
uses: actions/checkout@v1
with:
fetch-depth: 3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install linters
run: |
cd /tmp && go get -u golang.org/x/lint/golint
sudo npm install -g jshint
env:
GO111MODULE: off
- name: Run golint
run: |
export PATH=/home/runner/go/bin:$PATH
make lint
- name: Run jshint
run: jshint ui/static/js/*.js
unit-tests:
name: Unit Tests
runs-on: ${{ matrix.os }}
@@ -42,20 +12,14 @@ jobs:
max-parallel: 4
matrix:
os: [ubuntu-latest, windows-latest, macOS-latest]
go-version: [1.14, 1.15]
go-version: [1.16]
steps:
- name: Set up Go
uses: actions/setup-go@v1
uses: actions/setup-go@v2
with:
go-version: ${{ matrix.go-version }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Checkout
uses: actions/checkout@v1
with:
fetch-depth: 3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: actions/checkout@v2
- name: Run unit tests
run: make test
@@ -74,17 +38,11 @@ jobs:
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- name: Set up Go
uses: actions/setup-go@v1
uses: actions/setup-go@v2
with:
go-version: 1.15
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
go-version: 1.16
- name: Checkout
uses: actions/checkout@v1
with:
fetch-depth: 3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: actions/checkout@v2
- name: Install Postgres client
run: sudo apt-get install -y postgresql-client
- name: Run integration tests
+30
View File
@@ -0,0 +1,30 @@
name: Linters
on:
pull_request:
branches:
- master
jobs:
jshint:
name: Javascript Linter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Install jshint
run: |
sudo npm install -g jshint
- name: Run jshint
run: jshint ui/static/js/*.js
golangci:
name: Golang Linter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
go-version: 1.16
- uses: golangci/golangci-lint-action@v2
with:
args: --skip-dirs tests --disable errcheck --enable sqlclosecheck --enable misspell --enable gofmt --enable goimports --enable whitespace
skip-go-installation: true
+56
View File
@@ -1,3 +1,59 @@
Version 2.0.30 (May 7, 2021)
----------------------------
* Security fix: any user can delete any feed (Regression introduced in commit 51fb949)
* Fix password reset via CLI
* Increase default batch size value
* Handle RSS feed title with encoded Unicode entities
* Show number of unread per category in category list instead of number of feeds
* Bump github.com/lib/pq from 1.10.0 to 1.10.1
* Filtering doesn't work when selecting from multiple found feeds
* Bump github.com/tdewolff/minify/v2 from 2.9.15 to 2.9.16
* Use an appropriate color for visited links on dark theme
* Fix typo in reader/json/doc.go
* Create SECURITY.md
* Setup golangci-lint Github Action
* Add per feed cookies option
* Bump github.com/prometheus/client_golang from 1.9.0 to 1.10.0
* Bump github.com/tdewolff/minify/v2 from 2.9.13 to 2.9.15
Version 2.0.29 (Mar 21, 2021)
-----------------------------
* Miniflux requires at least Go 1.16 now
* Improved support of Atom text constructs
- Improve handling of CDATA in text elements
- Omit XHTML root element because it should not be part of the content
- Fix incorrect parsing of HTML elements
* Handle RDF feed with HTML encoded entry title
* Add Turkish language
* Improve deletion of feeds with lots of entries
* Add support of Systemd readiness notification using the sd_notify protocol
* Remove feed_icons service worker cache because it's causing more problems than it solves (and HTTP cache seems faster)
* Add basic PWA offline page
- Add basic offline mode when using the service worker
- Starting in Chrome 93, offline mode is going to be a requirement to install the PWA
* Replace icon for "Add to home screen" button
* Use SVG icons for "toast" notifications
* Use SVG sprite for icons instead of inline elements
* Reset scroll position on mark page as read
* Add link to mark all feed entries as read
* Make web app display mode configurable (The change is visible after reinstalling the web app)
* Handle RSS feeds with CDATA in author item element
* Add read time on the article page
* Avoid showing a broken image when there is no feed icon
* Add option to allow self-signed or invalid certificates
* Add new config option to scrape YouTube's website to get video duration as read time (disabled by default)
* Send full article content to Wallabag
* Add more extensive health check support
- Improve endpoint to test database connection
- Add new cli argument: -healthcheck
- Update Docker Compose examples
* Update iframe "allow list" to support Bilibili videos
* Remove completely generated files and use embed package to bundle JS/CSS/Images/Translations files into the final binary
* Remove deprecated io/ioutil package
* Show Postgres version in "About" page
Version 2.0.28 (Feb 15, 2021)
-----------------------------
+23 -22
View File
@@ -10,7 +10,7 @@ DEB_IMG_ARCH := amd64
export PGPASSWORD := postgres
.PHONY: generate \
.PHONY: \
miniflux \
linux-amd64 \
linux-arm64 \
@@ -19,6 +19,7 @@ export PGPASSWORD := postgres
linux-armv5 \
linux-x86 \
darwin-amd64 \
darwin-arm64 \
freebsd-amd64 \
freebsd-x86 \
openbsd-amd64 \
@@ -40,61 +41,61 @@ export PGPASSWORD := postgres
debian \
debian-packages
generate:
@ go generate
miniflux: generate
miniflux:
@ go build -ldflags=$(LD_FLAGS) -o $(APP) main.go
linux-amd64: generate
linux-amd64:
@ GOOS=linux GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-linux-amd64 main.go
linux-arm64: generate
linux-arm64:
@ GOOS=linux GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-linux-arm64 main.go
linux-armv7: generate
linux-armv7:
@ GOOS=linux GOARCH=arm GOARM=7 go build -ldflags=$(LD_FLAGS) -o $(APP)-linux-armv7 main.go
linux-armv6: generate
linux-armv6:
@ GOOS=linux GOARCH=arm GOARM=6 go build -ldflags=$(LD_FLAGS) -o $(APP)-linux-armv6 main.go
linux-armv5: generate
linux-armv5:
@ GOOS=linux GOARCH=arm GOARM=5 go build -ldflags=$(LD_FLAGS) -o $(APP)-linux-armv5 main.go
darwin-amd64: generate
darwin-amd64:
@ GOOS=darwin GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-darwin-amd64 main.go
freebsd-amd64: generate
darwin-arm64:
@ GOOS=darwin GOARCH=arm64 go build -ldflags=$(LD_FLAGS) -o $(APP)-darwin-arm64 main.go
freebsd-amd64:
@ GOOS=freebsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-freebsd-amd64 main.go
openbsd-amd64: generate
openbsd-amd64:
@ GOOS=openbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-openbsd-amd64 main.go
windows-amd64: generate
windows-amd64:
@ GOOS=windows GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-windows-amd64 main.go
build: linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-armv5 darwin-amd64 freebsd-amd64 openbsd-amd64 windows-amd64
build: linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-armv5 darwin-amd64 darwin-arm64 freebsd-amd64 openbsd-amd64 windows-amd64
# NOTE: unsupported targets
netbsd-amd64: generate
netbsd-amd64:
@ GOOS=netbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-netbsd-amd64 main.go
linux-x86: generate
linux-x86:
@ GOOS=linux GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-linux-x86 main.go
freebsd-x86: generate
freebsd-x86:
@ GOOS=freebsd GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-freebsd-x86 main.go
netbsd-x86: generate
netbsd-x86:
@ GOOS=netbsd GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-netbsd-x86 main.go
openbsd-x86: generate
openbsd-x86:
@ GOOS=openbsd GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-freebsd-x86 main.go
windows-x86: generate
windows-x86:
@ GOOS=windows GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-windows-x86 main.go
run: generate
run:
@ LOG_DATE_TIME=1 go run main.go -debug
clean:
+9
View File
@@ -0,0 +1,9 @@
# Security Policy
## Supported Versions
Only the latest stable version is supported.
## Reporting a Vulnerability
Send an email to `security AT miniflux DOT net` will all the steps to reproduce the problem.
+2
View File
@@ -29,9 +29,11 @@ func (h *handler) discoverSubscriptions(w http.ResponseWriter, r *http.Request)
subscriptions, finderErr := subscription.FindSubscriptions(
subscriptionDiscoveryRequest.URL,
subscriptionDiscoveryRequest.UserAgent,
subscriptionDiscoveryRequest.Cookie,
subscriptionDiscoveryRequest.Username,
subscriptionDiscoveryRequest.Password,
subscriptionDiscoveryRequest.FetchViaProxy,
subscriptionDiscoveryRequest.AllowSelfSignedCertificates,
)
if finderErr != nil {
json.ServerError(w, r, finderErr)
+39 -5
View File
@@ -10,8 +10,10 @@ import (
"miniflux.app/config"
"miniflux.app/database"
"miniflux.app/locale"
"miniflux.app/logger"
"miniflux.app/storage"
"miniflux.app/ui/static"
"miniflux.app/version"
)
@@ -26,6 +28,7 @@ const (
flagDebugModeHelp = "Show debug logs"
flagConfigFileHelp = "Load configuration file"
flagConfigDumpHelp = "Print parsed configuration values"
flagHealthCheckHelp = `Perform a health check on the given endpoint (the value "auto" try to guess the health check endpoint).`
)
// Parse parses command line arguments.
@@ -42,6 +45,7 @@ func Parse() {
flagDebugMode bool
flagConfigFile string
flagConfigDump bool
flagHealthCheck string
)
flag.BoolVar(&flagInfo, "info", false, flagInfoHelp)
@@ -57,6 +61,7 @@ func Parse() {
flag.StringVar(&flagConfigFile, "config-file", "", flagConfigFileHelp)
flag.StringVar(&flagConfigFile, "c", "", flagConfigFileHelp)
flag.BoolVar(&flagConfigDump, "config-dump", false, flagConfigDumpHelp)
flag.StringVar(&flagHealthCheck, "healthcheck", "", flagHealthCheckHelp)
flag.Parse()
cfg := config.NewParser()
@@ -86,6 +91,11 @@ func Parse() {
logger.EnableDebug()
}
if flagHealthCheck != "" {
doHealthCheck(flagHealthCheck)
return
}
if flagInfo {
info()
return
@@ -100,22 +110,46 @@ func Parse() {
logger.Info("The default value for DATABASE_URL is used")
}
logger.Debug("Loading translations...")
if err := locale.LoadCatalogMessages(); err != nil {
logger.Fatal("Unable to load translations: %v", err)
}
logger.Debug("Loading static assets...")
if err := static.CalculateBinaryFileChecksums(); err != nil {
logger.Fatal("Unable to calculate binary files checksum: %v", err)
}
if err := static.GenerateStylesheetsBundles(); err != nil {
logger.Fatal("Unable to generate Stylesheet bundles: %v", err)
}
if err := static.GenerateJavascriptBundles(); err != nil {
logger.Fatal("Unable to generate Javascript bundles: %v", err)
}
db, err := database.NewConnectionPool(
config.Opts.DatabaseURL(),
config.Opts.DatabaseMinConns(),
config.Opts.DatabaseMaxConns(),
)
if err != nil {
logger.Fatal("Unable to connect to the database: %v", err)
logger.Fatal("Unable to initialize database connection pool: %v", err)
}
defer db.Close()
if flagMigrate {
database.Migrate(db)
return
store := storage.NewStorage(db)
if err := store.Ping(); err != nil {
logger.Fatal("Unable to connect to the database: %v", err)
}
store := storage.NewStorage(db)
if flagMigrate {
if err := database.Migrate(db); err != nil {
logger.Fatal(`%v`, err)
}
return
}
if flagResetFeedErrors {
store.ResetFeedErrors()
+5
View File
@@ -44,6 +44,11 @@ func startDaemon(store *storage.Storage) {
go collector.GatherStorageMetrics()
}
// Notify systemd that we are ready.
if err := sdNotify(sdNotifyReady); err != nil {
logger.Error("Unable to send readiness notification to systemd: %v", err)
}
<-stop
logger.Info("Shutting down the process...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2021 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 cli // import "miniflux.app/cli"
import (
"net/http"
"time"
"miniflux.app/config"
"miniflux.app/logger"
)
func doHealthCheck(healthCheckEndpoint string) {
if healthCheckEndpoint == "auto" {
healthCheckEndpoint = "http://" + config.Opts.ListenAddr() + config.Opts.BasePath() + "/healthcheck"
}
logger.Debug(`Executing health check on %s`, healthCheckEndpoint)
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(healthCheckEndpoint)
if err != nil {
logger.Fatal(`Health check failure: %v`, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
logger.Fatal(`Health check failed with status code %d`, resp.StatusCode)
}
logger.Debug(`Health check is OK`)
}
+1
View File
@@ -34,6 +34,7 @@ func resetPassword(store *storage.Storage) {
os.Exit(1)
}
user.Password = password
if err := store.UpdateUser(user); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
+42
View File
@@ -0,0 +1,42 @@
// Copyright 2021 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 cli // import "miniflux.app/cli"
import (
"net"
"os"
)
const (
// sdNotifyReady tells the service manager that service startup is
// finished, or the service finished loading its configuration.
sdNotifyReady = "READY=1"
)
// sdNotify sends a message to systemd using the sd_notify protocol.
// See https://www.freedesktop.org/software/systemd/man/sd_notify.html.
func sdNotify(state string) error {
addr := &net.UnixAddr{
Net: "unixgram",
Name: os.Getenv("NOTIFY_SOCKET"),
}
if addr.Name == "" {
// We're not running under systemd (NOTIFY_SOCKET has not set).
return nil
}
conn, err := net.DialUnix(addr.Net, nil, addr)
if err != nil {
return err
}
defer conn.Close()
if _, err = conn.Write([]byte(state)); err != nil {
return err
}
return nil
}
+2 -2
View File
@@ -20,7 +20,7 @@ package main
import (
"fmt"
"io/ioutil"
"os"
miniflux "miniflux.app/client"
)
@@ -47,7 +47,7 @@ func main() {
return
}
err = ioutil.WriteFile("opml.xml", opml, 0644)
err = os.WriteFile("opml.xml", opml, 0644)
if err != nil {
fmt.Println(err)
return
+2 -3
View File
@@ -8,7 +8,6 @@ import (
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/url"
"strconv"
)
@@ -270,7 +269,7 @@ func (c *Client) Export() ([]byte, error) {
}
defer body.Close()
opml, err := ioutil.ReadAll(body)
opml, err := io.ReadAll(body)
if err != nil {
return nil, err
}
@@ -346,7 +345,7 @@ func (c *Client) MarkFeedAsRead(feedID int64) error {
// RefreshAllFeeds refreshes all feeds.
func (c *Client) RefreshAllFeeds() error {
_, err := c.request.Put(fmt.Sprintf("/v1/feeds/refresh"), nil)
_, err := c.request.Put("/v1/feeds/refresh", nil)
return err
}
+58 -50
View File
@@ -34,6 +34,7 @@ type User struct {
ShowReadingTime bool `json:"show_reading_time"`
EntrySwipe bool `json:"entry_swipe"`
LastLoginAt *time.Time `json:"last_login_at"`
DisplayMode string `json:"display_mode"`
}
func (u User) String() string {
@@ -65,6 +66,7 @@ type UserModificationRequest struct {
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
ShowReadingTime *bool `json:"show_reading_time"`
EntrySwipe *bool `json:"entry_swipe"`
DisplayMode *string `json:"display_mode"`
}
// Users represents a list of users.
@@ -100,64 +102,70 @@ type Subscriptions []*Subscription
// Feed represents a Miniflux feed.
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"`
Disabled bool `json:"disabled"`
IgnoreHTTPCache bool `json:"ignore_http_cache"`
FetchViaProxy bool `json:"fetch_via_proxy"`
ScraperRules string `json:"scraper_rules"`
RewriteRules string `json:"rewrite_rules"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
Crawler bool `json:"crawler"`
UserAgent string `json:"user_agent"`
Username string `json:"username"`
Password string `json:"password"`
Category *Category `json:"category,omitempty"`
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"`
Disabled bool `json:"disabled"`
IgnoreHTTPCache bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
FetchViaProxy bool `json:"fetch_via_proxy"`
ScraperRules string `json:"scraper_rules"`
RewriteRules string `json:"rewrite_rules"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
Crawler bool `json:"crawler"`
UserAgent string `json:"user_agent"`
Cookie string `json:"cookie"`
Username string `json:"username"`
Password string `json:"password"`
Category *Category `json:"category,omitempty"`
}
// FeedCreationRequest represents the request to create a feed.
type FeedCreationRequest struct {
FeedURL string `json:"feed_url"`
CategoryID int64 `json:"category_id"`
UserAgent string `json:"user_agent"`
Username string `json:"username"`
Password string `json:"password"`
Crawler bool `json:"crawler"`
Disabled bool `json:"disabled"`
IgnoreHTTPCache bool `json:"ignore_http_cache"`
FetchViaProxy bool `json:"fetch_via_proxy"`
ScraperRules string `json:"scraper_rules"`
RewriteRules string `json:"rewrite_rules"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
FeedURL string `json:"feed_url"`
CategoryID int64 `json:"category_id"`
UserAgent string `json:"user_agent"`
Cookie string `json:"cookie"`
Username string `json:"username"`
Password string `json:"password"`
Crawler bool `json:"crawler"`
Disabled bool `json:"disabled"`
IgnoreHTTPCache bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
FetchViaProxy bool `json:"fetch_via_proxy"`
ScraperRules string `json:"scraper_rules"`
RewriteRules string `json:"rewrite_rules"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
}
// FeedModificationRequest represents the request to update a feed.
type FeedModificationRequest struct {
FeedURL *string `json:"feed_url"`
SiteURL *string `json:"site_url"`
Title *string `json:"title"`
ScraperRules *string `json:"scraper_rules"`
RewriteRules *string `json:"rewrite_rules"`
BlocklistRules *string `json:"blocklist_rules"`
KeeplistRules *string `json:"keeplist_rules"`
Crawler *bool `json:"crawler"`
UserAgent *string `json:"user_agent"`
Username *string `json:"username"`
Password *string `json:"password"`
CategoryID *int64 `json:"category_id"`
Disabled *bool `json:"disabled"`
IgnoreHTTPCache *bool `json:"ignore_http_cache"`
FetchViaProxy *bool `json:"fetch_via_proxy"`
FeedURL *string `json:"feed_url"`
SiteURL *string `json:"site_url"`
Title *string `json:"title"`
ScraperRules *string `json:"scraper_rules"`
RewriteRules *string `json:"rewrite_rules"`
BlocklistRules *string `json:"blocklist_rules"`
KeeplistRules *string `json:"keeplist_rules"`
Crawler *bool `json:"crawler"`
UserAgent *string `json:"user_agent"`
Cookie *string `json:"cookie"`
Username *string `json:"username"`
Password *string `json:"password"`
CategoryID *int64 `json:"category_id"`
Disabled *bool `json:"disabled"`
IgnoreHTTPCache *bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates *bool `json:"allow_self_signed_certificates"`
FetchViaProxy *bool `json:"fetch_via_proxy"`
}
// FeedIcon represents the feed icon.
+3 -4
View File
@@ -10,7 +10,6 @@ import (
"errors"
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"net/url"
@@ -83,11 +82,11 @@ func (r *request) execute(method, path string, data interface{}) (io.ReadCloser,
}
if data != nil {
switch data.(type) {
switch data := data.(type) {
case io.ReadCloser:
request.Body = data.(io.ReadCloser)
request.Body = data
default:
request.Body = ioutil.NopCloser(bytes.NewBuffer(r.toJSON(data)))
request.Body = io.NopCloser(bytes.NewBuffer(r.toJSON(data)))
}
}
+19 -2
View File
@@ -5,7 +5,6 @@
package config // import "miniflux.app/config"
import (
"io/ioutil"
"os"
"testing"
)
@@ -1309,7 +1308,7 @@ DEBUG = yes
Invalid text
`)
tmpfile, err := ioutil.TempFile(".", "miniflux.*.unit_test.conf")
tmpfile, err := os.CreateTemp(".", "miniflux.*.unit_test.conf")
if err != nil {
t.Fatal(err)
}
@@ -1414,3 +1413,21 @@ func TestAuthProxyUserCreationAdmin(t *testing.T) {
t.Fatalf(`Unexpected AUTH_PROXY_USER_CREATION value, got %v instead of %v`, result, expected)
}
}
func TestFetchYouTubeWatchTime(t *testing.T) {
os.Clearenv()
os.Setenv("FETCH_YOUTUBE_WATCH_TIME", "1")
parser := NewParser()
opts, err := parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
expected := true
result := opts.FetchYouTubeWatchTime()
if result != expected {
t.Fatalf(`Unexpected FETCH_YOUTUBE_WATCH_TIME value, got %v instead of %v`, result, expected)
}
}
+11 -1
View File
@@ -25,7 +25,7 @@ const (
defaultBasePath = ""
defaultWorkerPoolSize = 5
defaultPollingFrequency = 60
defaultBatchSize = 10
defaultBatchSize = 100
defaultPollingScheduler = "round_robin"
defaultSchedulerEntryFrequencyMinInterval = 5
defaultSchedulerEntryFrequencyMaxInterval = 24 * 60
@@ -43,6 +43,7 @@ const (
defaultCleanupArchiveUnreadDays = 180
defaultCleanupRemoveSessionsDays = 30
defaultProxyImages = "http-only"
defaultFetchYouTubeWatchTime = false
defaultCreateAdmin = false
defaultAdminUsername = ""
defaultAdminPassword = ""
@@ -108,6 +109,7 @@ type Options struct {
adminUsername string
adminPassword string
proxyImages string
fetchYouTubeWatchTime bool
oauth2UserCreationAllowed bool
oauth2ClientID string
oauth2ClientSecret string
@@ -162,6 +164,7 @@ func NewOptions() *Options {
workerPoolSize: defaultWorkerPoolSize,
createAdmin: defaultCreateAdmin,
proxyImages: defaultProxyImages,
fetchYouTubeWatchTime: defaultFetchYouTubeWatchTime,
oauth2UserCreationAllowed: defaultOAuth2UserCreation,
oauth2ClientID: defaultOAuth2ClientID,
oauth2ClientSecret: defaultOAuth2ClientSecret,
@@ -373,6 +376,12 @@ func (o *Options) AdminPassword() string {
return o.adminPassword
}
// FetchYouTubeWatchTime returns true if the YouTube video duration
// should be fetched and used as a reading time.
func (o *Options) FetchYouTubeWatchTime() bool {
return o.fetchYouTubeWatchTime
}
// ProxyImages returns "none" to never proxy, "http-only" to proxy non-HTTPS, "all" to always proxy.
func (o *Options) ProxyImages() string {
return o.proxyImages
@@ -469,6 +478,7 @@ func (o *Options) SortedOptions() []*Option {
"DATABASE_MIN_CONNS": o.databaseMinConns,
"DATABASE_URL": o.databaseURL,
"DEBUG": o.debug,
"FETCH_YOUTUBE_WATCH_TIME": o.fetchYouTubeWatchTime,
"HSTS": o.hsts,
"HTTPS": o.HTTPS,
"HTTP_CLIENT_MAX_BODY_SIZE": o.httpClientMaxBodySize,
+3 -2
View File
@@ -10,7 +10,6 @@ import (
"errors"
"fmt"
"io"
"io/ioutil"
url_parser "net/url"
"os"
"strconv"
@@ -188,6 +187,8 @@ func (p *Parser) parseLines(lines []string) (err error) {
p.opts.metricsRefreshInterval = parseInt(value, defaultMetricsRefreshInterval)
case "METRICS_ALLOWED_NETWORKS":
p.opts.metricsAllowedNetworks = parseStringList(value, []string{defaultMetricsAllowedNetworks})
case "FETCH_YOUTUBE_WATCH_TIME":
p.opts.fetchYouTubeWatchTime = parseBool(value, defaultFetchYouTubeWatchTime)
}
}
@@ -269,7 +270,7 @@ func parseStringList(value string, fallback []string) []string {
}
func readSecretFile(filename, fallback string) string {
data, err := ioutil.ReadFile(filename)
data, err := os.ReadFile(filename)
if err != nil {
return fallback
}
+11 -1
View File
@@ -3,16 +3,22 @@ services:
miniflux:
image: miniflux/miniflux:latest
container_name: miniflux
restart: always
ports:
- "80:8080"
depends_on:
- db
db:
condition: service_healthy
environment:
- DATABASE_URL=postgres://miniflux:secret@db/miniflux?sslmode=disable
- RUN_MIGRATIONS=1
- CREATE_ADMIN=1
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=test123
- DEBUG=1
# Optional health check:
# healthcheck:
# test: ["CMD", "/usr/bin/miniflux", "-healthcheck", "auto"]
db:
image: postgres:latest
container_name: postgres
@@ -21,5 +27,9 @@ services:
- POSTGRES_PASSWORD=secret
volumes:
- miniflux-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "miniflux"]
interval: 10s
start_period: 30s
volumes:
miniflux-db:
+6 -1
View File
@@ -16,7 +16,8 @@ services:
image: miniflux/miniflux:latest
container_name: miniflux
depends_on:
- db
db:
condition: service_healthy
environment:
- DATABASE_URL=postgres://miniflux:secret@db/miniflux?sslmode=disable
- RUN_MIGRATIONS=1
@@ -32,6 +33,10 @@ services:
- POSTGRES_PASSWORD=secret
volumes:
- miniflux-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "miniflux"]
interval: 10s
start_period: 30s
volumes:
miniflux-db:
caddy_data:
+6 -1
View File
@@ -21,7 +21,8 @@ services:
image: miniflux/miniflux:latest
container_name: miniflux
depends_on:
- db
db:
condition: service_healthy
expose:
- "8080"
environment:
@@ -44,5 +45,9 @@ services:
- POSTGRES_PASSWORD=secret
volumes:
- miniflux-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "miniflux"]
interval: 10s
start_period: 30s
volumes:
miniflux-db:
+20
View File
@@ -10,6 +10,7 @@ import (
var schemaVersion = len(migrations)
// Order is important. Add new migrations at the end of the list.
var migrations = []func(tx *sql.Tx) error{
func(tx *sql.Tx) (err error) {
sql := `
@@ -514,4 +515,23 @@ var migrations = []func(tx *sql.Tx) error{
`)
return err
},
func(tx *sql.Tx) (err error) {
_, err = tx.Exec(`
ALTER TABLE feeds ADD COLUMN allow_self_signed_certificates boolean not null default false
`)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
CREATE TYPE webapp_display_mode AS enum('fullscreen', 'standalone', 'minimal-ui', 'browser');
ALTER TABLE users ADD COLUMN display_mode webapp_display_mode default 'standalone';
`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE feeds ADD COLUMN cookie text default ''`
_, err = tx.Exec(sql)
return err
},
}
-221
View File
@@ -1,221 +0,0 @@
// 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"
"github.com/tdewolff/minify/v2"
"github.com/tdewolff/minify/v2/css"
"github.com/tdewolff/minify/v2/js"
)
const tpl = `// Code generated by go generate; DO NOT EDIT.
package {{ .Package }} // import "miniflux.app/{{ .ImportPath }}"
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 bundleTpl = template.Must(template.New("").Parse(tpl))
type Bundle struct {
Package string
Map string
ImportPath string
Files map[string]string
Checksums map[string]string
}
func (b *Bundle) Write(filename string) {
f, err := os.Create(filename)
if err != nil {
panic(err)
}
defer f.Close()
bundleTpl.Execute(f, b)
}
func NewBundle(pkg, mapName, importPath string) *Bundle {
return &Bundle{
Package: pkg,
Map: mapName,
ImportPath: importPath,
Files: make(map[string]string),
Checksums: make(map[string]string),
}
}
func readFile(filename string) []byte {
data, err := ioutil.ReadFile(filename)
if err != nil {
panic(err)
}
return data
}
func checksum(data []byte) string {
return fmt.Sprintf("%x", sha256.Sum256(data))
}
func basename(filename string) string {
return path.Base(filename)
}
func stripExtension(filename string) string {
filename = strings.TrimSuffix(filename, path.Ext(filename))
return strings.Replace(filename, " ", "_", -1)
}
func glob(pattern string) []string {
// There is no Glob function in path package, so we have to use filepath and replace in case of Windows
files, _ := filepath.Glob(pattern)
for i := range files {
if strings.Contains(files[i], "\\") {
files[i] = strings.Replace(files[i], "\\", "/", -1)
}
}
return files
}
func concat(files []string) string {
var b strings.Builder
for _, file := range files {
b.Write(readFile(file))
}
return b.String()
}
func generateJSBundle(bundleFile string, bundleFiles map[string][]string, prefixes, suffixes map[string]string) {
bundle := NewBundle("static", "Javascripts", "ui/static")
m := minify.New()
m.AddFunc("text/javascript", js.Minify)
for name, srcFiles := range bundleFiles {
var b strings.Builder
if prefix, found := prefixes[name]; found {
b.WriteString(prefix)
}
b.WriteString(concat(srcFiles))
if suffix, found := suffixes[name]; found {
b.WriteString(suffix)
}
minifiedData, err := m.String("text/javascript", b.String())
if err != nil {
panic(err)
}
bundle.Files[name] = minifiedData
bundle.Checksums[name] = checksum([]byte(minifiedData))
}
bundle.Write(bundleFile)
}
func generateCSSBundle(bundleFile string, themes map[string][]string) {
bundle := NewBundle("static", "Stylesheets", "ui/static")
m := minify.New()
m.AddFunc("text/css", css.Minify)
for theme, srcFiles := range themes {
data := concat(srcFiles)
minifiedData, err := m.String("text/css", data)
if err != nil {
panic(err)
}
bundle.Files[theme] = minifiedData
bundle.Checksums[theme] = checksum([]byte(minifiedData))
}
bundle.Write(bundleFile)
}
func generateBinaryBundle(bundleFile string, srcFiles []string) {
bundle := NewBundle("static", "Binaries", "ui/static")
for _, srcFile := range srcFiles {
data := readFile(srcFile)
filename := basename(srcFile)
encodedData := base64.StdEncoding.EncodeToString(data)
bundle.Files[filename] = string(encodedData)
bundle.Checksums[filename] = checksum(data)
}
bundle.Write(bundleFile)
}
func generateBundle(bundleFile, pkg, mapName string, srcFiles []string) {
bundle := NewBundle(pkg, mapName, pkg)
for _, srcFile := range srcFiles {
data := readFile(srcFile)
filename := stripExtension(basename(srcFile))
bundle.Files[filename] = string(data)
bundle.Checksums[filename] = checksum(data)
}
bundle.Write(bundleFile)
}
func main() {
generateJSBundle("ui/static/js.go", map[string][]string{
"app": []string{
"ui/static/js/dom_helper.js",
"ui/static/js/touch_handler.js",
"ui/static/js/keyboard_handler.js",
"ui/static/js/request_builder.js",
"ui/static/js/modal_handler.js",
"ui/static/js/app.js",
"ui/static/js/bootstrap.js",
},
"service-worker": []string{
"ui/static/js/service_worker.js",
},
}, map[string]string{
"app": "(function(){'use strict';",
"sw": "'use strict';",
}, map[string]string{
"app": "})();",
})
generateCSSBundle("ui/static/css.go", map[string][]string{
"light_serif": []string{"ui/static/css/light.css", "ui/static/css/serif.css", "ui/static/css/common.css"},
"light_sans_serif": []string{"ui/static/css/light.css", "ui/static/css/sans_serif.css", "ui/static/css/common.css"},
"dark_serif": []string{"ui/static/css/dark.css", "ui/static/css/serif.css", "ui/static/css/common.css"},
"dark_sans_serif": []string{"ui/static/css/dark.css", "ui/static/css/sans_serif.css", "ui/static/css/common.css"},
"system_serif": []string{"ui/static/css/system.css", "ui/static/css/serif.css", "ui/static/css/common.css"},
"system_sans_serif": []string{"ui/static/css/system.css", "ui/static/css/sans_serif.css", "ui/static/css/common.css"},
})
generateBinaryBundle("ui/static/bin.go", glob("ui/static/bin/*"))
generateBundle("template/views.go", "template", "templateViewsMap", glob("template/html/*.html"))
generateBundle("template/common.go", "template", "templateCommonMap", glob("template/html/common/*.html"))
generateBundle("locale/translations.go", "locale", "translations", glob("locale/translations/*.json"))
}
+5 -5
View File
@@ -1,6 +1,6 @@
module miniflux.app
// +heroku goVersion go1.15
// +heroku goVersion go1.16
require (
github.com/PuerkitoBio/goquery v1.6.1
@@ -8,13 +8,13 @@ require (
github.com/felixge/httpsnoop v1.0.1 // indirect
github.com/golang/gddo v0.0.0-20200831202555-721e228c7686 // indirect
github.com/gorilla/mux v1.8.0
github.com/lib/pq v1.9.0
github.com/lib/pq v1.10.1
github.com/mitchellh/go-server-timing v1.0.2-0.20201108055052-feb680ab92c2
github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 // indirect
github.com/prometheus/client_golang v1.9.0
github.com/prometheus/client_golang v1.10.0
github.com/rylans/getlang v0.0.0-20200505200108-4c3188ff8a2d
github.com/stretchr/testify v1.6.1 // indirect
github.com/tdewolff/minify/v2 v2.9.11 // indirect
github.com/tdewolff/minify/v2 v2.9.16
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
golang.org/x/net v0.0.0-20201029221708-28c70e62bb1d
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
@@ -24,4 +24,4 @@ require (
mvdan.cc/xurls/v2 v2.2.0
)
go 1.15
go 1.16
+19 -33
View File
@@ -63,7 +63,6 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:ma
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -79,7 +78,6 @@ github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4s
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/felixge/httpsnoop v1.0.0 h1:gh8fMGz0rlOv/1WmRZm7OgncIOTsAj21iNJot48omJQ=
github.com/felixge/httpsnoop v1.0.0/go.mod h1:3+D9sFq0ahK/JeJPhCBUV1xlf4/eIYrUQaxulT0VzX8=
github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ=
github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
@@ -104,7 +102,6 @@ github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFG
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
github.com/golang/gddo v0.0.0-20180823221919-9d8ff1c67be5 h1:yrv1uUvgXH/tEat+wdvJMRJ4g51GlIydtDpU9pFjaaI=
github.com/golang/gddo v0.0.0-20180823221919-9d8ff1c67be5/go.mod h1:xEhNfoBDX1hzLm2Nf80qUvZ2sVwoMZ8d6IE2SrsQfh4=
github.com/golang/gddo v0.0.0-20200831202555-721e228c7686 h1:5vu7C+63KTbsSNnLhrgB98Sqy8MNVSW8FdhkcWA/3Rk=
github.com/golang/gddo v0.0.0-20200831202555-721e228c7686/go.mod h1:sam69Hju0uq+5uvLJUMDlsKlQ21Vrs1Kd/1YFPNYdOU=
@@ -114,7 +111,6 @@ github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4er
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/lint v0.0.0-20170918230701-e5d664eb928e/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -122,10 +118,8 @@ github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0 h1:oOuy+ugB+P/kBdUnG5QaMXSIyJ1q38wWSojYCb3z5VQ=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
@@ -137,11 +131,11 @@ github.com/google/go-cmp v0.1.1-0.20171103154506-982329095285/go.mod h1:oXzfMopK
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0 h1:/QaMHBdZ26BB3SSst0Iwl10Epc+xhTquomWX0oZEB6w=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4 h1:L8R9j+yAqZuZjsqh/z+F1NCffTKKLShY6zXTItVIZ8M=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-dap v0.2.0/go.mod h1:5q8aYQFnHOAZEMP+6vmq25HKYAEwE+LF5yh7JKrrhSQ=
github.com/google/go-dap v0.3.0/go.mod h1:5q8aYQFnHOAZEMP+6vmq25HKYAEwE+LF5yh7JKrrhSQ=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -205,15 +199,12 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lib/pq v1.9.0 h1:L8nSXQQzAYByakOFMTwpjRoHsMJklur4Gi59b6VivR8=
github.com/lib/pq v1.9.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.1 h1:6VXZrLU0jHBYyAqrSPa+MgPfnSvTPuMgK+k0o5kVFWo=
github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM=
github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4=
github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=
@@ -298,10 +289,9 @@ github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod
github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og=
github.com/prometheus/client_golang v1.7.1 h1:NTGy1Ja9pByO+xAeH/qiWnLrKtr3hJPNjaVUwnjpdpA=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
github.com/prometheus/client_golang v1.9.0 h1:Rrch9mh17XcxvEu9D9DEpb4isxjGBtcevQjKvxPRQIU=
github.com/prometheus/client_golang v1.9.0/go.mod h1:FqZLKOZnGdFAhOK4nqGHa7D66IdsO+O441Eve7ptJDU=
github.com/prometheus/client_golang v1.10.0 h1:/o0BDeWzLWXNZ+4q5gXltUvaMpJqckTa+jTNoB+z4cg=
github.com/prometheus/client_golang v1.10.0/go.mod h1:WJM3cc3yu7XKBKa/I8WeZm+V3eltZnBwfENSU7mdogU=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
@@ -314,19 +304,17 @@ github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y8
github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA=
github.com/prometheus/common v0.10.0 h1:RyRA7RzGXQZiW+tGMr7sxa85G1z0yOpM1qq5c8lNawc=
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
github.com/prometheus/common v0.15.0 h1:4fgOnadei3EZvgRwxJ7RMpG1k1pOZth5Pc13tyspaKM=
github.com/prometheus/common v0.15.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s=
github.com/prometheus/common v0.18.0 h1:WCVKW7aL6LEe1uryfI9dnEc2ZqNB1Fn0ok930v0iL1Y=
github.com/prometheus/common v0.18.0/go.mod h1:U+gB1OBLb1lF3O42bTCL+FK18tX9Oar16Clt/msog/s=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/prometheus/procfs v0.1.3 h1:F0+tqvhOksq22sc6iCHF5WGlWjdwj92p0udFh1VFBS8=
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.2.0 h1:wH4vA7pcjKuZzjF7lM8awk4fnuJO6idemZXoKnULUx4=
github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.6.0 h1:mxy4L2jP6qMonqmq+aTtOx1ifVWUgG/TAmntgbh3xv4=
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
@@ -376,12 +364,11 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tdewolff/minify v1.1.0 h1:nxHQi1ML+g3ZbZHffiZ6eC7vMqNvSRfX3KB5Y5y/kfw=
github.com/tdewolff/minify v2.3.6+incompatible h1:2hw5/9ZvxhWLvBUnHE06gElGYz+Jv9R4Eys0XUzItYo=
github.com/tdewolff/minify/v2 v2.9.11 h1:8o6hclGwxm6MNwTPHabvdND5SghhHs0bn+3/+uAf0yQ=
github.com/tdewolff/minify/v2 v2.9.11/go.mod h1:YZk0lGOc6CvQrqvm5f7V3ihaq3QUd9acS4HESdVDOaM=
github.com/tdewolff/parse/v2 v2.5.8 h1:vutkOO9Xi3DehIzCLHqvMM2hFXo54S0iDvIG/hYznnE=
github.com/tdewolff/parse/v2 v2.5.8/go.mod h1:WzaJpRSbwq++EIQHYIRTpbYKNA3gn9it1Ik++q4zyho=
github.com/tdewolff/minify/v2 v2.9.16 h1:2Pv8pFRX/ZfjTRYX2xzcuNrkEJqU5TfriNJJYOeN3rI=
github.com/tdewolff/minify/v2 v2.9.16/go.mod h1:cjMkr4ZgFjqxXAQ1kR9Fm4l1046mmONd2g6yMzGuN/w=
github.com/tdewolff/parse/v2 v2.5.14 h1:ftdD54vkOeLZ7VkEZxp+wZrYZyyPi43GGon5GwBTRUI=
github.com/tdewolff/parse/v2 v2.5.14/go.mod h1:WzaJpRSbwq++EIQHYIRTpbYKNA3gn9it1Ik++q4zyho=
github.com/tdewolff/test v1.0.6 h1:76mzYJQ83Op284kMT+63iCNCI7NEERsIN8dLM+RiKr4=
github.com/tdewolff/test v1.0.6/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
@@ -443,7 +430,6 @@ golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200625001655-4c5254603344 h1:vGXIOMxbNfDTk/aXCmfdLgkrSV+Z2tcbze+pEc3v5W4=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201029221708-28c70e62bb1d h1:dOiJ2n2cMwGLce/74I/QHMbnpk5GfY7InR8rczoMqRM=
golang.org/x/net v0.0.0-20201029221708-28c70e62bb1d/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
@@ -459,6 +445,7 @@ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -486,10 +473,10 @@ golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200724161237-0e2f3a69832c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e h1:AyodaIpKjppX+cBfTASF2E1US3H2JFBj920Ot3rtDjs=
golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210309074719-68d13333faf2 h1:46ULzRKLh1CwgRq2dC5SlBzEqqNCi8rreOZnNrbqcIY=
golang.org/x/sys v0.0.0-20210309074719-68d13333faf2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -554,7 +541,6 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+25 -6
View File
@@ -6,11 +6,11 @@ package client // import "miniflux.app/http/client"
import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net"
"net/http"
"net/url"
@@ -47,13 +47,15 @@ type Client struct {
requestUsername string
requestPassword string
requestUserAgent string
requestCookie string
useProxy bool
doNotFollowRedirects bool
ClientTimeout int
ClientMaxBodySize int64
ClientProxyURL string
ClientTimeout int
ClientMaxBodySize int64
ClientProxyURL string
AllowSelfSignedCertificates bool
}
// New initializes a new HTTP client.
@@ -88,13 +90,14 @@ func (c *Client) String() string {
}
return fmt.Sprintf(
`InputURL=%q RequestURL=%q ETag=%s LastModified=%s Auth=%v UserAgent=%q`,
`InputURL=%q RequestURL=%q ETag=%s LastMod=%s Auth=%v UserAgent=%q Verify=%v`,
c.inputURL,
c.requestURL,
etagHeader,
lastModifiedHeader,
c.requestAuthorizationHeader != "" || (c.requestUsername != "" && c.requestPassword != ""),
c.requestUserAgent,
!c.AllowSelfSignedCertificates,
)
}
@@ -140,6 +143,14 @@ func (c *Client) WithUserAgent(userAgent string) *Client {
return c
}
// WithCookie defines the Cookies to use for HTTP requests.
func (c *Client) WithCookie(cookie string) *Client {
if cookie != "" {
c.requestCookie = cookie
}
return c
}
// Get performs a GET HTTP request.
func (c *Client) Get() (*Response, error) {
request, err := c.buildRequest(http.MethodGet, nil)
@@ -219,7 +230,7 @@ func (c *Client) executeRequest(request *http.Request) (*Response, error) {
return nil, fmt.Errorf("client: response too large (%d bytes)", resp.ContentLength)
}
buf, err := ioutil.ReadAll(resp.Body)
buf, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("client: error while reading body %v", err)
}
@@ -289,6 +300,10 @@ func (c *Client) buildClient() http.Client {
IdleConnTimeout: 10 * time.Second,
}
if c.AllowSelfSignedCertificates {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
if c.doNotFollowRedirects {
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
@@ -330,6 +345,10 @@ func (c *Client) buildHeaders() http.Header {
headers.Add("Authorization", c.requestAuthorizationHeader)
}
if c.requestCookie != "" {
headers.Add("Cookie", c.requestCookie)
}
headers.Add("Connection", "close")
return headers
}
+2 -3
View File
@@ -8,7 +8,6 @@ import (
"bytes"
"fmt"
"io"
"io/ioutil"
"regexp"
"strings"
"unicode/utf8"
@@ -87,7 +86,7 @@ func (r *Response) IsModified(etag, lastModified string) bool {
// - Feeds with encoding specified only in XML document and not in HTTP header
// - Feeds with wrong encoding defined and already in UTF-8
func (r *Response) EnsureUnicodeBody() (err error) {
buffer, err := ioutil.ReadAll(r.Body)
buffer, err := io.ReadAll(r.Body)
if err != nil {
return err
}
@@ -116,6 +115,6 @@ func (r *Response) EnsureUnicodeBody() (err error) {
// BodyAsString returns the response body as string.
func (r *Response) BodyAsString() string {
bytes, _ := ioutil.ReadAll(r.Body)
bytes, _ := io.ReadAll(r.Body)
return string(bytes)
}
+3 -3
View File
@@ -6,7 +6,7 @@ package client // import "miniflux.app/http/client"
import (
"bytes"
"io/ioutil"
"os"
"strings"
"testing"
"unicode/utf8"
@@ -96,7 +96,7 @@ func TestToString(t *testing.T) {
r := &Response{Body: strings.NewReader(input)}
if r.BodyAsString() != input {
t.Error(`Unexpected ouput`)
t.Error(`Unexpected output`)
}
}
@@ -129,7 +129,7 @@ func TestEnsureUnicodeWithHTMLDocuments(t *testing.T) {
}
for _, tc := range unicodeTestCases {
content, err := ioutil.ReadFile("testdata/" + tc.filename)
content, err := os.ReadFile("testdata/" + tc.filename)
if err != nil {
t.Fatalf(`Unable to read file %q: %v`, tc.filename, err)
}
+4 -5
View File
@@ -19,13 +19,12 @@ func Path(router *mux.Router, name string, args ...interface{}) string {
}
var pairs []string
for _, param := range args {
switch param.(type) {
for _, arg := range args {
switch param := arg.(type) {
case string:
pairs = append(pairs, param.(string))
pairs = append(pairs, param)
case int64:
val := param.(int64)
pairs = append(pairs, strconv.FormatInt(val, 10))
pairs = append(pairs, strconv.FormatInt(param, 10))
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
integration.WallabagPassword,
)
if err := client.AddEntry(entry.URL, entry.Title); err != nil {
if err := client.AddEntry(entry.URL, entry.Title, entry.Content); err != nil {
logger.Error("[Integration] UserID #%d: %v", integration.UserID, err)
}
}
+3 -3
View File
@@ -7,7 +7,7 @@ package pocket // import "miniflux.app/integration/pocket"
import (
"errors"
"fmt"
"io/ioutil"
"io"
"net/url"
"miniflux.app/http/client"
@@ -35,7 +35,7 @@ func (c *Connector) RequestToken(redirectURL string) (string, error) {
return "", fmt.Errorf("pocket: unable to fetch request token, status=%d", response.StatusCode)
}
body, err := ioutil.ReadAll(response.Body)
body, err := io.ReadAll(response.Body)
if err != nil {
return "", fmt.Errorf("pocket: unable to read response body: %v", err)
}
@@ -70,7 +70,7 @@ func (c *Connector) AccessToken(requestToken string) (string, error) {
return "", fmt.Errorf("pocket: unable to fetch access token, status=%d", response.StatusCode)
}
body, err := ioutil.ReadAll(response.Body)
body, err := io.ReadAll(response.Body)
if err != nil {
return "", fmt.Errorf("pocket: unable to read response body: %v", err)
}
+5 -4
View File
@@ -23,7 +23,8 @@ type Client struct {
}
// AddEntry sends a link to Wallabag.
func (c *Client) AddEntry(link, title string) error {
// Pass an empty string in `content` to let Wallabag fetch the article content.
func (c *Client) AddEntry(link, title, content string) error {
if c.baseURL == "" || c.clientID == "" || c.clientSecret == "" || c.username == "" || c.password == "" {
return fmt.Errorf("wallabag: missing credentials")
}
@@ -33,10 +34,10 @@ func (c *Client) AddEntry(link, title string) error {
return err
}
return c.createEntry(accessToken, link, title)
return c.createEntry(accessToken, link, title, content)
}
func (c *Client) createEntry(accessToken, link, title string) error {
func (c *Client) createEntry(accessToken, link, title, content string) error {
endpoint, err := getAPIEndpoint(c.baseURL, "/api/entries.json")
if err != nil {
return fmt.Errorf("wallbag: unable to get entries endpoint: %v", err)
@@ -44,7 +45,7 @@ func (c *Client) createEntry(accessToken, link, title string) error {
clt := client.New(endpoint)
clt.WithAuthorization("Bearer " + accessToken)
response, err := clt.PostJSON(map[string]string{"url": link, "title": title})
response, err := clt.PostJSON(map[string]string{"url": link, "title": title, "content": content})
if err != nil {
return fmt.Errorf("wallabag: unable to post entry: %v", err)
}
+30 -11
View File
@@ -5,6 +5,7 @@
package locale // import "miniflux.app/locale"
import (
"embed"
"encoding/json"
"fmt"
)
@@ -14,23 +15,41 @@ type catalog map[string]translationDict
var defaultCatalog catalog
func init() {
//go:embed translations/*.json
var translationFiles embed.FS
// LoadCatalogMessages loads and parses all translations encoded in JSON.
func LoadCatalogMessages() error {
var err error
defaultCatalog = make(catalog)
for language, data := range translations {
messages, err := parseTranslationDict(data)
for language := range AvailableLanguages() {
defaultCatalog[language], err = loadTranslationFile(language)
if err != nil {
panic(err)
return err
}
defaultCatalog[language] = messages
}
return nil
}
func parseTranslationDict(data string) (translationDict, error) {
var translations translationDict
if err := json.Unmarshal([]byte(data), &translations); err != nil {
return nil, fmt.Errorf("invalid translation file: %v", err)
func loadTranslationFile(language string) (translationDict, error) {
translationFileData, err := translationFiles.ReadFile(fmt.Sprintf("translations/%s.json", language))
if err != nil {
return nil, err
}
return translations, nil
translationMessages, err := parseTranslationMessages(translationFileData)
if err != nil {
return nil, err
}
return translationMessages, nil
}
func parseTranslationMessages(data []byte) (translationDict, error) {
var translationMessages translationDict
if err := json.Unmarshal(data, &translationMessages); err != nil {
return nil, fmt.Errorf(`invalid translation file: %w`, err)
}
return translationMessages, nil
}
+59 -2
View File
@@ -7,14 +7,14 @@ package locale // import "miniflux.app/locale"
import "testing"
func TestParserWithInvalidData(t *testing.T) {
_, err := parseTranslationDict(`{`)
_, err := parseTranslationMessages([]byte(`{`))
if err == nil {
t.Fatal(`An error should be returned when parsing invalid data`)
}
}
func TestParser(t *testing.T) {
translations, err := parseTranslationDict(`{"k": "v"}`)
translations, err := parseTranslationMessages([]byte(`{"k": "v"}`))
if err != nil {
t.Fatalf(`Unexpected parsing error: %v`, err)
}
@@ -32,3 +32,60 @@ func TestParser(t *testing.T) {
t.Fatal(`The translation key should contains the defined value`)
}
}
func TestLoadCatalog(t *testing.T) {
if err := LoadCatalogMessages(); err != nil {
t.Fatal(err)
}
}
func TestAllKeysHaveValue(t *testing.T) {
for language := range AvailableLanguages() {
messages, err := loadTranslationFile(language)
if err != nil {
t.Fatalf(`Unable to load translation messages for language %q`, language)
}
if len(messages) == 0 {
t.Fatalf(`The language %q doesn't have any messages`, language)
}
for k, v := range messages {
switch value := v.(type) {
case string:
if value == "" {
t.Errorf(`The key %q for the language %q have an empty string as value`, k, language)
}
case []string:
if len(value) == 0 {
t.Errorf(`The key %q for the language %q have an empty list as value`, k, language)
}
}
}
}
}
func TestMissingTranslations(t *testing.T) {
refLang := "en_US"
references, err := loadTranslationFile(refLang)
if err != nil {
t.Fatal(`Unable to parse reference language`)
}
for language := range AvailableLanguages() {
if language == refLang {
continue
}
messages, err := loadTranslationFile(language)
if err != nil {
t.Fatalf(`Parsing error for language %q`, language)
}
for key := range references {
if _, found := messages[key]; !found {
t.Fatalf(`Translation key %q not found in language %q`, key, language)
}
}
}
}
+1
View File
@@ -18,5 +18,6 @@ func AvailableLanguages() map[string]string {
"ru_RU": "Русский",
"it_IT": "Italiano",
"ja_JP": "日本語",
"tr_TR": "Türkçe",
}
}
+8 -8
View File
@@ -8,12 +8,12 @@ import "testing"
func TestPluralRules(t *testing.T) {
scenarios := map[string]map[int]int{
"default": map[int]int{
"default": {
1: 0,
2: 1,
5: 1,
},
"ar_AR": map[int]int{
"ar_AR": {
0: 0,
1: 1,
2: 2,
@@ -21,32 +21,32 @@ func TestPluralRules(t *testing.T) {
11: 4,
200: 5,
},
"cs_CZ": map[int]int{
"cs_CZ": {
1: 0,
2: 1,
5: 2,
},
"pl_PL": map[int]int{
"pl_PL": {
1: 0,
2: 1,
5: 2,
},
"pt_BR": map[int]int{
"pt_BR": {
1: 0,
2: 1,
5: 1,
},
"ru_RU": map[int]int{
"ru_RU": {
1: 0,
2: 1,
5: 2,
},
"sr_RS": map[int]int{
"sr_RS": {
1: 0,
2: 1,
5: 2,
},
"zh_CN": map[int]int{
"zh_CN": {
1: 0,
5: 0,
},
File diff suppressed because it is too large Load Diff
+13
View File
@@ -92,6 +92,7 @@
"Es gibt %d Abonnement.",
"Es gibt %d Abonnements."
],
"page.categories.unread_counter": "Anzahl der ungelesenen Artikel",
"page.new_category.title": "Neue Kategorie",
"page.new_user.title": "Neuer Benutzer",
"page.edit_category.title": "Kategorie bearbeiten: %s",
@@ -114,6 +115,7 @@
"page.about.author": "Autor:",
"page.about.license": "Lizenz:",
"page.about.global_config_options": "Globale Konfigurationsoptionen",
"page.about.postgres_version": "Postgres Version:",
"page.add_feed.title": "Neues Abonnement",
"page.add_feed.no_category": "Es ist keine Kategorie vorhanden. Wenigstens eine Kategorie muss angelegt sein.",
"page.add_feed.label.url": "URL",
@@ -199,6 +201,9 @@
"page.api_keys.table.actions": "Aktionen",
"page.api_keys.never_used": "Nie benutzt",
"page.new_api_key.title": "Neuer API-Schlüssel",
"page.offline.title": "Offline-Modus",
"page.offline.message": "Du bist offline",
"page.offline.refresh_page": "Versuchen Sie, die Seite zu aktualisieren",
"alert.no_shared_entry": "Es existieren derzeit keine geteilten Artikel.",
"alert.no_bookmark": "Es existiert derzeit kein Lesezeichen.",
"alert.no_category": "Es ist keine Kategorie vorhanden.",
@@ -253,6 +258,7 @@
"error.invalid_language": "Ungültige Sprache.",
"error.invalid_timezone": "Ungültige Zeitzone.",
"error.invalid_entry_direction": "Ungültige Sortierreihenfolge.",
"error.invalid_display_mode": "Ungültiger Web-App-Anzeigemodus.",
"form.feed.label.title": "Titel",
"form.feed.label.site_url": "Webseite-URL",
"form.feed.label.feed_url": "Abonnement-URL",
@@ -261,11 +267,13 @@
"form.feed.label.feed_username": "Benutzername des Abonnements",
"form.feed.label.feed_password": "Passwort des Abonnements",
"form.feed.label.user_agent": "Standardbenutzeragenten überschreiben",
"form.feed.label.cookie": "Cookies setzen",
"form.feed.label.scraper_rules": "Extraktionsregeln",
"form.feed.label.rewrite_rules": "Umschreiberegeln",
"form.feed.label.blocklist_rules": "Blockierregeln",
"form.feed.label.keeplist_rules": "Erlaubnisregeln",
"form.feed.label.ignore_http_cache": "Ignoriere HTTP-cache",
"form.feed.label.allow_self_signed_certificates": "Erlaube selbstsignierte oder ungültige Zertifikate",
"form.feed.label.fetch_via_proxy": "Über Proxy abrufen",
"form.feed.label.disabled": "Dieses Abonnement nicht aktualisieren",
"form.category.label.title": "Titel",
@@ -278,8 +286,13 @@
"form.prefs.label.theme": "Thema",
"form.prefs.label.entry_sorting": "Sortierung der Artikel",
"form.prefs.label.entries_per_page": "Einträge pro Seite",
"form.prefs.label.display_mode": "Anzeigemodus der Web-App (muss neu installiert werden)",
"form.prefs.select.older_first": "Älteste Artikel zuerst",
"form.prefs.select.recent_first": "Neueste Artikel zuerst",
"form.prefs.select.fullscreen": "Vollbildschirm",
"form.prefs.select.standalone": "Eigenständige",
"form.prefs.select.minimal_ui": "Minimal",
"form.prefs.select.browser": "Browser",
"form.prefs.label.keyboard_shortcuts": "Tastaturkürzel aktivieren",
"form.prefs.label.entry_swipe": "Wischgeste für Einträge auf dem Handy aktivieren",
"form.prefs.label.show_reading_time": "Geschätzte Lesezeit für Artikel anzeigen",
+13
View File
@@ -92,6 +92,7 @@
"There is %d feed.",
"There are %d feeds."
],
"page.categories.unread_counter": "Number of unread entries",
"page.new_category.title": "New Category",
"page.new_user.title": "New User",
"page.edit_category.title": "Edit Category: %s",
@@ -114,6 +115,7 @@
"page.about.author": "Author:",
"page.about.license": "License:",
"page.about.global_config_options": "Global configuration options",
"page.about.postgres_version": "Postgres version:",
"page.add_feed.title": "New Subscription",
"page.add_feed.no_category": "There is no category. You must have at least one category.",
"page.add_feed.label.url": "URL",
@@ -199,6 +201,9 @@
"page.api_keys.table.actions": "Actions",
"page.api_keys.never_used": "Never Used",
"page.new_api_key.title": "New API Key",
"page.offline.title": "Offline Mode",
"page.offline.message": "You are offline",
"page.offline.refresh_page": "Try to refresh the page",
"alert.no_shared_entry": "There is no shared entry.",
"alert.no_bookmark": "There is no bookmark at the moment.",
"alert.no_category": "There is no category.",
@@ -232,6 +237,7 @@
"error.invalid_language": "Invalid language.",
"error.invalid_timezone": "Invalid timezone.",
"error.invalid_entry_direction": "Invalid entry direction.",
"error.invalid_display_mode": "Invalid web app display mode.",
"error.empty_file": "This file is empty.",
"error.bad_credentials": "Invalid username or password.",
"error.fields_mandatory": "All fields are mandatory.",
@@ -261,11 +267,13 @@
"form.feed.label.feed_username": "Feed Username",
"form.feed.label.feed_password": "Feed Password",
"form.feed.label.user_agent": "Override Default User Agent",
"form.feed.label.cookie": "Set Cookies",
"form.feed.label.scraper_rules": "Scraper Rules",
"form.feed.label.rewrite_rules": "Rewrite Rules",
"form.feed.label.blocklist_rules": "Block Rules",
"form.feed.label.keeplist_rules": "Keep Rules",
"form.feed.label.ignore_http_cache": "Ignore HTTP cache",
"form.feed.label.allow_self_signed_certificates": "Allow self-signed or invalid certificates",
"form.feed.label.fetch_via_proxy": "Fetch via proxy",
"form.feed.label.disabled": "Do not refresh this feed",
"form.category.label.title": "Title",
@@ -278,8 +286,13 @@
"form.prefs.label.theme": "Theme",
"form.prefs.label.entry_sorting": "Entry Sorting",
"form.prefs.label.entries_per_page": "Entries per page",
"form.prefs.label.display_mode": "Web app display mode (needs reinstalling)",
"form.prefs.select.older_first": "Older entries first",
"form.prefs.select.recent_first": "Recent entries first",
"form.prefs.select.fullscreen": "Fullscreen",
"form.prefs.select.standalone": "Standalone",
"form.prefs.select.minimal_ui": "Minimal",
"form.prefs.select.browser": "Browser",
"form.prefs.label.keyboard_shortcuts": "Enable keyboard shortcuts",
"form.prefs.label.entry_swipe": "Enable swipe gesture on entries on mobile",
"form.prefs.label.show_reading_time": "Show estimated reading time for articles",
+13
View File
@@ -92,6 +92,7 @@
"Hay %d fuente.",
"Hay %d fuentes."
],
"page.categories.unread_counter": "Número de entradas no leídas",
"page.new_category.title": "Nueva categoría",
"page.new_user.title": "Nuevo usario",
"page.edit_category.title": "Editar categoría: %s",
@@ -114,6 +115,7 @@
"page.about.author": "Autor:",
"page.about.license": "Licencia:",
"page.about.global_config_options": "Opciones de configuración global",
"page.about.postgres_version": "Postgres versión:",
"page.add_feed.title": "Nueva suscripción",
"page.add_feed.no_category": "No hay categoría. Debe tener al menos una categoría.",
"page.add_feed.label.url": "URL",
@@ -199,6 +201,9 @@
"page.api_keys.table.actions": "Acciones",
"page.api_keys.never_used": "Nunca usado",
"page.new_api_key.title": "Nueva clave API",
"page.offline.title": "Modo offline",
"page.offline.message": "Estas desconectado",
"page.offline.refresh_page": "Intenta actualizar la página",
"alert.no_shared_entry": "No hay entrada compartida.",
"alert.no_bookmark": "No hay marcador en este momento.",
"alert.no_category": "No hay categoría.",
@@ -253,6 +258,7 @@
"error.invalid_language": "Idioma no válido.",
"error.invalid_timezone": "Zona horaria no válida.",
"error.invalid_entry_direction": "Dirección de entrada no válida.",
"error.invalid_display_mode": "Modo de visualización de la aplicación web no válido.",
"form.feed.label.title": "Título",
"form.feed.label.site_url": "URL del sitio",
"form.feed.label.feed_url": "URL de la fuente",
@@ -261,11 +267,13 @@
"form.feed.label.feed_username": "Nombre de usuario de fuente",
"form.feed.label.feed_password": "Contraseña de fuente",
"form.feed.label.user_agent": "Invalidar el agente de usuario predeterminado",
"form.feed.label.cookie": "Configurar las cookies",
"form.feed.label.scraper_rules": "Reglas de raspador",
"form.feed.label.rewrite_rules": "Reglas de reescribir",
"form.feed.label.blocklist_rules": "Reglas de Filtrado(Bloquear)",
"form.feed.label.keeplist_rules": "Reglas de Filtrado(Permitir)",
"form.feed.label.ignore_http_cache": "Ignorar caché HTTP",
"form.feed.label.allow_self_signed_certificates": "Permitir certificados autofirmados o no válidos",
"form.feed.label.fetch_via_proxy": "Buscar a través de proxy",
"form.feed.label.disabled": "No actualice este feed",
"form.category.label.title": "Título",
@@ -278,8 +286,13 @@
"form.prefs.label.theme": "Tema",
"form.prefs.label.entry_sorting": "Clasificación de entradas",
"form.prefs.label.entries_per_page": "Entradas por página",
"form.prefs.label.display_mode": "Modo de visualización de la aplicación web (necesita reinstalación)",
"form.prefs.select.older_first": "Entradas más viejas primero",
"form.prefs.select.recent_first": "Entradas recientes primero",
"form.prefs.select.fullscreen": "Pantalla completa",
"form.prefs.select.standalone": "Ser único",
"form.prefs.select.minimal_ui": "Mínimo",
"form.prefs.select.browser": "Navegador",
"form.prefs.label.keyboard_shortcuts": "Habilitar atajos de teclado",
"form.prefs.label.entry_swipe": "Habilitar el gesto de deslizar el dedo en las entradas en el móvil",
"form.prefs.label.show_reading_time": "Mostrar el tiempo estimado de lectura de los artículos",
+13
View File
@@ -92,6 +92,7 @@
"Il y a %d abonnement.",
"Il y a %d abonnements."
],
"page.categories.unread_counter": "Nombre d'entrées non lues",
"page.new_category.title": "Nouvelle catégorie",
"page.new_user.title": "Nouvel Utilisateur",
"page.edit_category.title": "Modification de la catégorie : %s",
@@ -114,6 +115,7 @@
"page.about.author": "Auteur :",
"page.about.license": "Licence :",
"page.about.global_config_options": "Options de configuration globales",
"page.about.postgres_version": "Version de Postgresql :",
"page.add_feed.title": "Nouvel Abonnement",
"page.add_feed.no_category": "Il n'y a aucune catégorie. Vous devez avoir au moins une catégorie.",
"page.add_feed.label.url": "Lien",
@@ -199,6 +201,9 @@
"page.api_keys.table.actions": "Actions",
"page.api_keys.never_used": "Jamais utilisé",
"page.new_api_key.title": "Nouvelle clé d'API",
"page.offline.title": "Mode Hors-Ligne",
"page.offline.message": "Vous n'êtes pas connecté",
"page.offline.refresh_page": "Essayez de rafraîchir la page",
"alert.no_shared_entry": "Il n'y a pas d'article partagé.",
"alert.no_bookmark": "Il n'y a aucun favoris pour le moment.",
"alert.no_category": "Il n'y a aucune catégorie.",
@@ -253,6 +258,7 @@
"error.invalid_language": "Langue non valide.",
"error.invalid_timezone": "Fuseau horaire non valide.",
"error.invalid_entry_direction": "Ordre de trie non valide.",
"error.invalid_display_mode": "Mode d'affichage de l'application web non valide.",
"form.feed.label.title": "Titre",
"form.feed.label.site_url": "URL du site web",
"form.feed.label.feed_url": "URL du flux",
@@ -261,11 +267,13 @@
"form.feed.label.feed_username": "Nom d'utilisateur du flux",
"form.feed.label.feed_password": "Mot de passe du flux",
"form.feed.label.user_agent": "Remplacer l'agent utilisateur par défaut",
"form.feed.label.cookie": "Définir les cookies",
"form.feed.label.scraper_rules": "Règles pour récupérer le contenu original",
"form.feed.label.rewrite_rules": "Règles de réécriture",
"form.feed.label.blocklist_rules": "Règles de blocage",
"form.feed.label.keeplist_rules": "Règles d'autorisation",
"form.feed.label.ignore_http_cache": "Ignorer le cache HTTP",
"form.feed.label.allow_self_signed_certificates": "Autoriser les certificats auto-signés ou non valides",
"form.feed.label.fetch_via_proxy": "Récupérer via proxy",
"form.feed.label.disabled": "Ne pas actualiser ce flux",
"form.category.label.title": "Titre",
@@ -278,8 +286,13 @@
"form.prefs.label.theme": "Thème",
"form.prefs.label.entry_sorting": "Ordre des éléments",
"form.prefs.label.entries_per_page": "Entrées par page",
"form.prefs.label.display_mode": "Mode d'affichage de l'application web (doit être réinstallé)",
"form.prefs.select.older_first": "Ancien éléments en premier",
"form.prefs.select.recent_first": "Éléments récents en premier",
"form.prefs.select.fullscreen": "Plein écran",
"form.prefs.select.standalone": "Autonome",
"form.prefs.select.minimal_ui": "Minimal",
"form.prefs.select.browser": "Navigateur",
"form.prefs.label.keyboard_shortcuts": "Activer les raccourcis clavier",
"form.prefs.label.entry_swipe": "Activer le geste de balayage sur les entrées sur mobile",
"form.prefs.label.show_reading_time": "Afficher le temps de lecture estimé des articles",
+13
View File
@@ -92,6 +92,7 @@
"C'è %d feed.",
"Ci sono %d feed."
],
"page.categories.unread_counter": "Numero di voci non lette",
"page.new_category.title": "Nuova categoria",
"page.new_user.title": "Nuovo utente",
"page.edit_category.title": "Modifica categoria: %s",
@@ -114,6 +115,7 @@
"page.about.author": "Autore:",
"page.about.license": "Licenza:",
"page.about.global_config_options": "Opzioni di configurazione globali",
"page.about.postgres_version": "Postgres versione:",
"page.add_feed.title": "Nuovo feed",
"page.add_feed.no_category": "Nessuna categoria selezionata. Devi scegliere almeno una categoria.",
"page.add_feed.label.url": "URL",
@@ -199,6 +201,9 @@
"page.api_keys.table.actions": "Azioni",
"page.api_keys.never_used": "Mai usato",
"page.new_api_key.title": "Nuova chiave API",
"page.offline.title": "Modalità offline",
"page.offline.message": "Sei offline",
"page.offline.refresh_page": "Prova ad aggiornare la pagina",
"alert.no_shared_entry": "Non ci sono voci condivise.",
"alert.no_bookmark": "Nessun preferito disponibile.",
"alert.no_category": "Nessuna categoria disponibile.",
@@ -253,6 +258,7 @@
"error.invalid_language": "Lingua non valida.",
"error.invalid_timezone": "Fuso orario non valido.",
"error.invalid_entry_direction": "Ordinamento non valido.",
"error.invalid_display_mode": "Modalità di visualizzazione web app non valida.",
"form.feed.label.title": "Titolo",
"form.feed.label.site_url": "URL del sito",
"form.feed.label.feed_url": "URL del feed",
@@ -261,11 +267,13 @@
"form.feed.label.feed_username": "Nome utente del feed",
"form.feed.label.feed_password": "Password del feed",
"form.feed.label.user_agent": "Usa user agent personalizzato",
"form.feed.label.cookie": "Installare i cookies",
"form.feed.label.scraper_rules": "Regole di estrazione del contenuto",
"form.feed.label.rewrite_rules": "Regole di impaginazione del contenuto",
"form.feed.label.blocklist_rules": "Regole di blocco",
"form.feed.label.keeplist_rules": "Regole di autorizzazione",
"form.feed.label.ignore_http_cache": "Ignora cache HTTP",
"form.feed.label.allow_self_signed_certificates": "Consenti certificati autofirmati o non validi",
"form.feed.label.fetch_via_proxy": "Recuperare tramite proxy",
"form.feed.label.disabled": "Non aggiornare questo feed",
"form.category.label.title": "Titolo",
@@ -278,8 +286,13 @@
"form.prefs.label.theme": "Tema",
"form.prefs.label.entry_sorting": "Ordinamento articoli",
"form.prefs.label.entries_per_page": "Articoli per pagina",
"form.prefs.label.display_mode": "Modalità di visualizzazione web app (necessita la reinstallazione)",
"form.prefs.select.older_first": "Prima i più vecchi",
"form.prefs.select.recent_first": "Prima i più recenti",
"form.prefs.select.fullscreen": "Schermo intero",
"form.prefs.select.standalone": "Autonoma",
"form.prefs.select.minimal_ui": "Minimale",
"form.prefs.select.browser": "Browser",
"form.prefs.label.keyboard_shortcuts": "Abilita le scorciatoie da tastiera",
"form.prefs.label.entry_swipe": "Abilita il gesto di scorrimento sulle voci sul cellulare",
"form.prefs.label.show_reading_time": "Mostra il tempo di lettura stimato per gli articoli",
+13
View File
@@ -92,6 +92,7 @@
"%d 個の記事があります。",
"%d 個の記事があります。"
],
"page.categories.unread_counter": "未読記事の数",
"page.new_category.title": "新規カテゴリ",
"page.new_user.title": "新規ユーザー",
"page.edit_category.title": "カテゴリーを編集: %s",
@@ -114,6 +115,7 @@
"page.about.author": "作者:",
"page.about.license": "ライセンス:",
"page.about.global_config_options": "グローバル構成オプション",
"page.about.postgres_version": "Postgres バージョン:",
"page.add_feed.title": "新規購読",
"page.add_feed.no_category": "カテゴリが存在しません。 少なくとも1つのカテゴリが必要です。",
"page.add_feed.label.url": "URL",
@@ -199,6 +201,9 @@
"page.api_keys.table.actions": "アクション",
"page.api_keys.never_used": "使われたことがない",
"page.new_api_key.title": "新しいAPIキー",
"page.offline.title": "オフラインモード",
"page.offline.message": "オフラインです",
"page.offline.refresh_page": "ページを更新してみてください",
"alert.no_shared_entry": "共有エントリはありません。",
"alert.no_bookmark": "現在星付きはありません。",
"alert.no_category": "カテゴリが存在しません。",
@@ -253,6 +258,7 @@
"error.invalid_language": "言語が無効です。",
"error.invalid_timezone": "タイムゾーンが無効です。",
"error.invalid_entry_direction": "ソート順が無効です。",
"error.invalid_display_mode": "Webアプリの表示モードが無効です。",
"form.feed.label.title": "タイトル",
"form.feed.label.site_url": "サイト URL",
"form.feed.label.feed_url": "フィード URL",
@@ -261,11 +267,13 @@
"form.feed.label.feed_username": "フィードのユーザー名",
"form.feed.label.feed_password": "フィードのパスワード",
"form.feed.label.user_agent": "ディフォルトの User Agent を上書きする",
"form.feed.label.cookie": "クッキーの設定",
"form.feed.label.scraper_rules": "スクラップルール",
"form.feed.label.rewrite_rules": "Rewrite ルール",
"form.feed.label.blocklist_rules": "ブロックルール",
"form.feed.label.keeplist_rules": "許可規則",
"form.feed.label.ignore_http_cache": "HTTPキャッシュを無視",
"form.feed.label.allow_self_signed_certificates": "自己署名証明書または無効な証明書を許可する",
"form.feed.label.fetch_via_proxy": "プロキシ経由でフェッチ",
"form.feed.label.disabled": "このフィードを更新しない",
"form.category.label.title": "タイトル",
@@ -278,8 +286,13 @@
"form.prefs.label.theme": "テーマ",
"form.prefs.label.entry_sorting": "記事の並べ替え",
"form.prefs.label.entries_per_page": "ページあたりのエントリ",
"form.prefs.label.display_mode": "Webアプリの表示モード (再インストールが必要)",
"form.prefs.select.older_first": "古い記事を最初に",
"form.prefs.select.recent_first": "新しい記事を最初に",
"form.prefs.select.fullscreen": "全画面表示",
"form.prefs.select.standalone": "スタンドアロン",
"form.prefs.select.minimal_ui": "最小限",
"form.prefs.select.browser": "ブラウザ",
"form.prefs.label.keyboard_shortcuts": "キーボード・ショートカットを有効にする",
"form.prefs.label.entry_swipe": "モバイルのエントリでスワイプジェスチャーを有効にする",
"form.prefs.label.show_reading_time": "記事の推定読書時間を表示する",
+13
View File
@@ -92,6 +92,7 @@
"Er is %d feed.",
"Er zijn %d feeds."
],
"page.categories.unread_counter": "Aantal ongelezen vermeldingen",
"page.new_category.title": "Nieuwe categorie",
"page.new_user.title": "Nieuwe gebruiker",
"page.edit_category.title": "Bewerken van categorie: %s",
@@ -115,6 +116,7 @@
"page.about.author": "Auteur:",
"page.about.license": "Licentie:",
"page.about.global_config_options": "globale configuratie-opties",
"page.about.postgres_version": "Postgres versie:",
"page.add_feed.title": "Nieuwe feed",
"page.add_feed.no_category": "Er zijn geen categorieën. Je moet op zijn minst één caterogie hebben.",
"page.add_feed.label.url": "URL",
@@ -199,6 +201,9 @@
"page.api_keys.table.actions": "Acties",
"page.api_keys.never_used": "Nooit gebruikt",
"page.new_api_key.title": "Nieuwe API-sleutel",
"page.offline.title": "Offline modus",
"page.offline.message": "Je bent offline",
"page.offline.refresh_page": "Probeer de pagina te vernieuwen",
"alert.no_shared_entry": "Er is geen gedeelde toegang.",
"alert.no_bookmark": "Er zijn op dit moment geen favorieten.",
"alert.no_category": "Er zijn geen categorieën.",
@@ -253,6 +258,7 @@
"error.invalid_language": "Ongeldige taal.",
"error.invalid_timezone": "Ongeldige tijdzone.",
"error.invalid_entry_direction": "Ongeldige sorteervolgorde.",
"error.invalid_display_mode": "Ongeldige weergavemodus voor webapp.",
"form.feed.label.title": "Naam",
"form.feed.label.site_url": "Website URL",
"form.feed.label.feed_url": "Feed URL",
@@ -261,11 +267,13 @@
"form.feed.label.feed_username": "Feed-gebruikersnaam",
"form.feed.label.feed_password": "Feed wachtwoord",
"form.feed.label.user_agent": "Standaard User Agent overschrijven",
"form.feed.label.cookie": "Cookies instellen",
"form.feed.label.scraper_rules": "Scraper regels",
"form.feed.label.rewrite_rules": "Rewrite regels",
"form.feed.label.blocklist_rules": "Blokkeer regels",
"form.feed.label.keeplist_rules": "toestemmingsregels",
"form.feed.label.ignore_http_cache": "Negeer HTTP-cache",
"form.feed.label.allow_self_signed_certificates": "Sta zelfondertekende of ongeldige certificaten toe",
"form.feed.label.fetch_via_proxy": "Ophalen via proxy",
"form.feed.label.disabled": "Vernieuw deze feed niet",
"form.category.label.title": "Naam",
@@ -278,8 +286,13 @@
"form.prefs.label.theme": "Skin",
"form.prefs.label.entry_sorting": "Volgorde van items",
"form.prefs.label.entries_per_page": "Inzendingen per pagina",
"form.prefs.label.display_mode": "Weergavemodus voor webapp (moet opnieuw worden geïnstalleerd)",
"form.prefs.select.older_first": "Oudere items eerst",
"form.prefs.select.recent_first": "Recente items eerst",
"form.prefs.select.fullscreen": "Volledig scherm",
"form.prefs.select.standalone": "Standalone",
"form.prefs.select.minimal_ui": "Minimaal",
"form.prefs.select.browser": "Browser",
"form.prefs.label.keyboard_shortcuts": "Schakel sneltoetsen in",
"form.prefs.label.entry_swipe": "Schakel veegbewegingen in voor items op mobiel",
"form.prefs.label.show_reading_time": "Toon geschatte leestijd voor artikelen",
+13
View File
@@ -93,6 +93,7 @@
"Są %d kanały.",
"Jest %d kanałów."
],
"page.categories.unread_counter": "Liczba nieprzeczytanych wpisów",
"page.new_category.title": "Nowa kategoria",
"page.new_user.title": "Nowy użytkownik",
"page.edit_category.title": "Edycja Kategorii: %s",
@@ -115,6 +116,7 @@
"page.about.build_date": "Data opracowania:",
"page.about.author": "Autor:",
"page.about.license": "Licencja:",
"page.about.postgres_version": "Postgres wersja:",
"page.about.global_config_options": "globalne opcje konfiguracji",
"page.add_feed.title": "Nowa subskrypcja",
"page.add_feed.no_category": "Nie ma żadnej kategorii. Musisz mieć co najmniej jedną kategorię.",
@@ -201,6 +203,9 @@
"page.api_keys.table.actions": "Działania",
"page.api_keys.never_used": "Nigdy nie używany",
"page.new_api_key.title": "Nowy klucz API",
"page.offline.title": "Tryb offline",
"page.offline.message": "Jesteś odłączony od sieci",
"page.offline.refresh_page": "Spróbuj odświeżyć stronę",
"alert.no_shared_entry": "Brak wspólnego wpisu.",
"alert.no_bookmark": "Obecnie nie ma żadnych zakładek.",
"alert.no_category": "Nie ma żadnej kategorii!",
@@ -255,6 +260,7 @@
"error.invalid_language": "Nieprawidłowy język.",
"error.invalid_timezone": "Nieprawidłowa strefa czasowa.",
"error.invalid_entry_direction": "Nieprawidłowa kolejność sortowania.",
"error.invalid_display_mode": "Nieprawidłowy tryb wyświetlania aplikacji internetowej.",
"form.feed.label.title": "Tytuł",
"form.feed.label.site_url": "URL strony",
"form.feed.label.feed_url": "URL kanału",
@@ -263,11 +269,13 @@
"form.feed.label.feed_username": "Subskrypcję nazwa użytkownika",
"form.feed.label.feed_password": "Subskrypcję Hasło",
"form.feed.label.user_agent": "Zastąp domyślny agent użytkownika",
"form.feed.label.cookie": "Ustawianie ciasteczek",
"form.feed.label.scraper_rules": "Zasady ekstrakcji",
"form.feed.label.rewrite_rules": "Reguły zapisu",
"form.feed.label.blocklist_rules": "Zasady blokowania",
"form.feed.label.keeplist_rules": "Zasady zezwoleń",
"form.feed.label.ignore_http_cache": "Zignoruj pamięć podręczną HTTP",
"form.feed.label.allow_self_signed_certificates": "Zezwalaj na certyfikaty z podpisem własnym lub nieprawidłowe certyfikaty",
"form.feed.label.fetch_via_proxy": "Pobierz przez proxy",
"form.feed.label.disabled": "Не обновлять этот канал",
"form.category.label.title": "Tytuł",
@@ -280,11 +288,16 @@
"form.prefs.label.theme": "Wygląd",
"form.prefs.label.entry_sorting": "Sortowanie artykułów",
"form.prefs.label.entries_per_page": "Wpisy na stronie",
"form.prefs.label.display_mode": "Tryb wyświetlania aplikacji internetowej (wymaga ponownej instalacji)",
"form.prefs.select.older_first": "Najstarsze wpisy jako pierwsze",
"form.prefs.label.keyboard_shortcuts": "Włącz skróty klawiaturowe",
"form.prefs.label.entry_swipe": "Włącz gest przesuwania na wpisach na telefonie komórkowym",
"form.prefs.label.show_reading_time": "Pokaż szacowany czas czytania artykułów",
"form.prefs.select.recent_first": "Najnowsze wpisy jako pierwsze",
"form.prefs.select.fullscreen": "Pełny ekran",
"form.prefs.select.standalone": "Samodzielny",
"form.prefs.select.minimal_ui": "Minimalny",
"form.prefs.select.browser": "Przeglądarka",
"form.prefs.label.custom_css": "Niestandardowy CSS",
"form.import.label.file": "Plik OPML",
"form.import.label.url": "URL",
+13
View File
@@ -92,6 +92,7 @@
"Existe %d fonte.",
"Existem %d fontes."
],
"page.categories.unread_counter": "Numero de itens não lidos",
"page.new_category.title": "Nova categoria",
"page.new_user.title": "Novo usuário",
"page.edit_category.title": "Editar categoria: %s",
@@ -114,6 +115,7 @@
"page.about.author": "Autor:",
"page.about.license": "Licença:",
"page.about.global_config_options": "opções de configuração global",
"page.about.postgres_version": "Postgres versão:",
"page.add_feed.title": "Nova inscrição",
"page.add_feed.no_category": "Não existe uma categoria. Deve existir pelo menos uma categoria.",
"page.add_feed.label.url": "URL",
@@ -199,6 +201,9 @@
"page.api_keys.table.actions": "Ações",
"page.api_keys.never_used": "Nunca usado",
"page.new_api_key.title": "Nova chave de API",
"page.offline.title": "Modo offline",
"page.offline.message": "Você está offline",
"page.offline.refresh_page": "Tente atualizar a página",
"alert.no_shared_entry": "Não há itens compartilhados.",
"alert.no_bookmark": "Não há favorito neste momento.",
"alert.no_category": "Não há categoria.",
@@ -253,6 +258,7 @@
"error.invalid_language": "Idioma inválido.",
"error.invalid_timezone": "Fuso horário inválido.",
"error.invalid_entry_direction": "Direção de entrada inválida.",
"error.invalid_display_mode": "Modo de exibição de aplicativo inválido da web.",
"form.feed.label.title": "Título",
"form.feed.label.site_url": "URL do site",
"form.feed.label.feed_url": "URL da fonte",
@@ -261,11 +267,13 @@
"form.feed.label.feed_username": "Nome de usuário da fonte",
"form.feed.label.feed_password": "Senha da fonte",
"form.feed.label.user_agent": "Sobrescrever o agente de usuário (user-agent) padrão",
"form.feed.label.cookie": "Definir Cookies",
"form.feed.label.scraper_rules": "Regras do scraper",
"form.feed.label.rewrite_rules": "Regras para o Rewrite",
"form.feed.label.blocklist_rules": "Regras de bloqueio",
"form.feed.label.keeplist_rules": "Regras de permissão",
"form.feed.label.ignore_http_cache": "Ignorar cache HTTP",
"form.feed.label.allow_self_signed_certificates": "Permitir certificados autoassinados ou inválidos",
"form.feed.label.disabled": "Não atualizar esta fonte",
"form.feed.label.fetch_via_proxy": "Buscar via proxy",
"form.category.label.title": "Título",
@@ -278,8 +286,13 @@
"form.prefs.label.theme": "Tema",
"form.prefs.label.entry_sorting": "Ordenação dos itens",
"form.prefs.label.entries_per_page": "Itens por página",
"form.prefs.label.display_mode": "Modo de exibição do aplicativo Web (precisa ser reinstalado)",
"form.prefs.select.older_first": "Itens mais velhos primeiro",
"form.prefs.select.recent_first": "Itens mais recentes",
"form.prefs.select.fullscreen": "Tela completa",
"form.prefs.select.standalone": "Autônomo",
"form.prefs.select.minimal_ui": "Mínimo",
"form.prefs.select.browser": "Navegador",
"form.prefs.label.keyboard_shortcuts": "Habilitar atalhos do teclado",
"form.prefs.label.entry_swipe": "Ativar gesto de deslizar nas entradas no celular",
"form.prefs.label.show_reading_time": "Mostrar tempo estimado de leitura de artigos",
+13
View File
@@ -93,6 +93,7 @@
"Есть %d подписки.",
"Есть %d подписок."
],
"page.categories.unread_counter": "Количество непрочитанных записей",
"page.new_category.title": "Новая категория",
"page.new_user.title": "Новый пользователь",
"page.edit_category.title": "Изменить категорию: %s",
@@ -115,6 +116,7 @@
"page.about.build_date": "Дата сборки:",
"page.about.author": "Автор:",
"page.about.license": "Лицензия:",
"page.about.postgres_version": "Postgres bерсия:",
"page.about.global_config_options": "глобальные параметры конфигурации",
"page.add_feed.title": "Новая подписка",
"page.add_feed.no_category": "Категории отсутствуют. У вас должна быть хотя бы одна категория.",
@@ -201,6 +203,9 @@
"page.api_keys.table.actions": "Действия",
"page.api_keys.never_used": "Никогда не использовался",
"page.new_api_key.title": "Новый API-ключ",
"page.offline.title": "Автономный режим",
"page.offline.message": "Ты не в сети",
"page.offline.refresh_page": "Попробуйте обновить страницу",
"alert.no_shared_entry": "Общедоступные записи отсутствуют.",
"alert.no_bookmark": "Избранное отсутствует.",
"alert.no_category": "Категории отсутствуют.",
@@ -255,6 +260,7 @@
"error.invalid_language": "Неверный язык.",
"error.invalid_timezone": "Неверный часовой пояс.",
"error.invalid_entry_direction": "Неверное направление входа.",
"error.invalid_display_mode": "Недопустимый режим отображения веб-приложения.",
"form.feed.label.title": "Название",
"form.feed.label.site_url": "URL сайта",
"form.feed.label.feed_url": "URL подписки",
@@ -263,11 +269,13 @@
"form.feed.label.feed_username": "Имя пользователя подписки",
"form.feed.label.feed_password": "Пароль подписки",
"form.feed.label.user_agent": "Переопределить User Agent по умолчанию",
"form.feed.label.cookie": "Установить Ку́ки",
"form.feed.label.scraper_rules": "Правила Scraper",
"form.feed.label.rewrite_rules": "Правила Rewrite",
"form.feed.label.blocklist_rules": "Правила блокировки",
"form.feed.label.keeplist_rules": "правила разрешений",
"form.feed.label.ignore_http_cache": "Игнорировать HTTP-кеш",
"form.feed.label.allow_self_signed_certificates": "Разрешить самоподписанные или недействительные сертификаты",
"form.feed.label.fetch_via_proxy": "Получить через прокси",
"form.feed.label.disabled": "Не обновлять этот канал",
"form.category.label.title": "Название",
@@ -280,8 +288,13 @@
"form.prefs.label.theme": "Тема",
"form.prefs.label.entry_sorting": "Сортировка записей",
"form.prefs.label.entries_per_page": "Записи на странице",
"form.prefs.label.display_mode": "Режим отображения веб-приложения (требуется переустановка)",
"form.prefs.select.older_first": "Сначала старые записи",
"form.prefs.select.recent_first": "Сначала последние записи",
"form.prefs.select.fullscreen": "Полноэкранный",
"form.prefs.select.standalone": "Автономный",
"form.prefs.select.minimal_ui": "Минимальный",
"form.prefs.select.browser": "Браузер",
"form.prefs.label.keyboard_shortcuts": "Включить сочетания клавиш",
"form.prefs.label.entry_swipe": "Включить жест смахивания для записей на мобильном устройстве",
"form.prefs.label.show_reading_time": "Показать примерное время чтения статей",
+356
View File
@@ -0,0 +1,356 @@
{
"confirm.question": "Emin misiniz?",
"confirm.yes": "evet",
"confirm.no": "hayır",
"confirm.loading": "Devam ediyor...",
"action.subscribe": "Abone Ol",
"action.save": "Kaydet",
"action.or": "veya",
"action.cancel": "iptal",
"action.remove": "Kaldır",
"action.remove_feed": "Bu beslemeyi kaldır",
"action.update": "Güncelle",
"action.edit": "Düzenle",
"action.download": "İndir",
"action.import": "İçeri Aktar",
"action.login": "Giriş",
"action.home_screen": "Ana ekrana ekle",
"tooltip.keyboard_shortcuts": "Klavye Kısayolu: %s",
"tooltip.logged_user": "%s olarak giriş yapıldı",
"menu.unread": "Okunmadı",
"menu.starred": "Yıldız",
"menu.history": "Geçmiş",
"menu.feeds": "Beslemeler",
"menu.categories": "Kategoriler",
"menu.settings": "Ayarlar",
"menu.logout": "Çıkış",
"menu.preferences": "Tercihler",
"menu.integrations": "Bütünleşmeler",
"menu.sessions": "Oturumlar",
"menu.users": "Kullanıcılar",
"menu.about": "Hakkında",
"menu.export": "Dışarı Aktar",
"menu.import": "İçeri Aktar",
"menu.create_category": "Kategori oluştur",
"menu.mark_page_as_read": "Bu sayfayı okundu olarak işaretle",
"menu.mark_all_as_read": "Tümünü okundu olarak işaretle",
"menu.show_all_entries": "Tüm iletileri göster",
"menu.show_only_unread_entries": "Sadece okunmamış iletileri göster",
"menu.refresh_feed": "Yenile",
"menu.refresh_all_feeds": "Tüm beslemeleri arka planda yenile",
"menu.edit_feed": "Düzenle",
"menu.edit_category": "Düzenle",
"menu.add_feed": "Abonelik ekle",
"menu.add_user": "Kullanıcı ekle",
"menu.flush_history": "Geçmişi temizle",
"menu.feed_entries": "İletiler",
"menu.api_keys": "API Anahtarları",
"menu.create_api_key": "Yeni bir API anahtarı oluştur",
"menu.shared_entries": "Paylaşılan iletiler",
"search.label": "Ara",
"search.placeholder": "Ara...",
"pagination.next": "Sonraki",
"pagination.previous": "Önceki",
"entry.status.unread": "Okunmadı",
"entry.status.read": "Okundu",
"entry.status.toast.unread": "Okunmadı olarak işaretle",
"entry.status.toast.read": "Okundu olarak işaretle",
"entry.status.title": "İleti durumunu değiştir",
"entry.bookmark.toggle.on": "Yıldız ekle",
"entry.bookmark.toggle.off": "Yıldızı kaldır",
"entry.bookmark.toast.on": "Yıldızlı",
"entry.bookmark.toast.off": "Yıldızsız",
"entry.state.saving": "Kaydediliyor...",
"entry.state.loading": "Yükleniyor...",
"entry.save.label": "Kaydet",
"entry.save.title": "Bu makaleyi kaydet",
"entry.save.completed": "Bitti!",
"entry.save.toast.completed": "Makale kaydedildi",
"entry.scraper.label": "İndir",
"entry.scraper.title": "Orijinal içeriği çek",
"entry.scraper.completed": "Bitti!",
"entry.external_link.label": "Dış bağlantı",
"entry.comments.label": "Yorumlar",
"entry.comments.title": "Yorumları Göster",
"entry.share.label": "Paylaş",
"entry.share.title": "Bu makaleyi paylaş",
"entry.unshare.label": "Paylaşma",
"entry.shared_entry.title": "Herkese açık bağlantıyı aç",
"entry.shared_entry.label": "Paylaş",
"entry.estimated_reading_time": [
"%d dakikalık okuma",
"%d dakikalık okuma"
],
"page.shared_entries.title": "Paylaşılan iletiler",
"page.unread.title": "Okunmadı",
"page.starred.title": "Yıldızlı",
"page.categories.title": "Kategoriler",
"page.categories.no_feed": "Besleme yok.",
"page.categories.entries": "Makaleler",
"page.categories.feeds": "Abonelikler",
"page.categories.feed_count": [
"%d besleme var.",
"%d besleme var."
],
"page.categories.unread_counter": "Okunmamış iletilerin sayısı",
"page.new_category.title": "Yeni Kategori",
"page.new_user.title": "Yeni Kullanıcı",
"page.edit_category.title": "Kategoriyi Düzenle: %s",
"page.edit_user.title": "Kullanıcıyı Düzenle: %s",
"page.feeds.title": "Beslemeler",
"page.feeds.last_check": "Son kontrol:",
"page.feeds.unread_counter": "Okunmamış iletilerin sayısı",
"page.feeds.read_counter": "Okunmuş iletilerin sayısı",
"page.feeds.error_count": [
"%d hata",
"%d hata"
],
"page.history.title": "Geçmiş",
"page.import.title": "İçeri Aktar",
"page.search.title": "Arama Sonuçları",
"page.about.title": "Hakkında",
"page.about.credits": "Katkıda Bulunanlar",
"page.about.version": "Sürüm:",
"page.about.build_date": "Oluşturulma Tarihi:",
"page.about.author": "Yazar:",
"page.about.license": "Lisans:",
"page.about.global_config_options": "Global yapılandırma seçenekleri",
"page.about.postgres_version": "Postgres sürümü:",
"page.add_feed.title": "Yeni Abonelik",
"page.add_feed.no_category": "Kategori yok. En az bir kategoriye sahip olmalısınız.",
"page.add_feed.label.url": "URL",
"page.add_feed.submit": "Bir abonelik bul",
"page.add_feed.legend.advanced_options": "Gelişmiş Seçenekler",
"page.add_feed.choose_feed": "Bir Abonelik Seçin",
"page.edit_feed.title": "Beslemeyi düzenle: %s",
"page.edit_feed.last_check": "Son kontrol:",
"page.edit_feed.last_modified_header": "LastModified başlığı:",
"page.edit_feed.etag_header": "ETag başlığı:",
"page.edit_feed.no_header": "Hiçbiri",
"page.edit_feed.last_parsing_error": "Son Ayrıştırma Hatası",
"page.entry.attachments": "Ekler",
"page.keyboard_shortcuts.title": "Klavye Kısayolları",
"page.keyboard_shortcuts.subtitle.sections": "Bölüm Gezinmesi",
"page.keyboard_shortcuts.subtitle.items": "Öğe Gezinmesi",
"page.keyboard_shortcuts.subtitle.pages": "Sayfa Gezinmesi",
"page.keyboard_shortcuts.subtitle.actions": "Hareketler",
"page.keyboard_shortcuts.go_to_unread": "Okunmamışa git",
"page.keyboard_shortcuts.go_to_starred": "Yer imlerine git",
"page.keyboard_shortcuts.go_to_history": "Geçmişe git",
"page.keyboard_shortcuts.go_to_feeds": "Beslemelere git",
"page.keyboard_shortcuts.go_to_categories": "Kategorilere git",
"page.keyboard_shortcuts.go_to_settings": "Ayarlara git",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Klavye kısayollarını göster",
"page.keyboard_shortcuts.go_to_previous_item": "Önceki öğeye git",
"page.keyboard_shortcuts.go_to_next_item": "Sonraki öğeye git",
"page.keyboard_shortcuts.go_to_feed": "Beslemeye git",
"page.keyboard_shortcuts.go_to_previous_page": "Önceki sayfaya git",
"page.keyboard_shortcuts.go_to_next_page": "Sonraki sayfaya git",
"page.keyboard_shortcuts.open_item": "Seçili öğeyi aç",
"page.keyboard_shortcuts.open_original": "Orijinal bağlantıyı aç",
"page.keyboard_shortcuts.open_original_same_window": "Orijinal bağlantıyı mevcut sekmede aç",
"page.keyboard_shortcuts.open_comments": "Yorumlar bağlantısını aç",
"page.keyboard_shortcuts.open_comments_same_window": "Yorumlar bağlantısını mevcut sekmede aç",
"page.keyboard_shortcuts.toggle_read_status": "Okundu/okunmadı arasında geçiş yap",
"page.keyboard_shortcuts.refresh_all_feeds": "Tüm beslemeleri arka planda yenile",
"page.keyboard_shortcuts.mark_page_as_read": "Mevcut sayfayı okundu olarak işaretle",
"page.keyboard_shortcuts.download_content": "Orijinal içeriği indir",
"page.keyboard_shortcuts.toggle_bookmark_status": "Yer işaretini değiştir",
"page.keyboard_shortcuts.save_article": "Makaleyi kaydet",
"page.keyboard_shortcuts.scroll_item_to_top": "Öğeyi en üste kaydır",
"page.keyboard_shortcuts.remove_feed": "Bu beslemeyi kaldır",
"page.keyboard_shortcuts.go_to_search": "Arama formuna odakla",
"page.keyboard_shortcuts.close_modal": "İletişim kutusunu kapat",
"page.users.title": "Kullanıcılar",
"page.users.username": "Kullanıcı adı",
"page.users.never_logged": "Asla",
"page.users.admin.yes": "Evet",
"page.users.admin.no": "Hayır",
"page.users.actions": "Hareketler",
"page.users.last_login": "Son Giriş",
"page.users.is_admin": "Yönetici",
"page.settings.title": "Ayarlar",
"page.settings.link_google_account": "Google hesabımı bağla",
"page.settings.unlink_google_account": "Google hesabımın bağlantısını kaldır",
"page.settings.link_oidc_account": "OpenID Connect hesabımı bağla",
"page.settings.unlink_oidc_account": "OpenID Connect hesabımın bağlantısını kaldır",
"page.login.title": "Oturum aç",
"page.login.google_signin": "Google ile oturum aç",
"page.login.oidc_signin": "OpenID Connect ile oturum aç",
"page.integrations.title": "Bütünleşmeler",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "API Uç Noktası",
"page.integration.miniflux_api_username": "Kullanıcı adı",
"page.integration.miniflux_api_password": "Parola",
"page.integration.miniflux_api_password_value": "Hesap parolan",
"page.integration.bookmarklet": "Bookmarklet",
"page.integration.bookmarklet.name": "Miniflux'a Ekle",
"page.integration.bookmarklet.instructions": "Bu bağlantıyı yer imlerinize sürükleyip bırakın",
"page.integration.bookmarklet.help": "Bu özel bağlantı, web tarayıcınızdaki yer imini kullanarak bir web sitesine doğrudan abone olmanızı sağlar.",
"page.sessions.title": "Oturumlar",
"page.sessions.table.date": "Tarih",
"page.sessions.table.ip": "IP Adresi",
"page.sessions.table.user_agent": "User Agent",
"page.sessions.table.actions": "Hareketler",
"page.sessions.table.current_session": "Mevcut Oturum",
"page.api_keys.title": "API Anahtarları",
"page.api_keys.table.description": "Açıklama",
"page.api_keys.table.token": "Token",
"page.api_keys.table.last_used_at": "Son Kullanılma",
"page.api_keys.table.created_at": "Oluşturulma Tarihi",
"page.api_keys.table.actions": "Hareketler",
"page.api_keys.never_used": "Hiç Kullanılmadı",
"page.new_api_key.title": "Yeni API Anahtarı",
"page.offline.title": "Çevrimdışı Modu",
"page.offline.message": "Çevrimdışısınız",
"page.offline.refresh_page": "Sayfayı yenilemeyi dene",
"alert.no_shared_entry": "Paylaşılan ileti yok.",
"alert.no_bookmark": "Şu anda hiç yer imi yok.",
"alert.no_category": "Hiç kategori yok.",
"alert.no_category_entry": "Bu kategoride hiç makale yok.",
"alert.no_feed_entry": "Bu besleme için makale yok.",
"alert.no_feed": "Hiç aboneliğiniz yok.",
"alert.no_feed_in_category": "Bu kategori için aboneliğiniz yok.",
"alert.no_history": "Şu anda hiç geçmiş yok.",
"alert.feed_error": "Bu beslemeyle ilgili bir problem var",
"alert.no_search_result": "Bu arama için sonuç yok",
"alert.no_unread_entry": "Okunmamış makale yok",
"alert.no_user": "Tek kullanıcı sizsiniz",
"alert.account_unlinked": "Harici hesabınızın bağlantısı kaldırıldı!",
"alert.account_linked": "Harici hesabınız bağlandı.",
"alert.pocket_linked": "Pocket hesabınız bağlandı.",
"alert.prefs_saved": "Tercihler kaydedildi!",
"error.unlink_account_without_password": "Bir şifre belirlemelisiniz, aksi takdirde tekrar oturum açamazsınız.",
"error.duplicate_linked_account": "Bu sağlayıcıyla ilişkilendirilmiş biri zaten var!",
"error.duplicate_fever_username": "Aynı Fever kullanıcı adına sahip başka biri zaten var!",
"error.pocket_request_token": "Pocket'tan istek tokeni alınamıyor!",
"error.pocket_access_token": "Pocket'tan erişim tokeni alınamıyor!",
"error.category_already_exists": "Bu kategori zaten mevcut.",
"error.unable_to_create_category": "Bu kategori oluşturulamıyor.",
"error.unable_to_update_category": "Bu kategori güncellenemiyor.",
"error.user_already_exists": "Bu kullanıcı zaten mevcut.",
"error.unable_to_create_user": "Bu kullanıcı oluşturulamıyor.",
"error.unable_to_update_user": "Bu kullanıcı güncellenemiyor.",
"error.unable_to_update_feed": "Bu besleme güncellenemiyor.",
"error.subscription_not_found": "Herhangi bir abonelik bulunamadı.",
"error.invalid_theme": "Geçersiz tema.",
"error.invalid_language": "Geçersiz dil.",
"error.invalid_timezone": "Geçersiz saat dilimi",
"error.invalid_entry_direction": "Geçersiz giriş yönü.",
"error.invalid_display_mode": "Geçersiz web uygulaması görüntüleme modu.",
"error.empty_file": "Bu dosya boş.",
"error.bad_credentials": "Geçersiz kullanıcı veya parola.",
"error.fields_mandatory": "Tüm alanlar zorunlu.",
"error.title_required": "Başlık zorunlu.",
"error.different_passwords": "Parolalar eşleşmiyor.",
"error.password_min_length": "Parola en az 6 karakter içermeli.",
"error.settings_mandatory_fields": "Kullanıcı ad, tema, dil ve saat dilimi zorunlu.",
"error.entries_per_page_invalid": "Sayfa başına ileti sayısı geçersiz.",
"error.feed_mandatory_fields": "URL ve kategori zorunlu.",
"error.feed_already_exists": "Bu besleme zaten mevcut.",
"error.invalid_feed_url": "Geçersiz besleme URL'si.",
"error.invalid_site_url": "Geçersiz site URL'si.",
"error.feed_url_not_empty": "Besleme URL'si boş olamaz.",
"error.site_url_not_empty": "Site URL'si boş olamaz.",
"error.feed_title_not_empty": "Besleme başlığı boş olamaz.",
"error.feed_category_not_found": "Bu kategori mevcut değil ya da bu kullanıcıya ait değil.",
"error.feed_invalid_blocklist_rule": "Engelleme listesi kuralı geçersiz.",
"error.feed_invalid_keeplist_rule": "Saklama listesi kuralı geçersiz.",
"error.user_mandatory_fields": "Kullanıcı adı zorunlu.",
"error.api_key_already_exists": "Bu API anahtarı zaten mevcut.",
"error.unable_to_create_api_key": "Bu API anahtarı oluşturulamıyor.",
"form.feed.label.title": "Başlık",
"form.feed.label.site_url": "Site URL'si",
"form.feed.label.feed_url": "Besleme URL'si",
"form.feed.label.category": "Kategori",
"form.feed.label.crawler": "Orijinal içeriği çek",
"form.feed.label.feed_username": "Besleme Kullanıcı Adı",
"form.feed.label.feed_password": "Besleme Parolası",
"form.feed.label.user_agent": "Varsayılan User Agent'i Geçersiz Kıl",
"form.feed.label.cookie": "Çerezleri Ayarla",
"form.feed.label.scraper_rules": "Scrapper Kuralları",
"form.feed.label.rewrite_rules": "Yeniden Yazma Kuralları",
"form.feed.label.blocklist_rules": "Engelleme Kuralları",
"form.feed.label.keeplist_rules": "Saklama Kuralları",
"form.feed.label.ignore_http_cache": "HTTP önbelleğini yoksay",
"form.feed.label.allow_self_signed_certificates": "Kendinden imzalı veya geçersiz sertifikalara izin ver",
"form.feed.label.fetch_via_proxy": "Proxy ile çek",
"form.feed.label.disabled": "Bu beslemeyi yenileme",
"form.category.label.title": "Başlık",
"form.user.label.username": "Kullanıcı Adı",
"form.user.label.password": "Parola",
"form.user.label.confirmation": "Parola Doğrulama",
"form.user.label.admin": "Yönetici",
"form.prefs.label.language": "Dil",
"form.prefs.label.timezone": "Saat Dilimi",
"form.prefs.label.theme": "Tema",
"form.prefs.label.entry_sorting": "İleti Sıralaması",
"form.prefs.label.entries_per_page": "Sayfa başına ileti",
"form.prefs.label.display_mode": "Web uygulaması görüntüleme modu (yeniden kurulum gerektirir)",
"form.prefs.select.older_first": "Önce eski iletiler",
"form.prefs.select.recent_first": "Önce yeni iletiler",
"form.prefs.select.fullscreen": "Tam Ekran",
"form.prefs.select.standalone": "Bağımsız",
"form.prefs.select.minimal_ui": "Minimal",
"form.prefs.select.browser": "Tarayıcı",
"form.prefs.label.keyboard_shortcuts": "Klavye kısayollarını etkinleştir",
"form.prefs.label.entry_swipe": "Mobil cihazlarda iletiler için kaydırma hareketlerini etkinleştir",
"form.prefs.label.show_reading_time": "Makaleler için tahmini okuma süresini göster",
"form.prefs.label.custom_css": "Özel CSS",
"form.import.label.file": "OPML dosyası",
"form.import.label.url": "URL",
"form.integration.fever_activate": "Fever API'yi Etkinleştir",
"form.integration.fever_username": "Fever Kullanıcı Adı",
"form.integration.fever_password": "Fever Parolası",
"form.integration.fever_endpoint": "Fever API uç noktası:",
"form.integration.pinboard_activate": "Makaleleri Pinboard'a kaydet",
"form.integration.pinboard_token": "Pinboard API Token",
"form.integration.pinboard_tags": "Pinboard Etiketleri",
"form.integration.pinboard_bookmark": "Yer imini okunmadı olarak işaretle",
"form.integration.instapaper_activate": "Makaleleri Instapaper'a kaydet",
"form.integration.instapaper_username": "Instapaper Kullanıcı Adı",
"form.integration.instapaper_password": "Instapaper Parolası",
"form.integration.pocket_activate": "Makaleleri Pocket'a kaydet",
"form.integration.pocket_consumer_key": "Pocket Consumer Anahtarı",
"form.integration.pocket_access_token": "Pocket Access Token",
"form.integration.pocket_connect_link": "Pocket hesabını bağla",
"form.integration.wallabag_activate": "Makaleleri Wallabag'e kaydet",
"form.integration.wallabag_endpoint": "Wallabag API Uç Noktası",
"form.integration.wallabag_client_id": "Wallabag Client ID",
"form.integration.wallabag_client_secret": "Wallabag Client Secret",
"form.integration.wallabag_username": "Wallabag Kullanıcı Adı",
"form.integration.wallabag_password": "Wallabag Parolası",
"form.integration.nunux_keeper_activate": "Makaleleri Nunux Keeper'a kaydet",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper API Uç Noktası",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API anahtarı",
"form.api_key.label.description": "API Anahtar Etiketi",
"form.submit.loading": "Yükleniyor...",
"form.submit.saving": "Kaydediliyor...",
"time_elapsed.not_yet": "henüz değil",
"time_elapsed.yesterday": "dün",
"time_elapsed.now": "şimdi",
"time_elapsed.minutes": [
"%d dakika önce",
"%d dakika önce"
],
"time_elapsed.hours": [
"%d saat önce",
"%d saat önce"
],
"time_elapsed.days": [
"%d gün önce",
"%d gün önce"
],
"time_elapsed.weeks": [
"%d hafta önce",
"%d hafta önce"
],
"time_elapsed.months": [
"%d ay önce",
"%d ay önce"
],
"time_elapsed.years": [
"%d yıl önce",
"%d yıl önce"
]
}
+13
View File
@@ -91,6 +91,7 @@
"page.categories.feed_count": [
"有 %d 个源"
],
"page.categories.unread_counter": "未读条目数",
"page.new_category.title": "新分类",
"page.new_user.title": "新用户",
"page.edit_category.title": "编辑分类 : %s",
@@ -111,6 +112,7 @@
"page.about.build_date": "构建日期:",
"page.about.author": "作者:",
"page.about.license": "协议:",
"page.about.postgres_version": "Postgres 版本号:",
"page.about.global_config_options": "全局配置选项",
"page.add_feed.title": "新增订阅",
"page.add_feed.no_category": "没有类别,您必须至少有一个类别",
@@ -197,6 +199,9 @@
"page.api_keys.table.actions": "操作",
"page.api_keys.never_used": "没用过",
"page.new_api_key.title": "新的API密钥",
"page.offline.title": "离线模式",
"page.offline.message": "您离线",
"page.offline.refresh_page": "尝试刷新页面",
"alert.no_shared_entry": "没有共享条目。",
"alert.no_bookmark": "目前没有书签",
"alert.no_category": "目前没有分类",
@@ -251,6 +256,7 @@
"error.invalid_language": "语言无效。",
"error.invalid_timezone": "无效的时区。",
"error.invalid_entry_direction": "无效的输入方向。",
"error.invalid_display_mode": "无效的Web应用显示模式。",
"form.feed.label.title": "标题",
"form.feed.label.site_url": "站点 URL",
"form.feed.label.feed_url": "源 URL",
@@ -259,11 +265,13 @@
"form.feed.label.feed_username": "源用户名",
"form.feed.label.feed_password": "源密码",
"form.feed.label.user_agent": "覆盖默认 User-Agent",
"form.feed.label.cookie": "设置 Cookies",
"form.feed.label.scraper_rules": "Scraper 规则",
"form.feed.label.rewrite_rules": "重写规则",
"form.feed.label.blocklist_rules": "封锁规则",
"form.feed.label.keeplist_rules": "许可规则",
"form.feed.label.ignore_http_cache": "忽略HTTP缓存",
"form.feed.label.allow_self_signed_certificates": "允许自签名或无效的证书",
"form.feed.label.fetch_via_proxy": "通过代理获取",
"form.feed.label.disabled": "请勿刷新此Feed",
"form.category.label.title": "标题",
@@ -276,8 +284,13 @@
"form.prefs.label.theme": "主题",
"form.prefs.label.entry_sorting": "内容排序",
"form.prefs.label.entries_per_page": "每页条目",
"form.prefs.label.display_mode": "Web应用程序显示模式 (需要重新安装)",
"form.prefs.select.older_first": "旧->新",
"form.prefs.select.recent_first": "新->旧",
"form.prefs.select.fullscreen": "全屏",
"form.prefs.select.standalone": "单机版",
"form.prefs.select.minimal_ui": "最小的",
"form.prefs.select.browser": "浏览器",
"form.prefs.label.keyboard_shortcuts": "启用键盘快捷键",
"form.prefs.label.entry_swipe": "在移动设备上的条目上启用滑动手势",
"form.prefs.label.show_reading_time": "显示文章的预计阅读时间",
-66
View File
@@ -1,66 +0,0 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package locale // import "miniflux.app/locale"
import "testing"
func TestAllLanguagesHaveCatalog(t *testing.T) {
for language := range AvailableLanguages() {
if _, found := translations[language]; !found {
t.Fatalf(`This language do not have a catalog: %q`, language)
}
}
}
func TestAllKeysHaveValue(t *testing.T) {
for language := range AvailableLanguages() {
messages, err := parseTranslationDict(translations[language])
if err != nil {
t.Fatalf(`Parsing error for language %q`, language)
}
if len(messages) == 0 {
t.Fatalf(`The language %q doesn't have any messages`, language)
}
for k, v := range messages {
switch value := v.(type) {
case string:
if value == "" {
t.Errorf(`The key %q for the language %q have an empty string as value`, k, language)
}
case []string:
if len(value) == 0 {
t.Errorf(`The key %q for the language %q have an empty list as value`, k, language)
}
}
}
}
}
func TestMissingTranslations(t *testing.T) {
refLang := "en_US"
references, err := parseTranslationDict(translations[refLang])
if err != nil {
t.Fatal(`Unable to parse reference language`)
}
for language := range AvailableLanguages() {
if language == refLang {
continue
}
messages, err := parseTranslationDict(translations[language])
if err != nil {
t.Fatalf(`Parsing error for language %q`, language)
}
for key := range references {
if _, found := messages[key]; !found {
t.Fatalf(`Translation key %q not found in language %q`, key, language)
}
}
}
}
-8
View File
@@ -4,14 +4,6 @@
package main // import "miniflux.app"
//go:generate go run generate.go
//go:generate gofmt -s -w ui/static/css.go
//go:generate gofmt -s -w ui/static/bin.go
//go:generate gofmt -s -w ui/static/js.go
//go:generate gofmt -s -w template/views.go
//go:generate gofmt -s -w template/common.go
//go:generate gofmt -s -w locale/translations.go
import (
"miniflux.app/cli"
)
+15 -2
View File
@@ -1,5 +1,5 @@
.\" Manpage for miniflux.
.TH "MINIFLUX" "1" "September 28, 2020" "\ \&" "\ \&"
.TH "MINIFLUX" "1" "April 30, 2021" "\ \&" "\ \&"
.SH NAME
miniflux \- Minimalist and opinionated feed reader
@@ -43,6 +43,13 @@ Show debug logs\&.
Flush all sessions (disconnect users)\&.
.RE
.PP
.B \-healthcheck
.RS 4
Perform a health check on the given endpoint\&.
.br
The value "auto" try to guess the health check endpoint\&.
.RE
.PP
.B \-i
.RS 4
Show application information\&.
@@ -100,6 +107,12 @@ Set the value to 1 to enable debug logs\&.
.br
Disabled by default\&.
.TP
.B FETCH_YOUTUBE_WATCH_TIME
Set the value to 1 to scrape video duration from YouTube website and
use it as a reading time\&.
.br
Disabled by default\&.
.TP
.B SERVER_TIMING_HEADER
Set the value to 1 to enable server-timing headers\&.
.br
@@ -123,7 +136,7 @@ Default is 60 minutes\&.
.B BATCH_SIZE
Number of feeds to send to the queue for each interval\&.
.br
Default is 10 feeds\&.
Default is 100 feeds\&.
.TP
.B POLLING_SCHEDULER
Scheduler used for polling feeds. Possible values are "round_robin" or "entry_frequency"\&.
+5 -4
View File
@@ -8,10 +8,11 @@ import "fmt"
// Category represents a feed category.
type Category struct {
ID int64 `json:"id"`
Title string `json:"title"`
UserID int64 `json:"user_id"`
FeedCount int `json:"-"`
ID int64 `json:"id"`
Title string `json:"title"`
UserID int64 `json:"user_id"`
FeedCount int `json:"-"`
TotalUnread int `json:"-"`
}
func (c *Category) String() string {
+69 -55
View File
@@ -24,33 +24,35 @@ const (
// Feed represents a feed in the application.
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"`
NextCheckAt time.Time `json:"next_check_at"`
EtagHeader string `json:"etag_header"`
LastModifiedHeader string `json:"last_modified_header"`
ParsingErrorMsg string `json:"parsing_error_message"`
ParsingErrorCount int `json:"parsing_error_count"`
ScraperRules string `json:"scraper_rules"`
RewriteRules string `json:"rewrite_rules"`
Crawler bool `json:"crawler"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
UserAgent string `json:"user_agent"`
Username string `json:"username"`
Password string `json:"password"`
Disabled bool `json:"disabled"`
IgnoreHTTPCache bool `json:"ignore_http_cache"`
FetchViaProxy bool `json:"fetch_via_proxy"`
Category *Category `json:"category,omitempty"`
Entries Entries `json:"entries,omitempty"`
Icon *FeedIcon `json:"icon"`
UnreadCount int `json:"-"`
ReadCount int `json:"-"`
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"`
NextCheckAt time.Time `json:"next_check_at"`
EtagHeader string `json:"etag_header"`
LastModifiedHeader string `json:"last_modified_header"`
ParsingErrorMsg string `json:"parsing_error_message"`
ParsingErrorCount int `json:"parsing_error_count"`
ScraperRules string `json:"scraper_rules"`
RewriteRules string `json:"rewrite_rules"`
Crawler bool `json:"crawler"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
UserAgent string `json:"user_agent"`
Cookie string `json:"cookie"`
Username string `json:"username"`
Password string `json:"password"`
Disabled bool `json:"disabled"`
IgnoreHTTPCache bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
FetchViaProxy bool `json:"fetch_via_proxy"`
Category *Category `json:"category,omitempty"`
Entries Entries `json:"entries,omitempty"`
Icon *FeedIcon `json:"icon"`
UnreadCount int `json:"-"`
ReadCount int `json:"-"`
}
func (f *Feed) String() string {
@@ -117,38 +119,42 @@ func (f *Feed) ScheduleNextCheck(weeklyCount int) {
// FeedCreationRequest represents the request to create a feed.
type FeedCreationRequest struct {
FeedURL string `json:"feed_url"`
CategoryID int64 `json:"category_id"`
UserAgent string `json:"user_agent"`
Username string `json:"username"`
Password string `json:"password"`
Crawler bool `json:"crawler"`
Disabled bool `json:"disabled"`
IgnoreHTTPCache bool `json:"ignore_http_cache"`
FetchViaProxy bool `json:"fetch_via_proxy"`
ScraperRules string `json:"scraper_rules"`
RewriteRules string `json:"rewrite_rules"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
FeedURL string `json:"feed_url"`
CategoryID int64 `json:"category_id"`
UserAgent string `json:"user_agent"`
Cookie string `json:"cookie"`
Username string `json:"username"`
Password string `json:"password"`
Crawler bool `json:"crawler"`
Disabled bool `json:"disabled"`
IgnoreHTTPCache bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
FetchViaProxy bool `json:"fetch_via_proxy"`
ScraperRules string `json:"scraper_rules"`
RewriteRules string `json:"rewrite_rules"`
BlocklistRules string `json:"blocklist_rules"`
KeeplistRules string `json:"keeplist_rules"`
}
// FeedModificationRequest represents the request to update a feed.
type FeedModificationRequest struct {
FeedURL *string `json:"feed_url"`
SiteURL *string `json:"site_url"`
Title *string `json:"title"`
ScraperRules *string `json:"scraper_rules"`
RewriteRules *string `json:"rewrite_rules"`
BlocklistRules *string `json:"blocklist_rules"`
KeeplistRules *string `json:"keeplist_rules"`
Crawler *bool `json:"crawler"`
UserAgent *string `json:"user_agent"`
Username *string `json:"username"`
Password *string `json:"password"`
CategoryID *int64 `json:"category_id"`
Disabled *bool `json:"disabled"`
IgnoreHTTPCache *bool `json:"ignore_http_cache"`
FetchViaProxy *bool `json:"fetch_via_proxy"`
FeedURL *string `json:"feed_url"`
SiteURL *string `json:"site_url"`
Title *string `json:"title"`
ScraperRules *string `json:"scraper_rules"`
RewriteRules *string `json:"rewrite_rules"`
BlocklistRules *string `json:"blocklist_rules"`
KeeplistRules *string `json:"keeplist_rules"`
Crawler *bool `json:"crawler"`
UserAgent *string `json:"user_agent"`
Cookie *string `json:"cookie"`
Username *string `json:"username"`
Password *string `json:"password"`
CategoryID *int64 `json:"category_id"`
Disabled *bool `json:"disabled"`
IgnoreHTTPCache *bool `json:"ignore_http_cache"`
AllowSelfSignedCertificates *bool `json:"allow_self_signed_certificates"`
FetchViaProxy *bool `json:"fetch_via_proxy"`
}
// Patch updates a feed with modified values.
@@ -189,6 +195,10 @@ func (f *FeedModificationRequest) Patch(feed *Feed) {
feed.UserAgent = *f.UserAgent
}
if f.Cookie != nil {
feed.Cookie = *f.Cookie
}
if f.Username != nil {
feed.Username = *f.Username
}
@@ -209,6 +219,10 @@ func (f *FeedModificationRequest) Patch(feed *Feed) {
feed.IgnoreHTTPCache = *f.IgnoreHTTPCache
}
if f.AllowSelfSignedCertificates != nil {
feed.AllowSelfSignedCertificates = *f.AllowSelfSignedCertificates
}
if f.FetchViaProxy != nil {
feed.FetchViaProxy = *f.FetchViaProxy
}
+7 -5
View File
@@ -6,9 +6,11 @@ package model // import "miniflux.app/model"
// SubscriptionDiscoveryRequest represents a request to discover subscriptions.
type SubscriptionDiscoveryRequest struct {
URL string `json:"url"`
UserAgent string `json:"user_agent"`
Username string `json:"username"`
Password string `json:"password"`
FetchViaProxy bool `json:"fetch_via_proxy"`
URL string `json:"url"`
UserAgent string `json:"user_agent"`
Cookie string `json:"cookie"`
Username string `json:"username"`
Password string `json:"password"`
FetchViaProxy bool `json:"fetch_via_proxy"`
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
}
+6
View File
@@ -28,6 +28,7 @@ type User struct {
ShowReadingTime bool `json:"show_reading_time"`
EntrySwipe bool `json:"entry_swipe"`
LastLoginAt *time.Time `json:"last_login_at"`
DisplayMode string `json:"display_mode"`
}
// UserCreationRequest represents the request to create a user.
@@ -55,6 +56,7 @@ type UserModificationRequest struct {
KeyboardShortcuts *bool `json:"keyboard_shortcuts"`
ShowReadingTime *bool `json:"show_reading_time"`
EntrySwipe *bool `json:"entry_swipe"`
DisplayMode *string `json:"display_mode"`
}
// Patch updates the User object with the modification request.
@@ -114,6 +116,10 @@ func (u *UserModificationRequest) Patch(user *User) {
if u.EntrySwipe != nil {
user.EntrySwipe = *u.EntrySwipe
}
if u.DisplayMode != nil {
user.DisplayMode = *u.DisplayMode
}
}
// UseTimezone converts last login date to the given timezone.
-1
View File
@@ -20,7 +20,6 @@ LABEL org.opencontainers.image.documentation=https://miniflux.app/docs/
EXPOSE 8080
ENV LISTEN_ADDR 0.0.0.0:8080
RUN apk --no-cache add ca-certificates tzdata
COPY --from=build /go/src/app/miniflux /usr/bin/miniflux
USER nobody
+4 -3
View File
@@ -9,12 +9,13 @@ Description=Miniflux Feed Reader
After=network.target postgresql.service
[Service]
Type=simple
EnvironmentFile=/etc/miniflux.conf
User=miniflux
Type=notify
ExecStart=/usr/bin/miniflux
Restart=always
EnvironmentFile=/etc/miniflux.conf
User=miniflux
# https://www.freedesktop.org/software/systemd/man/systemd.exec.html#NoNewPrivileges=
NoNewPrivileges=true
+8 -8
View File
@@ -144,10 +144,10 @@ func (a *atom03Entry) entryHash() string {
}
type atom03Text struct {
Type string `xml:"type,attr"`
Mode string `xml:"mode,attr"`
Data string `xml:",chardata"`
XML string `xml:",innerxml"`
Type string `xml:"type,attr"`
Mode string `xml:"mode,attr"`
CharData string `xml:",chardata"`
InnerXML string `xml:",innerxml"`
}
func (a *atom03Text) String() string {
@@ -155,16 +155,16 @@ func (a *atom03Text) String() string {
switch {
case a.Mode == "xml":
content = a.XML
content = a.InnerXML
case a.Mode == "escaped":
content = a.Data
content = a.CharData
case a.Mode == "base64":
b, err := base64.StdEncoding.DecodeString(a.Data)
b, err := base64.StdEncoding.DecodeString(a.CharData)
if err == nil {
content = string(b)
}
default:
content = a.Data
content = a.CharData
}
if a.Type != "text/html" {
+25 -10
View File
@@ -48,7 +48,7 @@ func (a *atom10Feed) Transform(baseURL string) *model.Feed {
feed.SiteURL = siteURL
}
feed.Title = a.Title.String()
feed.Title = html.UnescapeString(a.Title.String())
if feed.Title == "" {
feed.Title = feed.SiteURL
}
@@ -100,7 +100,7 @@ func (a *atom10Entry) Transform() *model.Entry {
}
func (a *atom10Entry) entryTitle() string {
return a.Title.String()
return html.UnescapeString(a.Title.String())
}
func (a *atom10Entry) entryContent() string {
@@ -157,7 +157,7 @@ func (a *atom10Entry) entryHash() string {
func (a *atom10Entry) entryEnclosures() model.EnclosureList {
enclosures := make(model.EnclosureList, 0)
duplicates := make(map[string]bool, 0)
duplicates := make(map[string]bool)
for _, mediaThumbnail := range a.AllMediaThumbnails() {
if _, found := duplicates[mediaThumbnail.URL]; !found {
@@ -221,20 +221,35 @@ func (a *atom10Entry) entryCommentsURL() string {
}
type atom10Text struct {
Type string `xml:"type,attr"`
Data string `xml:",chardata"`
XML string `xml:",innerxml"`
Type string `xml:"type,attr"`
CharData string `xml:",chardata"`
InnerXML string `xml:",innerxml"`
XHTMLRootElement atomXHTMLRootElement `xml:"http://www.w3.org/1999/xhtml div"`
}
func (a *atom10Text) String() string {
content := ""
var content string
switch {
case a.Type == "", a.Type == "text", a.Type == "text/plain":
if strings.HasPrefix(a.InnerXML, `<![CDATA[`) {
content = html.EscapeString(a.CharData)
} else {
content = a.InnerXML
}
case a.Type == "xhtml":
content = a.XML
if a.XHTMLRootElement.InnerXML != "" {
content = a.XHTMLRootElement.InnerXML
} else {
content = a.InnerXML
}
default:
content = a.Data
content = a.CharData
}
return html.UnescapeString(strings.TrimSpace(content))
return strings.TrimSpace(content)
}
type atomXHTMLRootElement struct {
InnerXML string `xml:",innerxml"`
}
+284 -62
View File
@@ -244,12 +244,57 @@ func TestParseEntryTitleWithWhitespaces(t *testing.T) {
}
}
func TestParseEntryTitleWithHTMLAndCDATA(t *testing.T) {
func TestParseEntryWithPlainTextTitle(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<entry>
<title type="text">AT&amp;T bought by SBC!</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>
<entry>
<title>AT&amp;T bought by SBC!</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("https://example.org/", bytes.NewBufferString(data))
if err != nil {
t.Fatal(err)
}
expected := `AT&T bought by SBC!`
for i := 0; i < 2; i++ {
if feed.Entries[i].Title != expected {
t.Errorf("Incorrect title for entry #%d, got: %q", i, feed.Entries[i].Title)
}
}
}
func TestParseEntryWithHTMLTitle(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<entry>
<title type="html">&lt;code&gt;Test&lt;/code&gt; Test</title>
<link href="http://example.org/2003/12/13/atom03"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary>Some text.</summary>
</entry>
<entry>
<title type="html"><![CDATA[Test &#8220;Test&#8221;]]></title>
<link href="http://example.org/2003/12/13/atom03"/>
@@ -265,38 +310,16 @@ func TestParseEntryTitleWithHTMLAndCDATA(t *testing.T) {
t.Fatal(err)
}
if feed.Entries[0].Title != "Test “Test”" {
t.Errorf("Incorrect entry title, got: %q", feed.Entries[0].Title)
}
}
func TestParseEntryTitleWithHTML(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<entry>
<title type="html">&lt;code&gt;Test&lt;/code&gt; Test</title>
<link href="http://example.org/2003/12/13/atom03"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary>Some text.</summary>
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
if err != nil {
t.Fatal(err)
}
if feed.Entries[0].Title != "<code>Test</code> Test" {
t.Errorf("Incorrect entry title, got: %q", feed.Entries[0].Title)
}
if feed.Entries[1].Title != "Test “Test”" {
t.Errorf("Incorrect entry title, got: %q", feed.Entries[1].Title)
}
}
func TestParseEntryTitleWithXHTML(t *testing.T) {
func TestParseEntryWithXHTMLTitle(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
@@ -304,7 +327,19 @@ func TestParseEntryTitleWithXHTML(t *testing.T) {
<entry>
<title type="xhtml"><code>Test</code> Test</title>
<link href="http://example.org/2003/12/13/atom03"/>
<link href="http://example.org/a"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary>Some text.</summary>
</entry>
<entry>
<title type="xhtml">
<div xmlns="http://www.w3.org/1999/xhtml">
This is <b>XHTML</b> content.
</div>
</title>
<link href="http://example.org/b"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary>Some text.</summary>
@@ -317,12 +352,16 @@ func TestParseEntryTitleWithXHTML(t *testing.T) {
t.Fatal(err)
}
if feed.Entries[0].Title != "<code>Test</code> Test" {
if feed.Entries[0].Title != `<code>Test</code> Test` {
t.Errorf("Incorrect entry title, got: %q", feed.Entries[0].Title)
}
if feed.Entries[1].Title != `This is <b>XHTML</b> content.` {
t.Errorf("Incorrect entry title, got: %q", feed.Entries[1].Title)
}
}
func TestParseEntryTitleWithNumericCharacterReference(t *testing.T) {
func TestParseEntryWithNumericCharacterReferenceTitle(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
@@ -348,7 +387,7 @@ func TestParseEntryTitleWithNumericCharacterReference(t *testing.T) {
}
}
func TestParseEntryTitleWithDoubleEncodedEntities(t *testing.T) {
func TestParseEntryWithDoubleEncodedEntitiesTitle(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
@@ -369,49 +408,31 @@ func TestParseEntryTitleWithDoubleEncodedEntities(t *testing.T) {
t.Fatal(err)
}
if feed.Entries[0].Title != `'AT&T'` {
if feed.Entries[0].Title != `&#39;AT&amp;T&#39;` {
t.Errorf("Incorrect entry title, got: %q", feed.Entries[0].Title)
}
}
func TestParseEntrySummaryWithXHTML(t *testing.T) {
func TestParseEntryWithXHTMLSummary(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<entry>
<title type="xhtml"><code>Test</code> Test</title>
<title type="xhtml">Example</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 type="xhtml"><p>Some text.</p></summary>
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
if err != nil {
t.Fatal(err)
}
if feed.Entries[0].Content != "<p>Some text.</p>" {
t.Errorf("Incorrect entry content, got: %s", feed.Entries[0].Content)
}
}
func TestParseEntrySummaryWithHTML(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<entry>
<title type="html">&lt;code&gt;Test&lt;/code&gt; Test</title>
<title type="xhtml">Example</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 type="html"><![CDATA[<p>Some text.</p>]]></summary>
<summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>Test: <code>std::unique_ptr&lt;S&gt;</code></p></div></summary>
</entry>
</feed>`
@@ -421,23 +442,43 @@ func TestParseEntrySummaryWithHTML(t *testing.T) {
t.Fatal(err)
}
if feed.Entries[0].Content != "<p>Some text.</p>" {
if feed.Entries[0].Content != `<p>Some text.</p>` {
t.Errorf("Incorrect entry content, got: %s", feed.Entries[0].Content)
}
if feed.Entries[1].Content != `<p>Test: <code>std::unique_ptr&lt;S&gt;</code></p>` {
t.Errorf("Incorrect entry content, got: %s", feed.Entries[1].Content)
}
}
func TestParseEntrySummaryWithPlainText(t *testing.T) {
func TestParseEntryWithHTMLSummary(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<entry>
<title type="html">&lt;code&gt;Test&lt;/code&gt; Test</title>
<link href="http://example.org/2003/12/13/atom03"/>
<title type="html">Example</title>
<link href="http://example.org/1"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary type="text"><![CDATA[<Some text.>]]></summary>
<summary type="html">&lt;code&gt;std::unique_ptr&amp;lt;S&amp;gt;&lt;/code&gt;</summary>
</entry>
<entry>
<title type="html">Example</title>
<link href="http://example.org/2"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary type="text/html">&lt;code&gt;std::unique_ptr&amp;lt;S&amp;gt;&lt;/code&gt;</summary>
</entry>
<entry>
<title type="html">Example</title>
<link href="http://example.org/3"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary type="html"><![CDATA[<code>std::unique_ptr&lt;S&gt;</code>]]></summary>
</entry>
</feed>`
@@ -447,8 +488,189 @@ func TestParseEntrySummaryWithPlainText(t *testing.T) {
t.Fatal(err)
}
if feed.Entries[0].Content != "<Some text.>" {
t.Errorf("Incorrect entry content, got: %s", feed.Entries[0].Content)
expected := `<code>std::unique_ptr&lt;S&gt;</code>`
for i := 0; i < 3; i++ {
if feed.Entries[i].Content != expected {
t.Errorf("Incorrect content for entry #%d, got: %q", i, feed.Entries[i].Content)
}
}
}
func TestParseEntryWithTextSummary(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<entry>
<title type="html">Example</title>
<link href="http://example.org/a"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary>AT&amp;T &lt;S&gt;</summary>
</entry>
<entry>
<title type="html">Example</title>
<link href="http://example.org/b"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary type="text">AT&amp;T &lt;S&gt;</summary>
</entry>
<entry>
<title type="html">Example</title>
<link href="http://example.org/c"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary type="text/plain">AT&amp;T &lt;S&gt;</summary>
</entry>
<entry>
<title type="html">Example</title>
<link href="http://example.org/d"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary type="text"><![CDATA[AT&T <S>]]></summary>
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
if err != nil {
t.Fatal(err)
}
expected := `AT&amp;T &lt;S&gt;`
for i := 0; i < 4; i++ {
if feed.Entries[i].Content != expected {
t.Errorf("Incorrect content for entry #%d, got: %q", i, feed.Entries[i].Content)
}
}
}
func TestParseEntryWithTextContent(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<entry>
<title type="html">Example</title>
<link href="http://example.org/a"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<content>AT&amp;T &lt;S&gt;</content>
</entry>
<entry>
<title type="html">Example</title>
<link href="http://example.org/b"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<content type="text">AT&amp;T &lt;S&gt;</content>
</entry>
<entry>
<title type="html">Example</title>
<link href="http://example.org/c"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<content type="text/plain">AT&amp;T &lt;S&gt;</content>
</entry>
<entry>
<title type="html">Example</title>
<link href="http://example.org/d"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<content><![CDATA[AT&T <S>]]></content>
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
if err != nil {
t.Fatal(err)
}
expected := `AT&amp;T &lt;S&gt;`
for i := 0; i < 4; i++ {
if feed.Entries[i].Content != expected {
t.Errorf("Incorrect content for entry #%d, got: %q", i, feed.Entries[i].Content)
}
}
}
func TestParseEntryWithHTMLContent(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<entry>
<title type="html">Example</title>
<link href="http://example.org/a"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<content type="html">AT&amp;amp;T bought &lt;b&gt;by SBC&lt;/b&gt;!</content>
</entry>
<entry>
<title type="html">Example</title>
<link href="http://example.org/b"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<content type="text/html">AT&amp;amp;T bought &lt;b&gt;by SBC&lt;/b&gt;!</content>
</entry>
<entry>
<title type="html">Example</title>
<link href="http://example.org/c"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<content type="html"><![CDATA[AT&amp;T bought <b>by SBC</b>!]]></content>
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
if err != nil {
t.Fatal(err)
}
expected := `AT&amp;T bought <b>by SBC</b>!`
for i := 0; i < 3; i++ {
if feed.Entries[i].Content != expected {
t.Errorf("Incorrect content for entry #%d, got: %q", i, feed.Entries[i].Content)
}
}
}
func TestParseEntryWithXHTMLContent(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>Example</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>
<content type="xhtml">
<div xmlns="http://www.w3.org/1999/xhtml">AT&amp;T bought <b>by SBC</b>!</div>
</content>
</entry>
</feed>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
if err != nil {
t.Fatal(err)
}
if feed.Entries[0].Content != `AT&amp;T bought <b>by SBC</b>!` {
t.Errorf("Incorrect entry content, got: %q", feed.Entries[0].Content)
}
}
+2
View File
@@ -293,6 +293,8 @@ var invalidLocalizedDateReplacer = strings.NewReplacer(
"mai ", "May ",
"jui ", "Jun ",
"juin ", "June ",
"Thurs,", "Thu,",
"Thur,", "Thu,",
)
// Parse parses a given date string using a large
+2
View File
@@ -178,6 +178,8 @@ func TestParseWeirdDateFormat(t *testing.T) {
"2018-10-23 04:07:42 +00:00",
"5 August, 2019",
"mar., 01 déc. 2020 16:11:02 +0000",
"Thurs, 15 Oct 2020 00:00:39 +0000",
"Thur, 19 Nov 2020 00:00:39 +0000",
}
for _, date := range dates {
+1 -2
View File
@@ -7,7 +7,6 @@ package encoding // import "miniflux.app/reader/encoding"
import (
"bytes"
"io"
"io/ioutil"
"unicode/utf8"
"golang.org/x/net/html/charset"
@@ -25,7 +24,7 @@ import (
// - Feeds with encoding specified only in XML document and not in HTTP header
// - Feeds with wrong encoding defined and already in UTF-8
func CharsetReader(label string, input io.Reader) (io.Reader, error) {
buffer, _ := ioutil.ReadAll(input)
buffer, _ := io.ReadAll(input)
r := bytes.NewReader(buffer)
// The document is already UTF-8, do not do anything (avoid double-encoding).
+22 -4
View File
@@ -39,6 +39,8 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
request := client.NewClientWithConfig(feedCreationRequest.FeedURL, config.Opts)
request.WithCredentials(feedCreationRequest.Username, feedCreationRequest.Password)
request.WithUserAgent(feedCreationRequest.UserAgent)
request.WithCookie(feedCreationRequest.Cookie)
request.AllowSelfSignedCertificates = feedCreationRequest.AllowSelfSignedCertificates
if feedCreationRequest.FetchViaProxy {
request.WithProxy()
@@ -60,11 +62,13 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
subscription.UserID = userID
subscription.UserAgent = feedCreationRequest.UserAgent
subscription.Cookie = feedCreationRequest.Cookie
subscription.Username = feedCreationRequest.Username
subscription.Password = feedCreationRequest.Password
subscription.Crawler = feedCreationRequest.Crawler
subscription.Disabled = feedCreationRequest.Disabled
subscription.IgnoreHTTPCache = feedCreationRequest.IgnoreHTTPCache
subscription.AllowSelfSignedCertificates = feedCreationRequest.AllowSelfSignedCertificates
subscription.FetchViaProxy = feedCreationRequest.FetchViaProxy
subscription.ScraperRules = feedCreationRequest.ScraperRules
subscription.RewriteRules = feedCreationRequest.RewriteRules
@@ -82,7 +86,13 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
logger.Debug("[CreateFeed] Feed saved with ID: %d", subscription.ID)
checkFeedIcon(store, subscription.ID, subscription.SiteURL, feedCreationRequest.FetchViaProxy)
checkFeedIcon(
store,
subscription.ID,
subscription.SiteURL,
feedCreationRequest.FetchViaProxy,
feedCreationRequest.AllowSelfSignedCertificates,
)
return subscription, nil
}
@@ -116,6 +126,8 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64) error {
request := client.NewClientWithConfig(originalFeed.FeedURL, config.Opts)
request.WithCredentials(originalFeed.Username, originalFeed.Password)
request.WithUserAgent(originalFeed.UserAgent)
request.WithCookie(originalFeed.Cookie)
request.AllowSelfSignedCertificates = originalFeed.AllowSelfSignedCertificates
if !originalFeed.IgnoreHTTPCache {
request.WithCacheHeaders(originalFeed.EtagHeader, originalFeed.LastModifiedHeader)
@@ -162,7 +174,13 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64) error {
// We update caching headers only if the feed has been modified,
// because some websites don't return the same headers when replying with a 304.
originalFeed.WithClientResponse(response)
checkFeedIcon(store, originalFeed.ID, originalFeed.SiteURL, originalFeed.FetchViaProxy)
checkFeedIcon(
store,
originalFeed.ID,
originalFeed.SiteURL,
originalFeed.FetchViaProxy,
originalFeed.AllowSelfSignedCertificates,
)
} else {
logger.Debug("[RefreshFeed] Feed #%d not modified", feedID)
}
@@ -178,9 +196,9 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64) error {
return nil
}
func checkFeedIcon(store *storage.Storage, feedID int64, websiteURL string, fetchViaProxy bool) {
func checkFeedIcon(store *storage.Storage, feedID int64, websiteURL string, fetchViaProxy, allowSelfSignedCertificates bool) {
if !store.HasIcon(feedID) {
icon, err := icon.FindIcon(websiteURL, fetchViaProxy)
icon, err := icon.FindIcon(websiteURL, fetchViaProxy, allowSelfSignedCertificates)
if err != nil {
logger.Debug(`[CheckFeedIcon] %v (feedID=%d websiteURL=%s)`, err, feedID, websiteURL)
} else if icon == nil {
+7 -5
View File
@@ -8,7 +8,6 @@ import (
"encoding/base64"
"fmt"
"io"
"io/ioutil"
"strings"
"miniflux.app/config"
@@ -22,12 +21,14 @@ import (
)
// FindIcon try to find the website's icon.
func FindIcon(websiteURL string, fetchViaProxy bool) (*model.Icon, error) {
func FindIcon(websiteURL string, fetchViaProxy, allowSelfSignedCertificates bool) (*model.Icon, error) {
rootURL := url.RootURL(websiteURL)
clt := client.NewClientWithConfig(rootURL, config.Opts)
clt.AllowSelfSignedCertificates = allowSelfSignedCertificates
if fetchViaProxy {
clt.WithProxy()
}
response, err := clt.Get()
if err != nil {
return nil, fmt.Errorf("unable to download website index page: %v", err)
@@ -47,7 +48,7 @@ func FindIcon(websiteURL string, fetchViaProxy bool) (*model.Icon, error) {
}
logger.Debug("[FindIcon] Fetching icon => %s", iconURL)
icon, err := downloadIcon(iconURL, fetchViaProxy)
icon, err := downloadIcon(iconURL, fetchViaProxy, allowSelfSignedCertificates)
if err != nil {
return nil, err
}
@@ -90,8 +91,9 @@ func parseDocument(websiteURL string, data io.Reader) (string, error) {
return iconURL, nil
}
func downloadIcon(iconURL string, fetchViaProxy bool) (*model.Icon, error) {
func downloadIcon(iconURL string, fetchViaProxy, allowSelfSignedCertificates bool) (*model.Icon, error) {
clt := client.NewClientWithConfig(iconURL, config.Opts)
clt.AllowSelfSignedCertificates = allowSelfSignedCertificates
if fetchViaProxy {
clt.WithProxy()
}
@@ -104,7 +106,7 @@ func downloadIcon(iconURL string, fetchViaProxy bool) (*model.Icon, error) {
return nil, fmt.Errorf("unable to download icon: status=%d", response.StatusCode)
}
body, err := ioutil.ReadAll(response.Body)
body, err := io.ReadAll(response.Body)
if err != nil {
return nil, fmt.Errorf("unable to read downloaded icon: %v", err)
}
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"miniflux.app/model"
)
// Parse returns a normalized feed struct from a JON feed.
// Parse returns a normalized feed struct from a JSON feed.
func Parse(baseURL string, data io.Reader) (*model.Feed, *errors.LocalizedError) {
feed := new(jsonFeed)
decoder := json.NewDecoder(data)
+2 -2
View File
@@ -6,7 +6,7 @@ package parser // import "miniflux.app/reader/parser"
import (
"bytes"
"io/ioutil"
"os"
"testing"
"miniflux.app/http/client"
@@ -329,7 +329,7 @@ func TestDifferentEncodingWithResponse(t *testing.T) {
}
for _, tc := range unicodeTestCases {
content, err := ioutil.ReadFile("testdata/" + tc.filename)
content, err := os.ReadFile("testdata/" + tc.filename)
if err != nil {
t.Fatalf(`Unable to read file %q: %v`, tc.filename, err)
}
+133 -20
View File
@@ -5,24 +5,35 @@
package processor
import (
"errors"
"fmt"
"math"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
"miniflux.app/config"
"miniflux.app/http/client"
"miniflux.app/logger"
"miniflux.app/metric"
"miniflux.app/model"
"miniflux.app/reader/browser"
"miniflux.app/reader/rewrite"
"miniflux.app/reader/sanitizer"
"miniflux.app/reader/scraper"
"miniflux.app/storage"
"github.com/PuerkitoBio/goquery"
"github.com/rylans/getlang"
)
var (
youtubeRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)`)
iso8601Regex = regexp.MustCompile(`^P((?P<year>\d+)Y)?((?P<month>\d+)M)?((?P<week>\d+)W)?((?P<day>\d+)D)?(T((?P<hour>\d+)H)?((?P<minute>\d+)M)?((?P<second>\d+)S)?)?$`)
)
// ProcessFeedEntries downloads original web page for entries and apply filters.
func ProcessFeedEntries(store *storage.Storage, feed *model.Feed) {
var filteredEntries model.Entries
@@ -34,27 +45,32 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed) {
continue
}
if feed.Crawler {
if !store.EntryURLExists(feed.ID, entry.URL) {
logger.Debug("[Processor] Crawling entry %q from feed %q", entry.URL, feed.FeedURL)
entryIsNew := !store.EntryURLExists(feed.ID, entry.URL)
if feed.Crawler && entryIsNew {
logger.Debug("[Processor] Crawling entry %q from feed %q", entry.URL, feed.FeedURL)
startTime := time.Now()
content, scraperErr := scraper.Fetch(entry.URL, feed.ScraperRules, feed.UserAgent)
if config.Opts.HasMetricsCollector() {
status := "success"
if scraperErr != nil {
status = "error"
}
metric.ScraperRequestDuration.WithLabelValues(status).Observe(time.Since(startTime).Seconds())
}
startTime := time.Now()
content, scraperErr := scraper.Fetch(
entry.URL,
feed.ScraperRules,
feed.UserAgent,
feed.Cookie,
feed.AllowSelfSignedCertificates,
)
if config.Opts.HasMetricsCollector() {
status := "success"
if scraperErr != nil {
logger.Error(`[Processor] Unable to crawl this entry: %q => %v`, entry.URL, scraperErr)
} else if content != "" {
// We replace the entry content only if the scraper doesn't return any error.
entry.Content = content
status = "error"
}
metric.ScraperRequestDuration.WithLabelValues(status).Observe(time.Since(startTime).Seconds())
}
if scraperErr != nil {
logger.Error(`[Processor] Unable to crawl this entry: %q => %v`, entry.URL, scraperErr)
} else if content != "" {
// We replace the entry content only if the scraper doesn't return any error.
entry.Content = content
}
}
@@ -63,7 +79,7 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed) {
// The sanitizer should always run at the end of the process to make sure unsafe HTML is filtered.
entry.Content = sanitizer.Sanitize(entry.URL, entry.Content)
entry.ReadingTime = calculateReadingTime(entry.Content)
updateEntryReadingTime(store, feed, entry, entryIsNew)
filteredEntries = append(filteredEntries, entry)
}
@@ -94,9 +110,16 @@ func isAllowedEntry(feed *model.Feed, entry *model.Entry) bool {
}
// ProcessEntryWebPage downloads the entry web page and apply rewrite rules.
func ProcessEntryWebPage(entry *model.Entry) error {
func ProcessEntryWebPage(feed *model.Feed, entry *model.Entry) error {
startTime := time.Now()
content, scraperErr := scraper.Fetch(entry.URL, entry.Feed.ScraperRules, entry.Feed.UserAgent)
content, scraperErr := scraper.Fetch(
entry.URL,
entry.Feed.ScraperRules,
entry.Feed.UserAgent,
entry.Feed.Cookie,
feed.AllowSelfSignedCertificates,
)
if config.Opts.HasMetricsCollector() {
status := "success"
if scraperErr != nil {
@@ -120,6 +143,96 @@ func ProcessEntryWebPage(entry *model.Entry) error {
return nil
}
func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *model.Entry, entryIsNew bool) {
if shouldFetchYouTubeWatchTime(entry) {
if entryIsNew {
watchTime, err := fetchYouTubeWatchTime(entry.URL)
if err != nil {
logger.Error("[Processor] Unable to fetch YouTube watch time: %q => %v", entry.URL, err)
}
entry.ReadingTime = watchTime
} else {
entry.ReadingTime = store.GetReadTime(entry, feed)
}
}
// Handle YT error case and non-YT entries.
if entry.ReadingTime == 0 {
entry.ReadingTime = calculateReadingTime(entry.Content)
}
}
func shouldFetchYouTubeWatchTime(entry *model.Entry) bool {
if !config.Opts.FetchYouTubeWatchTime() {
return false
}
matches := youtubeRegex.FindStringSubmatch(entry.URL)
urlMatchesYouTubePattern := len(matches) == 2
return urlMatchesYouTubePattern
}
func fetchYouTubeWatchTime(url string) (int, error) {
clt := client.NewClientWithConfig(url, config.Opts)
response, browserErr := browser.Exec(clt)
if browserErr != nil {
return 0, browserErr
}
doc, docErr := goquery.NewDocumentFromReader(response.Body)
if docErr != nil {
return 0, docErr
}
durs, exists := doc.Find(`meta[itemprop="duration"]`).First().Attr("content")
if !exists {
return 0, errors.New("duration has not found")
}
dur, err := parseISO8601(durs)
if err != nil {
return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
}
return int(dur.Minutes()), nil
}
// parseISO8601 parses an ISO 8601 duration string.
func parseISO8601(from string) (time.Duration, error) {
var match []string
var d time.Duration
if iso8601Regex.MatchString(from) {
match = iso8601Regex.FindStringSubmatch(from)
} else {
return 0, errors.New("could not parse duration string")
}
for i, name := range iso8601Regex.SubexpNames() {
part := match[i]
if i == 0 || name == "" || part == "" {
continue
}
val, err := strconv.ParseInt(part, 10, 64)
if err != nil {
return 0, err
}
switch name {
case "hour":
d = d + (time.Duration(val) * time.Hour)
case "minute":
d = d + (time.Duration(val) * time.Minute)
case "second":
d = d + (time.Duration(val) * time.Second)
default:
return 0, fmt.Errorf("unknown field %s", name)
}
}
return d, nil
}
func calculateReadingTime(content string) int {
sanitizedContent := sanitizer.StripTags(content)
languageInfo := getlang.FromString(sanitizedContent)
+30
View File
@@ -6,6 +6,7 @@ package processor // import "miniflux.app/reader/processor"
import (
"testing"
"time"
"miniflux.app/model"
)
@@ -47,3 +48,32 @@ func TestAllowEntries(t *testing.T) {
}
}
}
func TestParseISO8601(t *testing.T) {
var scenarios = []struct {
duration string
expected time.Duration
}{
// Live streams and radio.
{"PT0M0S", 0},
// https://www.youtube.com/watch?v=HLrqNhgdiC0
{"PT6M20S", (6 * time.Minute) + (20 * time.Second)},
// https://www.youtube.com/watch?v=LZa5KKfqHtA
{"PT5M41S", (5 * time.Minute) + (41 * time.Second)},
// https://www.youtube.com/watch?v=yIxEEgEuhT4
{"PT51M52S", (51 * time.Minute) + (52 * time.Second)},
// https://www.youtube.com/watch?v=bpHf1XcoiFs
{"PT80M42S", (1 * time.Hour) + (20 * time.Minute) + (42 * time.Second)},
}
for _, tc := range scenarios {
result, err := parseISO8601(tc.duration)
if err != nil {
t.Errorf("Got an error when parsing %q: %v", tc.duration, err)
}
if tc.expected != result {
t.Errorf(`Unexpected result, got %v for duration %q`, result, tc.duration)
}
}
}
+58
View File
@@ -377,6 +377,31 @@ func TestParseItemWithoutDate(t *testing.T) {
}
}
func TestParseItemWithEncodedHTMLTitle(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<channel>
<title>Example</title>
<link>http://example.org</link>
</channel>
<item>
<title>AT&amp;amp;T</title>
<description>Test</description>
<link>http://example.org/test.html</link>
</item>
</rdf:RDF>`
feed, err := Parse("http://example.org", bytes.NewBufferString(data))
if err != nil {
t.Fatal(err)
}
if feed.Entries[0].Title != `AT&T` {
t.Errorf("Incorrect entry title, got: %v", feed.Entries[0].Title)
}
}
func TestParseInvalidXml(t *testing.T) {
data := `garbage`
_, err := Parse("http://example.org", bytes.NewBufferString(data))
@@ -519,3 +544,36 @@ func TestParseRDFWithContentEncoded(t *testing.T) {
t.Errorf(`Unexpected entry URL, got %q instead of %q`, result, expected)
}
}
func TestParseRDFWithEncodedHTMLDescription(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<rdf:RDF
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://purl.org/rss/1.0/"
xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>Example Feed</title>
<link>http://example.org/</link>
</channel>
<item>
<title>Item Title</title>
<link>http://example.org/</link>
<description>AT&amp;amp;T &lt;img src="https://example.org/img.png"&gt;&lt;/a&gt;</description>
</item>
</rdf:RDF>`
feed, err := Parse("http://example.org/", bytes.NewBufferString(data))
if err != nil {
t.Fatal(err)
}
if len(feed.Entries) != 1 {
t.Fatalf(`Unexpected number of entries, got %d`, len(feed.Entries))
}
expected := `AT&amp;T <img src="https://example.org/img.png"></a>`
result := feed.Entries[0].Content
if result != expected {
t.Errorf(`Unexpected entry URL, got %v instead of %v`, result, expected)
}
}
+2 -1
View File
@@ -6,6 +6,7 @@ package rdf // import "miniflux.app/reader/rdf"
import (
"encoding/xml"
"html"
"strings"
"time"
@@ -75,7 +76,7 @@ func (r *rdfItem) Transform() *model.Entry {
}
func (r *rdfItem) entryTitle() string {
return strings.TrimSpace(r.Title)
return html.UnescapeString(strings.TrimSpace(r.Title))
}
func (r *rdfItem) entryContent() string {
+47
View File
@@ -355,6 +355,34 @@ func TestParseEntryWithAuthorAndInnerHTML(t *testing.T) {
}
}
func TestParseEntryWithAuthorAndCDATA(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
<channel>
<title>Example</title>
<link>https://example.org/</link>
<atom:link href="https://example.org/rss" type="application/rss+xml" rel="self"></atom:link>
<item>
<title>Test</title>
<link>https://example.org/item</link>
<author>
by <![CDATA[Foo Bar]]>
</author>
</item>
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
if err != nil {
t.Fatal(err)
}
expected := "by Foo Bar"
result := feed.Entries[0].Author
if result != expected {
t.Errorf("Incorrect entry author, got %q instead of %q", result, expected)
}
}
func TestParseEntryWithNonStandardAtomAuthor(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
@@ -970,6 +998,25 @@ func TestParseFeedTitleWithHTMLEntity(t *testing.T) {
}
}
func TestParseFeedTitleWithUnicodeEntityAndCdata(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:slash="http://purl.org/rss/1.0/modules/slash/">
<channel>
<link>https://example.org/</link>
<title><![CDATA[Jenny&#8217;s Newsletter]]></title>
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
if err != nil {
t.Fatal(err)
}
if feed.Title != `Jennys Newsletter` {
t.Errorf(`Incorrect title, got: %q`, feed.Title)
}
}
func TestParseItemTitleWithHTMLEntity(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:slash="http://purl.org/rss/1.0/modules/slash/">
+4 -2
View File
@@ -53,7 +53,7 @@ func (r *rssFeed) Transform(baseURL string) *model.Feed {
feed.FeedURL = feedURL
}
feed.Title = strings.TrimSpace(r.Title)
feed.Title = html.UnescapeString(strings.TrimSpace(r.Title))
if feed.Title == "" {
feed.Title = feed.SiteURL
}
@@ -217,6 +217,8 @@ func (r *rssItem) entryAuthor() string {
default:
if rssAuthor.Name != "" {
author = rssAuthor.Name
} else if strings.Contains(rssAuthor.Inner, "<![CDATA[") {
author = rssAuthor.Data
} else {
author = rssAuthor.Inner
}
@@ -290,7 +292,7 @@ func (r *rssItem) entryURL() string {
func (r *rssItem) entryEnclosures() model.EnclosureList {
enclosures := make(model.EnclosureList, 0)
duplicates := make(map[string]bool, 0)
duplicates := make(map[string]bool)
for _, mediaThumbnail := range r.AllMediaThumbnails() {
if _, found := duplicates[mediaThumbnail.URL]; !found {
+1
View File
@@ -326,6 +326,7 @@ func isValidIframeSource(baseURL, src string) bool {
"http://bandcamp.com",
"https://bandcamp.com",
"https://cdn.embedly.com",
"https://player.bilibili.com",
}
// allow iframe from same origin
+4 -4
View File
@@ -20,11 +20,11 @@ import (
)
// Fetch downloads a web page and returns relevant contents.
func Fetch(websiteURL, rules, userAgent string) (string, error) {
func Fetch(websiteURL, rules, userAgent string, cookie string, allowSelfSignedCertificates bool) (string, error) {
clt := client.NewClientWithConfig(websiteURL, config.Opts)
if userAgent != "" {
clt.WithUserAgent(userAgent)
}
clt.WithUserAgent(userAgent)
clt.WithCookie(cookie)
clt.AllowSelfSignedCertificates = allowSelfSignedCertificates
response, err := clt.Get()
if err != nil {
+3 -3
View File
@@ -6,7 +6,7 @@ package scraper // import "miniflux.app/reader/scraper"
import (
"bytes"
"io/ioutil"
"os"
"strings"
"testing"
)
@@ -54,7 +54,7 @@ func TestSelectorRules(t *testing.T) {
}
for filename, rule := range ruleTestCases {
html, err := ioutil.ReadFile("testdata/" + filename)
html, err := os.ReadFile("testdata/" + filename)
if err != nil {
t.Fatalf(`Unable to read file %q: %v`, filename, err)
}
@@ -64,7 +64,7 @@ func TestSelectorRules(t *testing.T) {
t.Fatalf(`Scraping error for %q - %q: %v`, filename, rule, err)
}
expectedResult, err := ioutil.ReadFile("testdata/" + filename + "-result")
expectedResult, err := os.ReadFile("testdata/" + filename + "-result")
if err != nil {
t.Fatalf(`Unable to read file %q: %v`, filename, err)
}
+6 -3
View File
@@ -27,13 +27,15 @@ var (
)
// FindSubscriptions downloads and try to find one or more subscriptions from an URL.
func FindSubscriptions(websiteURL, userAgent, username, password string, fetchViaProxy bool) (Subscriptions, *errors.LocalizedError) {
func FindSubscriptions(websiteURL, userAgent, cookie, username, password string, fetchViaProxy, allowSelfSignedCertificates bool) (Subscriptions, *errors.LocalizedError) {
websiteURL = findYoutubeChannelFeed(websiteURL)
websiteURL = parseYoutubeVideoPage(websiteURL)
clt := client.NewClientWithConfig(websiteURL, config.Opts)
clt.WithCredentials(username, password)
clt.WithUserAgent(userAgent)
clt.WithCookie(cookie)
clt.AllowSelfSignedCertificates = allowSelfSignedCertificates
if fetchViaProxy {
clt.WithProxy()
@@ -61,7 +63,7 @@ func FindSubscriptions(websiteURL, userAgent, username, password string, fetchVi
return subscriptions, err
}
return tryWellKnownUrls(websiteURL, userAgent, username, password)
return tryWellKnownUrls(websiteURL, userAgent, cookie, username, password)
}
func parseWebPage(websiteURL string, data io.Reader) (Subscriptions, *errors.LocalizedError) {
@@ -137,7 +139,7 @@ func parseYoutubeVideoPage(websiteURL string) string {
return websiteURL
}
func tryWellKnownUrls(websiteURL, userAgent, username, password string) (Subscriptions, *errors.LocalizedError) {
func tryWellKnownUrls(websiteURL, userAgent, cookie, username, password string) (Subscriptions, *errors.LocalizedError) {
var subscriptions Subscriptions
knownURLs := map[string]string{
"/atom.xml": "atom",
@@ -160,6 +162,7 @@ func tryWellKnownUrls(websiteURL, userAgent, username, password string) (Subscri
clt := client.NewClientWithConfig(fullURL, config.Opts)
clt.WithCredentials(username, password)
clt.WithUserAgent(userAgent)
clt.WithCookie(cookie)
// Some websites redirects unknown URLs to the home page.
// As result, the list of known URLs is returned to the subscription list.
+2 -3
View File
@@ -9,7 +9,6 @@ import (
"encoding/xml"
"fmt"
"io"
"io/ioutil"
"strings"
"miniflux.app/reader/encoding"
@@ -18,7 +17,7 @@ import (
// NewDecoder returns a XML decoder that filters illegal characters.
func NewDecoder(data io.Reader) *xml.Decoder {
var decoder *xml.Decoder
buffer, _ := ioutil.ReadAll(data)
buffer, _ := io.ReadAll(data)
enc := procInst("encoding", string(buffer))
if enc != "" && enc != "utf-8" && enc != "UTF-8" && !strings.EqualFold(enc, "utf-8") {
// filter invalid chars later within decoder.CharsetReader
@@ -36,7 +35,7 @@ func NewDecoder(data io.Reader) *xml.Decoder {
if err != nil {
return nil, err
}
rawData, err := ioutil.ReadAll(utf8Reader)
rawData, err := io.ReadAll(utf8Reader)
if err != nil {
return nil, fmt.Errorf("Unable to read data: %q", err)
}
+5
View File
@@ -184,6 +184,11 @@ func setupHandler(store *storage.Storage, pool *worker.Pool) *mux.Router {
ui.Serve(router, store, pool)
router.HandleFunc("/healthcheck", func(w http.ResponseWriter, r *http.Request) {
if err := store.Ping(); err != nil {
http.Error(w, "Database Connection Error", http.StatusInternalServerError)
return
}
w.Write([]byte("OK"))
}).Name("healthcheck")
+6 -2
View File
@@ -116,7 +116,11 @@ func (s *Storage) CategoriesWithFeedCount(userID int64) (model.Categories, error
c.id,
c.user_id,
c.title,
(SELECT count(*) FROM feeds WHERE feeds.category_id=c.id) AS count
(SELECT count(*) FROM feeds WHERE feeds.category_id=c.id) AS count,
(SELECT count(*)
FROM feeds
JOIN entries ON (feeds.id = entries.feed_id)
WHERE feeds.category_id = c.id AND entries.status = 'unread')
FROM categories c
WHERE
user_id=$1
@@ -132,7 +136,7 @@ func (s *Storage) CategoriesWithFeedCount(userID int64) (model.Categories, error
categories := make(model.Categories, 0)
for rows.Next() {
var category model.Category
if err := rows.Scan(&category.ID, &category.UserID, &category.Title, &category.FeedCount); err != nil {
if err := rows.Scan(&category.ID, &category.UserID, &category.Title, &category.FeedCount, &category.TotalUnread); err != nil {
return nil, fmt.Errorf(`store: unable to fetch category row: %v`, err)
}
+14
View File
@@ -225,6 +225,20 @@ func (s *Storage) entryExists(tx *sql.Tx, entry *model.Entry) bool {
return result
}
// GetReadTime fetches the read time of an entry based on its hash, and the feed id and user id from the feed.
// It's intended to be used on entries objects created by parsing a feed as they don't contain much information.
// The feed param helps to scope the search to a specific user and feed in order to avoid hash clashes.
func (s *Storage) GetReadTime(entry *model.Entry, feed *model.Feed) int {
var result int
s.db.QueryRow(
`SELECT reading_time FROM entries WHERE user_id=$1 AND feed_id=$2 AND hash=$3`,
feed.UserID,
feed.ID,
entry.Hash,
).Scan(&result)
return result
}
// cleanupEntries deletes from the database entries marked as "removed" and not visible anymore in the feed.
func (s *Storage) cleanupEntries(feedID int64, entryHashes []string) error {
query := `
+3 -1
View File
@@ -242,6 +242,7 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
f.rewrite_rules,
f.crawler,
f.user_agent,
f.cookie,
fi.icon_id,
u.timezone
FROM
@@ -303,6 +304,7 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
&entry.Feed.RewriteRules,
&entry.Feed.Crawler,
&entry.Feed.UserAgent,
&entry.Feed.Cookie,
&iconID,
&tz,
)
@@ -372,7 +374,7 @@ func (e *EntryQueryBuilder) buildSorting() string {
}
if e.direction != "" {
parts = append(parts, fmt.Sprintf(`%s`, e.direction))
parts = append(parts, e.direction)
}
if e.limit > 0 {
+38 -19
View File
@@ -8,8 +8,10 @@ import (
"database/sql"
"errors"
"fmt"
"runtime"
"miniflux.app/config"
"miniflux.app/logger"
"miniflux.app/model"
)
@@ -193,6 +195,7 @@ func (s *Storage) CreateFeed(feed *model.Feed) error {
last_modified_header,
crawler,
user_agent,
cookie,
username,
password,
disabled,
@@ -201,10 +204,11 @@ func (s *Storage) CreateFeed(feed *model.Feed) error {
blocklist_rules,
keeplist_rules,
ignore_http_cache,
allow_self_signed_certificates,
fetch_via_proxy
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
RETURNING
id
`
@@ -219,6 +223,7 @@ func (s *Storage) CreateFeed(feed *model.Feed) error {
feed.LastModifiedHeader,
feed.Crawler,
feed.UserAgent,
feed.Cookie,
feed.Username,
feed.Password,
feed.Disabled,
@@ -227,6 +232,7 @@ func (s *Storage) CreateFeed(feed *model.Feed) error {
feed.BlocklistRules,
feed.KeeplistRules,
feed.IgnoreHTTPCache,
feed.AllowSelfSignedCertificates,
feed.FetchViaProxy,
).Scan(&feed.ID)
if err != nil {
@@ -278,14 +284,16 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) {
keeplist_rules=$13,
crawler=$14,
user_agent=$15,
username=$16,
password=$17,
disabled=$18,
next_check_at=$19,
ignore_http_cache=$20,
fetch_via_proxy=$21
cookie=$16,
username=$17,
password=$18,
disabled=$19,
next_check_at=$20,
ignore_http_cache=$21,
allow_self_signed_certificates=$22,
fetch_via_proxy=$23
WHERE
id=$22 AND user_id=$23
id=$24 AND user_id=$25
`
_, err = s.db.Exec(query,
feed.FeedURL,
@@ -303,11 +311,13 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) {
feed.KeeplistRules,
feed.Crawler,
feed.UserAgent,
feed.Cookie,
feed.Username,
feed.Password,
feed.Disabled,
feed.NextCheckAt,
feed.IgnoreHTTPCache,
feed.AllowSelfSignedCertificates,
feed.FetchViaProxy,
feed.ID,
feed.UserID,
@@ -349,21 +359,30 @@ func (s *Storage) UpdateFeedError(feed *model.Feed) (err error) {
return nil
}
// RemoveFeed removes a feed.
// RemoveFeed removes a feed and all entries.
// This operation can takes time if the feed has lot of entries.
func (s *Storage) RemoveFeed(userID, feedID int64) error {
query := `DELETE FROM feeds WHERE id = $1 AND user_id = $2`
result, err := s.db.Exec(query, feedID, userID)
rows, err := s.db.Query(`SELECT id FROM entries WHERE user_id=$1 AND feed_id=$2`, userID, feedID)
if err != nil {
return fmt.Errorf(`store: unable to remove feed #%d: %v`, feedID, err)
return fmt.Errorf(`store: unable to get user feed entries: %v`, err)
}
defer rows.Close()
for rows.Next() {
var entryID int64
if err := rows.Scan(&entryID); err != nil {
return fmt.Errorf(`store: unable to read user feed entry ID: %v`, err)
}
logger.Debug(`[FEED DELETION] Deleting entry #%d of feed #%d for user #%d (%d GoRoutines)`, entryID, feedID, userID, runtime.NumGoroutine())
if _, err := s.db.Exec(`DELETE FROM entries WHERE id=$1 AND user_id=$2`, entryID, userID); err != nil {
return fmt.Errorf(`store: unable to delete user feed entries #%d: %v`, entryID, err)
}
}
count, err := result.RowsAffected()
if err != nil {
return fmt.Errorf(`store: unable to remove feed #%d: %v`, feedID, err)
}
if count == 0 {
return errors.New(`store: no feed has been removed`)
if _, err := s.db.Exec(`DELETE FROM feeds WHERE id=$1 AND user_id=$2`, feedID, userID); err != nil {
return fmt.Errorf(`store: unable to delete feed #%d: %v`, feedID, err)
}
return nil
+5 -1
View File
@@ -106,7 +106,7 @@ func (f *FeedQueryBuilder) buildSorting() string {
}
if f.direction != "" {
parts = append(parts, fmt.Sprintf(`%s`, f.direction))
parts = append(parts, f.direction)
}
if len(parts) > 0 {
@@ -159,9 +159,11 @@ func (f *FeedQueryBuilder) GetFeeds() (model.Feeds, error) {
f.keeplist_rules,
f.crawler,
f.user_agent,
f.cookie,
f.username,
f.password,
f.ignore_http_cache,
f.allow_self_signed_certificates,
f.fetch_via_proxy,
f.disabled,
f.category_id,
@@ -217,9 +219,11 @@ func (f *FeedQueryBuilder) GetFeeds() (model.Feeds, error) {
&feed.KeeplistRules,
&feed.Crawler,
&feed.UserAgent,
&feed.Cookie,
&feed.Username,
&feed.Password,
&feed.IgnoreHTTPCache,
&feed.AllowSelfSignedCertificates,
&feed.FetchViaProxy,
&feed.Disabled,
&feed.Category.ID,
+17
View File
@@ -17,3 +17,20 @@ type Storage struct {
func NewStorage(db *sql.DB) *Storage {
return &Storage{db}
}
// DatabaseVersion returns the version of the database which is in use.
func (s *Storage) DatabaseVersion() string {
var dbVersion string
err := s.db.QueryRow(`SELECT current_setting('server_version')`).Scan(&dbVersion)
if err != nil {
return err.Error()
}
return dbVersion
}
// Ping checks if the database connection works.
func (s *Storage) Ping() error {
_, err := s.db.Exec(`SELECT true`)
return err
}
+50 -73
View File
@@ -83,7 +83,8 @@ func (s *Storage) CreateUser(userCreationRequest *model.UserCreationRequest) (*m
entry_swipe,
stylesheet,
google_id,
openid_connect_id
openid_connect_id,
display_mode
`
tx, err := s.db.Begin()
@@ -114,6 +115,7 @@ func (s *Storage) CreateUser(userCreationRequest *model.UserCreationRequest) (*m
&user.Stylesheet,
&user.GoogleID,
&user.OpenIDConnectID,
&user.DisplayMode,
)
if err != nil {
tx.Rollback()
@@ -162,9 +164,10 @@ func (s *Storage) UpdateUser(user *model.User) error {
entry_swipe=$11,
stylesheet=$12,
google_id=$13,
openid_connect_id=$14
openid_connect_id=$14,
display_mode=$15
WHERE
id=$15
id=$16
`
_, err = s.db.Exec(
@@ -183,6 +186,7 @@ func (s *Storage) UpdateUser(user *model.User) error {
user.Stylesheet,
user.GoogleID,
user.OpenIDConnectID,
user.DisplayMode,
user.ID,
)
if err != nil {
@@ -203,9 +207,10 @@ func (s *Storage) UpdateUser(user *model.User) error {
entry_swipe=$10,
stylesheet=$11,
google_id=$12,
openid_connect_id=$13
openid_connect_id=$13,
display_mode=$14
WHERE
id=$14
id=$15
`
_, err := s.db.Exec(
@@ -223,6 +228,7 @@ func (s *Storage) UpdateUser(user *model.User) error {
user.Stylesheet,
user.GoogleID,
user.OpenIDConnectID,
user.DisplayMode,
user.ID,
)
@@ -262,7 +268,8 @@ func (s *Storage) UserByID(userID int64) (*model.User, error) {
last_login_at,
stylesheet,
google_id,
openid_connect_id
openid_connect_id,
display_mode
FROM
users
WHERE
@@ -289,7 +296,8 @@ func (s *Storage) UserByUsername(username string) (*model.User, error) {
last_login_at,
stylesheet,
google_id,
openid_connect_id
openid_connect_id,
display_mode
FROM
users
WHERE
@@ -316,7 +324,8 @@ func (s *Storage) UserByField(field, value string) (*model.User, error) {
last_login_at,
stylesheet,
google_id,
openid_connect_id
openid_connect_id,
display_mode
FROM
users
WHERE
@@ -350,7 +359,8 @@ func (s *Storage) UserByAPIKey(token string) (*model.User, error) {
u.last_login_at,
u.stylesheet,
u.google_id,
u.openid_connect_id
u.openid_connect_id,
u.display_mode
FROM
users u
LEFT JOIN
@@ -379,6 +389,7 @@ func (s *Storage) fetchUser(query string, args ...interface{}) (*model.User, err
&user.Stylesheet,
&user.GoogleID,
&user.OpenIDConnectID,
&user.DisplayMode,
)
if err == sql.ErrNoRows {
@@ -417,13 +428,39 @@ func (s *Storage) RemoveUser(userID int64) error {
// RemoveUserAsync deletes user data without locking the database.
func (s *Storage) RemoveUserAsync(userID int64) {
go func() {
deleteUserFeeds(s.db, userID)
if err := s.deleteUserFeeds(userID); err != nil {
logger.Error(`%v`, err)
return
}
s.db.Exec(`DELETE FROM users WHERE id=$1`, userID)
s.db.Exec(`DELETE FROM integrations WHERE user_id=$1`, userID)
logger.Debug(`[MASS DELETE] User #%d has been deleted (%d GoRoutines)`, userID, runtime.NumGoroutine())
}()
}
func (s *Storage) deleteUserFeeds(userID int64) error {
rows, err := s.db.Query(`SELECT id FROM feeds WHERE user_id=$1`, userID)
if err != nil {
return fmt.Errorf(`store: unable to get user feeds: %v`, err)
}
defer rows.Close()
for rows.Next() {
var feedID int64
rows.Scan(&feedID)
logger.Debug(`[USER DELETION] Deleting feed #%d for user #%d (%d GoRoutines)`, feedID, userID, runtime.NumGoroutine())
if err := s.RemoveFeed(userID, feedID); err != nil {
return err
}
}
return nil
}
// Users returns all users.
func (s *Storage) Users() (model.Users, error) {
query := `
@@ -442,7 +479,8 @@ func (s *Storage) Users() (model.Users, error) {
last_login_at,
stylesheet,
google_id,
openid_connect_id
openid_connect_id,
display_mode
FROM
users
ORDER BY username ASC
@@ -472,6 +510,7 @@ func (s *Storage) Users() (model.Users, error) {
&user.Stylesheet,
&user.GoogleID,
&user.OpenIDConnectID,
&user.DisplayMode,
)
if err != nil {
@@ -525,65 +564,3 @@ func hashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
func deleteUserFeeds(db *sql.DB, userID int64) {
query := `SELECT id FROM feeds WHERE user_id=$1`
rows, err := db.Query(query, userID)
if err != nil {
logger.Error(`store: unable to get user feeds: %v`, err)
return
}
defer rows.Close()
var feedIDs []int64
for rows.Next() {
var feedID int64
rows.Scan(&feedID)
feedIDs = append(feedIDs, feedID)
}
worker := func(jobs <-chan int64, results chan<- bool) {
for feedID := range jobs {
logger.Debug(`[MASS DELETE] Deleting feed #%d for user #%d (%d GoRoutines)`, feedID, userID, runtime.NumGoroutine())
deleteUserEntries(db, userID, feedID)
db.Exec(`DELETE FROM feeds WHERE id=$1`, feedID)
results <- true
}
}
numJobs := len(feedIDs)
jobs := make(chan int64, numJobs)
results := make(chan bool, numJobs)
for w := 0; w < 2; w++ {
go worker(jobs, results)
}
for j := 0; j < numJobs; j++ {
jobs <- feedIDs[j]
}
close(jobs)
for a := 1; a <= numJobs; a++ {
<-results
}
}
func deleteUserEntries(db *sql.DB, userID int64, feedID int64) {
rows, err := db.Query(`SELECT id FROM entries WHERE user_id=$1 AND feed_id=$2`, userID, feedID)
if err != nil {
logger.Error(`store: unable to get user feed entries: %v`, err)
return
}
defer rows.Close()
for rows.Next() {
var entryID int64
rows.Scan(&entryID)
logger.Debug(`[MASS DELETE] Deleting entry #%d for user #%d (%d GoRoutines)`, entryID, userID, runtime.NumGoroutine())
db.Exec(`DELETE FROM enclosures WHERE entry_id=$1 AND user_id=$2`, entryID, userID)
db.Exec(`DELETE FROM entries WHERE id=$1 AND user_id=$2`, entryID, userID)
}
}
-548
View File
@@ -1,548 +0,0 @@
// Code generated by go generate; DO NOT EDIT.
package template // import "miniflux.app/template"
var templateCommonMap = map[string]string{
"entry_pagination": `{{ define "entry_pagination" }}
<div class="pagination">
<div class="pagination-prev">
{{ if .prevEntry }}
<a href="{{ .prevEntryRoute }}{{ if .searchQuery }}?q={{ .searchQuery }}{{ end }}" title="{{ .prevEntry.Title }}" data-page="previous" rel="prev">{{ t "pagination.previous" }}</a>
{{ else }}
{{ t "pagination.previous" }}
{{ end }}
</div>
<div class="pagination-next">
{{ if .nextEntry }}
<a href="{{ .nextEntryRoute }}{{ if .searchQuery }}?q={{ .searchQuery }}{{ end }}" title="{{ .nextEntry.Title }}" data-page="next" rel="next">{{ t "pagination.next" }}</a>
{{ else }}
{{ t "pagination.next" }}
{{ end }}
</div>
</div>
{{ end }}
`,
"feed_list": `{{ define "feed_list" }}
<div class="items">
{{ range .feeds }}
<article class="item {{ if ne .ParsingErrorCount 0 }}feed-parsing-error{{ end }}">
<div class="item-header" dir="auto">
<span class="item-title">
{{ if .Icon }}
<img src="{{ route "icon" "iconID" .Icon.IconID }}" width="16" height="16" loading="lazy" alt="{{ .Title }}">
{{ end }}
{{ if .Disabled }} 🚫 {{ end }}
<a href="{{ route "feedEntries" "feedID" .ID }}">{{ .Title }}</a>
</span>
<span class="feed-entries-counter">
(<span title="{{ t "page.feeds.unread_counter" }}">{{ .UnreadCount }}</span>/<span title="{{ t "page.feeds.read_counter" }}">{{ .ReadCount }}</span>)
</span>
<span class="category">
<a href="{{ route "categoryEntries" "categoryID" .Category.ID }}">{{ .Category.Title }}</a>
</span>
</div>
<div class="item-meta">
<ul class="item-meta-info">
<li dir="auto">
<a href="{{ .SiteURL | safeURL }}" title="{{ .SiteURL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ domain .SiteURL }}</a>
</li>
<li>
{{ t "page.feeds.last_check" }} <time datetime="{{ isodate .CheckedAt }}" title="{{ isodate .CheckedAt }}">{{ elapsed $.user.Timezone .CheckedAt }}</time>
</li>
</ul>
<ul class="item-meta-icons">
<li>
<a href="{{ route "refreshFeed" "feedID" .ID }}">{{ template "icon_refresh" }}<span class="icon-label">{{ t "menu.refresh_feed" }}</span></a>
</li>
<li>
<a href="{{ route "editFeed" "feedID" .ID }}">{{ template "icon_edit" }}<span class="icon-label">{{ t "menu.edit_feed" }}</span></a>
</li>
<li>
<a href="#"
data-confirm="true"
data-label-question="{{ t "confirm.question" }}"
data-label-yes="{{ t "confirm.yes" }}"
data-label-no="{{ t "confirm.no" }}"
data-label-loading="{{ t "confirm.loading" }}"
data-url="{{ route "removeFeed" "feedID" .ID }}">{{ template "icon_delete" }}<span class="icon-label">{{ t "action.remove" }}</span></a>
</li>
{{ if .UnreadCount }}
<li>
<a href="{{ route "markFeedAsRead" "feedID" .ID }}">{{ template "icon_read" }}<span class="icon-label">{{ t "menu.mark_all_as_read" }}</span></a>
</li>
{{ end }}
</ul>
</div>
{{ if ne .ParsingErrorCount 0 }}
<div class="parsing-error">
<strong title="{{ .ParsingErrorMsg }}" class="parsing-error-count">{{ plural "page.feeds.error_count" .ParsingErrorCount .ParsingErrorCount }}</strong>
- <small class="parsing-error-message">{{ .ParsingErrorMsg }}</small>
</div>
{{ end }}
</article>
{{ end }}
</div>
{{ end }}
`,
"feed_menu": `{{ define "feed_menu" }}
<ul>
<li>
<a href="{{ route "feeds" }}">{{ t "menu.feeds" }}</a>
</li>
<li>
<a href="{{ route "addSubscription" }}">{{ t "menu.add_feed" }}</a>
</li>
<li>
<a href="{{ route "export" }}">{{ t "menu.export" }}</a>
</li>
<li>
<a href="{{ route "import" }}">{{ t "menu.import" }}</a>
</li>
<li>
<a href="{{ route "refreshAllFeeds" }}">{{ t "menu.refresh_all_feeds" }}</a>
</li>
</ul>
{{ end }}`,
"icons": `<!--
MIT License
Copyright (c) 2020 Paweł Kuna
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-->
{{ define "icon_read" }}
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-circle-check" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<circle cx="12" cy="12" r="9" />
<path d="M9 12l2 2l4 -4" />
</svg>
{{ end }}
{{ define "icon_unread" }}
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-circle-x" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<circle cx="12" cy="12" r="9" />
<path d="M10 10l4 4m0 -4l-4 4" />
</svg>
{{ end }}
{{ define "icon_star" }}
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-star" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<path d="M12 17.75l-6.172 3.245 1.179-6.873-4.993-4.867 6.9-1.002L12 2l3.086 6.253 6.9 1.002-4.993 4.867 1.179 6.873z" />
</svg>
{{ end }}
{{ define "icon_unstar" }}
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-unstar" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<path fill="currentColor" d="M12 17.75l-6.172 3.245 1.179-6.873-4.993-4.867 6.9-1.002L12 2l3.086 6.253 6.9 1.002-4.993 4.867 1.179 6.873z" />
</svg>
{{ end }}
{{ define "icon_save" }}
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-download" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2" />
<polyline points="7 11 12 16 17 11" />
<line x1="12" y1="4" x2="12" y2="16" />
</svg>
{{ end }}
{{ define "icon_scraper" }}
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-cloud-download" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<path d="M19 18a3.5 3.5 0 0 0 0 -7h-1a5 4.5 0 0 0 -11 -2a4.6 4.4 0 0 0 -2.1 8.4" />
<line x1="12" y1="13" x2="12" y2="22" />
<polyline points="9 19 12 22 15 19" />
</svg>
{{ end }}
{{ define "icon_share" }}
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-share" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="6" r="3" />
<circle cx="18" cy="18" r="3" />
<line x1="8.7" y1="10.7" x2="15.3" y2="7.3" />
<line x1="8.7" y1="13.3" x2="15.3" y2="16.7" />
</svg>
{{ end }}
{{ define "icon_comment" }}
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-message-circle" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<path d="M3 20l1.3 -3.9a9 8 0 1 1 3.4 2.9l-4.7 1" />
<line x1="12" y1="12" x2="12" y2="12.01" />
<line x1="8" y1="12" x2="8" y2="12.01" />
<line x1="16" y1="12" x2="16" y2="12.01" />
</svg>
{{ end }}
{{ define "icon_external_link" }}
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-external-link" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<path d="M11 7h-5a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-5" />
<line x1="10" y1="14" x2="20" y2="4" />
<polyline points="15 4 20 4 20 9" />
</svg>
{{ end }}
{{ define "icon_delete" }}
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-trash" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<line x1="4" y1="7" x2="20" y2="7" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
<path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12" />
<path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3" />
</svg>
{{ end }}
{{ define "icon_edit" }}
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-edit" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<path d="M9 7 h-3a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-3" />
<path d="M9 15h3l8.5 -8.5a1.5 1.5 0 0 0 -3 -3l-8.5 8.5v3" />
<line x1="16" y1="5" x2="19" y2="8" />
</svg>
{{ end }}
{{ define "icon_feeds" }}
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-folders" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<path d="M9 4h3l2 2h5a2 2 0 0 1 2 2v7a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2" />
<path d="M17 17v2a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2h2" />
</svg>
{{ end }}
{{ define "icon_entries" }}
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-news" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<path d="M16 6h3a1 1 0 0 1 1 1v11a2 2 0 0 1 -4 0v-13a1 1 0 0 0 -1 -1h-10a1 1 0 0 0 -1 1v12a3 3 0 0 0 3 3h11" />
<line x1="8" y1="8" x2="12" y2="8" />
<line x1="8" y1="12" x2="12" y2="12" />
<line x1="8" y1="16" x2="12" y2="16" />
</svg>
{{ end }}
{{ define "icon_refresh" }}
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-refresh" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -5v5h5" />
<path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 5v-5h-5" />
</svg>
{{ end }}`,
"item_meta": `{{ define "item_meta" }}
<div class="item-meta">
<ul class="item-meta-info">
<li>
<a href="{{ route "feedEntries" "feedID" .entry.Feed.ID }}" title="{{ .entry.Feed.SiteURL }}" data-feed-link="true">{{ truncate .entry.Feed.Title 35 }}</a>
</li>
<li>
<time datetime="{{ isodate .entry.Date }}" title="{{ isodate .entry.Date }}">{{ elapsed .user.Timezone .entry.Date }}</time>
</li>
{{ if and .user.ShowReadingTime (gt .entry.ReadingTime 0) }}
<li>
<span>
{{ plural "entry.estimated_reading_time" .entry.ReadingTime .entry.ReadingTime }}
</span>
</li>
{{ end }}
</ul>
<ul class="item-meta-icons">
<li>
<a href="#"
title="{{ t "entry.status.title" }}"
data-toggle-status="true"
data-label-read="{{ t "entry.status.read" }}"
data-label-unread="{{ t "entry.status.unread" }}"
data-value="{{ if eq .entry.Status "read" }}read{{ else }}unread{{ end }}"
>{{ if eq .entry.Status "read" }}{{ template "icon_unread" }}{{ else }}{{ template "icon_read" }}{{ end }}<span class="icon-label">{{ if eq .entry.Status "read" }}{{ t "entry.status.unread" }}{{ else }}{{ t "entry.status.read" }}{{ end }}</span></a>
</li>
<li>
<a href="#"
data-toggle-bookmark="true"
data-bookmark-url="{{ route "toggleBookmark" "entryID" .entry.ID }}"
data-label-loading="{{ t "entry.state.saving" }}"
data-label-star="{{ t "entry.bookmark.toggle.on" }}"
data-label-unstar="{{ t "entry.bookmark.toggle.off" }}"
data-value="{{ if .entry.Starred }}star{{ else }}unstar{{ end }}"
>{{ if .entry.Starred }}{{ template "icon_unstar" }}{{ else }}{{ template "icon_star" }}{{ end }}<span class="icon-label">{{ if .entry.Starred }}{{ t "entry.bookmark.toggle.off" }}{{ else }}{{ t "entry.bookmark.toggle.on" }}{{ end }}</span></a>
</li>
{{ if .entry.ShareCode }}
<li>
<a href="{{ route "sharedEntry" "shareCode" .entry.ShareCode }}"
title="{{ t "entry.shared_entry.title" }}"
target="_blank">{{ template "icon_share" }}<span class="icon-label">{{ t "entry.shared_entry.label" }}</span></a>
</li>
{{ end }}
{{ if .hasSaveEntry }}
<li>
<a href="#"
title="{{ t "entry.save.title" }}"
data-save-entry="true"
data-save-url="{{ route "saveEntry" "entryID" .entry.ID }}"
data-label-loading="{{ t "entry.state.saving" }}"
data-label-done="{{ t "entry.save.completed" }}"
>{{ template "icon_save" }}<span class="icon-label">{{ t "entry.save.label" }}</span></a>
</li>
{{ end }}
<li>
<a href="{{ .entry.URL | safeURL }}"
target="_blank"
rel="noopener noreferrer"
referrerpolicy="no-referrer"
data-original-link="true">{{ template "icon_external_link" }}<span class="icon-label">{{ t "entry.external_link.label" }}</span></a>
</li>
{{ if .entry.CommentsURL }}
<li>
<a href="{{ .entry.CommentsURL | safeURL }}"
title="{{ t "entry.comments.title" }}"
target="_blank"
rel="noopener noreferrer"
referrerpolicy="no-referrer"
data-comments-link="true">{{ template "icon_comment" }}<span class="icon-label">{{ t "entry.comments.label" }}</span></a>
</li>
{{ end }}
</ul>
</div>
{{ end }}
`,
"layout": `{{ define "base" }}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{{template "title" .}} - Miniflux</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Miniflux">
<link rel="manifest" href="{{ route "webManifest" }}" crossorigin="use-credentials"/>
<meta name="robots" content="noindex,nofollow">
<meta name="referrer" content="no-referrer">
<meta name="google" content="notranslate">
<!-- Favicons -->
<link rel="icon" type="image/png" sizes="16x16" href="{{ route "appIcon" "filename" "favicon-16.png" }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ route "appIcon" "filename" "favicon-32.png" }}">
<!-- Android icons -->
<link rel="icon" type="image/png" sizes="128x128" href="{{ route "appIcon" "filename" "icon-128.png" }}">
<link rel="icon" type="image/png" sizes="192x192" href="{{ route "appIcon" "filename" "icon-192.png" }}">
<!-- iOS icons -->
<link rel="apple-touch-icon" sizes="120x120" href="{{ route "appIcon" "filename" "icon-120.png" }}">
<link rel="apple-touch-icon" sizes="152x152" href="{{ route "appIcon" "filename" "icon-152.png" }}">
<link rel="apple-touch-icon" sizes="167x167" href="{{ route "appIcon" "filename" "icon-167.png" }}">
<link rel="apple-touch-icon" sizes="180x180" href="{{ route "appIcon" "filename" "icon-180.png" }}">
{{ if .csrf }}
<meta name="X-CSRF-Token" value="{{ .csrf }}">
{{ end }}
<meta name="theme-color" content="{{ theme_color .theme }}">
<link rel="stylesheet" type="text/css" href="{{ route "stylesheet" "name" .theme }}?{{ .theme_checksum }}">
{{ if and .user .user.Stylesheet }}
<link rel="stylesheet" type="text/css" href="{{ route "stylesheet" "name" "custom_css" }}">
{{ end }}
<script type="text/javascript" src="{{ route "javascript" "name" "app" }}?{{ .app_js_checksum }}" defer></script>
<script type="text/javascript" src="{{ route "javascript" "name" "service-worker" }}?{{ .sw_js_checksum }}" defer id="service-worker-script"></script>
</head>
<body
data-entries-status-url="{{ route "updateEntriesStatus" }}"
data-refresh-all-feeds-url="{{ route "refreshAllFeeds" }}"
{{ if .user }}{{ if not .user.KeyboardShortcuts }}data-disable-keyboard-shortcuts="true"{{ end }}{{ end }}>
<div class="toast-wrap">
<span class="toast-msg"></span>
</div>
{{ if .user }}
<header class="header">
<nav>
<div class="logo">
<a href="{{ route "unread" }}">Mini<span>flux</span></a>
</div>
<ul>
<li {{ if eq .menu "unread" }}class="active"{{ end }} title="{{ t "tooltip.keyboard_shortcuts" "g u" }}">
<a href="{{ route "unread" }}" data-page="unread">{{ t "menu.unread" }}
{{ if gt .countUnread 0 }}
<span class="unread-counter-wrapper">(<span class="unread-counter">{{ .countUnread }}</span>)</span>
{{ end }}
</a>
</li>
<li {{ if eq .menu "starred" }}class="active"{{ end }} title="{{ t "tooltip.keyboard_shortcuts" "g b" }}">
<a href="{{ route "starred" }}" data-page="starred">{{ t "menu.starred" }}</a>
</li>
<li {{ if eq .menu "history" }}class="active"{{ end }} title="{{ t "tooltip.keyboard_shortcuts" "g h" }}">
<a href="{{ route "history" }}" data-page="history">{{ t "menu.history" }}</a>
</li>
<li {{ if eq .menu "feeds" }}class="active"{{ end }} title="{{ t "tooltip.keyboard_shortcuts" "g f" }}">
<a href="{{ route "feeds" }}" data-page="feeds">{{ t "menu.feeds" }}
{{ if gt .countErrorFeeds 0 }}
<span class="error-feeds-counter-wrapper">(<span class="error-feeds-counter">{{ .countErrorFeeds }}</span>)</span>
{{ end }}
</a>
</li>
<li {{ if eq .menu "categories" }}class="active"{{ end }} title="{{ t "tooltip.keyboard_shortcuts" "g c" }}">
<a href="{{ route "categories" }}" data-page="categories">{{ t "menu.categories" }}</a>
</li>
<li {{ if eq .menu "settings" }}class="active"{{ end }} title="{{ t "tooltip.keyboard_shortcuts" "g s" }}">
<a href="{{ route "settings" }}" data-page="settings">{{ t "menu.settings" }}</a>
</li>
<li>
<a href="{{ route "logout" }}" title="{{ t "tooltip.logged_user" .user.Username }}">{{ t "menu.logout" }}</a>
</li>
</ul>
<div class="search">
<div class="search-toggle-switch {{ if $.searchQuery }}has-search-query{{ end }}">
<a href="#" data-action="search">&laquo;&nbsp;{{ t "search.label" }}</a>
</div>
<form action="{{ route "searchEntries" }}" class="search-form {{ if $.searchQuery }}has-search-query{{ end }}">
<input type="search" name="q" id="search-input" placeholder="{{ t "search.placeholder" }}" {{ if $.searchQuery }}value="{{ .searchQuery }}"{{ end }} required>
</form>
</div>
</nav>
</header>
{{ end }}
{{ if .flashMessage }}
<div class="flash-message alert alert-success">{{ .flashMessage }}</div>
{{ end }}
{{ if .flashErrorMessage }}
<div class="flash-error-message alert alert-error">{{ .flashErrorMessage }}</div>
{{ end }}
<main>
{{template "content" .}}
</main>
<template id="keyboard-shortcuts">
<div id="modal-left">
<a href="#" class="btn-close-modal">x</a>
<h3>{{ t "page.keyboard_shortcuts.title" }}</h3>
<div class="keyboard-shortcuts">
<p>{{ t "page.keyboard_shortcuts.subtitle.sections" }}</p>
<ul>
<li>{{ t "page.keyboard_shortcuts.go_to_unread" }} = <strong>g + u</strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_starred" }} = <strong>g + b</strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_history" }} = <strong>g + h</strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_feeds" }} = <strong>g + f</strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_categories" }} = <strong>g + c</strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_settings" }} = <strong>g + s</strong></li>
<li>{{ t "page.keyboard_shortcuts.show_keyboard_shortcuts" }} = <strong>?</strong></li>
</ul>
<p>{{ t "page.keyboard_shortcuts.subtitle.items" }}</p>
<ul>
<li>{{ t "page.keyboard_shortcuts.go_to_previous_item" }} = <strong>p</strong>, <strong>k</strong>, <strong></strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_next_item" }} = <strong>n</strong>, <strong>j</strong>, <strong></strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_feed" }} = <strong>F</strong></li>
</ul>
<p>{{ t "page.keyboard_shortcuts.subtitle.pages" }}</p>
<ul>
<li>{{ t "page.keyboard_shortcuts.go_to_previous_page" }} = <strong>h</strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_next_page" }} = <strong>l</strong></li>
</ul>
<p>{{ t "page.keyboard_shortcuts.subtitle.actions" }}</p>
<ul>
<li>{{ t "page.keyboard_shortcuts.open_item" }} = <strong>o</strong></li>
<li>{{ t "page.keyboard_shortcuts.open_original" }} = <strong>v</strong></li>
<li>{{ t "page.keyboard_shortcuts.open_original_same_window" }} = <strong>V</strong></li>
<li>{{ t "page.keyboard_shortcuts.open_comments" }} = <strong>c</strong></li>
<li>{{ t "page.keyboard_shortcuts.open_comments_same_window" }} = <strong>C</strong></li>
<li>{{ t "page.keyboard_shortcuts.toggle_read_status" }} = <strong>m</strong></li>
<li>{{ t "page.keyboard_shortcuts.mark_page_as_read" }} = <strong>A</strong></li>
<li>{{ t "page.keyboard_shortcuts.download_content" }} = <strong>d</strong></li>
<li>{{ t "page.keyboard_shortcuts.toggle_bookmark_status" }} = <strong>f</strong></li>
<li>{{ t "page.keyboard_shortcuts.save_article" }} = <strong>s</strong></li>
<li>{{ t "page.keyboard_shortcuts.scroll_item_to_top" }} = <strong>z + t</strong></li>
<li>{{ t "page.keyboard_shortcuts.refresh_all_feeds" }} = <strong>R</strong></li>
<li>{{ t "page.keyboard_shortcuts.remove_feed" }} = <strong>#</strong></li>
<li>{{ t "page.keyboard_shortcuts.go_to_search" }} = <strong>/</strong></li>
<li>{{ t "page.keyboard_shortcuts.close_modal" }} = <strong>Esc</strong></li>
</ul>
</div>
</div>
</template>
<template id="icon_read">
{{ template "icon_read" }}
</template>
<template id="icon_unread">
{{ template "icon_unread" }}
</template>
<template id="icon_star">
{{ template "icon_star" }}
</template>
<template id="icon_unstar">
{{ template "icon_unstar" }}
</template>
</body>
</html>
{{ end }}
`,
"pagination": `{{ define "pagination" }}
<div class="pagination">
<div class="pagination-prev">
{{ if .ShowPrev }}
<a href="{{ .Route }}{{ if gt .PrevOffset 0 }}?offset={{ .PrevOffset }}{{ if .SearchQuery }}&amp;q={{ .SearchQuery }}{{ end }}{{ else }}{{ if .SearchQuery }}?q={{ .SearchQuery }}{{ end }}{{ end }}" data-page="previous" rel="prev">{{ t "pagination.previous" }}</a>
{{ else }}
{{ t "pagination.previous" }}
{{ end }}
</div>
<div class="pagination-next">
{{ if .ShowNext }}
<a href="{{ .Route }}?offset={{ .NextOffset }}{{ if .SearchQuery }}&amp;q={{ .SearchQuery }}{{ end }}" data-page="next" rel="next">{{ t "pagination.next" }}</a>
{{ else }}
{{ t "pagination.next" }}
{{ end }}
</div>
</div>
{{ end }}
`,
"settings_menu": `{{ define "settings_menu" }}
<ul>
<li>
<a href="{{ route "settings" }}">{{ t "menu.settings" }}</a>
</li>
<li>
<a href="{{ route "integrations" }}">{{ t "menu.integrations" }}</a>
</li>
<li>
<a href="{{ route "apiKeys" }}">{{ t "menu.api_keys" }}</a>
</li>
<li>
<a href="{{ route "sessions" }}">{{ t "menu.sessions" }}</a>
</li>
{{ if .user.IsAdmin }}
<li>
<a href="{{ route "users" }}">{{ t "menu.users" }}</a>
</li>
{{ end }}
<li>
<a href="{{ route "about" }}">{{ t "menu.about" }}</a>
</li>
</ul>
{{ end }}`,
}
var templateCommonMapChecksums = map[string]string{
"entry_pagination": "cdca9cf12586e41e5355190b06d9168f57f77b85924d1e63b13524bc15abcbf6",
"feed_list": "931e43d328a116318c510de5658c688cd940b934c86b6ec82a472e1f81e020ae",
"feed_menu": "318d8662dda5ca9dfc75b909c8461e79c86fb5082df1428f67aaf856f19f4b50",
"icons": "7161afa4cce46245a99cb1e49a605d3ff30e907c3f568ef9c17218718d20e042",
"item_meta": "fefa219c8296f0370632336ed59a2c8b0c2146ee77f3b10de1d9b87982219dc5",
"layout": "03c77ed0163b790c0622ecec173119537087c66f6a3925a931ae83a9a94d32cf",
"pagination": "7b61288e86283c4cf0dc83bcbf8bf1c00c7cb29e60201c8c0b633b2450d2911f",
"settings_menu": "e2b777630c0efdbc529800303c01d6744ed3af80ec505ac5a5b3f99c9b989156",
}
-10
View File
@@ -1,10 +0,0 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
/*
Package template handles template parsing and execution.
*/
package template // import "miniflux.app/template"
+70 -18
View File
@@ -6,7 +6,9 @@ package template // import "miniflux.app/template"
import (
"bytes"
"embed"
"html/template"
"strings"
"time"
"miniflux.app/errors"
@@ -16,22 +18,83 @@ import (
"github.com/gorilla/mux"
)
//go:embed templates/common/*.html
var commonTemplateFiles embed.FS
//go:embed templates/views/*.html
var viewTemplateFiles embed.FS
//go:embed templates/standalone/*.html
var standaloneTemplateFiles embed.FS
// Engine handles the templating system.
type Engine struct {
templates map[string]*template.Template
funcMap *funcMap
}
func (e *Engine) parseAll() {
commonTemplates := ""
for _, content := range templateCommonMap {
commonTemplates += content
// NewEngine returns a new template engine.
func NewEngine(router *mux.Router) *Engine {
return &Engine{
templates: make(map[string]*template.Template),
funcMap: &funcMap{router},
}
}
// ParseTemplates parses template files embed into the application.
func (e *Engine) ParseTemplates() error {
var commonTemplateContents strings.Builder
dirEntries, err := commonTemplateFiles.ReadDir("templates/common")
if err != nil {
return err
}
for name, content := range templateViewsMap {
logger.Debug("[Template] Parsing: %s", name)
e.templates[name] = template.Must(template.New("main").Funcs(e.funcMap.Map()).Parse(commonTemplates + content))
for _, dirEntry := range dirEntries {
fileData, err := commonTemplateFiles.ReadFile("templates/common/" + dirEntry.Name())
if err != nil {
return err
}
commonTemplateContents.Write(fileData)
}
dirEntries, err = viewTemplateFiles.ReadDir("templates/views")
if err != nil {
return err
}
for _, dirEntry := range dirEntries {
templateName := dirEntry.Name()
fileData, err := viewTemplateFiles.ReadFile("templates/views/" + dirEntry.Name())
if err != nil {
return err
}
var templateContents strings.Builder
templateContents.WriteString(commonTemplateContents.String())
templateContents.Write(fileData)
logger.Debug("[Template] Parsing: %s", templateName)
e.templates[templateName] = template.Must(template.New("main").Funcs(e.funcMap.Map()).Parse(templateContents.String()))
}
dirEntries, err = standaloneTemplateFiles.ReadDir("templates/standalone")
if err != nil {
return err
}
for _, dirEntry := range dirEntries {
templateName := dirEntry.Name()
fileData, err := standaloneTemplateFiles.ReadFile("templates/standalone/" + dirEntry.Name())
if err != nil {
return err
}
logger.Debug("[Template] Parsing: %s", templateName)
e.templates[templateName] = template.Must(template.New("base").Funcs(e.funcMap.Map()).Parse(string(fileData)))
}
return nil
}
// Render process a template.
@@ -75,14 +138,3 @@ func (e *Engine) Render(name, language string, data interface{}) []byte {
return b.Bytes()
}
// NewEngine returns a new template engine.
func NewEngine(router *mux.Router) *Engine {
tpl := &Engine{
templates: make(map[string]*template.Template),
funcMap: &funcMap{router},
}
tpl.parseAll()
return tpl
}
+8 -4
View File
@@ -80,6 +80,13 @@ func (f *funcMap) Map() template.FuncMap {
"theme_color": func(theme string) string {
return model.ThemeColor(theme)
},
"icon": func(iconName string) template.HTML {
return template.HTML(fmt.Sprintf(
`<svg class="icon" aria-hidden="true"><use xlink:href="%s#icon-%s"></svg>`,
route.Path(f.router, "appIcon", "filename", "sprite.svg"),
iconName,
))
},
// These functions are overrided at runtime after the parsing.
"elapsed": func(timezone string, t time.Time) string {
@@ -129,10 +136,7 @@ func truncate(str string, max int) string {
func isEmail(str string) bool {
_, err := mail.ParseAddress(str)
if err != nil {
return false
}
return true
return err == nil
}
func elapsedTime(printer *locale.Printer, tz string, t time.Time) string {
-135
View File
@@ -1,135 +0,0 @@
<!--
MIT License
Copyright (c) 2020 Paweł Kuna
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-->
{{ define "icon_read" }}
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-circle-check" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<circle cx="12" cy="12" r="9" />
<path d="M9 12l2 2l4 -4" />
</svg>
{{ end }}
{{ define "icon_unread" }}
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-circle-x" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<circle cx="12" cy="12" r="9" />
<path d="M10 10l4 4m0 -4l-4 4" />
</svg>
{{ end }}
{{ define "icon_star" }}
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-star" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<path d="M12 17.75l-6.172 3.245 1.179-6.873-4.993-4.867 6.9-1.002L12 2l3.086 6.253 6.9 1.002-4.993 4.867 1.179 6.873z" />
</svg>
{{ end }}
{{ define "icon_unstar" }}
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-unstar" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<path fill="currentColor" d="M12 17.75l-6.172 3.245 1.179-6.873-4.993-4.867 6.9-1.002L12 2l3.086 6.253 6.9 1.002-4.993 4.867 1.179 6.873z" />
</svg>
{{ end }}
{{ define "icon_save" }}
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-download" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2" />
<polyline points="7 11 12 16 17 11" />
<line x1="12" y1="4" x2="12" y2="16" />
</svg>
{{ end }}
{{ define "icon_scraper" }}
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-cloud-download" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<path d="M19 18a3.5 3.5 0 0 0 0 -7h-1a5 4.5 0 0 0 -11 -2a4.6 4.4 0 0 0 -2.1 8.4" />
<line x1="12" y1="13" x2="12" y2="22" />
<polyline points="9 19 12 22 15 19" />
</svg>
{{ end }}
{{ define "icon_share" }}
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-share" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="6" r="3" />
<circle cx="18" cy="18" r="3" />
<line x1="8.7" y1="10.7" x2="15.3" y2="7.3" />
<line x1="8.7" y1="13.3" x2="15.3" y2="16.7" />
</svg>
{{ end }}
{{ define "icon_comment" }}
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-message-circle" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<path d="M3 20l1.3 -3.9a9 8 0 1 1 3.4 2.9l-4.7 1" />
<line x1="12" y1="12" x2="12" y2="12.01" />
<line x1="8" y1="12" x2="8" y2="12.01" />
<line x1="16" y1="12" x2="16" y2="12.01" />
</svg>
{{ end }}
{{ define "icon_external_link" }}
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-external-link" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<path d="M11 7h-5a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-5" />
<line x1="10" y1="14" x2="20" y2="4" />
<polyline points="15 4 20 4 20 9" />
</svg>
{{ end }}
{{ define "icon_delete" }}
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-trash" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<line x1="4" y1="7" x2="20" y2="7" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
<path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12" />
<path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3" />
</svg>
{{ end }}
{{ define "icon_edit" }}
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-edit" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<path d="M9 7 h-3a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-3" />
<path d="M9 15h3l8.5 -8.5a1.5 1.5 0 0 0 -3 -3l-8.5 8.5v3" />
<line x1="16" y1="5" x2="19" y2="8" />
</svg>
{{ end }}
{{ define "icon_feeds" }}
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-folders" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<path d="M9 4h3l2 2h5a2 2 0 0 1 2 2v7a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2" />
<path d="M17 17v2a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2h2" />
</svg>
{{ end }}
{{ define "icon_entries" }}
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-news" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<path d="M16 6h3a1 1 0 0 1 1 1v11a2 2 0 0 1 -4 0v-13a1 1 0 0 0 -1 -1h-10a1 1 0 0 0 -1 1v12a3 3 0 0 0 3 3h11" />
<line x1="8" y1="8" x2="12" y2="8" />
<line x1="8" y1="12" x2="12" y2="12" />
<line x1="8" y1="16" x2="12" y2="16" />
</svg>
{{ end }}
{{ define "icon_refresh" }}
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-refresh" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -5v5h5" />
<path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 5v-5h-5" />
</svg>
{{ end }}

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