Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c56b4d580 | |||
| 32439ca2f0 | |||
| fa49bcaf8b | |||
| 89e8e94bf1 | |||
| 5b8eb4735c | |||
| 1c9f000576 | |||
| de53d8762f | |||
| daa56efe27 | |||
| f1db010a68 | |||
| dbb5906767 | |||
| 18e414ec45 | |||
| fc00a1b3a8 | |||
| 556b74f4fc | |||
| 6e2e2d1665 | |||
| 9242350f0e | |||
| b0c14aa8f9 | |||
| 4b2516532e | |||
| 1aec8d727c | |||
| e60e0ba3c4 | |||
| 5877048749 | |||
| c8c1f05328 | |||
| 96f3e888cf | |||
| 14888f1cb8 | |||
| 49171c5e8c | |||
| 51fb9495ad | |||
| 89c1b3b4d8 | |||
| 1d80c12e18 | |||
| d092fc8436 | |||
| 8a812cd8ec | |||
| ae13b4e420 | |||
| 548c4d4efe | |||
| f6ed2feab4 | |||
| b730aa520d | |||
| 3d16c3e4e6 | |||
| 0d935a863f | |||
| 053b1d0f8d | |||
| b247f3f089 | |||
| 20ec435d6d | |||
| 53cd369b05 | |||
| ec3c604a83 | |||
| c3f871b49b | |||
| e5b2eab727 | |||
| bbf93430b7 | |||
| 3cb04b2c56 | |||
| e3c28a6c96 | |||
| c2571f9f47 | |||
| 4855fbd13f | |||
| 9569666259 | |||
| 42edd357bc | |||
| b33706006a | |||
| 0de80c2ff0 | |||
| 5d65a85bdb | |||
| a352aff93b | |||
| 713d575bad | |||
| 421bb467d0 |
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a bug report
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -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
|
||||
@@ -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: ''
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
-----------------------------
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,5 +18,6 @@ func AvailableLanguages() map[string]string {
|
||||
"ru_RU": "Русский",
|
||||
"it_IT": "Italiano",
|
||||
"ja_JP": "日本語",
|
||||
"tr_TR": "Türkçe",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "記事の推定読書時間を表示する",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Показать примерное время чтения статей",
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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": "显示文章的预计阅读时间",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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&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&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"><code>Test</code> Test</title>
|
||||
<link href="http://example.org/2003/12/13/atom03"/>
|
||||
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
||||
<updated>2003-12-13T18:30:02Z</updated>
|
||||
<summary>Some text.</summary>
|
||||
</entry>
|
||||
|
||||
<entry>
|
||||
<title type="html"><![CDATA[Test “Test”]]></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"><code>Test</code> Test</title>
|
||||
<link href="http://example.org/2003/12/13/atom03"/>
|
||||
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
||||
<updated>2003-12-13T18:30:02Z</updated>
|
||||
<summary>Some text.</summary>
|
||||
</entry>
|
||||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("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 != `'AT&T'` {
|
||||
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"><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="html"><![CDATA[<p>Some text.</p>]]></summary>
|
||||
<summary type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>Test: <code>std::unique_ptr<S></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<S></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"><code>Test</code> 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"><code>std::unique_ptr&lt;S&gt;</code></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"><code>std::unique_ptr&lt;S&gt;</code></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<S></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<S></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&T <S></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&T <S></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&T <S></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&T <S>`
|
||||
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&T <S></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&T <S></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&T <S></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&T <S>`
|
||||
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;T bought <b>by SBC</b>!</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;T bought <b>by SBC</b>!</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&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&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&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&T bought <b>by SBC</b>!` {
|
||||
t.Errorf("Incorrect entry content, got: %q", feed.Entries[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;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;T <img src="https://example.org/img.png"></a></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&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
@@ -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 {
|
||||
|
||||
@@ -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’s Newsletter]]></title>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Title != `Jenny’s 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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 := `
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">« {{ 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 }}&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 }}&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",
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user