mirror of https://github.com/miniflux/v2.git
Merge branch 'main' of github.com:miniflux/v2 into patch-1
This commit is contained in:
commit
d90738005f
|
@ -12,10 +12,13 @@ jobs:
|
|||
- name: Set up Golang
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22"
|
||||
go-version: "1.22.x"
|
||||
check-latest: true
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Compile binaries
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
run: make build
|
||||
- name: Upload binaries
|
||||
uses: actions/upload-artifact@v4
|
||||
|
|
|
@ -29,6 +29,10 @@ jobs:
|
|||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22.x"
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
name: Debian Packages
|
||||
permissions: read-all
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- '[0-9]+.[0-9]+.[0-9]+'
|
||||
|
@ -28,8 +29,34 @@ jobs:
|
|||
run: make debian-packages
|
||||
- name: List generated files
|
||||
run: ls -l *.deb
|
||||
build-packages-manually:
|
||||
if: github.event_name != 'pull_request' && github.event_name != 'push'
|
||||
name: Build Packages Manually
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
id: buildx
|
||||
with:
|
||||
install: true
|
||||
- name: Available Docker Platforms
|
||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
||||
- name: Build Debian Packages
|
||||
run: make debian-packages
|
||||
- name: Upload package
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: packages
|
||||
path: "*.deb"
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
publish-packages:
|
||||
if: ${{ ! github.event.pull_request }}
|
||||
if: github.event_name == 'push'
|
||||
name: Publish Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
|
|
@ -13,11 +13,13 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install jshint
|
||||
- name: Install linters
|
||||
run: |
|
||||
sudo npm install -g jshint@2.13.3
|
||||
sudo npm install -g jshint@2.13.6 eslint@8.57.0
|
||||
- name: Run jshint
|
||||
run: jshint ui/static/js/*.js
|
||||
run: jshint internal/ui/static/js/*.js
|
||||
- name: Run ESLint
|
||||
run: eslint internal/ui/static/js/*.js
|
||||
|
||||
golangci:
|
||||
name: Golang Linter
|
||||
|
@ -26,7 +28,12 @@ jobs:
|
|||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22"
|
||||
go-version: "1.22.x"
|
||||
- run: "go vet ./..."
|
||||
- uses: golangci/golangci-lint-action@v4
|
||||
with:
|
||||
args: --timeout 10m --skip-dirs tests --disable errcheck --enable sqlclosecheck --enable misspell --enable gofmt --enable goimports --enable whitespace
|
||||
- uses: dominikh/staticcheck-action@v1.3.0
|
||||
with:
|
||||
version: "2023.1.7"
|
||||
install-go: false
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
name: RPM Packages
|
||||
permissions: read-all
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- '[0-9]+.[0-9]+.[0-9]+'
|
||||
|
@ -19,8 +20,25 @@ jobs:
|
|||
run: make rpm
|
||||
- name: List generated files
|
||||
run: ls -l *.rpm
|
||||
build-package-manually:
|
||||
if: github.event_name != 'pull_request' && github.event_name != 'push'
|
||||
name: Build Packages Manually
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Build RPM Package
|
||||
run: make rpm
|
||||
- name: Upload package
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: packages
|
||||
path: "*.rpm"
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
publish-package:
|
||||
if: ${{ ! github.event.pull_request }}
|
||||
if: github.event_name == 'push'
|
||||
name: Publish Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
max-parallel: 4
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macOS-latest]
|
||||
go-version: ["1.22"]
|
||||
go-version: ["1.22.x"]
|
||||
steps:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
|
@ -43,7 +43,7 @@ jobs:
|
|||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22"
|
||||
go-version: "1.22.x"
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Postgres client
|
||||
|
|
113
ChangeLog
113
ChangeLog
|
@ -1,3 +1,116 @@
|
|||
Version 2.1.1 (March 10, 2024)
|
||||
-----------------------------
|
||||
|
||||
* Move search form to a dedicated page
|
||||
* Add Readeck integration
|
||||
* Add feed option to disable HTTP/2 to avoid fingerprinting
|
||||
* Add `Enter` key as a hotkey to open selected item
|
||||
* Proxify `video` element `poster` attribute
|
||||
* Add a couple of new possible locations for feeds
|
||||
* Hugo likes to generate `index.xml`
|
||||
* `feed.atom` and `feed.rss` are used by enterprise-scale/old-school gigantic CMS
|
||||
* Fix categories import from Thunderbird's OPML
|
||||
* Fix logo misalignment when using languages that are more verbose than English
|
||||
* Google Reader: Do not return a 500 error when no items is returned
|
||||
* Handle RDF feeds with duplicated `<title>` elements
|
||||
* Sort integrations alphabetically
|
||||
* Add more URL validation in media proxy
|
||||
* Add unit test to ensure each translation has the correct number of plurals
|
||||
* Add missing plurals for some languages
|
||||
* Makefile: quiet `git describe` and `rev-parse` stderr: When building from a tarball instead of a cloned git repo, there would be two `fatal: not a git repository` errors emitted even though the build succeeds. This is because of how `VERSION` and `COMMIT` are set in the Makefile. This PR suppresses the stderr for these variable assignments.
|
||||
* Makefile: do not force `CGO_ENABLED=0` for `miniflux` target
|
||||
* Add GitHub Action pipeline to build packages on-demand
|
||||
* Remove Golint (deprecated), use `staticcheck` and `golangci-lint` instead
|
||||
* Build amd64/arm64 Debian packages with CGO disabled
|
||||
* Update `go.mod` and add `.exe` suffix to Windows binary
|
||||
* Add a couple of fuzzers
|
||||
* Fix CodeQL workflow
|
||||
* Code and performance improvements:
|
||||
* Use an `io.ReadSeeker` instead of an `io.Reader` to parse feeds
|
||||
* Speed up the sanitizer:
|
||||
- Allow Youtube URLs to start with `www`
|
||||
- Use `strings.Builder` instead of a `bytes.Buffer`
|
||||
- Use a `strings.NewReader` instead of a `bytes.NewBufferString`
|
||||
- Sprinkles a couple of `continue` to make the code-flow more obvious
|
||||
- Inline calls to `inList`, and put their parameters in the right order
|
||||
- Simplify `isPixelTracker`
|
||||
- Simplify `isValidIframeSource`, by extracting the hostname and comparing it directly, instead of using the full url and checking if it starts with multiple variations of the same one (`//`, `http:`, `https://` multiplied by `/www.`)
|
||||
- Add a benchmark
|
||||
- Instead of having to allocate a ~100 keys map containing possibly dynamic values (at least to the go compiler), allocate it once in a global variable. This significantly speeds things up, by reducing the garbage
|
||||
- Use constant time access for maps instead of iterating on them
|
||||
- Build a ~large whitelist map inline instead of constructing it item by item (and remove a duplicate key/value pair)
|
||||
- Use `slices` instead of hand-rolled loops
|
||||
collector/allocator involvements.
|
||||
* Reuse a `Reader` instead of copying to a buffer when parsing an Atom feed
|
||||
* Preallocate memory when exporting to OPML: This should marginally increase performance when exporting a large amount of feeds to OPML
|
||||
* Delay call of `view.New` after logging the user in: There is no need to do extra work like creating a session and its associated view until the user has been properly identified and as many possibly-failing sql request have been successfully run
|
||||
* Use constant-time comparison for anti-csrf tokens: This is probably completely overkill, but since anti-csrf tokens are secrets, they should be compared against untrusted inputs in constant time
|
||||
* Simplify and optimize `genericProxyRewriter`
|
||||
- Reduce the amount of nested loops: it's preferable to search the whole page once and filter on it (even with filters that should always be false), than searching it again for every element we're looking for.
|
||||
- Factorize the proxying conditions into a `shouldProxy` function to reduce the copy-pasta.
|
||||
* Speed up `removeUnlikelyCandidates`: `.Not` returns a brand new `Selection`, copied element by element
|
||||
* Improve `EstimateReadingTime`'s speed by a factor 7
|
||||
- Refactorise the tests and add some
|
||||
- Use 250 signs instead of the whole text
|
||||
- Only check for Korean, Chinese and Japanese script
|
||||
- Add a benchmark
|
||||
- Use a more idiomatic control flow
|
||||
* Don't compute reading-time when unused: If the user doesn't display reading times, there is no need to compute them. This should speed things up a bit, since `whatlanggo.Detect` is abysmally slow.
|
||||
* Simplify `username` generation for the integration tests: No need to generate random numbers 10 times, generate a single big-enough one. A single int64 should be more than enough
|
||||
* Add missing regex anchor detected by CodeQL
|
||||
* Don't mix up slices capacity and length
|
||||
* Use prepared statements for intervals, `ArchiveEntries` and `updateEnclosures`
|
||||
* Use modern for-loops introduced with Go 1.22
|
||||
* Remove a superfluous condition: No need to check if the length of `line` is positive since we're checking afterwards that it contains the `=` sign
|
||||
* Close resources as soon as possible, instead of using `defer()` in a loop
|
||||
* Remove superfluous escaping in a regex
|
||||
* Use `strings.ReplaceAll` instead of `strings.Replace(…, -1)`
|
||||
* Use `strings.EqualFold` instead of `strings.ToLower(…) ==`
|
||||
* Use `.WriteString(` instead of `.Write([]byte(…`
|
||||
* Use `%q` instead of `"%s"`
|
||||
* Make `internal/worker/worker.go` read-only
|
||||
* Use a switch-case construct in `internal/locale/plural.go` instead of an avalanche of `if`
|
||||
* Template functions: simplify `formatFileSize` and `duration` implementation
|
||||
* Inline some templating functions
|
||||
* Make use of `printer.Print` when possible
|
||||
* Add a `printer.Print` to `internal/locale/printer.go`: No need to use variadic functions with string format interpolation to generate static strings
|
||||
* Minor code simplification in `internal/ui/view/view.go`: No need to create the map item by item when we can create it in one go
|
||||
* Build the map inline in `CountAllFeeds()`: No need to build an empty map to then add more fields in it one by one
|
||||
* Miscellaneous improvements to `internal/reader/subscription/finder.go`:
|
||||
- Surface `localizedError` in `FindSubscriptionsFromWellKnownURLs` via `slog`
|
||||
- Use an inline declaration for new subscriptions, like done elsewhere in the
|
||||
file, if only for consistency's sake
|
||||
- Preallocate the `subscriptions` slice when using an RSS-bridge,
|
||||
* Use an update-where for `MarkCategoryAsRead` instead of a subquery
|
||||
* Simplify `CleanOldUserSessions`' query: No need for a subquery, filtering on `created_at` directly is enough
|
||||
* Simplify `cleanupEntries`' query
|
||||
- `NOT (hash=ANY(%4))` can be expressed as `hash NOT IN $4`
|
||||
- There is no need for a subquery operating on the same table, moving the conditions out is equivalent.
|
||||
* Reformat `ArchiveEntries`'s query for consistency's sake and replace the `=ANY` with an `IN`
|
||||
* Reformat the query in `GetEntryIDs` and `GetReadTime`'s query for consistency's sake
|
||||
* Simplify `WeeklyFeedEntryCount`: No need for a `BETWEEN`: we want to filter on entries published in the last week, no need to express is as "entries published between now and last week", "entries published after last week" is enough
|
||||
* Add some tests for `add_image_title`
|
||||
* Remove `github.com/google/uuid` dependencies: Replace it with a hand-rolled implementation. Heck, an UUID isn't even a requirement according to Omnivore API docs
|
||||
* Simplify `internal/reader/icon/finder.go`:
|
||||
- Use a simple regex to parse data uri instead of a hand-rolled parser, and document what fields are considered mandatory.
|
||||
- Use case-insensitive matching to find (fav)icons, instead of doing the same query twice with different letter cases
|
||||
- Add `apple-touch-icon-precomposed.png` as a fallback `favicon`
|
||||
- Reorder the queries to have `icon` first, since it seems to be the most popular one. It used to be last, meaning that pages had to be parsed completely 4 times, instead of one now.
|
||||
- Minor factorisation in `findIconURLsFromHTMLDocument`
|
||||
* Small refactoring of `internal/reader/date/parser.go`:
|
||||
- Split dates formats into those that require local times and those who don't, so that there is no need to have a switch-case in the for loop with around 250 iterations at most.
|
||||
- Be more strict when it comes to timezones, previously invalid ones like -13 were accepted. Also add a test for this.
|
||||
- Bail out early if the date is an empty string.
|
||||
* Make use of Go ≥ 1.21 slices package instead of hand-rolled loops
|
||||
* Reorder the fields of the `Entry` struct to save some memory
|
||||
* Dependencies update:
|
||||
* Bump `golang.org/x/oauth2` from `0.17.0` to `0.18.0`
|
||||
* Bump `github.com/prometheus/client_golang` from `1.18.0` to `1.19.0`
|
||||
* Bump `github.com/tdewolff/minify/v2` from `2.20.16` to `2.20.18`
|
||||
* Bump `github.com/PuerkitoBio/goquery` from `1.8.1` to `1.9.1`
|
||||
* Bump `golang.org/x/crypto` from `0.19.0` to `0.20.0`
|
||||
* Bump `github.com/go-jose/go-jose/v3` from `3.0.1` to `3.0.3`
|
||||
|
||||
Version 2.1.0 (February 17, 2024)
|
||||
---------------------------------
|
||||
|
||||
|
|
18
Makefile
18
Makefile
|
@ -1,7 +1,7 @@
|
|||
APP := miniflux
|
||||
DOCKER_IMAGE := miniflux/miniflux
|
||||
VERSION := $(shell git describe --tags --abbrev=0)
|
||||
COMMIT := $(shell git rev-parse --short HEAD)
|
||||
VERSION := $(shell git describe --tags --abbrev=0 2>/dev/null)
|
||||
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null)
|
||||
BUILD_DATE := `date +%FT%T%z`
|
||||
LD_FLAGS := "-s -w -X 'miniflux.app/v2/internal/version.Version=$(VERSION)' -X 'miniflux.app/v2/internal/version.Commit=$(COMMIT)' -X 'miniflux.app/v2/internal/version.BuildDate=$(BUILD_DATE)'"
|
||||
PKG_LIST := $(shell go list ./... | grep -v /vendor/)
|
||||
|
@ -44,7 +44,7 @@ export PGPASSWORD := postgres
|
|||
debian-packages
|
||||
|
||||
miniflux:
|
||||
@ CGO_ENABLED=0 go build -buildmode=pie -ldflags=$(LD_FLAGS) -o $(APP) main.go
|
||||
@ go build -buildmode=pie -ldflags=$(LD_FLAGS) -o $(APP) main.go
|
||||
|
||||
miniflux-no-pie:
|
||||
@ go build -ldflags=$(LD_FLAGS) -o $(APP) main.go
|
||||
|
@ -77,7 +77,7 @@ openbsd-amd64:
|
|||
@ GOOS=openbsd GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
|
||||
windows-amd64:
|
||||
@ GOOS=windows GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ GOOS=windows GOARCH=amd64 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@.exe main.go
|
||||
|
||||
build: linux-amd64 linux-arm64 linux-armv7 linux-armv6 linux-armv5 darwin-amd64 darwin-arm64 freebsd-amd64 openbsd-amd64 windows-amd64
|
||||
|
||||
|
@ -98,19 +98,21 @@ openbsd-x86:
|
|||
@ GOOS=openbsd GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
|
||||
windows-x86:
|
||||
@ GOOS=windows GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@ main.go
|
||||
@ GOOS=windows GOARCH=386 go build -ldflags=$(LD_FLAGS) -o $(APP)-$@.exe main.go
|
||||
|
||||
run:
|
||||
@ LOG_DATE_TIME=1 DEBUG=1 RUN_MIGRATIONS=1 CREATE_ADMIN=1 ADMIN_USERNAME=admin ADMIN_PASSWORD=test123 go run main.go
|
||||
|
||||
clean:
|
||||
@ rm -f $(APP)-* $(APP) $(APP)*.rpm $(APP)*.deb
|
||||
@ rm -f $(APP)-* $(APP) $(APP)*.rpm $(APP)*.deb $(APP)*.exe
|
||||
|
||||
test:
|
||||
go test -cover -race -count=1 ./...
|
||||
|
||||
lint:
|
||||
golint -set_exit_status ${PKG_LIST}
|
||||
go vet ./...
|
||||
staticcheck ./...
|
||||
golangci-lint run --disable errcheck --enable sqlclosecheck --enable misspell --enable gofmt --enable goimports --enable whitespace
|
||||
|
||||
integration-test:
|
||||
psql -U postgres -c 'drop database if exists miniflux_test;'
|
||||
|
@ -124,7 +126,7 @@ integration-test:
|
|||
RUN_MIGRATIONS=1 \
|
||||
DEBUG=1 \
|
||||
./miniflux-test >/tmp/miniflux.log 2>&1 & echo "$$!" > "/tmp/miniflux.pid"
|
||||
|
||||
|
||||
while ! nc -z localhost 8080; do sleep 1; done
|
||||
go test -v -tags=integration -count=1 miniflux.app/v2/internal/tests
|
||||
|
||||
|
|
|
@ -107,7 +107,7 @@ type Subscription struct {
|
|||
}
|
||||
|
||||
func (s Subscription) String() string {
|
||||
return fmt.Sprintf(`Title="%s", URL="%s", Type="%s"`, s.Title, s.URL, s.Type)
|
||||
return fmt.Sprintf(`Title=%q, URL=%q, Type=%q`, s.Title, s.URL, s.Type)
|
||||
}
|
||||
|
||||
// Subscriptions represents a list of subscriptions.
|
||||
|
@ -140,6 +140,7 @@ type Feed struct {
|
|||
Password string `json:"password"`
|
||||
Category *Category `json:"category,omitempty"`
|
||||
HideGlobally bool `json:"hide_globally"`
|
||||
DisableHTTP2 bool `json:"disable_http2"`
|
||||
}
|
||||
|
||||
// FeedCreationRequest represents the request to create a feed.
|
||||
|
@ -160,6 +161,7 @@ type FeedCreationRequest struct {
|
|||
BlocklistRules string `json:"blocklist_rules"`
|
||||
KeeplistRules string `json:"keeplist_rules"`
|
||||
HideGlobally bool `json:"hide_globally"`
|
||||
DisableHTTP2 bool `json:"disable_http2"`
|
||||
}
|
||||
|
||||
// FeedModificationRequest represents the request to update a feed.
|
||||
|
@ -182,6 +184,7 @@ type FeedModificationRequest struct {
|
|||
AllowSelfSignedCertificates *bool `json:"allow_self_signed_certificates"`
|
||||
FetchViaProxy *bool `json:"fetch_via_proxy"`
|
||||
HideGlobally *bool `json:"hide_globally"`
|
||||
DisableHTTP2 *bool `json:"disable_http2"`
|
||||
}
|
||||
|
||||
// FeedIcon represents the feed icon.
|
||||
|
@ -202,24 +205,24 @@ type Feeds []*Feed
|
|||
// Entry represents a subscription item in the system.
|
||||
type Entry struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
FeedID int64 `json:"feed_id"`
|
||||
Status string `json:"status"`
|
||||
Date time.Time `json:"published_at"`
|
||||
ChangedAt time.Time `json:"changed_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Feed *Feed `json:"feed,omitempty"`
|
||||
Hash string `json:"hash"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
CommentsURL string `json:"comments_url"`
|
||||
Date time.Time `json:"published_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ChangedAt time.Time `json:"changed_at"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Content string `json:"content"`
|
||||
Author string `json:"author"`
|
||||
ShareCode string `json:"share_code"`
|
||||
Starred bool `json:"starred"`
|
||||
ReadingTime int `json:"reading_time"`
|
||||
Enclosures Enclosures `json:"enclosures,omitempty"`
|
||||
Feed *Feed `json:"feed,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
ReadingTime int `json:"reading_time"`
|
||||
UserID int64 `json:"user_id"`
|
||||
FeedID int64 `json:"feed_id"`
|
||||
Starred bool `json:"starred"`
|
||||
}
|
||||
|
||||
// EntryModificationRequest represents a request to modify an entry.
|
||||
|
|
29
go.mod
29
go.mod
|
@ -1,22 +1,21 @@
|
|||
module miniflux.app/v2
|
||||
|
||||
// +heroku goVersion go1.21
|
||||
// +heroku goVersion go1.22
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.8.1
|
||||
github.com/PuerkitoBio/goquery v1.9.1
|
||||
github.com/abadojack/whatlanggo v1.0.1
|
||||
github.com/coreos/go-oidc/v3 v3.9.0
|
||||
github.com/go-webauthn/webauthn v0.10.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/prometheus/client_golang v1.18.0
|
||||
github.com/tdewolff/minify/v2 v2.20.17
|
||||
github.com/prometheus/client_golang v1.19.0
|
||||
github.com/tdewolff/minify/v2 v2.20.18
|
||||
github.com/yuin/goldmark v1.7.0
|
||||
golang.org/x/crypto v0.19.0
|
||||
golang.org/x/net v0.21.0
|
||||
golang.org/x/oauth2 v0.17.0
|
||||
golang.org/x/term v0.17.0
|
||||
golang.org/x/crypto v0.21.0
|
||||
golang.org/x/net v0.22.0
|
||||
golang.org/x/oauth2 v0.18.0
|
||||
golang.org/x/term v0.18.0
|
||||
mvdan.cc/xurls/v2 v2.5.0
|
||||
)
|
||||
|
||||
|
@ -31,19 +30,19 @@ require (
|
|||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.5.0 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.1 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.3 // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/prometheus/client_model v0.5.0 // indirect
|
||||
github.com/prometheus/common v0.45.0 // indirect
|
||||
github.com/prometheus/common v0.48.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.7.12 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/sys v0.17.0 // indirect
|
||||
golang.org/x/sys v0.18.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
google.golang.org/protobuf v1.32.0 // indirect
|
||||
)
|
||||
|
||||
go 1.21
|
||||
go 1.22
|
||||
|
|
60
go.sum
60
go.sum
|
@ -1,8 +1,7 @@
|
|||
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
|
||||
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
|
||||
github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI=
|
||||
github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY=
|
||||
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
|
||||
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
|
||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
|
@ -16,8 +15,8 @@ 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=
|
||||
github.com/fxamacker/cbor/v2 v2.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADivE=
|
||||
github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
|
||||
github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA=
|
||||
github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
|
||||
github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k=
|
||||
github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
|
||||
github.com/go-webauthn/webauthn v0.10.1 h1:+RFKj4yHPy282teiiy5sqTYPfRilzBpJyedrz9KsNFE=
|
||||
github.com/go-webauthn/webauthn v0.10.1/go.mod h1:a7BwAtrSMkeuJXtIKz433Av99nAv01pdfzB0a9xkDnI=
|
||||
github.com/go-webauthn/x v0.1.8 h1:f1C6k1AyUlDvnIzWSW+G9rN9nbp1hhLXZagUtyxZ8nc=
|
||||
|
@ -28,10 +27,10 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
|
|||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk=
|
||||
github.com/google/go-tpm v0.9.0/go.mod h1:FkNVkc6C+IsvDI9Jw1OveJmxGZUUaKxtrpOS47QWKfU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
|
@ -40,26 +39,24 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
|||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
|
||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
|
||||
github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA=
|
||||
github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
|
||||
github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
|
||||
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
|
||||
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
|
||||
github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
|
||||
github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
|
||||
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
|
||||
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
|
||||
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/tdewolff/minify/v2 v2.20.17 h1:zGqEDhspr3XjSrQI/56vw9IdAhLAaKTLXWnDBsxNVt8=
|
||||
github.com/tdewolff/minify/v2 v2.20.17/go.mod h1:ulkFoeAVWMLEyjuDz1ZIWOA31g5aWOawCFRp9R/MudM=
|
||||
github.com/tdewolff/minify/v2 v2.20.18 h1:y+s6OzlZwFqApgNXWNtaMuEMEPbHT72zrCyb9Az35Xo=
|
||||
github.com/tdewolff/minify/v2 v2.20.18/go.mod h1:ulkFoeAVWMLEyjuDz1ZIWOA31g5aWOawCFRp9R/MudM=
|
||||
github.com/tdewolff/parse/v2 v2.7.12 h1:tgavkHc2ZDEQVKy1oWxwIyh5bP4F5fEh/JmBwPP/3LQ=
|
||||
github.com/tdewolff/parse/v2 v2.7.12/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
|
||||
github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
|
||||
|
@ -71,47 +68,46 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t
|
|||
github.com/yuin/goldmark v1.7.0 h1:EfOIvIMZIzHdB/R/zVrikYLPPwJlfMcNczJFMs1m6sA=
|
||||
github.com/yuin/goldmark v1.7.0/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ=
|
||||
golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI=
|
||||
golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
|
@ -128,8 +124,8 @@ google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAs
|
|||
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
|
||||
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
|
|
@ -275,7 +275,9 @@ func (h *handler) updateEntry(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
entryUpdateRequest.Patch(entry)
|
||||
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
|
||||
if user.ShowReadingTime {
|
||||
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
|
||||
}
|
||||
|
||||
if err := h.store.UpdateEntryTitleAndContent(entry); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
|
|
|
@ -42,6 +42,7 @@ func (h *handler) discoverSubscriptions(w http.ResponseWriter, r *http.Request)
|
|||
requestBuilder.WithUsernameAndPassword(subscriptionDiscoveryRequest.Username, subscriptionDiscoveryRequest.Password)
|
||||
requestBuilder.UseProxy(subscriptionDiscoveryRequest.FetchViaProxy)
|
||||
requestBuilder.IgnoreTLSErrors(subscriptionDiscoveryRequest.AllowSelfSignedCertificates)
|
||||
requestBuilder.DisableHTTP2(subscriptionDiscoveryRequest.DisableHTTP2)
|
||||
|
||||
subscriptions, localizedError := subscription.NewSubscriptionFinder(requestBuilder).FindSubscriptions(
|
||||
subscriptionDiscoveryRequest.URL,
|
||||
|
|
|
@ -77,7 +77,7 @@ func (h *handler) updateUser(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
if userModificationRequest.IsAdmin != nil && *userModificationRequest.IsAdmin {
|
||||
json.BadRequest(w, r, errors.New("Only administrators can change permissions of standard users"))
|
||||
json.BadRequest(w, r, errors.New("only administrators can change permissions of standard users"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
@ -141,7 +141,7 @@ func (h *handler) userByID(w http.ResponseWriter, r *http.Request) {
|
|||
userID := request.RouteInt64Param(r, "userID")
|
||||
user, err := h.store.UserByID(userID)
|
||||
if err != nil {
|
||||
json.BadRequest(w, r, errors.New("Unable to fetch this user from the database"))
|
||||
json.BadRequest(w, r, errors.New("unable to fetch this user from the database"))
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -163,7 +163,7 @@ func (h *handler) userByUsername(w http.ResponseWriter, r *http.Request) {
|
|||
username := request.RouteStringParam(r, "username")
|
||||
user, err := h.store.UserByUsername(username)
|
||||
if err != nil {
|
||||
json.BadRequest(w, r, errors.New("Unable to fetch this user from the database"))
|
||||
json.BadRequest(w, r, errors.New("unable to fetch this user from the database"))
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -194,7 +194,7 @@ func (h *handler) removeUser(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
if user.ID == request.UserID(r) {
|
||||
json.BadRequest(w, r, errors.New("You cannot remove yourself"))
|
||||
json.BadRequest(w, r, errors.New("you cannot remove yourself"))
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -45,7 +45,7 @@ func refreshFeeds(store *storage.Storage) {
|
|||
slog.Int("nb_workers", config.Opts.WorkerPoolSize()),
|
||||
)
|
||||
|
||||
for i := 0; i < config.Opts.WorkerPoolSize(); i++ {
|
||||
for i := range config.Opts.WorkerPoolSize() {
|
||||
wg.Add(1)
|
||||
go func(workerID int) {
|
||||
defer wg.Done()
|
||||
|
|
|
@ -1944,7 +1944,7 @@ func TestParseConfigDumpOutput(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := tmpfile.Write([]byte(serialized)); err != nil {
|
||||
if _, err := tmpfile.WriteString(serialized); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
|
@ -4,12 +4,12 @@
|
|||
package config // import "miniflux.app/v2/internal/config"
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
|
@ -171,9 +171,6 @@ type Options struct {
|
|||
|
||||
// NewOptions returns Options with default values.
|
||||
func NewOptions() *Options {
|
||||
randomKey := make([]byte, 16)
|
||||
rand.Read(randomKey)
|
||||
|
||||
return &Options{
|
||||
HTTPS: defaultHTTPS,
|
||||
logFile: defaultLogFile,
|
||||
|
@ -242,7 +239,7 @@ func NewOptions() *Options {
|
|||
metricsPassword: defaultMetricsPassword,
|
||||
watchdog: defaultWatchdog,
|
||||
invidiousInstance: defaultInvidiousInstance,
|
||||
proxyPrivateKey: randomKey,
|
||||
proxyPrivateKey: crypto.GenerateRandomBytes(16),
|
||||
webAuthn: defaultWebAuthn,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -56,7 +56,7 @@ func (p *Parser) parseFileContent(r io.Reader) (lines []string) {
|
|||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if len(line) > 0 && !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
|
||||
if !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import (
|
|||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
@ -16,8 +17,7 @@ import (
|
|||
|
||||
// HashFromBytes returns a SHA-256 checksum of the input.
|
||||
func HashFromBytes(value []byte) string {
|
||||
sum := sha256.Sum256(value)
|
||||
return fmt.Sprintf("%x", sum)
|
||||
return fmt.Sprintf("%x", sha256.Sum256(value))
|
||||
}
|
||||
|
||||
// Hash returns a SHA-256 checksum of a string.
|
||||
|
@ -55,3 +55,12 @@ func GenerateSHA256Hmac(secret string, data []byte) string {
|
|||
h.Write(data)
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
func GenerateUUID() string {
|
||||
b := GenerateRandomBytes(16)
|
||||
return fmt.Sprintf("%X-%X-%X-%X-%X", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
|
||||
}
|
||||
|
||||
func ConstantTimeCmp(a, b string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||
}
|
||||
|
|
|
@ -855,4 +855,20 @@ var migrations = []func(tx *sql.Tx) error{
|
|||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `
|
||||
ALTER TABLE integrations ADD COLUMN readeck_enabled bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN readeck_only_url bool default 'f';
|
||||
ALTER TABLE integrations ADD COLUMN readeck_url text default '';
|
||||
ALTER TABLE integrations ADD COLUMN readeck_api_key text default '';
|
||||
ALTER TABLE integrations ADD COLUMN readeck_labels text default '';
|
||||
`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
func(tx *sql.Tx) (err error) {
|
||||
sql := `ALTER TABLE feeds ADD COLUMN disable_http2 bool default 'f'`
|
||||
_, err = tx.Exec(sql)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
|
|
@ -909,7 +909,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
|||
)
|
||||
|
||||
if err := checkOutputFormat(w, r); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -960,7 +960,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
|||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
json.ServerError(w, r, fmt.Errorf("googlereader: no items returned from the database"))
|
||||
json.BadRequest(w, r, fmt.Errorf("googlereader: no items returned from the database for item IDs: %v", itemIDs))
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -984,7 +984,7 @@ func (h *handler) streamItemContentsHandler(w http.ResponseWriter, r *http.Reque
|
|||
}
|
||||
contentItems := make([]contentItem, len(entries))
|
||||
for i, entry := range entries {
|
||||
enclosures := make([]contentItemEnclosure, len(entry.Enclosures))
|
||||
enclosures := make([]contentItemEnclosure, 0, len(entry.Enclosures))
|
||||
for _, enclosure := range entry.Enclosures {
|
||||
enclosures = append(enclosures, contentItemEnclosure{URL: enclosure.URL, Type: enclosure.MimeType})
|
||||
}
|
||||
|
@ -1206,7 +1206,7 @@ func (h *handler) subscriptionListHandler(w http.ResponseWriter, r *http.Request
|
|||
)
|
||||
|
||||
if err := checkOutputFormat(w, r); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -1252,7 +1252,7 @@ func (h *handler) userInfoHandler(w http.ResponseWriter, r *http.Request) {
|
|||
)
|
||||
|
||||
if err := checkOutputFormat(w, r); err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -1277,7 +1277,7 @@ func (h *handler) streamItemIDsHandler(w http.ResponseWriter, r *http.Request) {
|
|||
)
|
||||
|
||||
if err := checkOutputFormat(w, r); err != nil {
|
||||
json.ServerError(w, r, fmt.Errorf("googlereader: output only as json supported"))
|
||||
json.BadRequest(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@ import (
|
|||
"miniflux.app/v2/internal/integration/omnivore"
|
||||
"miniflux.app/v2/internal/integration/pinboard"
|
||||
"miniflux.app/v2/internal/integration/pocket"
|
||||
"miniflux.app/v2/internal/integration/readeck"
|
||||
"miniflux.app/v2/internal/integration/readwise"
|
||||
"miniflux.app/v2/internal/integration/shaarli"
|
||||
"miniflux.app/v2/internal/integration/shiori"
|
||||
|
@ -250,6 +251,29 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
|
|||
}
|
||||
}
|
||||
|
||||
if userIntegrations.ReadeckEnabled {
|
||||
slog.Debug("Sending entry to Readeck",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
)
|
||||
|
||||
client := readeck.NewClient(
|
||||
userIntegrations.ReadeckURL,
|
||||
userIntegrations.ReadeckAPIKey,
|
||||
userIntegrations.ReadeckLabels,
|
||||
userIntegrations.ReadeckOnlyURL,
|
||||
)
|
||||
if err := client.CreateBookmark(entry.URL, entry.Title, entry.Content); err != nil {
|
||||
slog.Error("Unable to send entry to Readeck",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
slog.Int64("entry_id", entry.ID),
|
||||
slog.String("entry_url", entry.URL),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if userIntegrations.ReadwiseEnabled {
|
||||
slog.Debug("Sending entry to Readwise",
|
||||
slog.Int64("user_id", userIntegrations.UserID),
|
||||
|
|
|
@ -28,7 +28,7 @@ func PushEntries(feed *model.Feed, entries model.Entries, matrixBaseURL, matrixU
|
|||
|
||||
for _, entry := range entries {
|
||||
textMessages = append(textMessages, fmt.Sprintf(`[%s] %s - %s`, feed.Title, entry.Title, entry.URL))
|
||||
formattedTextMessages = append(formattedTextMessages, fmt.Sprintf(`<li><strong>%s</strong>: <a href="%s">%s</a></li>`, feed.Title, entry.URL, entry.Title))
|
||||
formattedTextMessages = append(formattedTextMessages, fmt.Sprintf(`<li><strong>%s</strong>: <a href=%q>%s</a></li>`, feed.Title, entry.URL, entry.Title))
|
||||
}
|
||||
|
||||
_, err = client.SendFormattedTextMessage(
|
||||
|
|
|
@ -11,8 +11,7 @@ import (
|
|||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
|
@ -79,7 +78,7 @@ func (c *client) SaveUrl(url string) error {
|
|||
"query": mutation,
|
||||
"variables": map[string]interface{}{
|
||||
"input": map[string]interface{}{
|
||||
"clientRequestId": uuid.New().String(),
|
||||
"clientRequestId": crypto.GenerateUUID(),
|
||||
"source": "api",
|
||||
"url": url,
|
||||
},
|
||||
|
|
|
@ -0,0 +1,149 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package readeck // import "miniflux.app/v2/internal/integration/readeck"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
"miniflux.app/v2/internal/version"
|
||||
)
|
||||
|
||||
const defaultClientTimeout = 10 * time.Second
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
labels string
|
||||
onlyURL bool
|
||||
}
|
||||
|
||||
func NewClient(baseURL, apiKey, labels string, onlyURL bool) *Client {
|
||||
return &Client{baseURL: baseURL, apiKey: apiKey, labels: labels, onlyURL: onlyURL}
|
||||
}
|
||||
|
||||
func (c *Client) CreateBookmark(entryURL, entryTitle string, entryContent string) error {
|
||||
if c.baseURL == "" || c.apiKey == "" {
|
||||
return fmt.Errorf("readeck: missing base URL or API key")
|
||||
}
|
||||
|
||||
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/bookmarks/")
|
||||
if err != nil {
|
||||
return fmt.Errorf(`readeck: invalid API endpoint: %v`, err)
|
||||
}
|
||||
|
||||
labelsSplitFn := func(c rune) bool {
|
||||
return c == ',' || c == ' '
|
||||
}
|
||||
labelsSplit := strings.FieldsFunc(c.labels, labelsSplitFn)
|
||||
|
||||
var request *http.Request
|
||||
if c.onlyURL {
|
||||
requestBodyJson, err := json.Marshal(&readeckBookmark{
|
||||
Url: entryURL,
|
||||
Title: entryTitle,
|
||||
Labels: labelsSplit,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("readeck: unable to encode request body: %v", err)
|
||||
}
|
||||
request, err = http.NewRequest(http.MethodPost, apiEndpoint, bytes.NewReader(requestBodyJson))
|
||||
if err != nil {
|
||||
return fmt.Errorf("readeck: unable to create request: %v", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
requestBody := new(bytes.Buffer)
|
||||
multipartWriter := multipart.NewWriter(requestBody)
|
||||
|
||||
urlPart, err := multipartWriter.CreateFormField("url")
|
||||
if err != nil {
|
||||
return fmt.Errorf("readeck: unable to encode request body (entry url): %v", err)
|
||||
}
|
||||
urlPart.Write([]byte(entryURL))
|
||||
|
||||
titlePart, err := multipartWriter.CreateFormField("title")
|
||||
if err != nil {
|
||||
return fmt.Errorf("readeck: unable to encode request body (entry title): %v", err)
|
||||
}
|
||||
titlePart.Write([]byte(entryTitle))
|
||||
|
||||
featurePart, err := multipartWriter.CreateFormField("feature_find_main")
|
||||
if err != nil {
|
||||
return fmt.Errorf("readeck: unable to encode request body (feature_find_main flag): %v", err)
|
||||
}
|
||||
featurePart.Write([]byte("false")) // false to disable readability
|
||||
|
||||
for _, label := range labelsSplit {
|
||||
labelPart, err := multipartWriter.CreateFormField("labels")
|
||||
if err != nil {
|
||||
return fmt.Errorf("readeck: unable to encode request body (entry labels): %v", err)
|
||||
}
|
||||
labelPart.Write([]byte(label))
|
||||
}
|
||||
|
||||
contentBodyHeader, err := json.Marshal(&partContentHeader{
|
||||
Url: entryURL,
|
||||
ContentHeader: contentHeader{ContentType: "text/html"},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("readeck: unable to encode request body (entry content header): %v", err)
|
||||
}
|
||||
|
||||
contentPart, err := multipartWriter.CreateFormFile("resource", "blob")
|
||||
if err != nil {
|
||||
return fmt.Errorf("readeck: unable to encode request body (entry content): %v", err)
|
||||
}
|
||||
contentPart.Write(contentBodyHeader)
|
||||
contentPart.Write([]byte("\n"))
|
||||
contentPart.Write([]byte(entryContent))
|
||||
|
||||
err = multipartWriter.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("readeck: unable to encode request body: %v", err)
|
||||
}
|
||||
request, err = http.NewRequest(http.MethodPost, apiEndpoint, requestBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("readeck: unable to create request: %v", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", multipartWriter.FormDataContentType())
|
||||
}
|
||||
|
||||
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
|
||||
request.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultClientTimeout}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("readeck: unable to send request: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("readeck: unable to create bookmark: url=%s status=%d", apiEndpoint, response.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type readeckBookmark struct {
|
||||
Url string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
}
|
||||
|
||||
type contentHeader struct {
|
||||
ContentType string `json:"content-type"`
|
||||
}
|
||||
|
||||
type partContentHeader struct {
|
||||
Url string `json:"url"`
|
||||
ContentHeader contentHeader `json:"headers"`
|
||||
}
|
|
@ -20,7 +20,7 @@ var translationFiles embed.FS
|
|||
// LoadCatalogMessages loads and parses all translations encoded in JSON.
|
||||
func LoadCatalogMessages() error {
|
||||
var err error
|
||||
defaultCatalog = make(catalog)
|
||||
defaultCatalog = make(catalog, len(AvailableLanguages()))
|
||||
|
||||
for language := range AvailableLanguages() {
|
||||
defaultCatalog[language], err = loadTranslationFile(language)
|
||||
|
|
|
@ -53,11 +53,11 @@ func TestAllKeysHaveValue(t *testing.T) {
|
|||
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)
|
||||
t.Errorf(`The key %q for the language %q has an empty string as value`, k, language)
|
||||
}
|
||||
case []string:
|
||||
case []any:
|
||||
if len(value) == 0 {
|
||||
t.Errorf(`The key %q for the language %q have an empty list as value`, k, language)
|
||||
t.Errorf(`The key %q for the language %q has an empty list as value`, k, language)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -88,3 +88,20 @@ func TestMissingTranslations(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslationFilePluralForms(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)
|
||||
}
|
||||
|
||||
for k, v := range messages {
|
||||
if value, ok := v.([]any); ok {
|
||||
if len(value) != numberOfPluralFormsPerLanguage[language] {
|
||||
t.Errorf(`The key %q for the language %q does not have the expected number of plurals, got %d instead of %d`, k, language, len(value), numberOfPluralFormsPerLanguage[language])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,27 @@
|
|||
|
||||
package locale // import "miniflux.app/v2/internal/locale"
|
||||
|
||||
var numberOfPluralFormsPerLanguage = map[string]int{
|
||||
"en_US": 2,
|
||||
"es_ES": 2,
|
||||
"fr_FR": 2,
|
||||
"de_DE": 2,
|
||||
"pl_PL": 3,
|
||||
"pt_BR": 2,
|
||||
"zh_CN": 1,
|
||||
"zh_TW": 1,
|
||||
"nl_NL": 2,
|
||||
"ru_RU": 3,
|
||||
"it_IT": 2,
|
||||
"ja_JP": 1,
|
||||
"tr_TR": 2,
|
||||
"el_EL": 2,
|
||||
"fi_FI": 2,
|
||||
"hi_IN": 2,
|
||||
"uk_UA": 3,
|
||||
"id_ID": 1,
|
||||
}
|
||||
|
||||
// AvailableLanguages returns the list of available languages.
|
||||
func AvailableLanguages() map[string]string {
|
||||
return map[string]string{
|
||||
|
|
|
@ -3,69 +3,65 @@
|
|||
|
||||
package locale // import "miniflux.app/v2/internal/locale"
|
||||
|
||||
type pluralFormFunc func(n int) int
|
||||
|
||||
// See https://localization-guide.readthedocs.io/en/latest/l10n/pluralforms.html
|
||||
// And http://www.unicode.org/cldr/charts/29/supplemental/language_plural_rules.html
|
||||
var pluralForms = map[string]pluralFormFunc{
|
||||
var pluralForms = map[string](func(n int) int){
|
||||
// nplurals=2; plural=(n != 1);
|
||||
"default": func(n int) int {
|
||||
if n != 1 {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
},
|
||||
// nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 ? 4 : 5);
|
||||
"ar_AR": func(n int) int {
|
||||
if n == 0 {
|
||||
switch {
|
||||
case n == 0:
|
||||
return 0
|
||||
}
|
||||
|
||||
if n == 1 {
|
||||
case n == 1:
|
||||
return 1
|
||||
}
|
||||
|
||||
if n == 2 {
|
||||
case n == 2:
|
||||
return 2
|
||||
}
|
||||
|
||||
if n%100 >= 3 && n%100 <= 10 {
|
||||
case n%100 >= 3 && n%100 <= 10:
|
||||
return 3
|
||||
}
|
||||
|
||||
if n%100 >= 11 {
|
||||
case n%100 >= 11:
|
||||
return 4
|
||||
}
|
||||
|
||||
return 5
|
||||
},
|
||||
// nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;
|
||||
"cs_CZ": func(n int) int {
|
||||
if n == 1 {
|
||||
switch {
|
||||
case n == 1:
|
||||
return 0
|
||||
}
|
||||
|
||||
if n >= 2 && n <= 4 {
|
||||
case n >= 2 && n <= 4:
|
||||
return 1
|
||||
}
|
||||
|
||||
return 2
|
||||
},
|
||||
// nplurals=2; plural=(n > 1);
|
||||
"fr_FR": func(n int) int {
|
||||
if n > 1 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
},
|
||||
// nplurals=1; plural=0;
|
||||
"id_ID": func(n int) int {
|
||||
return 0
|
||||
},
|
||||
// nplurals=1; plural=0;
|
||||
"ja_JP": func(n int) int {
|
||||
return 0
|
||||
},
|
||||
// nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);
|
||||
"pl_PL": func(n int) int {
|
||||
if n == 1 {
|
||||
switch {
|
||||
case n == 1:
|
||||
return 0
|
||||
}
|
||||
|
||||
if n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20) {
|
||||
case n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20):
|
||||
return 1
|
||||
}
|
||||
|
||||
return 2
|
||||
},
|
||||
// nplurals=2; plural=(n > 1);
|
||||
|
@ -76,23 +72,31 @@ var pluralForms = map[string]pluralFormFunc{
|
|||
return 0
|
||||
},
|
||||
"ru_RU": pluralFormRuSrUa,
|
||||
// nplurals=2; plural=(n > 1);
|
||||
"tr_TR": func(n int) int {
|
||||
if n > 1 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
},
|
||||
"uk_UA": pluralFormRuSrUa,
|
||||
"sr_RS": pluralFormRuSrUa,
|
||||
// nplurals=1; plural=0;
|
||||
"zh_CN": func(n int) int {
|
||||
return 0
|
||||
},
|
||||
"zh_TW": func(n int) int {
|
||||
return 0
|
||||
},
|
||||
}
|
||||
|
||||
// nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);
|
||||
func pluralFormRuSrUa(n int) int {
|
||||
if n%10 == 1 && n%100 != 11 {
|
||||
switch {
|
||||
case n%10 == 1 && n%100 != 11:
|
||||
return 0
|
||||
}
|
||||
|
||||
if n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20) {
|
||||
case n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20):
|
||||
return 1
|
||||
}
|
||||
|
||||
return 2
|
||||
}
|
||||
|
|
|
@ -25,6 +25,20 @@ func TestPluralRules(t *testing.T) {
|
|||
2: 1,
|
||||
5: 2,
|
||||
},
|
||||
"fr_FR": {
|
||||
1: 0,
|
||||
2: 1,
|
||||
5: 1,
|
||||
},
|
||||
"id_ID": {
|
||||
1: 0,
|
||||
5: 0,
|
||||
},
|
||||
"ja_JP": {
|
||||
1: 0,
|
||||
2: 0,
|
||||
5: 0,
|
||||
},
|
||||
"pl_PL": {
|
||||
1: 0,
|
||||
2: 1,
|
||||
|
@ -45,10 +59,24 @@ func TestPluralRules(t *testing.T) {
|
|||
2: 1,
|
||||
5: 2,
|
||||
},
|
||||
"tr_TR": {
|
||||
1: 0,
|
||||
2: 1,
|
||||
5: 1,
|
||||
},
|
||||
"uk_UA": {
|
||||
1: 0,
|
||||
2: 1,
|
||||
5: 2,
|
||||
},
|
||||
"zh_CN": {
|
||||
1: 0,
|
||||
5: 0,
|
||||
},
|
||||
"zh_TW": {
|
||||
1: 0,
|
||||
5: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for rule, values := range scenarios {
|
||||
|
|
|
@ -10,6 +10,15 @@ type Printer struct {
|
|||
language string
|
||||
}
|
||||
|
||||
func (p *Printer) Print(key string) string {
|
||||
if str, ok := defaultCatalog[p.language][key]; ok {
|
||||
if translation, ok := str.(string); ok {
|
||||
return translation
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// Printf is like fmt.Printf, but using language-specific formatting.
|
||||
func (p *Printer) Printf(key string, args ...interface{}) string {
|
||||
var translation string
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
"menu.about": "Über",
|
||||
"menu.export": "Exportieren",
|
||||
"menu.import": "Importieren",
|
||||
"menu.search": "Suche",
|
||||
"menu.create_category": "Kategorie anlegen",
|
||||
"menu.mark_page_as_read": "Diese Seite als gelesen markieren",
|
||||
"menu.mark_all_as_read": "Alle als gelesen markieren",
|
||||
|
@ -327,6 +328,7 @@
|
|||
"form.feed.label.urlrewrite_rules": "Umschreibregeln für URL",
|
||||
"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.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.fetch_via_proxy": "Über Proxy abrufen",
|
||||
"form.feed.label.disabled": "Dieses Abonnement nicht aktualisieren",
|
||||
"form.feed.label.no_media_player": "Kein Media-Player (Audio/Video)",
|
||||
|
@ -447,6 +449,11 @@
|
|||
"form.integration.matrix_bot_password": "Passwort für Matrix-Benutzer",
|
||||
"form.integration.matrix_bot_url": "URL des Matrix-Servers",
|
||||
"form.integration.matrix_bot_chat_id": "ID des Matrix-Raums",
|
||||
"form.integration.readeck_activate": "Artikel in Readeck speichern",
|
||||
"form.integration.readeck_endpoint": "Readeck API-Endpunkt",
|
||||
"form.integration.readeck_api_key": "Readeck API-Schlüssel",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "Nur URL senden (anstelle des vollständigen Inhalts)",
|
||||
"form.integration.shiori_activate": "Artikel in Shiori speichern",
|
||||
"form.integration.shiori_endpoint": "Shiori API-Endpunkt",
|
||||
"form.integration.shiori_username": "Shiori Benutzername",
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
"menu.about": "Περί",
|
||||
"menu.export": "Εξαγωγή",
|
||||
"menu.import": "Εισαγωγή",
|
||||
"menu.search": "Αναζήτηση",
|
||||
"menu.create_category": "Δημιουργήστε μια κατηγορία",
|
||||
"menu.mark_page_as_read": "Σημείωση αυτής της σελίδας ως αναγνωσμένη",
|
||||
"menu.mark_all_as_read": "Σημείωση όλων ως αναγνωσμένα",
|
||||
|
@ -327,6 +328,7 @@
|
|||
"form.feed.label.keeplist_rules": "Κρατήστε Κανόνες",
|
||||
"form.feed.label.ignore_http_cache": "Αγνοήστε την προσωρινή μνήμη HTTP",
|
||||
"form.feed.label.allow_self_signed_certificates": "Να επιτρέπονται αυτο-υπογεγραμμένα ή μη έγκυρα πιστοποιητικά",
|
||||
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.fetch_via_proxy": "Λήψη μέσω διακομιστή μεσολάβησης",
|
||||
"form.feed.label.disabled": "Μη ανανέωση αυτής της ροής",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
|
@ -447,6 +449,11 @@
|
|||
"form.integration.matrix_bot_password": "Κωδικός πρόσβασης για τον χρήστη Matrix",
|
||||
"form.integration.matrix_bot_url": "URL διακομιστή Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "Αναγνωριστικό της αίθουσας Matrix",
|
||||
"form.integration.readeck_activate": "Αποθήκευση άρθρων στο Readeck",
|
||||
"form.integration.readeck_endpoint": "Τελικό σημείο Readeck API",
|
||||
"form.integration.readeck_api_key": "Κλειδί API Readeck",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "Αποστολή μόνο URL (αντί για πλήρες περιεχόμενο)",
|
||||
"form.integration.shiori_activate": "Αποθήκευση άρθρων στο Shiori",
|
||||
"form.integration.shiori_endpoint": "Τελικό σημείο Shiori",
|
||||
"form.integration.shiori_username": "Όνομα Χρήστη Shiori",
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
"menu.about": "About",
|
||||
"menu.export": "Export",
|
||||
"menu.import": "Import",
|
||||
"menu.search": "Search",
|
||||
"menu.create_category": "Create a category",
|
||||
"menu.mark_page_as_read": "Mark this page as read",
|
||||
"menu.mark_all_as_read": "Mark all as read",
|
||||
|
@ -327,6 +328,7 @@
|
|||
"form.feed.label.urlrewrite_rules": "URL Rewrite 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.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.fetch_via_proxy": "Fetch via proxy",
|
||||
"form.feed.label.disabled": "Do not refresh this feed",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
|
@ -447,6 +449,11 @@
|
|||
"form.integration.matrix_bot_password": "Password for Matrix user",
|
||||
"form.integration.matrix_bot_url": "Matrix server URL",
|
||||
"form.integration.matrix_bot_chat_id": "ID of Matrix Room",
|
||||
"form.integration.readeck_activate": "Save entries to readeck",
|
||||
"form.integration.readeck_endpoint": "Readeck API Endpoint",
|
||||
"form.integration.readeck_api_key": "Readeck API key",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "Send only URL (instead of full content)",
|
||||
"form.integration.shiori_activate": "Save articles to Shiori",
|
||||
"form.integration.shiori_endpoint": "Shiori API Endpoint",
|
||||
"form.integration.shiori_username": "Shiori Username",
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
"menu.about": "Acerca de",
|
||||
"menu.export": "Exportar",
|
||||
"menu.import": "Importar",
|
||||
"menu.search": "Buscar",
|
||||
"menu.create_category": "Crear una categoría",
|
||||
"menu.mark_page_as_read": "Marcar esta página como leída",
|
||||
"menu.mark_all_as_read": "Marcar todos como leídos",
|
||||
|
@ -327,6 +328,7 @@
|
|||
"form.feed.label.urlrewrite_rules": "Reglas de Filtrado (Reescritura)",
|
||||
"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.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.fetch_via_proxy": "Buscar a través de proxy",
|
||||
"form.feed.label.disabled": "No actualice este feed",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
|
@ -447,6 +449,11 @@
|
|||
"form.integration.matrix_bot_password": "Contraseña para el usuario de Matrix",
|
||||
"form.integration.matrix_bot_url": "URL del servidor de Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "ID de la sala de Matrix",
|
||||
"form.integration.readeck_activate": "Enviar artículos a Readeck",
|
||||
"form.integration.readeck_endpoint": "Acceso API de Readeck",
|
||||
"form.integration.readeck_api_key": "Clave de API de Readeck",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "Enviar solo URL (en lugar de contenido completo)",
|
||||
"form.integration.shiori_activate": "Guardar artículos a Shiori",
|
||||
"form.integration.shiori_endpoint": "Extremo de API de Shiori",
|
||||
"form.integration.shiori_username": "Nombre de usuario de Shiori",
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
"menu.about": "Tietoja",
|
||||
"menu.export": "Vie",
|
||||
"menu.import": "Tuo",
|
||||
"menu.search": "Haku",
|
||||
"menu.create_category": "Luo kategoria",
|
||||
"menu.mark_page_as_read": "Merkitse tämä sivu luetuksi",
|
||||
"menu.mark_all_as_read": "Merkitse kaikki luetuksi",
|
||||
|
@ -327,6 +328,7 @@
|
|||
"form.feed.label.keeplist_rules": "Keep-säännöt",
|
||||
"form.feed.label.ignore_http_cache": "Ohita HTTP-välimuisti",
|
||||
"form.feed.label.allow_self_signed_certificates": "Salli itseallekirjoitetut tai virheelliset varmenteet",
|
||||
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.fetch_via_proxy": "Nouda välityspalvelimen kautta",
|
||||
"form.feed.label.disabled": "Älä päivitä tätä syötettä",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
|
@ -447,6 +449,11 @@
|
|||
"form.integration.matrix_bot_password": "Matrix-käyttäjän salasana",
|
||||
"form.integration.matrix_bot_url": "Matrix-palvelimen URL-osoite",
|
||||
"form.integration.matrix_bot_chat_id": "Matrix-huoneen tunnus",
|
||||
"form.integration.readeck_activate": "Tallenna artikkelit Readeckiin",
|
||||
"form.integration.readeck_endpoint": "Readeck API-päätepiste",
|
||||
"form.integration.readeck_api_key": "Readeck API-avain",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "Lähetä vain URL-osoite (koko sisällön sijaan)",
|
||||
"form.integration.shiori_activate": "Save articles to Shiori",
|
||||
"form.integration.shiori_endpoint": "Shiori API Endpoint",
|
||||
"form.integration.shiori_username": "Shiori Username",
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
"menu.about": "À propos",
|
||||
"menu.export": "Export",
|
||||
"menu.import": "Import",
|
||||
"menu.search": "Recherche",
|
||||
"menu.create_category": "Créer une catégorie",
|
||||
"menu.mark_page_as_read": "Marquer cette page comme lu",
|
||||
"menu.mark_all_as_read": "Tout marquer comme lu",
|
||||
|
@ -327,6 +328,7 @@
|
|||
"form.feed.label.urlrewrite_rules": "Règles de réécriture d'URL",
|
||||
"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.disable_http2": "Désactiver HTTP/2",
|
||||
"form.feed.label.fetch_via_proxy": "Récupérer via proxy",
|
||||
"form.feed.label.disabled": "Ne pas actualiser ce flux",
|
||||
"form.feed.label.no_media_player": "Pas de lecteur multimedia (audio/vidéo)",
|
||||
|
@ -447,6 +449,11 @@
|
|||
"form.integration.matrix_bot_password": "Mot de passe de l'utilisateur Matrix",
|
||||
"form.integration.matrix_bot_url": "URL du serveur Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "Identifiant de la salle Matrix",
|
||||
"form.integration.readeck_activate": "Sauvegarder les articles vers Readeck",
|
||||
"form.integration.readeck_endpoint": "URL de l'API de Readeck",
|
||||
"form.integration.readeck_api_key": "Clé d'API de Readeck",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "Envoyer uniquement l'URL (au lieu du contenu complet)",
|
||||
"form.integration.shiori_activate": "Sauvegarder les articles vers Shiori",
|
||||
"form.integration.shiori_endpoint": "URL de l'API de Shiori",
|
||||
"form.integration.shiori_username": "Nom d'utilisateur de Shiori",
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
"menu.about": "के बारे में",
|
||||
"menu.export": "निर्यात करे",
|
||||
"menu.import": "आयात करे",
|
||||
"menu.search": "खोज",
|
||||
"menu.create_category": "श्रेणी बनाए",
|
||||
"menu.mark_page_as_read": "इस पृष्ठ को पढ़ा हुआ चिह्नित करें",
|
||||
"menu.mark_all_as_read": "सभी को पढ़ा हुआ मार्क करें",
|
||||
|
@ -327,6 +328,7 @@
|
|||
"form.feed.label.urlrewrite_rules": " यूआरएल पुनर्लेखन नियम",
|
||||
"form.feed.label.ignore_http_cache": "एचटीटीपी कैश पर ध्यान न दें",
|
||||
"form.feed.label.allow_self_signed_certificates": "स्व-हस्ताक्षरित या अमान्य प्रमाणपत्रों की अनुमति दें",
|
||||
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.fetch_via_proxy": "प्रॉक्सी के माध्यम से प्राप्त करें",
|
||||
"form.feed.label.disabled": "इस फ़ीड को रीफ़्रेश न करें",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
|
@ -447,6 +449,11 @@
|
|||
"form.integration.matrix_bot_password": "मैट्रिक्स उपयोगकर्ता के लिए पासवर्ड",
|
||||
"form.integration.matrix_bot_url": "मैट्रिक्स सर्वर URL",
|
||||
"form.integration.matrix_bot_chat_id": "मैट्रिक्स रूम की आईडी",
|
||||
"form.integration.readeck_activate": "Readeck में विषयवस्तु सहेजें",
|
||||
"form.integration.readeck_endpoint": "Readeck·एपीआई·समापन·बिंदु",
|
||||
"form.integration.readeck_api_key": "Readeck एपीआई कुंजी",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "केवल URL भेजें (पूर्ण सामग्री के बजाय)",
|
||||
"form.integration.shiori_activate": "Save articles to Shiori",
|
||||
"form.integration.shiori_endpoint": "Shiori API Endpoint",
|
||||
"form.integration.shiori_username": "Shiori Username",
|
||||
|
@ -518,4 +525,4 @@
|
|||
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
|
||||
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
|
||||
"error.feed_format_not_detected": "Unable to detect feed format: %v."
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
"menu.about": "Tentang",
|
||||
"menu.export": "Ekspor",
|
||||
"menu.import": "Impor",
|
||||
"menu.search": "Cari",
|
||||
"menu.create_category": "Buat kategori",
|
||||
"menu.mark_page_as_read": "Tandai halaman ini sebagai telah dibaca",
|
||||
"menu.mark_all_as_read": "Tandai semua sebagai telah dibaca",
|
||||
|
@ -83,38 +84,33 @@
|
|||
"entry.shared_entry.title": "Buka tautan publik",
|
||||
"entry.shared_entry.label": "Bagikan",
|
||||
"entry.estimated_reading_time": [
|
||||
"%d menit untuk dibaca"
|
||||
"%d menit untuk dibaca"
|
||||
],
|
||||
"entry.tags.label": "Tanda:",
|
||||
"page.shared_entries.title": "Entri yang Dibagikan",
|
||||
"page.shared_entries_count": [
|
||||
"%d shared entry",
|
||||
"%d shared entries"
|
||||
"%d shared entry"
|
||||
],
|
||||
"page.unread.title": "Belum Dibaca",
|
||||
"page.unread_entry_count": [
|
||||
"%d unread entry",
|
||||
"%d unread entries"
|
||||
"%d unread entry"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d entry in total",
|
||||
"%d entries in total"
|
||||
"%d entry in total"
|
||||
],
|
||||
"page.starred.title": "Markah",
|
||||
"page.starred_entry_count": [
|
||||
"%d starred entry",
|
||||
"%d starred entries"
|
||||
"%d starred entry"
|
||||
],
|
||||
"page.categories.title": "Kategori",
|
||||
"page.categories.no_feed": "Tidak ada umpan.",
|
||||
"page.categories.entries": "Artikel",
|
||||
"page.categories.feeds": "Langganan",
|
||||
"page.categories.feed_count": [
|
||||
"Ada %d umpan."
|
||||
"Ada %d umpan."
|
||||
],
|
||||
"page.categories_count": [
|
||||
"%d category",
|
||||
"%d categories"
|
||||
"%d category"
|
||||
],
|
||||
"page.new_category.title": "Kategori Baru",
|
||||
"page.new_user.title": "Pengguna Baru",
|
||||
|
@ -126,12 +122,11 @@
|
|||
"page.feeds.next_check": "Next check:",
|
||||
"page.feeds.read_counter": "Jumlah entri yang telah dibaca",
|
||||
"page.feeds.error_count": [
|
||||
"%d galat"
|
||||
"%d galat"
|
||||
],
|
||||
"page.history.title": "Riwayat",
|
||||
"page.read_entry_count": [
|
||||
"%d read entry",
|
||||
"%d read entries"
|
||||
"%d read entry"
|
||||
],
|
||||
"page.import.title": "Impor",
|
||||
"page.search.title": "Hasil Pencarian",
|
||||
|
@ -212,8 +207,7 @@
|
|||
"page.settings.webauthn.register": "Register passkey",
|
||||
"page.settings.webauthn.register.error": "Unable to register passkey",
|
||||
"page.settings.webauthn.delete": [
|
||||
"Remove %d passkey",
|
||||
"Remove %d passkeys"
|
||||
"Remove %d passkey"
|
||||
],
|
||||
"page.login.title": "Masuk",
|
||||
"page.login.google_signin": "Masuk dengan Google",
|
||||
|
@ -324,6 +318,7 @@
|
|||
"form.feed.label.urlrewrite_rules": "Aturan Tulis Ulang URL",
|
||||
"form.feed.label.ignore_http_cache": "Abaikan Tembolok HTTP",
|
||||
"form.feed.label.allow_self_signed_certificates": "Perbolehkan sertifikat web tidak valid atau sertifikasi sendiri",
|
||||
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.fetch_via_proxy": "Ambil via Proksi",
|
||||
"form.feed.label.disabled": "Jangan perbarui umpan ini",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
|
@ -444,6 +439,11 @@
|
|||
"form.integration.matrix_bot_password": "Kata Sandi Matrix",
|
||||
"form.integration.matrix_bot_url": "URL Peladen Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "ID Ruang Matrix",
|
||||
"form.integration.readeck_activate": "Simpan artikel ke Readeck",
|
||||
"form.integration.readeck_endpoint": "Titik URL API Readeck",
|
||||
"form.integration.readeck_api_key": "Kunci API Readeck",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "Kirim hanya URL (alih-alih konten penuh)",
|
||||
"form.integration.shiori_activate": "Save articles to Shiori",
|
||||
"form.integration.shiori_endpoint": "Shiori API Endpoint",
|
||||
"form.integration.shiori_username": "Shiori Username",
|
||||
|
@ -463,26 +463,25 @@
|
|||
"time_elapsed.yesterday": "kemarin",
|
||||
"time_elapsed.now": "baru saja",
|
||||
"time_elapsed.minutes": [
|
||||
"%d menit yang lalu"
|
||||
"%d menit yang lalu"
|
||||
],
|
||||
"time_elapsed.hours": [
|
||||
"%d jam yang lalu"
|
||||
"%d jam yang lalu"
|
||||
],
|
||||
"time_elapsed.days": [
|
||||
"%d hari yang lalu"
|
||||
"%d hari yang lalu"
|
||||
],
|
||||
"time_elapsed.weeks": [
|
||||
"%d pekan yang lalu"
|
||||
"%d pekan yang lalu"
|
||||
],
|
||||
"time_elapsed.months": [
|
||||
"%d bulan yang lalu"
|
||||
"%d bulan yang lalu"
|
||||
],
|
||||
"time_elapsed.years": [
|
||||
"%d tahun yang lalu"
|
||||
"%d tahun yang lalu"
|
||||
],
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
|
||||
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
|
||||
"You have triggered too many feed refreshes. Please wait %d minute before trying again."
|
||||
],
|
||||
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
|
||||
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
"menu.about": "Informazioni",
|
||||
"menu.export": "Esporta",
|
||||
"menu.import": "Importa",
|
||||
"menu.search": "Cerca",
|
||||
"menu.create_category": "Aggiungi una categoria",
|
||||
"menu.mark_page_as_read": "Segna questa pagina come letta",
|
||||
"menu.mark_all_as_read": "Segna tutti gli articoli come letti",
|
||||
|
@ -327,6 +328,7 @@
|
|||
"form.feed.label.urlrewrite_rules": "Regole di riscrittura URL",
|
||||
"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.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.fetch_via_proxy": "Recuperare tramite proxy",
|
||||
"form.feed.label.disabled": "Non aggiornare questo feed",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
|
@ -447,6 +449,11 @@
|
|||
"form.integration.matrix_bot_password": "Password per l'utente Matrix",
|
||||
"form.integration.matrix_bot_url": "URL del server Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "ID della stanza Matrix",
|
||||
"form.integration.readeck_activate": "Salva gli articoli su Readeck",
|
||||
"form.integration.readeck_endpoint": "Endpoint dell'API di Readeck",
|
||||
"form.integration.readeck_api_key": "API key dell'account Readeck",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "Invia solo URL (invece del contenuto completo)",
|
||||
"form.integration.shiori_activate": "Salva gli articoli su Shiori",
|
||||
"form.integration.shiori_endpoint": "Endpoint dell'API di Shiori",
|
||||
"form.integration.shiori_username": "Nome utente dell'account Shiori",
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
"menu.about": "ソフトウェア情報",
|
||||
"menu.export": "エクスポート",
|
||||
"menu.import": "インポート",
|
||||
"menu.search": "検索",
|
||||
"menu.create_category": "カテゴリを作成",
|
||||
"menu.mark_page_as_read": "このページを既読にする",
|
||||
"menu.mark_all_as_read": "すべて既読にする",
|
||||
|
@ -83,40 +84,33 @@
|
|||
"entry.shared_entry.title": "公開リンクを開く",
|
||||
"entry.shared_entry.label": "共有する",
|
||||
"entry.estimated_reading_time": [
|
||||
"%d 分で読めます",
|
||||
"%d 分で読めます"
|
||||
],
|
||||
"entry.tags.label": "タグ:",
|
||||
"page.shared_entries.title": "共有エントリ",
|
||||
"page.shared_entries_count": [
|
||||
"%d shared entry",
|
||||
"%d shared entries"
|
||||
"%d shared entry"
|
||||
],
|
||||
"page.unread.title": "未読",
|
||||
"page.unread_entry_count": [
|
||||
"%d unread entry",
|
||||
"%d unread entries"
|
||||
"%d unread entry"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d entry in total",
|
||||
"%d entries in total"
|
||||
"%d entry in total"
|
||||
],
|
||||
"page.starred.title": "星付き",
|
||||
"page.starred_entry_count": [
|
||||
"%d starred entry",
|
||||
"%d starred entries"
|
||||
"%d starred entry"
|
||||
],
|
||||
"page.categories.title": "カテゴリ",
|
||||
"page.categories.no_feed": "フィードはありません。",
|
||||
"page.categories.entries": "記事一覧",
|
||||
"page.categories.feeds": "フィード一覧",
|
||||
"page.categories.feed_count": [
|
||||
"%d 件のフィードがあります。",
|
||||
"%d 件のフィードがあります。"
|
||||
],
|
||||
"page.categories_count": [
|
||||
"%d category",
|
||||
"%d categories"
|
||||
"%d category"
|
||||
],
|
||||
"page.new_category.title": "新規カテゴリ",
|
||||
"page.new_user.title": "新規ユーザー",
|
||||
|
@ -128,13 +122,11 @@
|
|||
"page.feeds.next_check": "Next check:",
|
||||
"page.feeds.read_counter": "既読記事の数",
|
||||
"page.feeds.error_count": [
|
||||
"%d 個のエラー",
|
||||
"%d 個のエラー"
|
||||
],
|
||||
"page.history.title": "履歴",
|
||||
"page.read_entry_count": [
|
||||
"%d read entry",
|
||||
"%d read entries"
|
||||
"%d read entry"
|
||||
],
|
||||
"page.import.title": "インポート",
|
||||
"page.search.title": "検索結果",
|
||||
|
@ -215,7 +207,6 @@
|
|||
"page.settings.webauthn.register": "パスキーを登録する",
|
||||
"page.settings.webauthn.register.error": "パスキーを登録できません",
|
||||
"page.settings.webauthn.delete": [
|
||||
"%d 個のパスキーを削除",
|
||||
"%d 個のパスキーを削除"
|
||||
],
|
||||
"page.login.title": "ログイン",
|
||||
|
@ -327,6 +318,7 @@
|
|||
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
|
||||
"form.feed.label.ignore_http_cache": "HTTPキャッシュを無視",
|
||||
"form.feed.label.allow_self_signed_certificates": "自己署名証明書または無効な証明書を許可する",
|
||||
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.fetch_via_proxy": "プロキシ経由で取得",
|
||||
"form.feed.label.disabled": "このフィードを更新しない",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
|
@ -447,6 +439,11 @@
|
|||
"form.integration.matrix_bot_password": "Matrixユーザ用パスワード",
|
||||
"form.integration.matrix_bot_url": "MatrixサーバーのURL",
|
||||
"form.integration.matrix_bot_chat_id": "MatrixルームのID",
|
||||
"form.integration.readeck_activate": "Readeck に記事を保存する",
|
||||
"form.integration.readeck_endpoint": "Readeck の API Endpoint",
|
||||
"form.integration.readeck_api_key": "Readeck の API key",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "URL のみを送信 (完全なコンテンツではなく)",
|
||||
"form.integration.shiori_activate": "Shiori に記事を保存する",
|
||||
"form.integration.shiori_endpoint": "Shiori の API Endpoint",
|
||||
"form.integration.shiori_username": "Shiori の ユーザー名",
|
||||
|
@ -466,32 +463,25 @@
|
|||
"time_elapsed.yesterday": "昨日",
|
||||
"time_elapsed.now": "今",
|
||||
"time_elapsed.minutes": [
|
||||
"%d 分前",
|
||||
"%d 分前"
|
||||
],
|
||||
"time_elapsed.hours": [
|
||||
"%d 時間前",
|
||||
"%d 時間前"
|
||||
],
|
||||
"time_elapsed.days": [
|
||||
"%d 日前",
|
||||
"%d 日前"
|
||||
],
|
||||
"time_elapsed.weeks": [
|
||||
"%d 週間前",
|
||||
"%d 週間前"
|
||||
],
|
||||
"time_elapsed.months": [
|
||||
"%d か月前",
|
||||
"%d か月前"
|
||||
],
|
||||
"time_elapsed.years": [
|
||||
"%d 年前",
|
||||
"%d 年前"
|
||||
],
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
|
||||
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
|
||||
"You have triggered too many feed refreshes. Please wait %d minute before trying again."
|
||||
],
|
||||
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
|
||||
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
"menu.about": "Over",
|
||||
"menu.export": "Exporteren",
|
||||
"menu.import": "Importeren",
|
||||
"menu.search": "Zoeken",
|
||||
"menu.create_category": "Categorie toevoegen",
|
||||
"menu.mark_page_as_read": "Markeer deze pagina als gelezen",
|
||||
"menu.mark_all_as_read": "Markeer alle items als gelezen",
|
||||
|
@ -327,6 +328,7 @@
|
|||
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
|
||||
"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.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.fetch_via_proxy": "Ophalen via proxy",
|
||||
"form.feed.label.disabled": "Vernieuw deze feed niet",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
|
@ -447,6 +449,11 @@
|
|||
"form.integration.matrix_bot_password": "Wachtwoord voor Matrix-gebruiker",
|
||||
"form.integration.matrix_bot_url": "URL van de Matrix-server",
|
||||
"form.integration.matrix_bot_chat_id": "ID van Matrix-kamer",
|
||||
"form.integration.readeck_activate": "Opslaan naar Readeck",
|
||||
"form.integration.readeck_endpoint": "Readeck URL",
|
||||
"form.integration.readeck_api_key": "Readeck API-sleutel",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "Alleen URL verzenden (in plaats van volledige inhoud)",
|
||||
"form.integration.shiori_activate": "Opslaan naar Shiori",
|
||||
"form.integration.shiori_endpoint": "Shiori URL",
|
||||
"form.integration.shiori_username": "Shiori gebruikersnaam",
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
"menu.about": "O stronie",
|
||||
"menu.export": "Eksportuj",
|
||||
"menu.import": "Importuj",
|
||||
"menu.search": "Szukaj",
|
||||
"menu.create_category": "Utwórz kategorię",
|
||||
"menu.mark_page_as_read": "Oznacz jako przeczytane",
|
||||
"menu.mark_all_as_read": "Oznacz wszystko jako przeczytane",
|
||||
|
@ -84,25 +85,30 @@
|
|||
"entry.shared_entry.label": "Udostępnianie",
|
||||
"entry.estimated_reading_time": [
|
||||
"%d minuta czytania",
|
||||
"%d minuty czytania",
|
||||
"%d minut czytania"
|
||||
],
|
||||
"entry.tags.label": "Tagi:",
|
||||
"page.shared_entries.title": "Udostępnione wpisy",
|
||||
"page.shared_entries_count": [
|
||||
"%d shared entry",
|
||||
"%d shared entry",
|
||||
"%d shared entries"
|
||||
],
|
||||
"page.unread.title": "Nieprzeczytane",
|
||||
"page.unread_entry_count": [
|
||||
"%d unread entry",
|
||||
"%d unread entry",
|
||||
"%d unread entries"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d entry in total",
|
||||
"%d entry in total",
|
||||
"%d entries in total"
|
||||
],
|
||||
"page.starred.title": "Oznaczone gwiazdką",
|
||||
"page.starred_entry_count": [
|
||||
"%d starred entry",
|
||||
"%d starred entry",
|
||||
"%d starred entries"
|
||||
],
|
||||
|
@ -116,6 +122,7 @@
|
|||
"Jest %d kanałów."
|
||||
],
|
||||
"page.categories_count": [
|
||||
"%d category",
|
||||
"%d category",
|
||||
"%d categories"
|
||||
],
|
||||
|
@ -135,6 +142,7 @@
|
|||
],
|
||||
"page.history.title": "Historia",
|
||||
"page.read_entry_count": [
|
||||
"%d read entry",
|
||||
"%d read entry",
|
||||
"%d read entries"
|
||||
],
|
||||
|
@ -330,6 +338,7 @@
|
|||
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
|
||||
"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.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.fetch_via_proxy": "Pobierz przez proxy",
|
||||
"form.feed.label.disabled": "Nie odświeżaj tego kanału",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
|
@ -450,6 +459,11 @@
|
|||
"form.integration.matrix_bot_password": "Hasło dla użytkownika Matrix",
|
||||
"form.integration.matrix_bot_url": "URL serwera Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "Identyfikator pokoju Matrix",
|
||||
"form.integration.readeck_activate": "Zapisz artykuły do Readeck",
|
||||
"form.integration.readeck_endpoint": "Readeck URL",
|
||||
"form.integration.readeck_api_key": "Readeck API key",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "Wyślij tylko adres URL (zamiast pełnej treści)",
|
||||
"form.integration.shiori_activate": "Zapisz artykuły do Shiori",
|
||||
"form.integration.shiori_endpoint": "Shiori URL",
|
||||
"form.integration.shiori_username": "Login do Shiori",
|
||||
|
@ -500,6 +514,7 @@
|
|||
],
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
|
||||
"You have triggered too many feed refreshes. Please wait %d minutes before trying again.",
|
||||
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
|
||||
],
|
||||
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
"menu.about": "Sobre",
|
||||
"menu.export": "Exportar",
|
||||
"menu.import": "Importar",
|
||||
"menu.search": "Buscar",
|
||||
"menu.create_category": "Criar uma categoria",
|
||||
"menu.mark_page_as_read": "Marcar essa página como lida",
|
||||
"menu.mark_all_as_read": "Marcar todos como lido",
|
||||
|
@ -327,6 +328,7 @@
|
|||
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
|
||||
"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.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.disabled": "Não atualizar esta fonte",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
"form.feed.label.fetch_via_proxy": "Buscar via proxy",
|
||||
|
@ -447,6 +449,11 @@
|
|||
"form.integration.matrix_bot_password": "Palavra-passe para utilizador da Matrix",
|
||||
"form.integration.matrix_bot_url": "URL do servidor Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "Identificação da sala Matrix",
|
||||
"form.integration.readeck_activate": "Salvar itens no Readeck",
|
||||
"form.integration.readeck_endpoint": "Endpoint de API do Readeck",
|
||||
"form.integration.readeck_api_key": "Chave de API do Readeck",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "Enviar apenas URL (em vez de conteúdo completo)",
|
||||
"form.integration.shiori_activate": "Salvar itens no Shiori",
|
||||
"form.integration.shiori_endpoint": "Endpoint da API do Shiori",
|
||||
"form.integration.shiori_username": "Nome de usuário do Shiori",
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
"menu.about": "О приложении",
|
||||
"menu.export": "Экспорт",
|
||||
"menu.import": "Импорт",
|
||||
"menu.search": "Поиск",
|
||||
"menu.create_category": "Создать категорию",
|
||||
"menu.mark_page_as_read": "Отметить эту страницу прочитанной",
|
||||
"menu.mark_all_as_read": "Отметить всё как прочитанное",
|
||||
|
@ -84,26 +85,31 @@
|
|||
"entry.shared_entry.label": "Поделиться",
|
||||
"entry.estimated_reading_time": [
|
||||
"%d минута чтения",
|
||||
"%d минуты чтения",
|
||||
"%d минут чтения"
|
||||
],
|
||||
"entry.tags.label": "Теги:",
|
||||
"page.shared_entries.title": "Общедоступные статьи",
|
||||
"page.shared_entries_count": [
|
||||
"%d shared entry",
|
||||
"%d shared entries",
|
||||
"%d shared entries"
|
||||
],
|
||||
"page.unread.title": "Непрочитанное",
|
||||
"page.unread_entry_count": [
|
||||
"%d unread entry",
|
||||
"%d unread entries",
|
||||
"%d unread entries"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d entry in total",
|
||||
"%d entries in total",
|
||||
"%d entries in total"
|
||||
],
|
||||
"page.starred.title": "Избранное",
|
||||
"page.starred_entry_count": [
|
||||
"%d starred entry",
|
||||
"%d starred entries",
|
||||
"%d starred entries"
|
||||
],
|
||||
"page.categories.title": "Категории",
|
||||
|
@ -117,6 +123,7 @@
|
|||
],
|
||||
"page.categories_count": [
|
||||
"%d category",
|
||||
"%d categories",
|
||||
"%d categories"
|
||||
],
|
||||
"page.new_category.title": "Новая категория",
|
||||
|
@ -136,6 +143,7 @@
|
|||
"page.history.title": "История",
|
||||
"page.read_entry_count": [
|
||||
"%d read entry",
|
||||
"%d read entries",
|
||||
"%d read entries"
|
||||
],
|
||||
"page.import.title": "Импорт",
|
||||
|
@ -330,6 +338,7 @@
|
|||
"form.feed.label.apprise_service_urls": "Список ссылок сервисов Apprise, разделенный запятой",
|
||||
"form.feed.label.ignore_http_cache": "Игнорировать HTTP кеш",
|
||||
"form.feed.label.allow_self_signed_certificates": "Разрешить самоподписанные или недействительные сертификаты",
|
||||
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.fetch_via_proxy": "Использовать прокси",
|
||||
"form.feed.label.disabled": "Не обновлять эту подписку",
|
||||
"form.feed.label.no_media_player": "Отключить медиаплеер (аудио и видео)",
|
||||
|
@ -450,6 +459,11 @@
|
|||
"form.integration.matrix_bot_password": "Пароль пользователя Matrix",
|
||||
"form.integration.matrix_bot_url": "Ссылка на сервер Matrix",
|
||||
"form.integration.matrix_bot_chat_id": "ID комнаты Matrix",
|
||||
"form.integration.readeck_activate": "Сохранять статьи в Readeck",
|
||||
"form.integration.readeck_endpoint": "Конечная точка Readeck API",
|
||||
"form.integration.readeck_api_key": "API-ключ Readeck",
|
||||
"form.integration.readeck_labels": "Теги Readeck",
|
||||
"form.integration.readeck_only_url": "Отправлять только ссылку (без содержимого)",
|
||||
"form.integration.shiori_activate": "Сохранять статьи в Shiori",
|
||||
"form.integration.shiori_endpoint": "Конечная точка Shiori API",
|
||||
"form.integration.shiori_username": "Имя пользователя Shiori",
|
||||
|
@ -500,6 +514,7 @@
|
|||
],
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
|
||||
"You have triggered too many feed refreshes. Please wait %d minutes before trying again.",
|
||||
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
|
||||
],
|
||||
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
"menu.about": "Hakkında",
|
||||
"menu.export": "Dışarı Aktar",
|
||||
"menu.import": "İçeri Aktar",
|
||||
"menu.search": "Ara",
|
||||
"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",
|
||||
|
@ -327,6 +328,7 @@
|
|||
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
|
||||
"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.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.fetch_via_proxy": "Proxy ile çek",
|
||||
"form.feed.label.disabled": "Bu beslemeyi yenileme",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
|
@ -447,6 +449,11 @@
|
|||
"form.integration.matrix_bot_password": "Matrix kullanıcısı için şifre",
|
||||
"form.integration.matrix_bot_url": "Matris sunucusu URL'si",
|
||||
"form.integration.matrix_bot_chat_id": "Matris odasının kimliği",
|
||||
"form.integration.readeck_activate": "Makaleleri Readeck'e kaydet",
|
||||
"form.integration.readeck_endpoint": "Readeck API Uç Noktası",
|
||||
"form.integration.readeck_api_key": "Readeck API Anahtarı",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "Yalnızca URL gönder (tam içerik yerine)",
|
||||
"form.integration.shiori_activate": "Makaleleri Shiori'e kaydet",
|
||||
"form.integration.shiori_endpoint": "Shiori API Uç Noktası",
|
||||
"form.integration.shiori_username": "Shiori Kullanıcı Adı",
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
"menu.about": "Про додаток",
|
||||
"menu.export": "Експорт",
|
||||
"menu.import": "Імпорт",
|
||||
"menu.search": "Пошук",
|
||||
"menu.create_category": "Створити категорію",
|
||||
"menu.mark_page_as_read": "Відмітити цю сторінку як прочитане",
|
||||
"menu.mark_all_as_read": "Відмітити все як прочитане",
|
||||
|
@ -91,20 +92,24 @@
|
|||
"page.shared_entries.title": "Спильні записи",
|
||||
"page.shared_entries_count": [
|
||||
"%d shared entry",
|
||||
"%d shared entries",
|
||||
"%d shared entries"
|
||||
],
|
||||
"page.unread.title": "Непрочитане",
|
||||
"page.unread_entry_count": [
|
||||
"%d unread entry",
|
||||
"%d unread entries",
|
||||
"%d unread entries"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d entry in total",
|
||||
"%d entries in total",
|
||||
"%d entries in total"
|
||||
],
|
||||
"page.starred.title": "З зірочкою",
|
||||
"page.starred_entry_count": [
|
||||
"%d starred entry",
|
||||
"%d starred entries",
|
||||
"%d starred entries"
|
||||
],
|
||||
"page.categories.title": "Категорії",
|
||||
|
@ -118,6 +123,7 @@
|
|||
],
|
||||
"page.categories_count": [
|
||||
"%d category",
|
||||
"%d categories",
|
||||
"%d categories"
|
||||
],
|
||||
"page.new_category.title": "Нова категорія",
|
||||
|
@ -137,6 +143,7 @@
|
|||
"page.history.title": "Історія",
|
||||
"page.read_entry_count": [
|
||||
"%d read entry",
|
||||
"%d read entries",
|
||||
"%d read entries"
|
||||
],
|
||||
"page.import.title": "Імпорт",
|
||||
|
@ -331,6 +338,7 @@
|
|||
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
|
||||
"form.feed.label.ignore_http_cache": "Ігнорувати кеш HTTP",
|
||||
"form.feed.label.allow_self_signed_certificates": "Дозволити сертифікати з власним підписом або недійсні",
|
||||
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.fetch_via_proxy": "Використати проксі-сервер",
|
||||
"form.feed.label.disabled": "Не оновлювати цю стрічку",
|
||||
"form.feed.label.no_media_player": "No media player (audio/video)",
|
||||
|
@ -451,6 +459,11 @@
|
|||
"form.integration.matrix_bot_password": "Пароль для користувача Matrix",
|
||||
"form.integration.matrix_bot_url": "URL-адреса сервера Матриці",
|
||||
"form.integration.matrix_bot_chat_id": "Ідентифікатор кімнати Матриці",
|
||||
"form.integration.readeck_activate": "Зберігати статті до Readeck",
|
||||
"form.integration.readeck_endpoint": "Readeck API Endpoint",
|
||||
"form.integration.readeck_api_key": "Ключ API Readeck",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "Надіслати лише URL (замість повного вмісту)",
|
||||
"form.integration.shiori_activate": "Save articles to Shiori",
|
||||
"form.integration.shiori_endpoint": "Shiori API Endpoint",
|
||||
"form.integration.shiori_username": "Shiori Username",
|
||||
|
@ -501,6 +514,7 @@
|
|||
],
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
|
||||
"You have triggered too many feed refreshes. Please wait %d minutes before trying again.",
|
||||
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
|
||||
],
|
||||
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
"menu.about": "关于",
|
||||
"menu.export": "导出",
|
||||
"menu.import": "导入",
|
||||
"menu.search": "搜索",
|
||||
"menu.create_category": "新建分类",
|
||||
"menu.mark_page_as_read": "标记为已读",
|
||||
"menu.mark_all_as_read": "全部标为已读",
|
||||
|
@ -83,28 +84,23 @@
|
|||
"entry.shared_entry.title": "打开公共链接",
|
||||
"entry.shared_entry.label": "分享",
|
||||
"entry.estimated_reading_time": [
|
||||
"需要 %d 分钟阅读",
|
||||
"需要 %d 分钟阅读"
|
||||
],
|
||||
"entry.tags.label": "标签:",
|
||||
"page.shared_entries.title": "已分享的文章",
|
||||
"page.shared_entries_count": [
|
||||
"%d shared entry",
|
||||
"%d shared entries"
|
||||
"%d shared entry"
|
||||
],
|
||||
"page.unread.title": "未读",
|
||||
"page.unread_entry_count": [
|
||||
"%d unread entry",
|
||||
"%d unread entries"
|
||||
"%d unread entry"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d entry in total",
|
||||
"%d entries in total"
|
||||
"%d entry in total"
|
||||
],
|
||||
"page.starred.title": "收藏",
|
||||
"page.starred_entry_count": [
|
||||
"%d starred entry",
|
||||
"%d starred entries"
|
||||
"%d starred entry"
|
||||
],
|
||||
"page.categories.title": "分类",
|
||||
"page.categories.no_feed": "没有源",
|
||||
|
@ -114,8 +110,7 @@
|
|||
"有 %d 个源"
|
||||
],
|
||||
"page.categories_count": [
|
||||
"%d category",
|
||||
"%d categories"
|
||||
"%d category"
|
||||
],
|
||||
"page.new_category.title": "新分类",
|
||||
"page.new_user.title": "新用户",
|
||||
|
@ -131,8 +126,7 @@
|
|||
],
|
||||
"page.history.title": "历史",
|
||||
"page.read_entry_count": [
|
||||
"%d read entry",
|
||||
"%d read entries"
|
||||
"%d read entry"
|
||||
],
|
||||
"page.import.title": "导入",
|
||||
"page.search.title": "搜索结果",
|
||||
|
@ -213,7 +207,6 @@
|
|||
"page.settings.webauthn.register": "注册 Passkey",
|
||||
"page.settings.webauthn.register.error": "无法注册 Passkey",
|
||||
"page.settings.webauthn.delete": [
|
||||
"删除 %d 个 Passkey",
|
||||
"删除 %d 个 Passkey"
|
||||
],
|
||||
"page.login.title": "登录",
|
||||
|
@ -325,6 +318,7 @@
|
|||
"form.feed.label.apprise_service_urls": "使用逗号分隔的 Apprise 服务 URL 列表",
|
||||
"form.feed.label.ignore_http_cache": "忽略 HTTP 缓存",
|
||||
"form.feed.label.allow_self_signed_certificates": "允许自签名证书或无效证书",
|
||||
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.fetch_via_proxy": "通过代理获取",
|
||||
"form.feed.label.disabled": "请勿刷新此源",
|
||||
"form.feed.label.no_media_player": "没有媒体播放器(音频/视频)",
|
||||
|
@ -445,6 +439,11 @@
|
|||
"form.integration.matrix_bot_password": "Matrix Bot 密码",
|
||||
"form.integration.matrix_bot_url": "Matrix 服务器 URL",
|
||||
"form.integration.matrix_bot_chat_id": "Matrix 聊天 ID",
|
||||
"form.integration.readeck_activate": "保存文章到 Readeck",
|
||||
"form.integration.readeck_endpoint": "Readeck API 端点",
|
||||
"form.integration.readeck_api_key": "Readeck API 密钥",
|
||||
"form.integration.readeck_labels": "Readeck 默认标签",
|
||||
"form.integration.readeck_only_url": "仅发送 URL(而不是完整内容)",
|
||||
"form.integration.shiori_activate": "保存文章到 Shiori",
|
||||
"form.integration.shiori_endpoint": "Shiori API 端点",
|
||||
"form.integration.shiori_username": "Shiori 用户名",
|
||||
|
@ -482,8 +481,7 @@
|
|||
"%d 年前"
|
||||
],
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
|
||||
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
|
||||
"You have triggered too many feed refreshes. Please wait %d minute before trying again."
|
||||
],
|
||||
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
|
||||
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
"menu.about": "關於",
|
||||
"menu.export": "匯出",
|
||||
"menu.import": "匯入",
|
||||
"menu.search": "搜尋",
|
||||
"menu.create_category": "新建分類",
|
||||
"menu.mark_page_as_read": "將此頁面標記為已讀",
|
||||
"menu.mark_all_as_read": "全部標為已讀",
|
||||
|
@ -83,40 +84,33 @@
|
|||
"entry.shared_entry.title": "開啟公共連結",
|
||||
"entry.shared_entry.label": "分享",
|
||||
"entry.estimated_reading_time": [
|
||||
"需要 %d 分鐘閱讀",
|
||||
"需要 %d 分鐘閱讀"
|
||||
],
|
||||
"entry.tags.label": "標籤:",
|
||||
"page.shared_entries.title": "已分享的文章",
|
||||
"page.shared_entries_count": [
|
||||
"%d shared entry",
|
||||
"%d shared entries"
|
||||
"%d shared entry"
|
||||
],
|
||||
"page.unread.title": "未讀",
|
||||
"page.unread_entry_count": [
|
||||
"%d unread entry",
|
||||
"%d unread entries"
|
||||
"%d unread entry"
|
||||
],
|
||||
"page.total_entry_count": [
|
||||
"%d entry in total",
|
||||
"%d entries in total"
|
||||
"%d entry in total"
|
||||
],
|
||||
"page.starred.title": "收藏",
|
||||
"page.starred_entry_count": [
|
||||
"%d starred entry",
|
||||
"%d starred entries"
|
||||
"%d starred entry"
|
||||
],
|
||||
"page.categories.title": "分類",
|
||||
"page.categories.no_feed": "沒有Feed",
|
||||
"page.categories.entries": "檢視內容",
|
||||
"page.categories.feeds": "檢視Feeds",
|
||||
"page.categories.feed_count": [
|
||||
"有 %d 個Feed",
|
||||
"有 %d 個Feeds"
|
||||
"有 %d 個Feed"
|
||||
],
|
||||
"page.categories_count": [
|
||||
"%d category",
|
||||
"%d categories"
|
||||
"%d category"
|
||||
],
|
||||
"page.new_category.title": "新分類",
|
||||
"page.new_user.title": "新使用者",
|
||||
|
@ -128,13 +122,11 @@
|
|||
"page.feeds.next_check": "下次檢查時間:",
|
||||
"page.feeds.read_counter": "已讀文章數",
|
||||
"page.feeds.error_count": [
|
||||
"%d 錯誤",
|
||||
"%d 錯誤"
|
||||
],
|
||||
"page.history.title": "歷史",
|
||||
"page.read_entry_count": [
|
||||
"%d read entry",
|
||||
"%d read entries"
|
||||
"%d read entry"
|
||||
],
|
||||
"page.import.title": "匯入",
|
||||
"page.search.title": "搜尋結果",
|
||||
|
@ -215,7 +207,6 @@
|
|||
"page.settings.webauthn.register": "註冊 Passkey",
|
||||
"page.settings.webauthn.register.error": "無法註冊 Passkey",
|
||||
"page.settings.webauthn.delete": [
|
||||
"刪除 %d 個 Passkey",
|
||||
"刪除 %d 個 Passkey"
|
||||
],
|
||||
"page.login.title": "登入",
|
||||
|
@ -327,6 +318,7 @@
|
|||
"form.feed.label.apprise_service_urls": "使用逗號分隔的 Apprise 服務 URL 列表",
|
||||
"form.feed.label.ignore_http_cache": "忽略 HTTP 快取",
|
||||
"form.feed.label.allow_self_signed_certificates": "允許自簽章憑證或無效憑證",
|
||||
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
|
||||
"form.feed.label.fetch_via_proxy": "透過代理獲取",
|
||||
"form.feed.label.disabled": "請勿更新此 Feed",
|
||||
"form.feed.label.no_media_player": "沒有媒體播放器(音訊/視訊)",
|
||||
|
@ -447,6 +439,11 @@
|
|||
"form.integration.matrix_bot_password": "Matrix 的密碼",
|
||||
"form.integration.matrix_bot_url": "Matrix 伺服器的 URL",
|
||||
"form.integration.matrix_bot_chat_id": "Matrix 房間 ID",
|
||||
"form.integration.readeck_activate": "儲存文章到 Readeck",
|
||||
"form.integration.readeck_endpoint": "Readeck API 端點",
|
||||
"form.integration.readeck_api_key": "Readeck API 金鑰",
|
||||
"form.integration.readeck_labels": "Readeck Labels",
|
||||
"form.integration.readeck_only_url": "仅发送 URL(而不是完整内容)",
|
||||
"form.integration.shiori_activate": "儲存文章到 Shiori",
|
||||
"form.integration.shiori_endpoint": "Shiori API 端點",
|
||||
"form.integration.shiori_username": "Shiori 使用者名稱",
|
||||
|
@ -466,32 +463,25 @@
|
|||
"time_elapsed.yesterday": "昨天",
|
||||
"time_elapsed.now": "剛剛",
|
||||
"time_elapsed.minutes": [
|
||||
"%d 分鐘前",
|
||||
"%d 分鐘前"
|
||||
],
|
||||
"time_elapsed.hours": [
|
||||
"%d 小時前",
|
||||
"%d 小時前"
|
||||
],
|
||||
"time_elapsed.days": [
|
||||
"%d 天前",
|
||||
"%d 天前"
|
||||
],
|
||||
"time_elapsed.weeks": [
|
||||
"%d 周前",
|
||||
"%d 周前"
|
||||
],
|
||||
"time_elapsed.months": [
|
||||
"%d 月前",
|
||||
"%d 月前"
|
||||
],
|
||||
"time_elapsed.years": [
|
||||
"%d 年前",
|
||||
"%d 年前"
|
||||
],
|
||||
"alert.too_many_feeds_refresh": [
|
||||
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
|
||||
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
|
||||
"You have triggered too many feed refreshes. Please wait %d minute before trying again."
|
||||
],
|
||||
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
|
||||
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
|
||||
|
|
|
@ -67,5 +67,5 @@ type Session struct {
|
|||
}
|
||||
|
||||
func (s *Session) String() string {
|
||||
return fmt.Sprintf(`ID="%s", Data={%v}`, s.ID, s.Data)
|
||||
return fmt.Sprintf(`ID=%q, Data={%v}`, s.ID, s.Data)
|
||||
}
|
||||
|
|
|
@ -51,6 +51,7 @@ type Feed struct {
|
|||
FetchViaProxy bool `json:"fetch_via_proxy"`
|
||||
HideGlobally bool `json:"hide_globally"`
|
||||
AppriseServiceURLs string `json:"apprise_service_urls"`
|
||||
DisableHTTP2 bool `json:"disable_http2"`
|
||||
|
||||
// Non persisted attributes
|
||||
Category *Category `json:"category,omitempty"`
|
||||
|
@ -150,6 +151,7 @@ type FeedCreationRequest struct {
|
|||
KeeplistRules string `json:"keeplist_rules"`
|
||||
HideGlobally bool `json:"hide_globally"`
|
||||
UrlRewriteRules string `json:"urlrewrite_rules"`
|
||||
DisableHTTP2 bool `json:"disable_http2"`
|
||||
}
|
||||
|
||||
type FeedCreationRequestFromSubscriptionDiscovery struct {
|
||||
|
@ -175,6 +177,7 @@ type FeedCreationRequestFromSubscriptionDiscovery struct {
|
|||
KeeplistRules string `json:"keeplist_rules"`
|
||||
HideGlobally bool `json:"hide_globally"`
|
||||
UrlRewriteRules string `json:"urlrewrite_rules"`
|
||||
DisableHTTP2 bool `json:"disable_http2"`
|
||||
}
|
||||
|
||||
// FeedModificationRequest represents the request to update a feed.
|
||||
|
@ -199,6 +202,7 @@ type FeedModificationRequest struct {
|
|||
AllowSelfSignedCertificates *bool `json:"allow_self_signed_certificates"`
|
||||
FetchViaProxy *bool `json:"fetch_via_proxy"`
|
||||
HideGlobally *bool `json:"hide_globally"`
|
||||
DisableHTTP2 *bool `json:"disable_http2"`
|
||||
}
|
||||
|
||||
// Patch updates a feed with modified values.
|
||||
|
@ -282,6 +286,10 @@ func (f *FeedModificationRequest) Patch(feed *Feed) {
|
|||
if f.HideGlobally != nil {
|
||||
feed.HideGlobally = *f.HideGlobally
|
||||
}
|
||||
|
||||
if f.DisableHTTP2 != nil {
|
||||
feed.DisableHTTP2 = *f.DisableHTTP2
|
||||
}
|
||||
}
|
||||
|
||||
// Feeds is a list of feed
|
||||
|
|
|
@ -70,6 +70,11 @@ type Integration struct {
|
|||
AppriseEnabled bool
|
||||
AppriseURL string
|
||||
AppriseServicesURL string
|
||||
ReadeckEnabled bool
|
||||
ReadeckURL string
|
||||
ReadeckAPIKey string
|
||||
ReadeckLabels string
|
||||
ReadeckOnlyURL bool
|
||||
ShioriEnabled bool
|
||||
ShioriURL string
|
||||
ShioriUsername string
|
||||
|
|
|
@ -12,4 +12,5 @@ type SubscriptionDiscoveryRequest struct {
|
|||
Password string `json:"password"`
|
||||
FetchViaProxy bool `json:"fetch_via_proxy"`
|
||||
AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"`
|
||||
DisableHTTP2 bool `json:"disable_http2"`
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ type UserSession struct {
|
|||
}
|
||||
|
||||
func (u *UserSession) String() string {
|
||||
return fmt.Sprintf(`ID="%d", UserID="%d", IP="%s", Token="%s"`, u.ID, u.UserID, u.IP, u.Token)
|
||||
return fmt.Sprintf(`ID=%q, UserID=%q, IP=%q, Token=%q`, u.ID, u.UserID, u.IP, u.Token)
|
||||
}
|
||||
|
||||
// UseTimezone converts creation date to the given timezone.
|
||||
|
|
|
@ -43,9 +43,9 @@ func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter,
|
|||
for _, mediaType := range config.Opts.ProxyMediaTypes() {
|
||||
switch mediaType {
|
||||
case "image":
|
||||
doc.Find("img").Each(func(i int, img *goquery.Selection) {
|
||||
doc.Find("img, picture source").Each(func(i int, img *goquery.Selection) {
|
||||
if srcAttrValue, ok := img.Attr("src"); ok {
|
||||
if !isDataURL(srcAttrValue) && (proxyOption == "all" || !urllib.IsHTTPS(srcAttrValue)) {
|
||||
if shouldProxy(srcAttrValue, proxyOption) {
|
||||
img.SetAttr("src", proxifyFunction(router, srcAttrValue))
|
||||
}
|
||||
}
|
||||
|
@ -55,42 +55,34 @@ func genericProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter,
|
|||
}
|
||||
})
|
||||
|
||||
doc.Find("picture source").Each(func(i int, sourceElement *goquery.Selection) {
|
||||
if srcsetAttrValue, ok := sourceElement.Attr("srcset"); ok {
|
||||
proxifySourceSet(sourceElement, router, proxifyFunction, proxyOption, srcsetAttrValue)
|
||||
doc.Find("video").Each(func(i int, video *goquery.Selection) {
|
||||
if posterAttrValue, ok := video.Attr("poster"); ok {
|
||||
if shouldProxy(posterAttrValue, proxyOption) {
|
||||
video.SetAttr("poster", proxifyFunction(router, posterAttrValue))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
case "audio":
|
||||
doc.Find("audio").Each(func(i int, audio *goquery.Selection) {
|
||||
doc.Find("audio, audio source").Each(func(i int, audio *goquery.Selection) {
|
||||
if srcAttrValue, ok := audio.Attr("src"); ok {
|
||||
if !isDataURL(srcAttrValue) && (proxyOption == "all" || !urllib.IsHTTPS(srcAttrValue)) {
|
||||
if shouldProxy(srcAttrValue, proxyOption) {
|
||||
audio.SetAttr("src", proxifyFunction(router, srcAttrValue))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
doc.Find("audio source").Each(func(i int, sourceElement *goquery.Selection) {
|
||||
if srcAttrValue, ok := sourceElement.Attr("src"); ok {
|
||||
if !isDataURL(srcAttrValue) && (proxyOption == "all" || !urllib.IsHTTPS(srcAttrValue)) {
|
||||
sourceElement.SetAttr("src", proxifyFunction(router, srcAttrValue))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
case "video":
|
||||
doc.Find("video").Each(func(i int, video *goquery.Selection) {
|
||||
doc.Find("video, video source").Each(func(i int, video *goquery.Selection) {
|
||||
if srcAttrValue, ok := video.Attr("src"); ok {
|
||||
if !isDataURL(srcAttrValue) && (proxyOption == "all" || !urllib.IsHTTPS(srcAttrValue)) {
|
||||
if shouldProxy(srcAttrValue, proxyOption) {
|
||||
video.SetAttr("src", proxifyFunction(router, srcAttrValue))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
doc.Find("video source").Each(func(i int, sourceElement *goquery.Selection) {
|
||||
if srcAttrValue, ok := sourceElement.Attr("src"); ok {
|
||||
if !isDataURL(srcAttrValue) && (proxyOption == "all" || !urllib.IsHTTPS(srcAttrValue)) {
|
||||
sourceElement.SetAttr("src", proxifyFunction(router, srcAttrValue))
|
||||
if posterAttrValue, ok := video.Attr("poster"); ok {
|
||||
if shouldProxy(posterAttrValue, proxyOption) {
|
||||
video.SetAttr("poster", proxifyFunction(router, posterAttrValue))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@ -109,7 +101,7 @@ func proxifySourceSet(element *goquery.Selection, router *mux.Router, proxifyFun
|
|||
imageCandidates := sanitizer.ParseSrcSetAttribute(srcsetAttrValue)
|
||||
|
||||
for _, imageCandidate := range imageCandidates {
|
||||
if !isDataURL(imageCandidate.ImageURL) && (proxyOption == "all" || !urllib.IsHTTPS(imageCandidate.ImageURL)) {
|
||||
if shouldProxy(imageCandidate.ImageURL, proxyOption) {
|
||||
imageCandidate.ImageURL = proxifyFunction(router, imageCandidate.ImageURL)
|
||||
}
|
||||
}
|
||||
|
@ -117,6 +109,7 @@ func proxifySourceSet(element *goquery.Selection, router *mux.Router, proxifyFun
|
|||
element.SetAttr("srcset", imageCandidates.String())
|
||||
}
|
||||
|
||||
func isDataURL(s string) bool {
|
||||
return strings.HasPrefix(s, "data:")
|
||||
func shouldProxy(attrValue, proxyOption string) bool {
|
||||
return !strings.HasPrefix(attrValue, "data:") &&
|
||||
(proxyOption == "all" || !urllib.IsHTTPS(attrValue))
|
||||
}
|
||||
|
|
|
@ -377,3 +377,53 @@ func TestProxyWithImageSourceDataURL(t *testing.T) {
|
|||
t.Errorf(`Not expected output: got %s`, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyFilterWithVideo(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("PROXY_OPTION", "all")
|
||||
os.Setenv("PROXY_MEDIA_TYPES", "video")
|
||||
os.Setenv("PROXY_PRIVATE_KEY", "test")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
r := mux.NewRouter()
|
||||
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||
|
||||
input := `<video poster="https://example.com/img.png" src="https://example.com/video.mp4"></video>`
|
||||
expected := `<video poster="/proxy/aDFfroYL57q5XsojIzATT6OYUCkuVSPXYJQAVrotnLw=/aHR0cHM6Ly9leGFtcGxlLmNvbS9pbWcucG5n" src="/proxy/0y3LR8zlx8S8qJkj1qWFOO6x3a-5yf2gLWjGIJV5yyc=/aHR0cHM6Ly9leGFtcGxlLmNvbS92aWRlby5tcDQ="></video>`
|
||||
output := ProxyRewriter(r, input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Not expected output: got %s`, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyFilterVideoPoster(t *testing.T) {
|
||||
os.Clearenv()
|
||||
os.Setenv("PROXY_OPTION", "all")
|
||||
os.Setenv("PROXY_MEDIA_TYPES", "image")
|
||||
os.Setenv("PROXY_PRIVATE_KEY", "test")
|
||||
|
||||
var err error
|
||||
parser := config.NewParser()
|
||||
config.Opts, err = parser.ParseEnvironmentVariables()
|
||||
if err != nil {
|
||||
t.Fatalf(`Parsing failure: %v`, err)
|
||||
}
|
||||
|
||||
r := mux.NewRouter()
|
||||
r.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
|
||||
|
||||
input := `<video poster="https://example.com/img.png" src="https://example.com/video.mp4"></video>`
|
||||
expected := `<video poster="/proxy/aDFfroYL57q5XsojIzATT6OYUCkuVSPXYJQAVrotnLw=/aHR0cHM6Ly9leGFtcGxlLmNvbS9pbWcucG5n" src="https://example.com/video.mp4"></video>`
|
||||
output := ProxyRewriter(r, input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Not expected output: got %s`, output)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,7 +27,7 @@ func TestParseAtom03(t *testing.T) {
|
|||
</entry>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("http://diveintomark.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -87,7 +87,7 @@ func TestParseAtom03WithoutFeedTitle(t *testing.T) {
|
|||
</entry>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("http://diveintomark.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -110,7 +110,7 @@ func TestParseAtom03WithoutEntryTitleButWithLink(t *testing.T) {
|
|||
</entry>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("http://diveintomark.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -138,7 +138,7 @@ func TestParseAtom03WithoutEntryTitleButWithSummary(t *testing.T) {
|
|||
</entry>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("http://diveintomark.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -166,7 +166,7 @@ func TestParseAtom03WithoutEntryTitleButWithXMLContent(t *testing.T) {
|
|||
</entry>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("http://diveintomark.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -197,7 +197,7 @@ func TestParseAtom03WithSummaryOnly(t *testing.T) {
|
|||
</entry>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("http://diveintomark.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -228,7 +228,7 @@ func TestParseAtom03WithXMLContent(t *testing.T) {
|
|||
</entry>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("http://diveintomark.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -259,7 +259,7 @@ func TestParseAtom03WithBase64Content(t *testing.T) {
|
|||
</entry>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("http://diveintomark.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("http://diveintomark.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
|
@ -184,7 +184,7 @@ func (a *atom10Entry) entryEnclosures() model.EnclosureList {
|
|||
}
|
||||
|
||||
for _, link := range a.Links {
|
||||
if strings.ToLower(link.Rel) == "enclosure" {
|
||||
if strings.EqualFold(link.Rel, "enclosure") {
|
||||
if link.URL == "" {
|
||||
continue
|
||||
}
|
||||
|
|
|
@ -31,7 +31,7 @@ func TestParseAtomSample(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("http://example.org/feed.xml", bytes.NewBufferString(data))
|
||||
feed, err := Parse("http://example.org/feed.xml", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -93,7 +93,7 @@ func TestParseFeedWithoutTitle(t *testing.T) {
|
|||
<updated>2003-12-13T18:30:02Z</updated>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -123,7 +123,7 @@ func TestParseEntryWithoutTitleButWithURL(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -154,7 +154,7 @@ func TestParseEntryWithoutTitleButWithSummary(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -187,7 +187,7 @@ func TestParseEntryWithoutTitleButWithXHTMLContent(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -206,7 +206,7 @@ func TestParseFeedURL(t *testing.T) {
|
|||
<updated>2003-12-13T18:30:02Z</updated>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -238,7 +238,7 @@ func TestParseFeedWithRelativeURL(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -272,7 +272,7 @@ func TestParseEntryWithRelativeURL(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.net/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.net/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -298,7 +298,7 @@ func TestParseEntryURLWithTextHTMLType(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.net/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.net/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -324,7 +324,7 @@ func TestParseEntryURLWithNoRelAndNoType(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.net/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.net/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -350,7 +350,7 @@ func TestParseEntryURLWithAlternateRel(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.net/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.net/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -378,7 +378,7 @@ func TestParseEntryTitleWithWhitespaces(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -412,13 +412,13 @@ func TestParseEntryWithPlainTextTitle(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := `AT&T bought by SBC!`
|
||||
for i := 0; i < 2; i++ {
|
||||
for i := range 2 {
|
||||
if feed.Entries[i].Title != expected {
|
||||
t.Errorf("Incorrect title for entry #%d, got: %q", i, feed.Entries[i].Title)
|
||||
}
|
||||
|
@ -459,7 +459,7 @@ func TestParseEntryWithHTMLTitle(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -497,7 +497,7 @@ func TestParseEntryWithXHTMLTitle(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -524,7 +524,7 @@ func TestParseEntryWithEmptyXHTMLTitle(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -551,7 +551,7 @@ func TestParseEntryWithXHTMLTitleWithoutDiv(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -577,7 +577,7 @@ func TestParseEntryWithNumericCharacterReferenceTitle(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -603,7 +603,7 @@ func TestParseEntryWithDoubleEncodedEntitiesTitle(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -629,7 +629,7 @@ func TestParseEntryWithXHTMLSummary(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -671,13 +671,13 @@ func TestParseEntryWithHTMLSummary(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := `<code>std::unique_ptr<S></code>`
|
||||
for i := 0; i < 3; i++ {
|
||||
for i := range 3 {
|
||||
if feed.Entries[i].Content != expected {
|
||||
t.Errorf("Incorrect content for entry #%d, got: %q", i, feed.Entries[i].Content)
|
||||
}
|
||||
|
@ -723,13 +723,13 @@ func TestParseEntryWithTextSummary(t *testing.T) {
|
|||
</entry>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := `AT&T <S>`
|
||||
for i := 0; i < 4; i++ {
|
||||
for i := range 4 {
|
||||
if feed.Entries[i].Content != expected {
|
||||
t.Errorf("Incorrect content for entry #%d, got: %q", i, feed.Entries[i].Content)
|
||||
}
|
||||
|
@ -776,13 +776,13 @@ func TestParseEntryWithTextContent(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := `AT&T <S>`
|
||||
for i := 0; i < 4; i++ {
|
||||
for i := range 4 {
|
||||
if feed.Entries[i].Content != expected {
|
||||
t.Errorf("Incorrect content for entry #%d, got: %q", i, feed.Entries[i].Content)
|
||||
}
|
||||
|
@ -821,13 +821,13 @@ func TestParseEntryWithHTMLContent(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := `AT&T bought <b>by SBC</b>!`
|
||||
for i := 0; i < 3; i++ {
|
||||
for i := range 3 {
|
||||
if feed.Entries[i].Content != expected {
|
||||
t.Errorf("Incorrect content for entry #%d, got: %q", i, feed.Entries[i].Content)
|
||||
}
|
||||
|
@ -852,7 +852,7 @@ func TestParseEntryWithXHTMLContent(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -881,7 +881,7 @@ func TestParseEntryWithAuthorName(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -910,7 +910,7 @@ func TestParseEntryWithoutAuthorName(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -941,7 +941,7 @@ func TestParseEntryWithMultipleAuthors(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -969,7 +969,7 @@ func TestParseEntryWithoutAuthor(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1000,7 +1000,7 @@ func TestParseFeedWithMultipleAuthors(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1025,7 +1025,7 @@ func TestParseFeedWithoutAuthor(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1075,7 +1075,7 @@ func TestParseEntryWithEnclosures(t *testing.T) {
|
|||
</entry>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1135,7 +1135,7 @@ func TestParseEntryWithoutEnclosureURL(t *testing.T) {
|
|||
</entry>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1168,7 +1168,7 @@ func TestParseEntryWithPublished(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1194,7 +1194,7 @@ func TestParseEntryWithPublishedAndUpdated(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1206,7 +1206,7 @@ func TestParseEntryWithPublishedAndUpdated(t *testing.T) {
|
|||
|
||||
func TestParseInvalidXml(t *testing.T) {
|
||||
data := `garbage`
|
||||
_, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
_, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err == nil {
|
||||
t.Error("Parse should returns an error")
|
||||
}
|
||||
|
@ -1221,7 +1221,7 @@ func TestParseTitleWithSingleQuote(t *testing.T) {
|
|||
</feed>
|
||||
`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1240,7 +1240,7 @@ func TestParseTitleWithEncodedSingleQuote(t *testing.T) {
|
|||
</feed>
|
||||
`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1259,7 +1259,7 @@ func TestParseTitleWithSingleQuoteAndHTMLType(t *testing.T) {
|
|||
</feed>
|
||||
`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1278,7 +1278,7 @@ func TestParseWithHTMLEntity(t *testing.T) {
|
|||
</feed>
|
||||
`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1297,7 +1297,7 @@ func TestParseWithInvalidCharacterEntity(t *testing.T) {
|
|||
</feed>
|
||||
`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1330,7 +1330,7 @@ A website: http://example.org/</media:description>
|
|||
</entry>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1396,7 +1396,7 @@ A website: http://example.org/</media:description>
|
|||
</entry>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1467,7 +1467,7 @@ func TestParseRepliesLinkRelationWithHTMLType(t *testing.T) {
|
|||
</entry>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1511,7 +1511,7 @@ func TestParseRepliesLinkRelationWithXHTMLType(t *testing.T) {
|
|||
</entry>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1550,7 +1550,7 @@ func TestParseRepliesLinkRelationWithNoType(t *testing.T) {
|
|||
</entry>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1590,7 +1590,7 @@ func TestAbsoluteCommentsURL(t *testing.T) {
|
|||
</entry>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1631,7 +1631,7 @@ func TestParseFeedWithCategories(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1661,7 +1661,7 @@ func TestParseFeedWithIconURL(t *testing.T) {
|
|||
<icon>http://example.org/icon.png</icon>
|
||||
</feed>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
|
@ -46,7 +46,7 @@ type atomLinks []*atomLink
|
|||
|
||||
func (a atomLinks) originalLink() string {
|
||||
for _, link := range a {
|
||||
if strings.ToLower(link.Rel) == "alternate" {
|
||||
if strings.EqualFold(link.Rel, "alternate") {
|
||||
return strings.TrimSpace(link.URL)
|
||||
}
|
||||
|
||||
|
@ -60,7 +60,7 @@ func (a atomLinks) originalLink() string {
|
|||
|
||||
func (a atomLinks) firstLinkWithRelation(relation string) string {
|
||||
for _, link := range a {
|
||||
if strings.ToLower(link.Rel) == relation {
|
||||
if strings.EqualFold(link.Rel, relation) {
|
||||
return strings.TrimSpace(link.URL)
|
||||
}
|
||||
}
|
||||
|
@ -70,9 +70,9 @@ func (a atomLinks) firstLinkWithRelation(relation string) string {
|
|||
|
||||
func (a atomLinks) firstLinkWithRelationAndType(relation string, contentTypes ...string) string {
|
||||
for _, link := range a {
|
||||
if strings.ToLower(link.Rel) == relation {
|
||||
if strings.EqualFold(link.Rel, relation) {
|
||||
for _, contentType := range contentTypes {
|
||||
if strings.ToLower(link.Type) == contentType {
|
||||
if strings.EqualFold(link.Type, contentType) {
|
||||
return strings.TrimSpace(link.URL)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
package atom // import "miniflux.app/v2/internal/reader/atom"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
|
@ -18,25 +17,23 @@ type atomFeed interface {
|
|||
}
|
||||
|
||||
// Parse returns a normalized feed struct from a Atom feed.
|
||||
func Parse(baseURL string, r io.Reader) (*model.Feed, error) {
|
||||
var buf bytes.Buffer
|
||||
tee := io.TeeReader(r, &buf)
|
||||
|
||||
func Parse(baseURL string, r io.ReadSeeker) (*model.Feed, error) {
|
||||
var rawFeed atomFeed
|
||||
if getAtomFeedVersion(tee) == "0.3" {
|
||||
if getAtomFeedVersion(r) == "0.3" {
|
||||
rawFeed = new(atom03Feed)
|
||||
} else {
|
||||
rawFeed = new(atom10Feed)
|
||||
}
|
||||
r.Seek(0, io.SeekStart)
|
||||
|
||||
if err := xml_decoder.NewXMLDecoder(&buf).Decode(rawFeed); err != nil {
|
||||
if err := xml_decoder.NewXMLDecoder(r).Decode(rawFeed); err != nil {
|
||||
return nil, fmt.Errorf("atom: unable to parse feed: %w", err)
|
||||
}
|
||||
|
||||
return rawFeed.Transform(baseURL), nil
|
||||
}
|
||||
|
||||
func getAtomFeedVersion(data io.Reader) string {
|
||||
func getAtomFeedVersion(data io.ReadSeeker) string {
|
||||
decoder := xml_decoder.NewXMLDecoder(data)
|
||||
for {
|
||||
token, _ := decoder.Token()
|
||||
|
|
|
@ -30,7 +30,7 @@ func TestDetectAtom10(t *testing.T) {
|
|||
|
||||
</feed>`
|
||||
|
||||
version := getAtomFeedVersion(bytes.NewBufferString(data))
|
||||
version := getAtomFeedVersion(bytes.NewReader([]byte(data)))
|
||||
if version != "1.0" {
|
||||
t.Errorf(`Invalid Atom version detected: %s`, version)
|
||||
}
|
||||
|
@ -54,7 +54,7 @@ func TestDetectAtom03(t *testing.T) {
|
|||
</entry>
|
||||
</feed>`
|
||||
|
||||
version := getAtomFeedVersion(bytes.NewBufferString(data))
|
||||
version := getAtomFeedVersion(bytes.NewReader([]byte(data)))
|
||||
if version != "0.3" {
|
||||
t.Errorf(`Invalid Atom version detected: %s`, version)
|
||||
}
|
||||
|
|
|
@ -6,22 +6,25 @@ package date // import "miniflux.app/v2/internal/reader/date"
|
|||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DateFormats taken from github.com/mjibson/goread
|
||||
// RFC822, RFC850, and RFC1123 formats should be applied only to local times.
|
||||
var dateFormatsLocalTimesOnly = []string{
|
||||
time.RFC822, // RSS
|
||||
time.RFC850,
|
||||
time.RFC1123,
|
||||
}
|
||||
|
||||
// dateFormats taken from github.com/mjibson/goread
|
||||
var dateFormats = []string{
|
||||
time.RFC822, // RSS
|
||||
time.RFC822Z, // RSS
|
||||
time.RFC3339, // Atom
|
||||
time.UnixDate,
|
||||
time.RubyDate,
|
||||
time.RFC850,
|
||||
time.RFC1123Z,
|
||||
time.RFC1123,
|
||||
time.ANSIC,
|
||||
"Mon, 02 Jan 2006 15:04:05 MST -07:00",
|
||||
"Mon, January 2, 2006, 3:04 PM MST",
|
||||
|
@ -314,34 +317,30 @@ var invalidLocalizedDateReplacer = strings.NewReplacer(
|
|||
// list of commonly found feed date formats.
|
||||
func Parse(rawInput string) (t time.Time, err error) {
|
||||
rawInput = strings.TrimSpace(rawInput)
|
||||
timestamp, err := strconv.ParseInt(rawInput, 10, 64)
|
||||
if err == nil {
|
||||
if rawInput == "" {
|
||||
return t, errors.New(`date parser: empty value`)
|
||||
}
|
||||
|
||||
if timestamp, err := strconv.ParseInt(rawInput, 10, 64); err == nil {
|
||||
return time.Unix(timestamp, 0), nil
|
||||
}
|
||||
|
||||
processedInput := invalidLocalizedDateReplacer.Replace(rawInput)
|
||||
processedInput = invalidTimezoneReplacer.Replace(processedInput)
|
||||
if processedInput == "" {
|
||||
return t, errors.New(`date parser: empty value`)
|
||||
|
||||
for _, layout := range dateFormatsLocalTimesOnly {
|
||||
if t, err = parseLocalTimeDates(layout, processedInput); err == nil {
|
||||
return checkTimezoneRange(t), nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, layout := range dateFormats {
|
||||
switch layout {
|
||||
case time.RFC822, time.RFC850, time.RFC1123:
|
||||
if t, err = parseLocalTimeDates(layout, processedInput); err == nil {
|
||||
t = checkTimezoneRange(t)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if t, err = time.Parse(layout, processedInput); err == nil {
|
||||
t = checkTimezoneRange(t)
|
||||
return
|
||||
return checkTimezoneRange(t), nil
|
||||
}
|
||||
}
|
||||
|
||||
err = fmt.Errorf(`date parser: failed to parse date "%s"`, rawInput)
|
||||
return
|
||||
return t, fmt.Errorf(`date parser: failed to parse date "%s"`, rawInput)
|
||||
}
|
||||
|
||||
// According to Golang documentation:
|
||||
|
@ -369,7 +368,7 @@ func parseLocalTimeDates(layout, ds string) (t time.Time, err error) {
|
|||
// Avoid "pq: time zone displacement out of range" errors
|
||||
func checkTimezoneRange(t time.Time) time.Time {
|
||||
_, offset := t.Zone()
|
||||
if math.Abs(float64(offset)) > 14*60*60 {
|
||||
if float64(offset) > 14*60*60 || float64(offset) < -12*60*60 {
|
||||
t = t.UTC()
|
||||
}
|
||||
return t
|
||||
|
|
|
@ -7,6 +7,14 @@ import (
|
|||
"testing"
|
||||
)
|
||||
|
||||
func FuzzParse(f *testing.F) {
|
||||
f.Add("2017-12-22T22:09:49+00:00")
|
||||
f.Add("Fri, 31 Mar 2023 20:19:00 America/Los_Angeles")
|
||||
f.Fuzz(func(t *testing.T, date string) {
|
||||
Parse(date)
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseEmptyDate(t *testing.T) {
|
||||
if _, err := Parse(" "); err == nil {
|
||||
t.Fatalf(`Empty dates should return an error`)
|
||||
|
@ -228,14 +236,19 @@ func TestParseWeirdDateFormat(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestParseDateWithTimezoneOutOfRange(t *testing.T) {
|
||||
date, err := Parse("2023-05-29 00:00:00-23:00")
|
||||
|
||||
if err != nil {
|
||||
t.Errorf(`Unable to parse date: %v`, err)
|
||||
inputs := []string{
|
||||
"2023-05-29 00:00:00-13:00",
|
||||
"2023-05-29 00:00:00+15:00",
|
||||
}
|
||||
for _, input := range inputs {
|
||||
date, err := Parse(input)
|
||||
|
||||
_, offset := date.Zone()
|
||||
if offset != 0 {
|
||||
t.Errorf(`The offset should be reinitialized to 0 instead of %v because it's out of range`, offset)
|
||||
if err != nil {
|
||||
t.Errorf(`Unable to parse date: %v`, err)
|
||||
}
|
||||
|
||||
if _, offset := date.Zone(); offset != 0 {
|
||||
t.Errorf(`The offset should be reinitialized to 0 instead of %v because it's out of range`, offset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@ func (feed *DublinCoreFeedElement) GetSanitizedCreator() string {
|
|||
|
||||
// DublinCoreItemElement represents Dublin Core entry XML elements.
|
||||
type DublinCoreItemElement struct {
|
||||
DublinCoreTitle string `xml:"http://purl.org/dc/elements/1.1/ title"`
|
||||
DublinCoreDate string `xml:"http://purl.org/dc/elements/1.1/ date"`
|
||||
DublinCoreCreator string `xml:"http://purl.org/dc/elements/1.1/ creator"`
|
||||
DublinCoreContent string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
|
||||
|
|
|
@ -26,6 +26,7 @@ type RequestBuilder struct {
|
|||
clientTimeout int
|
||||
withoutRedirects bool
|
||||
ignoreTLSErrors bool
|
||||
disableHTTP2 bool
|
||||
}
|
||||
|
||||
func NewRequestBuilder() *RequestBuilder {
|
||||
|
@ -97,6 +98,11 @@ func (r *RequestBuilder) WithoutRedirects() *RequestBuilder {
|
|||
return r
|
||||
}
|
||||
|
||||
func (r *RequestBuilder) DisableHTTP2(value bool) *RequestBuilder {
|
||||
r.disableHTTP2 = value
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *RequestBuilder) IgnoreTLSErrors(value bool) *RequestBuilder {
|
||||
r.ignoreTLSErrors = value
|
||||
return r
|
||||
|
@ -126,6 +132,14 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
|
|||
},
|
||||
}
|
||||
|
||||
if r.disableHTTP2 {
|
||||
transport.ForceAttemptHTTP2 = false
|
||||
|
||||
// https://pkg.go.dev/net/http#hdr-HTTP_2
|
||||
// Programs that must disable HTTP/2 can do so by setting [Transport.TLSNextProto] (for clients) or [Server.TLSNextProto] (for servers) to a non-nil, empty map.
|
||||
transport.TLSNextProto = map[string]func(string, *tls.Conn) http.RoundTripper{}
|
||||
}
|
||||
|
||||
if r.useClientProxy && r.clientProxyURL != "" {
|
||||
if proxyURL, err := url.Parse(r.clientProxyURL); err != nil {
|
||||
slog.Warn("Unable to parse proxy URL",
|
||||
|
@ -165,6 +179,8 @@ func (r *RequestBuilder) ExecuteRequest(requestURL string) (*http.Response, erro
|
|||
slog.Bool("without_redirects", r.withoutRedirects),
|
||||
slog.Bool("with_proxy", r.useClientProxy),
|
||||
slog.String("proxy_url", r.clientProxyURL),
|
||||
slog.Bool("ignore_tls_errors", r.ignoreTLSErrors),
|
||||
slog.Bool("disable_http2", r.disableHTTP2),
|
||||
))
|
||||
|
||||
return client.Do(req)
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package googleplay // import "miniflux.app/v2/internal/reader/googleplay"
|
||||
|
||||
// Specs:
|
||||
// https://support.google.com/googleplay/podcasts/answer/6260341
|
||||
// https://www.google.com/schemas/play-podcasts/1.0/play-podcasts.xsd
|
||||
type GooglePlayFeedElement struct {
|
||||
GooglePlayAuthor string `xml:"http://www.google.com/schemas/play-podcasts/1.0 author"`
|
||||
GooglePlayEmail string `xml:"http://www.google.com/schemas/play-podcasts/1.0 email"`
|
||||
GooglePlayImage GooglePlayImageElement `xml:"http://www.google.com/schemas/play-podcasts/1.0 image"`
|
||||
GooglePlayDescription string `xml:"http://www.google.com/schemas/play-podcasts/1.0 description"`
|
||||
GooglePlayCategory GooglePlayCategoryElement `xml:"http://www.google.com/schemas/play-podcasts/1.0 category"`
|
||||
}
|
||||
|
||||
type GooglePlayItemElement struct {
|
||||
GooglePlayAuthor string `xml:"http://www.google.com/schemas/play-podcasts/1.0 author"`
|
||||
GooglePlayDescription string `xml:"http://www.google.com/schemas/play-podcasts/1.0 description"`
|
||||
GooglePlayExplicit string `xml:"http://www.google.com/schemas/play-podcasts/1.0 explicit"`
|
||||
GooglePlayBlock string `xml:"http://www.google.com/schemas/play-podcasts/1.0 block"`
|
||||
GooglePlayNewFeedURL string `xml:"http://www.google.com/schemas/play-podcasts/1.0 new-feed-url"`
|
||||
}
|
||||
|
||||
type GooglePlayImageElement struct {
|
||||
Href string `xml:"href,attr"`
|
||||
}
|
||||
|
||||
type GooglePlayCategoryElement struct {
|
||||
Text string `xml:"text,attr"`
|
||||
}
|
|
@ -67,6 +67,7 @@ func CreateFeedFromSubscriptionDiscovery(store *storage.Storage, userID int64, f
|
|||
subscription.EtagHeader = feedCreationRequest.ETag
|
||||
subscription.LastModifiedHeader = feedCreationRequest.LastModified
|
||||
subscription.FeedURL = feedCreationRequest.FeedURL
|
||||
subscription.DisableHTTP2 = feedCreationRequest.DisableHTTP2
|
||||
subscription.WithCategoryID(feedCreationRequest.CategoryID)
|
||||
subscription.CheckedNow()
|
||||
|
||||
|
@ -90,6 +91,7 @@ func CreateFeedFromSubscriptionDiscovery(store *storage.Storage, userID int64, f
|
|||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
requestBuilder.UseProxy(feedCreationRequest.FetchViaProxy)
|
||||
requestBuilder.IgnoreTLSErrors(feedCreationRequest.AllowSelfSignedCertificates)
|
||||
requestBuilder.DisableHTTP2(feedCreationRequest.DisableHTTP2)
|
||||
|
||||
checkFeedIcon(
|
||||
store,
|
||||
|
@ -126,6 +128,7 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
|
|||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
requestBuilder.UseProxy(feedCreationRequest.FetchViaProxy)
|
||||
requestBuilder.IgnoreTLSErrors(feedCreationRequest.AllowSelfSignedCertificates)
|
||||
requestBuilder.DisableHTTP2(feedCreationRequest.DisableHTTP2)
|
||||
|
||||
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(feedCreationRequest.FeedURL))
|
||||
defer responseHandler.Close()
|
||||
|
@ -159,6 +162,7 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
|
|||
subscription.Disabled = feedCreationRequest.Disabled
|
||||
subscription.IgnoreHTTPCache = feedCreationRequest.IgnoreHTTPCache
|
||||
subscription.AllowSelfSignedCertificates = feedCreationRequest.AllowSelfSignedCertificates
|
||||
subscription.DisableHTTP2 = feedCreationRequest.DisableHTTP2
|
||||
subscription.FetchViaProxy = feedCreationRequest.FetchViaProxy
|
||||
subscription.ScraperRules = feedCreationRequest.ScraperRules
|
||||
subscription.RewriteRules = feedCreationRequest.RewriteRules
|
||||
|
@ -238,6 +242,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
|
|||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
requestBuilder.UseProxy(originalFeed.FetchViaProxy)
|
||||
requestBuilder.IgnoreTLSErrors(originalFeed.AllowSelfSignedCertificates)
|
||||
requestBuilder.DisableHTTP2(originalFeed.DisableHTTP2)
|
||||
|
||||
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(originalFeed.FeedURL))
|
||||
defer responseHandler.Close()
|
||||
|
|
|
@ -9,6 +9,7 @@ import (
|
|||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
|
@ -184,10 +185,10 @@ func (f *IconFinder) DownloadIcon(iconURL string) (*model.Icon, error) {
|
|||
|
||||
func findIconURLsFromHTMLDocument(body io.Reader, contentType string) ([]string, error) {
|
||||
queries := []string{
|
||||
"link[rel='shortcut icon']",
|
||||
"link[rel='Shortcut Icon']",
|
||||
"link[rel='icon shortcut']",
|
||||
"link[rel='icon']",
|
||||
"link[rel='icon' i]",
|
||||
"link[rel='shortcut icon' i]",
|
||||
"link[rel='icon shortcut' i]",
|
||||
"link[rel='apple-touch-icon-precomposed.png']",
|
||||
}
|
||||
|
||||
htmlDocumentReader, err := encoding.CharsetReaderFromContentType(contentType, body)
|
||||
|
@ -205,18 +206,13 @@ func findIconURLsFromHTMLDocument(body io.Reader, contentType string) ([]string,
|
|||
slog.Debug("Searching icon URL in HTML document", slog.String("query", query))
|
||||
|
||||
doc.Find(query).Each(func(i int, s *goquery.Selection) {
|
||||
var iconURL string
|
||||
|
||||
if href, exists := s.Attr("href"); exists {
|
||||
iconURL = strings.TrimSpace(href)
|
||||
}
|
||||
|
||||
if iconURL != "" {
|
||||
iconURLs = append(iconURLs, iconURL)
|
||||
|
||||
slog.Debug("Found icon URL in HTML document",
|
||||
slog.String("query", query),
|
||||
slog.String("icon_url", iconURL))
|
||||
if iconURL := strings.TrimSpace(href); iconURL != "" {
|
||||
iconURLs = append(iconURLs, iconURL)
|
||||
slog.Debug("Found icon URL in HTML document",
|
||||
slog.String("query", query),
|
||||
slog.String("icon_url", iconURL))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -225,35 +221,23 @@ func findIconURLsFromHTMLDocument(body io.Reader, contentType string) ([]string,
|
|||
}
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs#syntax
|
||||
// data:[<mediatype>][;base64],<data>
|
||||
// data:[<mediatype>][;encoding],<data>
|
||||
// we consider <mediatype> to be mandatory, and it has to start with `image/`.
|
||||
// we consider `base64`, `utf8` and the empty string to be the only valid encodings
|
||||
func parseImageDataURL(value string) (*model.Icon, error) {
|
||||
var mediaType string
|
||||
var encoding string
|
||||
re := regexp.MustCompile(`^data:` +
|
||||
`(?P<mediatype>image/[^;,]+)` +
|
||||
`(?:;(?P<encoding>base64|utf8))?` +
|
||||
`,(?P<data>.+)$`)
|
||||
|
||||
if !strings.HasPrefix(value, "data:") {
|
||||
return nil, fmt.Errorf(`icon: invalid data URL (missing data:) %q`, value)
|
||||
matches := re.FindStringSubmatch(value)
|
||||
if matches == nil {
|
||||
return nil, fmt.Errorf(`icon: invalid data URL %q`, value)
|
||||
}
|
||||
|
||||
value = value[5:]
|
||||
|
||||
comma := strings.Index(value, ",")
|
||||
if comma < 0 {
|
||||
return nil, fmt.Errorf(`icon: invalid data URL (no comma) %q`, value)
|
||||
}
|
||||
|
||||
data := value[comma+1:]
|
||||
semicolon := strings.Index(value[0:comma], ";")
|
||||
|
||||
if semicolon > 0 {
|
||||
mediaType = value[0:semicolon]
|
||||
encoding = value[semicolon+1 : comma]
|
||||
} else {
|
||||
mediaType = value[0:comma]
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(mediaType, "image/") {
|
||||
return nil, fmt.Errorf(`icon: invalid media type %q`, mediaType)
|
||||
}
|
||||
mediaType := matches[re.SubexpIndex("mediatype")]
|
||||
encoding := matches[re.SubexpIndex("encoding")]
|
||||
data := matches[re.SubexpIndex("data")]
|
||||
|
||||
var blob []byte
|
||||
switch encoding {
|
||||
|
@ -271,19 +255,11 @@ func parseImageDataURL(value string) (*model.Icon, error) {
|
|||
blob = []byte(decodedData)
|
||||
case "utf8":
|
||||
blob = []byte(data)
|
||||
default:
|
||||
return nil, fmt.Errorf(`icon: unsupported data URL encoding %q`, value)
|
||||
}
|
||||
|
||||
if len(blob) == 0 {
|
||||
return nil, fmt.Errorf(`icon: empty data URL %q`, value)
|
||||
}
|
||||
|
||||
icon := &model.Icon{
|
||||
return &model.Icon{
|
||||
Hash: crypto.HashFromBytes(blob),
|
||||
Content: blob,
|
||||
MimeType: mediaType,
|
||||
}
|
||||
|
||||
return icon, nil
|
||||
}, nil
|
||||
}
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package itunes // import "miniflux.app/v2/internal/reader/itunes"
|
||||
|
||||
import "strings"
|
||||
|
||||
// Specs: https://help.apple.com/itc/podcasts_connect/#/itcb54353390
|
||||
type ItunesFeedElement struct {
|
||||
ItunesAuthor string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd author"`
|
||||
ItunesBlock string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd block"`
|
||||
ItunesCategories []ItunesCategoryElement `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd category"`
|
||||
ItunesComplete string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd complete"`
|
||||
ItunesCopyright string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd copyright"`
|
||||
ItunesExplicit string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd explicit"`
|
||||
ItunesImage ItunesImageElement `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd image"`
|
||||
Keywords string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd keywords"`
|
||||
ItunesNewFeedURL string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd new-feed-url"`
|
||||
ItunesOwner ItunesOwnerElement `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd owner"`
|
||||
ItunesSummary string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd summary"`
|
||||
ItunesTitle string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd title"`
|
||||
ItunesType string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd type"`
|
||||
}
|
||||
|
||||
func (i *ItunesFeedElement) GetItunesCategories() []string {
|
||||
var categories []string
|
||||
for _, category := range i.ItunesCategories {
|
||||
categories = append(categories, category.Text)
|
||||
if category.SubCategory != nil {
|
||||
categories = append(categories, category.SubCategory.Text)
|
||||
}
|
||||
}
|
||||
return categories
|
||||
}
|
||||
|
||||
type ItunesItemElement struct {
|
||||
ItunesAuthor string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd author"`
|
||||
ItunesEpisode string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd episode"`
|
||||
ItunesEpisodeType string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd episodeType"`
|
||||
ItunesExplicit string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd explicit"`
|
||||
ItunesDuration string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd duration"`
|
||||
ItunesImage ItunesImageElement `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd image"`
|
||||
ItunesSeason string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd season"`
|
||||
ItunesSubtitle string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd subtitle"`
|
||||
ItunesSummary string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd summary"`
|
||||
ItunesTitle string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd title"`
|
||||
ItunesTranscript string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd transcript"`
|
||||
}
|
||||
|
||||
type ItunesImageElement struct {
|
||||
Href string `xml:"href,attr"`
|
||||
}
|
||||
|
||||
type ItunesCategoryElement struct {
|
||||
Text string `xml:"text,attr"`
|
||||
SubCategory *ItunesCategoryElement `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd category"`
|
||||
}
|
||||
|
||||
type ItunesOwnerElement struct {
|
||||
Name string `xml:"name"`
|
||||
Email string `xml:"email"`
|
||||
}
|
||||
|
||||
func (i *ItunesOwnerElement) String() string {
|
||||
var name string
|
||||
|
||||
switch {
|
||||
case i.Name != "":
|
||||
name = i.Name
|
||||
case i.Email != "":
|
||||
name = i.Email
|
||||
}
|
||||
|
||||
return strings.TrimSpace(name)
|
||||
}
|
|
@ -12,6 +12,7 @@ import (
|
|||
var textLinkRegex = regexp.MustCompile(`(?mi)(\bhttps?:\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])`)
|
||||
|
||||
// Element represents XML media elements.
|
||||
// Specs: https://www.rssboard.org/media-rss
|
||||
type Element struct {
|
||||
MediaGroups []Group `xml:"http://search.yahoo.com/mrss/ group"`
|
||||
MediaContents []Content `xml:"http://search.yahoo.com/mrss/ content"`
|
||||
|
@ -156,7 +157,7 @@ func (d *Description) HTML() string {
|
|||
return d.Description
|
||||
}
|
||||
|
||||
content := strings.Replace(d.Description, "\n", "<br>", -1)
|
||||
content := strings.ReplaceAll(d.Description, "\n", "<br>")
|
||||
return textLinkRegex.ReplaceAllString(content, `<a href="${1}">${1}</a>`)
|
||||
}
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@ func (h *Handler) Export(userID int64) (string, error) {
|
|||
return "", err
|
||||
}
|
||||
|
||||
var subscriptions SubcriptionList
|
||||
subscriptions := make(SubcriptionList, 0, len(feeds))
|
||||
for _, feed := range feeds {
|
||||
subscriptions = append(subscriptions, &Subcription{
|
||||
Title: feed.Title,
|
||||
|
|
|
@ -37,7 +37,7 @@ func getSubscriptionsFromOutlines(outlines opmlOutlineCollection, category strin
|
|||
CategoryName: category,
|
||||
})
|
||||
} else if outline.Outlines.HasChildren() {
|
||||
subscriptions = append(subscriptions, getSubscriptionsFromOutlines(outline.Outlines, outline.Text)...)
|
||||
subscriptions = append(subscriptions, getSubscriptionsFromOutlines(outline.Outlines, outline.GetTitle())...)
|
||||
}
|
||||
}
|
||||
return subscriptions
|
||||
|
|
|
@ -81,7 +81,7 @@ func TestParseOpmlWithCategories(t *testing.T) {
|
|||
t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 3)
|
||||
}
|
||||
|
||||
for i := 0; i < len(subscriptions); i++ {
|
||||
for i := range len(subscriptions) {
|
||||
if !subscriptions[i].Equals(expected[i]) {
|
||||
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
|
||||
}
|
||||
|
@ -114,7 +114,7 @@ func TestParseOpmlWithEmptyTitleAndEmptySiteURL(t *testing.T) {
|
|||
t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 2)
|
||||
}
|
||||
|
||||
for i := 0; i < len(subscriptions); i++ {
|
||||
for i := range len(subscriptions) {
|
||||
if !subscriptions[i].Equals(expected[i]) {
|
||||
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
|
||||
}
|
||||
|
@ -129,10 +129,10 @@ func TestParseOpmlVersion1(t *testing.T) {
|
|||
<dateCreated>Wed, 13 Mar 2019 11:51:41 GMT</dateCreated>
|
||||
</head>
|
||||
<body>
|
||||
<outline title="Feed 1">
|
||||
<outline title="Category 1">
|
||||
<outline type="rss" title="Feed 1" xmlUrl="http://example.org/feed1/" htmlUrl="http://example.org/1"></outline>
|
||||
</outline>
|
||||
<outline title="Feed 2">
|
||||
<outline title="Category 2">
|
||||
<outline type="rss" title="Feed 2" xmlUrl="http://example.org/feed2/" htmlUrl="http://example.org/2"></outline>
|
||||
</outline>
|
||||
</body>
|
||||
|
@ -140,8 +140,8 @@ func TestParseOpmlVersion1(t *testing.T) {
|
|||
`
|
||||
|
||||
var expected SubcriptionList
|
||||
expected = append(expected, &Subcription{Title: "Feed 1", FeedURL: "http://example.org/feed1/", SiteURL: "http://example.org/1", CategoryName: ""})
|
||||
expected = append(expected, &Subcription{Title: "Feed 2", FeedURL: "http://example.org/feed2/", SiteURL: "http://example.org/2", CategoryName: ""})
|
||||
expected = append(expected, &Subcription{Title: "Feed 1", FeedURL: "http://example.org/feed1/", SiteURL: "http://example.org/1", CategoryName: "Category 1"})
|
||||
expected = append(expected, &Subcription{Title: "Feed 2", FeedURL: "http://example.org/feed2/", SiteURL: "http://example.org/2", CategoryName: "Category 2"})
|
||||
|
||||
subscriptions, err := Parse(bytes.NewBufferString(data))
|
||||
if err != nil {
|
||||
|
@ -152,7 +152,7 @@ func TestParseOpmlVersion1(t *testing.T) {
|
|||
t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 2)
|
||||
}
|
||||
|
||||
for i := 0; i < len(subscriptions); i++ {
|
||||
for i := range len(subscriptions) {
|
||||
if !subscriptions[i].Equals(expected[i]) {
|
||||
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
|
||||
}
|
||||
|
@ -186,7 +186,7 @@ func TestParseOpmlVersion1WithoutOuterOutline(t *testing.T) {
|
|||
t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 2)
|
||||
}
|
||||
|
||||
for i := 0; i < len(subscriptions); i++ {
|
||||
for i := range len(subscriptions) {
|
||||
if !subscriptions[i].Equals(expected[i]) {
|
||||
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
|
||||
}
|
||||
|
@ -228,7 +228,7 @@ func TestParseOpmlVersion1WithSeveralNestedOutlines(t *testing.T) {
|
|||
t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 3)
|
||||
}
|
||||
|
||||
for i := 0; i < len(subscriptions); i++ {
|
||||
for i := range len(subscriptions) {
|
||||
if !subscriptions[i].Equals(expected[i]) {
|
||||
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
|
||||
}
|
||||
|
@ -250,7 +250,7 @@ func TestParseOpmlWithInvalidCharacterEntity(t *testing.T) {
|
|||
`
|
||||
|
||||
var expected SubcriptionList
|
||||
expected = append(expected, &Subcription{Title: "Feed 1", FeedURL: "http://example.org/feed1/a&b", SiteURL: "http://example.org/c&d", CategoryName: ""})
|
||||
expected = append(expected, &Subcription{Title: "Feed 1", FeedURL: "http://example.org/feed1/a&b", SiteURL: "http://example.org/c&d", CategoryName: "Feed 1"})
|
||||
|
||||
subscriptions, err := Parse(bytes.NewBufferString(data))
|
||||
if err != nil {
|
||||
|
@ -261,7 +261,7 @@ func TestParseOpmlWithInvalidCharacterEntity(t *testing.T) {
|
|||
t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 1)
|
||||
}
|
||||
|
||||
for i := 0; i < len(subscriptions); i++ {
|
||||
for i := range len(subscriptions) {
|
||||
if !subscriptions[i].Equals(expected[i]) {
|
||||
t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
|
||||
}
|
||||
|
|
|
@ -38,14 +38,14 @@ func convertSubscriptionsToOPML(subscriptions SubcriptionList) *opmlDocument {
|
|||
opmlDocument.Header.DateCreated = time.Now().Format("Mon, 02 Jan 2006 15:04:05 MST")
|
||||
|
||||
groupedSubs := groupSubscriptionsByFeed(subscriptions)
|
||||
var categories []string
|
||||
categories := make([]string, 0, len(groupedSubs))
|
||||
for k := range groupedSubs {
|
||||
categories = append(categories, k)
|
||||
}
|
||||
sort.Strings(categories)
|
||||
|
||||
for _, categoryName := range categories {
|
||||
category := opmlOutline{Text: categoryName}
|
||||
category := opmlOutline{Text: categoryName, Outlines: make(opmlOutlineCollection, 0, len(groupedSubs[categoryName]))}
|
||||
for _, subscription := range groupedSubs[categoryName] {
|
||||
category.Outlines = append(category.Outlines, opmlOutline{
|
||||
Title: subscription.Title,
|
||||
|
|
|
@ -8,6 +8,62 @@ import (
|
|||
"testing"
|
||||
)
|
||||
|
||||
func FuzzParse(f *testing.F) {
|
||||
f.Add("https://z.org", `<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>Example Feed</title>
|
||||
<link href="http://z.org/"/>
|
||||
<link href="/k"/>
|
||||
<updated>2003-12-13T18:30:02Z</updated>
|
||||
<author><name>John Doe</name></author>
|
||||
<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
|
||||
<entry>
|
||||
<title>a</title>
|
||||
<link href="http://example.org/b"/>
|
||||
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
||||
<updated>2003-12-13T18:30:02Z</updated>
|
||||
<summary>c</summary>
|
||||
</entry>
|
||||
</feed>`)
|
||||
f.Add("https://z.org", `<?xml version="1.0"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>a</title>
|
||||
<link>http://z.org</link>
|
||||
<item>
|
||||
<title>a</title>
|
||||
<link>http://z.org</link>
|
||||
<description>d</description>
|
||||
<pubDate>Tue, 03 Jun 2003 09:39:21 GMT</pubDate>
|
||||
<guid>l</guid>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`)
|
||||
f.Add("https://z.org", `<?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/">
|
||||
<channel>
|
||||
<title>a</title>
|
||||
<link>http://z.org/</link>
|
||||
</channel>
|
||||
<item>
|
||||
<title>a</title>
|
||||
<link>/</link>
|
||||
<description>c</description>
|
||||
</item>
|
||||
</rdf:RDF>`)
|
||||
f.Add("http://z.org", `{
|
||||
"version": "http://jsonfeed.org/version/1",
|
||||
"title": "a",
|
||||
"home_page_url": "http://z.org/",
|
||||
"feed_url": "http://z.org/a.json",
|
||||
"items": [
|
||||
{"id": "2","content_text": "a","url": "https://z.org/2"},
|
||||
{"id": "1","content_html": "<a","url":"http://z.org/1"}]}`)
|
||||
f.Fuzz(func(t *testing.T, url string, data string) {
|
||||
ParseFeed(url, strings.NewReader(data))
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseAtom(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
|
|
|
@ -8,6 +8,7 @@ import (
|
|||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
|
@ -25,7 +26,7 @@ import (
|
|||
)
|
||||
|
||||
var (
|
||||
youtubeRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)`)
|
||||
youtubeRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)$`)
|
||||
odyseeRegex = regexp.MustCompile(`^https://odysee\.com`)
|
||||
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)?)?$`)
|
||||
customReplaceRuleRegex = regexp.MustCompile(`rewrite\("(.*)"\|"(.*)"\)`)
|
||||
|
@ -71,6 +72,7 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.Us
|
|||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
requestBuilder.UseProxy(feed.FetchViaProxy)
|
||||
requestBuilder.IgnoreTLSErrors(feed.AllowSelfSignedCertificates)
|
||||
requestBuilder.DisableHTTP2(feed.DisableHTTP2)
|
||||
|
||||
content, scraperErr := scraper.ScrapeWebsite(
|
||||
requestBuilder,
|
||||
|
@ -115,13 +117,9 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.Us
|
|||
|
||||
func isBlockedEntry(feed *model.Feed, entry *model.Entry) bool {
|
||||
if feed.BlocklistRules != "" {
|
||||
var containsBlockedTag bool = false
|
||||
for _, tag := range entry.Tags {
|
||||
if matchField(feed.BlocklistRules, tag) {
|
||||
containsBlockedTag = true
|
||||
break
|
||||
}
|
||||
}
|
||||
containsBlockedTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
|
||||
return matchField(feed.BlocklistRules, tag)
|
||||
})
|
||||
|
||||
if matchField(feed.BlocklistRules, entry.URL) || matchField(feed.BlocklistRules, entry.Title) || matchField(feed.BlocklistRules, entry.Author) || containsBlockedTag {
|
||||
slog.Debug("Blocking entry based on rule",
|
||||
|
@ -140,13 +138,9 @@ func isBlockedEntry(feed *model.Feed, entry *model.Entry) bool {
|
|||
|
||||
func isAllowedEntry(feed *model.Feed, entry *model.Entry) bool {
|
||||
if feed.KeeplistRules != "" {
|
||||
var containsAllowedTag bool = false
|
||||
for _, tag := range entry.Tags {
|
||||
if matchField(feed.KeeplistRules, tag) {
|
||||
containsAllowedTag = true
|
||||
break
|
||||
}
|
||||
}
|
||||
containsAllowedTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
|
||||
return matchField(feed.KeeplistRules, tag)
|
||||
})
|
||||
|
||||
if matchField(feed.KeeplistRules, entry.URL) || matchField(feed.KeeplistRules, entry.Title) || matchField(feed.KeeplistRules, entry.Author) || containsAllowedTag {
|
||||
slog.Debug("Allow entry based on rule",
|
||||
|
@ -188,6 +182,7 @@ func ProcessEntryWebPage(feed *model.Feed, entry *model.Entry, user *model.User)
|
|||
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
|
||||
requestBuilder.UseProxy(feed.FetchViaProxy)
|
||||
requestBuilder.IgnoreTLSErrors(feed.AllowSelfSignedCertificates)
|
||||
requestBuilder.DisableHTTP2(feed.DisableHTTP2)
|
||||
|
||||
content, scraperErr := scraper.ScrapeWebsite(
|
||||
requestBuilder,
|
||||
|
@ -209,7 +204,9 @@ func ProcessEntryWebPage(feed *model.Feed, entry *model.Entry, user *model.User)
|
|||
|
||||
if content != "" {
|
||||
entry.Content = content
|
||||
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
|
||||
if user.ShowReadingTime {
|
||||
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
|
||||
}
|
||||
}
|
||||
|
||||
rewrite.Rewriter(websiteURL, entry, entry.Feed.RewriteRules)
|
||||
|
@ -287,7 +284,9 @@ func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *mod
|
|||
}
|
||||
// Handle YT error case and non-YT entries.
|
||||
if entry.ReadingTime == 0 {
|
||||
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
|
||||
if user.ShowReadingTime {
|
||||
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ import (
|
|||
)
|
||||
|
||||
// Parse returns a normalized feed struct from a RDF feed.
|
||||
func Parse(baseURL string, data io.Reader) (*model.Feed, error) {
|
||||
func Parse(baseURL string, data io.ReadSeeker) (*model.Feed, error) {
|
||||
feed := new(rdfFeed)
|
||||
if err := xml.NewXMLDecoder(data).Decode(feed); err != nil {
|
||||
return nil, fmt.Errorf("rdf: unable to parse feed: %w", err)
|
||||
|
|
|
@ -75,7 +75,7 @@ func TestParseRDFSample(t *testing.T) {
|
|||
|
||||
</rdf:RDF>`
|
||||
|
||||
feed, err := Parse("http://xml.com/pub/rdf.xml", bytes.NewBufferString(data))
|
||||
feed, err := Parse("http://xml.com/pub/rdf.xml", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -186,7 +186,7 @@ func TestParseRDFSampleWithDublinCore(t *testing.T) {
|
|||
|
||||
</rdf:RDF>`
|
||||
|
||||
feed, err := Parse("http://meerkat.oreillynet.com/feed.rdf", bytes.NewBufferString(data))
|
||||
feed, err := Parse("http://meerkat.oreillynet.com/feed.rdf", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -253,7 +253,7 @@ func TestParseItemWithOnlyFeedAuthor(t *testing.T) {
|
|||
</item>
|
||||
</rdf:RDF>`
|
||||
|
||||
feed, err := Parse("http://meerkat.oreillynet.com", bytes.NewBufferString(data))
|
||||
feed, err := Parse("http://meerkat.oreillynet.com", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -278,7 +278,7 @@ func TestParseItemRelativeURL(t *testing.T) {
|
|||
</item>
|
||||
</rdf:RDF>`
|
||||
|
||||
feed, err := Parse("http://meerkat.oreillynet.com", bytes.NewBufferString(data))
|
||||
feed, err := Parse("http://meerkat.oreillynet.com", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -307,7 +307,7 @@ func TestParseItemWithoutLink(t *testing.T) {
|
|||
</item>
|
||||
</rdf:RDF>`
|
||||
|
||||
feed, err := Parse("http://meerkat.oreillynet.com", bytes.NewBufferString(data))
|
||||
feed, err := Parse("http://meerkat.oreillynet.com", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -338,7 +338,7 @@ func TestParseItemWithDublicCoreDate(t *testing.T) {
|
|||
</item>
|
||||
</rdf:RDF>`
|
||||
|
||||
feed, err := Parse("http://example.org", bytes.NewBufferString(data))
|
||||
feed, err := Parse("http://example.org", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -366,7 +366,7 @@ func TestParseItemWithEncodedHTMLInDCCreatorField(t *testing.T) {
|
|||
</item>
|
||||
</rdf:RDF>`
|
||||
|
||||
feed, err := Parse("http://example.org", bytes.NewBufferString(data))
|
||||
feed, err := Parse("http://example.org", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -392,7 +392,7 @@ func TestParseItemWithoutDate(t *testing.T) {
|
|||
</item>
|
||||
</rdf:RDF>`
|
||||
|
||||
feed, err := Parse("http://example.org", bytes.NewBufferString(data))
|
||||
feed, err := Parse("http://example.org", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -406,7 +406,7 @@ 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#">
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://purl.org/rss/1.0/">
|
||||
<channel>
|
||||
<title>Example</title>
|
||||
<link>http://example.org</link>
|
||||
|
@ -419,19 +419,19 @@ func TestParseItemWithEncodedHTMLTitle(t *testing.T) {
|
|||
</item>
|
||||
</rdf:RDF>`
|
||||
|
||||
feed, err := Parse("http://example.org", bytes.NewBufferString(data))
|
||||
feed, err := Parse("http://example.org", bytes.NewReader([]byte(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)
|
||||
t.Errorf("Incorrect entry title, got: %q", feed.Entries[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInvalidXml(t *testing.T) {
|
||||
data := `garbage`
|
||||
_, err := Parse("http://example.org", bytes.NewBufferString(data))
|
||||
_, err := Parse("http://example.org", bytes.NewReader([]byte(data)))
|
||||
if err == nil {
|
||||
t.Fatal("Parse should returns an error")
|
||||
}
|
||||
|
@ -446,7 +446,7 @@ func TestParseFeedWithHTMLEntity(t *testing.T) {
|
|||
</channel>
|
||||
</rdf:RDF>`
|
||||
|
||||
feed, err := Parse("http://example.org", bytes.NewBufferString(data))
|
||||
feed, err := Parse("http://example.org", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -465,7 +465,7 @@ func TestParseFeedWithInvalidCharacterEntity(t *testing.T) {
|
|||
</channel>
|
||||
</rdf:RDF>`
|
||||
|
||||
feed, err := Parse("http://example.org", bytes.NewBufferString(data))
|
||||
feed, err := Parse("http://example.org", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -502,7 +502,7 @@ func TestParseFeedWithURLWrappedInSpaces(t *testing.T) {
|
|||
<item rdf:about="http://biorxiv.org/cgi/content/short/857789v1?rss=1">
|
||||
<title>
|
||||
<![CDATA[
|
||||
Microscale Collagen and Fibroblast Interactions Enhance Primary Human Hepatocyte Functions in 3-Dimensional Models
|
||||
Microscale Collagen and Fibroblast Interactions Enhance Primary Human Hepatocyte Functions in 3-Dimensional Models
|
||||
]]>
|
||||
</title>
|
||||
<link>
|
||||
|
@ -521,7 +521,7 @@ func TestParseFeedWithURLWrappedInSpaces(t *testing.T) {
|
|||
</item>
|
||||
</rdf:RDF>`
|
||||
|
||||
feed, err := Parse("http://biorxiv.org", bytes.NewBufferString(data))
|
||||
feed, err := Parse("http://biorxiv.org", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -556,7 +556,7 @@ func TestParseRDFWithContentEncoded(t *testing.T) {
|
|||
</item>
|
||||
</rdf:RDF>`
|
||||
|
||||
feed, err := Parse("http://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("http://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -568,7 +568,7 @@ func TestParseRDFWithContentEncoded(t *testing.T) {
|
|||
expected := `<p>Test</p>`
|
||||
result := feed.Entries[0].Content
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected entry URL, got %q instead of %q`, result, expected)
|
||||
t.Errorf(`Unexpected entry content, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -589,7 +589,7 @@ func TestParseRDFWithEncodedHTMLDescription(t *testing.T) {
|
|||
</item>
|
||||
</rdf:RDF>`
|
||||
|
||||
feed, err := Parse("http://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("http://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -601,6 +601,105 @@ func TestParseRDFWithEncodedHTMLDescription(t *testing.T) {
|
|||
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)
|
||||
t.Errorf(`Unexpected entry content, got %v instead of %v`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRDFItemWithDuplicateTitleElement(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:dc="http://purl.org/dc/elements/1.1/">
|
||||
<channel>
|
||||
<title>Example Feed</title>
|
||||
<link>http://example.org/</link>
|
||||
</channel>
|
||||
<item>
|
||||
<title>Item Title</title>
|
||||
<dc:title/>
|
||||
<link>http://example.org/</link>
|
||||
<description>Test</description>
|
||||
</item>
|
||||
</rdf:RDF>`
|
||||
|
||||
feed, err := Parse("http://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(feed.Entries) != 1 {
|
||||
t.Fatalf(`Unexpected number of entries, got %d`, len(feed.Entries))
|
||||
}
|
||||
|
||||
expected := `Item Title`
|
||||
result := feed.Entries[0].Title
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected entry title, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRDFItemWithDublinCoreTitleElement(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:dc="http://purl.org/dc/elements/1.1/">
|
||||
<channel>
|
||||
<title>Example Feed</title>
|
||||
<link>http://example.org/</link>
|
||||
</channel>
|
||||
<item>
|
||||
<dc:title>Dublin Core Title</dc:title>
|
||||
<link>http://example.org/</link>
|
||||
<description>Test</description>
|
||||
</item>
|
||||
</rdf:RDF>`
|
||||
|
||||
feed, err := Parse("http://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(feed.Entries) != 1 {
|
||||
t.Fatalf(`Unexpected number of entries, got %d`, len(feed.Entries))
|
||||
}
|
||||
|
||||
expected := `Dublin Core Title`
|
||||
result := feed.Entries[0].Title
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected entry title, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRDFItemWitEmptyTitleElement(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/">
|
||||
<channel>
|
||||
<title>Example Feed</title>
|
||||
<link>http://example.org/</link>
|
||||
</channel>
|
||||
<item>
|
||||
<title> </title>
|
||||
<link>http://example.org/item</link>
|
||||
<description>Test</description>
|
||||
</item>
|
||||
</rdf:RDF>`
|
||||
|
||||
feed, err := Parse("http://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(feed.Entries) != 1 {
|
||||
t.Fatalf(`Unexpected number of entries, got %d`, len(feed.Entries))
|
||||
}
|
||||
|
||||
expected := `http://example.org/item`
|
||||
result := feed.Entries[0].Title
|
||||
if result != expected {
|
||||
t.Errorf(`Unexpected entry title, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -58,7 +58,7 @@ func (r *rdfFeed) Transform(baseURL string) *model.Feed {
|
|||
}
|
||||
|
||||
type rdfItem struct {
|
||||
Title string `xml:"title"`
|
||||
Title string `xml:"http://purl.org/rss/1.0/ title"`
|
||||
Link string `xml:"link"`
|
||||
Description string `xml:"description"`
|
||||
dublincore.DublinCoreItemElement
|
||||
|
@ -72,11 +72,21 @@ func (r *rdfItem) Transform() *model.Entry {
|
|||
entry.Content = r.entryContent()
|
||||
entry.Hash = r.entryHash()
|
||||
entry.Date = r.entryDate()
|
||||
|
||||
if entry.Title == "" {
|
||||
entry.Title = entry.URL
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func (r *rdfItem) entryTitle() string {
|
||||
return html.UnescapeString(strings.TrimSpace(r.Title))
|
||||
for _, title := range []string{r.Title, r.DublinCoreTitle} {
|
||||
title = strings.TrimSpace(title)
|
||||
if title != "" {
|
||||
return html.UnescapeString(title)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (r *rdfItem) entryContent() string {
|
||||
|
|
|
@ -132,12 +132,15 @@ func getArticle(topCandidate *candidate, candidates candidateList) string {
|
|||
}
|
||||
})
|
||||
|
||||
output.Write([]byte("</div>"))
|
||||
output.WriteString("</div>")
|
||||
return output.String()
|
||||
}
|
||||
|
||||
func removeUnlikelyCandidates(document *goquery.Document) {
|
||||
document.Find("*").Not("html,body").Each(func(i int, s *goquery.Selection) {
|
||||
document.Find("*").Each(func(i int, s *goquery.Selection) {
|
||||
if s.Length() == 0 || s.Get(0).Data == "html" || s.Get(0).Data == "body" {
|
||||
return
|
||||
}
|
||||
class, _ := s.Attr("class")
|
||||
id, _ := s.Attr("id")
|
||||
str := class + id
|
||||
|
|
|
@ -17,15 +17,23 @@ import (
|
|||
// EstimateReadingTime returns the estimated reading time of an article in minute.
|
||||
func EstimateReadingTime(content string, defaultReadingSpeed, cjkReadingSpeed int) int {
|
||||
sanitizedContent := sanitizer.StripTags(content)
|
||||
langInfo := whatlanggo.Detect(sanitizedContent)
|
||||
|
||||
var timeToReadInt int
|
||||
if langInfo.IsReliable() && (langInfo.Lang == whatlanggo.Jpn || langInfo.Lang == whatlanggo.Cmn || langInfo.Lang == whatlanggo.Kor) {
|
||||
timeToReadInt = int(math.Ceil(float64(utf8.RuneCountInString(sanitizedContent)) / float64(cjkReadingSpeed)))
|
||||
} else {
|
||||
nbOfWords := len(strings.Fields(sanitizedContent))
|
||||
timeToReadInt = int(math.Ceil(float64(nbOfWords) / float64(defaultReadingSpeed)))
|
||||
// Litterature on language detection says that around 100 signes is enough, we're safe here.
|
||||
truncationPoint := int(math.Min(float64(len(sanitizedContent)), 250))
|
||||
|
||||
// We're only interested in identifying Japanse/Chinese/Korean
|
||||
options := whatlanggo.Options{
|
||||
Whitelist: map[whatlanggo.Lang]bool{
|
||||
whatlanggo.Jpn: true,
|
||||
whatlanggo.Cmn: true,
|
||||
whatlanggo.Kor: true,
|
||||
},
|
||||
}
|
||||
langInfo := whatlanggo.DetectWithOptions(sanitizedContent[:truncationPoint], options)
|
||||
|
||||
return timeToReadInt
|
||||
if langInfo.IsReliable() {
|
||||
return int(math.Ceil(float64(utf8.RuneCountInString(sanitizedContent)) / float64(cjkReadingSpeed)))
|
||||
}
|
||||
nbOfWords := len(strings.Fields(sanitizedContent))
|
||||
return int(math.Ceil(float64(nbOfWords) / float64(defaultReadingSpeed)))
|
||||
}
|
||||
|
|
|
@ -5,8 +5,10 @@ package readingtime
|
|||
|
||||
import "testing"
|
||||
|
||||
func TestEstimateReadingTimeInEnglish(t *testing.T) {
|
||||
sampleText := `
|
||||
var samples = map[string]string{
|
||||
"shortenglish": `This is a short paragraph in english, less than 250 chars.`,
|
||||
"shortchinese": ` 労問委格名町違載式新青脂通由。割止書円画民京般著治登門画拡下。有国同観教田美森素説砂者徴多。上治速相支存色分繰年活元事集遣逆山`,
|
||||
"english": `
|
||||
In turpis lacus, sollicitudin non accumsan sed, suscipit eget magna. Morbi id
|
||||
neque enim. Aenean ac lacus consectetur, accumsan elit ac, suscipit dui. Donec
|
||||
congue mi et nisl bibendum, venenatis fringilla orci tristique. Nullam ullamcorper
|
||||
|
@ -35,27 +37,52 @@ func TestEstimateReadingTimeInEnglish(t *testing.T) {
|
|||
turpis. Sed semper eu urna sit amet malesuada. Suspendisse blandit condimentum elit,
|
||||
in scelerisque tellus convallis eu. Nunc eleifend sem et mauris vestibulum
|
||||
mattis. Praesent ultricies pellentesque eros non posuere.
|
||||
`
|
||||
`,
|
||||
"chinese": `
|
||||
労問委格名町違載式新青脂通由。割止書円画民京般著治登門画拡下。有国同観教田美森素説砂者徴多。上治速相支存色分繰年活元事集遣逆山。身消年森発世財間世変悲原記潟旅好手真今。現通浪口特愛始信川節身方一表著購。郁不使権草定内防並要更一条露加。載交源図訴際属年券重供健三洗。事北残却女鮎朝分要廷込宣政愛無投事。
|
||||
|
||||
readingTime := EstimateReadingTime(sampleText, 200, 500)
|
||||
if readingTime != 2 {
|
||||
t.Errorf(`Wrong reading time, got %d instead of 2`, readingTime)
|
||||
問警技亮参沼洗請米物模人。誰探重午局新戦報投性病庭。典向載問千著書故表視新権最石車音端乏大。白僚三掲局係仕表広無旧見要最裁。額寄済生年余講前本次載隊劇。権成観始応泉早高拓了経地本稼室目犯井出。暮載必広傷内校岡公南散広転行別釈。康運行関本掲隠泉傷退報告。独変年換差取予口男旅挑講禁姿。出芳工類胸管払時済潟髪内豊。
|
||||
|
||||
康浴部問玲玉追球化就店岡問画路投。施先太業阪能敏所陸不供探掲方用。手右演社援発示竹育対橋除際愛功旬転好使公。利時改本項輸属嘆員複携者地剤。天政朝戸祝言月接住世黙極者議編連。囲淑覧重弾必治物健賄開頂外称豊開名銀戸院。政稿調励廃演手生告題営味董演何南峰貨。学横公得行提大品回猿齢利込家前役把煎。天代者内身慢作業署間地日。
|
||||
|
||||
中個興本広坂態掲神中能等無滞長対。号処月画界意気様党目購栃欠歌暮。一耳供意盛四俊健必財下画例本判著堺要北王。宮大攻人水一備治首闘振円分建前趣校。目少供午見掲岡安画入情薦続土世始。診読格七久改急目斉実配正。性止月模多様更社発掲雪奇芸量全兵経負。予転済反問止下生買再無旅的。模治明以共会必華浅知館版領送。
|
||||
`,
|
||||
"korean": `
|
||||
세계 인권 선언(世界人權宣言, 영어: Universal Declaration of Human Rights, UDHR)은 1948년 12월 10일 파리에서 열린 제3회 유엔 총회에서 채택된 인권에 관한 세계 선언문이다.[1] 2차 세계대전 전후로 전 세계에 만연하였던 인권침해 사태에 대한 인류의 반성을 촉구하고, 모든 인간의 기본적 권리를 존중해야 한다는 유엔 헌장의 취지를 구체화 하였다.[2] 시민적, 정치적 권리가 중심이지만 노동자의 단결권, 교육에 관한 권리, 예술을 향유할 권리 등 경제적, 사회적, 문화적 권리에 대하여서도 규정하고 있다.[1]
|
||||
초안은 1946년 존 험프리가 작성하였다.[3] 인권선언문은 전문과 본문의 30개 조에 개인의 기본적인 자유와 함께 노동권적 권리, 생존권적 권리를 오늘날의 진보적인 국가의 헌법에서 규정하는 인권보장과 같이 자세히 규정하고 있다.[4] 프랑스 파리 샤요 궁(Palais de Chaillot)에서 열린 3번째 회의에서 당시 국제연합 가입국 58개 국가 중 48개 국가가 찬성하여 유엔 총회 결의 217 A (III)로 승인되었다.
|
||||
초안은 1946년 존 험프리가 작성하였다.[3] 인권선언문은 전문과 본문의 30개 조에 개인의 기본적인 자유와 함께 노동권적 권리, 생존권적 권리를 오늘날의 진보적인 국가의 헌법에서 규정하는 인권보장과 같이 자세히 규정하고 있다.[4] 프랑스 파리 샤요 궁(Palais de Chaillot)에서 열린 3번째 회의에서 당시 국제연합 가입국 58개 국가 중 48개 국가가 찬성하여 유엔 총회 결의 217 A (III)로 승인되었다.
|
||||
초안은 1946년 존 험프리가 작성하였다.[3] 인권선언문은 전문과 본문의 30개 조에 개인의 기본적인 자유와 함께 노동권적 권리, 생존권적 권리를 오늘날의 진보적인 국가의 헌법에서 규정하는 인권보장과 같이 자세히 규정하고 있다.[4] 프랑스 파리 샤요 궁(Palais de Chaillot)에서 열린 3번째 회의에서 당시 국제연합 가입국 58개 국가 중 48개 국가가 찬성하여 유엔 총회 결의 217 A (III)로 승인되었다.
|
||||
초안은 1946년 존 험프리가 작성하였다.[3] 인권선언문은 전문과 본문의 30개 조에 개인의 기본적인 자유와 함께 노동권적 권리, 생존권적 권리를 오늘날의 진보적인 국가의 헌법에서 규정하는 인권보장과 같이 자세히 규정하고 있다.[4] 프랑스 파리 샤요 궁(Palais de Chaillot)에서 열린 3번째 회의에서 당시 국제연합 가입국 58개 국가 중 48개 국가가 찬성하여 유엔 총회 결의 217 A (III)로 승인되었다.
|
||||
초안은 1946년 존 험프리가 작성하였다.[3] 인권선언문은 전문과 본문의 30개 조에 개인의 기본적인 자유와 함께 노동권적 권리, 생존권적 권리를 오늘날의 진보적인 국가의 헌법에서 규정하는 인권보장과 같이 자세히 규정하고 있다.[4] 프랑스 파리 샤요 궁(Palais de Chaillot)에서 열린 3번째 회의에서 당시 국제연합 가입국 58개 국가 중 48개 국가가 찬성하여 유엔 총회 결의 217 A (III)로 승인되었다.
|
||||
초안은 1946년 존 험프리가 작성하였다.[3] 인권선언문은 전문과 본문의 30개 조에 개인의 기본적인 자유와 함께 노동권적 권리, 생존권적 권리를 오늘날의 진보적인 국가의 헌법에서 규정하는 인권보장과 같이 자세히 규정하고 있다.[4] 프랑스 파리 샤요 궁(Palais de Chaillot)에서 열린 3번째 회의에서 당시 국제연합 가입국 58개 국가 중 48개 국가가 찬성하여 유엔 총회 결의 217 A (III)로 승인되었다.
|
||||
초안은 1946년 존 험프리가 작성하였다.[3] 인권선언문은 전문과 본문의 30개 조에 개인의 기본적인 자유와 함께 노동권적 권리, 생존권적 권리를 오늘날의 진보적인 국가의 헌법에서 규정하는 인권보장과 같이 자세히 규정하고 있다.[4] 프랑스 파리 샤요 궁(Palais de Chaillot)에서 열린 3번째 회의에서 당시 국제연합 가입국 58개 국가 중 48개 국가가 찬성하여 유엔 총회 결의 217 A (III)로 승인되었다.
|
||||
초안은 1946년 존 험프리가 작성하였다.[3] 인권선언문은 전문과 본문의 30개 조에 개인의 기본적인 자유와 함께 노동권적 권리, 생존권적 권리를 오늘날의 진보적인 국가의 헌법에서 규정하는 인권보장과 같이 자세히 규정하고 있다.[4] 프랑스 파리 샤요 궁(Palais de Chaillot)에서 열린 3번째 회의에서 당시 국제연합 가입국 58개 국가 중 48개 국가가 찬성하여 유엔 총회 결의 217 A (III)로 승인되었다.
|
||||
초안은 1946년 존 험프리가 작성하였다.[3] 인권선언문은 전문과 본문의 30개 조에 개인의 기본적인 자유와 함께 노동권적 권리, 생존권적 권리를 오늘날의 진보적인 국가의 헌법에서 규정하는 인권보장과 같이 자세히 규정하고 있다.[4] 프랑스 파리 샤요 궁(Palais de Chaillot)에서 열린 3번째 회의에서 당시 국제연합 가입국 58개 국가 중 48개 국가가 찬성하여 유엔 총회 결의 217 A (III)로 승인되었다.
|
||||
`,
|
||||
}
|
||||
|
||||
func TestEstimateReadingTime(t *testing.T) {
|
||||
expected := map[string]int{
|
||||
"shortenglish": 1,
|
||||
"shortchinese": 1,
|
||||
"english": 2,
|
||||
"chinese": 2,
|
||||
"korean": 5,
|
||||
}
|
||||
|
||||
for language, sample := range samples {
|
||||
got := EstimateReadingTime(sample, 200, 500)
|
||||
want := expected[language]
|
||||
if got != want {
|
||||
t.Errorf(`Wrong reading time, got %d instead of %d for %s`, got, want, language)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateReadingTimeInChinese(t *testing.T) {
|
||||
sampleText := `
|
||||
労問委格名町違載式新青脂通由。割止書円画民京般著治登門画拡下。有国同観教田美森素説砂者徴多。上治速相支存色分繰年活元事集遣逆山。身消年森発世財間世変悲原記潟旅好手真今。現通浪口特愛始信川節身方一表著購。郁不使権草定内防並要更一条露加。載交源図訴際属年券重供健三洗。事北残却女鮎朝分要廷込宣政愛無投事。
|
||||
|
||||
問警技亮参沼洗請米物模人。誰探重午局新戦報投性病庭。典向載問千著書故表視新権最石車音端乏大。白僚三掲局係仕表広無旧見要最裁。額寄済生年余講前本次載隊劇。権成観始応泉早高拓了経地本稼室目犯井出。暮載必広傷内校岡公南散広転行別釈。康運行関本掲隠泉傷退報告。独変年換差取予口男旅挑講禁姿。出芳工類胸管払時済潟髪内豊。
|
||||
|
||||
康浴部問玲玉追球化就店岡問画路投。施先太業阪能敏所陸不供探掲方用。手右演社援発示竹育対橋除際愛功旬転好使公。利時改本項輸属嘆員複携者地剤。天政朝戸祝言月接住世黙極者議編連。囲淑覧重弾必治物健賄開頂外称豊開名銀戸院。政稿調励廃演手生告題営味董演何南峰貨。学横公得行提大品回猿齢利込家前役把煎。天代者内身慢作業署間地日。
|
||||
|
||||
中個興本広坂態掲神中能等無滞長対。号処月画界意気様党目購栃欠歌暮。一耳供意盛四俊健必財下画例本判著堺要北王。宮大攻人水一備治首闘振円分建前趣校。目少供午見掲岡安画入情薦続土世始。診読格七久改急目斉実配正。性止月模多様更社発掲雪奇芸量全兵経負。予転済反問止下生買再無旅的。模治明以共会必華浅知館版領送。
|
||||
`
|
||||
|
||||
readingTime := EstimateReadingTime(sampleText, 200, 500)
|
||||
if readingTime != 2 {
|
||||
t.Errorf(`Wrong reading time, got %d instead of 2`, readingTime)
|
||||
func BenchmarkEstimateReadingTime(b *testing.B) {
|
||||
for range b.N {
|
||||
for _, sample := range samples {
|
||||
EstimateReadingTime(sample, 200, 500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,9 +20,9 @@ import (
|
|||
)
|
||||
|
||||
var (
|
||||
youtubeRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)`)
|
||||
youtubeRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)$`)
|
||||
youtubeIdRegex = regexp.MustCompile(`youtube_id"?\s*[:=]\s*"([a-zA-Z0-9_-]{11})"`)
|
||||
invidioRegex = regexp.MustCompile(`https?:\/\/(.*)\/watch\?v=(.*)`)
|
||||
invidioRegex = regexp.MustCompile(`https?://(.*)/watch\?v=(.*)`)
|
||||
imgRegex = regexp.MustCompile(`<img [^>]+>`)
|
||||
textLinkRegex = regexp.MustCompile(`(?mi)(\bhttps?:\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])`)
|
||||
)
|
||||
|
@ -292,7 +292,7 @@ func addInvidiousVideo(entryURL, entryContent string) string {
|
|||
|
||||
func addPDFLink(entryURL, entryContent string) string {
|
||||
if strings.HasSuffix(entryURL, ".pdf") {
|
||||
return fmt.Sprintf(`<a href="%s">PDF</a><br>%s`, entryURL, entryContent)
|
||||
return fmt.Sprintf(`<a href=%q>PDF</a><br>%s`, entryURL, entryContent)
|
||||
}
|
||||
return entryContent
|
||||
}
|
||||
|
@ -302,7 +302,7 @@ func replaceTextLinks(input string) string {
|
|||
}
|
||||
|
||||
func replaceLineFeeds(input string) string {
|
||||
return strings.Replace(input, "\n", "<br>", -1)
|
||||
return strings.ReplaceAll(input, "\n", "<br>")
|
||||
}
|
||||
|
||||
func replaceCustom(entryContent string, searchTerm string, replaceTerm string) string {
|
||||
|
|
|
@ -671,3 +671,35 @@ func TestAddHackerNewsLinksUsingOpener(t *testing.T) {
|
|||
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddImageTitle(t *testing.T) {
|
||||
testEntry := &model.Entry{
|
||||
Title: `A title`,
|
||||
Content: `
|
||||
<img src="pif" title="pouf">
|
||||
<img src="pif" title="pouf" alt='"onerror=alert(1) a="'>
|
||||
<img src="pif" title="pouf" alt='"onerror=alert(1) a="'>
|
||||
<img src="pif" title="pouf" alt=';&quot;onerror=alert(1) a=;&quot;'>
|
||||
<img src="pif" alt="pouf" title='"onerror=alert(1) a="'>
|
||||
<img src="pif" alt="pouf" title='"onerror=alert(1) a="'>
|
||||
<img src="pif" alt="pouf" title=';&quot;onerror=alert(1) a=;&quot;'>
|
||||
`,
|
||||
}
|
||||
|
||||
controlEntry := &model.Entry{
|
||||
Title: `A title`,
|
||||
Content: `<figure><img src="pif" alt=""/><figcaption><p>pouf</p></figcaption></figure>
|
||||
<figure><img src="pif" alt="" onerror="alert(1)" a=""/><figcaption><p>pouf</p></figcaption></figure>
|
||||
<figure><img src="pif" alt="" onerror="alert(1)" a=""/><figcaption><p>pouf</p></figcaption></figure>
|
||||
<figure><img src="pif" alt=";"onerror=alert(1) a=;""/><figcaption><p>pouf</p></figcaption></figure>
|
||||
<figure><img src="pif" alt="pouf"/><figcaption><p>"onerror=alert(1) a="</p></figcaption></figure>
|
||||
<figure><img src="pif" alt="pouf"/><figcaption><p>"onerror=alert(1) a="</p></figcaption></figure>
|
||||
<figure><img src="pif" alt="pouf"/><figcaption><p>;&quot;onerror=alert(1) a=;&quot;</p></figcaption></figure>
|
||||
`,
|
||||
}
|
||||
Rewriter("https://example.org/article", testEntry, `add_image_title`)
|
||||
|
||||
if !reflect.DeepEqual(testEntry, controlEntry) {
|
||||
t.Errorf(`Not expected output: got "%+v" instead of "%+v"`, testEntry, controlEntry)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package rss // import "miniflux.app/v2/internal/reader/rss"
|
||||
|
||||
import "strings"
|
||||
|
||||
type AtomAuthor struct {
|
||||
Author AtomPerson `xml:"http://www.w3.org/2005/Atom author"`
|
||||
}
|
||||
|
||||
func (a *AtomAuthor) String() string {
|
||||
return a.Author.String()
|
||||
}
|
||||
|
||||
type AtomPerson struct {
|
||||
Name string `xml:"name"`
|
||||
Email string `xml:"email"`
|
||||
}
|
||||
|
||||
func (a *AtomPerson) String() string {
|
||||
var name string
|
||||
|
||||
switch {
|
||||
case a.Name != "":
|
||||
name = a.Name
|
||||
case a.Email != "":
|
||||
name = a.Email
|
||||
}
|
||||
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
|
||||
type AtomLink struct {
|
||||
URL string `xml:"href,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
Rel string `xml:"rel,attr"`
|
||||
Length string `xml:"length,attr"`
|
||||
}
|
||||
|
||||
type AtomLinks struct {
|
||||
Links []*AtomLink `xml:"http://www.w3.org/2005/Atom link"`
|
||||
}
|
|
@ -12,9 +12,11 @@ import (
|
|||
)
|
||||
|
||||
// Parse returns a normalized feed struct from a RSS feed.
|
||||
func Parse(baseURL string, data io.Reader) (*model.Feed, error) {
|
||||
func Parse(baseURL string, data io.ReadSeeker) (*model.Feed, error) {
|
||||
feed := new(rssFeed)
|
||||
if err := xml.NewXMLDecoder(data).Decode(feed); err != nil {
|
||||
decoder := xml.NewXMLDecoder(data)
|
||||
decoder.DefaultSpace = "rss"
|
||||
if err := decoder.Decode(feed); err != nil {
|
||||
return nil, fmt.Errorf("rss: unable to parse feed: %w", err)
|
||||
}
|
||||
return feed.Transform(baseURL), nil
|
||||
|
|
|
@ -58,7 +58,7 @@ func TestParseRss2Sample(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("http://liftoff.msfc.nasa.gov/rss.xml", bytes.NewBufferString(data))
|
||||
feed, err := Parse("http://liftoff.msfc.nasa.gov/rss.xml", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -117,7 +117,7 @@ func TestParseFeedWithoutTitle(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -138,7 +138,7 @@ func TestParseEntryWithoutTitleAndDescription(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -162,7 +162,7 @@ func TestParseEntryWithoutTitleButWithDescription(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -185,7 +185,7 @@ func TestParseEntryWithMediaTitle(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -207,7 +207,7 @@ func TestParseEntryWithDCTitleOnly(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -228,7 +228,7 @@ func TestParseEntryWithoutLink(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -256,7 +256,7 @@ func TestParseEntryWithOnlyGuidPermalink(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -282,7 +282,7 @@ func TestParseEntryWithAtomLink(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -300,12 +300,12 @@ func TestParseEntryWithMultipleAtomLinks(t *testing.T) {
|
|||
<item>
|
||||
<title>Test</title>
|
||||
<atom:link rel="payment" href="https://example.org/a" />
|
||||
<atom:link rel="http://foobar.tld" href="https://example.org/b" />
|
||||
<atom:link rel="alternate" href="https://example.org/b" />
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -325,7 +325,7 @@ func TestParseFeedURLWithAtomLink(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -353,7 +353,7 @@ func TestParseFeedWithWebmaster(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -380,7 +380,7 @@ func TestParseFeedWithManagingEditor(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -407,7 +407,7 @@ func TestParseEntryWithAuthorAndInnerHTML(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -430,13 +430,13 @@ func TestParseEntryWithAuthorAndCDATA(t *testing.T) {
|
|||
<title>Test</title>
|
||||
<link>https://example.org/item</link>
|
||||
<author>
|
||||
by <![CDATA[Foo Bar]]>
|
||||
<![CDATA[by Foo Bar]]>
|
||||
</author>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -447,38 +447,6 @@ func TestParseEntryWithAuthorAndCDATA(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<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 xmlns:author="http://www.w3.org/2005/Atom">
|
||||
<name>Foo Bar</name>
|
||||
<title>Vice President</title>
|
||||
<department/>
|
||||
<company>FooBar Inc.</company>
|
||||
</author>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := "Foo Bar"
|
||||
result := feed.Entries[0].Author
|
||||
if result != expected {
|
||||
t.Errorf("Incorrect entry author, got %q instead of %q", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntryWithAtomAuthorEmail(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
|
||||
|
@ -496,7 +464,7 @@ func TestParseEntryWithAtomAuthorEmail(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -508,7 +476,7 @@ func TestParseEntryWithAtomAuthorEmail(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestParseEntryWithAtomAuthor(t *testing.T) {
|
||||
func TestParseEntryWithAtomAuthorName(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
|
||||
<channel>
|
||||
|
@ -525,7 +493,7 @@ func TestParseEntryWithAtomAuthor(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -551,7 +519,7 @@ func TestParseEntryWithDublinCoreAuthor(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -577,7 +545,7 @@ func TestParseEntryWithItunesAuthor(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -603,7 +571,7 @@ func TestParseFeedWithItunesAuthor(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -632,7 +600,7 @@ func TestParseFeedWithItunesOwner(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -660,7 +628,7 @@ func TestParseFeedWithItunesOwnerEmail(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -686,7 +654,7 @@ func TestParseEntryWithGooglePlayAuthor(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -712,7 +680,7 @@ func TestParseFeedWithGooglePlayAuthor(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -740,7 +708,7 @@ func TestParseEntryWithDublinCoreDate(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -768,7 +736,7 @@ func TestParseEntryWithContentEncoded(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -792,7 +760,7 @@ func TestParseEntryWithFeedBurnerLink(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -818,7 +786,7 @@ func TestParseEntryTitleWithWhitespaces(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -848,7 +816,7 @@ func TestParseEntryWithEnclosures(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -896,7 +864,7 @@ func TestParseEntryWithEmptyEnclosureURL(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -933,7 +901,7 @@ func TestParseEntryWithFeedBurnerEnclosures(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -974,7 +942,7 @@ func TestParseEntryWithRelativeURL(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1000,7 +968,7 @@ func TestParseEntryWithCommentsURL(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1025,7 +993,7 @@ func TestParseEntryWithInvalidCommentsURL(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1037,7 +1005,7 @@ func TestParseEntryWithInvalidCommentsURL(t *testing.T) {
|
|||
|
||||
func TestParseInvalidXml(t *testing.T) {
|
||||
data := `garbage`
|
||||
_, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
_, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err == nil {
|
||||
t.Error("Parse should returns an error")
|
||||
}
|
||||
|
@ -1052,7 +1020,7 @@ func TestParseFeedTitleWithHTMLEntity(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1071,7 +1039,7 @@ func TestParseFeedTitleWithUnicodeEntityAndCdata(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1094,7 +1062,7 @@ func TestParseItemTitleWithHTMLEntity(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1117,7 +1085,7 @@ func TestParseItemTitleWithNumericCharacterReference(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1140,7 +1108,7 @@ func TestParseItemTitleWithDoubleEncodedEntities(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1159,7 +1127,7 @@ func TestParseFeedLinkWithInvalidCharacterEntity(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1193,7 +1161,7 @@ func TestParseEntryWithMediaGroup(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1251,7 +1219,7 @@ func TestParseEntryWithMediaContent(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1302,7 +1270,7 @@ func TestParseEntryWithMediaPeerLink(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1354,7 +1322,7 @@ func TestEntryDescriptionFromItunesSummary(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1385,7 +1353,7 @@ func TestEntryDescriptionFromItunesSubtitle(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1419,7 +1387,7 @@ func TestEntryDescriptionFromGooglePlayDescription(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1435,23 +1403,53 @@ func TestEntryDescriptionFromGooglePlayDescription(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestParseEntryWithCategoryAndInnerHTML(t *testing.T) {
|
||||
func TestParseEntryWithRSSDescriptionAndMediaDescription(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/">
|
||||
<channel>
|
||||
<title>Podcast Example</title>
|
||||
<link>http://www.example.com/index.html</link>
|
||||
<item>
|
||||
<title>Entry Title</title>
|
||||
<link>http://www.example.com/entries/1</link>
|
||||
<description>Entry Description</description>
|
||||
<media:description type="plain">Media Description</media:description>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(feed.Entries) != 1 {
|
||||
t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
|
||||
}
|
||||
|
||||
expected := "Entry Description"
|
||||
result := feed.Entries[0].Content
|
||||
if expected != result {
|
||||
t.Errorf(`Unexpected description, got %q instead of %q`, result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFeedWithCategories(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>
|
||||
<category>Category 1</category>
|
||||
<category><![CDATA[Category 2]]></category>
|
||||
<item>
|
||||
<title>Test</title>
|
||||
<link>https://example.org/item</link>
|
||||
<category>Category 1</category>
|
||||
<category>Category 2</category>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1460,32 +1458,104 @@ func TestParseEntryWithCategoryAndInnerHTML(t *testing.T) {
|
|||
t.Errorf("Incorrect number of tags, got: %d", len(feed.Entries[0].Tags))
|
||||
}
|
||||
|
||||
expected := "Category 2"
|
||||
result := feed.Entries[0].Tags[1]
|
||||
if result != expected {
|
||||
t.Errorf("Incorrect entry category, got %q instead of %q", result, expected)
|
||||
expected := []string{"Category 1", "Category 2"}
|
||||
result := feed.Entries[0].Tags
|
||||
|
||||
for i, tag := range result {
|
||||
if tag != expected[i] {
|
||||
t.Errorf("Incorrect tag, got: %q", tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntryWithCategoryAndCDATA(t *testing.T) {
|
||||
func TestParseEntryWithCategories(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>
|
||||
<category>Category 3</category>
|
||||
<item>
|
||||
<title>Test</title>
|
||||
<link>https://example.org/item</link>
|
||||
<author>
|
||||
by <![CDATA[Foo Bar]]>
|
||||
</author>
|
||||
<category>Sample Category</category>
|
||||
<category>Category 1</category>
|
||||
<category><![CDATA[Category 2]]></category>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(feed.Entries[0].Tags) != 3 {
|
||||
t.Errorf("Incorrect number of tags, got: %d", len(feed.Entries[0].Tags))
|
||||
}
|
||||
|
||||
expected := []string{"Category 1", "Category 2", "Category 3"}
|
||||
result := feed.Entries[0].Tags
|
||||
|
||||
for i, tag := range result {
|
||||
if tag != expected[i] {
|
||||
t.Errorf("Incorrect tag, got: %q", tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFeedWithItunesCategories(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss xmlns:atom="http://www.w3.org/2005/Atom" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" version="2.0">
|
||||
<channel>
|
||||
<title>Example</title>
|
||||
<link>https://example.org/</link>
|
||||
<itunes:category text="Society & Culture">
|
||||
<itunes:category text="Documentary" />
|
||||
</itunes:category>
|
||||
<itunes:category text="Health">
|
||||
<itunes:category text="Mental Health" />
|
||||
</itunes:category>
|
||||
<item>
|
||||
<title>Test</title>
|
||||
<link>https://example.org/item</link>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(feed.Entries[0].Tags) != 4 {
|
||||
t.Errorf("Incorrect number of tags, got: %d", len(feed.Entries[0].Tags))
|
||||
}
|
||||
|
||||
expected := []string{"Society & Culture", "Documentary", "Health", "Mental Health"}
|
||||
result := feed.Entries[0].Tags
|
||||
|
||||
for i, tag := range result {
|
||||
if tag != expected[i] {
|
||||
t.Errorf("Incorrect tag, got: %q", tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFeedWithGooglePlayCategory(t *testing.T) {
|
||||
data := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss xmlns:atom="http://www.w3.org/2005/Atom" xmlns:gplay="http://www.google.com/schemas/play-podcasts/1.0" version="2.0">
|
||||
<channel>
|
||||
<title>Example</title>
|
||||
<link>https://example.org/</link>
|
||||
<gplay:category text="Art"></gplay:category>
|
||||
<item>
|
||||
<title>Test</title>
|
||||
<link>https://example.org/item</link>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1494,10 +1564,13 @@ func TestParseEntryWithCategoryAndCDATA(t *testing.T) {
|
|||
t.Errorf("Incorrect number of tags, got: %d", len(feed.Entries[0].Tags))
|
||||
}
|
||||
|
||||
expected := "Sample Category"
|
||||
result := feed.Entries[0].Tags[0]
|
||||
if result != expected {
|
||||
t.Errorf("Incorrect entry category, got %q instead of %q", result, expected)
|
||||
expected := []string{"Art"}
|
||||
result := feed.Entries[0].Tags
|
||||
|
||||
for i, tag := range result {
|
||||
if tag != expected[i] {
|
||||
t.Errorf("Incorrect tag, got: %q", tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1515,7 +1588,7 @@ func TestParseFeedWithTTLField(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
@ -1539,7 +1612,7 @@ func TestParseFeedWithIncorrectTTLValue(t *testing.T) {
|
|||
</channel>
|
||||
</rss>`
|
||||
|
||||
feed, err := Parse("https://example.org/", bytes.NewBufferString(data))
|
||||
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
|
@ -12,70 +12,6 @@ import (
|
|||
|
||||
var ErrInvalidDurationFormat = errors.New("rss: invalid duration format")
|
||||
|
||||
// PodcastFeedElement represents iTunes and GooglePlay feed XML elements.
|
||||
// Specs:
|
||||
// - https://github.com/simplepie/simplepie-ng/wiki/Spec:-iTunes-Podcast-RSS
|
||||
// - https://developers.google.com/search/reference/podcast/rss-feed
|
||||
type PodcastFeedElement struct {
|
||||
ItunesAuthor string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd channel>author"`
|
||||
Subtitle string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd channel>subtitle"`
|
||||
Summary string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd channel>summary"`
|
||||
PodcastOwner PodcastOwner `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd channel>owner"`
|
||||
GooglePlayAuthor string `xml:"http://www.google.com/schemas/play-podcasts/1.0 channel>author"`
|
||||
}
|
||||
|
||||
// PodcastEntryElement represents iTunes and GooglePlay entry XML elements.
|
||||
type PodcastEntryElement struct {
|
||||
Subtitle string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd subtitle"`
|
||||
Summary string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd summary"`
|
||||
Duration string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd duration"`
|
||||
GooglePlayDescription string `xml:"http://www.google.com/schemas/play-podcasts/1.0 description"`
|
||||
}
|
||||
|
||||
// PodcastOwner represents contact information for the podcast owner.
|
||||
type PodcastOwner struct {
|
||||
Name string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd name"`
|
||||
Email string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd email"`
|
||||
}
|
||||
|
||||
// Image represents podcast artwork.
|
||||
type Image struct {
|
||||
URL string `xml:"href,attr"`
|
||||
}
|
||||
|
||||
// PodcastAuthor returns the author of the podcast.
|
||||
func (e *PodcastFeedElement) PodcastAuthor() string {
|
||||
author := ""
|
||||
|
||||
switch {
|
||||
case e.ItunesAuthor != "":
|
||||
author = e.ItunesAuthor
|
||||
case e.GooglePlayAuthor != "":
|
||||
author = e.GooglePlayAuthor
|
||||
case e.PodcastOwner.Name != "":
|
||||
author = e.PodcastOwner.Name
|
||||
case e.PodcastOwner.Email != "":
|
||||
author = e.PodcastOwner.Email
|
||||
}
|
||||
|
||||
return strings.TrimSpace(author)
|
||||
}
|
||||
|
||||
// PodcastDescription returns the description of the podcast.
|
||||
func (e *PodcastEntryElement) PodcastDescription() string {
|
||||
description := ""
|
||||
|
||||
switch {
|
||||
case e.GooglePlayDescription != "":
|
||||
description = e.GooglePlayDescription
|
||||
case e.Summary != "":
|
||||
description = e.Summary
|
||||
case e.Subtitle != "":
|
||||
description = e.Subtitle
|
||||
}
|
||||
return strings.TrimSpace(description)
|
||||
}
|
||||
|
||||
// normalizeDuration returns the duration tag value as a number of minutes
|
||||
func normalizeDuration(rawDuration string) (int, error) {
|
||||
var sumSeconds int
|
||||
|
|
|
@ -16,26 +16,35 @@ import (
|
|||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/reader/date"
|
||||
"miniflux.app/v2/internal/reader/dublincore"
|
||||
"miniflux.app/v2/internal/reader/googleplay"
|
||||
"miniflux.app/v2/internal/reader/itunes"
|
||||
"miniflux.app/v2/internal/reader/media"
|
||||
"miniflux.app/v2/internal/reader/sanitizer"
|
||||
"miniflux.app/v2/internal/urllib"
|
||||
)
|
||||
|
||||
// Specs: https://cyber.harvard.edu/rss/rss.html
|
||||
// Specs: https://www.rssboard.org/rss-specification
|
||||
type rssFeed struct {
|
||||
XMLName xml.Name `xml:"rss"`
|
||||
Version string `xml:"version,attr"`
|
||||
Title string `xml:"channel>title"`
|
||||
Links []rssLink `xml:"channel>link"`
|
||||
ImageURL string `xml:"channel>image>url"`
|
||||
Language string `xml:"channel>language"`
|
||||
Description string `xml:"channel>description"`
|
||||
PubDate string `xml:"channel>pubDate"`
|
||||
ManagingEditor string `xml:"channel>managingEditor"`
|
||||
Webmaster string `xml:"channel>webMaster"`
|
||||
TimeToLive rssTTL `xml:"channel>ttl"`
|
||||
Items []rssItem `xml:"channel>item"`
|
||||
PodcastFeedElement
|
||||
XMLName xml.Name `xml:"rss"`
|
||||
Version string `xml:"rss version,attr"`
|
||||
Channel rssChannel `xml:"rss channel"`
|
||||
}
|
||||
|
||||
type rssChannel struct {
|
||||
Categories []string `xml:"rss category"`
|
||||
Title string `xml:"rss title"`
|
||||
Link string `xml:"rss link"`
|
||||
ImageURL string `xml:"rss image>url"`
|
||||
Language string `xml:"rss language"`
|
||||
Description string `xml:"rss description"`
|
||||
PubDate string `xml:"rss pubDate"`
|
||||
ManagingEditor string `xml:"rss managingEditor"`
|
||||
Webmaster string `xml:"rss webMaster"`
|
||||
TimeToLive rssTTL `xml:"rss ttl"`
|
||||
Items []rssItem `xml:"rss item"`
|
||||
AtomLinks
|
||||
itunes.ItunesFeedElement
|
||||
googleplay.GooglePlayFeedElement
|
||||
}
|
||||
|
||||
type rssTTL struct {
|
||||
|
@ -72,15 +81,15 @@ func (r *rssFeed) Transform(baseURL string) *model.Feed {
|
|||
feed.FeedURL = feedURL
|
||||
}
|
||||
|
||||
feed.Title = html.UnescapeString(strings.TrimSpace(r.Title))
|
||||
feed.Title = html.UnescapeString(strings.TrimSpace(r.Channel.Title))
|
||||
if feed.Title == "" {
|
||||
feed.Title = feed.SiteURL
|
||||
}
|
||||
|
||||
feed.IconURL = strings.TrimSpace(r.ImageURL)
|
||||
feed.TTL = r.TimeToLive.Value()
|
||||
feed.IconURL = strings.TrimSpace(r.Channel.ImageURL)
|
||||
feed.TTL = r.Channel.TimeToLive.Value()
|
||||
|
||||
for _, item := range r.Items {
|
||||
for _, item := range r.Channel.Items {
|
||||
entry := item.Transform()
|
||||
if entry.Author == "" {
|
||||
entry.Author = r.feedAuthor()
|
||||
|
@ -103,6 +112,13 @@ func (r *rssFeed) Transform(baseURL string) *model.Feed {
|
|||
entry.Title = entry.URL
|
||||
}
|
||||
|
||||
entry.Tags = append(entry.Tags, r.Channel.Categories...)
|
||||
entry.Tags = append(entry.Tags, r.Channel.GetItunesCategories()...)
|
||||
|
||||
if r.Channel.GooglePlayCategory.Text != "" {
|
||||
entry.Tags = append(entry.Tags, r.Channel.GooglePlayCategory.Text)
|
||||
}
|
||||
|
||||
feed.Entries = append(feed.Entries, entry)
|
||||
}
|
||||
|
||||
|
@ -110,32 +126,31 @@ func (r *rssFeed) Transform(baseURL string) *model.Feed {
|
|||
}
|
||||
|
||||
func (r *rssFeed) siteURL() string {
|
||||
for _, element := range r.Links {
|
||||
if element.XMLName.Space == "" {
|
||||
return strings.TrimSpace(element.Data)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
return strings.TrimSpace(r.Channel.Link)
|
||||
}
|
||||
|
||||
func (r *rssFeed) feedURL() string {
|
||||
for _, element := range r.Links {
|
||||
if element.XMLName.Space == "http://www.w3.org/2005/Atom" {
|
||||
return strings.TrimSpace(element.Href)
|
||||
for _, atomLink := range r.Channel.AtomLinks.Links {
|
||||
if atomLink.Rel == "self" {
|
||||
return strings.TrimSpace(atomLink.URL)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (r rssFeed) feedAuthor() string {
|
||||
author := r.PodcastAuthor()
|
||||
var author string
|
||||
switch {
|
||||
case r.ManagingEditor != "":
|
||||
author = r.ManagingEditor
|
||||
case r.Webmaster != "":
|
||||
author = r.Webmaster
|
||||
case r.Channel.ItunesAuthor != "":
|
||||
author = r.Channel.ItunesAuthor
|
||||
case r.Channel.GooglePlayAuthor != "":
|
||||
author = r.Channel.GooglePlayAuthor
|
||||
case r.Channel.ItunesOwner.String() != "":
|
||||
author = r.Channel.ItunesOwner.String()
|
||||
case r.Channel.ManagingEditor != "":
|
||||
author = r.Channel.ManagingEditor
|
||||
case r.Channel.Webmaster != "":
|
||||
author = r.Channel.Webmaster
|
||||
}
|
||||
return sanitizer.StripTags(strings.TrimSpace(author))
|
||||
}
|
||||
|
@ -146,27 +161,7 @@ type rssGUID struct {
|
|||
IsPermaLink string `xml:"isPermaLink,attr"`
|
||||
}
|
||||
|
||||
type rssLink struct {
|
||||
XMLName xml.Name
|
||||
Data string `xml:",chardata"`
|
||||
Href string `xml:"href,attr"`
|
||||
Rel string `xml:"rel,attr"`
|
||||
}
|
||||
|
||||
type rssCommentLink struct {
|
||||
XMLName xml.Name
|
||||
Data string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type rssAuthor struct {
|
||||
XMLName xml.Name
|
||||
Data string `xml:",chardata"`
|
||||
Name string `xml:"name"`
|
||||
Email string `xml:"email"`
|
||||
Inner string `xml:",innerxml"`
|
||||
}
|
||||
|
||||
type rssTitle struct {
|
||||
XMLName xml.Name
|
||||
Data string `xml:",chardata"`
|
||||
Inner string `xml:",innerxml"`
|
||||
|
@ -178,12 +173,6 @@ type rssEnclosure struct {
|
|||
Length string `xml:"length,attr"`
|
||||
}
|
||||
|
||||
type rssCategory struct {
|
||||
XMLName xml.Name
|
||||
Data string `xml:",chardata"`
|
||||
Inner string `xml:",innerxml"`
|
||||
}
|
||||
|
||||
func (enclosure *rssEnclosure) Size() int64 {
|
||||
if enclosure.Length == "" {
|
||||
return 0
|
||||
|
@ -193,19 +182,22 @@ func (enclosure *rssEnclosure) Size() int64 {
|
|||
}
|
||||
|
||||
type rssItem struct {
|
||||
GUID rssGUID `xml:"guid"`
|
||||
Title []rssTitle `xml:"title"`
|
||||
Links []rssLink `xml:"link"`
|
||||
Description string `xml:"description"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
Authors []rssAuthor `xml:"author"`
|
||||
CommentLinks []rssCommentLink `xml:"comments"`
|
||||
EnclosureLinks []rssEnclosure `xml:"enclosure"`
|
||||
Categories []rssCategory `xml:"category"`
|
||||
GUID rssGUID `xml:"rss guid"`
|
||||
Title string `xml:"rss title"`
|
||||
Link string `xml:"rss link"`
|
||||
Description string `xml:"rss description"`
|
||||
PubDate string `xml:"rss pubDate"`
|
||||
Author rssAuthor `xml:"rss author"`
|
||||
Comments string `xml:"rss comments"`
|
||||
EnclosureLinks []rssEnclosure `xml:"rss enclosure"`
|
||||
Categories []string `xml:"rss category"`
|
||||
dublincore.DublinCoreItemElement
|
||||
FeedBurnerElement
|
||||
PodcastEntryElement
|
||||
media.Element
|
||||
AtomAuthor
|
||||
AtomLinks
|
||||
itunes.ItunesItemElement
|
||||
googleplay.GooglePlayItemElement
|
||||
}
|
||||
|
||||
func (r *rssItem) Transform() *model.Entry {
|
||||
|
@ -218,8 +210,8 @@ func (r *rssItem) Transform() *model.Entry {
|
|||
entry.Content = r.entryContent()
|
||||
entry.Title = r.entryTitle()
|
||||
entry.Enclosures = r.entryEnclosures()
|
||||
entry.Tags = r.entryCategories()
|
||||
if duration, err := normalizeDuration(r.Duration); err == nil {
|
||||
entry.Tags = r.Categories
|
||||
if duration, err := normalizeDuration(r.ItunesDuration); err == nil {
|
||||
entry.ReadingTime = duration
|
||||
}
|
||||
|
||||
|
@ -250,34 +242,24 @@ func (r *rssItem) entryDate() time.Time {
|
|||
}
|
||||
|
||||
func (r *rssItem) entryAuthor() string {
|
||||
author := ""
|
||||
var author string
|
||||
|
||||
for _, rssAuthor := range r.Authors {
|
||||
switch rssAuthor.XMLName.Space {
|
||||
case "http://www.itunes.com/dtds/podcast-1.0.dtd", "http://www.google.com/schemas/play-podcasts/1.0":
|
||||
author = rssAuthor.Data
|
||||
case "http://www.w3.org/2005/Atom":
|
||||
if rssAuthor.Name != "" {
|
||||
author = rssAuthor.Name
|
||||
} else if rssAuthor.Email != "" {
|
||||
author = rssAuthor.Email
|
||||
}
|
||||
default:
|
||||
if rssAuthor.Name != "" {
|
||||
author = rssAuthor.Name
|
||||
} else if strings.Contains(rssAuthor.Inner, "<![CDATA[") {
|
||||
author = rssAuthor.Data
|
||||
} else {
|
||||
author = rssAuthor.Inner
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case r.GooglePlayAuthor != "":
|
||||
author = r.GooglePlayAuthor
|
||||
case r.ItunesAuthor != "":
|
||||
author = r.ItunesAuthor
|
||||
case r.DublinCoreCreator != "":
|
||||
author = r.DublinCoreCreator
|
||||
case r.AtomAuthor.String() != "":
|
||||
author = r.AtomAuthor.String()
|
||||
case strings.Contains(r.Author.Inner, "<![CDATA["):
|
||||
author = r.Author.Data
|
||||
default:
|
||||
author = r.Author.Inner
|
||||
}
|
||||
|
||||
if author == "" {
|
||||
author = r.GetSanitizedCreator()
|
||||
}
|
||||
|
||||
return sanitizer.StripTags(strings.TrimSpace(author))
|
||||
return strings.TrimSpace(sanitizer.StripTags(author))
|
||||
}
|
||||
|
||||
func (r *rssItem) entryHash() string {
|
||||
|
@ -291,28 +273,23 @@ func (r *rssItem) entryHash() string {
|
|||
}
|
||||
|
||||
func (r *rssItem) entryTitle() string {
|
||||
var title string
|
||||
title := r.Title
|
||||
|
||||
for _, rssTitle := range r.Title {
|
||||
switch rssTitle.XMLName.Space {
|
||||
case "http://search.yahoo.com/mrss/":
|
||||
// Ignore title in media namespace
|
||||
case "http://purl.org/dc/elements/1.1/":
|
||||
title = rssTitle.Data
|
||||
default:
|
||||
title = rssTitle.Data
|
||||
}
|
||||
|
||||
if title != "" {
|
||||
break
|
||||
}
|
||||
if r.DublinCoreTitle != "" {
|
||||
title = r.DublinCoreTitle
|
||||
}
|
||||
|
||||
return html.UnescapeString(strings.TrimSpace(title))
|
||||
}
|
||||
|
||||
func (r *rssItem) entryContent() string {
|
||||
for _, value := range []string{r.DublinCoreContent, r.Description, r.PodcastDescription()} {
|
||||
for _, value := range []string{
|
||||
r.DublinCoreContent,
|
||||
r.Description,
|
||||
r.GooglePlayDescription,
|
||||
r.ItunesSummary,
|
||||
r.ItunesSubtitle,
|
||||
} {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
|
@ -321,17 +298,15 @@ func (r *rssItem) entryContent() string {
|
|||
}
|
||||
|
||||
func (r *rssItem) entryURL() string {
|
||||
if r.FeedBurnerLink != "" {
|
||||
return r.FeedBurnerLink
|
||||
for _, link := range []string{r.FeedBurnerLink, r.Link} {
|
||||
if link != "" {
|
||||
return strings.TrimSpace(link)
|
||||
}
|
||||
}
|
||||
|
||||
for _, link := range r.Links {
|
||||
if link.XMLName.Space == "http://www.w3.org/2005/Atom" && link.Href != "" && isValidLinkRelation(link.Rel) {
|
||||
return strings.TrimSpace(link.Href)
|
||||
}
|
||||
|
||||
if link.Data != "" {
|
||||
return strings.TrimSpace(link.Data)
|
||||
for _, atomLink := range r.AtomLinks.Links {
|
||||
if atomLink.URL != "" && (strings.EqualFold(atomLink.Rel, "alternate") || atomLink.Rel == "") {
|
||||
return strings.TrimSpace(atomLink.URL)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -410,43 +385,11 @@ func (r *rssItem) entryEnclosures() model.EnclosureList {
|
|||
return enclosures
|
||||
}
|
||||
|
||||
func (r *rssItem) entryCategories() []string {
|
||||
categoryList := make([]string, 0)
|
||||
|
||||
for _, rssCategory := range r.Categories {
|
||||
if strings.Contains(rssCategory.Inner, "<![CDATA[") {
|
||||
categoryList = append(categoryList, strings.TrimSpace(rssCategory.Data))
|
||||
} else {
|
||||
categoryList = append(categoryList, strings.TrimSpace(rssCategory.Inner))
|
||||
}
|
||||
}
|
||||
|
||||
return categoryList
|
||||
}
|
||||
|
||||
func (r *rssItem) entryCommentsURL() string {
|
||||
for _, commentLink := range r.CommentLinks {
|
||||
if commentLink.XMLName.Space == "" {
|
||||
commentsURL := strings.TrimSpace(commentLink.Data)
|
||||
// The comments URL is supposed to be absolute (some feeds publishes incorrect comments URL)
|
||||
// See https://cyber.harvard.edu/rss/rss.html#ltcommentsgtSubelementOfLtitemgt
|
||||
if urllib.IsAbsoluteURL(commentsURL) {
|
||||
return commentsURL
|
||||
}
|
||||
}
|
||||
commentsURL := strings.TrimSpace(r.Comments)
|
||||
if commentsURL != "" && urllib.IsAbsoluteURL(commentsURL) {
|
||||
return commentsURL
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func isValidLinkRelation(rel string) bool {
|
||||
switch rel {
|
||||
case "", "alternate", "enclosure", "related", "self", "via":
|
||||
return true
|
||||
default:
|
||||
if strings.HasPrefix(rel, "http") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,10 +4,10 @@
|
|||
package sanitizer // import "miniflux.app/v2/internal/reader/sanitizer"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
|
@ -18,17 +18,73 @@ import (
|
|||
)
|
||||
|
||||
var (
|
||||
youtubeEmbedRegex = regexp.MustCompile(`//www\.youtube\.com/embed/(.*)`)
|
||||
youtubeEmbedRegex = regexp.MustCompile(`//(?:www\.)?youtube\.com/embed/(.+)$`)
|
||||
tagAllowList = map[string][]string{
|
||||
"a": {"href", "title", "id"},
|
||||
"abbr": {"title"},
|
||||
"acronym": {"title"},
|
||||
"audio": {"src"},
|
||||
"blockquote": {},
|
||||
"br": {},
|
||||
"caption": {},
|
||||
"cite": {},
|
||||
"code": {},
|
||||
"dd": {"id"},
|
||||
"del": {},
|
||||
"dfn": {},
|
||||
"dl": {"id"},
|
||||
"dt": {"id"},
|
||||
"em": {},
|
||||
"figcaption": {},
|
||||
"figure": {},
|
||||
"h1": {"id"},
|
||||
"h2": {"id"},
|
||||
"h3": {"id"},
|
||||
"h4": {"id"},
|
||||
"h5": {"id"},
|
||||
"h6": {"id"},
|
||||
"iframe": {"width", "height", "frameborder", "src", "allowfullscreen"},
|
||||
"img": {"alt", "title", "src", "srcset", "sizes", "width", "height"},
|
||||
"ins": {},
|
||||
"kbd": {},
|
||||
"li": {"id"},
|
||||
"ol": {"id"},
|
||||
"p": {},
|
||||
"picture": {},
|
||||
"pre": {},
|
||||
"q": {"cite"},
|
||||
"rp": {},
|
||||
"rt": {},
|
||||
"rtc": {},
|
||||
"ruby": {},
|
||||
"s": {},
|
||||
"samp": {},
|
||||
"source": {"src", "type", "srcset", "sizes", "media"},
|
||||
"strong": {},
|
||||
"sub": {},
|
||||
"sup": {"id"},
|
||||
"table": {},
|
||||
"td": {"rowspan", "colspan"},
|
||||
"tfooter": {},
|
||||
"th": {"rowspan", "colspan"},
|
||||
"thead": {},
|
||||
"time": {"datetime"},
|
||||
"tr": {},
|
||||
"ul": {"id"},
|
||||
"var": {},
|
||||
"video": {"poster", "height", "width", "src"},
|
||||
"wbr": {},
|
||||
}
|
||||
)
|
||||
|
||||
// Sanitize returns safe HTML.
|
||||
func Sanitize(baseURL, input string) string {
|
||||
var buffer bytes.Buffer
|
||||
var buffer strings.Builder
|
||||
var tagStack []string
|
||||
var parentTag string
|
||||
blacklistedTagDepth := 0
|
||||
|
||||
tokenizer := html.NewTokenizer(bytes.NewBufferString(input))
|
||||
tokenizer := html.NewTokenizer(strings.NewReader(input))
|
||||
for {
|
||||
if tokenizer.Next() == html.ErrorToken {
|
||||
err := tokenizer.Err()
|
||||
|
@ -57,7 +113,10 @@ func Sanitize(baseURL, input string) string {
|
|||
tagName := token.DataAtom.String()
|
||||
parentTag = tagName
|
||||
|
||||
if !isPixelTracker(tagName, token.Attr) && isValidTag(tagName) {
|
||||
if isPixelTracker(tagName, token.Attr) {
|
||||
continue
|
||||
}
|
||||
if isValidTag(tagName) {
|
||||
attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)
|
||||
|
||||
if hasRequiredAttributes(tagName, attrNames) {
|
||||
|
@ -74,16 +133,18 @@ func Sanitize(baseURL, input string) string {
|
|||
}
|
||||
case html.EndTagToken:
|
||||
tagName := token.DataAtom.String()
|
||||
if isValidTag(tagName) && inList(tagName, tagStack) {
|
||||
buffer.WriteString(fmt.Sprintf("</%s>", tagName))
|
||||
if isValidTag(tagName) && slices.Contains(tagStack, tagName) {
|
||||
buffer.WriteString("</" + tagName + ">")
|
||||
} else if isBlockedTag(tagName) {
|
||||
blacklistedTagDepth--
|
||||
}
|
||||
case html.SelfClosingTagToken:
|
||||
tagName := token.DataAtom.String()
|
||||
if !isPixelTracker(tagName, token.Attr) && isValidTag(tagName) {
|
||||
if isPixelTracker(tagName, token.Attr) {
|
||||
continue
|
||||
}
|
||||
if isValidTag(tagName) {
|
||||
attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)
|
||||
|
||||
if hasRequiredAttributes(tagName, attrNames) {
|
||||
if len(attrNames) > 0 {
|
||||
buffer.WriteString("<" + tagName + " " + htmlAttributes + "/>")
|
||||
|
@ -130,11 +191,10 @@ func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([
|
|||
|
||||
if isExternalResourceAttribute(attribute.Key) {
|
||||
if tagName == "iframe" {
|
||||
if isValidIframeSource(baseURL, attribute.Val) {
|
||||
value = rewriteIframeURL(attribute.Val)
|
||||
} else {
|
||||
if !isValidIframeSource(baseURL, attribute.Val) {
|
||||
continue
|
||||
}
|
||||
value = rewriteIframeURL(attribute.Val)
|
||||
} else if tagName == "img" && attribute.Key == "src" && isValidDataAttribute(attribute.Val) {
|
||||
value = attribute.Val
|
||||
} else if isAnchor("a", attribute) {
|
||||
|
@ -153,7 +213,7 @@ func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([
|
|||
}
|
||||
|
||||
attrNames = append(attrNames, attribute.Key)
|
||||
htmlAttrs = append(htmlAttrs, fmt.Sprintf(`%s="%s"`, attribute.Key, html.EscapeString(value)))
|
||||
htmlAttrs = append(htmlAttrs, fmt.Sprintf(`%s=%q`, attribute.Key, html.EscapeString(value)))
|
||||
}
|
||||
|
||||
if !isAnchorLink {
|
||||
|
@ -183,24 +243,16 @@ func getExtraAttributes(tagName string) ([]string, []string) {
|
|||
}
|
||||
|
||||
func isValidTag(tagName string) bool {
|
||||
for element := range getTagAllowList() {
|
||||
if tagName == element {
|
||||
return true
|
||||
}
|
||||
if _, ok := tagAllowList[tagName]; ok {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isValidAttribute(tagName, attributeName string) bool {
|
||||
for element, attributes := range getTagAllowList() {
|
||||
if tagName == element {
|
||||
if inList(attributeName, attributes) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if attributes, ok := tagAllowList[tagName]; ok {
|
||||
return slices.Contains(attributes, attributeName)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
@ -214,45 +266,41 @@ func isExternalResourceAttribute(attribute string) bool {
|
|||
}
|
||||
|
||||
func isPixelTracker(tagName string, attributes []html.Attribute) bool {
|
||||
if tagName == "img" {
|
||||
hasHeight := false
|
||||
hasWidth := false
|
||||
if tagName != "img" {
|
||||
return false
|
||||
}
|
||||
hasHeight := false
|
||||
hasWidth := false
|
||||
|
||||
for _, attribute := range attributes {
|
||||
if attribute.Key == "height" && attribute.Val == "1" {
|
||||
for _, attribute := range attributes {
|
||||
if attribute.Val == "1" {
|
||||
if attribute.Key == "height" {
|
||||
hasHeight = true
|
||||
}
|
||||
|
||||
if attribute.Key == "width" && attribute.Val == "1" {
|
||||
} else if attribute.Key == "width" {
|
||||
hasWidth = true
|
||||
}
|
||||
}
|
||||
|
||||
return hasHeight && hasWidth
|
||||
}
|
||||
|
||||
return false
|
||||
return hasHeight && hasWidth
|
||||
}
|
||||
|
||||
func hasRequiredAttributes(tagName string, attributes []string) bool {
|
||||
elements := make(map[string][]string)
|
||||
elements["a"] = []string{"href"}
|
||||
elements["iframe"] = []string{"src"}
|
||||
elements["img"] = []string{"src"}
|
||||
elements["source"] = []string{"src", "srcset"}
|
||||
elements := map[string][]string{
|
||||
"a": {"href"},
|
||||
"iframe": {"src"},
|
||||
"img": {"src"},
|
||||
"source": {"src", "srcset"},
|
||||
}
|
||||
|
||||
for element, attrs := range elements {
|
||||
if tagName == element {
|
||||
for _, attribute := range attributes {
|
||||
for _, attr := range attrs {
|
||||
if attr == attribute {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if attrs, ok := elements[tagName]; ok {
|
||||
for _, attribute := range attributes {
|
||||
if slices.Contains(attrs, attribute) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
|
@ -303,13 +351,9 @@ func hasValidURIScheme(src string) bool {
|
|||
"hack://", // https://apps.apple.com/it/app/hack-for-hacker-news-reader/id1464477788?l=en-GB
|
||||
}
|
||||
|
||||
for _, prefix := range whitelist {
|
||||
if strings.HasPrefix(src, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return slices.ContainsFunc(whitelist, func(prefix string) bool {
|
||||
return strings.HasPrefix(src, prefix)
|
||||
})
|
||||
}
|
||||
|
||||
func isBlockedResource(src string) bool {
|
||||
|
@ -322,125 +366,38 @@ func isBlockedResource(src string) bool {
|
|||
"feeds.feedburner.com",
|
||||
}
|
||||
|
||||
for _, element := range blacklist {
|
||||
if strings.Contains(src, element) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return slices.ContainsFunc(blacklist, func(element string) bool {
|
||||
return strings.Contains(src, element)
|
||||
})
|
||||
}
|
||||
|
||||
func isValidIframeSource(baseURL, src string) bool {
|
||||
whitelist := []string{
|
||||
"//www.youtube.com",
|
||||
"http://www.youtube.com",
|
||||
"https://www.youtube.com",
|
||||
"https://www.youtube-nocookie.com",
|
||||
"http://player.vimeo.com",
|
||||
"https://player.vimeo.com",
|
||||
"http://www.dailymotion.com",
|
||||
"https://www.dailymotion.com",
|
||||
"http://vk.com",
|
||||
"https://vk.com",
|
||||
"http://soundcloud.com",
|
||||
"https://soundcloud.com",
|
||||
"http://w.soundcloud.com",
|
||||
"https://w.soundcloud.com",
|
||||
"http://bandcamp.com",
|
||||
"https://bandcamp.com",
|
||||
"https://cdn.embedly.com",
|
||||
"https://player.bilibili.com",
|
||||
"https://player.twitch.tv",
|
||||
"bandcamp.com",
|
||||
"cdn.embedly.com",
|
||||
"player.bilibili.com",
|
||||
"player.twitch.tv",
|
||||
"player.vimeo.com",
|
||||
"soundcloud.com",
|
||||
"vk.com",
|
||||
"w.soundcloud.com",
|
||||
"dailymotion.com",
|
||||
"youtube-nocookie.com",
|
||||
"youtube.com",
|
||||
}
|
||||
domain := urllib.Domain(src)
|
||||
|
||||
// allow iframe from same origin
|
||||
if urllib.Domain(baseURL) == urllib.Domain(src) {
|
||||
if urllib.Domain(baseURL) == domain {
|
||||
return true
|
||||
}
|
||||
|
||||
// allow iframe from custom invidious instance
|
||||
if config.Opts != nil && config.Opts.InvidiousInstance() == urllib.Domain(src) {
|
||||
if config.Opts != nil && config.Opts.InvidiousInstance() == domain {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, prefix := range whitelist {
|
||||
if strings.HasPrefix(src, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func getTagAllowList() map[string][]string {
|
||||
whitelist := make(map[string][]string)
|
||||
whitelist["img"] = []string{"alt", "title", "src", "srcset", "sizes", "width", "height"}
|
||||
whitelist["picture"] = []string{}
|
||||
whitelist["audio"] = []string{"src"}
|
||||
whitelist["video"] = []string{"poster", "height", "width", "src"}
|
||||
whitelist["source"] = []string{"src", "type", "srcset", "sizes", "media"}
|
||||
whitelist["dt"] = []string{"id"}
|
||||
whitelist["dd"] = []string{"id"}
|
||||
whitelist["dl"] = []string{"id"}
|
||||
whitelist["table"] = []string{}
|
||||
whitelist["caption"] = []string{}
|
||||
whitelist["thead"] = []string{}
|
||||
whitelist["tfooter"] = []string{}
|
||||
whitelist["tr"] = []string{}
|
||||
whitelist["td"] = []string{"rowspan", "colspan"}
|
||||
whitelist["th"] = []string{"rowspan", "colspan"}
|
||||
whitelist["h1"] = []string{"id"}
|
||||
whitelist["h2"] = []string{"id"}
|
||||
whitelist["h3"] = []string{"id"}
|
||||
whitelist["h4"] = []string{"id"}
|
||||
whitelist["h5"] = []string{"id"}
|
||||
whitelist["h6"] = []string{"id"}
|
||||
whitelist["strong"] = []string{}
|
||||
whitelist["em"] = []string{}
|
||||
whitelist["code"] = []string{}
|
||||
whitelist["pre"] = []string{}
|
||||
whitelist["blockquote"] = []string{}
|
||||
whitelist["q"] = []string{"cite"}
|
||||
whitelist["p"] = []string{}
|
||||
whitelist["ul"] = []string{"id"}
|
||||
whitelist["li"] = []string{"id"}
|
||||
whitelist["ol"] = []string{"id"}
|
||||
whitelist["br"] = []string{}
|
||||
whitelist["del"] = []string{}
|
||||
whitelist["a"] = []string{"href", "title", "id"}
|
||||
whitelist["figure"] = []string{}
|
||||
whitelist["figcaption"] = []string{}
|
||||
whitelist["cite"] = []string{}
|
||||
whitelist["time"] = []string{"datetime"}
|
||||
whitelist["abbr"] = []string{"title"}
|
||||
whitelist["acronym"] = []string{"title"}
|
||||
whitelist["wbr"] = []string{}
|
||||
whitelist["dfn"] = []string{}
|
||||
whitelist["sub"] = []string{}
|
||||
whitelist["sup"] = []string{"id"}
|
||||
whitelist["var"] = []string{}
|
||||
whitelist["samp"] = []string{}
|
||||
whitelist["s"] = []string{}
|
||||
whitelist["del"] = []string{}
|
||||
whitelist["ins"] = []string{}
|
||||
whitelist["kbd"] = []string{}
|
||||
whitelist["rp"] = []string{}
|
||||
whitelist["rt"] = []string{}
|
||||
whitelist["rtc"] = []string{}
|
||||
whitelist["ruby"] = []string{}
|
||||
whitelist["iframe"] = []string{"width", "height", "frameborder", "src", "allowfullscreen"}
|
||||
return whitelist
|
||||
}
|
||||
|
||||
func inList(needle string, haystack []string) bool {
|
||||
for _, element := range haystack {
|
||||
if element == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return slices.Contains(whitelist, strings.TrimPrefix(domain, "www."))
|
||||
}
|
||||
|
||||
func rewriteIframeURL(link string) string {
|
||||
|
@ -459,13 +416,7 @@ func isBlockedTag(tagName string) bool {
|
|||
"style",
|
||||
}
|
||||
|
||||
for _, element := range blacklist {
|
||||
if element == tagName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return slices.Contains(blacklist, tagName)
|
||||
}
|
||||
|
||||
func sanitizeSrcsetAttr(baseURL, value string) string {
|
||||
|
@ -493,13 +444,9 @@ func isValidDataAttribute(value string) bool {
|
|||
"data:image/gif",
|
||||
"data:image/webp",
|
||||
}
|
||||
|
||||
for _, prefix := range dataAttributeAllowList {
|
||||
if strings.HasPrefix(value, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return slices.ContainsFunc(dataAttributeAllowList, func(prefix string) bool {
|
||||
return strings.HasPrefix(value, prefix)
|
||||
})
|
||||
}
|
||||
|
||||
func isAnchor(tagName string, attribute html.Attribute) bool {
|
||||
|
|
|
@ -16,6 +16,25 @@ func TestMain(m *testing.M) {
|
|||
os.Exit(exitCode)
|
||||
}
|
||||
|
||||
func BenchmarkSanitize(b *testing.B) {
|
||||
var testCases = map[string][]string{
|
||||
"miniflux_github.html": {"https://github.com/miniflux/v2", ""},
|
||||
"miniflux_wikipedia.html": {"https://fr.wikipedia.org/wiki/Miniflux", ""},
|
||||
}
|
||||
for filename := range testCases {
|
||||
data, err := os.ReadFile("testdata/" + filename)
|
||||
if err != nil {
|
||||
b.Fatalf(`Unable to read file %q: %v`, filename, err)
|
||||
}
|
||||
testCases[filename][1] = string(data)
|
||||
}
|
||||
for range b.N {
|
||||
for _, v := range testCases {
|
||||
Sanitize(v[0], v[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidInput(t *testing.T) {
|
||||
input := `<p>This is a <strong>text</strong> with an image: <img src="http://example.org/" alt="Test" loading="lazy">.</p>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
|
|
@ -11,6 +11,7 @@ import (
|
|||
)
|
||||
|
||||
// StripTags removes all HTML/XML tags from the input string.
|
||||
// This function must *only* be used for cosmetic purposes, not to prevent code injections like XSS.
|
||||
func StripTags(input string) string {
|
||||
tokenizer := html.NewTokenizer(bytes.NewBufferString(input))
|
||||
var buffer bytes.Buffer
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,807 @@
|
|||
<!DOCTYPE html>
|
||||
<html class="client-nojs vector-feature-language-in-header-enabled vector-feature-language-in-main-page-header-disabled vector-feature-sticky-header-disabled vector-feature-page-tools-pinned-disabled vector-feature-toc-pinned-clientpref-1 vector-feature-main-menu-pinned-disabled vector-feature-limited-width-clientpref-1 vector-feature-limited-width-content-enabled vector-feature-custom-font-size-clientpref-0 vector-feature-client-preferences-disabled vector-feature-client-prefs-pinned-disabled vector-feature-night-mode-disabled skin-night-mode-clientpref-0 vector-toc-available" lang="fr" dir="ltr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Miniflux — Wikipédia</title>
|
||||
<script>(function(){var className="client-js vector-feature-language-in-header-enabled vector-feature-language-in-main-page-header-disabled vector-feature-sticky-header-disabled vector-feature-page-tools-pinned-disabled vector-feature-toc-pinned-clientpref-1 vector-feature-main-menu-pinned-disabled vector-feature-limited-width-clientpref-1 vector-feature-limited-width-content-enabled vector-feature-custom-font-size-clientpref-0 vector-feature-client-preferences-disabled vector-feature-client-prefs-pinned-disabled vector-feature-night-mode-disabled skin-night-mode-clientpref-0 vector-toc-available";var cookie=document.cookie.match(/(?:^|; )frwikimwclientpreferences=([^;]+)/);if(cookie){cookie[1].split('%2C').forEach(function(pref){className=className.replace(new RegExp('(^| )'+pref.replace(/-clientpref-\w+$|[^\w-]+/g,'')+'-clientpref-\\w+( |$)'),'$1'+pref+'$2');});}document.documentElement.className=className;}());RLCONF={"wgBreakFrames":false,"wgSeparatorTransformTable":[",\t."," \t,"],
|
||||
"wgDigitTransformTable":["",""],"wgDefaultDateFormat":"dmy","wgMonthNames":["","janvier","février","mars","avril","mai","juin","juillet","août","septembre","octobre","novembre","décembre"],"wgRequestId":"22e3aa19-1dce-40a9-bbd5-d250c14d2223","wgCanonicalNamespace":"","wgCanonicalSpecialPageName":false,"wgNamespaceNumber":0,"wgPageName":"Miniflux","wgTitle":"Miniflux","wgCurRevisionId":204322562,"wgRevisionId":204322562,"wgArticleId":7063156,"wgIsArticle":true,"wgIsRedirect":false,"wgAction":"view","wgUserName":null,"wgUserGroups":["*"],"wgCategories":["Wikipédia:ébauche Internet","Article manquant de références depuis mars 2014","Article manquant de références/Liste complète","Page utilisant P348","Page utilisant P1324","Page utilisant P277","Logiciel catégorisé automatiquement par langage d'écriture","Article utilisant une Infobox","Article contenant un appel à traduction en anglais","Portail:Logiciels libres/Articles liés","Portail:Logiciel/Articles liés",
|
||||
"Portail:Informatique/Articles liés","Logiciel écrit en Go","Agrégateur","Application web"],"wgPageViewLanguage":"fr","wgPageContentLanguage":"fr","wgPageContentModel":"wikitext","wgRelevantPageName":"Miniflux","wgRelevantArticleId":7063156,"wgIsProbablyEditable":true,"wgRelevantPageIsProbablyEditable":true,"wgRestrictionEdit":[],"wgRestrictionMove":[],"wgNoticeProject":"wikipedia","wgMediaViewerOnClick":true,"wgMediaViewerEnabledByDefault":true,"wgPopupsFlags":4,"wgVisualEditor":{"pageLanguageCode":"fr","pageLanguageDir":"ltr","pageVariantFallbacks":"fr"},"wgMFDisplayWikibaseDescriptions":{"search":true,"watchlist":true,"tagline":true,"nearby":true},"wgWMESchemaEditAttemptStepOversample":false,"wgWMEPageLength":2000,"wgULSCurrentAutonym":"français","wgCentralAuthMobileDomain":false,"wgEditSubmitButtonLabelPublish":true,"wgULSPosition":"interlanguage","wgULSisCompactLinksEnabled":false,"wgVector2022LanguageInHeader":true,"wgULSisLanguageSelectorEmpty":false,"wgWikibaseItemId":
|
||||
"Q16664605","wgCheckUserClientHintsHeadersJsApi":["architecture","bitness","brands","fullVersionList","mobile","model","platform","platformVersion"],"GEHomepageSuggestedEditsEnableTopics":true,"wgGETopicsMatchModeEnabled":false,"wgGEStructuredTaskRejectionReasonTextInputEnabled":false,"wgGELevelingUpEnabledForUser":false};RLSTATE={"skins.vector.user.styles":"ready","ext.globalCssJs.user.styles":"ready","site.styles":"ready","user.styles":"ready","skins.vector.user":"ready","ext.globalCssJs.user":"ready","user":"ready","user.options":"loading","ext.cite.styles":"ready","codex-search-styles":"ready","skins.vector.styles":"ready","skins.vector.icons":"ready","ext.visualEditor.desktopArticleTarget.noscript":"ready","ext.uls.interlanguage":"ready","wikibase.client.init":"ready","ext.wikimediaBadges":"ready"};RLPAGEMODULES=["ext.cite.ux-enhancements","site","mediawiki.page.ready","skins.vector.js","ext.centralNotice.geoIP","ext.centralNotice.startUp","ext.gadget.ArchiveLinks",
|
||||
"ext.gadget.Wdsearch","ext.urlShortener.toolbar","ext.centralauth.centralautologin","mmv.head","mmv.bootstrap.autostart","ext.popups","ext.visualEditor.desktopArticleTarget.init","ext.visualEditor.targetLoader","ext.echo.centralauth","ext.eventLogging","ext.wikimediaEvents","ext.navigationTiming","ext.uls.interface","ext.cx.eventlogging.campaigns","ext.cx.uls.quick.actions","wikibase.client.vector-2022","ext.checkUser.clientHints","ext.quicksurveys.init","ext.growthExperiments.SuggestedEditSession"];</script>
|
||||
<script>(RLQ=window.RLQ||[]).push(function(){mw.loader.impl(function(){return["user.options@12s5i",function($,jQuery,require,module){mw.user.tokens.set({"patrolToken":"+\\","watchToken":"+\\","csrfToken":"+\\"});
|
||||
}];});});</script>
|
||||
<link rel="stylesheet" href="/w/load.php?lang=fr&modules=codex-search-styles%7Cext.cite.styles%7Cext.uls.interlanguage%7Cext.visualEditor.desktopArticleTarget.noscript%7Cext.wikimediaBadges%7Cskins.vector.icons%2Cstyles%7Cwikibase.client.init&only=styles&skin=vector-2022">
|
||||
<script async="" src="/w/load.php?lang=fr&modules=startup&only=scripts&raw=1&skin=vector-2022"></script>
|
||||
<meta name="ResourceLoaderDynamicStyles" content="">
|
||||
<link rel="stylesheet" href="/w/load.php?lang=fr&modules=site.styles&only=styles&skin=vector-2022">
|
||||
<meta name="generator" content="MediaWiki 1.42.0-wmf.20">
|
||||
<meta name="referrer" content="origin">
|
||||
<meta name="referrer" content="origin-when-cross-origin">
|
||||
<meta name="robots" content="max-image-preview:standard">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="viewport" content="width=1000">
|
||||
<meta property="og:title" content="Miniflux — Wikipédia">
|
||||
<meta property="og:type" content="website">
|
||||
<link rel="preconnect" href="//upload.wikimedia.org">
|
||||
<link rel="alternate" media="only screen and (max-width: 720px)" href="//fr.m.wikipedia.org/wiki/Miniflux">
|
||||
<link rel="alternate" type="application/x-wiki" title="Modifier" href="/w/index.php?title=Miniflux&action=edit">
|
||||
<link rel="apple-touch-icon" href="/static/apple-touch/wikipedia.png">
|
||||
<link rel="icon" href="/static/favicon/wikipedia.ico">
|
||||
<link rel="search" type="application/opensearchdescription+xml" href="/w/opensearch_desc.php" title="Wikipédia (fr)">
|
||||
<link rel="EditURI" type="application/rsd+xml" href="//fr.wikipedia.org/w/api.php?action=rsd">
|
||||
<link rel="canonical" href="https://fr.wikipedia.org/wiki/Miniflux">
|
||||
<link rel="license" href="https://creativecommons.org/licenses/by-sa/4.0/deed.fr">
|
||||
<link rel="alternate" type="application/atom+xml" title="Flux Atom de Wikipédia" href="/w/index.php?title=Sp%C3%A9cial:Modifications_r%C3%A9centes&feed=atom">
|
||||
<link rel="dns-prefetch" href="//meta.wikimedia.org" />
|
||||
<link rel="dns-prefetch" href="//login.wikimedia.org">
|
||||
</head>
|
||||
<body class="skin-vector skin-vector-search-vue mediawiki ltr sitedir-ltr mw-hide-empty-elt ns-0 ns-subject mw-editable page-Miniflux rootpage-Miniflux skin-vector-2022 action-view"><a class="mw-jump-link" href="#bodyContent">Aller au contenu</a>
|
||||
<div class="vector-header-container">
|
||||
<header class="vector-header mw-header">
|
||||
<div class="vector-header-start">
|
||||
<nav class="vector-main-menu-landmark" aria-label="Site" role="navigation">
|
||||
|
||||
<div id="vector-main-menu-dropdown" class="vector-dropdown vector-main-menu-dropdown vector-button-flush-left vector-button-flush-right" >
|
||||
<input type="checkbox" id="vector-main-menu-dropdown-checkbox" role="button" aria-haspopup="true" data-event-name="ui.dropdown-vector-main-menu-dropdown" class="vector-dropdown-checkbox " aria-label="Menu principal" >
|
||||
<label id="vector-main-menu-dropdown-label" for="vector-main-menu-dropdown-checkbox" class="vector-dropdown-label cdx-button cdx-button--fake-button cdx-button--fake-button--enabled cdx-button--weight-quiet cdx-button--icon-only " aria-hidden="true" ><span class="vector-icon mw-ui-icon-menu mw-ui-icon-wikimedia-menu"></span>
|
||||
|
||||
<span class="vector-dropdown-label-text">Menu principal</span>
|
||||
</label>
|
||||
<div class="vector-dropdown-content">
|
||||
|
||||
|
||||
<div id="vector-main-menu-unpinned-container" class="vector-unpinned-container">
|
||||
|
||||
<div id="vector-main-menu" class="vector-main-menu vector-pinnable-element">
|
||||
<div
|
||||
class="vector-pinnable-header vector-main-menu-pinnable-header vector-pinnable-header-unpinned"
|
||||
data-feature-name="main-menu-pinned"
|
||||
data-pinnable-element-id="vector-main-menu"
|
||||
data-pinned-container-id="vector-main-menu-pinned-container"
|
||||
data-unpinned-container-id="vector-main-menu-unpinned-container"
|
||||
>
|
||||
<div class="vector-pinnable-header-label">Menu principal</div>
|
||||
<button class="vector-pinnable-header-toggle-button vector-pinnable-header-pin-button" data-event-name="pinnable-header.vector-main-menu.pin">déplacer vers la barre latérale</button>
|
||||
<button class="vector-pinnable-header-toggle-button vector-pinnable-header-unpin-button" data-event-name="pinnable-header.vector-main-menu.unpin">masquer</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="p-navigation" class="vector-menu mw-portlet mw-portlet-navigation" >
|
||||
<div class="vector-menu-heading">
|
||||
Navigation
|
||||
</div>
|
||||
<div class="vector-menu-content">
|
||||
|
||||
<ul class="vector-menu-content-list">
|
||||
|
||||
<li id="n-mainpage-description" class="mw-list-item"><a href="/wiki/Wikip%C3%A9dia:Accueil_principal" title="Accueil général [z]" accesskey="z"><span>Accueil</span></a></li><li id="n-thema" class="mw-list-item"><a href="/wiki/Portail:Accueil"><span>Portails thématiques</span></a></li><li id="n-randompage" class="mw-list-item"><a href="/wiki/Sp%C3%A9cial:Page_au_hasard" title="Affiche un article au hasard [x]" accesskey="x"><span>Article au hasard</span></a></li><li id="n-contact" class="mw-list-item"><a href="/wiki/Wikip%C3%A9dia:Contact"><span>Contact</span></a></li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div id="p-Contribuer" class="vector-menu mw-portlet mw-portlet-Contribuer" >
|
||||
<div class="vector-menu-heading">
|
||||
Contribuer
|
||||
</div>
|
||||
<div class="vector-menu-content">
|
||||
|
||||
<ul class="vector-menu-content-list">
|
||||
|
||||
<li id="n-aboutwp" class="mw-list-item"><a href="/wiki/Aide:D%C3%A9buter"><span>Débuter sur Wikipédia</span></a></li><li id="n-help" class="mw-list-item"><a href="/wiki/Aide:Accueil" title="Accès à l’aide"><span>Aide</span></a></li><li id="n-portal" class="mw-list-item"><a href="/wiki/Wikip%C3%A9dia:Accueil_de_la_communaut%C3%A9" title="À propos du projet, ce que vous pouvez faire, où trouver les informations"><span>Communauté</span></a></li><li id="n-recentchanges" class="mw-list-item"><a href="/wiki/Sp%C3%A9cial:Modifications_r%C3%A9centes" title="Liste des modifications récentes sur le wiki [r]" accesskey="r"><span>Modifications récentes</span></a></li><li id="n-sitesupport" class="mw-list-item"><a href="//donate.wikimedia.org/wiki/Special:FundraiserRedirector?utm_source=donate&utm_medium=sidebar&utm_campaign=C13_fr.wikipedia.org&uselang=fr" title="Soutenez-nous"><span>Faire un don</span></a></li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="vector-main-menu-action vector-main-menu-action-lang-alert vector-main-menu-action-lang-alert-empty">
|
||||
<div class="vector-main-menu-action-item">
|
||||
<div class="vector-main-menu-action-heading vector-menu-heading">Langues</div>
|
||||
<div class="vector-main-menu-action-content vector-menu-content">
|
||||
<div class="mw-message-box cdx-message cdx-message--block mw-message-box-notice cdx-message--notice vector-language-sidebar-alert"><span class="cdx-message__icon"></span><div class="cdx-message__content">Sur cette version linguistique de Wikipédia, les liens interlangues sont placés en haut à droite du titre de l’article.<br /><a href="#p-lang-btn">Aller en haut</a>.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
|
||||
<a href="/wiki/Wikip%C3%A9dia:Accueil_principal" class="mw-logo">
|
||||
<img class="mw-logo-icon" src="/static/images/icons/wikipedia.png" alt="" aria-hidden="true" height="50" width="50">
|
||||
<span class="mw-logo-container">
|
||||
<img class="mw-logo-wordmark" alt="Wikipédia" src="/static/images/mobile/copyright/wikipedia-wordmark-fr.svg" style="width: 7.5em; height: 1.125em;">
|
||||
<img class="mw-logo-tagline" alt="l'encyclopédie libre" src="/static/images/mobile/copyright/wikipedia-tagline-fr.svg" width="120" height="13" style="width: 7.5em; height: 0.8125em;">
|
||||
</span>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
<div class="vector-header-end">
|
||||
|
||||
<div id="p-search" role="search" class="vector-search-box-vue vector-search-box-collapses vector-search-box-show-thumbnail vector-search-box-auto-expand-width vector-search-box">
|
||||
<a href="/wiki/Sp%C3%A9cial:Recherche" class="cdx-button cdx-button--fake-button cdx-button--fake-button--enabled cdx-button--weight-quiet cdx-button--icon-only search-toggle" id="" title="Rechercher sur Wikipédia [f]" accesskey="f"><span class="vector-icon mw-ui-icon-search mw-ui-icon-wikimedia-search"></span>
|
||||
|
||||
<span>Rechercher</span>
|
||||
</a>
|
||||
<div class="vector-typeahead-search-container">
|
||||
<div class="cdx-typeahead-search cdx-typeahead-search--show-thumbnail cdx-typeahead-search--auto-expand-width">
|
||||
<form action="/w/index.php" id="searchform" class="cdx-search-input cdx-search-input--has-end-button">
|
||||
<div id="simpleSearch" class="cdx-search-input__input-wrapper" data-search-loc="header-moved">
|
||||
<div class="cdx-text-input cdx-text-input--has-start-icon">
|
||||
<input
|
||||
class="cdx-text-input__input"
|
||||
type="search" name="search" placeholder="Rechercher sur Wikipédia" aria-label="Rechercher sur Wikipédia" autocapitalize="sentences" title="Rechercher sur Wikipédia [f]" accesskey="f" id="searchInput"
|
||||
>
|
||||
<span class="cdx-text-input__icon cdx-text-input__start-icon"></span>
|
||||
</div>
|
||||
<input type="hidden" name="title" value="Spécial:Recherche">
|
||||
</div>
|
||||
<button class="cdx-button cdx-search-input__end-button">Rechercher</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="vector-user-links vector-user-links-wide" aria-label="Outils personnels" role="navigation" >
|
||||
<div class="vector-user-links-main">
|
||||
|
||||
<div id="p-vector-user-menu-preferences" class="vector-menu mw-portlet emptyPortlet" >
|
||||
<div class="vector-menu-content">
|
||||
|
||||
<ul class="vector-menu-content-list">
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="p-vector-user-menu-userpage" class="vector-menu mw-portlet emptyPortlet" >
|
||||
<div class="vector-menu-content">
|
||||
|
||||
<ul class="vector-menu-content-list">
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="vector-client-prefs-landmark" aria-label="Apparence">
|
||||
|
||||
|
||||
</nav>
|
||||
|
||||
<div id="p-vector-user-menu-notifications" class="vector-menu mw-portlet emptyPortlet" >
|
||||
<div class="vector-menu-content">
|
||||
|
||||
<ul class="vector-menu-content-list">
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="p-vector-user-menu-overflow" class="vector-menu mw-portlet" >
|
||||
<div class="vector-menu-content">
|
||||
|
||||
<ul class="vector-menu-content-list">
|
||||
<li id="pt-createaccount-2" class="user-links-collapsible-item mw-list-item user-links-collapsible-item"><a data-mw="interface" href="/w/index.php?title=Sp%C3%A9cial:Cr%C3%A9er_un_compte&returnto=Miniflux" title="Nous vous encourageons à créer un compte utilisateur et vous connecter ; ce n’est cependant pas obligatoire." class=""><span>Créer un compte</span></a>
|
||||
</li>
|
||||
<li id="pt-login-2" class="user-links-collapsible-item mw-list-item user-links-collapsible-item"><a data-mw="interface" href="/w/index.php?title=Sp%C3%A9cial:Connexion&returnto=Miniflux" title="Nous vous encourageons à vous connecter ; ce n’est cependant pas obligatoire. [o]" accesskey="o" class=""><span>Se connecter</span></a>
|
||||
</li>
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="vector-user-links-dropdown" class="vector-dropdown vector-user-menu vector-button-flush-right vector-user-menu-logged-out" title="Plus d’options" >
|
||||
<input type="checkbox" id="vector-user-links-dropdown-checkbox" role="button" aria-haspopup="true" data-event-name="ui.dropdown-vector-user-links-dropdown" class="vector-dropdown-checkbox " aria-label="Outils personnels" >
|
||||
<label id="vector-user-links-dropdown-label" for="vector-user-links-dropdown-checkbox" class="vector-dropdown-label cdx-button cdx-button--fake-button cdx-button--fake-button--enabled cdx-button--weight-quiet cdx-button--icon-only " aria-hidden="true" ><span class="vector-icon mw-ui-icon-ellipsis mw-ui-icon-wikimedia-ellipsis"></span>
|
||||
|
||||
<span class="vector-dropdown-label-text">Outils personnels</span>
|
||||
</label>
|
||||
<div class="vector-dropdown-content">
|
||||
|
||||
|
||||
|
||||
<div id="p-personal" class="vector-menu mw-portlet mw-portlet-personal user-links-collapsible-item" title="Menu utilisateur" >
|
||||
<div class="vector-menu-content">
|
||||
|
||||
<ul class="vector-menu-content-list">
|
||||
|
||||
<li id="pt-createaccount" class="user-links-collapsible-item mw-list-item"><a href="/w/index.php?title=Sp%C3%A9cial:Cr%C3%A9er_un_compte&returnto=Miniflux" title="Nous vous encourageons à créer un compte utilisateur et vous connecter ; ce n’est cependant pas obligatoire."><span class="vector-icon mw-ui-icon-userAdd mw-ui-icon-wikimedia-userAdd"></span> <span>Créer un compte</span></a></li><li id="pt-login" class="user-links-collapsible-item mw-list-item"><a href="/w/index.php?title=Sp%C3%A9cial:Connexion&returnto=Miniflux" title="Nous vous encourageons à vous connecter ; ce n’est cependant pas obligatoire. [o]" accesskey="o"><span class="vector-icon mw-ui-icon-logIn mw-ui-icon-wikimedia-logIn"></span> <span>Se connecter</span></a></li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="p-user-menu-anon-editor" class="vector-menu mw-portlet mw-portlet-user-menu-anon-editor" >
|
||||
<div class="vector-menu-heading">
|
||||
Pages pour les contributeurs déconnectés <a href="/wiki/Aide:Introduction" aria-label="En savoir plus sur la contribution"><span>en savoir plus</span></a>
|
||||
</div>
|
||||
<div class="vector-menu-content">
|
||||
|
||||
<ul class="vector-menu-content-list">
|
||||
|
||||
<li id="pt-anoncontribs" class="mw-list-item"><a href="/wiki/Sp%C3%A9cial:Mes_contributions" title="Une liste des modifications effectuées depuis cette adresse IP [y]" accesskey="y"><span>Contributions</span></a></li><li id="pt-anontalk" class="mw-list-item"><a href="/wiki/Sp%C3%A9cial:Mes_discussions" title="La page de discussion pour les contributions depuis cette adresse IP [n]" accesskey="n"><span>Discussion</span></a></li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
<div class="mw-page-container">
|
||||
<div class="mw-page-container-inner">
|
||||
<div class="vector-sitenotice-container">
|
||||
<div id="siteNotice"><!-- CentralNotice --></div>
|
||||
</div>
|
||||
<div class="vector-column-start">
|
||||
<div class="vector-main-menu-container">
|
||||
<div id="mw-navigation">
|
||||
<nav id="mw-panel" class="vector-main-menu-landmark" aria-label="Site" role="navigation">
|
||||
<div id="vector-main-menu-pinned-container" class="vector-pinned-container">
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vector-sticky-pinned-container">
|
||||
<nav id="mw-panel-toc" role="navigation" aria-label="Sommaire" data-event-name="ui.sidebar-toc" class="mw-table-of-contents-container vector-toc-landmark">
|
||||
<div id="vector-toc-pinned-container" class="vector-pinned-container">
|
||||
<div id="vector-toc" class="vector-toc vector-pinnable-element">
|
||||
<div
|
||||
class="vector-pinnable-header vector-toc-pinnable-header vector-pinnable-header-pinned"
|
||||
data-feature-name="toc-pinned"
|
||||
data-pinnable-element-id="vector-toc"
|
||||
|
||||
|
||||
>
|
||||
<h2 class="vector-pinnable-header-label">Sommaire</h2>
|
||||
<button class="vector-pinnable-header-toggle-button vector-pinnable-header-pin-button" data-event-name="pinnable-header.vector-toc.pin">déplacer vers la barre latérale</button>
|
||||
<button class="vector-pinnable-header-toggle-button vector-pinnable-header-unpin-button" data-event-name="pinnable-header.vector-toc.unpin">masquer</button>
|
||||
</div>
|
||||
|
||||
|
||||
<ul class="vector-toc-contents" id="mw-panel-toc-list">
|
||||
<li id="toc-mw-content-text"
|
||||
class="vector-toc-list-item vector-toc-level-1">
|
||||
<a href="#" class="vector-toc-link">
|
||||
<div class="vector-toc-text">Début</div>
|
||||
</a>
|
||||
</li>
|
||||
<li id="toc-Caractéristiques"
|
||||
class="vector-toc-list-item vector-toc-level-1 vector-toc-list-item-expanded">
|
||||
<a class="vector-toc-link" href="#Caractéristiques">
|
||||
<div class="vector-toc-text">
|
||||
<span class="vector-toc-numb">1</span>Caractéristiques</div>
|
||||
</a>
|
||||
|
||||
<ul id="toc-Caractéristiques-sublist" class="vector-toc-list">
|
||||
</ul>
|
||||
</li>
|
||||
<li id="toc-Liens_externes"
|
||||
class="vector-toc-list-item vector-toc-level-1 vector-toc-list-item-expanded">
|
||||
<a class="vector-toc-link" href="#Liens_externes">
|
||||
<div class="vector-toc-text">
|
||||
<span class="vector-toc-numb">2</span>Liens externes</div>
|
||||
</a>
|
||||
|
||||
<ul id="toc-Liens_externes-sublist" class="vector-toc-list">
|
||||
</ul>
|
||||
</li>
|
||||
<li id="toc-Notes_et_références"
|
||||
class="vector-toc-list-item vector-toc-level-1 vector-toc-list-item-expanded">
|
||||
<a class="vector-toc-link" href="#Notes_et_références">
|
||||
<div class="vector-toc-text">
|
||||
<span class="vector-toc-numb">3</span>Notes et références</div>
|
||||
</a>
|
||||
|
||||
<ul id="toc-Notes_et_références-sublist" class="vector-toc-list">
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mw-content-container">
|
||||
<main id="content" class="mw-body" role="main">
|
||||
<header class="mw-body-header vector-page-titlebar">
|
||||
<nav role="navigation" aria-label="Sommaire" class="vector-toc-landmark">
|
||||
|
||||
<div id="vector-page-titlebar-toc" class="vector-dropdown vector-page-titlebar-toc vector-button-flush-left" >
|
||||
<input type="checkbox" id="vector-page-titlebar-toc-checkbox" role="button" aria-haspopup="true" data-event-name="ui.dropdown-vector-page-titlebar-toc" class="vector-dropdown-checkbox " aria-label="Basculer la table des matières" >
|
||||
<label id="vector-page-titlebar-toc-label" for="vector-page-titlebar-toc-checkbox" class="vector-dropdown-label cdx-button cdx-button--fake-button cdx-button--fake-button--enabled cdx-button--weight-quiet cdx-button--icon-only " aria-hidden="true" ><span class="vector-icon mw-ui-icon-listBullet mw-ui-icon-wikimedia-listBullet"></span>
|
||||
|
||||
<span class="vector-dropdown-label-text">Basculer la table des matières</span>
|
||||
</label>
|
||||
<div class="vector-dropdown-content">
|
||||
|
||||
|
||||
<div id="vector-page-titlebar-toc-unpinned-container" class="vector-unpinned-container">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
<h1 id="firstHeading" class="firstHeading mw-first-heading"><span class="mw-page-title-main">Miniflux</span></h1>
|
||||
|
||||
<div id="p-lang-btn" class="vector-dropdown mw-portlet mw-portlet-lang" >
|
||||
<input type="checkbox" id="p-lang-btn-checkbox" role="button" aria-haspopup="true" data-event-name="ui.dropdown-p-lang-btn" class="vector-dropdown-checkbox mw-interlanguage-selector" aria-label="Cet article n’existe que dans cette langue. Ajouter l’article pour d’autres langues." >
|
||||
<label id="p-lang-btn-label" for="p-lang-btn-checkbox" class="vector-dropdown-label cdx-button cdx-button--fake-button cdx-button--fake-button--enabled cdx-button--weight-quiet cdx-button--action-progressive mw-portlet-lang-heading-0" aria-hidden="true" ><span class="vector-icon mw-ui-icon-language-progressive mw-ui-icon-wikimedia-language-progressive"></span>
|
||||
|
||||
<span class="vector-dropdown-label-text">Ajouter des langues</span>
|
||||
</label>
|
||||
<div class="vector-dropdown-content">
|
||||
|
||||
<div class="vector-menu-content">
|
||||
|
||||
<ul class="vector-menu-content-list">
|
||||
|
||||
|
||||
</ul>
|
||||
<div class="after-portlet after-portlet-lang"><span class="uls-after-portlet-link"></span><span class="wb-langlinks-add wb-langlinks-link"><a href="https://www.wikidata.org/wiki/Special:EntityPage/Q16664605#sitelinks-wikipedia" title="Ajouter des liens interlangues" class="wbc-editpage">Ajouter des liens</a></span></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="vector-page-toolbar">
|
||||
<div class="vector-page-toolbar-container">
|
||||
<div id="left-navigation">
|
||||
<nav aria-label="Espaces de noms">
|
||||
|
||||
<div id="p-associated-pages" class="vector-menu vector-menu-tabs mw-portlet mw-portlet-associated-pages" >
|
||||
<div class="vector-menu-content">
|
||||
|
||||
<ul class="vector-menu-content-list">
|
||||
|
||||
<li id="ca-nstab-main" class="selected vector-tab-noicon mw-list-item"><a href="/wiki/Miniflux" title="Voir le contenu de la page [c]" accesskey="c"><span>Article</span></a></li><li id="ca-talk" class="new vector-tab-noicon mw-list-item"><a href="/w/index.php?title=Discussion:Miniflux&action=edit&redlink=1" rel="discussion" title="Discussion au sujet de cette page de contenu (page inexistante) [t]" accesskey="t"><span>Discussion</span></a></li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="p-variants" class="vector-dropdown emptyPortlet" >
|
||||
<input type="checkbox" id="p-variants-checkbox" role="button" aria-haspopup="true" data-event-name="ui.dropdown-p-variants" class="vector-dropdown-checkbox " aria-label="Modifier la variante de langue" >
|
||||
<label id="p-variants-label" for="p-variants-checkbox" class="vector-dropdown-label cdx-button cdx-button--fake-button cdx-button--fake-button--enabled cdx-button--weight-quiet" aria-hidden="true" ><span class="vector-dropdown-label-text">français</span>
|
||||
</label>
|
||||
<div class="vector-dropdown-content">
|
||||
|
||||
|
||||
|
||||
<div id="p-variants" class="vector-menu mw-portlet mw-portlet-variants emptyPortlet" >
|
||||
<div class="vector-menu-content">
|
||||
|
||||
<ul class="vector-menu-content-list">
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
</div>
|
||||
<div id="right-navigation" class="vector-collapsible">
|
||||
<nav aria-label="Affichages">
|
||||
|
||||
<div id="p-views" class="vector-menu vector-menu-tabs mw-portlet mw-portlet-views" >
|
||||
<div class="vector-menu-content">
|
||||
|
||||
<ul class="vector-menu-content-list">
|
||||
|
||||
<li id="ca-view" class="selected vector-tab-noicon mw-list-item"><a href="/wiki/Miniflux"><span>Lire</span></a></li><li id="ca-ve-edit" class="vector-tab-noicon mw-list-item"><a href="/w/index.php?title=Miniflux&veaction=edit" title="Modifier cette page [v]" accesskey="v"><span>Modifier</span></a></li><li id="ca-edit" class="collapsible vector-tab-noicon mw-list-item"><a href="/w/index.php?title=Miniflux&action=edit" title="Modifier le wikicode de cette page [e]" accesskey="e"><span>Modifier le code</span></a></li><li id="ca-history" class="vector-tab-noicon mw-list-item"><a href="/w/index.php?title=Miniflux&action=history" title="Historique des versions de cette page [h]" accesskey="h"><span>Voir l’historique</span></a></li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
|
||||
<nav class="vector-page-tools-landmark" aria-label="Outils de la page">
|
||||
|
||||
<div id="vector-page-tools-dropdown" class="vector-dropdown vector-page-tools-dropdown" >
|
||||
<input type="checkbox" id="vector-page-tools-dropdown-checkbox" role="button" aria-haspopup="true" data-event-name="ui.dropdown-vector-page-tools-dropdown" class="vector-dropdown-checkbox " aria-label="Outils" >
|
||||
<label id="vector-page-tools-dropdown-label" for="vector-page-tools-dropdown-checkbox" class="vector-dropdown-label cdx-button cdx-button--fake-button cdx-button--fake-button--enabled cdx-button--weight-quiet" aria-hidden="true" ><span class="vector-dropdown-label-text">Outils</span>
|
||||
</label>
|
||||
<div class="vector-dropdown-content">
|
||||
|
||||
|
||||
<div id="vector-page-tools-unpinned-container" class="vector-unpinned-container">
|
||||
|
||||
<div id="vector-page-tools" class="vector-page-tools vector-pinnable-element">
|
||||
<div
|
||||
class="vector-pinnable-header vector-page-tools-pinnable-header vector-pinnable-header-unpinned"
|
||||
data-feature-name="page-tools-pinned"
|
||||
data-pinnable-element-id="vector-page-tools"
|
||||
data-pinned-container-id="vector-page-tools-pinned-container"
|
||||
data-unpinned-container-id="vector-page-tools-unpinned-container"
|
||||
>
|
||||
<div class="vector-pinnable-header-label">Outils</div>
|
||||
<button class="vector-pinnable-header-toggle-button vector-pinnable-header-pin-button" data-event-name="pinnable-header.vector-page-tools.pin">déplacer vers la barre latérale</button>
|
||||
<button class="vector-pinnable-header-toggle-button vector-pinnable-header-unpin-button" data-event-name="pinnable-header.vector-page-tools.unpin">masquer</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="p-cactions" class="vector-menu mw-portlet mw-portlet-cactions emptyPortlet vector-has-collapsible-items" title="Plus d’options" >
|
||||
<div class="vector-menu-heading">
|
||||
Actions
|
||||
</div>
|
||||
<div class="vector-menu-content">
|
||||
|
||||
<ul class="vector-menu-content-list">
|
||||
|
||||
<li id="ca-more-view" class="selected vector-more-collapsible-item mw-list-item"><a href="/wiki/Miniflux"><span>Lire</span></a></li><li id="ca-more-ve-edit" class="vector-more-collapsible-item mw-list-item"><a href="/w/index.php?title=Miniflux&veaction=edit" title="Modifier cette page [v]" accesskey="v"><span>Modifier</span></a></li><li id="ca-more-edit" class="collapsible vector-more-collapsible-item mw-list-item"><a href="/w/index.php?title=Miniflux&action=edit" title="Modifier le wikicode de cette page [e]" accesskey="e"><span>Modifier le code</span></a></li><li id="ca-more-history" class="vector-more-collapsible-item mw-list-item"><a href="/w/index.php?title=Miniflux&action=history"><span>Voir l’historique</span></a></li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="p-tb" class="vector-menu mw-portlet mw-portlet-tb" >
|
||||
<div class="vector-menu-heading">
|
||||
Général
|
||||
</div>
|
||||
<div class="vector-menu-content">
|
||||
|
||||
<ul class="vector-menu-content-list">
|
||||
|
||||
<li id="t-whatlinkshere" class="mw-list-item"><a href="/wiki/Sp%C3%A9cial:Pages_li%C3%A9es/Miniflux" title="Liste des pages liées qui pointent sur celle-ci [j]" accesskey="j"><span>Pages liées</span></a></li><li id="t-recentchangeslinked" class="mw-list-item"><a href="/wiki/Sp%C3%A9cial:Suivi_des_liens/Miniflux" rel="nofollow" title="Liste des modifications récentes des pages appelées par celle-ci [k]" accesskey="k"><span>Suivi des pages liées</span></a></li><li id="t-upload" class="mw-list-item"><a href="/wiki/Aide:Importer_un_fichier" title="Téléverser des fichiers [u]" accesskey="u"><span>Téléverser un fichier</span></a></li><li id="t-specialpages" class="mw-list-item"><a href="/wiki/Sp%C3%A9cial:Pages_sp%C3%A9ciales" title="Liste de toutes les pages spéciales [q]" accesskey="q"><span>Pages spéciales</span></a></li><li id="t-permalink" class="mw-list-item"><a href="/w/index.php?title=Miniflux&oldid=204322562" title="Adresse permanente de cette version de cette page"><span>Lien permanent</span></a></li><li id="t-info" class="mw-list-item"><a href="/w/index.php?title=Miniflux&action=info" title="Davantage d’informations sur cette page"><span>Informations sur la page</span></a></li><li id="t-cite" class="mw-list-item"><a href="/w/index.php?title=Sp%C3%A9cial:Citer&page=Miniflux&id=204322562&wpFormIdentifier=titleform" title="Informations sur la manière de citer cette page"><span>Citer cette page</span></a></li><li id="t-urlshortener" class="mw-list-item"><a href="/w/index.php?title=Sp%C3%A9cial:UrlShortener&url=https%3A%2F%2Ffr.wikipedia.org%2Fwiki%2FMiniflux"><span>Obtenir l'URL raccourcie</span></a></li><li id="t-urlshortener-qrcode" class="mw-list-item"><a href="/w/index.php?title=Sp%C3%A9cial:QrCode&url=https%3A%2F%2Ffr.wikipedia.org%2Fwiki%2FMiniflux"><span>Télécharger le code QR</span></a></li><li id="t-wikibase" class="mw-list-item"><a href="https://www.wikidata.org/wiki/Special:EntityPage/Q16664605" title="Lien vers l’élément dans le dépôt de données connecté [g]" accesskey="g"><span>Élément Wikidata</span></a></li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="p-coll-print_export" class="vector-menu mw-portlet mw-portlet-coll-print_export" >
|
||||
<div class="vector-menu-heading">
|
||||
Imprimer / exporter
|
||||
</div>
|
||||
<div class="vector-menu-content">
|
||||
|
||||
<ul class="vector-menu-content-list">
|
||||
|
||||
<li id="coll-create_a_book" class="mw-list-item"><a href="/w/index.php?title=Sp%C3%A9cial:Livre&bookcmd=book_creator&referer=Miniflux"><span>Créer un livre</span></a></li><li id="coll-download-as-rl" class="mw-list-item"><a href="/w/index.php?title=Sp%C3%A9cial:DownloadAsPdf&page=Miniflux&action=show-download-screen"><span>Télécharger comme PDF</span></a></li><li id="t-print" class="mw-list-item"><a href="/w/index.php?title=Miniflux&printable=yes" title="Version imprimable de cette page [p]" accesskey="p"><span>Version imprimable</span></a></li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vector-column-end">
|
||||
<div class="vector-sticky-pinned-container">
|
||||
<nav class="vector-page-tools-landmark" aria-label="Outils de la page">
|
||||
<div id="vector-page-tools-pinned-container" class="vector-pinned-container">
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
<nav class="vector-client-prefs-landmark" aria-label="Apparence">
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<div id="bodyContent" class="vector-body" aria-labelledby="firstHeading" data-mw-ve-target-container>
|
||||
<div class="vector-body-before-content">
|
||||
<div class="mw-indicators">
|
||||
</div>
|
||||
|
||||
<div id="siteSub" class="noprint">Un article de Wikipédia, l'encyclopédie libre.</div>
|
||||
</div>
|
||||
<div id="contentSub"><div id="mw-content-subtitle"></div></div>
|
||||
|
||||
|
||||
<div id="mw-content-text" class="mw-body-content"><div class="mw-content-ltr mw-parser-output" lang="fr" dir="ltr"><div class="bandeau-container metadata bandeau-article bandeau-niveau-ebauche"><div class="bandeau-cell bandeau-icone" style="display:table-cell;padding-right:0.5em"><span class="noviewer" typeof="mw:File"><a href="/wiki/Fichier:Circle-icons-email.svg" class="mw-file-description"><img alt="" src="//upload.wikimedia.org/wikipedia/commons/thumb/d/d0/Circle-icons-email.svg/35px-Circle-icons-email.svg.png" decoding="async" width="35" height="35" class="mw-file-element" srcset="//upload.wikimedia.org/wikipedia/commons/thumb/d/d0/Circle-icons-email.svg/53px-Circle-icons-email.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/d/d0/Circle-icons-email.svg/70px-Circle-icons-email.svg.png 2x" data-file-width="512" data-file-height="512" /></a></span></div><div class="bandeau-cell" style="display:table-cell;padding-right:0.5em">
|
||||
<p><strong class="bandeau-titre">Cet article est une <a href="/wiki/Aide:%C3%89bauche" title="Aide:Ébauche">ébauche</a> concernant <a href="/wiki/Internet" title="Internet">Internet</a>.</strong>
|
||||
</p><p>Vous pouvez partager vos connaissances en l’améliorant (<b><a href="/wiki/Aide:Comment_modifier_une_page" title="Aide:Comment modifier une page">comment ?</a></b>) selon les recommandations des <a href="/wiki/Projet:Accueil" title="Projet:Accueil">projets correspondants</a>.
|
||||
</p>
|
||||
</div></div>
|
||||
<div class="bandeau-container metadata bandeau-article bandeau-niveau-modere"><figure class="mw-halign-right noviewer" typeof="mw:File"><a href="/wiki/Mod%C3%A8le:Sources_secondaires" title="Si ce bandeau n'est plus pertinent, retirez-le. Cliquez ici pour en savoir plus."><img alt="Si ce bandeau n'est plus pertinent, retirez-le. Cliquez ici pour en savoir plus." src="//upload.wikimedia.org/wikipedia/commons/thumb/3/38/Info_Simple.svg/12px-Info_Simple.svg.png" decoding="async" width="12" height="12" class="mw-file-element" srcset="//upload.wikimedia.org/wikipedia/commons/thumb/3/38/Info_Simple.svg/18px-Info_Simple.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/3/38/Info_Simple.svg/24px-Info_Simple.svg.png 2x" data-file-width="512" data-file-height="512" /></a><figcaption>Si ce bandeau n'est plus pertinent, retirez-le. Cliquez ici pour en savoir plus.</figcaption></figure><div class="bandeau-cell bandeau-icone" style="display:table-cell;padding-right:0.5em"><span class="noviewer" typeof="mw:File"><a href="/wiki/Fichier:2017-fr.wp-orange-source.svg" class="mw-file-description"><img alt="" src="//upload.wikimedia.org/wikipedia/commons/thumb/a/a1/2017-fr.wp-orange-source.svg/45px-2017-fr.wp-orange-source.svg.png" decoding="async" width="45" height="45" class="mw-file-element" srcset="//upload.wikimedia.org/wikipedia/commons/thumb/a/a1/2017-fr.wp-orange-source.svg/68px-2017-fr.wp-orange-source.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/a/a1/2017-fr.wp-orange-source.svg/90px-2017-fr.wp-orange-source.svg.png 2x" data-file-width="512" data-file-height="512" /></a></span></div><div class="bandeau-cell" style="display:table-cell;padding-right:0.5em">
|
||||
<p><strong class="bandeau-titre">Cet article ne s'appuie pas, ou pas assez, sur des sources <a href="/wiki/Wikip%C3%A9dia:Sources_primaires,_secondaires_et_tertiaires" title="Wikipédia:Sources primaires, secondaires et tertiaires">secondaires ou tertiaires</a></strong> <small>(<time class="nowrap" datetime="2014-03" data-sort-value="2014-03">mars 2014</time>).</small>
|
||||
</p><p>Pour améliorer la <a href="/wiki/Wikip%C3%A9dia:V%C3%A9rifiabilit%C3%A9" title="Wikipédia:Vérifiabilité">vérifiabilité</a> de l'article ainsi que <a href="/wiki/Wikip%C3%A9dia:Ce_que_Wikip%C3%A9dia_n%27est_pas#Un_annuaire_ou_une_base_de_données" title="Wikipédia:Ce que Wikipédia n'est pas">son intérêt encyclopédique</a>, il est nécessaire, quand des <a href="/wiki/Wikip%C3%A9dia:Sources_primaires,_secondaires_et_tertiaires" title="Wikipédia:Sources primaires, secondaires et tertiaires">sources primaires</a> sont citées, de les associer à des analyses faites par des sources secondaires.
|
||||
</p>
|
||||
</div></div>
|
||||
<div class="infobox_v3 noarchive">
|
||||
<div class="entete icon informatique" style="color: #000000;"><style data-mw-deduplicate="TemplateStyles:r188801372">.mw-parser-output .entete.informatique{background-image:url("//upload.wikimedia.org/wikipedia/commons/a/ae/Picto-infoboxinfo.png")}</style>
|
||||
<div>Miniflux</div>
|
||||
</div>
|
||||
<p class="mw-empty-elt">
|
||||
|
||||
|
||||
</p>
|
||||
<table><caption style="color:#000000;">Informations</caption>
|
||||
|
||||
|
||||
|
||||
<tbody><tr>
|
||||
<th scope="row"><a href="/wiki/D%C3%A9veloppeur" title="Développeur">Développé par</a></th>
|
||||
<td>
|
||||
Frédéric Guillot</td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
<th scope="row"> <a href="/wiki/Version_d%27un_logiciel" title="Version d'un logiciel">Dernière version</a>
|
||||
</th>
|
||||
<td>
|
||||
<span class="wd_p348">2.1.0 (<time class="nowrap" datetime="2024-02-17" data-sort-value="2024-02-17">17 février 2024</time>)<sup id="cite_ref-wikidata-f2992d0f89b91ee9578634940004e13779ead67d_1-0" class="reference"><a href="#cite_note-wikidata-f2992d0f89b91ee9578634940004e13779ead67d-1"><span class="cite_crochet">[</span>1<span class="cite_crochet">]</span></a></sup><span class="noprint wikidata-linkback"><span class="mw-valign-baseline noviewer" typeof="mw:File"><a href="https://www.wikidata.org/wiki/Q16664605?uselang=fr#P348" title="Voir et modifier les données sur Wikidata"><img alt="Voir et modifier les données sur Wikidata" src="//upload.wikimedia.org/wikipedia/commons/thumb/7/73/Blue_pencil.svg/10px-Blue_pencil.svg.png" decoding="async" width="10" height="10" class="mw-file-element" srcset="//upload.wikimedia.org/wikipedia/commons/thumb/7/73/Blue_pencil.svg/15px-Blue_pencil.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/7/73/Blue_pencil.svg/20px-Blue_pencil.svg.png 2x" data-file-width="600" data-file-height="600" /></a></span></span></span></td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
<th scope="row"><a href="/wiki/D%C3%A9p%C3%B4t_(informatique)" title="Dépôt (informatique)">Dépôt</a></th>
|
||||
<td>
|
||||
<span class="wd_p1324"><a rel="nofollow" class="external text" href="https://github.com/miniflux/miniflux">github.com/miniflux/miniflux</a><span class="noprint wikidata-linkback"><span class="mw-valign-baseline noviewer" typeof="mw:File"><a href="https://www.wikidata.org/wiki/Q16664605?uselang=fr#P1324" title="Voir et modifier les données sur Wikidata"><img alt="Voir et modifier les données sur Wikidata" src="//upload.wikimedia.org/wikipedia/commons/thumb/7/73/Blue_pencil.svg/10px-Blue_pencil.svg.png" decoding="async" width="10" height="10" class="mw-file-element" srcset="//upload.wikimedia.org/wikipedia/commons/thumb/7/73/Blue_pencil.svg/15px-Blue_pencil.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/7/73/Blue_pencil.svg/20px-Blue_pencil.svg.png 2x" data-file-width="600" data-file-height="600" /></a></span></span></span></td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
<th scope="row"> <a href="/wiki/Langage_de_programmation" title="Langage de programmation">Écrit en</a>
|
||||
</th>
|
||||
<td>
|
||||
<span class="wd_p277"><a href="/wiki/Go_(langage)" title="Go (langage)">Go</a><span class="noprint wikidata-linkback"><span class="mw-valign-baseline noviewer" typeof="mw:File"><a href="https://www.wikidata.org/wiki/Q16664605?uselang=fr#P277" title="Voir et modifier les données sur Wikidata"><img alt="Voir et modifier les données sur Wikidata" src="//upload.wikimedia.org/wikipedia/commons/thumb/7/73/Blue_pencil.svg/10px-Blue_pencil.svg.png" decoding="async" width="10" height="10" class="mw-file-element" srcset="//upload.wikimedia.org/wikipedia/commons/thumb/7/73/Blue_pencil.svg/15px-Blue_pencil.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/7/73/Blue_pencil.svg/20px-Blue_pencil.svg.png 2x" data-file-width="600" data-file-height="600" /></a></span></span></span></td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
<th scope="row"><a href="/wiki/Plate-forme_(informatique)" title="Plate-forme (informatique)">Environnement</a></th>
|
||||
<td>
|
||||
<a href="/wiki/Logiciel_multiplate-forme" class="mw-redirect" title="Logiciel multiplate-forme">multiplateforme</a></td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
<th scope="row"><a href="/wiki/Internationalisation_(informatique)" title="Internationalisation (informatique)">Langues</a></th>
|
||||
<td>
|
||||
<a href="/wiki/Multilingue" class="mw-redirect" title="Multilingue">Multilingue</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th scope="row"> Type
|
||||
</th>
|
||||
<td>
|
||||
<a href="/wiki/RSS" title="RSS">agrégateur de RSS</a></td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
<th scope="row"><a href="/wiki/Licence_de_logiciel" title="Licence de logiciel">Licence</a></th>
|
||||
<td>
|
||||
<a href="/wiki/AGPL" class="mw-redirect" title="AGPL">Licence AGPL</a></td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
<tr>
|
||||
<th scope="row"><a href="/wiki/Site_web" title="Site web">Site web</a></th>
|
||||
<td>
|
||||
<a rel="nofollow" class="external text" href="http://miniflux.net/">miniflux.net</a></td>
|
||||
</tr>
|
||||
|
||||
</tbody></table>
|
||||
<p class="mw-empty-elt">
|
||||
|
||||
</p>
|
||||
<p class="navbar bordered noprint" style=""><span class="plainlinks"><a class="external text" href="https://fr.wikipedia.org/w/index.php?title=Miniflux&veaction=edit&section=0">modifier</a> - <a class="external text" href="https://fr.wikipedia.org/w/index.php?title=Miniflux&action=edit&section=0">modifier le code</a> - <a href="https://www.wikidata.org/wiki/Special:ItemByTitle/frwiki/Miniflux" class="extiw" title="d:Special:ItemByTitle/frwiki/Miniflux">voir Wikidata</a> <a href="/wiki/Aide:Infobox_Wikidata" title="Aide:Infobox Wikidata">(aide)</a></span> <span typeof="mw:File"><a href="/wiki/Mod%C3%A8le:Infobox_Logiciel" title="Consultez la documentation du modèle"><img alt="Consultez la documentation du modèle" src="//upload.wikimedia.org/wikipedia/commons/thumb/b/b4/Gtk-dialog-info.svg/12px-Gtk-dialog-info.svg.png" decoding="async" width="12" height="12" class="mw-file-element" srcset="//upload.wikimedia.org/wikipedia/commons/thumb/b/b4/Gtk-dialog-info.svg/18px-Gtk-dialog-info.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/b/b4/Gtk-dialog-info.svg/24px-Gtk-dialog-info.svg.png 2x" data-file-width="60" data-file-height="60" /></a></span></p></div>
|
||||
<p><b>Miniflux</b> est un agrégateur de flux <a href="/wiki/RSS" title="RSS">RSS</a> minimaliste. C'est une <a href="/wiki/Application_web" title="Application web">application web</a> <a href="/wiki/Logiciel_libre" title="Logiciel libre">libre</a> diffusé sous licence <a href="/wiki/AGPL" class="mw-redirect" title="AGPL">AGPL</a>.
|
||||
Ce logiciel est prévu pour être <a href="/wiki/Auto-h%C3%A9bergement_(Internet)" title="Auto-hébergement (Internet)">auto-hébergé</a> sur son propre serveur ou sur un hébergement mutualisé.
|
||||
</p>
|
||||
<h2><span id="Caract.C3.A9ristiques"></span><span class="mw-headline" id="Caractéristiques">Caractéristiques</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Miniflux&veaction=edit&section=1" title="Modifier la section : Caractéristiques" class="mw-editsection-visualeditor"><span>modifier</span></a><span class="mw-editsection-divider"> | </span><a href="/w/index.php?title=Miniflux&action=edit&section=1" title="Modifier le code source de la section : Caractéristiques"><span>modifier le code</span></a><span class="mw-editsection-bracket">]</span></span></h2>
|
||||
<ul><li>Mises à jour : il est possible de mettre à jour ses abonnements depuis une tâche planifiée (<a href="/wiki/Cron" title="Cron">cronjob</a>) ou depuis l'interface web (<a href="/wiki/Ajax_(informatique)" title="Ajax (informatique)">Ajax</a>). De plus, Miniflux vérifie les en-têtes <a href="/wiki/HTTP" class="mw-redirect" title="HTTP">HTTP</a> et met à jour les flux uniquement lorsque c'est nécessaire.</li>
|
||||
<li>Réseaux sociaux : Miniflux ne s'intègre pas avec les réseaux sociaux, il n'y a donc aucune forme de partage, d'envoi par email ou encore de système de favoris.</li>
|
||||
<li>Publicité : les publicités dans les abonnements sont supprimées automatiquement ainsi que tout éventuel "<a href="/wiki/Pixel_espion" title="Pixel espion">pixel espion</a>" (image de 1px sur 1px utilisée par les outils de statistiques). De plus, les liens externes ne transmettent pas le <a href="/wiki/R%C3%A9f%C3%A9rent_(informatique)" title="Référent (informatique)">référent</a> (l'<a href="/wiki/Uniform_Resource_Locator" title="Uniform Resource Locator">adresse web</a> d'où l'utilisateur vient).</li>
|
||||
<li>Données personnelles : Miniflux est compatible avec le format <a href="/wiki/OPML" class="mw-redirect" title="OPML">OPML</a> qui permet d'importer ou d'exporter sa liste d'abonnements.</li>
|
||||
<li>Accessibilité : une fois installé, Miniflux est accessible à la manière d'une <a href="/wiki/Application_web" title="Application web">application web</a> depuis n'importe quel navigateur web et ce même sur les appareils mobiles.</li>
|
||||
<li><a href="/wiki/Interface_de_programmation" title="Interface de programmation">Interface de programmation</a>: Le logiciel intègre une interface de programmation de sorte à voir créer des scripts afin d'automatiser certaines tâches comme la création d'un utilisateur, de récupérer des statistiques ou encore de récupérer des données concernant son flux ou ses abonnements<sup id="cite_ref-2" class="reference"><a href="#cite_note-2"><span class="cite_crochet">[</span>2<span class="cite_crochet">]</span></a></sup>.</li></ul>
|
||||
<h2><span class="mw-headline" id="Liens_externes">Liens externes</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Miniflux&veaction=edit&section=2" title="Modifier la section : Liens externes" class="mw-editsection-visualeditor"><span>modifier</span></a><span class="mw-editsection-divider"> | </span><a href="/w/index.php?title=Miniflux&action=edit&section=2" title="Modifier le code source de la section : Liens externes"><span>modifier le code</span></a><span class="mw-editsection-bracket">]</span></span></h2>
|
||||
<ul><li><abbr class="abbr indicateur-langue" title="Langue : anglais">(en)</abbr> <a rel="nofollow" class="external text" href="http://miniflux.net/">Site officiel en anglais</a></li></ul>
|
||||
<h2><span id="Notes_et_r.C3.A9f.C3.A9rences"></span><span class="mw-headline" id="Notes_et_références">Notes et références</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Miniflux&veaction=edit&section=3" title="Modifier la section : Notes et références" class="mw-editsection-visualeditor"><span>modifier</span></a><span class="mw-editsection-divider"> | </span><a href="/w/index.php?title=Miniflux&action=edit&section=3" title="Modifier le code source de la section : Notes et références"><span>modifier le code</span></a><span class="mw-editsection-bracket">]</span></span></h2>
|
||||
<div class="references-small decimal" style=""><div class="mw-references-wrap"><ol class="references">
|
||||
<li id="cite_note-wikidata-f2992d0f89b91ee9578634940004e13779ead67d-1"><span class="mw-cite-backlink noprint"><a href="#cite_ref-wikidata-f2992d0f89b91ee9578634940004e13779ead67d_1-0">↑</a> </span><span class="reference-text"><span class="ouvrage">« <a rel="nofollow" class="external text" href="https://github.com/miniflux/v2/releases/tag/2.1.0"><cite style="font-style:normal;"><span class="lang-en" lang="en">Release 2.1.0</span></cite></a> », <time class="nowrap" datetime="2024-02-17" data-sort-value="2024-02-17">17 février 2024</time> <small style="line-height:1em;">(consulté le <time class="nowrap" datetime="2024-02-20" data-sort-value="2024-02-20">20 février 2024</time>)</small></span></span>
|
||||
</li>
|
||||
<li id="cite_note-2"><span class="mw-cite-backlink noprint"><a href="#cite_ref-2">↑</a> </span><span class="reference-text"><span class="ouvrage">« <a rel="nofollow" class="external text" href="https://miniflux.app/docs/api.html"><cite style="font-style:normal;">API Reference - Documentation</cite></a> », sur <span class="italique">miniflux.app</span> <small style="line-height:1em;">(consulté le <time class="nowrap" datetime="2020-06-26" data-sort-value="2020-06-26">26 juin 2020</time>)</small></span></span>
|
||||
</li>
|
||||
</ol></div>
|
||||
</div>
|
||||
<div class="navbox-container" style="clear:both;">
|
||||
<table class="navbox collapsible noprint autocollapse" style="">
|
||||
<tbody><tr><th class="navbox-title" colspan="3" style=""><div style="float:left; width:6em; text-align:left"><div class="noprint plainlinks nowrap tnavbar" style="background-color:transparent; padding:0; font-size:xx-small; color:#000000;"><a href="/wiki/Mod%C3%A8le:Palette_Agr%C3%A9gateurs" title="Modèle:Palette Agrégateurs"><abbr class="abbr" title="Voir ce modèle.">v</abbr></a> · <a class="external text" href="https://fr.wikipedia.org/w/index.php?title=Mod%C3%A8le:Palette_Agr%C3%A9gateurs&action=edit"><abbr class="abbr" title="Modifier ce modèle. Merci de prévisualiser avant de sauvegarder.">m</abbr></a></div></div><div style="font-size:110%"><a href="/wiki/Agr%C3%A9gateur" title="Agrégateur">Agrégateurs</a></div></th>
|
||||
</tr> <tr>
|
||||
<th class="navbox-group" style=""><a href="/wiki/Client_lourd" title="Client lourd">Clients de bureau</a></th>
|
||||
<td class="navbox-list" style=""><table class="navbox-subgroup" style="">
|
||||
<tbody><tr>
|
||||
<th class="navbox-group" style="width:10px; white-space:nowrap;"><a href="/wiki/Logiciel_libre" title="Logiciel libre">Libre</a></th>
|
||||
<td class="navbox-list" style=";background:#EDEDFF;"><div class="liste-horizontale">
|
||||
<ul><li><i><a href="/wiki/Akregator" title="Akregator">Akregator</a></i></li>
|
||||
<li><span class="description-wikidata" title="<span class="error">identifiant wikidata inconnu</span>"><a href="/w/index.php?title=%27%27FeedReader%27%27&action=edit&redlink=1" class="new" title="''FeedReader'' (page inexistante)"><i>FeedReader</i></a> <a href="https://www.wikidata.org/wiki/Q50836189" class="extiw" title="d:Q50836189"><span class="indicateur-langue">(<abbr class="abbr" title="Wikidata">d</abbr>)</span></a> <span typeof="mw:File"><a href="//tools.wmflabs.org/reasonator/?q=Q50836189&lang=fr" title="Voir avec Reasonator"><img alt="Voir avec Reasonator" src="//upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Wikidata-Reasonator_small_logo.svg/12px-Wikidata-Reasonator_small_logo.svg.png" decoding="async" width="12" height="12" class="mw-file-element" srcset="//upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Wikidata-Reasonator_small_logo.svg/18px-Wikidata-Reasonator_small_logo.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/b/ba/Wikidata-Reasonator_small_logo.svg/24px-Wikidata-Reasonator_small_logo.svg.png 2x" data-file-width="500" data-file-height="500" /></a></span></span></li>
|
||||
<li><i><a href="/wiki/Liferea" title="Liferea">Liferea</a></i></li>
|
||||
<li><i><a href="/wiki/Mozilla_Thunderbird" title="Mozilla Thunderbird">Mozilla Thunderbird</a></i></li>
|
||||
<li><i>QuiteRSS</i></li>
|
||||
<li><i><a href="/wiki/GNOME_Web" title="GNOME Web">Web</a></i></li>
|
||||
<li><i><a href="/wiki/QBittorrent" title="QBittorrent">QBittorrent</a></i></li>
|
||||
<li><i>RSSOwl</i></li>
|
||||
<li><i><a href="/wiki/Zimbra" title="Zimbra">Zimbra</a></i></li></ul>
|
||||
</div></td>
|
||||
</tr> <tr>
|
||||
<th class="navbox-group" style="width:10px; white-space:nowrap;"><a href="/wiki/Logiciel_propri%C3%A9taire" title="Logiciel propriétaire">Propriétaire</a></th>
|
||||
<td class="navbox-list navbox-even" style=";"><div class="liste-horizontale">
|
||||
<ul><li><i><a href="/wiki/Microsoft_Outlook" title="Microsoft Outlook">Microsoft Outlook</a></i></li></ul>
|
||||
</div></td>
|
||||
</tr>
|
||||
|
||||
</tbody></table></td>
|
||||
<td class="navbox-image" rowspan="2" style="vertical-align:middle;padding-left:7px"><span class="noviewer" typeof="mw:File"><a href="/wiki/Fichier:Feed-icon.svg" class="mw-file-description"><img src="//upload.wikimedia.org/wikipedia/commons/thumb/4/43/Feed-icon.svg/70px-Feed-icon.svg.png" decoding="async" width="70" height="70" class="mw-file-element" srcset="//upload.wikimedia.org/wikipedia/commons/thumb/4/43/Feed-icon.svg/105px-Feed-icon.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/4/43/Feed-icon.svg/140px-Feed-icon.svg.png 2x" data-file-width="128" data-file-height="128" /></a></span></td>
|
||||
</tr> <tr>
|
||||
<th class="navbox-group" style="">Basés sur le <a href="/wiki/Web" class="mw-redirect" title="Web">web</a></th>
|
||||
<td class="navbox-list navbox-even" style=""><table class="navbox-subgroup" style="">
|
||||
<tbody><tr>
|
||||
<th class="navbox-group" style="width:10px; white-space:nowrap;"><a href="/wiki/Logiciel_libre" title="Logiciel libre">Libre</a></th>
|
||||
<td class="navbox-list" style=";background:#EDEDFF;"><div class="liste-horizontale">
|
||||
<ul><li><i><a href="/w/index.php?title=Feedbin&action=edit&redlink=1" class="new" title="Feedbin (page inexistante)">Feedbin</a> <a href="https://en.wikipedia.org/wiki/Feedbin" class="extiw" title="en:Feedbin"><span class="indicateur-langue" title="Article en anglais : « Feedbin »">(en)</span></a></i></li>
|
||||
<li><i><a href="/wiki/FreshRSS" title="FreshRSS">FreshRSS</a></i></li>
|
||||
<li><i><a href="/wiki/KrISS-feed" title="KrISS-feed">KrISS-feed</a></i></li>
|
||||
<li><i>Leed</i></li>
|
||||
<li><i>Selfoss</i></li>
|
||||
<li><i><a href="/wiki/NewsBlur" title="NewsBlur">NewsBlur</a></i></li>
|
||||
<li><i>Cartulary</i></li>
|
||||
<li><i><a class="mw-selflink selflink">Miniflux</a></i></li>
|
||||
<li><i><a href="/wiki/Tiny_Tiny_RSS" title="Tiny Tiny RSS">Tiny Tiny RSS</a></i></li></ul>
|
||||
</div></td>
|
||||
</tr> <tr>
|
||||
<th class="navbox-group" style="width:10px; white-space:nowrap;"><a href="/wiki/Logiciel_propri%C3%A9taire" title="Logiciel propriétaire">Propriétaire</a></th>
|
||||
<td class="navbox-list navbox-even" style=";"><div class="liste-horizontale">
|
||||
<ul><li><i><a href="/wiki/Feedly" title="Feedly">Feedly</a></i></li>
|
||||
<li><i><a href="/wiki/Inoreader" title="Inoreader">Inoreader</a></i></li>
|
||||
<li><i><a href="/wiki/Netvibes" title="Netvibes">Netvibes</a></i></li></ul>
|
||||
</div></td>
|
||||
</tr>
|
||||
|
||||
</tbody></table></td>
|
||||
</tr> </tbody></table>
|
||||
</div><p>,
|
||||
</p><ul id="bandeau-portail" class="bandeau-portail"><li><span class="bandeau-portail-element"><span class="bandeau-portail-icone"><span class="noviewer" typeof="mw:File"><a href="/wiki/Portail:Logiciels_libres" title="Portail des logiciels libres"><img alt="icône décorative" src="//upload.wikimedia.org/wikipedia/commons/thumb/2/22/Heckert_GNU_white.svg/25px-Heckert_GNU_white.svg.png" decoding="async" width="25" height="24" class="mw-file-element" srcset="//upload.wikimedia.org/wikipedia/commons/thumb/2/22/Heckert_GNU_white.svg/37px-Heckert_GNU_white.svg.png 1.5x, //upload.wikimedia.org/wikipedia/commons/thumb/2/22/Heckert_GNU_white.svg/49px-Heckert_GNU_white.svg.png 2x" data-file-width="535" data-file-height="523" /></a></span></span> <span class="bandeau-portail-texte"><a href="/wiki/Portail:Logiciels_libres" title="Portail:Logiciels libres">Portail des logiciels libres</a></span> </span></li> </ul>
|
||||
<!--
|
||||
NewPP limit report
|
||||
Parsed by mw‐web.eqiad.canary‐7c9994f4f8‐6g6bc
|
||||
Cached time: 20240304111906
|
||||
Cache expiry: 2592000
|
||||
Reduced expiry: false
|
||||
Complications: []
|
||||
CPU time usage: 0.369 seconds
|
||||
Real time usage: 0.494 seconds
|
||||
Preprocessor visited node count: 3432/1000000
|
||||
Post‐expand include size: 58705/2097152 bytes
|
||||
Template argument size: 16628/2097152 bytes
|
||||
Highest expansion depth: 21/100
|
||||
Expensive parser function count: 6/500
|
||||
Unstrip recursion depth: 0/20
|
||||
Unstrip post‐expand size: 2070/5000000 bytes
|
||||
Lua time usage: 0.226/10.000 seconds
|
||||
Lua memory usage: 10407160/52428800 bytes
|
||||
Number of Wikibase entities loaded: 1/400
|
||||
-->
|
||||
<!--
|
||||
Transclusion expansion time report (%,ms,calls,template)
|
||||
100.00% 449.099 1 -total
|
||||
52.56% 236.049 45 Modèle:Wikidata
|
||||
52.35% 235.089 1 Modèle:Infobox_Logiciel
|
||||
34.76% 156.108 23 Modèle:Infobox_V3/Tableau_Ligne_mixte
|
||||
21.42% 96.191 1 Modèle:Ébauche
|
||||
11.91% 53.472 1 Modèle:Palette
|
||||
10.43% 46.825 1 Modèle:Palette_Agrégateurs
|
||||
9.96% 44.723 1 Modèle:Méta_palette_de_navigation
|
||||
8.15% 36.590 13 Modèle:Infobox_V3/Tableau_Ligne_mixte_Wikidata
|
||||
8.02% 36.009 2 Modèle:Méta_palette_de_navigation_sous-groupe
|
||||
-->
|
||||
|
||||
<!-- Saved in parser cache with key frwiki:pcache:idhash:7063156-0!canonical and timestamp 20240304111906 and revision id 204322562. Rendering was triggered because: page-view
|
||||
-->
|
||||
</div><!--esi <esi:include src="/esitest-fa8a495983347898/content" /> --><noscript><img src="https://login.wikimedia.org/wiki/Special:CentralAutoLogin/start?type=1x1" alt="" width="1" height="1" style="border: none; position: absolute;"></noscript>
|
||||
<div class="printfooter" data-nosnippet="">Ce document provient de « <a dir="ltr" href="https://fr.wikipedia.org/w/index.php?title=Miniflux&oldid=204322562">https://fr.wikipedia.org/w/index.php?title=Miniflux&oldid=204322562</a> ».</div></div>
|
||||
<div id="catlinks" class="catlinks" data-mw="interface"><div id="mw-normal-catlinks" class="mw-normal-catlinks"><a href="/wiki/Cat%C3%A9gorie:Accueil" title="Catégorie:Accueil">Catégories</a> : <ul><li><a href="/wiki/Cat%C3%A9gorie:Logiciel_%C3%A9crit_en_Go" title="Catégorie:Logiciel écrit en Go">Logiciel écrit en Go</a></li><li><a href="/wiki/Cat%C3%A9gorie:Agr%C3%A9gateur" title="Catégorie:Agrégateur">Agrégateur</a></li><li><a href="/wiki/Cat%C3%A9gorie:Application_web" title="Catégorie:Application web">Application web</a></li></ul></div><div id="mw-hidden-catlinks" class="mw-hidden-catlinks mw-hidden-cats-hidden">Catégories cachées : <ul><li><a href="/wiki/Cat%C3%A9gorie:Wikip%C3%A9dia:%C3%A9bauche_Internet" title="Catégorie:Wikipédia:ébauche Internet">Wikipédia:ébauche Internet</a></li><li><a href="/wiki/Cat%C3%A9gorie:Article_manquant_de_r%C3%A9f%C3%A9rences_depuis_mars_2014" title="Catégorie:Article manquant de références depuis mars 2014">Article manquant de références depuis mars 2014</a></li><li><a href="/wiki/Cat%C3%A9gorie:Article_manquant_de_r%C3%A9f%C3%A9rences/Liste_compl%C3%A8te" title="Catégorie:Article manquant de références/Liste complète">Article manquant de références/Liste complète</a></li><li><a href="/wiki/Cat%C3%A9gorie:Page_utilisant_P348" title="Catégorie:Page utilisant P348">Page utilisant P348</a></li><li><a href="/wiki/Cat%C3%A9gorie:Page_utilisant_P1324" title="Catégorie:Page utilisant P1324">Page utilisant P1324</a></li><li><a href="/wiki/Cat%C3%A9gorie:Page_utilisant_P277" title="Catégorie:Page utilisant P277">Page utilisant P277</a></li><li><a href="/wiki/Cat%C3%A9gorie:Logiciel_cat%C3%A9goris%C3%A9_automatiquement_par_langage_d%27%C3%A9criture" title="Catégorie:Logiciel catégorisé automatiquement par langage d'écriture">Logiciel catégorisé automatiquement par langage d'écriture</a></li><li><a href="/wiki/Cat%C3%A9gorie:Article_utilisant_une_Infobox" title="Catégorie:Article utilisant une Infobox">Article utilisant une Infobox</a></li><li><a href="/wiki/Cat%C3%A9gorie:Article_contenant_un_appel_%C3%A0_traduction_en_anglais" title="Catégorie:Article contenant un appel à traduction en anglais">Article contenant un appel à traduction en anglais</a></li><li><a href="/wiki/Cat%C3%A9gorie:Portail:Logiciels_libres/Articles_li%C3%A9s" title="Catégorie:Portail:Logiciels libres/Articles liés">Portail:Logiciels libres/Articles liés</a></li><li><a href="/wiki/Cat%C3%A9gorie:Portail:Logiciel/Articles_li%C3%A9s" title="Catégorie:Portail:Logiciel/Articles liés">Portail:Logiciel/Articles liés</a></li><li><a href="/wiki/Cat%C3%A9gorie:Portail:Informatique/Articles_li%C3%A9s" title="Catégorie:Portail:Informatique/Articles liés">Portail:Informatique/Articles liés</a></li></ul></div></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
<div class="mw-footer-container">
|
||||
|
||||
<footer id="footer" class="mw-footer" role="contentinfo" >
|
||||
<ul id="footer-info">
|
||||
<li id="footer-info-lastmod"> La dernière modification de cette page a été faite le 17 mai 2023 à 04:04.</li>
|
||||
<li id="footer-info-copyright"><span style="white-space: normal"><a class="internal" href="/wiki/Wikip%C3%A9dia:Citation_et_r%C3%A9utilisation_du_contenu_de_Wikip%C3%A9dia" title="Droit d'auteur">Droit d'auteur</a> : les textes sont disponibles sous <a rel="license" href="https://creativecommons.org/licenses/by-sa/4.0/deed.fr" title="Licence Creative Commons Attribution - partage dans les mêmes conditions 4.0 international">licence Creative Commons attribution, partage dans les mêmes conditions</a> ; d’autres conditions peuvent s’appliquer. Voyez les <a href="https://foundation.wikimedia.org/wiki/Policy:Terms_of_Use/fr" title="Conditions d’utilisation de la Wikimedia Foundation">conditions d’utilisation</a> pour plus de détails, ainsi que les <a class="internal" href="/wiki/Wikip%C3%A9dia:Cr%C3%A9dits_graphiques" title="Droit d'auteur de certaines icônes">crédits graphiques</a>. En cas de réutilisation des textes de cette page, voyez <a class="internal" href="/wiki/Sp%C3%A9cial:Citer/Miniflux" title="Citer ou réutiliser cette page">comment citer les auteurs et mentionner la licence</a>.<br />
|
||||
Wikipedia® est une marque déposée de la <a href="https://wikimediafoundation.org/" title="Wikimedia Foundation">Wikimedia Foundation, Inc.</a>, organisation de bienfaisance régie par le paragraphe <a class="internal" href="/wiki/501c" title="501c">501(c)(3)</a> du code fiscal des États-Unis.</span><br /></li>
|
||||
</ul>
|
||||
|
||||
<ul id="footer-places">
|
||||
<li id="footer-places-privacy"><a href="https://foundation.wikimedia.org/wiki/Special:MyLanguage/Policy:Privacy_policy/fr">Politique de confidentialité</a></li>
|
||||
<li id="footer-places-about"><a href="/wiki/Wikip%C3%A9dia:%C3%80_propos_de_Wikip%C3%A9dia">À propos de Wikipédia</a></li>
|
||||
<li id="footer-places-disclaimers"><a href="/wiki/Wikip%C3%A9dia:Avertissements_g%C3%A9n%C3%A9raux">Avertissements</a></li>
|
||||
<li id="footer-places-contact"><a href="//fr.wikipedia.org/wiki/Wikipédia:Contact">Contact</a></li>
|
||||
<li id="footer-places-wm-codeofconduct"><a href="https://foundation.wikimedia.org/wiki/Special:MyLanguage/Policy:Universal_Code_of_Conduct">Code de conduite</a></li>
|
||||
<li id="footer-places-developers"><a href="https://developer.wikimedia.org">Développeurs</a></li>
|
||||
<li id="footer-places-statslink"><a href="https://stats.wikimedia.org/#/fr.wikipedia.org">Statistiques</a></li>
|
||||
<li id="footer-places-cookiestatement"><a href="https://foundation.wikimedia.org/wiki/Special:MyLanguage/Policy:Cookie_statement">Déclaration sur les témoins (cookies)</a></li>
|
||||
<li id="footer-places-mobileview"><a href="//fr.m.wikipedia.org/w/index.php?title=Miniflux&mobileaction=toggle_view_mobile" class="noprint stopMobileRedirectToggle">Version mobile</a></li>
|
||||
</ul>
|
||||
|
||||
<ul id="footer-icons" class="noprint">
|
||||
<li id="footer-copyrightico"><a href="https://wikimediafoundation.org/"><img src="/static/images/footer/wikimedia-button.png" srcset="/static/images/footer/wikimedia-button-1.5x.png 1.5x, /static/images/footer/wikimedia-button-2x.png 2x" width="88" height="31" alt="Wikimedia Foundation" loading="lazy" /></a></li>
|
||||
<li id="footer-poweredbyico"><a href="https://www.mediawiki.org/"><img src="/static/images/footer/poweredby_mediawiki_88x31.png" alt="Powered by MediaWiki" srcset="/static/images/footer/poweredby_mediawiki_132x47.png 1.5x, /static/images/footer/poweredby_mediawiki_176x62.png 2x" width="88" height="31" loading="lazy"></a></li>
|
||||
</ul>
|
||||
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vector-settings" id="p-dock-bottom">
|
||||
<ul>
|
||||
<li>
|
||||
<button class="cdx-button cdx-button--icon-only vector-limited-width-toggle" id=""><span class="vector-icon mw-ui-icon-fullScreen mw-ui-icon-wikimedia-fullScreen"></span>
|
||||
|
||||
<span>Activer ou désactiver la limitation de largeur du contenu</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<script>(RLQ=window.RLQ||[]).push(function(){mw.config.set({"wgHostname":"mw1401","wgBackendResponseTime":136,"wgPageParseReport":{"limitreport":{"cputime":"0.369","walltime":"0.494","ppvisitednodes":{"value":3432,"limit":1000000},"postexpandincludesize":{"value":58705,"limit":2097152},"templateargumentsize":{"value":16628,"limit":2097152},"expansiondepth":{"value":21,"limit":100},"expensivefunctioncount":{"value":6,"limit":500},"unstrip-depth":{"value":0,"limit":20},"unstrip-size":{"value":2070,"limit":5000000},"entityaccesscount":{"value":1,"limit":400},"timingprofile":["100.00% 449.099 1 -total"," 52.56% 236.049 45 Modèle:Wikidata"," 52.35% 235.089 1 Modèle:Infobox_Logiciel"," 34.76% 156.108 23 Modèle:Infobox_V3/Tableau_Ligne_mixte"," 21.42% 96.191 1 Modèle:Ébauche"," 11.91% 53.472 1 Modèle:Palette"," 10.43% 46.825 1 Modèle:Palette_Agrégateurs"," 9.96% 44.723 1 Modèle:Méta_palette_de_navigation"," 8.15% 36.590 13 Modèle:Infobox_V3/Tableau_Ligne_mixte_Wikidata"," 8.02% 36.009 2 Modèle:Méta_palette_de_navigation_sous-groupe"]},"scribunto":{"limitreport-timeusage":{"value":"0.226","limit":"10.000"},"limitreport-memusage":{"value":10407160,"limit":52428800}},"cachereport":{"origin":"mw-web.eqiad.canary-7c9994f4f8-6g6bc","timestamp":"20240304111906","ttl":2592000,"transientcontent":false}}});});</script>
|
||||
<script type="application/ld+json">{"@context":"https:\/\/schema.org","@type":"Article","name":"Miniflux","url":"https:\/\/fr.wikipedia.org\/wiki\/Miniflux","sameAs":"http:\/\/www.wikidata.org\/entity\/Q16664605","mainEntity":"http:\/\/www.wikidata.org\/entity\/Q16664605","author":{"@type":"Organization","name":"Contributeurs aux projets Wikimedia"},"publisher":{"@type":"Organization","name":"Fondation Wikimedia, Inc.","logo":{"@type":"ImageObject","url":"https:\/\/www.wikimedia.org\/static\/images\/wmf-hor-googpub.png"}},"datePublished":"2013-04-14T21:28:24Z","dateModified":"2023-05-17T03:04:00Z","headline":"lecteur RSS pour serveur Web en Golang"}</script>
|
||||
</body>
|
||||
</html>
|
|
@ -78,10 +78,9 @@ func findContentUsingCustomRules(page io.Reader, rules string) (string, error) {
|
|||
|
||||
contents := ""
|
||||
document.Find(rules).Each(func(i int, s *goquery.Selection) {
|
||||
var content string
|
||||
|
||||
content, _ = goquery.OuterHtml(s)
|
||||
contents += content
|
||||
if content, err := goquery.OuterHtml(s); err == nil {
|
||||
contents += content
|
||||
}
|
||||
})
|
||||
|
||||
return contents, nil
|
||||
|
@ -89,13 +88,11 @@ func findContentUsingCustomRules(page io.Reader, rules string) (string, error) {
|
|||
|
||||
func getPredefinedScraperRules(websiteURL string) string {
|
||||
urlDomain := urllib.Domain(websiteURL)
|
||||
urlDomain = strings.TrimPrefix(urlDomain, "www.")
|
||||
|
||||
for domain, rules := range predefinedRules {
|
||||
if strings.Contains(urlDomain, domain) {
|
||||
return rules
|
||||
}
|
||||
if rules, ok := predefinedRules[urlDomain]; ok {
|
||||
return rules
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
|
|
|
@ -19,6 +19,10 @@ func TestGetPredefinedRules(t *testing.T) {
|
|||
t.Error("Unable to find rule for linux.com")
|
||||
}
|
||||
|
||||
if getPredefinedScraperRules("https://linux.com/") == "" {
|
||||
t.Error("Unable to find rule for linux.com")
|
||||
}
|
||||
|
||||
if getPredefinedScraperRules("https://example.org/") != "" {
|
||||
t.Error("A rule not defined should not return anything")
|
||||
}
|
||||
|
|
|
@ -23,8 +23,8 @@ import (
|
|||
)
|
||||
|
||||
var (
|
||||
youtubeChannelRegex = regexp.MustCompile(`youtube\.com/channel/(.*)`)
|
||||
youtubeVideoRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)`)
|
||||
youtubeChannelRegex = regexp.MustCompile(`youtube\.com/channel/(.*)$`)
|
||||
youtubeVideoRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)$`)
|
||||
)
|
||||
|
||||
type SubscriptionFinder struct {
|
||||
|
@ -196,11 +196,14 @@ func (f *SubscriptionFinder) FindSubscriptionsFromWebPage(websiteURL, contentTyp
|
|||
|
||||
func (f *SubscriptionFinder) FindSubscriptionsFromWellKnownURLs(websiteURL string) (Subscriptions, *locale.LocalizedErrorWrapper) {
|
||||
knownURLs := map[string]string{
|
||||
"atom.xml": parser.FormatAtom,
|
||||
"feed.xml": parser.FormatAtom,
|
||||
"feed/": parser.FormatAtom,
|
||||
"rss.xml": parser.FormatRSS,
|
||||
"rss/": parser.FormatRSS,
|
||||
"atom.xml": parser.FormatAtom,
|
||||
"feed.xml": parser.FormatAtom,
|
||||
"feed/": parser.FormatAtom,
|
||||
"rss.xml": parser.FormatRSS,
|
||||
"rss/": parser.FormatRSS,
|
||||
"index.rss": parser.FormatRSS,
|
||||
"index.xml": parser.FormatRSS,
|
||||
"feed.atom": parser.FormatAtom,
|
||||
}
|
||||
|
||||
websiteURLRoot := urllib.RootURL(websiteURL)
|
||||
|
@ -229,17 +232,19 @@ func (f *SubscriptionFinder) FindSubscriptionsFromWellKnownURLs(websiteURL strin
|
|||
f.requestBuilder.WithoutRedirects()
|
||||
|
||||
responseHandler := fetcher.NewResponseHandler(f.requestBuilder.ExecuteRequest(fullURL))
|
||||
defer responseHandler.Close()
|
||||
localizedError := responseHandler.LocalizedError()
|
||||
responseHandler.Close()
|
||||
|
||||
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
|
||||
if localizedError != nil {
|
||||
slog.Debug("Unable to subscribe", slog.String("fullURL", fullURL), slog.Any("error", localizedError.Error()))
|
||||
continue
|
||||
}
|
||||
|
||||
subscription := new(Subscription)
|
||||
subscription.Type = kind
|
||||
subscription.Title = fullURL
|
||||
subscription.URL = fullURL
|
||||
subscriptions = append(subscriptions, subscription)
|
||||
subscriptions = append(subscriptions, &Subscription{
|
||||
Type: kind,
|
||||
Title: fullURL,
|
||||
URL: fullURL,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -267,7 +272,7 @@ func (f *SubscriptionFinder) FindSubscriptionsFromRSSBridge(websiteURL, rssBridg
|
|||
return nil, nil
|
||||
}
|
||||
|
||||
var subscriptions Subscriptions
|
||||
subscriptions := make(Subscriptions, 0, len(bridges))
|
||||
for _, bridge := range bridges {
|
||||
subscriptions = append(subscriptions, &Subscription{
|
||||
Title: bridge.BridgeMeta.Name,
|
||||
|
|
|
@ -17,7 +17,7 @@ func NewSubscription(title, url, kind string) *Subscription {
|
|||
}
|
||||
|
||||
func (s Subscription) String() string {
|
||||
return fmt.Sprintf(`Title="%s", URL="%s", Type="%s"`, s.Title, s.URL, s.Type)
|
||||
return fmt.Sprintf(`Title=%q, URL=%q, Type=%q`, s.Title, s.URL, s.Type)
|
||||
}
|
||||
|
||||
// Subscriptions represents a list of subscription.
|
||||
|
|
|
@ -14,13 +14,14 @@ import (
|
|||
)
|
||||
|
||||
// NewXMLDecoder returns a XML decoder that filters illegal characters.
|
||||
func NewXMLDecoder(data io.Reader) *xml.Decoder {
|
||||
func NewXMLDecoder(data io.ReadSeeker) *xml.Decoder {
|
||||
var decoder *xml.Decoder
|
||||
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
|
||||
decoder = xml.NewDecoder(bytes.NewReader(buffer))
|
||||
data.Seek(0, io.SeekStart)
|
||||
decoder = xml.NewDecoder(data)
|
||||
} else {
|
||||
// filter invalid chars now, since decoder.CharsetReader not called for utf-8 content
|
||||
filteredBytes := bytes.Map(filterValidXMLChar, buffer)
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue